Compare commits

..
388 changed files with 586284 additions and 570264 deletions
-2
View File
@@ -71,8 +71,6 @@ Checking a ticket in
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``. :>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_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 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**: **Example request**:
+1 -2
View File
@@ -351,8 +351,7 @@ Endpoints
:<json boolean error_reason: One of ``canceled``, ``invalid``, ``unpaid``, ``product``, ``rules``, ``revoked``, :<json boolean error_reason: One of ``canceled``, ``invalid``, ``unpaid``, ``product``, ``rules``, ``revoked``,
``incomplete``, ``already_redeemed``, ``blocked``, ``invalid_time``, or ``error``. Required. ``incomplete``, ``already_redeemed``, ``blocked``, ``invalid_time``, or ``error``. Required.
:<json raw_barcode: The raw barcode or identifier you scanned. Required. :<json raw_barcode: The raw barcode 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 datetime: Date and time of the scan. Optional.
:<json type: Type of scan, defaults to ``"entry"``. :<json type: Type of scan, defaults to ``"entry"``.
:<json position: Internal ID of an order position you matched. Optional. :<json position: Internal ID of an order position you matched. Optional.
+2 -8
View File
@@ -864,9 +864,6 @@ Generating new secrets
Triggers generation of new ``secret`` and ``web_secret`` attributes for both the order and all order positions. 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**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -898,9 +895,6 @@ Generating new secrets
Triggers generation of a new ``secret`` and ``web_secret`` attribute for a single order position. 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**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -2038,7 +2032,7 @@ Manipulating individual positions
* ``order`` (mandatory, specified as a string mapping to a ``code``) * ``order`` (mandatory, specified as a string mapping to a ``code``)
* ``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) * ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position)
* ``item`` (mandatory) * ``item`` (mandatory)
@@ -2348,7 +2342,7 @@ otherwise, such as splitting an order or changing fees.
"subevent": 562, "subevent": 562,
"seat": "seat-guid-2", "seat": "seat-guid-2",
"price": "99.99", "price": "99.99",
"addon_to": 1, "addon_to": 12374,
"attendee_name": "Peter", "attendee_name": "Peter",
} }
], ],
+1 -1
View File
@@ -53,7 +53,7 @@ Working with the code
--------------------- ---------------------
If you do not have a recent installation of ``nodejs``, install it now:: If you do not have a recent installation of ``nodejs``, install it now::
curl -sL https://deb.nodesource.com/setup_24.x | sudo -E bash - curl -sL https://deb.nodesource.com/setup_17.x | sudo -E bash -
sudo apt install nodejs 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:: To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
+10 -10
View File
@@ -2584,9 +2584,9 @@
} }
}, },
"node_modules/immutable": { "node_modules/immutable": {
"version": "5.1.9", "version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3176,9 +3176,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -3352,9 +3352,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.23", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -3371,7 +3371,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.16", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
+18 -19
View File
@@ -33,29 +33,28 @@ dependencies = [
"bleach==6.4.*", "bleach==6.4.*",
"celery==5.6.*", "celery==5.6.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=50.0.0", "cryptography>=49.0.0",
"css-inline==0.21.*", "css-inline==0.21.*",
"defusedcsv>=3.0.0", "defusedcsv>=3.0.0",
"dnspython==2.*", "dnspython==2.*",
"Django[argon2]==5.2.*", "Django[argon2]==5.2.*",
"django-bootstrap3==26.2", "django-bootstrap3==26.1",
"django-compressor==4.6.0", "django-compressor==4.6.0",
"django-countries==9.0.*", "django-countries==8.2.*",
"django-filter==26.1", "django-filter==25.1",
"django-formset-js-improved==0.5.0.5", "django-formset-js-improved==0.5.0.5",
"django-formtools==2.7", "django-formtools==2.6.1",
"django-hierarkey==2.0.*,>=2.0.2", "django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*", "django-hijack==3.7.*",
"django-i18nfield==1.11.*", "django-i18nfield==1.11.*",
"django-libsass==0.9", "django-libsass==0.9",
"django-localflavor==5.1", "django-localflavor==5.0",
"django-markup", "django-markup",
"django-oauth-toolkit==2.3.*", "django-oauth-toolkit==2.3.*",
"django-otp==1.7.*", "django-otp==1.7.*",
"django-phonenumber-field==8.5.*", "django-phonenumber-field==8.4.*",
"django-querytagger==0.0.3", "django-redis==6.0.*",
"django-redis==7.0.*", "django-scopes==2.0.*",
"django-scopes==2.1.*",
"django-statici18n==2.7.*", "django-statici18n==2.7.*",
"djangorestframework==3.17.*", "djangorestframework==3.17.*",
"dnspython==2.8.*", "dnspython==2.8.*",
@@ -67,7 +66,7 @@ dependencies = [
"kombu==5.6.*", "kombu==5.6.*",
"libsass==0.23.*", "libsass==0.23.*",
"lxml", "lxml",
"markdown==3.10.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3. "markdown==3.10.2", # 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 # We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*", "mt-940==4.30.*",
"oauthlib==3.3.*", "oauthlib==3.3.*",
@@ -77,7 +76,7 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.13.*", "PyJWT==2.13.*",
"phonenumberslite==9.0.*", "phonenumberslite==9.0.*",
"Pillow==12.3.*", "Pillow==12.2.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==7.35.*", "protobuf==7.35.*",
"psycopg2-binary", "psycopg2-binary",
@@ -92,17 +91,17 @@ dependencies = [
"pyuca", "pyuca",
"qrcode==8.2", "qrcode==8.2",
"redis==7.4.*", "redis==7.4.*",
"reportlab==5.0.*", "reportlab==4.5.*",
"requests==2.34.*", "requests==2.32.*",
"sentry-sdk==2.66.*", "sentry-sdk==2.63.*",
"sepaxml==2.7.*", "sepaxml==2.7.*",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
"tlds>=2026072401", "tlds>=2026041800",
"tqdm==4.*", "tqdm==4.*",
"ua-parser==1.0.*", "ua-parser==1.0.*",
"vobject==0.9.*", "vobject==0.9.*",
"webauthn==3.0.*", "webauthn==2.8.*",
"zeep==4.3.*" "zeep==4.3.*"
] ]
@@ -112,7 +111,7 @@ dev = [
"aiohttp==3.14.*", "aiohttp==3.14.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.37.*", "fakeredis==2.36.*",
"flake8==7.3.*", "flake8==7.3.*",
"freezegun", "freezegun",
"isort==8.0.*", "isort==8.0.*",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2026.8.0.dev0" __version__ = "2026.7.0.dev0"
-2
View File
@@ -104,7 +104,6 @@ ALL_LANGUAGES = [
('gl', _('Galician')), ('gl', _('Galician')),
('el', _('Greek')), ('el', _('Greek')),
('he', _('Hebrew')), ('he', _('Hebrew')),
('hu', _('Hungarian')),
('id', _('Indonesian')), ('id', _('Indonesian')),
('it', _('Italian')), ('it', _('Italian')),
('ja', _('Japanese')), ('ja', _('Japanese')),
@@ -119,7 +118,6 @@ ALL_LANGUAGES = [
('sv', _('Swedish')), ('sv', _('Swedish')),
('es', _('Spanish')), ('es', _('Spanish')),
('es-419', _('Spanish (Latin America)')), ('es-419', _('Spanish (Latin America)')),
('th', _('Thai')),
('tr', _('Turkish')), ('tr', _('Turkish')),
('uk', _('Ukrainian')), ('uk', _('Ukrainian')),
] ]
+1 -25
View File
@@ -20,11 +20,8 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging import logging
from datetime import timedelta
from django.contrib.auth.models import AnonymousUser 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 django_scopes import scopes_disabled
from rest_framework import exceptions from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
@@ -33,7 +30,6 @@ from pretix.api.auth.devicesecurity import (
FullAccessSecurityProfile, get_all_security_profiles, FullAccessSecurityProfile, get_all_security_profiles,
) )
from pretix.base.models import Device from pretix.base.models import Device
from pretix.base.models.devices import DeviceLastSeen
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,7 +42,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
model = self.get_model() model = self.get_model()
try: try:
with scopes_disabled(): with scopes_disabled():
device = model.objects.select_related('organizer', 'last_seen').get(api_token=key) device = model.objects.select_related('organizer').get(api_token=key)
except model.DoesNotExist: except model.DoesNotExist:
raise exceptions.AuthenticationFailed('Invalid token.') raise exceptions.AuthenticationFailed('Invalid token.')
@@ -57,7 +53,6 @@ class DeviceTokenAuthentication(TokenAuthentication):
logging.warning(f'Connection attempt of revoked device {device.pk}.') logging.warning(f'Connection attempt of revoked device {device.pk}.')
raise exceptions.AuthenticationFailed('Device access has been revoked.') raise exceptions.AuthenticationFailed('Device access has been revoked.')
self._update_last_seen(device)
return AnonymousUser(), device return AnonymousUser(), device
def authenticate(self, request): def authenticate(self, request):
@@ -68,22 +63,3 @@ class DeviceTokenAuthentication(TokenAuthentication):
if not profile.is_allowed(request): if not profile.is_allowed(request):
raise exceptions.PermissionDenied('Request denied by device security profile.') raise exceptions.PermissionDenied('Request denied by device security profile.')
return r 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")
+4 -16
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging import logging
from collections import OrderedDict
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -51,18 +52,10 @@ class BaseSecurityProfile:
""" """
raise NotImplementedError() raise NotImplementedError()
@property
def priority(self) -> int:
"""
Priority for ordering, higher will come first.
"""
return 100
class FullAccessSecurityProfile(BaseSecurityProfile): class FullAccessSecurityProfile(BaseSecurityProfile):
identifier = 'full' identifier = 'full'
verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)') verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)')
priority = 1000
def is_allowed(self, request): def is_allowed(self, request):
return True return True
@@ -115,7 +108,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'), ('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-lookup'), ('POST', 'api-v1:reusablemedium-lookup'),
@@ -154,7 +146,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:checkinrpc.search'),
) )
@@ -191,7 +182,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:checkinrpc.search'),
) )
@@ -202,15 +192,13 @@ def get_all_security_profiles():
if _ALL_PROFILES: if _ALL_PROFILES:
return _ALL_PROFILES return _ALL_PROFILES
types = [] types = OrderedDict()
for recv, ret in register_device_security_profile.send(None): for recv, ret in register_device_security_profile.send(None):
if isinstance(ret, (list, tuple)): if isinstance(ret, (list, tuple)):
for r in ret: for r in ret:
types.append(r) types[r.identifier] = r
else: else:
types.append(ret) types[ret.identifier] = ret
types.sort(key=lambda el: el.priority, reverse=True)
types = {r.identifier: r for r in types}
_ALL_PROFILES = types _ALL_PROFILES = types
return types return types
-28
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import json import json
import re
from django.db.models import prefetch_related_objects from django.db.models import prefetch_related_objects
from rest_framework import serializers from rest_framework import serializers
@@ -136,30 +135,3 @@ class SalesChannelMigrationMixin:
else: else:
value["sales_channels"] = value["limit_sales_channels"] value["sales_channels"] = value["limit_sales_channels"]
return value 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
-1
View File
@@ -90,7 +90,6 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
answers = serializers.JSONField(required=False, allow_null=True) answers = serializers.JSONField(required=False, allow_null=True)
exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES) exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES)
exchange_medium_identifier = serializers.CharField(required=False) exchange_medium_identifier = serializers.CharField(required=False)
simulate = serializers.BooleanField(default=False, required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
+1 -3
View File
@@ -48,7 +48,7 @@ from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
from pretix.api.serializers import ( from pretix.api.serializers import (
CompatDecimalField, CompatibleJSONField, SalesChannelMigrationMixin, CompatibleJSONField, SalesChannelMigrationMixin,
) )
from pretix.api.serializers.fields import PluginsField from pretix.api.serializers.fields import PluginsField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -681,7 +681,6 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
required=False, required=False,
allow_null=True, allow_null=True,
) )
rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta: class Meta:
model = TaxRule model = TaxRule
@@ -748,7 +747,6 @@ class EventSettingsSerializer(SettingsSerializer):
'max_items_per_order', 'max_items_per_order',
'reservation_time', 'reservation_time',
'contact_mail', 'contact_mail',
'contact_url',
'show_variations_expanded', 'show_variations_expanded',
'hide_sold_out', 'hide_sold_out',
'meta_noindex', 'meta_noindex',
+5 -7
View File
@@ -42,9 +42,7 @@ from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from pretix.api.serializers import ( from pretix.api.serializers import SalesChannelMigrationMixin
CompatDecimalField, SalesChannelMigrationMixin,
)
from pretix.api.serializers.event import MetaDataField from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -278,10 +276,10 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
return value return value
class ItemTaxRateField(CompatDecimalField): class ItemTaxRateField(serializers.Field):
def to_representation(self, i): def to_representation(self, i):
if i.tax_rule: if i.tax_rule:
return super().to_representation(Decimal(i.tax_rule.rate)) return str(Decimal(i.tax_rule.rate))
else: else:
return str(Decimal('0.00')) return str(Decimal('0.00'))
@@ -291,7 +289,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
bundles = InlineItemBundleSerializer(many=True, required=False) bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False) variations = InlineItemVariationSerializer(many=True, required=False)
program_times = InlineItemProgramTimeSerializer(many=True, required=False) program_times = InlineItemProgramTimeSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True, max_digits=7, decimal_places=4) tax_rate = ItemTaxRateField(source='*', read_only=True)
meta_data = MetaDataField(required=False, source='*') meta_data = MetaDataField(required=False, source='*')
picture = UploadedFileField(required=False, allow_null=True, allowed_types=( picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
'image/png', 'image/jpeg', 'image/gif' 'image/png', 'image/jpeg', 'image/gif'
@@ -619,7 +617,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
options_data = validated_data.pop('options') if 'options' in validated_data else [] options_data = validated_data.pop('options') if 'options' in validated_data else []
items = validated_data.pop('items', []) items = validated_data.pop('items', [])
question = Question.objects.create(**validated_data, container_type=Question.ContainerType.ORDERPOSITION) question = Question.objects.create(**validated_data)
question.items.set(items) question.items.set(items)
for opt_data in options_data: for opt_data in options_data:
QuestionOption.objects.create(question=question, **opt_data) QuestionOption.objects.create(question=question, **opt_data)
+2 -9
View File
@@ -41,7 +41,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from pretix.api.serializers import CompatDecimalField, CompatibleJSONField from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.forms import form_field_to_serializer_field from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -52,7 +52,6 @@ from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress, CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question, InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
@@ -382,7 +381,6 @@ class PrintLogSerializer(serializers.ModelSerializer):
class FailedCheckinSerializer(I18nAwareModelSerializer): class FailedCheckinSerializer(I18nAwareModelSerializer):
error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False) error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False)
raw_barcode = serializers.CharField(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) 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_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True) raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
@@ -392,7 +390,7 @@ class FailedCheckinSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Checkin model = Checkin
fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation', fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation',
'raw_subevent', 'raw_source_type', 'nonce', 'datetime', 'type', 'position') 'raw_subevent', 'nonce', 'datetime', 'type', 'position')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -593,7 +591,6 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
country = CompatibleCountryField(source='*') country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False) attendee_name = serializers.CharField(required=False)
plugin_data = OrderPositionPluginDataField(source='*', allow_null=True, read_only=True) plugin_data = OrderPositionPluginDataField(source='*', allow_null=True, read_only=True)
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta: class Meta:
list_serializer_class = OrderPositionListSerializer list_serializer_class = OrderPositionListSerializer
@@ -750,8 +747,6 @@ class OrderPaymentDateField(serializers.DateField):
class OrderFeeSerializer(I18nAwareModelSerializer): class OrderFeeSerializer(I18nAwareModelSerializer):
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta: class Meta:
model = OrderFee model = OrderFee
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule',
@@ -1916,7 +1911,6 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
position = LinePositionField(read_only=True) position = LinePositionField(read_only=True)
event_date_from = serializers.DateTimeField(read_only=True, source="period_start") event_date_from = serializers.DateTimeField(read_only=True, source="period_start")
event_date_to = serializers.DateTimeField(read_only=True, source="period_end") event_date_to = serializers.DateTimeField(read_only=True, source="period_end")
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta: class Meta:
model = InvoiceLine model = InvoiceLine
@@ -2000,7 +1994,6 @@ class BlockedTicketSecretSerializer(I18nAwareModelSerializer):
class TransactionSerializer(I18nAwareModelSerializer): class TransactionSerializer(I18nAwareModelSerializer):
order = serializers.SlugRelatedField(slug_field="code", read_only=True) order = serializers.SlugRelatedField(slug_field="code", read_only=True)
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta: class Meta:
model = Transaction model = Transaction
+2 -5
View File
@@ -27,7 +27,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@@ -492,9 +492,7 @@ class TeamInviteSerializer(serializers.ModelSerializer):
def _send_invite(self, instance): def _send_invite(self, instance):
mail( mail(
instance.email, instance.email,
gettext('You\'ve been invited to join %(organizer)s') % { _('Account invitation'),
'organizer': self.context['organizer'].name,
},
'pretixcontrol/email/invitation.txt', 'pretixcontrol/email/invitation.txt',
{ {
'instance': settings.PRETIX_INSTANCE_NAME, 'instance': settings.PRETIX_INSTANCE_NAME,
@@ -576,7 +574,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'customer_accounts_require_login_for_order_access', 'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed', 'invoice_regenerate_allowed',
'contact_mail', 'contact_mail',
'contact_url',
'imprint_url', 'imprint_url',
'organizer_info_text', 'organizer_info_text',
'event_list_type', 'event_list_type',
-8
View File
@@ -139,7 +139,6 @@ class CheckinListViewSet(viewsets.ModelViewSet):
) )
return qs return qs
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -154,7 +153,6 @@ class CheckinListViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -839,11 +837,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
) )
if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate 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(): with transaction.atomic():
# Do exchange and check-in atomically, i.e. both succeed or both fail # Do exchange and check-in atomically, i.e. both succeed or both fail
medium = perform_media_exchange( medium = perform_media_exchange(
@@ -1071,7 +1064,6 @@ class CheckinRPCRedeemView(views.APIView):
legacy_url_support=False, legacy_url_support=False,
exchange_medium_type=s.validated_data.get('exchange_medium_type'), exchange_medium_type=s.validated_data.get('exchange_medium_type'),
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'), exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
simulate=s.validated_data.get('simulate'),
) )
-4
View File
@@ -32,7 +32,6 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # 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. # 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_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
@@ -65,7 +64,6 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
'limit_sales_channels', 'limit_sales_channels',
) )
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -80,7 +78,6 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -90,7 +87,6 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('You cannot delete this discount because it already has ' raise PermissionDenied('You cannot delete this discount because it already has '
+1 -13
View File
@@ -45,7 +45,6 @@ from rest_framework.exceptions import (
NotFound, PermissionDenied, ValidationError, NotFound, PermissionDenied, ValidationError,
) )
from rest_framework.generics import get_object_or_404 from rest_framework.generics import get_object_or_404
from rest_framework.mixins import UpdateModelMixin
from rest_framework.response import Response from rest_framework.response import Response
from pretix.api.auth.permission import EventCRUDPermission from pretix.api.auth.permission import EventCRUDPermission
@@ -257,7 +256,6 @@ class EventViewSet(viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
copy_from = None copy_from = None
if 'clone_from' in self.request.GET: if 'clone_from' in self.request.GET:
@@ -321,7 +319,6 @@ class EventViewSet(viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('The event can not be deleted as it already contains orders. Please set \'live\'' raise PermissionDenied('The event can not be deleted as it already contains orders. Please set \'live\''
@@ -357,7 +354,6 @@ class CloneEventViewSet(viewsets.ModelViewSet):
ctx['organizer'] = self.request.organizer ctx['organizer'] = self.request.organizer
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
# Weird edge case: Requires settings permission on the event (to read) but also on the organizer (two write) # 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)) perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken))
@@ -516,7 +512,6 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
resp['X-Page-Generated'] = date resp['X-Page-Generated'] = date
return resp return resp
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data original_data = self.get_serializer(instance=serializer.instance).data
super().perform_update(serializer) super().perform_update(serializer)
@@ -533,7 +528,6 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -543,7 +537,6 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('The sub-event can not be deleted as it has already been used in orders. Please set' raise PermissionDenied('The sub-event can not be deleted as it has already been used in orders. Please set'
@@ -572,7 +565,6 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
return self.request.event.tax_rules.all() return self.request.event.tax_rules.all()
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
super().perform_update(serializer) super().perform_update(serializer)
serializer.instance.log_action( serializer.instance.log_action(
@@ -582,7 +574,6 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -592,7 +583,6 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('This tax rule can not be deleted as it is currently in use.') raise PermissionDenied('This tax rule can not be deleted as it is currently in use.')
@@ -721,7 +711,7 @@ class SeatFilter(FilterSet):
fields = ('zone_name', 'row_name', 'row_label', 'seat_number', 'seat_label', 'seat_guid', 'blocked',) fields = ('zone_name', 'row_name', 'row_label', 'seat_number', 'seat_label', 'seat_guid', 'blocked',)
class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelViewSet): class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = SeatSerializer serializer_class = SeatSerializer
queryset = Seat.objects.none() queryset = Seat.objects.none()
write_permission = 'event.settings.general:write' write_permission = 'event.settings.general:write'
@@ -766,7 +756,6 @@ class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelV
} }
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
super().perform_update(serializer) super().perform_update(serializer)
serializer.instance.event.log_action( serializer.instance.event.log_action(
@@ -776,7 +765,6 @@ class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelV
data={"seats": [serializer.instance.pk]}, data={"seats": [serializer.instance.pk]},
) )
@transaction.atomic()
def bulk_change_blocked(self, blocked): def bulk_change_blocked(self, blocked):
s = SeatBulkBlockInputSerializer( s = SeatBulkBlockInputSerializer(
data=self.request.data, data=self.request.data,
+5 -19
View File
@@ -23,7 +23,6 @@ from datetime import timedelta
from celery.result import AsyncResult from celery.result import AsyncResult
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -46,8 +45,7 @@ from pretix.base.models import (
) )
from pretix.base.models.organizer import TeamQuerySet from pretix.base.models.organizer import TeamQuerySet
from pretix.base.services.export import ( from pretix.base.services.export import (
ExportError, export, init_event_exporters, init_organizer_exporters, export, init_event_exporters, init_organizer_exporters, multiexport,
multiexport,
) )
from pretix.helpers.http import ChunkBasedFileResponse from pretix.helpers.http import ChunkBasedFileResponse
@@ -151,11 +149,8 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
)) ))
exporters = [] exporters = []
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
try: ex._serializer = JobRunSerializer(exporter=ex)
ex._serializer = JobRunSerializer(exporter=ex) exporters.append(ex)
exporters.append(ex)
except ExportError:
pass
return exporters return exporters
def do_export(self, cf, instance, data): def do_export(self, cf, instance, data):
@@ -185,11 +180,8 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
)) ))
exporters = [] exporters = []
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
try: ex._serializer = JobRunSerializer(exporter=ex)
ex._serializer = JobRunSerializer(exporter=ex) exporters.append(ex)
exporters.append(ex)
except ExportError:
pass
return exporters return exporters
def do_export(self, cf, instance, data): def do_export(self, cf, instance, data):
@@ -228,7 +220,6 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
qs = self.request.event.scheduled_exports qs = self.request.event.scheduled_exports
return qs.select_related("owner") return qs.select_related("owner")
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
raise PermissionDenied('Creation of exports requires user-specific API access.') raise PermissionDenied('Creation of exports requires user-specific API access.')
@@ -259,7 +250,6 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
)) ))
return {e.identifier: e for e in exporters} return {e.identifier: e for e in exporters}
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner: 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 # This is to prevent a possible privilege escalation where user A creates a scheduled export and
@@ -285,7 +275,6 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
self.request.event.log_action( self.request.event.log_action(
'pretix.event.export.schedule.deleted', 'pretix.event.export.schedule.deleted',
@@ -313,7 +302,6 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
qs = self.request.organizer.scheduled_exports qs = self.request.organizer.scheduled_exports
return qs.select_related("owner") return qs.select_related("owner")
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
raise PermissionDenied('Creation of exports requires user-specific API access.') raise PermissionDenied('Creation of exports requires user-specific API access.')
@@ -344,7 +332,6 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
)) ))
return {e.identifier: e for e in exporters} return {e.identifier: e for e in exporters}
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner: 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 # This is to prevent a possible privilege escalation where user A creates a scheduled export and
@@ -395,7 +382,6 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
self.request.organizer.log_action( self.request.organizer.log_action(
'pretix.organizer.export.schedule.deleted', 'pretix.organizer.export.schedule.deleted',
+1 -32
View File
@@ -33,7 +33,6 @@
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
import django_filters import django_filters
from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -110,7 +109,6 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times' 'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
).all() ).all()
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -125,7 +123,6 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data original_data = self.get_serializer(instance=serializer.instance).data
@@ -142,7 +139,6 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('This item cannot be deleted because it has already been ordered ' raise PermissionDenied('This item cannot be deleted because it has already been ordered '
@@ -187,7 +183,6 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
item = self.item item = self.item
if not item.has_variations: if not item.has_variations:
@@ -202,7 +197,6 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
{'value': serializer.instance.value}) {'value': serializer.instance.value})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.item.log_action( serializer.instance.item.log_action(
@@ -213,7 +207,6 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
{'value': serializer.instance.value}) {'value': serializer.instance.value})
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('This variation cannot be deleted because it has already been ordered ' raise PermissionDenied('This variation cannot be deleted because it has already been ordered '
@@ -256,7 +249,6 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
ctx['item'] = self.item ctx['item'] = self.item
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
serializer.save(base_item=item) serializer.save(base_item=item)
@@ -267,7 +259,6 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.base_item.log_action( serializer.instance.base_item.log_action(
@@ -277,7 +268,6 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
super().perform_destroy(instance) super().perform_destroy(instance)
instance.base_item.log_action( instance.base_item.log_action(
@@ -313,7 +303,6 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
ctx['item'] = self.item ctx['item'] = self.item
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
serializer.save(item=item) serializer.save(item=item)
@@ -324,7 +313,6 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.item.log_action( serializer.instance.item.log_action(
@@ -334,7 +322,6 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
super().perform_destroy(instance) super().perform_destroy(instance)
instance.item.log_action( instance.item.log_action(
@@ -367,7 +354,6 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
ctx['item'] = self.item ctx['item'] = self.item
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
item = self.item item = self.item
category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category']) category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category'])
@@ -379,7 +365,6 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.base_item.log_action( serializer.instance.base_item.log_action(
@@ -389,7 +374,6 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
super().perform_destroy(instance) super().perform_destroy(instance)
instance.base_item.log_action( instance.base_item.log_action(
@@ -419,7 +403,6 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
return self.request.event.categories.all() return self.request.event.categories.all()
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -434,7 +417,6 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -444,7 +426,6 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
for item in instance.items.all(): for item in instance.items.all():
item.category = None item.category = None
@@ -475,12 +456,8 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
write_permission = 'event.items:write' write_permission = 'event.items:write'
def get_queryset(self): def get_queryset(self):
return self.request.event.questions.filter( return self.request.event.questions.prefetch_related('options').all()
# the container_type parameter is undocumented, this API is going to change in a later release
container_type=self.request.GET.get('container_type', Question.ContainerType.ORDERPOSITION),
).prefetch_related('options').all()
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -495,7 +472,6 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -505,7 +481,6 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.log_action( instance.log_action(
'pretix.event.question.deleted', 'pretix.event.question.deleted',
@@ -534,7 +509,6 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
serializer.save(question=q) serializer.save(question=q)
@@ -545,7 +519,6 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.question.log_action( serializer.instance.question.log_action(
@@ -555,7 +528,6 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.question.log_action( instance.question.log_action(
'pretix.event.question.option.deleted', 'pretix.event.question.option.deleted',
@@ -614,7 +586,6 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer = self.get_serializer(page, many=True) serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data) return self.get_paginated_response(serializer.data)
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -637,7 +608,6 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['request'] = self.request ctx['request'] = self.request
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data original_data = self.get_serializer(instance=serializer.instance).data
@@ -693,7 +663,6 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
) )
serializer.instance.rebuild_cache() serializer.instance.rebuild_cache()
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.log_action( instance.log_action(
'pretix.event.quota.deleted', 'pretix.event.quota.deleted',
+1 -3
View File
@@ -1658,7 +1658,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
count_waitinglist=False, count_waitinglist=False,
force=request.data.get('force', False), force=request.data.get('force', False),
send_mail=send_mail, send_mail=send_mail,
ignore_date=request.data.get('force', False),
) )
except Quota.QuotaExceededException: except Quota.QuotaExceededException:
pass pass
@@ -1694,8 +1693,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
auth=self.request.auth, auth=self.request.auth,
count_waitinglist=False, count_waitinglist=False,
send_mail=send_mail, send_mail=send_mail,
force=force, force=force)
ignore_date=force)
except Quota.QuotaExceededException as e: except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e: except PaymentException as e:
-3
View File
@@ -394,7 +394,6 @@ class TeamViewSet(viewsets.ModelViewSet):
) )
return inst return inst
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.log_action('pretix.team.deleted', user=self.request.user, auth=self.request.auth) instance.log_action('pretix.team.deleted', user=self.request.user, auth=self.request.auth)
instance.delete() instance.delete()
@@ -694,7 +693,6 @@ class MembershipTypeViewSet(viewsets.ModelViewSet):
ctx['organizer'] = self.request.organizer ctx['organizer'] = self.request.organizer
return ctx return ctx
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied("Can only be deleted if unused.") raise PermissionDenied("Can only be deleted if unused.")
@@ -835,7 +833,6 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
) )
return inst return inst
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied("Can only be deleted if unused.") raise PermissionDenied("Can only be deleted if unused.")
-4
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import django_filters import django_filters
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
@@ -63,7 +62,6 @@ class WaitingListViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -72,7 +70,6 @@ class WaitingListViewSet(viewsets.ModelViewSet):
auth=self.request.auth, auth=self.request.auth,
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
if serializer.instance.voucher: if serializer.instance.voucher:
raise PermissionDenied('This entry can not be changed as it has already been assigned a voucher.') raise PermissionDenied('This entry can not be changed as it has already been assigned a voucher.')
@@ -83,7 +80,6 @@ class WaitingListViewSet(viewsets.ModelViewSet):
auth=self.request.auth, auth=self.request.auth,
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if instance.voucher: if instance.voucher:
raise PermissionDenied('This entry can not be deleted as it has already been assigned a voucher.') raise PermissionDenied('This entry can not be deleted as it has already been assigned a voucher.')
-4
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import django_filters import django_filters
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets from rest_framework import viewsets
@@ -49,7 +48,6 @@ class WebHookViewSet(viewsets.ModelViewSet):
ctx['organizer'] = self.request.organizer ctx['organizer'] = self.request.organizer
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer) inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action( self.request.organizer.log_action(
@@ -59,7 +57,6 @@ class WebHookViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': inst.pk}) data=merge_dicts(self.request.data, {'id': inst.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
inst = serializer.save(organizer=self.request.organizer) inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action( self.request.organizer.log_action(
@@ -70,7 +67,6 @@ class WebHookViewSet(viewsets.ModelViewSet):
) )
return inst return inst
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
self.request.organizer.log_action( self.request.organizer.log_action(
'pretix.webhook.changed', 'pretix.webhook.changed',
+9 -12
View File
@@ -23,7 +23,6 @@ import sys
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext from django.utils.translation import gettext
@@ -36,23 +35,21 @@ def get_powered_by(request, safelink=True):
d = gs.settings.license_check_input d = gs.settings.license_check_input
if d.get('poweredby_name'): if d.get('poweredby_name'):
if d.get('poweredby_url'): if d.get('poweredby_url'):
msg = format_html( msg = gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>').format(
gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>'),
name=d['poweredby_name'], name=d['poweredby_name'],
a_name_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format( a_name_attr='href="{}" target="_blank" rel="noopener"'.format(
escape(sl(d['poweredby_url'])) if safelink else escape(d['poweredby_url']), sl(d['poweredby_url']) if safelink else d['poweredby_url'],
)), ),
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format( a_attr='href="{}" target="_blank" rel="noopener"'.format(
sl('https://pretix.eu') if safelink else 'https://pretix.eu', sl('https://pretix.eu') if safelink else 'https://pretix.eu',
)) )
) )
else: else:
msg = format_html( msg = gettext('<a {a_attr}>powered by {name} based on pretix</a>').format(
gettext('<a {a_attr}>powered by {name} based on pretix</a>'),
name=d['poweredby_name'], name=d['poweredby_name'],
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format( a_attr='href="{}" target="_blank" rel="noopener"'.format(
sl('https://pretix.eu') if safelink else 'https://pretix.eu', sl('https://pretix.eu') if safelink else 'https://pretix.eu',
)) )
) )
else: else:
msg = gettext('<a %(a_attr)s>ticketing powered by pretix</a>') % { msg = gettext('<a %(a_attr)s>ticketing powered by pretix</a>') % {
+13 -4
View File
@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import ipaddress
import logging import logging
import smtplib import smtplib
import socket import socket
@@ -42,7 +43,6 @@ from pretix.base.templatetags.rich_text import (
markdown_compile_email, truelink_callback, markdown_compile_email, truelink_callback,
) )
from pretix.helpers.format import FormattedString, SafeFormatter, format_map from pretix.helpers.format import FormattedString, SafeFormatter, format_map
from pretix.helpers.ssrf import should_block_access
from pretix.base.services.placeholders import ( # noqa from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext get_available_placeholders, PlaceholderContext
@@ -57,6 +57,8 @@ logger = logging.getLogger('pretix.base.email')
T = TypeVar("T", bound=EmailBackend) 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: def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
try: try:
@@ -252,9 +254,16 @@ def create_connection(address, timeout=socket.getdefaulttimeout(),
af, socktype, proto, canonname, sa = res af, socktype, proto, canonname, sa = res
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False): if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
is_private, msg = should_block_access(sa) ip_addr = ipaddress.ip_address(sa[0])
if is_private: check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
raise socket.error(msg) 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")
sock = None sock = None
try: try:
+3 -57
View File
@@ -40,12 +40,11 @@ from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from ..exporter import MultiSheetListExporter, OrganizerLevelExportMixin from ..exporter import ListExporter, OrganizerLevelExportMixin
from ..models import Membership
from ..signals import register_multievent_data_exporters from ..signals import register_multievent_data_exporters
class CustomerListExporter(OrganizerLevelExportMixin, MultiSheetListExporter): class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'customerlist' identifier = 'customerlist'
verbose_name = gettext_lazy('Customer accounts') verbose_name = gettext_lazy('Customer accounts')
category = pgettext_lazy('export_category', 'Customer accounts') category = pgettext_lazy('export_category', 'Customer accounts')
@@ -55,20 +54,13 @@ class CustomerListExporter(OrganizerLevelExportMixin, MultiSheetListExporter):
def get_required_organizer_permission(cls) -> str: def get_required_organizer_permission(cls) -> str:
return 'organizer.customers:write' return 'organizer.customers:write'
@property
def sheets(self):
return (
('customers', _('Customers')),
('memberships', _('Memberships')),
)
@property @property
def additional_form_fields(self): def additional_form_fields(self):
return OrderedDict( return OrderedDict(
[] []
) )
def iterate_customers(self, form_data): def iterate_list(self, form_data):
qs = self.organizer.customers.prefetch_related('provider') qs = self.organizer.customers.prefetch_related('provider')
headers = [ headers = [
@@ -117,52 +109,6 @@ class CustomerListExporter(OrganizerLevelExportMixin, MultiSheetListExporter):
] ]
yield row 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): def get_filename(self):
return '{}_customers'.format(self.organizer.slug) return '{}_customers'.format(self.organizer.slug)
+32 -9
View File
@@ -33,6 +33,8 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # 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. # License for the specific language governing permissions and limitations under the License.
import hashlib
import ipaddress
import logging import logging
from django import forms from django import forms
@@ -40,12 +42,13 @@ from django.conf import settings
from django.contrib.auth.password_validation import ( from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password, password_validators_help_texts, validate_password,
) )
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.metrics import pretix_failed_logins from pretix.base.metrics import pretix_failed_logins
from pretix.base.models import User from pretix.base.models import User
from pretix.helpers.dicts import move_to_end from pretix.helpers.dicts import move_to_end
from pretix.helpers.ratelimit import rate_limit from pretix.helpers.http import get_client_ip
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -82,20 +85,40 @@ class LoginForm(forms.Form):
else: else:
move_to_end(self.fields, 'keep_logged_in') 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): def clean(self):
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required): if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
rate_limit_kwargs = dict(include_ip_from_request=self.request, max_num=10, expire_time=300) if self.ratelimit_key:
if rate_limit("login", **rate_limit_kwargs, increase=False): from django_redis import get_redis_connection
# Check rate limit without counting up, we increase below only on failed logins rc = get_redis_connection("redis")
pretix_failed_logins.inc(1, reason="ratelimit") cnt = rc.get(self.ratelimit_key)
logger.info("Backend login rejected due to rate limit.") if cnt and int(cnt) > 10:
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit') 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) self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
if self.user_cache is None: 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.") logger.info("Backend login invalid.")
pretix_failed_logins.inc(1, reason="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( raise forms.ValidationError(
self.error_messages['invalid_login'], self.error_messages['invalid_login'],
code='invalid_login' code='invalid_login'
File diff suppressed because it is too large Load Diff
+11 -22
View File
@@ -33,6 +33,7 @@
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
from django import forms from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password from django.contrib.auth.hashers import check_password
from django.contrib.auth.password_validation import ( from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password, password_validators_help_texts, validate_password,
@@ -45,7 +46,6 @@ from pytz import common_timezones
from pretix.base.models import User from pretix.base.models import User
from pretix.control.forms import SingleLanguageWidget from pretix.control.forms import SingleLanguageWidget
from pretix.helpers.format import format_map from pretix.helpers.format import format_map
from pretix.helpers.ratelimit import rate_limit
class UserSettingsForm(forms.ModelForm): class UserSettingsForm(forms.ModelForm):
@@ -128,11 +128,16 @@ class UserPasswordChangeForm(forms.Form):
def clean_old_pw(self): def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw') old_pw = self.cleaned_data.get('old_pw')
if rate_limit("pwchange", self.user.pk, max_num=10, expire_time=300): if settings.HAS_REDIS:
raise forms.ValidationError( from django_redis import get_redis_connection
self.error_messages['rate_limit'], rc = get_redis_connection("redis")
code='rate_limit', 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 not check_password(old_pw, self.user.password): if not check_password(old_pw, self.user.password):
raise forms.ValidationError( raise forms.ValidationError(
@@ -170,35 +175,19 @@ class UserEmailChangeForm(forms.Form):
error_messages = { error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. " 'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."), "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) old_email = forms.EmailField(label=_('Old email address'), disabled=True)
new_email = forms.EmailField(label=_('New email address')) new_email = forms.EmailField(label=_('New email address'))
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user') self.user = kwargs.pop('user')
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def clean_new_email(self): def clean_new_email(self):
email = self.cleaned_data['new_email'] 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(): if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
raise forms.ValidationError( raise forms.ValidationError(
self.error_messages['duplicate_identifier'], self.error_messages['duplicate_identifier'],
code='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 return email
+23 -74
View File
@@ -43,11 +43,6 @@ from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.helpers.format import PlainHtmlAlternativeString from pretix.helpers.format import PlainHtmlAlternativeString
from pretix.helpers.i18n import (
get_format_without_seconds, get_javascript_format,
get_javascript_format_without_seconds,
)
from pretix.helpers.safedownload import get_token
def replace_arabic_numbers(inp): def replace_arabic_numbers(inp):
@@ -113,7 +108,7 @@ class DatePickerWidget(forms.DateInput):
class TimePickerWidget(forms.TimeInput): class TimePickerWidget(forms.TimeInput):
def __init__(self, attrs=None, time_format=None, without_seconds=False): def __init__(self, attrs=None, time_format=None):
attrs = attrs or {} attrs = attrs or {}
if 'placeholder' in attrs: if 'placeholder' in attrs:
del attrs['placeholder'] del attrs['placeholder']
@@ -122,27 +117,8 @@ class TimePickerWidget(forms.TimeInput):
time_attrs['class'] += ' timepickerfield' time_attrs['class'] += ' timepickerfield'
time_attrs['autocomplete'] = 'off' 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(): def placeholder():
if without_seconds: tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
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( return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0 year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf) ).strftime(tf)
@@ -158,26 +134,36 @@ class TimePickerWidget(forms.TimeInput):
class UploadedFileWidget(forms.ClearableFileInput): class UploadedFileWidget(forms.ClearableFileInput):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.position = kwargs.pop('position')
self.event = kwargs.pop('event')
self.answer = kwargs.pop('answer') self.answer = kwargs.pop('answer')
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class FakeFile: class FakeFile:
def __init__(self, file, answer, request): def __init__(self, file, position, event, answer):
self.file = file self.file = file
self.position = position
self.event = event
self.answer = answer self.answer = answer
self.request = request
def __str__(self): def __str__(self):
return os.path.basename(self.file.name).split('.', 1)[-1] return os.path.basename(self.file.name).split('.', 1)[-1]
@property @property
def url(self): def url(self):
token = get_token(self.request, self.answer) from pretix.base.models import OrderPosition
if self.request.resolver_match.namespace == 'control': from pretix.multidomain.urlreverse import eventreverse
return self.answer.backend_file_url + '?token=' + token
if isinstance(self.position, OrderPosition):
return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={
'order': self.position.order.code,
'secret': self.position.order.secret,
'answer': self.answer.pk,
})
else: else:
return self.answer.frontend_file_url + '?token=' + token return eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={
'answer': self.answer.pk,
})
def get_context(self, name, value, attrs): def get_context(self, name, value, attrs):
# Browsers can't recognize that the server already has a file uploaded # Browsers can't recognize that the server already has a file uploaded
@@ -190,13 +176,13 @@ class UploadedFileWidget(forms.ClearableFileInput):
def format_value(self, value): def format_value(self, value):
if self.is_initial(value): if self.is_initial(value):
return self.FakeFile(value, self.answer, self.request) return self.FakeFile(value, self.position, self.event, self.answer)
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget): class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
template_name = 'pretixbase/forms/widgets/splitdatetime.html' template_name = 'pretixbase/forms/widgets/splitdatetime.html'
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None, without_seconds=False): def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None):
attrs = attrs or {} attrs = attrs or {}
if 'placeholder' in attrs: if 'placeholder' in attrs:
del attrs['placeholder'] del attrs['placeholder']
@@ -219,36 +205,14 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date() max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date()
).isoformat() ).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(): def date_placeholder():
if without_seconds: df = date_format or get_format('DATE_INPUT_FORMATS')[0]
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( return now().replace(
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0 year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
).strftime(df) ).strftime(df)
def time_placeholder(): def time_placeholder():
if without_seconds: tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
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( return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0 year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf) ).strftime(tf)
@@ -305,18 +269,3 @@ class BusinessBooleanRadio(forms.RadioSelect):
'False': False, 'False': False,
False: False, False: False,
}.get(value) }.get(value)
class OptionAttrsSelect(forms.Select):
def __init__(self, *args, option_attrs=None, **kwargs):
super().__init__(*args, **kwargs)
self.option_attrs = option_attrs or {}
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
option = super().create_option(
name, value, label, selected, index, subindex=subindex, attrs=attrs
)
extra = self.option_attrs.get(str(value))
if extra:
option["attrs"].update(extra)
return option
@@ -1,29 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Do nothing. Useful for startup performance testing."
def handle(self, *args, **options):
pass
@@ -40,7 +40,6 @@ from django.core.cache import cache
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import close_old_connections from django.db import close_old_connections
from django.dispatch.dispatcher import NO_RECEIVERS from django.dispatch.dispatcher import NO_RECEIVERS
from django_querytagger.tagging import with_tag
from pretix.helpers.periodic import SKIPPED from pretix.helpers.periodic import SKIPPED
@@ -83,8 +82,7 @@ class Command(BaseCommand):
try: try:
# Check if the DB connection is still good, it might be closed if the previous task took too long. # Check if the DB connection is still good, it might be closed if the previous task took too long.
close_old_connections() close_old_connections()
with with_tag(f"periodictask={name}"): r = receiver(signal=periodic_task, sender=self)
r = receiver(signal=periodic_task, sender=self)
except Exception as err: except Exception as err:
if isinstance(err, KeyboardInterrupt): if isinstance(err, KeyboardInterrupt):
raise err raise err
+58 -193
View File
@@ -19,10 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import base64
import hashlib
import logging
import re
from collections import OrderedDict from collections import OrderedDict
from urllib.parse import urlparse, urlsplit from urllib.parse import urlparse, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -47,8 +43,6 @@ from pretix.multidomain.urlreverse import (
) )
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
_supported = None _supported = None
@@ -71,44 +65,11 @@ def get_supported_language(requested_language, allowed_languages, default_langua
return language return language
class BaseLocaleMiddleware(MiddlewareMixin):
"""
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): class LocaleMiddleware(MiddlewareMixin):
""" """
This is the full LocaleMiddleware that uses all available information to figure out the correct This middleware sets the correct locale and timezone
language for the request using all available sources, in this order of priority: for a request.
- 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): def process_request(self, request: HttpRequest):
@@ -223,24 +184,6 @@ def get_default_language():
return settings.LANGUAGE_CODE 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: def get_language_from_request(request: HttpRequest) -> str:
""" """
Analyzes the request to find what language the user wants the system to Analyzes the request to find what language the user wants the system to
@@ -255,6 +198,7 @@ def get_language_from_request(request: HttpRequest) -> str:
if request.path.startswith(get_script_prefix() + 'control'): if request.path.startswith(get_script_prefix() + 'control'):
return ( return (
get_language_from_user_settings(request) get_language_from_user_settings(request)
or get_language_from_customer_settings(request)
or get_language_from_cookie(request) or get_language_from_cookie(request)
or get_language_from_browser(request) or get_language_from_browser(request)
or get_language_from_event(request) or get_language_from_event(request)
@@ -279,26 +223,7 @@ def _parse_csp(header):
return h return h
VALID_CSP_DIRECTIVES = [
"child-src", "connect-src", "default-src", "fenced-frame-src", "font-src", "form-action", "frame-src", "img-src",
"manifest-src", "media-src", "object-src", "prefetch-src", "report-uri", "script-src", "script-src-elem",
"script-src-attr", "style-src", "style-src-elem", "style-src-attr", "worker-src",
]
CSP_ILLEGAL_CHARS = re.compile(r'[\s,;]')
def _sanitize_csp(h):
for k, v in h.items():
if k not in VALID_CSP_DIRECTIVES:
raise ValueError("Invalid CSP directive " + k)
if any(CSP_ILLEGAL_CHARS.search(el) for el in v):
logger.warning("Stripping invalid component from CSP: %r", h)
h[k] = [el for el in v if not CSP_ILLEGAL_CHARS.search(el)]
def _render_csp(h): def _render_csp(h):
_sanitize_csp(h)
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v) return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v)
@@ -314,51 +239,25 @@ def _merge_csp(a, b):
for k, v in a.items(): for k, v in a.items():
if "'unsafe-inline'" in v: if "'unsafe-inline'" in v:
# If we need unsafe-inline, drop any hashes or nonce as they will be ignored otherwise # 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("'sha256-")] a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha-")]
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): class SecurityMiddleware(MiddlewareMixin):
SAFE_TYPES = ( CSP_EXEMPT = (
# CSP policies are only used for: '/api/v1/docs/',
# - 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): def process_response(self, request, resp):
def nested_dict_values(d):
for v in d.values():
if isinstance(v, dict):
yield from nested_dict_values(v)
else:
if isinstance(v, str):
yield v
url = resolve(request.path_info)
if settings.DEBUG and resp.status_code >= 400: if settings.DEBUG and resp.status_code >= 400:
# Don't use CSP on debug error page as it breaks of Django's fancy error # Don't use CSP on debug error page as it breaks of Django's fancy error
# pages # pages
@@ -369,24 +268,18 @@ class SecurityMiddleware(MiddlewareMixin):
# https://github.com/pretix/pretix/issues/765 # https://github.com/pretix/pretix/issues/765
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"' resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
if self._needs_csp(request, resp): img_src = []
resp['Content-Security-Policy'] = _render_csp(self._build_csp(request, resp)) gs = global_settings_object(request)
elif 'Content-Security-Policy' in resp: if gs.settings.leaflet_tiles:
del resp['Content-Security-Policy'] img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
return resp font_src = set()
if hasattr(request, 'event'):
def _needs_csp(self, request, resp): for font in get_fonts(request.event, pdf_support_required=False).values():
if "Content-Type" in resp and resp["Content-Type"].split(";")[0] in self.SAFE_TYPES: for path in list(nested_dict_values(font)):
return False font_location = urlparse(path)
if font_location.scheme and font_location.netloc:
if getattr(resp, '_csp_ignore', False): font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
return False
return True
def _build_csp(self, request, resp):
url = resolve(request.path_info)
h = { h = {
'default-src': ["{static}"], 'default-src': ["{static}"],
@@ -394,9 +287,9 @@ class SecurityMiddleware(MiddlewareMixin):
'object-src': ["'none'"], 'object-src': ["'none'"],
'frame-src': ['{static}'], 'frame-src': ['{static}'],
'style-src': ["{static}", "{media}"], 'style-src': ["{static}", "{media}"],
'connect-src': ["{static}", "{dynamic}", "{media}"], 'connect-src': ["{dynamic}", "{media}"],
'img-src': ["{static}", "{media}", "data:"], 'img-src': ["{static}", "{media}", "data:"] + img_src,
'font-src': ["{static}"], 'font-src': ["{static}"] + list(font_src),
'media-src': ["{static}", "data:"], 'media-src': ["{static}", "data:"],
# form-action is not only used to match on form actions, but also on URLs # form-action is not only used to match on form actions, but also on URLs
# form-actions redirect to. In the context of e.g. payment providers or # form-actions redirect to. In the context of e.g. payment providers or
@@ -405,18 +298,17 @@ class SecurityMiddleware(MiddlewareMixin):
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []), 'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
} }
gs = global_settings_object(request)
if gs.settings.leaflet_tiles:
h['img-src'].append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
if hasattr(request, 'event'):
h['font-src'] += list(self._get_font_origins(request.event))
if settings.VITE_DEV_MODE: if settings.VITE_DEV_MODE:
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"] h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
h['style-src'] += ["'unsafe-inline'"] h['style-src'] += ["'unsafe-inline'"]
h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"] 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 # Only include pay.google.com for wallet detection purposes on the Payment selection page
if ( if (
url.url_name == "event.order.pay.change" or url.url_name == "event.order.pay.change" or
@@ -425,35 +317,27 @@ class SecurityMiddleware(MiddlewareMixin):
h['script-src'].append('https://pay.google.com') h['script-src'].append('https://pay.google.com')
h['frame-src'].append('https://pay.google.com') h['frame-src'].append('https://pay.google.com')
h['connect-src'].append('https://google.com/pay') h['connect-src'].append('https://google.com/pay')
if settings.LOG_CSP: if settings.LOG_CSP:
h['report-uri'] = ["/csp_report/"] h['report-uri'] = ["/csp_report/"]
if request._csp_to_merge:
_merge_csp(h, request._csp_to_merge)
if 'Content-Security-Policy' in resp: if 'Content-Security-Policy' in resp:
_merge_csp(h, _parse_csp(resp['Content-Security-Policy'])) _merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
if settings.CSP_ADDITIONAL_HEADER: if settings.CSP_ADDITIONAL_HEADER:
_merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER)) _merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER))
placeholders = { staticdomain = "'self'"
"{static}": ["'self'"], dynamicdomain = "'self'"
"{dynamic}": ["'self'"], mediadomain = "'self'"
"{media}": ["'self'"],
}
if settings.MEDIA_URL.startswith('http'): if settings.MEDIA_URL.startswith('http'):
placeholders["{media}"].append(settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)]) mediadomain += " " + settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)]
if settings.STATIC_URL.startswith('http'): if settings.STATIC_URL.startswith('http'):
placeholders["{static}"].append(settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)]) staticdomain += " " + settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)]
if settings.SITE_URL.startswith('http'): if settings.SITE_URL.startswith('http'):
if settings.SITE_URL.find('/', 9) > 0: if settings.SITE_URL.find('/', 9) > 0:
placeholders["{static}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)]) staticdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
placeholders["{dynamic}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)]) dynamicdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
else: else:
placeholders["{static}"].append(settings.SITE_URL) staticdomain += " " + settings.SITE_URL
placeholders["{dynamic}"].append(settings.SITE_URL) dynamicdomain += " " + settings.SITE_URL
if hasattr(request, 'organizer') and request.organizer: if hasattr(request, 'organizer') and request.organizer:
if hasattr(request, 'event') and request.event: if hasattr(request, 'event') and request.event:
@@ -464,29 +348,18 @@ class SecurityMiddleware(MiddlewareMixin):
siteurlsplit = urlsplit(settings.SITE_URL) siteurlsplit = urlsplit(settings.SITE_URL)
if siteurlsplit.port and siteurlsplit.port not in (80, 443): if siteurlsplit.port and siteurlsplit.port not in (80, 443):
domain = '%s:%d' % (domain, siteurlsplit.port) domain = '%s:%d' % (domain, siteurlsplit.port)
placeholders["{dynamic}"].append(domain) dynamicdomain += " " + domain
for k, v in h.items(): if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False):
h[k] = sorted(set(result for part in v for result in placeholders.get(part, [part]))) resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
media=mediadomain)
for k, v in h.items():
h[k] = sorted(set(' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')))
resp['Content-Security-Policy'] = _render_csp(h)
elif 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']
return h return resp
def _get_font_origins(self, event):
def nested_dict_values(d):
for v in d.values():
if isinstance(v, dict):
yield from nested_dict_values(v)
else:
if isinstance(v, str):
yield v
font_src = set()
for font in get_fonts(event, pdf_support_required=False).values():
for path in list(nested_dict_values(font)):
font_location = urlparse(path)
if font_location.scheme and font_location.netloc:
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
return font_src
class RejectInvalidInputMiddleware(MiddlewareMixin): class RejectInvalidInputMiddleware(MiddlewareMixin):
@@ -497,16 +370,8 @@ class RejectInvalidInputMiddleware(MiddlewareMixin):
if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']: if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']:
raise BadRequest("Invalid characters in input.") raise BadRequest("Invalid characters in input.")
if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded": if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded":
try: if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list):
post_data = request.POST.lists() raise BadRequest("Invalid characters in input.")
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): class CustomCommonMiddleware(CommonMiddleware):
@@ -1,58 +0,0 @@
# 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),
]
@@ -1,46 +0,0 @@
# 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",
),
),
]
@@ -1,48 +0,0 @@
# 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),
),
]
@@ -1,91 +0,0 @@
# 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),
]
@@ -1,17 +0,0 @@
# 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")},
),
]
@@ -1,43 +0,0 @@
# 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",
),
),
]
@@ -1,63 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-01 20:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0307_devicelastseen"),
]
operations = [
migrations.CreateModel(
name="CheckoutSession",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("cart_id", models.CharField(max_length=255, unique=True)),
("created", models.DateTimeField(auto_now_add=True)),
("testmode", models.BooleanField(default=False)),
("session_data", models.JSONField(default=dict)),
(
"customer",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="checkout_sessions",
to="pretixbase.customer",
),
),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="checkout_sessions",
to="pretixbase.event",
),
),
(
"sales_channel",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.saleschannel",
),
),
],
),
migrations.AddField(
model_name="invoiceaddress",
name="checkout_session",
field=models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="invoice_address",
to="pretixbase.checkoutsession",
),
),
]
@@ -1,33 +0,0 @@
# Generated by Django 5.2.15 on 2026-08-07 20:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0308_checkoutsession_invoiceaddress_checkout_session'),
]
operations = [
migrations.AddField(
model_name='question',
name='container_type',
field=models.CharField(default='P', max_length=5),
),
migrations.AddField(
model_name='questionanswer',
name='checkoutsession',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.checkoutsession'),
),
migrations.AddField(
model_name='questionanswer',
name='order',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.order'),
),
migrations.AlterUniqueTogether(
name='questionanswer',
unique_together={('cartposition', 'question'), ('checkoutsession', 'question'), ('order', 'question'), ('orderposition', 'question')},
),
]
+14 -14
View File
@@ -373,7 +373,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
mail( mail(
email or self.email, email or self.email,
_('Changes to your account'), _('Account information changed'),
'pretixcontrol/email/security_notice.txt', 'pretixcontrol/email/security_notice.txt',
{ {
'user': self, 'user': self,
@@ -400,13 +400,12 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
with language(self.locale): with language(self.locale):
if reason == 'email_change': if reason == 'email_change':
msg = str(_('To change your email address from {old_email} to {new_email}, use the following code:').format( msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
old_email=self.email, new_email=email, old_email=self.email, new_email=email,
)) ))
elif reason == 'email_verify': elif reason == 'email_verify':
msg = str(_('To verify your email address {email} on {instance}, use the following code:').format( msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
email=self.email, email=self.email,
instance=settings.PRETIX_INSTANCE_NAME,
)) ))
else: else:
raise Exception('Invalid confirmation code reason') raise Exception('Invalid confirmation code reason')
@@ -419,7 +418,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
} }
mail( mail(
email or self.email, email or self.email,
_('Your confirmation code'), _('pretix confirmation code'),
'pretixcontrol/email/confirmation_code.txt', 'pretixcontrol/email/confirmation_code.txt',
{ {
'user': self, 'user': self,
@@ -463,9 +462,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
from pretix.base.services.mail import mail from pretix.base.services.mail import mail
mail( mail(
self.email, self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
_('Reset your password'),
'pretixcontrol/email/forgot.txt',
{ {
'instance': settings.PRETIX_INSTANCE_NAME, 'instance': settings.PRETIX_INSTANCE_NAME,
'user': self, 'user': self,
@@ -650,22 +647,25 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True) id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True)
) )
def has_active_staff_session(self, session_key): def has_active_staff_session(self, session_key=None):
""" """
Returns whether or not a user has an active staff session (formerly known as superuser session) Returns whether or not a user has an active staff session (formerly known as superuser session)
with the given session key. with the given session key.
""" """
return self.get_active_staff_session(session_key) is not None return self.get_active_staff_session(session_key) is not None
def get_active_staff_session(self, session_key): def get_active_staff_session(self, session_key=None):
if not self.is_staff or not session_key: if not self.is_staff:
return None return None
if not hasattr(self, '_staff_session_cache'): if not hasattr(self, '_staff_session_cache'):
self._staff_session_cache = {} self._staff_session_cache = {}
if session_key not in self._staff_session_cache: if session_key not in self._staff_session_cache:
sess = StaffSession.objects.filter( qs = StaffSession.objects.filter(
user=self, date_end__isnull=True, session_key=session_key user=self, date_end__isnull=True
).first() )
if session_key:
qs = qs.filter(session_key=session_key)
sess = qs.first()
if sess: if sess:
if sess.date_start < now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE): if sess.date_start < now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE):
sess.date_end = now() sess.date_end = now()
-12
View File
@@ -33,7 +33,6 @@ from django.db.models.aggregates import Sum
from django.db.models.expressions import OuterRef, Subquery from django.db.models.expressions import OuterRef, Subquery
from django.db.models.functions.comparison import Coalesce from django.db.models.functions.comparison import Coalesce
from django.utils.crypto import get_random_string, salted_hmac from django.utils.crypto import get_random_string, salted_hmac
from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
@@ -410,17 +409,6 @@ class AttendeeProfile(models.Model):
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()]) return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
@cached_property
def answers_key_to_index(self):
return {a.get('field_name'): i for i, a in enumerate(self.answers)}
def store_answer(self, answer_dict):
k = answer_dict['field_name']
if k in self.answers_key_to_index:
self.answers[self.answers_key_to_index[k]] = answer_dict
else:
self.answers.append(answer_dict)
def generate_client_id(): def generate_client_id():
return get_random_string(40) return get_random_string(40)
-27
View File
@@ -20,13 +20,11 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import string import string
from datetime import timedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Max from django.db.models import Max
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
@@ -34,7 +32,6 @@ from pretix.base.models import LoggedModel
from pretix.base.permissions import ( from pretix.base.permissions import (
AnyPermissionOf, assert_valid_event_permission, AnyPermissionOf, assert_valid_event_permission,
) )
from pretix.helpers import BrinIndexIgnoredOnSQLite
@scopes_disabled() @scopes_disabled()
@@ -290,27 +287,3 @@ class Device(LoggedModel):
return self.get_events_with_any_permission() return self.get_events_with_any_permission()
else: else:
return self.organizer.events.none() 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)
+11 -18
View File
@@ -179,12 +179,6 @@ class EventMixin:
self.date_to.astimezone(tz), ("D" if short else "l") 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: 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 Returns a formatted string containing the start date and the end date
@@ -238,9 +232,6 @@ class EventMixin:
@property @property
def timezone(self): 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) return pytz_deprecation_shim.timezone(self.settings.timezone)
@property @property
@@ -658,7 +649,7 @@ class Event(EventMixin, LoggedModel):
is_remote = models.BooleanField( is_remote = models.BooleanField(
default=False, default=False,
verbose_name=_("This event is remote or partially remote."), 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 to let us calculate the local time of a user."), help_text=_("This will be used to let users know if the event is in a different timezone and lets us calculate users local times."),
) )
geo_lat = models.FloatField( geo_lat = models.FloatField(
verbose_name=_("Latitude"), verbose_name=_("Latitude"),
@@ -736,7 +727,7 @@ class Event(EventMixin, LoggedModel):
self.settings.mail_send_order_paid_attendee = True self.settings.mail_send_order_paid_attendee = True
self.settings.mail_send_order_approved_attendee = True self.settings.mail_send_order_approved_attendee = True
self.settings.mail_send_order_approved_free_attendee = True self.settings.mail_send_order_approved_free_attendee = True
self.settings.mail_send_download_reminder_attendee = True self.settings.mail_text_download_reminder_attendee = True
@property @property
def social_image(self): def social_image(self):
@@ -1412,12 +1403,15 @@ class Event(EventMixin, LoggedModel):
for mp in self.organizer.meta_properties.all(): for mp in self.organizer.meta_properties.all():
if mp.required and not self.meta_data.get(mp.name): if mp.required and not self.meta_data.get(mp.name):
issues.append(format_html( issues.append(
'<a href="{href}{href_hash}">{text}</a>', ('<a {a_attr}>' + gettext('You need to fill the meta parameter "{property}".') + '</a>').format(
text=gettext('You need to fill the meta parameter "{property}".').format(property=mp.name), property=mp.name,
href=reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}), a_attr='href="%s#id_prop-%d-value"' % (
href_hash=f'#id_prop-{mp.pk}-value', reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
)) mp.pk
)
)
)
responses = event_live_issues.send(self) responses = event_live_issues.send(self)
for receiver, response in sorted(responses, key=lambda r: str(r[0])): for receiver, response in sorted(responses, key=lambda r: str(r[0])):
@@ -1860,7 +1854,6 @@ class EventMetaProperty(LoggedModel):
class Meta: class Meta:
ordering = ("position", "name",) ordering = ("position", "name",)
unique_together = ('organizer', 'name')
@property @property
def choice_keys(self): def choice_keys(self):
+1 -2
View File
@@ -49,7 +49,6 @@ from django_scopes import ScopedManager
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.helpers.countries import FastCountryField from pretix.helpers.countries import FastCountryField
from pretix.helpers.models import NormalizedDecimalField
def invoice_filename(instance, filename: str) -> str: def invoice_filename(instance, filename: str) -> str:
@@ -451,7 +450,7 @@ class InvoiceLine(models.Model):
description = models.TextField() description = models.TextField()
gross_value = models.DecimalField(max_digits=13, decimal_places=2) 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_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00'))
tax_rate = NormalizedDecimalField(max_digits=7, decimal_places=4, default=Decimal('0')) tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
tax_name = models.CharField(max_length=190) tax_name = models.CharField(max_length=190)
tax_code = models.CharField(max_length=190, null=True, blank=True) tax_code = models.CharField(max_length=190, null=True, blank=True)
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT) subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
+36 -9
View File
@@ -885,6 +885,26 @@ class Item(LoggedModel):
return False return False
return True 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): def _get_quotas(self, ignored_quotas=None, subevent=None):
check_quotas = set(getattr( check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list self, '_subevent_quotas', # Utilize cache in product list
@@ -1393,6 +1413,22 @@ class ItemVariation(models.Model):
return False return False
return True 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 @property
def meta_data(self): def meta_data(self):
data = self.item.meta_data data = self.item.meta_data
@@ -1606,9 +1642,6 @@ class Question(LoggedModel):
:param dependency_values: The values that `dependency_question` needs to be set to for this question to be applicable. :param dependency_values: The values that `dependency_question` needs to be set to for this question to be applicable.
:type dependency_values: list[str] :type dependency_values: list[str]
""" """
class ContainerType(models.TextChoices):
ORDER = "O", _("Order")
ORDERPOSITION = "P", _("Order position")
TYPE_NUMBER = "N" TYPE_NUMBER = "N"
TYPE_STRING = "S" TYPE_STRING = "S"
TYPE_TEXT = "T" TYPE_TEXT = "T"
@@ -1644,12 +1677,6 @@ class Question(LoggedModel):
related_name="questions", related_name="questions",
on_delete=models.CASCADE on_delete=models.CASCADE
) )
container_type = models.CharField(
max_length=5,
choices=ContainerType.choices,
verbose_name=_("Asked on"),
default=ContainerType.ORDERPOSITION,
)
question = I18nTextField( question = I18nTextField(
verbose_name=_("Question") verbose_name=_("Question")
) )
+36 -128
View File
@@ -87,7 +87,6 @@ from pretix.base.timemachine import time_machine_now
from ...helpers import OF_SELF from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.models import NormalizedDecimalField
from ...helpers.names import build_name from ...helpers.names import build_name
from ...testutils.middleware import debugflags_var from ...testutils.middleware import debugflags_var
from ._transactions import ( from ._transactions import (
@@ -225,6 +224,8 @@ class Order(LockModel, LoggedModel):
"Organizer", "Organizer",
related_name="orders", related_name="orders",
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True,
blank=True,
) )
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,
@@ -328,7 +329,7 @@ class Order(LockModel, LoggedModel):
default="line", default="line",
) )
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='organizer') objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Order") verbose_name = _("Order")
@@ -508,20 +509,20 @@ class Order(LockModel, LoggedModel):
@classmethod @classmethod
def annotate_overpayments(cls, qs, results=True, refunds=True, sums=False): def annotate_overpayments(cls, qs, results=True, refunds=True, sums=False):
payment_sum = OrderPayment.objects.with_scopes_disabled().filter( payment_sum = OrderPayment.objects.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
order=OuterRef('pk') order=OuterRef('pk')
).order_by().values('order').annotate(s=Sum('amount')).values('s') ).order_by().values('order').annotate(s=Sum('amount')).values('s')
refund_sum = OrderRefund.objects.with_scopes_disabled().filter( refund_sum = OrderRefund.objects.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED), OrderRefund.REFUND_STATE_CREATED),
order=OuterRef('pk') order=OuterRef('pk')
).order_by().values('order').annotate(s=Sum('amount')).values('s') ).order_by().values('order').annotate(s=Sum('amount')).values('s')
external_refund = OrderRefund.objects.with_scopes_disabled().filter( external_refund = OrderRefund.objects.filter(
state=OrderRefund.REFUND_STATE_EXTERNAL, state=OrderRefund.REFUND_STATE_EXTERNAL,
order=OuterRef('pk') order=OuterRef('pk')
) )
pending_refund = OrderRefund.objects.with_scopes_disabled().filter( pending_refund = OrderRefund.objects.filter(
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT), state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
order=OuterRef('pk') order=OuterRef('pk')
) )
@@ -1304,9 +1305,10 @@ class Order(LockModel, LoggedModel):
def answerfile_name(instance, filename: str) -> str: def answerfile_name(instance, filename: str) -> str:
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits) secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
event = (instance.cartposition if instance.cartposition else instance.orderposition.order).event
return 'cachedfiles/answers/{org}/{ev}/{secret}.{filename}'.format( return 'cachedfiles/answers/{org}/{ev}/{secret}.{filename}'.format(
org=instance.event.organizer.slug, org=event.organizer.slug,
ev=instance.event.slug, ev=event.slug,
secret=secret, secret=secret,
filename=escape_uri_path(filename), filename=escape_uri_path(filename),
) )
@@ -1335,14 +1337,6 @@ class QuestionAnswer(models.Model):
'CartPosition', null=True, blank=True, 'CartPosition', null=True, blank=True,
related_name='answers', on_delete=models.CASCADE related_name='answers', on_delete=models.CASCADE
) )
order = models.ForeignKey(
'Order', null=True, blank=True,
related_name='answers', on_delete=models.CASCADE
)
checkoutsession = models.ForeignKey(
'CheckoutSession', null=True, blank=True,
related_name='answers', on_delete=models.CASCADE
)
question = models.ForeignKey( question = models.ForeignKey(
Question, related_name='answers', on_delete=models.CASCADE Question, related_name='answers', on_delete=models.CASCADE
) )
@@ -1358,21 +1352,16 @@ class QuestionAnswer(models.Model):
objects = ScopedManager(organizer='question__event__organizer') objects = ScopedManager(organizer='question__event__organizer')
class Meta: class Meta:
unique_together = [ unique_together = [['orderposition', 'question'], ['cartposition', 'question']]
['orderposition', 'question'],
['cartposition', 'question'],
['order', 'question'],
['checkoutsession', 'question'],
]
@property @property
def backend_file_url(self): def backend_file_url(self):
if self.file: if self.file:
if self.associated_order: if self.orderposition:
return reverse('control:event.order.download.answer', kwargs={ return reverse('control:event.order.download.answer', kwargs={
'code': self.associated_order.code, 'code': self.orderposition.order.code,
'event': self.associated_order.event.slug, 'event': self.orderposition.order.event.slug,
'organizer': self.associated_order.event.organizer.slug, 'organizer': self.orderposition.order.event.organizer.slug,
'answer': self.pk, 'answer': self.pk,
}) })
return "" return ""
@@ -1382,14 +1371,14 @@ class QuestionAnswer(models.Model):
from pretix.multidomain.urlreverse import eventreverse from pretix.multidomain.urlreverse import eventreverse
if self.file: if self.file:
if self.associated_order: if self.orderposition:
url = eventreverse(self.associated_order.event, 'presale:event.order.download.answer', kwargs={ url = eventreverse(self.orderposition.order.event, 'presale:event.order.download.answer', kwargs={
'order': self.associated_order.code, 'order': self.orderposition.order.code,
'secret': self.associated_order.secret, 'secret': self.orderposition.order.secret,
'answer': self.pk, 'answer': self.pk,
}) })
else: else:
url = eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={ url = eventreverse(self.cartposition.event, 'presale:event.cart.download.answer', kwargs={
'answer': self.pk, 'answer': self.pk,
}) })
@@ -1404,24 +1393,6 @@ class QuestionAnswer(models.Model):
def file_name(self): def file_name(self):
return self.file.name.split('.', 1)[-1] return self.file.name.split('.', 1)[-1]
@property
def associated_order(self):
if self.orderposition:
return self.orderposition.order
elif self.order:
return self.order
@property
def event(self):
if self.orderposition:
return self.orderposition.order.event
elif self.cartposition:
return self.cartposition.event
elif self.order:
return self.order.event
elif self.checkoutsession:
return self.checkoutsession.event
def __str__(self): def __str__(self):
return self.to_string(use_cached=True) return self.to_string(use_cached=True)
@@ -2102,17 +2073,6 @@ class OrderPayment(models.Model):
""" """
return '{}-P-{}'.format(self.order.code, self.local_id) 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): def save(self, *args, **kwargs):
if not self.local_id: if not self.local_id:
self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1 self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1
@@ -2313,17 +2273,6 @@ class OrderRefund(models.Model):
""" """
return '{}-R-{}'.format(self.order.code, self.local_id) 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): def save(self, *args, **kwargs):
if not self.local_id: if not self.local_id:
self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1 self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1
@@ -2338,12 +2287,9 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def ActivePositionManager(**scope): class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__):
class InnerClass(ScopedManager(**scope).__class__): def get_queryset(self):
def get_queryset(self): return super().get_queryset().filter(canceled=False)
return super().get_queryset().filter(canceled=False)
return InnerClass()
class OrderFee(RoundingCorrectionMixin, models.Model): class OrderFee(RoundingCorrectionMixin, models.Model):
@@ -2410,8 +2356,8 @@ class OrderFee(RoundingCorrectionMixin, models.Model):
) )
description = models.CharField(max_length=190, blank=True) description = models.CharField(max_length=190, blank=True)
internal_type = models.CharField(max_length=255, blank=True) internal_type = models.CharField(max_length=255, blank=True)
tax_rate = NormalizedDecimalField( tax_rate = models.DecimalField(
max_digits=7, decimal_places=4, max_digits=7, decimal_places=2,
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
) )
tax_rule = models.ForeignKey( tax_rule = models.ForeignKey(
@@ -2433,7 +2379,7 @@ class OrderFee(RoundingCorrectionMixin, models.Model):
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
all = ScopedManager(organizer='order__event__organizer') all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager(organizer='order__event__organizer') objects = ActivePositionManager()
@property @property
def net_value(self): def net_value(self):
@@ -2595,6 +2541,8 @@ class OrderPosition(AbstractPosition):
"Organizer", "Organizer",
related_name="order_positions", related_name="order_positions",
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True,
blank=True,
) )
order = models.ForeignKey( order = models.ForeignKey(
Order, Order,
@@ -2607,8 +2555,8 @@ class OrderPosition(AbstractPosition):
max_digits=13, decimal_places=2, null=True, blank=True, max_digits=13, decimal_places=2, null=True, blank=True,
) )
tax_rate = NormalizedDecimalField( tax_rate = models.DecimalField(
max_digits=7, decimal_places=4, max_digits=7, decimal_places=2,
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
) )
tax_rule = models.ForeignKey( tax_rule = models.ForeignKey(
@@ -2651,8 +2599,8 @@ class OrderPosition(AbstractPosition):
blank=True, blank=True,
) )
all = ScopedManager(organizer='organizer') all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager(organizer='organizer') objects = ActivePositionManager()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -3126,8 +3074,8 @@ class Transaction(models.Model):
price_includes_rounding_correction = models.DecimalField( price_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00") max_digits=13, decimal_places=2, default=Decimal("0.00")
) )
tax_rate = NormalizedDecimalField( tax_rate = models.DecimalField(
max_digits=7, decimal_places=4, max_digits=7, decimal_places=2,
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
) )
tax_rule = models.ForeignKey( tax_rule = models.ForeignKey(
@@ -3207,39 +3155,6 @@ class Transaction(models.Model):
return self.tax_value_includes_rounding_correction * self.count return self.tax_value_includes_rounding_correction * self.count
class CheckoutSession(models.Model):
"""
A checkout session optionally bundles cart positions with additional information. This is historically
not required in pretix and currently only used in the Storefront API.
"""
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
related_name="checkout_sessions",
on_delete=models.CASCADE,
)
cart_id = models.CharField(
max_length=255, unique=True,
verbose_name=_("Cart ID (e.g. session key)"),
)
created = models.DateTimeField(
verbose_name=_("Date"),
auto_now_add=True,
)
customer = models.ForeignKey(
Customer,
related_name='checkout_sessions',
null=True, blank=True,
on_delete=models.SET_NULL,
)
sales_channel = models.ForeignKey(
"SalesChannel",
on_delete=models.CASCADE,
)
testmode = models.BooleanField(default=False)
session_data = models.JSONField(default=dict)
class CartPosition(AbstractPosition): class CartPosition(AbstractPosition):
""" """
A cart position is similar to an order line, except that it is not A cart position is similar to an order line, except that it is not
@@ -3275,8 +3190,8 @@ class CartPosition(AbstractPosition):
verbose_name=_("Limit for extending expiration date"), verbose_name=_("Limit for extending expiration date"),
null=True null=True
) )
tax_rate = NormalizedDecimalField( tax_rate = models.DecimalField(
max_digits=7, decimal_places=4, default=Decimal('0'), max_digits=7, decimal_places=2, default=Decimal('0.00'),
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
) )
tax_code = models.CharField( tax_code = models.CharField(
@@ -3444,13 +3359,6 @@ class CartPosition(AbstractPosition):
class InvoiceAddress(models.Model): class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True) last_modified = models.DateTimeField(auto_now=True)
checkout_session = models.OneToOneField(
CheckoutSession,
null=True,
blank=True,
related_name='invoice_address',
on_delete=models.CASCADE
)
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE) order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
customer = models.ForeignKey( customer = models.ForeignKey(
Customer, Customer,
+3 -4
View File
@@ -40,7 +40,6 @@ from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.countries import FastCountryField from pretix.helpers.countries import FastCountryField
from pretix.helpers.models import NormalizedDecimalField
class TaxedPrice: class TaxedPrice:
@@ -336,9 +335,9 @@ class TaxRule(LoggedModel):
max_length=190, max_length=190,
choices=TAX_CODE_LISTS, choices=TAX_CODE_LISTS,
) )
rate = NormalizedDecimalField( rate = models.DecimalField(
max_digits=7, max_digits=10,
decimal_places=4, decimal_places=2,
validators=[ validators=[
MaxValueValidator( MaxValueValidator(
limit_value=Decimal("100.00"), limit_value=Decimal("100.00"),
+38 -45
View File
@@ -32,10 +32,8 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is # 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 # 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. # 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 decimal import ROUND_HALF_UP, Decimal
from typing import Union
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -423,33 +421,27 @@ class Voucher(LoggedModel):
return False return False
@staticmethod @staticmethod
def get_affected_quotas(quota, item, variation, subevent): def clean_quota_get_ignored(old_instance):
if quota: quotas = set()
return {quota} was_valid = old_instance and (
elif item and variation: old_instance.valid_until is None or old_instance.valid_until >= now()
return set(variation.quotas.filter(subevent=subevent)) )
elif item and not item.has_variations: if old_instance and old_instance.block_quota and was_valid:
return set(item.quotas.filter(subevent=subevent)) if old_instance.quota:
elif item and item.has_variations: quotas.add(old_instance.quota)
return set( elif old_instance.variation:
Quota.objects.filter( quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
pk__in=Quota.variations.through.objects.filter( elif old_instance.item:
itemvariation__item=item, if old_instance.item.has_variations:
quota__subevent=subevent, quotas |= set(
).values('quota_id') Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
) itemvariation__item=old_instance.item,
) quota__subevent=old_instance.subevent,
else: ).values('quota_id'))
return set() )
else:
@staticmethod quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
def clean_quota_get_ignored(voucher_data: Union["VoucherBulkData", "Voucher"]): return quotas
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 @staticmethod
def clean_quota_check(data, cnt, old_instance, event, quota, item, variation): def clean_quota_check(data, cnt, old_instance, event, quota, item, variation):
@@ -461,8 +453,22 @@ class Voucher(LoggedModel):
if event.has_subevents and data.get('block_quota') and not data.get('subevent'): if event.has_subevents and data.get('block_quota') and not data.get('subevent'):
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.')) raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
new_quotas = Voucher.get_affected_quotas(quota, item, variation, data.get('subevent')) if quota:
if not new_quotas: 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:
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve ' raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
'tickets.')) 'tickets.'))
@@ -638,16 +644,3 @@ class Voucher(LoggedModel):
] ]
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00') ).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
return ops 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
+7 -76
View File
@@ -330,24 +330,9 @@ class BasePaymentProvider:
payment method. This returns ``False`` by default which is no guarantee that payment method. This returns ``False`` by default which is no guarantee that
aborting a pending payment can never happen, it just hides the frontend button aborting a pending payment can never happen, it just hides the frontend button
to avoid users accidentally committing double payments. to avoid users accidentally committing double payments.
If the decision doesn't depend on the specific payment, then only implementing
``abort_pending_allowed`` is enough, ``payment_abort_pending_allowed(payment: OrderPayment)``
is expected to take this into account.
As a consumer only evaluate ``payment_abort_pending_allowed(payment: OrderPayment)``
to check if aborting this pending payment is possible.
""" """
return False return False
def _payment_abort_pending_allowed(self, payment: OrderPayment) -> bool:
"""
Experimental: This might change during upcomming releases.
Whether or not a user can abort a payment in pending state to switch to another
payment method. This returns ``self.abort_pending_allowed`` by default which is
no guarantee that aborting a pending payment can never happen, it just hides the
frontend button to avoid users accidentally committing double payments.
"""
return self.abort_pending_allowed
@property @property
def requires_invoice_immediately(self): def requires_invoice_immediately(self):
""" """
@@ -849,7 +834,7 @@ class BasePaymentProvider:
""" """
raise NotImplementedError() # NOQA raise NotImplementedError() # NOQA
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str | None: def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
""" """
After the user has confirmed their purchase, this method will be called to complete After the user has confirmed their purchase, this method will be called to complete
the payment process. This is the place to actually move the money if applicable. the payment process. This is the place to actually move the money if applicable.
@@ -951,7 +936,7 @@ class BasePaymentProvider:
""" """
Will be called if the *event administrator* views the details of a payment. Will be called if the *event administrator* views the details of a payment.
It should return a SafeString containing HTML code, with information regarding the current payment It should return HTML code containing information regarding the current payment
status and, if applicable, next steps. status and, if applicable, next steps.
The default implementation returns an empty string. The default implementation returns an empty string.
@@ -976,7 +961,7 @@ class BasePaymentProvider:
""" """
Will be called if the *event administrator* views the details of a refund. Will be called if the *event administrator* views the details of a refund.
It should return a SafeString containing HTML code, with information regarding the current refund It should return HTML code containing information regarding the current refund
status and, if applicable, next steps. status and, if applicable, next steps.
The default implementation returns an empty string. The default implementation returns an empty string.
@@ -1034,12 +1019,10 @@ class BasePaymentProvider:
On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method). On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method).
On failure, you should raise a PaymentException. On failure, you should raise a PaymentException.
""" """
if payment.state == OrderPayment.PAYMENT_STATE_PENDING and not self.abort_pending_allowed:
if payment.state == OrderPayment.PAYMENT_STATE_PENDING: raise PaymentException(_(
if not self._payment_abort_pending_allowed(payment): "This payment is already being processed and can not be canceled any more."
raise PaymentException(_( ))
"This payment is already being processed and cannot be canceled any more."
))
payment.state = OrderPayment.PAYMENT_STATE_CANCELED payment.state = OrderPayment.PAYMENT_STATE_CANCELED
payment.save(update_fields=['state']) payment.save(update_fields=['state'])
@@ -1723,58 +1706,6 @@ 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") @receiver(register_payment_providers, dispatch_uid="payment_free")
def register_payment_provider(sender, **kwargs): def register_payment_provider(sender, **kwargs):
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment, GiftCardPayment] return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment, GiftCardPayment]
+5 -10
View File
@@ -77,7 +77,6 @@ from reportlab.platypus import Paragraph
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
from pretix.base.services.placeholders import PlaceholderContext
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
@@ -402,7 +401,11 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Event organizer info text"), "editor_sample": _("Event organizer info text"),
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text) "evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
}), }),
("event_info_text", {}), # Placeholder to "reserve" position, defined later in `get_variables` ("event_info_text", {
"label": _("Event info text"),
"editor_sample": _("Event info text"),
"evaluate": lambda op, order, ev: str(order.event.settings.event_info_text)
}),
("now_date", { ("now_date", {
"label": _("Printing date"), "label": _("Printing date"),
"editor_sample": _("2017-05-31"), "editor_sample": _("2017-05-31"),
@@ -667,14 +670,6 @@ def get_images(event):
def get_variables(event): def get_variables(event):
v = copy.copy(DEFAULT_VARIABLES) v = copy.copy(DEFAULT_VARIABLES)
templating_context = PlaceholderContext(event=event)
v['event_info_text'] = {
"label": _("Event info text"),
"editor_sample": _("Event info text"),
"evaluate": lambda op, order, ev:
templating_context.format(str(order.event.settings.event_info_text))
}
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme] scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"]) concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"])
+155 -373
View File
@@ -19,245 +19,30 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import copy
import datetime import datetime
import os
import warnings
from collections import namedtuple from collections import namedtuple
from dataclasses import dataclass from typing import Union
from typing import (
TYPE_CHECKING, Iterable, List, Literal, Optional, Tuple, Union, cast,
)
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from dateutil import parser from dateutil import parser
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.forms.widgets import Widget
from django.utils.formats import get_format from django.utils.formats import get_format
from django.utils.functional import Promise, lazy from django.utils.functional import lazy
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from pretix.base.forms.widgets import OptionAttrsSelect BASE_CHOICES = (
('date_from', _('Event start')),
('date_to', _('Event end')),
('date_admission', _('Event admission')),
('presale_start', _('Presale start')),
('presale_end', _('Presale end')),
)
if TYPE_CHECKING: RelativeDate = namedtuple('RelativeDate', ['days', 'minutes', 'time', 'is_after', 'base_date_name'], defaults=(0, None, None, False, 'date_from'))
from .models import Event, Order, SubEvent
@dataclass(frozen=True)
class BaseChoice:
base: Literal["event", "order"]
attribute: str
text: Promise
supports_before: bool
supports_after: bool
@property
def key(self) -> str:
key = f"{self.base}__{self.attribute}"
return key
@staticmethod
def find(objects: Iterable["BaseChoice"], key: str) -> "BaseChoice":
if "__" in key:
choice = next((obj for obj in objects if obj.key == key), None)
else:
# fallback for RelativeDateFields stored, before support for bases other than event was added
choice = next((obj for obj in objects if obj.attribute == key and obj.base == "event"), None)
if choice is None:
raise TypeError(f"key {key} must be a valid key in BASE_CHOICES")
return choice
BASE_CHOICES: List[BaseChoice] = [
BaseChoice('event', 'date_from', _('Event start'), True, True),
BaseChoice('event', 'date_to', _('Event end'), True, True),
BaseChoice('event', 'date_admission', _('Event admission'), True, True),
BaseChoice('event', 'presale_start', _('Presale start'), True, True),
BaseChoice('event', 'presale_end', _('Presale end'), True, True),
BaseChoice('order', 'datetime', _('Order creation'), False, True),
BaseChoice('order', 'expires', _('Order expiry'), True, True),
]
EVENT_BASE_CHOICES = [
x for x in BASE_CHOICES if x.base == 'event'
]
ORDER_BASE_CHOICES = [
x for x in BASE_CHOICES if x.base == 'order'
]
@dataclass(frozen=True)
class RelativeDate:
"""
This contains information on a date that is defined in relation to a fixed base point.
This means that the underlying data is a fixed date as the base point and a number of days or a time interval
to calculate the date.
The list of valid base date choices is defined in BASE_CHOICES.
If the base_date_name is not set, the date_from attribute of Event is used.
"""
days: int = 0
minutes: Optional[int] = None
time: Optional[datetime.time] = None
is_after: bool = False
base_date_name: str = 'event__date_from__'
def __post_init__(self) -> None:
if self.is_after and not self._choice.supports_after:
raise ValueError(
"The selected base date and attribute combination does not support relative dates placed after the base date"
)
if not self.is_after and not self._choice.supports_before:
raise ValueError(
"The selected base date and attribute combination does not support relative dates placed before the base date")
@property
def _choice(self):
return BaseChoice.find(BASE_CHOICES, self.base_date_name)
@property
def key(self):
return self._choice.key
def __eq__(self, o: object) -> bool:
if not isinstance(o, RelativeDate):
return False
return self.to_string() == o.to_string()
def _resolve_date(self, base: "Event | Order | SubEvent") -> Tuple[datetime.datetime, ZoneInfo]:
"""
Resolves the datetime and timezone information of this RelativeDate object in relation to the provided reference.
:param base: the reference which should be used to resolve the relative date
:return:
"""
from .models import Event, Order, SubEvent
choice = self._choice
if choice.base == "order" and isinstance(base, Order):
event = base.event
base_date = getattr(base, choice.attribute)
elif choice.base == "event" and isinstance(base, SubEvent):
event = base.event
base_date = (getattr(base, choice.attribute) or
getattr(base.event, choice.attribute) or
base.date_from)
elif choice.base == "event" and isinstance(base, Event):
event = base
base_date = getattr(base, choice.attribute) or event.date_from
else:
raise TypeError("The base defined by data does not match the passed in base")
tz = ZoneInfo(event.settings.timezone)
return base_date, tz
def date(self, base: "Event | Order | SubEvent") -> datetime.date:
"""
Resolves the effective date of this RelativeDate object in relation to the provided reference.
:param base: the reference which should be used to resolve the date
:return: datetime.date
"""
if self.minutes is not None:
raise ValueError('A minute-based relative datetime can not be used as a date')
base_date, tz = self._resolve_date(base)
if self.is_after:
new_date = base_date.astimezone(tz) + datetime.timedelta(days=self.days)
else:
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.days)
return new_date.date()
def datetime(self, base: "Event | Order | SubEvent") -> datetime.datetime:
"""
Resolves the effective datetime of this RelativeDate object in relation to the provided reference.
:param base: the reference which should be used to resolve the datetime
:return: datetime.datetime
"""
base_date, tz = self._resolve_date(base)
if self.minutes is not None:
if self.is_after:
return base_date.astimezone(tz) + datetime.timedelta(minutes=self.minutes)
else:
return base_date.astimezone(tz) - datetime.timedelta(minutes=self.minutes)
else:
if self.is_after:
new_date = (base_date.astimezone(tz) + datetime.timedelta(days=self.days)).astimezone(tz)
else:
new_date = (base_date.astimezone(tz) - datetime.timedelta(days=self.days)).astimezone(tz)
if self.time:
new_date = new_date.replace(
hour=self.time.hour,
minute=self.time.minute,
second=self.time.second
)
new_date = new_date.astimezone(tz)
return new_date
def to_string(self) -> str:
if self.minutes is not None:
return 'RELDATE/minutes/{}/{}/{}'.format( #
self.minutes,
self._choice.key,
'after' if self.is_after else '',
)
return 'RELDATE/{}/{}/{}/{}'.format( #
self.days,
self.time.strftime('%H:%M:%S') if self.time else '-',
self._choice.key,
'after' if self.is_after else '',
)
@classmethod
def from_string(cls, input: str):
if not input.startswith('RELDATE/'):
raise TypeError("Invalid input for RelativeDate.from_string()")
parts = input.split('/')
if parts[1] == 'minutes':
data = RelativeDate(
days=0,
minutes=int(parts[2]),
base_date_name=parts[3],
time=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
else:
if parts[2] == '-':
time = None
else:
timeparts = parts[2].split(':')
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
try:
data = RelativeDate(
days=int(parts[1] or 0),
base_date_name=parts[3],
time=time,
minutes=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
except ValueError:
data = RelativeDate(
days=0,
base_date_name=parts[3],
time=time,
minutes=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
return data
class RelativeDateWrapper: class RelativeDateWrapper:
@@ -265,53 +50,130 @@ class RelativeDateWrapper:
This contains information on a date that might be relative to an event. This means This contains information on a date that might be relative to an event. This means
that the underlying data is either a fixed date or a number of days and a wall clock that the underlying data is either a fixed date or a number of days and a wall clock
time to calculate the date based on a base point. time to calculate the date based on a base point.
The base point can be the date_from, date_to, date_admission, presale_start or presale_end
attribute of an event or subevent. If the respective attribute is not set, ``date_from``
will be used.
""" """
def __init__(self, data: Union[datetime.datetime, RelativeDate]): def __init__(self, data: Union[datetime.datetime, RelativeDate]):
self.data = data self.data = data
def date(self, base: "Event | Order | SubEvent") -> datetime.date: def date(self, event) -> datetime.date:
""" from .models import SubEvent
If the RelativeDateWrapper wraps a RelativeDate object:
Resolves the effective date of this object in relation to the provided reference.
If the RelativeDateWrapper wraps an absolute date or datetime:
Returns the wrapped absolute date.
:param base: the reference which should be used to resolve the date in case it is a relative date.
:return: datetime.date
"""
if isinstance(self.data, datetime.datetime): if isinstance(self.data, datetime.datetime):
return self.data.date() return self.data.date()
elif isinstance(self.data, datetime.date): elif isinstance(self.data, datetime.date):
return self.data return self.data
else: else:
return self.data.date(base) if self.data.minutes is not None:
raise ValueError('A minute-based relative datetime can not be used as a date')
def datetime(self, base: "Event | Order | SubEvent") -> datetime.datetime: tz = ZoneInfo(event.settings.timezone)
""" if isinstance(event, SubEvent):
If the RelativeDateWrapper wraps a RelativeDate object: base_date = (
Resolves the effective datetime of this object in relation to the provided reference. getattr(event, self.data.base_date_name)
If the RelativeDateWrapper wraps an absolute date or datetime: or getattr(event.event, self.data.base_date_name)
Returns the wrapped absolute datetime. or event.date_from
)
else:
base_date = getattr(event, self.data.base_date_name) or event.date_from
if self.data.is_after:
new_date = base_date.astimezone(tz) + datetime.timedelta(days=self.data.days)
else:
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days)
return new_date.date()
def datetime(self, event) -> datetime.datetime:
from .models import SubEvent
:param base: the reference which should be used to resolve the datetime in case it is a relative datetime.
:return: datetime.datetime
"""
if isinstance(self.data, (datetime.datetime, datetime.date)): if isinstance(self.data, (datetime.datetime, datetime.date)):
return self.data return self.data
else: else:
return self.data.datetime(base) tz = ZoneInfo(event.settings.timezone)
if isinstance(event, SubEvent):
base_date = (
getattr(event, self.data.base_date_name)
or getattr(event.event, self.data.base_date_name)
or event.date_from
)
else:
base_date = getattr(event, self.data.base_date_name) or event.date_from
if self.data.minutes is not None:
if self.data.is_after:
return base_date.astimezone(tz) + datetime.timedelta(minutes=self.data.minutes)
else:
return base_date.astimezone(tz) - datetime.timedelta(minutes=self.data.minutes)
else:
if self.data.is_after:
new_date = (base_date.astimezone(tz) + datetime.timedelta(days=self.data.days)).astimezone(tz)
else:
new_date = (base_date.astimezone(tz) - datetime.timedelta(days=self.data.days)).astimezone(tz)
if self.data.time:
new_date = new_date.replace(
hour=self.data.time.hour,
minute=self.data.time.minute,
second=self.data.time.second
)
new_date = new_date.astimezone(tz)
return new_date
def to_string(self) -> str: def to_string(self) -> str:
if isinstance(self.data, (datetime.datetime, datetime.date)): if isinstance(self.data, (datetime.datetime, datetime.date)):
return self.data.isoformat() return self.data.isoformat()
else: else:
return self.data.to_string() if self.data.minutes is not None:
return 'RELDATE/minutes/{}/{}/{}'.format( #
self.data.minutes,
self.data.base_date_name,
'after' if self.data.is_after else '',
)
return 'RELDATE/{}/{}/{}/{}'.format( #
self.data.days,
self.data.time.strftime('%H:%M:%S') if self.data.time else '-',
self.data.base_date_name,
'after' if self.data.is_after else '',
)
@classmethod @classmethod
def from_string(cls, input: str): def from_string(cls, input: str):
if input.startswith('RELDATE/'): if input.startswith('RELDATE/'):
data = RelativeDate.from_string(input) parts = input.split('/')
if parts[1] == 'minutes':
data = RelativeDate(
days=0,
minutes=int(parts[2]),
base_date_name=parts[3],
time=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
else:
if parts[2] == '-':
time = None
else:
timeparts = parts[2].split(':')
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
try:
data = RelativeDate(
days=int(parts[1] or 0),
base_date_name=parts[3],
time=time,
minutes=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
except ValueError:
data = RelativeDate(
days=0,
base_date_name=parts[3],
time=time,
minutes=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
if data.base_date_name not in [k[0] for k in BASE_CHOICES]:
raise ValueError('{} is not a valid base date'.format(data.base_date_name))
else: else:
data = parser.parse(input) data = parser.parse(input)
return RelativeDateWrapper(data) return RelativeDateWrapper(data)
@@ -325,6 +187,7 @@ BEFORE_AFTER_CHOICE = (
('after', _('after')), ('after', _('after')),
) )
reldatetimeparts = namedtuple('reldatetimeparts', ( reldatetimeparts = namedtuple('reldatetimeparts', (
"status", # 0 "status", # 0
"absolute", # 1 "absolute", # 1
@@ -339,14 +202,6 @@ reldatetimeparts = namedtuple('reldatetimeparts', (
reldatetimeparts.indizes = reldatetimeparts(*range(9)) reldatetimeparts.indizes = reldatetimeparts(*range(9))
def _get_choices(base_choices: List[BaseChoice]) -> List[Tuple[str, Promise]]:
return [(c.key, c.text) for c in base_choices]
def _get_choice_validation_obj(choices: List[BaseChoice]):
return {c.key: {"data-supports-before": c.supports_before, "data-supports-after": c.supports_after} for c in choices}
class RelativeDateTimeWidget(forms.MultiWidget): class RelativeDateTimeWidget(forms.MultiWidget):
template_name = 'pretixbase/forms/widgets/reldatetime.html' template_name = 'pretixbase/forms/widgets/reldatetime.html'
parts = reldatetimeparts parts = reldatetimeparts
@@ -354,7 +209,6 @@ class RelativeDateTimeWidget(forms.MultiWidget):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.status_choices = kwargs.pop('status_choices') self.status_choices = kwargs.pop('status_choices')
base_choices = kwargs.pop('base_choices') base_choices = kwargs.pop('base_choices')
choices = _get_choices(base_choices)
def placeholder_datetime_format(): def placeholder_datetime_format():
df = get_format('DATETIME_INPUT_FORMATS')[0] df = get_format('DATETIME_INPUT_FORMATS')[0]
@@ -366,29 +220,21 @@ class RelativeDateTimeWidget(forms.MultiWidget):
tf = get_format('TIME_INPUT_FORMATS')[0] tf = get_format('TIME_INPUT_FORMATS')[0]
return datetime.time(8, 30, 0).strftime(tf) return datetime.time(8, 30, 0).strftime(tf)
widgets = cast(dict[str, Widget | type[Widget]], cast(object, reldatetimeparts( widgets = reldatetimeparts(
status=forms.RadioSelect(choices=self.status_choices), status=forms.RadioSelect(choices=self.status_choices),
absolute=forms.DateTimeInput( absolute=forms.DateTimeInput(
attrs={'placeholder': lazy(placeholder_datetime_format, str), 'class': 'datetimepicker'} attrs={'placeholder': lazy(placeholder_datetime_format, str), 'class': 'datetimepicker'}
), ),
rel_days_number=forms.NumberInput(), rel_days_number=forms.NumberInput(),
rel_mins_relationto=OptionAttrsSelect( rel_mins_relationto=forms.Select(choices=base_choices),
attrs={'data-relative-choice': True},
choices=choices,
option_attrs=_get_choice_validation_obj(base_choices)
),
rel_days_timeofday=forms.TimeInput( rel_days_timeofday=forms.TimeInput(
attrs={'placeholder': lazy(placeholder_time_format, str), 'class': 'timepickerfield'} attrs={'placeholder': lazy(placeholder_time_format, str), 'class': 'timepickerfield'}
), ),
rel_mins_number=forms.NumberInput(), rel_mins_number=forms.NumberInput(),
rel_days_relationto=OptionAttrsSelect( rel_days_relationto=forms.Select(choices=base_choices),
attrs={'data-relative-choice': True}, rel_mins_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
choices=choices, rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
option_attrs=_get_choice_validation_obj(base_choices) )
),
rel_mins_relation=forms.Select(attrs={'data-relation-choice': True}, choices=BEFORE_AFTER_CHOICE),
rel_days_relation=forms.Select(attrs={'data-relation-choice': True}, choices=BEFORE_AFTER_CHOICE),
)))
super().__init__(widgets=widgets, *args, **kwargs) super().__init__(widgets=widgets, *args, **kwargs)
def decompress(self, value): def decompress(self, value):
@@ -401,10 +247,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="unset", status="unset",
absolute=None, absolute=None,
rel_days_number=1, rel_days_number=1,
rel_mins_relationto="event__date_from", rel_mins_relationto="date_from",
rel_days_timeofday=None, rel_days_timeofday=None,
rel_mins_number=0, rel_mins_number=0,
rel_days_relationto="event__date_from", rel_days_relationto="date_from",
rel_mins_relation="before", rel_mins_relation="before",
rel_days_relation="before" rel_days_relation="before"
) )
@@ -413,10 +259,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="absolute", status="absolute",
absolute=value.data, absolute=value.data,
rel_days_number=1, rel_days_number=1,
rel_mins_relationto="event__date_from", rel_mins_relationto="date_from",
rel_days_timeofday=None, rel_days_timeofday=None,
rel_mins_number=0, rel_mins_number=0,
rel_days_relationto="event__date_from", rel_days_relationto="date_from",
rel_mins_relation="before", rel_mins_relation="before",
rel_days_relation="before" rel_days_relation="before"
) )
@@ -425,10 +271,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="relative_minutes", status="relative_minutes",
absolute=None, absolute=None,
rel_days_number=None, rel_days_number=None,
rel_mins_relationto=value.data.key, rel_mins_relationto=value.data.base_date_name,
rel_days_timeofday=None, rel_days_timeofday=None,
rel_mins_number=value.data.minutes, rel_mins_number=value.data.minutes,
rel_days_relationto=value.data.key, rel_days_relationto=value.data.base_date_name,
rel_mins_relation="after" if value.data.is_after else "before", rel_mins_relation="after" if value.data.is_after else "before",
rel_days_relation="after" if value.data.is_after else "before" rel_days_relation="after" if value.data.is_after else "before"
) )
@@ -436,10 +282,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="relative", status="relative",
absolute=None, absolute=None,
rel_days_number=value.data.days, rel_days_number=value.data.days,
rel_mins_relationto=value.data.key, rel_mins_relationto=value.data.base_date_name,
rel_days_timeofday=value.data.time, rel_days_timeofday=value.data.time,
rel_mins_number=0, rel_mins_number=0,
rel_days_relationto=value.data.key, rel_days_relationto=value.data.base_date_name,
rel_mins_relation="after" if value.data.is_after else "before", rel_mins_relation="after" if value.data.is_after else "before",
rel_days_relation="after" if value.data.is_after else "before" rel_days_relation="after" if value.data.is_after else "before"
) )
@@ -463,41 +309,17 @@ class RelativeDateTimeField(forms.MultiValueField):
('relative', _('Relative date:')), ('relative', _('Relative date:')),
('relative_minutes', _('Relative time:')), ('relative_minutes', _('Relative time:')),
] ]
self.relative_to_order = kwargs.pop('relative_to_order', False)
possible_choices = copy.deepcopy(EVENT_BASE_CHOICES)
if self.relative_to_order:
possible_choices.extend(ORDER_BASE_CHOICES)
if kwargs.get('limit_choices'): if kwargs.get('limit_choices'):
limit = kwargs.pop('limit_choices') limit = kwargs.pop('limit_choices')
if any(["__" not in l for l in limit]): choices = [(k, v) for k, v in BASE_CHOICES if k in limit]
_warn_skips = (str(os.path.dirname(__file__)),) else:
choices = BASE_CHOICES
warnings.warn(
message="Please prefix limit_choices with the base the attributes refer to, for example event__date_from",
category=DeprecationWarning,
skip_file_prefixes=_warn_skips
)
possible_choices = [
c for c in possible_choices if
# new base case as we want limit_choices to be expressed as base__attribute
(c.key in limit) or
# fallback for old event based entries
# if the base is an event, then using only attribute is fine
(c.base == "event" and c.attribute in limit)
]
if not kwargs.get('required', True): if not kwargs.get('required', True):
status_choices.insert(0, ('unset', _('Not set'))) status_choices.insert(0, ('unset', _('Not set')))
choices = _get_choices(possible_choices)
fields = reldatetimeparts( fields = reldatetimeparts(
status=forms.ChoiceField( status=forms.ChoiceField(
choices=status_choices, choices=status_choices,
required=True, required=True
), ),
absolute=forms.DateTimeField( absolute=forms.DateTimeField(
required=False required=False
@@ -507,7 +329,7 @@ class RelativeDateTimeField(forms.MultiValueField):
), ),
rel_mins_relationto=forms.ChoiceField( rel_mins_relationto=forms.ChoiceField(
choices=choices, choices=choices,
required=False, required=False
), ),
rel_days_timeofday=forms.TimeField( rel_days_timeofday=forms.TimeField(
required=False, required=False,
@@ -517,7 +339,7 @@ class RelativeDateTimeField(forms.MultiValueField):
), ),
rel_days_relationto=forms.ChoiceField( rel_days_relationto=forms.ChoiceField(
choices=choices, choices=choices,
required=False, required=False
), ),
rel_mins_relation=forms.ChoiceField( rel_mins_relation=forms.ChoiceField(
choices=BEFORE_AFTER_CHOICE, choices=BEFORE_AFTER_CHOICE,
@@ -528,9 +350,8 @@ class RelativeDateTimeField(forms.MultiValueField):
required=False required=False
), ),
) )
if 'widget' not in kwargs: if 'widget' not in kwargs:
kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=possible_choices) kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=choices)
kwargs.pop('max_length', 0) kwargs.pop('max_length', 0)
kwargs.pop('empty_value', 0) kwargs.pop('empty_value', 0)
super().__init__( super().__init__(
@@ -538,24 +359,21 @@ class RelativeDateTimeField(forms.MultiValueField):
) )
def set_event(self, event): def set_event(self, event):
possible_choices = copy.deepcopy(EVENT_BASE_CHOICES) self.widget.widgets[reldatetimeparts.indizes.rel_days_relationto].choices = [
if self.relative_to_order: (k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
possible_choices.extend(ORDER_BASE_CHOICES) ]
self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = [
possible_choices = possible_choices (k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
choices = _get_choices(possible_choices) ]
self.widget.widgets[reldatetimeparts.indizes.rel_days_relationto].choices = choices
self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = choices
def compress(self, data_list): def compress(self, data_list):
if not data_list: if not data_list:
return None return None
data = reldatetimeparts(*data_list) data = reldatetimeparts(*data_list)
if data.status == 'unset': if data.status == 'absolute':
return None
elif data.status == 'absolute':
return RelativeDateWrapper(data.absolute) return RelativeDateWrapper(data.absolute)
elif data.status == 'unset':
return None
elif data.status == 'relative_minutes': elif data.status == 'relative_minutes':
return RelativeDateWrapper(RelativeDate( return RelativeDateWrapper(RelativeDate(
days=0, days=0,
@@ -586,18 +404,6 @@ class RelativeDateTimeField(forms.MultiValueField):
raise ValidationError(self.error_messages['incomplete']) raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative_minutes' and (data.rel_mins_number is None or not data.rel_mins_relationto): elif data.status == 'relative_minutes' and (data.rel_mins_number is None or not data.rel_mins_relationto):
raise ValidationError(self.error_messages['incomplete']) raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative':
choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto)
if data.rel_days_relation == "before" and not choice.supports_before:
raise ValidationError(_('A relative date cannot be expressed as "before" for "{}"'.format(choice.text)))
elif data.status == 'relative' and data.rel_days_relation == "after" and not choice.supports_after:
raise ValidationError(_('A relative date cannot be expressed as "after" for "{}"'.format(choice.text)))
elif data.status == 'relative_minutes':
choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto)
if data.rel_days_relation == "before" and not choice.supports_before:
raise ValidationError(_('A relative time cannot be expressed as "before" for "{}"'.format(choice.text)))
elif data.rel_days_relation == "after" and not choice.supports_after:
raise ValidationError(_('A relative time cannot be expressed as "after" for "{}"'.format(choice.text)))
return super().clean(value) return super().clean(value)
@@ -618,20 +424,15 @@ class RelativeDateWidget(RelativeDateTimeWidget):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.status_choices = kwargs.pop('status_choices') self.status_choices = kwargs.pop('status_choices')
self.base_choices = kwargs.pop('base_choices') widgets = reldateparts(
widgets = cast(dict[str, Widget | type[Widget]], cast(object, reldateparts(
status=forms.RadioSelect(choices=self.status_choices), status=forms.RadioSelect(choices=self.status_choices),
absolute=forms.DateInput( absolute=forms.DateInput(
attrs={'class': 'datepickerfield'} attrs={'class': 'datepickerfield'}
), ),
rel_days_number=forms.NumberInput(), rel_days_number=forms.NumberInput(),
rel_days_relationto=OptionAttrsSelect( rel_days_relationto=forms.Select(choices=kwargs.pop('base_choices')),
choices=self.base_choices, rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
option_attrs=_get_choice_validation_obj(self.base_choices), )
attrs={'data-relative-choice': True},
),
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE, attrs={'data-relation-choice': True},),
)))
forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs) forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs)
def decompress(self, value): def decompress(self, value):
@@ -659,7 +460,7 @@ class RelativeDateWidget(RelativeDateTimeWidget):
status="relative", status="relative",
absolute=None, absolute=None,
rel_days_number=value.data.days, rel_days_number=value.data.days,
rel_days_relationto=value.data.key, rel_days_relationto=value.data.base_date_name,
rel_days_relation="after" if value.data.is_after else "before" rel_days_relation="after" if value.data.is_after else "before"
) )
@@ -673,15 +474,6 @@ class RelativeDateField(RelativeDateTimeField):
] ]
if not kwargs.get('required', True): if not kwargs.get('required', True):
status_choices.insert(0, ('unset', _('Not set'))) status_choices.insert(0, ('unset', _('Not set')))
self.relative_to_order = kwargs.pop('relative_to_order', False)
possible_choices = copy.deepcopy(EVENT_BASE_CHOICES)
if self.relative_to_order:
possible_choices.extend(ORDER_BASE_CHOICES)
choices = _get_choices(possible_choices)
fields = reldateparts( fields = reldateparts(
status=forms.ChoiceField( status=forms.ChoiceField(
choices=status_choices, choices=status_choices,
@@ -694,37 +486,33 @@ class RelativeDateField(RelativeDateTimeField):
required=False required=False
), ),
rel_days_relationto=forms.ChoiceField( rel_days_relationto=forms.ChoiceField(
choices=choices, choices=BASE_CHOICES,
required=False, required=False
), ),
rel_days_relation=forms.ChoiceField( rel_days_relation=forms.ChoiceField(
choices=BEFORE_AFTER_CHOICE, choices=BEFORE_AFTER_CHOICE,
required=False required=False
), ),
) )
if 'widget' not in kwargs: if 'widget' not in kwargs:
kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=possible_choices) kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=BASE_CHOICES)
forms.MultiValueField.__init__( forms.MultiValueField.__init__(
self, fields=fields, require_all_fields=False, *args, **kwargs self, fields=fields, require_all_fields=False, *args, **kwargs
) )
def set_event(self, event): def set_event(self, event):
choices = [ self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = [
(c.key, c.text) for c in EVENT_BASE_CHOICES if getattr(event, c.attribute, None) (k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
] ]
if self.relative_to_order:
choices += [(c.key, c.text) for c in ORDER_BASE_CHOICES]
self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = choices
def compress(self, data_list): def compress(self, data_list):
if not data_list: if not data_list:
return None return None
data = reldateparts(*data_list) data = reldateparts(*data_list)
if data.status == 'unset': if data.status == 'absolute':
return None
elif data.status == 'absolute':
return RelativeDateWrapper(data.absolute) return RelativeDateWrapper(data.absolute)
elif data.status == 'unset':
return None
else: else:
return RelativeDateWrapper(RelativeDate( return RelativeDateWrapper(RelativeDate(
days=data.rel_days_number, days=data.rel_days_number,
@@ -737,14 +525,8 @@ class RelativeDateField(RelativeDateTimeField):
data = reldateparts(*value) data = reldateparts(*value)
if data.status == 'absolute' and not data.absolute: if data.status == 'absolute' and not data.absolute:
raise ValidationError(self.error_messages['incomplete']) raise ValidationError(self.error_messages['incomplete'])
if data.status == 'relative': elif data.status == 'relative' and (data.rel_days_number is None or not data.rel_days_relationto):
choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto) raise ValidationError(self.error_messages['incomplete'])
if data.rel_days_number is None or not data.rel_days_relationto:
raise ValidationError(self.error_messages['incomplete'])
elif data.rel_days_relation == "before" and not choice.supports_before:
raise ValidationError(_("A relative date cannot be expressed as 'before' for '{}'".format(choice.text)))
elif data.rel_days_relation == "after" and not choice.supports_after:
raise ValidationError(_("A relative date cannot be expressed as 'after' for '{}'".format(choice.text)))
return forms.MultiValueField.clean(self, value) return forms.MultiValueField.clean(self, value)
-3
View File
@@ -245,9 +245,6 @@ def recv_classic(sender, **kwargs):
def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_used=False, force_invalidate=False, save=True): 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 gen = event.ticket_secret_generator
if gen.use_revocation_list and force_invalidate_if_revokation_list_used: if gen.use_revocation_list and force_invalidate_if_revokation_list_used:
force_invalidate = True force_invalidate = True
+1 -3
View File
@@ -22,7 +22,6 @@
import logging import logging
from decimal import Decimal from decimal import Decimal
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
@@ -378,13 +377,12 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
confirmation_code = get_random_string(8, allowed_chars="01234567890") confirmation_code = get_random_string(8, allowed_chars="01234567890")
mail( mail(
user.email, user.email,
subject=gettext('Confirm event cancellation and bulk refund'), subject=gettext('Bulk-refund confirmation'),
template='pretixbase/email/cancel_confirm.txt', template='pretixbase/email/cancel_confirm.txt',
context={ context={
"event": str(event), "event": str(event),
"amount": money_filter(refund_total, event.currency), "amount": money_filter(refund_total, event.currency),
"confirmation_code": confirmation_code, "confirmation_code": confirmation_code,
"instance": settings.PRETIX_INSTANCE_NAME,
}, },
locale=user.locale, locale=user.locale,
) )
+1 -17
View File
@@ -53,7 +53,6 @@ from django.utils.translation import (
) )
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
@@ -61,7 +60,7 @@ from pretix.base.models import (
Seat, SeatCategoryMapping, Voucher, Seat, SeatCategoryMapping, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import CheckoutSession, OrderFee from pretix.base.models.orders import OrderFee
from pretix.base.models.tax import TaxRule from pretix.base.models.tax import TaxRule
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers from pretix.base.services.checkin import _save_answers
@@ -472,16 +471,6 @@ class CartManager:
if term_last < time_machine_now(self.real_now_dt): if term_last < time_machine_now(self.real_now_dt):
raise CartError(error_messages['payment_ended']) raise CartError(error_messages['payment_ended'])
def _ensure_checkout_session(self):
CheckoutSession.objects.get_or_create(
event=self.event,
cart_id=self.cart_id,
defaults={
"sales_channel": self._sales_channel,
"testmode": self.event.testmode,
},
)
def _extend_expiry_of_valid_existing_positions(self): def _extend_expiry_of_valid_existing_positions(self):
# real_now_dt is initialized at CartManager instantiation, so it's slightly in the past. Add a small # real_now_dt is initialized at CartManager instantiation, so it's slightly in the past. Add a small
# delta to reduce risk of extending already expired CartPositions. # delta to reduce risk of extending already expired CartPositions.
@@ -927,8 +916,6 @@ class CartManager:
if custom_price > 99_999_999_999: if custom_price > 99_999_999_999:
raise CartError(error_messages['price_too_high']) raise CartError(error_messages['price_too_high'])
custom_price = round_decimal(custom_price, currency=self.event.currency)
op = self.AddOperation( op = self.AddOperation(
count=i['count'], count=i['count'],
item=item, item=item,
@@ -1051,8 +1038,6 @@ class CartManager:
if custom_price > 99_999_999_999: if custom_price > 99_999_999_999:
raise CartError(error_messages['price_too_high']) 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) # 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']]: for ca in current_addons[cp][a['item'], a['variation']]:
if ca.listed_price != listed_price: if ca.listed_price != listed_price:
@@ -1569,7 +1554,6 @@ class CartManager:
def commit(self): def commit(self):
self._check_presale_dates() self._check_presale_dates()
self._ensure_checkout_session()
self._check_max_cart_size() self._check_max_cart_size()
err = self._delete_out_of_timeframe() err = self._delete_out_of_timeframe()
+3 -5
View File
@@ -40,7 +40,7 @@ import dateutil
import dateutil.parser import dateutil.parser
from dateutil.tz import datetime_exists from dateutil.tz import datetime_exists
from django.core.files import File from django.core.files import File
from django.db import IntegrityError from django.db import IntegrityError, transaction
from django.db.models import ( from django.db.models import (
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min, BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, TextField, Value, When, OuterRef, Q, Subquery, TextField, Value, When,
@@ -59,7 +59,6 @@ from pretix.base.models import (
) )
from pretix.base.signals import checkin_created, periodic_task from pretix.base.signals import checkin_created, periodic_task
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.database import conditional_atomic
from pretix.helpers.jsonlogic import Logic from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
from pretix.helpers.jsonlogic_query import ( from pretix.helpers.jsonlogic_query import (
@@ -892,7 +891,6 @@ def _save_answers(op, answers, given_answers):
qa.answer = answer qa.answer = answer
qa.save(update_fields=['answer']) qa.save(update_fields=['answer'])
qa.options.clear() qa.options.clear()
return qa
written = False written = False
for q, a in given_answers.items(): for q, a in given_answers.items():
@@ -1045,10 +1043,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
if not simulate: if not simulate:
_save_answers(op, answers, given_answers) _save_answers(op, answers, given_answers)
with conditional_atomic(not simulate): with transaction.atomic():
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic # 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") opqs = OrderPosition.all.select_related("order", "item")
if type != Checkin.TYPE_EXIT and not simulate: if type != Checkin.TYPE_EXIT:
opqs = opqs.select_for_update(of=OF_SELF) opqs = opqs.select_for_update(of=OF_SELF)
op = opqs.get(pk=op.pk) op = opqs.get(pk=op.pk)
-5
View File
@@ -33,7 +33,6 @@ from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress from ..models import CachedFile, CartPosition, InvoiceAddress
from ..models.auth import UserKnownLoginSource from ..models.auth import UserKnownLoginSource
from ..models.orders import CheckoutSession
from ..signals import periodic_task from ..signals import periodic_task
@@ -44,10 +43,6 @@ def clean_cart_positions(sender, **kwargs):
cp.delete() cp.delete()
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True): for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
cp.delete() cp.delete()
for cs in CheckoutSession.objects.filter(created__lt=now() - timedelta(days=14)).exclude(
Exists(CartPosition.objects.filter(cart_id=OuterRef("cart_id")))
):
cs.delete()
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)): for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
ia.delete() ia.delete()
+2 -2
View File
@@ -29,7 +29,7 @@ from typing import List
from django.utils.functional import cached_property from django.utils.functional import cached_property
from pretix.base.models import CartPosition, ItemCategory, SalesChannel from pretix.base.models import CartPosition, ItemCategory, SalesChannel
from pretix.presale.productlist import prepare_item_list_for_shop from pretix.presale.views.event import get_grouped_items
class DummyCategory: class DummyCategory:
@@ -162,7 +162,7 @@ class CrossSellingService:
] ]
def _prepare_items(self, subevent, items_qs, discount_info): def _prepare_items(self, subevent, items_qs, discount_info):
items, _btn = prepare_item_list_for_shop( items, _btn = get_grouped_items(
self.event, self.event,
subevent=subevent, subevent=subevent,
voucher=None, voucher=None,
+1 -2
View File
@@ -340,13 +340,12 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter,
if schedule.owner.is_active: if schedule.owner.is_active:
mail( mail(
email=schedule.owner.email, email=schedule.owner.email,
subject=gettext('Scheduled export failed'), subject=gettext('Export failed'),
template='pretixbase/email/export_failed.txt', template='pretixbase/email/export_failed.txt',
context={ context={
'configuration_url': config_url, 'configuration_url': config_url,
'reason': msg, 'reason': msg,
'soft': soft, 'soft': soft,
'instance': settings.PRETIX_INSTANCE_NAME,
}, },
event=context if isinstance(context, Event) else None, event=context if isinstance(context, Event) else None,
organizer=context.organizer if isinstance(context, Event) else context, organizer=context.organizer if isinstance(context, Event) else context,
-3
View File
@@ -24,7 +24,6 @@ from typing import List, Optional
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -97,8 +96,6 @@ 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 :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. 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 tz = event.timezone
applicable_positions = [ applicable_positions = [
p for p in positions p for p in positions
+100 -160
View File
@@ -48,12 +48,12 @@ from celery.exceptions import MaxRetriesExceededError
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models, transaction from django.db import transaction
from django.db.models import ( from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Subquery, Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
Sum, Value, Value,
) )
from django.db.models.functions import Cast, Greatest from django.db.models.functions import Coalesce, Greatest
from django.db.transaction import get_connection from django.db.transaction import get_connection
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -71,10 +71,10 @@ from pretix.base.models import (
Membership, Order, OrderPayment, OrderPosition, Quota, Seat, Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
SeatCategoryMapping, User, Voucher, SeatCategoryMapping, User, Voucher,
) )
from pretix.base.models.event import Event_SettingsStore, SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import ( from pretix.base.models.orders import (
BlockedTicketSecret, CheckoutSession, InvoiceAddress, OrderFee, BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
OrderRefund, generate_secret, generate_secret,
) )
from pretix.base.models.organizer import SalesChannel, TeamAPIToken from pretix.base.models.organizer import SalesChannel, TeamAPIToken
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
@@ -110,7 +110,6 @@ from pretix.celery_app import app
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from pretix.helpers.periodic import minimum_interval from pretix.helpers.periodic import minimum_interval
from pretix.presale.productlist import prepare_item_list_for_shop
from pretix.testutils.middleware import debugflags_var from pretix.testutils.middleware import debugflags_var
@@ -791,12 +790,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
[op.seat for op in sorted_positions if op.seat], [op.seat for op in sorted_positions if op.seat],
shared_lock_objects=[event] 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() q_avail = Counter()
v_avail = Counter() v_avail = Counter()
@@ -1030,8 +1023,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime, def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None, payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None, address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None, customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None):
cart_id: str=None):
payments = [] payments = []
try: try:
@@ -1114,14 +1106,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
if meta_info: if meta_info:
for msg in meta_info.get('confirm_messages', []): for msg in meta_info.get('confirm_messages', []):
order.log_action('pretix.event.order.consent', data={'msg': msg}) order.log_action('pretix.event.order.consent', data={'msg': msg})
if cart_id:
try:
session = CheckoutSession.objects.get(event=event, cart_id=cart_id)
except CheckoutSession.DoesNotExist:
pass
else:
session.answers.update(order=order, checkoutsession=None)
session.delete()
order_placed.send(event, order=order, bulk=False) order_placed.send(event, order=order, bulk=False)
return order, payments return order, payments
@@ -1169,7 +1153,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str], def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web', email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None, cart_id: str=None): shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None):
for p in payment_requests: for p in payment_requests:
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']] p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
if not p['pprov']: if not p['pprov']:
@@ -1276,7 +1260,6 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
valid_if_pending=valid_if_pending, valid_if_pending=valid_if_pending,
api_meta=api_meta, api_meta=api_meta,
tax_rounding_mode=tax_rounding_mode, tax_rounding_mode=tax_rounding_mode,
cart_id=cart_id,
) )
try: try:
@@ -1504,104 +1487,83 @@ def send_expiry_warnings(sender, **kwargs):
@scopes_disabled() @scopes_disabled()
def send_download_reminders(sender, **kwargs): def send_download_reminders(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0, microsecond=0) today = now().replace(hour=0, minute=0, second=0, microsecond=0)
qs = Order.objects.annotate(
events = Event.objects.filter( first_date=Coalesce(
Q(has_subevents=False, date_from__gte=now()) | Min('all_positions__subevent__date_from'),
(Q(has_subevents=True) & Q(Exists( F('event__date_from')
SubEvent.objects.filter(event_id=OuterRef('id'), date_from__gte=now())
)))
).annotate(
reminder_days=Subquery(
Event_SettingsStore.objects.filter(
object=OuterRef('id'),
key='mail_days_download_reminder'
).exclude(
value="None"
).annotate(
val=Cast(F("value"), output_field=models.IntegerField()),
).values("val")
) )
).filter( ).filter(
reminder_days__isnull=False, download_reminder_sent=False,
).order_by() datetime__lte=now() - timedelta(hours=2),
first_date__gte=today,
).only(
'pk', 'event_id', 'sales_channel', 'datetime',
).order_by('event_id')
event_id = None
days = None
event = None
for event in events.iterator(chunk_size=10_000): for o in qs:
qs = event.orders.filter( if o.event_id != event_id:
download_reminder_sent=False, days = o.event.settings.get('mail_days_download_reminder', as_type=int)
datetime__lte=now() - timedelta(hours=2), event = o.event
) event_id = o.event_id
if event.has_subevents: if days is None:
qs = qs.annotate( continue
first_date=Min('all_positions__subevent__date_from')
).filter( if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
Q(first_date__gte=today) continue
)
else: reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
event_reminder_date = (event.date_from - timedelta(days=event.reminder_days)).replace(hour=0, minute=0, second=0, microsecond=0) if now() < reminder_date or o.datetime > reminder_date:
if now() < event_reminder_date: continue
with transaction.atomic():
o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
positions = list(o.positions_with_tickets)
if not positions:
continue continue
qs = qs.only( if not o.ticket_download_available:
'pk', 'event_id', 'sales_channel', 'datetime',
).order_by()
for o in qs:
if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
continue continue
if event.has_subevents: if o.status != Order.STATUS_PAID:
reminder_date = ((o.first_date or event.date_from) - timedelta(days=event.reminder_days)).replace(hour=0, minute=0, second=0, microsecond=0) if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
else:
reminder_date = event_reminder_date
if now() < reminder_date or o.datetime > reminder_date:
continue
with transaction.atomic():
o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
positions = list(o.positions_with_tickets)
if not positions:
continue continue
if not o.ticket_download_available: with language(o.locale, o.event.settings.region):
continue o.download_reminder_sent = True
o.save(update_fields=['download_reminder_sent'])
email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
if o.status != Order.STATUS_PAID: if event.settings.mail_send_download_reminder_attendee:
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending): for p in positions:
continue if p.subevent_id:
reminder_date = (p.subevent.date_from - timedelta(days=days)).replace(
with language(o.locale, o.event.settings.region): hour=0, minute=0, second=0, microsecond=0
o.download_reminder_sent = True )
o.save(update_fields=['download_reminder_sent']) if now() < reminder_date:
email_template = event.settings.mail_text_download_reminder continue
email_subject = event.settings.mail_subject_download_reminder if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_context = get_email_context(event=event, order=o) email_template = event.settings.mail_text_download_reminder_attendee
o.send_mail( email_subject = event.settings.mail_subject_download_reminder_attendee
email_subject, email_template, email_context, email_context = get_email_context(event=event, order=o, position=p)
'pretix.event.order.email.download_reminder_sent', o.send_mail(
attach_tickets=True email_subject, email_template, email_context,
) 'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
if event.settings.mail_send_download_reminder_attendee: )
for p in positions:
if p.subevent_id:
reminder_date = (p.subevent.date_from - timedelta(days=event.reminder_days)).replace(
hour=0, minute=0, second=0, microsecond=0
)
if now() < reminder_date:
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
def notify_user_changed_order(order, user=None, auth=None, invoices=[]): def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
@@ -1637,7 +1599,6 @@ class OrderChangeManager:
'seat_forbidden': gettext_lazy('The selected product does not allow to select a seat.'), '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.'), '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_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( '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 item of the product %(product)s.",
"You cannot select more than %(max)s items of the product %(product)s.", "You cannot select more than %(max)s items of the product %(product)s.",
@@ -1795,9 +1756,6 @@ class OrderChangeManager:
self._operations.append(self.RegenerateSecretOperation(position)) self._operations.append(self.RegenerateSecretOperation(position))
def change_ticket_secret(self, position: OrderPosition, new_secret: str): 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)) self._operations.append(self.ChangeSecretOperation(position, new_secret))
def change_valid_from(self, position: OrderPosition, new_value: datetime): def change_valid_from(self, position: OrderPosition, new_value: datetime):
@@ -1985,18 +1943,13 @@ class OrderChangeManager:
:param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values), :param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values),
``"count"``, and ``"price"``. ``"count"``, and ``"price"``.
:param limit_main_positions: By default, the method works on all positions of the order. If you set this to a :param limit_main_positions: By default, the method works on all methods 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 queryset or a list of positions, all other positions and their add-ons will be kept
untouched. untouched.
""" """
if self._operations: if self._operations:
raise ValueError("Setting addons should be the first/only operation") 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 # Prepare containers for min/max check of products
item_counts = Counter() item_counts = Counter()
for p in self.order.positions.all(): for p in self.order.positions.all():
@@ -2090,11 +2043,13 @@ class OrderChangeManager:
if not item.is_available() or (variation and not variation.is_available()): if not item.is_available() or (variation and not variation.is_available()):
raise OrderError(error_messages['unavailable']) raise OrderError(error_messages['unavailable'])
if not _allowed_on_order_sales_channel(item, self.order): if not item.all_sales_channels:
raise OrderError(error_messages['unavailable']) if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if variation and not _allowed_on_order_sales_channel(variation, self.order): if variation and not variation.all_sales_channels:
raise OrderError(error_messages['unavailable']) if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(): 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']) raise OrderError(error_messages['not_for_sale'])
@@ -2142,36 +2097,6 @@ class OrderChangeManager:
) )
item_counts[item] += 1 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 # Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()): for cp, al in list(current_addons.items()):
for k, v in al.items(): for k, v in al.items():
@@ -2181,7 +2106,22 @@ class OrderChangeManager:
for a in current_addons[cp][k][:current_num - input_num]: for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled: if a.canceled:
continue continue
if not _addon_is_available(a): 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:
# "Re-select" add-on # "Re-select" add-on
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1 selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
continue continue
@@ -3200,12 +3140,12 @@ class OrderChangeManager:
def perform_order(self, event: Event, payments: List[dict], positions: List[str], def perform_order(self, event: Event, payments: List[dict], positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None, email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None, sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None,
api_meta: dict=None, cart_id: str=None): api_meta: dict=None):
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
try: try:
return _perform_order(event, payments, positions, email, locale, address, meta_info, return _perform_order(event, payments, positions, email, locale, address, meta_info,
sales_channel, shown_total, customer, api_meta, cart_id=cart_id) sales_channel, shown_total, customer, api_meta)
except LockTimeoutException: except LockTimeoutException:
self.retry() self.retry()
except (MaxRetriesExceededError, LockTimeoutException): except (MaxRetriesExceededError, LockTimeoutException):
+3 -11
View File
@@ -801,10 +801,11 @@ def get_available_placeholders(event, base_parameters, rich=False):
return params return params
def prepare_sample_context_for_preview(placeholder_to_sample): def get_sample_context(event, context_parameters, rich=True):
context_dict = {} context_dict = {}
lbl = _('This value will be replaced based on dynamic parameters.') lbl = _('This value will be replaced based on dynamic parameters.')
for k, sample in placeholder_to_sample.items(): for k, v in get_available_placeholders(event, context_parameters, rich=rich).items():
sample = v.render_sample(event)
if isinstance(sample, PlainHtmlAlternativeString): if isinstance(sample, PlainHtmlAlternativeString):
context_dict[k] = PlainHtmlAlternativeString( context_dict[k] = PlainHtmlAlternativeString(
'<{el} class="placeholder" title="{title}">{plain}</{el}>'.format( '<{el} class="placeholder" title="{title}">{plain}</{el}>'.format(
@@ -829,12 +830,3 @@ def prepare_sample_context_for_preview(placeholder_to_sample):
escape(sample) escape(sample)
)) ))
return context_dict 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()
}
)
+1 -1
View File
@@ -193,7 +193,7 @@ class QuotaAvailability:
lock_name = '_'.join([str(p) for p in sorted([q.pk for q in quotas])]) lock_name = '_'.join([str(p) for p in sorted([q.pk for q in quotas])])
if rc.exists(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}'): if rc.exists(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}'):
return return
rc.set(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}', '1', ex=10) rc.setex(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}', '1', 10)
update = defaultdict(list) update = defaultdict(list)
for q in quotas: for q in quotas:
+3 -4
View File
@@ -44,7 +44,7 @@ from django.conf import settings
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import CachedFile, Event, User, cachedfile_name from pretix.base.models import CachedFile, Event, User, cachedfile_name
@@ -171,16 +171,15 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo
if user: if user:
with language(user.locale): with language(user.locale):
event_name = str(event.name)
mail( mail(
user.email, user.email,
gettext('Data shredding completed for %(event)s') % {'event': event_name}, _('Data shredding completed'),
'pretixbase/email/shred_completed.txt', 'pretixbase/email/shred_completed.txt',
{ {
'instance': settings.PRETIX_INSTANCE_NAME, 'instance': settings.PRETIX_INSTANCE_NAME,
'user': user, 'user': user,
'organizer': event.organizer.name, 'organizer': event.organizer.name,
'event': event_name, 'event': str(event.name),
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'), 'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
'shredders': ', '.join([str(s.verbose_name) for s in shredders]) 'shredders': ', '.join([str(s.verbose_name) for s in shredders])
}, },
+4 -13
View File
@@ -1276,7 +1276,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'write_permission': 'event.settings.invoicing:write', 'write_permission': 'event.settings.invoicing:write',
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Allow updating existing invoices"), label=_("Allow to update existing invoices"),
help_text=_("By default, invoices can never again be changed once they are issued. In most countries, we " help_text=_("By default, invoices can never again be changed once they are issued. In most countries, we "
"recommend to leave this option turned off and always issue a new invoice if a change needs " "recommend to leave this option turned off and always issue a new invoice if a change needs "
"to be made."), "to be made."),
@@ -1924,6 +1924,8 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Hide all past dates from calendar"), label=_("Hide all past dates from calendar"),
help_text=_("This option currently only affects the calendar of this event series, not the organizer-wide "
"calendar.")
) )
}, },
'allow_modifications': { 'allow_modifications': {
@@ -2284,17 +2286,6 @@ DEFAULTS = {
help_text=_("We'll show this publicly to allow attendees to contact you.") help_text=_("We'll show this publicly to allow attendees to contact you.")
) )
}, },
'contact_url': {
'default': None,
'type': str,
'serializer_class': serializers.URLField,
'form_class': forms.URLField,
'form_kwargs': dict(
label=_("Contact URL"),
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.")
)
},
'imprint_url': { 'imprint_url': {
'default': None, 'default': None,
'type': str, 'type': str,
@@ -2930,7 +2921,7 @@ Your {event} team""")) # noqa: W291
}, },
'mail_days_download_reminder': { 'mail_days_download_reminder': {
'type': int, 'type': int,
'default': None # when this default is changed, send_download_reminders needs to change 'default': None
}, },
'mail_send_download_reminder_attendee': { 'mail_send_download_reminder_attendee': {
'type': bool, 'type': bool,
+2 -3
View File
@@ -535,9 +535,8 @@ EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
event_live_issues = EventPluginSignal() event_live_issues = EventPluginSignal()
""" """
This signal is sent out to determine whether an event can be taken live. If you want to 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 an error message to display to the user (either prevent the event from going live, return a string that will be displayed to the user
as a SafeString containing HTML, or a string that will be HTML-escaped). If you don't, as the error message. If you don't, your receiver should return ``None``.
your receiver should return ``None``.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """
@@ -1,14 +1,10 @@
{% load i18n %} {% load i18n %}
{% trans "You requested to cancel an event that involves a large bulk refund:" %} {% trans "You have requested us to cancel an event which includes a larger bulk-refund:" %}
- {% trans "Event" %}: {{ event }} {% trans "Event" %}: {{ event }}
- {% trans "Estimated refund" %}: **{{ amount }}**
{% trans "To confirm, paste the following code into the cancellation form:" %} {% trans "Estimated refund amount" %}: **{{ amount }}**
{{ confirmation_code }} {% trans "Please confirm that you want to proceed by coping the following confirmation code into the cancellation form:" %}
{% blocktrans with instance=instance %}Don't share this code with anyone. The {{ instance }} team will never ask you for it.{% endblocktrans %} **{{ confirmation_code }}**
{% blocktrans with instance=instance %}Thanks,
The {{ instance }} Team{% endblocktrans %}
@@ -1,13 +1,12 @@
{% load i18n %} {% load i18n %}
{% trans "Your scheduled export failed." %} {% trans "Your export failed." %}
- {% trans "Reason" %}: {{ reason }} {% trans "Reason:" %} {{ reason }}
{% if not soft %}{% trans "If an export fails five times in a row, we'll stop sending it." %}{% endif %} {% if not soft %}
{% trans "If your export fails five times in a row, it will no longer be sent." %}
{% endif %}
{% trans "You can adjust or remove this export here:" %} {% trans "Configuration link:" %}
{{ configuration_url }} {{ configuration_url }}
{% blocktrans with instance=instance %}Thanks,
The {{ instance }} Team{% endblocktrans %}
@@ -52,13 +52,13 @@
<table cellpadding="20"><tr><td> <table cellpadding="20"><tr><td>
<![endif]--> <![endif]-->
<div class="content"> <div class="content">
{% trans "You're receiving this email based on your notification settings." %}<br> {% trans "You receive these emails based on your notification settings." %}<br>
<a href="{{ settings_url }}"> <a href="{{ settings_url }}">
{% trans "Manage settings" %} {% trans "Click here to view and change your notification settings" %}
</a> </a>
{% if disable_url %}<br> {% if disable_url %}<br>
<a href="{{ disable_url }}"> <a href="{{ disable_url }}">
{% trans "Disable all notifications" %} {% trans "Click here disable all notifications immediately." %}
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -1,21 +1,19 @@
{% load i18n %} {% load i18n %}
{{ notification.title }}{% if notification.detail %} {{ notification.title }}{% if notification.detail %}
{{ notification.detail }}{% endif %}{% if notification.url %} {{ notification.detail }}
{% endif %}{% if notification.url %}
{{ notification.url }}{% endif %}{% if notification.attributes %} {{ notification.url }}{% endif %}{% for attr in notification.attributes %}
{% for attr in notification.attributes %}- {{ attr.title }}: {{ attr.value }} {{ attr.title }}: {{ attr.value }}{% endfor %}{% for action in notification.actions %}
{% endfor %}{% endif %}{% for action in notification.actions %}
{{ action.label }}: {{ action.label }}
{{ action.url }}{% endfor %}
{{ action.url }}{% endfor %} {% trans "You receive these emails based on your notification settings." %}
{% trans "Click here to view and change your notification settings:" %}
--- {{ settings_url }}
{% if disable_url %}{% trans "Click here disable all notifications immediately:" %}
{% trans "You're receiving this email based on your notification settings." %} {{ disable_url }}
- {% trans "Manage settings" %}: {{ settings_url }}
{% if disable_url %}- {% trans "Disable all notifications" %}: {{ disable_url }}
{% endif %} {% endif %}
@@ -1,14 +1,17 @@
{% load i18n %}{% blocktrans %}Hello, {% load i18n %}
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
The following data shredding job has been completed: we hereby confirm that the following data shredding job has been completed:
- Organizer: {{ organizer }} Organizer: {{ organizer }}
- Event: {{ event }}
- Data selection: {{ shredders }}
- Start time: {{ start_time }}
Any data added to the event after the start time may not have been deleted. Event: {{ event }}
Thanks, Data selection: {{ shredders }}
The {{ instance }} Team
Start time: {{ start_time }} (new data added after this time might not have been deleted)
Best regards,
Your {{ instance }} team
{% endblocktrans %} {% endblocktrans %}
+2 -3
View File
@@ -22,7 +22,6 @@
import importlib import importlib
from django import template from django import template
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from pretix.base.models import Event from pretix.base.models import Event
@@ -45,7 +44,7 @@ def eventsignal(event: Event, signame: str, **kwargs):
_html = [] _html = []
for receiver, response in signal.send(event, **kwargs): for receiver, response in signal.send(event, **kwargs):
if response: if response:
_html.append(conditional_escape(response)) _html.append(response)
return mark_safe("".join(_html)) return mark_safe("".join(_html))
@@ -64,5 +63,5 @@ def signal(signame: str, request, **kwargs):
_html = [] _html = []
for receiver, response in signal.send(request, **kwargs): for receiver, response in signal.send(request, **kwargs):
if response: if response:
_html.append(conditional_escape(response)) _html.append(response)
return mark_safe("".join(_html)) return mark_safe("".join(_html))
-18
View File
@@ -26,8 +26,6 @@ from babel.numbers import format_currency
from django import template from django import template
from django.conf import settings from django.conf import settings
from django.template.defaultfilters import floatformat 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 from pretix.base.i18n import get_babel_locale
@@ -84,19 +82,3 @@ def money_numberfield_filter(value: Decimal, arg=''):
places = settings.CURRENCY_PLACES.get(arg, 2) places = settings.CURRENCY_PLACES.get(arg, 2)
return str(value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)) 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,
)
)
+6 -14
View File
@@ -19,10 +19,12 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import json import json
import logging import logging
import pathlib import pathlib
import re import re
import secrets
from urllib.parse import urljoin from urllib.parse import urljoin
from urllib.request import urlopen from urllib.request import urlopen
@@ -31,10 +33,6 @@ from django import template
from django.conf import settings from django.conf import settings
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from pretix.base.middleware import (
add_to_response_csp_via_request, calculate_csp_hash,
)
register = template.Library() register = template.Library()
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
_MANIFEST = {} _MANIFEST = {}
@@ -236,16 +234,10 @@ def vite_importmap(context):
if not imports: if not imports:
return "" return ""
json_string = json.dumps({"imports": imports}) # Generate a nonce and store it on the request so the CSP middleware can allow it
nonce = secrets.token_urlsafe(16)
# 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') request = context.get('request')
if request: if request:
csp_hash = calculate_csp_hash(json_string) request.csp_nonce = nonce
add_to_response_csp_via_request(request, {
'style-src': [csp_hash],
'script-src': [csp_hash],
})
return f'<script type="importmap">{json_string}</script>' return f'<script type="importmap" nonce="{nonce}">{json.dumps({"imports": imports})}</script>'
+1 -1
View File
@@ -27,11 +27,11 @@ from django.template import TemplateDoesNotExist, loader
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.functional import Promise from django.utils.functional import Promise
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.decorators.csrf import requires_csrf_token
from sentry_sdk import last_event_id from sentry_sdk import last_event_id
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.middleware import get_language_from_request from pretix.base.middleware import get_language_from_request
from pretix.multidomain.middlewares import requires_csrf_token
def csrf_failure(request, reason=""): def csrf_failure(request, reason=""):
+72 -112
View File
@@ -32,21 +32,18 @@ from django.utils.functional import cached_property
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from pretix.base.forms.questions import ( from pretix.base.forms.questions import (
BaseInvoiceAddressForm, BaseInvoiceNameForm, OrderLevelQuestionsForm, BaseInvoiceAddressForm, BaseInvoiceNameForm, BaseQuestionsForm,
TicketLevelQuestionsForm,
) )
from pretix.base.models import ( from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer, CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
QuestionOption, QuestionOption,
) )
from pretix.base.models.customers import AttendeeProfile from pretix.base.models.customers import AttendeeProfile
from pretix.base.models.orders import CheckoutSession, Order
from pretix.presale.signals import contact_form_fields_overrides from pretix.presale.signals import contact_form_fields_overrides
class BaseQuestionsViewMixin: class BaseQuestionsViewMixin:
order_form_class = OrderLevelQuestionsForm form_class = BaseQuestionsForm
orderposition_form_class = TicketLevelQuestionsForm
all_optional = False all_optional = False
@cached_property @cached_property
@@ -59,28 +56,6 @@ class BaseQuestionsViewMixin:
def question_form_kwargs(self, cr): def question_form_kwargs(self, cr):
return {} return {}
@property
def order_question_container(self):
raise NotImplementedError()
@cached_property
def order_questions_form(self):
container = self.order_question_container
if container is None:
return None
kwargs = {} # self.question_form_kwargs(cr)
form = self.order_form_class(
event=self.request.event,
prefix='order',
request=self.request,
container=container,
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 @cached_property
def forms(self): def forms(self):
""" """
@@ -94,17 +69,15 @@ class BaseQuestionsViewMixin:
orderpos = cr if isinstance(cr, OrderPosition) else None orderpos = cr if isinstance(cr, OrderPosition) else None
kwargs = self.question_form_kwargs(cr) kwargs = self.question_form_kwargs(cr)
form = self.orderposition_form_class( form = self.form_class(event=self.request.event,
event=self.request.event, prefix=cr.id,
prefix=cr.id, request=self.request,
request=self.request, cartpos=cartpos,
cartpos=cartpos, orderpos=orderpos,
orderpos=orderpos, all_optional=self.all_optional,
all_optional=self.all_optional, data=(self.request.POST if self.request.method == 'POST' else None),
data=(self.request.POST if self.request.method == 'POST' else None), files=(self.request.FILES if self.request.method == 'POST' else None),
files=(self.request.FILES if self.request.method == 'POST' else None), **kwargs)
**kwargs
)
form.pos = cartpos or orderpos form.pos = cartpos or orderpos
form.show_copy_answers_to_addon_button = form.pos.addon_to and ( form.show_copy_answers_to_addon_button = form.pos.addon_to and (
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
@@ -157,38 +130,8 @@ class BaseQuestionsViewMixin:
def save(self): def save(self):
failed = False failed = False
if self.order_questions_form:
if not self.order_questions_form.is_valid():
failed = True
else:
checkoutsession = self.order_question_container if isinstance(self.order_question_container, CheckoutSession) else None
order = self.order_question_container if isinstance(self.order_question_container, Order) else None
for k, v in self.order_questions_form.cleaned_data.items():
if k.startswith('question_'):
field = self.order_questions_form.fields[k]
if hasattr(field, 'answer'):
# We already have a cached answer object, so we don't
# have to create a new one
if v == '' or v is None or (isinstance(field, forms.FileField) and v is False) \
or (isinstance(v, QuerySet) and not v.exists()):
if field.answer.file:
field.answer.file.delete()
field.answer.delete()
else:
self._save_to_answer(field, field.answer, v)
field.answer.save()
elif v != '' and v is not None:
self._upsert_answer(
field, v,
checkoutsession=checkoutsession,
order=order,
question=field.question,
)
for form in self.forms: for form in self.forms:
meta_info = form.pos.meta_info_data meta_info = form.pos.meta_info_data
cartposition = form.pos if isinstance(form.pos, CartPosition) else None
orderposition = form.pos if isinstance(form.pos, OrderPosition) else None
# Every form represents a CartPosition or OrderPosition with questions attached # Every form represents a CartPosition or OrderPosition with questions attached
if not form.is_valid(): if not form.is_valid():
failed = True failed = True
@@ -197,8 +140,10 @@ class BaseQuestionsViewMixin:
prof = AttendeeProfile.objects.filter( prof = AttendeeProfile.objects.filter(
customer=self.cart_customer, pk=form.cleaned_data.get('saved_id') customer=self.cart_customer, pk=form.cleaned_data.get('saved_id')
).first() or AttendeeProfile(customer=getattr(self, 'cart_customer', None)) ).first() or AttendeeProfile(customer=getattr(self, 'cart_customer', None))
answers_key_to_index = {a.get('field_name'): i for i, a in enumerate(prof.answers)}
else: else:
prof = AttendeeProfile(customer=getattr(self, 'cart_customer', None)) prof = AttendeeProfile(customer=getattr(self, 'cart_customer', None))
answers_key_to_index = {}
# This form was correctly filled, so we store the data as # This form was correctly filled, so we store the data as
# answers to the questions / in the CartPosition object # answers to the questions / in the CartPosition object
@@ -236,19 +181,64 @@ class BaseQuestionsViewMixin:
else: else:
self._save_to_answer(field, field.answer, v) self._save_to_answer(field, field.answer, v)
field.answer.save() field.answer.save()
if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
answer_dict = self._build_answer_dict(field, field.answer, k) answer_value = {o.identifier: str(o) for o in field.answer.options.all()}
prof.store_answer(answer_dict) elif isinstance(field, forms.BooleanField):
answer_value = bool(field.answer.answer)
else:
answer_value = str(field.answer.answer)
answer_dict = {
'field_name': k,
'field_label': str(field.label),
'value': answer_value,
'question_type': field.question.type,
'question_identifier': field.question.identifier,
}
if k in answers_key_to_index:
prof.answers[answers_key_to_index[k]] = answer_dict
else:
prof.answers.append(answer_dict)
elif v != '' and v is not None: elif v != '' and v is not None:
answer = self._upsert_answer( answer = QuestionAnswer(
field, v, cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
cartposition=cartposition, orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
orderposition=orderposition,
question=field.question, question=field.question,
) )
try:
self._save_to_answer(field, answer, v)
answer.save()
except IntegrityError:
# Since we prefill ``field.answer`` at form creation time, there's a possible race condition
# here if the users submits their save request a second time while the first one is still running,
# thus leading to duplicate QuestionAnswer objects. Since Django doesn't support UPSERT, the "proper"
# fix would be a transaction with select_for_update(), or at least fetching using get_or_create here
# again. However, both of these approaches have a significant performance overhead for *all* requests,
# while the issue happens very very rarely. So we opt for just catching the error and retrying properly.
answer = QuestionAnswer.objects.get(
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
question=field.question,
)
self._save_to_answer(field, answer, v)
answer.save()
answer_dict = self._build_answer_dict(field, answer, k) if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
prof.store_answer(answer_dict) answer_value = {o.identifier: str(o) for o in answer.options.all()}
elif isinstance(field, forms.BooleanField):
answer_value = bool(answer.answer)
else:
answer_value = str(answer.answer)
answer_dict = {
'field_name': k,
'field_label': str(field.label),
'value': answer_value,
'question_type': field.question.type,
'question_identifier': field.question.identifier,
}
if k in answers_key_to_index:
prof.answers[answers_key_to_index[k]] = answer_dict
else:
prof.answers.append(answer_dict)
else: else:
field = form.fields[k] field = form.fields[k]
@@ -267,7 +257,10 @@ class BaseQuestionsViewMixin:
'question_type': None, 'question_type': None,
'question_identifier': None, 'question_identifier': None,
} }
prof.store_answer(answer_dict) if k in answers_key_to_index:
prof.answers[answers_key_to_index[k]] = answer_dict
else:
prof.answers.append(answer_dict)
form.pos.meta_info = json.dumps(meta_info) form.pos.meta_info = json.dumps(meta_info)
form.pos.save() form.pos.save()
@@ -278,23 +271,6 @@ class BaseQuestionsViewMixin:
return not failed return not failed
def _upsert_answer(self, field, v, **answer_kwargs):
answer = QuestionAnswer(**answer_kwargs)
try:
self._save_to_answer(field, answer, v)
answer.save()
except IntegrityError:
# Since we prefill ``field.answer`` at form creation time, there's a possible race condition
# here if the users submits their save request a second time while the first one is still running,
# thus leading to duplicate QuestionAnswer objects. Since Django doesn't support UPSERT, the "proper"
# fix would be a transaction with select_for_update(), or at least fetching using get_or_create here
# again. However, both of these approaches have a significant performance overhead for *all* requests,
# while the issue happens very very rarely. So we opt for just catching the error and retrying properly.
answer = QuestionAnswer.objects.get(**answer_kwargs)
self._save_to_answer(field, answer, v)
answer.save()
return answer
def _save_to_answer(self, field, answer, value): def _save_to_answer(self, field, answer, value):
if isinstance(field, forms.ModelMultipleChoiceField): if isinstance(field, forms.ModelMultipleChoiceField):
answstr = ", ".join([str(o) for o in value]) answstr = ", ".join([str(o) for o in value])
@@ -318,21 +294,6 @@ class BaseQuestionsViewMixin:
else: else:
answer.answer = value answer.answer = value
def _build_answer_dict(self, field, answer, k):
if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
answer_value = {o.identifier: str(o) for o in answer.options.all()}
elif isinstance(field, forms.BooleanField):
answer_value = bool(answer.answer)
else:
answer_value = str(answer.answer)
return {
'field_name': k,
'field_label': str(field.label),
'value': answer_value,
'question_type': field.question.type,
'question_identifier': field.question.identifier,
}
class OrderQuestionsViewMixin(BaseQuestionsViewMixin): class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
invoice_form_class = BaseInvoiceAddressForm invoice_form_class = BaseInvoiceAddressForm
@@ -348,7 +309,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
def positions(self): def positions(self):
qqs = self.request.event.questions.all() qqs = self.request.event.questions.all()
if self.only_user_visible: if self.only_user_visible:
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION) qqs = qqs.filter(ask_during_checkin=False, hidden=False)
return list(self.order.positions.select_related( return list(self.order.positions.select_related(
'item', 'variation' 'item', 'variation'
).prefetch_related( ).prefetch_related(
@@ -436,7 +397,6 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['order'] = self.order ctx['order'] = self.order
ctx['order_questions_form'] = self.order_questions_form
ctx['formgroups'] = self.formdict.items() ctx['formgroups'] = self.formdict.items()
ctx['invoice_form'] = self.invoice_form ctx['invoice_form'] = self.invoice_form
ctx['invoice_address_asked'] = self.address_asked ctx['invoice_address_asked'] = self.address_asked
+1 -43
View File
@@ -19,56 +19,14 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging
import os import os
from celery import Celery, signals from celery import Celery
from django.dispatch import receiver
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
logger = logging.getLogger(__name__)
from django.conf import settings from django.conf import settings
app = Celery('pretix') app = Celery('pretix')
app.config_from_object('django.conf:settings', namespace='CELERY') app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
@receiver(signals.before_task_publish)
def on_before_task_publish(sender, body, exchange, routing_key, headers, properties, declare, retry_policy, **kwargs):
from pretix.helpers.logs import local
trace = getattr(local, 'trace', [])
request_id = getattr(local, 'request_id', None)
if request_id:
trace.append(request_id)
headers["X-Pretix-Trace"] = " ".join(trace)
@receiver(signals.task_received)
def on_task_received(sender, request, **kwargs):
trace = request._request_dict.get("X-Pretix-Trace")
if trace:
logger.info(f"Task {request.id} has trace {trace}")
@receiver(signals.task_prerun)
def on_task_prerun(sender, task_id, task, **kwargs):
from pretix.helpers.logs import local
local.request_id = task_id
if task.request.headers and "X-Pretix-Trace" in task.request.headers:
local.trace = task.request.headers["X-Pretix-Trace"].split(" ")
else:
local.trace = []
local.trace.append(task_id)
@receiver(signals.task_postrun)
def on_task_postrun(sender, task_id, task, **kwargs):
from pretix.helpers.logs import local
local.request_id = None
local.trace = []
+10 -16
View File
@@ -197,10 +197,10 @@ class EventWizardBasicsForm(I18nModelForm):
'presale_end': SplitDateTimeField, 'presale_end': SplitDateTimeField,
} }
widgets = { widgets = {
'date_from': SplitDateTimePickerWidget(without_seconds=True), 'date_from': SplitDateTimePickerWidget(),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-date_from_0'}, without_seconds=True), 'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-date_from_0'}),
'presale_start': SplitDateTimePickerWidget(without_seconds=True), 'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-presale_start_0'}, without_seconds=True), 'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-presale_start_0'}),
'slug': SlugWidget, 'slug': SlugWidget,
} }
@@ -521,11 +521,11 @@ class EventUpdateForm(I18nModelForm):
'limit_sales_channels': SafeModelMultipleChoiceField, 'limit_sales_channels': SafeModelMultipleChoiceField,
} }
widgets = { widgets = {
'date_from': SplitDateTimePickerWidget(without_seconds=True), 'date_from': SplitDateTimePickerWidget(),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True), 'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}, without_seconds=True), 'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
'presale_start': SplitDateTimePickerWidget(without_seconds=True), 'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}, without_seconds=True), 'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
} }
@@ -625,7 +625,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'max_items_per_order', 'max_items_per_order',
'reservation_time', 'reservation_time',
'contact_mail', 'contact_mail',
'contact_url',
'show_variations_expanded', 'show_variations_expanded',
'hide_sold_out', 'hide_sold_out',
'meta_noindex', 'meta_noindex',
@@ -672,11 +671,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
base_context = { base_context = {
'frontpage_text': ['event'], 'frontpage_text': ['event'],
'presale_has_ended_text': ['event'],
'voucher_explanation_text': ['event'],
'banner_text': ['event'],
'banner_text_bottom': ['event'],
'event_info_text': ['event'],
} }
def _resolve_virtual_keys_input(self, data, prefix=''): def _resolve_virtual_keys_input(self, data, prefix=''):
@@ -1679,7 +1673,7 @@ class CountriesAndEUAndStates(CountriesAndEU):
class TaxRuleLineForm(I18nForm): class TaxRuleLineForm(I18nForm):
country = LazyTypedChoiceField( country = LazyTypedChoiceField(
choices=lazy(lambda: CountriesAndEUAndStates(), CountriesAndEUAndStates), choices=CountriesAndEUAndStates(),
required=False required=False
) )
address_type = forms.ChoiceField( address_type = forms.ChoiceField(
+2 -91
View File
@@ -39,7 +39,6 @@ from urllib.parse import urlencode
from django import forms from django import forms
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import ( from django.db.models import (
Count, Exists, F, Max, Model, OrderBy, OuterRef, Q, QuerySet, Count, Exists, F, Max, Model, OrderBy, OuterRef, Q, QuerySet,
) )
@@ -60,7 +59,7 @@ from pretix.base.models import (
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition, Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
OrderRefund, Organizer, OutgoingMail, Question, QuestionAnswer, Quota, OrderRefund, Organizer, OutgoingMail, Question, QuestionAnswer, Quota,
SalesChannel, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, SalesChannel, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite,
User, Voucher, Voucher,
) )
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
from pretix.base.timeframes import ( from pretix.base.timeframes import (
@@ -771,7 +770,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
) )
elif q.type == Question.TYPE_TIME: elif q.type == Question.TYPE_TIME:
self.fields[fname] = forms.TimeField( self.fields[fname] = forms.TimeField(
widget=TimePickerWidget(without_seconds=True), widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
help_text=_('Exact matches only'), help_text=_('Exact matches only'),
**kwargs, **kwargs,
) )
@@ -3008,91 +3007,3 @@ class OutgoingMailFilterForm(FilterForm):
qs = qs.order_by("-created", "-pk") qs = qs.order_by("-created", "-pk")
return qs 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
+7 -15
View File
@@ -153,19 +153,11 @@ class QuestionForm(I18nModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance.container_type == Question.ContainerType.ORDERPOSITION: self.fields['items'].queryset = self.instance.event.items.all()
self.fields['items'].queryset = self.instance.event.items.all() self.fields['items'].required = True
self.fields['items'].required = True
else:
del self.fields['items']
del self.fields['ask_during_checkin']
del self.fields['show_during_checkin']
del self.fields['print_on_invoice']
self.fields['dependency_question'].widget.attrs['data-container-type'] = self.instance.container_type
self.fields['dependency_question'].queryset = self.instance.event.questions.filter( self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE), type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
ask_during_checkin=False, ask_during_checkin=False
container_type=self.instance.container_type,
) )
if self.instance.pk: if self.instance.pk:
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude( self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
@@ -254,8 +246,8 @@ class QuestionForm(I18nModelForm):
'valid_string_length_max', 'valid_string_length_max',
] ]
widgets = { widgets = {
'valid_datetime_min': SplitDateTimePickerWidget(without_seconds=True), 'valid_datetime_min': SplitDateTimePickerWidget(),
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True), 'valid_datetime_max': SplitDateTimePickerWidget(),
'valid_date_min': DatePickerWidget(), 'valid_date_min': DatePickerWidget(),
'valid_date_max': DatePickerWidget(), 'valid_date_max': DatePickerWidget(),
'items': forms.CheckboxSelectMultiple( 'items': forms.CheckboxSelectMultiple(
@@ -1435,6 +1427,6 @@ class ItemProgramTimeForm(I18nModelForm):
'end': forms.SplitDateTimeField, 'end': forms.SplitDateTimeField,
} }
widgets = { widgets = {
'start': SplitDateTimePickerWidget(without_seconds=True), 'start': SplitDateTimePickerWidget(),
'end': SplitDateTimePickerWidget(without_seconds=True), 'end': SplitDateTimePickerWidget(),
} }
+3 -1
View File
@@ -562,7 +562,9 @@ class OrderPositionChangeForm(forms.Form):
if instance.addon_to_id: if instance.addon_to_id:
del self.fields['operation_split'] del self.fields['operation_split']
if not instance.seat and not instance._seat_allowed: if not instance.seat and not (
instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists()
):
del self.fields['seat'] del self.fields['seat']
choices = [ choices = [
-1
View File
@@ -604,7 +604,6 @@ class OrganizerSettingsForm(SettingsForm):
'customer_accounts_require_login_for_order_access', 'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed', 'invoice_regenerate_allowed',
'contact_mail', 'contact_mail',
'contact_url',
'imprint_url', 'imprint_url',
'organizer_info_text', 'organizer_info_text',
'event_list_type', 'event_list_type',
+12 -19
View File
@@ -39,7 +39,6 @@ from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.rrule import RRuleForm 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 from pretix.helpers.money import change_decimal_field
@@ -81,11 +80,11 @@ class SubEventForm(I18nModelForm):
'presale_end': SplitDateTimeField, 'presale_end': SplitDateTimeField,
} }
widgets = { widgets = {
'date_from': SplitDateTimePickerWidget(without_seconds=True), 'date_from': SplitDateTimePickerWidget(),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True), 'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True), 'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'presale_start': SplitDateTimePickerWidget(without_seconds=True), 'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}, without_seconds=True), 'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
} }
@@ -94,14 +93,14 @@ class SubEventBulkForm(SubEventForm):
label=_('Start of presale'), label=_('Start of presale'),
help_text=_('Optional. No products will be sold before this date.'), help_text=_('Optional. No products will be sold before this date.'),
required=False, required=False,
limit_choices=('event__date_from', 'event__date_to'), limit_choices=('date_from', 'date_to'),
) )
rel_presale_end = RelativeDateTimeField( rel_presale_end = RelativeDateTimeField(
label=_('End of presale'), label=_('End of presale'),
help_text=_('Optional. No products will be sold after this date. If you do not set this value, the presale ' help_text=_('Optional. No products will be sold after this date. If you do not set this value, the presale '
'will end after the end date of your event.'), 'will end after the end date of your event.'),
required=False, required=False,
limit_choices=('event__date_from', 'event__date_to'), limit_choices=('date_from', 'date_to'),
) )
skip_if_overlap = forms.BooleanField( skip_if_overlap = forms.BooleanField(
label=pgettext_lazy('subevent', 'Skip dates that overlap with any existing date'), label=pgettext_lazy('subevent', 'Skip dates that overlap with any existing date'),
@@ -163,7 +162,7 @@ class SubEventBulkEditForm(I18nModelForm):
self.fields[k + '_time'] = forms.TimeField( self.fields[k + '_time'] = forms.TimeField(
label=self._meta.model._meta.get_field(k).verbose_name, label=self._meta.model._meta.get_field(k).verbose_name,
help_text=self._meta.model._meta.get_field(k).help_text, help_text=self._meta.model._meta.get_field(k).help_text,
widget=TimePickerWidget(without_seconds=True), widget=TimePickerWidget(),
required=False, required=False,
) )
@@ -333,12 +332,12 @@ class BulkSubEventItemForm(SubEventItemForm):
rel_available_from = RelativeDateTimeField( rel_available_from = RelativeDateTimeField(
label=_('Available from'), label=_('Available from'),
required=False, required=False,
limit_choices=('event__date_from', 'event__date_to'), limit_choices=('date_from', 'date_to'),
) )
rel_available_until = RelativeDateTimeField( rel_available_until = RelativeDateTimeField(
label=_('Available until'), label=_('Available until'),
required=False, required=False,
limit_choices=('event__date_from', 'event__date_to'), limit_choices=('date_from', 'date_to'),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -355,12 +354,12 @@ class BulkSubEventItemVariationForm(SubEventItemVariationForm):
rel_available_from = RelativeDateTimeField( rel_available_from = RelativeDateTimeField(
label=_('Available from'), label=_('Available from'),
required=False, required=False,
limit_choices=('event__date_from', 'event__date_to'), limit_choices=('date_from', 'date_to'),
) )
rel_available_until = RelativeDateTimeField( rel_available_until = RelativeDateTimeField(
label=_('Available_until'), label=_('Available_until'),
required=False, required=False,
limit_choices=('event__date_from', 'event__date_to'), limit_choices=('date_from', 'date_to'),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -507,12 +506,6 @@ class TimeForm(forms.Form):
required=False 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( TimeFormSet = formset_factory(
TimeForm, TimeForm,
+51 -280
View File
@@ -32,20 +32,16 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # 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. # License for the specific language governing permissions and limitations under the License.
import copy
import csv import csv
from collections import Counter, namedtuple from collections import namedtuple
from io import StringIO from io import StringIO
from django import forms from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import EmailValidator from django.core.validators import EmailValidator
from django.db.models import Count, F, Max
from django.db.models.functions import Upper from django.db.models.functions import Upper
from django.forms.utils import ErrorDict
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField from django_scopes.forms import SafeModelChoiceField
from pretix.base.email import get_available_placeholders from pretix.base.email import get_available_placeholders
@@ -54,10 +50,7 @@ from pretix.base.forms import (
) )
from pretix.base.forms.widgets import format_placeholders_help_text from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Item, ItemVariation, Quota, SubEvent, Voucher from pretix.base.models import Item, 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 import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
from pretix.control.signals import voucher_form_validation from pretix.control.signals import voucher_form_validation
@@ -112,22 +105,15 @@ class VoucherForm(I18nModelForm):
except Item.DoesNotExist: except Item.DoesNotExist:
pass pass
super().__init__(*args, **kwargs) 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={ if instance.event.has_subevents:
'event': self.event.slug, self.fields['subevent'].queryset = instance.event.subevents.all()
'organizer': self.event.organizer.slug,
})
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2( self.fields['subevent'].widget = Select2(
attrs={ attrs={
'data-model-select2': 'event', 'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={ 'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug, 'event': instance.event.slug,
'organizer': self.event.organizer.slug, 'organizer': instance.event.organizer.slug,
}), }),
} }
) )
@@ -137,19 +123,18 @@ class VoucherForm(I18nModelForm):
del self.fields['subevent'] del self.fields['subevent']
choices = [] choices = []
prefix = (self.prefix + '-') if self.prefix else '' if 'itemvar' in initial or (self.data and 'itemvar' in self.data):
if 'itemvar' in initial or (self.data and prefix + 'itemvar' in self.data): iv = self.data.get('itemvar') or initial.get('itemvar', '')
iv = self.data.get(prefix + 'itemvar', '') or initial.get('itemvar', '') or ''
if iv.startswith('q-'): if iv.startswith('q-'):
q = self.event.quotas.get(pk=iv[2:]) q = self.instance.event.quotas.get(pk=iv[2:])
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q))) choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
elif '-' in iv: elif '-' in iv:
itemid, varid = iv.split('-') itemid, varid = iv.split('-')
i = self.event.items.get(pk=itemid) i = self.instance.event.items.get(pk=itemid)
v = i.variations.get(pk=varid) v = i.variations.get(pk=varid)
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value))) choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value)))
elif iv: elif iv:
i = self.event.items.get(pk=iv) i = self.instance.event.items.get(pk=iv)
if i.variations.exists(): if i.variations.exists():
choices.append((str(i.pk), _('{product} Any variation').format(product=i))) choices.append((str(i.pk), _('{product} Any variation').format(product=i)))
else: else:
@@ -160,8 +145,8 @@ class VoucherForm(I18nModelForm):
attrs={ attrs={
'data-model-select2': 'generic', 'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={ 'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={
'event': self.event.slug, 'event': instance.event.slug,
'organizer': self.event.organizer.slug, 'organizer': instance.event.organizer.slug,
}), }),
'data-placeholder': _('All products') 'data-placeholder': _('All products')
} }
@@ -169,7 +154,7 @@ class VoucherForm(I18nModelForm):
self.fields['itemvar'].required = False self.fields['itemvar'].required = False
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
if self.event.seating_plan or self.event.subevents.filter(seating_plan__isnull=False).exists(): if self.instance.event.seating_plan or self.instance.event.subevents.filter(seating_plan__isnull=False).exists():
self.fields['seat'] = forms.CharField( self.fields['seat'] = forms.CharField(
label=_("Specific seat ID"), label=_("Specific seat ID"),
max_length=255, max_length=255,
@@ -179,45 +164,40 @@ class VoucherForm(I18nModelForm):
help_text=str(self.instance.seat) if self.instance.seat else '', 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): def clean(self):
data = super().clean() data = super().clean()
if not self._errors: if not self._errors:
self.instance.item, self.instance.variation, self.instance.quota = self.parse_itemvar(self.data) 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."))
if 'codes' in data: if 'codes' in data:
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a] data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
@@ -229,7 +209,7 @@ class VoucherForm(I18nModelForm):
try: try:
Voucher.clean_item_properties( Voucher.clean_item_properties(
data, self.event, data, self.instance.event,
self.instance.quota, self.instance.item, self.instance.variation, self.instance.quota, self.instance.item, self.instance.variation,
seats_given=data.get('seat') or data.get('seats'), seats_given=data.get('seat') or data.get('seats'),
block_quota=data.get('block_quota') block_quota=data.get('block_quota')
@@ -249,7 +229,7 @@ class VoucherForm(I18nModelForm):
try: try:
Voucher.clean_subevent( Voucher.clean_subevent(
data, self.event data, self.instance.event
) )
except ValidationError as e: except ValidationError as e:
raise ValidationError({"subevent": e.message}) raise ValidationError({"subevent": e.message})
@@ -265,19 +245,19 @@ class VoucherForm(I18nModelForm):
if check_quota: if check_quota:
Voucher.clean_quota_check( Voucher.clean_quota_check(
data, cnt, self.initial_instance_data, data, cnt, self.initial_instance_data,
self.event, self.instance.quota, self.instance.item, self.instance.variation self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
) )
Voucher.clean_voucher_code(data, self.event, self.instance.pk) Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
if 'seat' in self.fields: if 'seat' in self.fields:
if data.get('seat'): if data.get('seat'):
self.instance.seat = Voucher.clean_seat_id( self.instance.seat = Voucher.clean_seat_id(
data, self.instance.item, self.instance.quota, self.event, self.instance.pk data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
) )
self.instance.item = self.instance.seat.product self.instance.item = self.instance.seat.product
else: else:
self.instance.seat = None self.instance.seat = None
voucher_form_validation.send(sender=self.event, form=self, data=data) voucher_form_validation.send(sender=self.instance.event, form=self, data=data)
return data return data
@@ -285,215 +265,6 @@ class VoucherForm(I18nModelForm):
return super().save(commit) 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): class VoucherBulkForm(VoucherForm):
codes = forms.CharField( codes = forms.CharField(
widget=forms.Textarea, widget=forms.Textarea,
+13 -21
View File
@@ -33,7 +33,6 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # 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. # License for the specific language governing permissions and limitations under the License.
import functools
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
@@ -70,15 +69,6 @@ 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): class OrderChangeLogEntryType(OrderLogEntryType):
prefix = _('The order has been changed:') prefix = _('The order has been changed:')
@@ -103,10 +93,10 @@ class OrderItemChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data): def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item'])) old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']: if data['old_variation']:
old_item += ' - ' + str(object_id_to_string(ItemVariation, item__event_id=event.pk, pk=data['old_variation'])) old_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['old_variation']))
new_item = str(event.items.get(pk=data['new_item'])) new_item = str(event.items.get(pk=data['new_item']))
if data['new_variation']: if data['new_variation']:
new_item += ' - ' + str(object_id_to_string(ItemVariation, item__event_id=event.pk, pk=data['new_variation'])) new_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['new_variation']))
return _('Position #{posid}: {old_item} ({old_price}) changed to {new_item} ({new_price}).').format( return _('Position #{posid}: {old_item} ({old_price}) changed to {new_item} ({new_price}).').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
old_item=old_item, new_item=new_item, old_item=old_item, new_item=new_item,
@@ -163,14 +153,14 @@ class OrderTaxRuleChanged(OrderChangeLogEntryType):
if 'positionid' in data: if 'positionid' in data:
return _('Tax rule of position #{posid} changed from {old_rule} to {new_rule}.').format( return _('Tax rule of position #{posid} changed from {old_rule} to {new_rule}.').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
old_rule=object_id_to_string(TaxRule, pk=data['old_taxrule']) if data['old_taxrule'] else '', old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=object_id_to_string(TaxRule, pk=data['new_taxrule']), new_rule=TaxRule.objects.get(pk=data['new_taxrule']),
) )
elif 'fee' in data: elif 'fee' in data:
return _('Tax rule of fee #{fee} changed from {old_rule} to {new_rule}.').format( return _('Tax rule of fee #{fee} changed from {old_rule} to {new_rule}.').format(
fee=data.get('fee', '?'), fee=data.get('fee', '?'),
old_rule=object_id_to_string(TaxRule, pk=data['old_taxrule']) if data['old_taxrule'] else '', old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=object_id_to_string(TaxRule, pk=data['new_taxrule']), new_rule=TaxRule.objects.get(pk=data['new_taxrule']),
) )
@@ -214,7 +204,7 @@ class OrderCanceled(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data): def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item'])) old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']: if data['old_variation']:
old_item += ' - ' + object_id_to_string(ItemVariation, pk=data['old_variation']) old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
return _('Position #{posid} ({old_item}, {old_price}) canceled.').format( return _('Position #{posid} ({old_item}, {old_price}) canceled.').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
old_item=old_item, old_item=old_item,
@@ -229,7 +219,7 @@ class OrderPositionAdded(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data): def display_prefixed(self, event: Event, logentry: LogEntry, data):
item = str(event.items.get(pk=data['item'])) item = str(event.items.get(pk=data['item']))
if data['variation']: if data['variation']:
item += ' - ' + object_id_to_string(ItemVariation, item__event_id=event.pk, pk=data['variation']) item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['variation']))
if data['addon_to']: if data['addon_to']:
addon_to = OrderPosition.objects.get(order__event=event, pk=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( return _('Position #{posid} created: {item} ({price}) as an add-on to position #{addon_to}.').format(
@@ -293,7 +283,7 @@ class OrderChangedSplit(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data): def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item'])) old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']: if data['old_variation']:
old_item += ' - ' + object_id_to_string(ItemVariation, pk=data['old_variation']) old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
url = reverse('control:event.order', kwargs={ url = reverse('control:event.order', kwargs={
'event': event.slug, 'event': event.slug,
'organizer': event.organizer.slug, 'organizer': event.organizer.slug,
@@ -349,7 +339,6 @@ class OrderChangedSplitFrom(OrderLogEntryType):
'pretix.event.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'), 'pretix.event.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
}) })
class CheckinErrorLogEntryType(OrderLogEntryType): class CheckinErrorLogEntryType(OrderLogEntryType):
def display(self, logentry: LogEntry, data): def display(self, logentry: LogEntry, data):
return self.display_plain(self.plain, logentry, data) return self.display_plain(self.plain, logentry, data)
@@ -364,7 +353,10 @@ class CheckinErrorLogEntryType(OrderLogEntryType):
event = logentry.event event = logentry.event
if 'list' in data and event: if 'list' in data and event:
data['list'] = object_id_to_string(CheckinList, event_id=event.id, pk=data['list']) try:
data['list'] = event.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
data['list'] = _("(unknown)")
else: else:
data['list'] = _("(unknown)") data['list'] = _("(unknown)")
+10 -27
View File
@@ -36,8 +36,7 @@ from urllib.parse import quote, urljoin, urlparse
from django.conf import settings from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, logout 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.shortcuts import get_object_or_404, resolve_url
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import get_script_prefix, resolve, reverse from django.urls import get_script_prefix, resolve, reverse
@@ -98,8 +97,6 @@ class PermissionMiddleware:
super().__init__() super().__init__()
def _login_redirect(self, request): def _login_redirect(self, request):
from django.contrib.auth.views import redirect_to_login
# Taken from django/contrib/auth/decorators.py # Taken from django/contrib/auth/decorators.py
path = request.build_absolute_uri() path = request.build_absolute_uri()
# urlparse chokes on lazy objects in Python 3, force to str # urlparse chokes on lazy objects in Python 3, force to str
@@ -112,21 +109,10 @@ class PermissionMiddleware:
if ((not login_scheme or login_scheme == current_scheme) and if ((not login_scheme or login_scheme == current_scheme) and
(not login_netloc or login_netloc == current_netloc)): (not login_netloc or login_netloc == current_netloc)):
path = request.get_full_path() path = request.get_full_path()
from django.contrib.auth.views import redirect_to_login
if request.headers.get("X-Requested-With") == "XMLHttpRequest": return redirect_to_login(
# It's not useful to return a 302 redirect on a XMLHttpRequest request, because path, resolved_login_url, REDIRECT_FIELD_NAME)
# 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): def __call__(self, request):
url = resolve(request.path_info) url = resolve(request.path_info)
@@ -228,15 +214,12 @@ class AuditLogMiddleware:
hijack_history = request.session.get('hijack_history', False) hijack_history = request.session.get('hijack_history', False)
hijacker = get_object_or_404(User, pk=hijack_history[0]["user"]) hijacker = get_object_or_404(User, pk=hijack_history[0]["user"])
ss = hijacker.get_active_staff_session(request.session.get('hijacker_session')) ss = hijacker.get_active_staff_session(request.session.get('hijacker_session'))
if not ss: if ss:
# Staff session expired or not found ss.logs.create(
logout(request) url=request.path,
return redirect_to_login(request.get_full_path()) method=request.method,
ss.logs.create( impersonating=request.user
url=request.path, )
method=request.method,
impersonating=request.user
)
else: else:
ss = request.user.get_active_staff_session(request.session.session_key) ss = request.user.get_active_staff_session(request.session.session_key)
if ss: if ss:
+6 -14
View File
@@ -39,8 +39,7 @@ from pretix.base.signals import (
html_page_start = GlobalSignal() html_page_start = GlobalSignal()
""" """
This signal allows you to put code in the beginning of the main page for every 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 a SafeString containing HTML, or page in the backend. You are expected to return HTML.
a string that will be HTML-escaped.
The ``sender`` keyword argument will contain the request. The ``sender`` keyword argument will contain the request.
""" """
@@ -130,7 +129,7 @@ event_dashboard_top = EventPluginSignal()
Arguments: 'request' Arguments: 'request'
This signal is sent out to include custom HTML in the top part of the the event dashboard. This signal is sent out to include custom HTML in the top part of the the event dashboard.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped. Receivers should return HTML.
As with all event plugin signals, the ``sender`` keyword argument will contain the event. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
An additional keyword argument ``subevent`` *can* contain a sub-event. An additional keyword argument ``subevent`` *can* contain a sub-event.
@@ -141,7 +140,7 @@ event_dashboard_widgets = EventPluginSignal()
This signal is sent out to include widgets in the event dashboard. Receivers 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: should return a list of dictionaries, where each dictionary can have the keys:
* content (SafeString, containing HTML) * content (str, containing HTML)
* display_size (str, one of "full" (whole row), "big" (half a row) or "small" * 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") (quarter of a row). May be ignored on small displays, default is "small")
* priority (int, used for ordering, higher comes first, default is 1) * priority (int, used for ordering, higher comes first, default is 1)
@@ -158,7 +157,7 @@ Arguments: 'user'
This signal is sent out to include widgets in the personal user dashboard. Receivers 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: should return a list of dictionaries, where each dictionary can have the keys:
* content (SafeString, containing HTML) * content (str, containing HTML)
* display_size (str, one of "full" (whole row), "big" (half a row) or "small" * 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") (quarter of a row). May be ignored on small displays, default is "small")
* priority (int, used for ordering, higher comes first, default is 1) * priority (int, used for ordering, higher comes first, default is 1)
@@ -173,7 +172,6 @@ Arguments: 'form'
This signal allows you to add additional HTML to the form that is used for modifying vouchers. 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. 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. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
""" """
@@ -211,7 +209,6 @@ Arguments: 'quota'
This signal allows you to append HTML to a Quota's detail view. You receive the This signal allows you to append HTML to a Quota's detail view. You receive the
quota as argument in the ``quota`` keyword argument. 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. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
""" """
@@ -222,7 +219,6 @@ Arguments: 'subevent'
This signal allows you to append HTML to a SubEvent's detail view. You receive the This signal allows you to append HTML to a SubEvent's detail view. You receive the
subevent as argument in the ``subevent`` keyword argument. 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. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
""" """
@@ -269,8 +265,7 @@ order_info = EventPluginSignal()
""" """
Arguments: ``order``, ``request`` 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. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available. Additionally, the argument ``order`` and ``request`` are available.
@@ -280,8 +275,7 @@ order_approve_info = EventPluginSignal()
""" """
Arguments: ``order``, ``request`` 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. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available. Additionally, the argument ``order`` and ``request`` are available.
@@ -292,7 +286,6 @@ order_position_buttons = EventPluginSignal()
Arguments: ``order``, ``position``, ``request`` Arguments: ``order``, ``position``, ``request``
This signal is sent out to display additional buttons for a single position of an order. 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. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available. Additionally, the argument ``order`` and ``request`` are available.
@@ -322,7 +315,6 @@ Arguments: 'request'
This signal is sent out to include template snippets on the settings page of an event This signal is sent out to include template snippets on the settings page of an event
that allows generating a pretix Widget code. 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. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
A second keyword argument ``request`` will contain the request object. A second keyword argument ``request`` will contain the request object.
@@ -56,4 +56,5 @@
</form> </form>
<script type="text/plain" id="good_origin">{{ good_origin }}</script> <script type="text/plain" id="good_origin">{{ good_origin }}</script>
<script type="text/plain" id="bad_origin_report_url">{{ bad_origin_report_url }}</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 %} {% endblock %}
@@ -3,7 +3,6 @@
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% load compress %} {% load compress %}
{% load escapejson %}
{% block content %} {% block content %}
<form class="form-signin" action="" method="post" id="webauthn-form"> <form class="form-signin" action="" method="post" id="webauthn-form">
{% csrf_token %} {% csrf_token %}
@@ -31,7 +30,8 @@
</form> </form>
{% if jsondata %} {% if jsondata %}
<script type="text/json" id="webauthn-login"> <script type="text/json" id="webauthn-login">
{{ jsondata|escapejson }} {{ jsondata|safe }}
</script> </script>
{% endif %} {% endif %}
{% compress js %} {% compress js %}
@@ -58,7 +58,6 @@
<script type="text/javascript" src="{% static "leaflet/leaflet.js" %}"></script> <script type="text/javascript" src="{% static "leaflet/leaflet.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/geo.js" %}"></script> <script type="text/javascript" src="{% static "pretixcontrol/js/ui/geo.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script> <script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/reldate.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script> <script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
<script type="text/javascript" src="{% static "sortable/Sortable.js" %}"></script> <script type="text/javascript" src="{% static "sortable/Sortable.js" %}"></script>
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script> <script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
@@ -28,7 +28,7 @@
{% if w.lazy %} {% if w.lazy %}
<span class="fa fa-cog fa-4x"></span> <span class="fa fa-cog fa-4x"></span>
{% else %} {% else %}
{{ w.content }} {{ w.content|safe }}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -51,7 +51,7 @@
{% if w.lazy %} {% if w.lazy %}
<span class="fa fa-cog fa-4x"></span> <span class="fa fa-cog fa-4x"></span>
{% else %} {% else %}
{{ w.content }} {{ w.content|safe }}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -72,7 +72,7 @@
{% if w.lazy %} {% if w.lazy %}
<span class="fa fa-cog fa-4x"></span> <span class="fa fa-cog fa-4x"></span>
{% else %} {% else %}
{{ w.content }} {{ w.content|safe }}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -94,7 +94,7 @@
{% if w.lazy %} {% if w.lazy %}
<span class="fa fa-cog fa-4x"></span> <span class="fa fa-cog fa-4x"></span>
{% else %} {% else %}
{{ w.content }} {{ w.content|safe }}
{% endif %} {% endif %}
</a> </a>
{% else %} {% else %}
@@ -102,7 +102,7 @@
{% if w.lazy %} {% if w.lazy %}
<span class="fa fa-cog fa-4x"></span> <span class="fa fa-cog fa-4x"></span>
{% else %} {% else %}
{{ w.content }} {{ w.content|safe }}
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -1,13 +1,13 @@
{% load i18n %}{% blocktrans with code=code reason=reason instance=instance %}Hello, {% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
{{ reason }} {{ reason }}
{{ code }} {{ code }}
Don't share this code with anyone. The {{ instance }} team will never ask you for it. Please do never give this code to another person. Our support team will never ask for this code.
If you didn't request this code, please contact us immediately. If this code was not requested by you, please contact us immediately.
Thanks, Best regards,
The {{ instance }} Team Your {{ instance }} team
{% endblocktrans %} {% endblocktrans %}
@@ -1,15 +1,14 @@
{% load i18n %}{% blocktrans with code=code address=address instance=instance %}Hello, {% load i18n %}{% blocktrans with code=code instance=instance %}Hello,
Someone requested to use {{ address }} as a sender address on {{ instance }}. Once verified, emails sent from {{ instance }} can show this address as the sender. someone requested to use {{ address }} as a sender address on {{ instance }}.
This will allow them to send emails that are shown to originate from this email address.
If that was you, please enter the following confirmation code:
If this was you, enter the following code in the setup form: {{ code }}
{{ code }} If this was not requested by you, you can safely ignore this email.
Don't share this code with anyone unless you want to authorize them to use this address for this purpose. The {{ instance }} team will never ask you for it. Best regards,
If you didn't request this, you can safely ignore this email. Your {{ instance }} team
{% endblocktrans %}
Thanks,
The {{ instance }} Team
{% endblocktrans %}
@@ -1,11 +1,9 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello, {% load i18n %}{% blocktrans with url=url|safe %}Hello,
We received a request to reset the password for your {{ instance }} account. To choose a new password, follow the link below: you requested a new password. Please go to the following page to reset your password:
{{ url }} {{ url }}
If you didn't request this, you can safely ignore this email — your password won't change. Best regards,
Your {{ instance }} team
Thanks,
The {{ instance }} Team
{% endblocktrans %} {% endblocktrans %}
@@ -1,16 +1,17 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello, {% load i18n %}{% blocktrans with url=url|safe %}Hello,
You've been invited to join a team on {{ instance }}, an event ticket sales platform. you have been invited to a team on {{ instance }}, a platform to perform event
ticket sales.
- Organizer: {{ organizer }} Organizer: {{ organizer }}
- Team: {{ team }} Team: {{ team }}
To accept, follow the link below:
If you want to join that team, just click on the following link:
{{ url }} {{ url }}
If you don't want to join, you can safely ignore this email. If you do not want to join, you can safely ignore or delete this email.
Thanks, Best regards,
The {{ instance }} Team
Your {{ instance }} team
{% endblocktrans %} {% endblocktrans %}
@@ -1,19 +1,13 @@
{% load i18n %}{% blocktrans %}Hello, {% load i18n %}{% blocktrans with url=url|safe os=source.os_type agent=source.agent_type %}Hello,
We noticed a new sign-in to your {{ instance }} account: a login to your {{ instance }} account from an unusual or new location was detected. The login was performed using {{ agent }} on {{ os }} from {{ country }}.
{% endblocktrans %}
- {% trans "Time" %}: {{ when }}
- {% trans "Browser" %}: {{ agent }}
- {% trans "Operating system" %}: {{ os }}
{% if device %}- {% trans "Device" %}: {{ device }}
{% endif %}{% if country %}- {% trans "Country" %}: {{ country }}
{% endif %}
{% blocktrans with url=url|safe %}If it was you, no action is needed.
If you don't recognize this sign-in, please change your password immediately: If this was you, you can safely ignore this email.
If this was not you, we recommend that you change your password in your account settings:
{{ url }} {{ url }}
Thanks, Best regards,
The {{ instance }} Team Your {{ instance }} team
{% endblocktrans %} {% endblocktrans %}
@@ -1,15 +1,16 @@
{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello, {% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
The following changes were made to your {{ instance }} account: this is to inform you that the account information of your {{ instance }} account has been
changed. In particular, the following changes have been performed:
{{ messages }} {{ messages }}
If you didn't make these changes, please contact the {{ instance }} support team immediately. If this change was not performed by you, please contact us immediately.
You can review your account settings here: You can review and change your account settings here:
{{ url }} {{ url }}
Thanks, Best regards,
The {{ instance }} Team Your {{ instance }} team
{% endblocktrans %} {% endblocktrans %}

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