Compare commits

..
337 changed files with 566203 additions and 564756 deletions
+1 -2
View File
@@ -351,8 +351,7 @@ Endpoints
:<json boolean error_reason: One of ``canceled``, ``invalid``, ``unpaid``, ``product``, ``rules``, ``revoked``,
``incomplete``, ``already_redeemed``, ``blocked``, ``invalid_time``, or ``error``. Required.
:<json raw_barcode: The raw barcode or identifier you scanned. Required.
:<json raw_source_type: The type of medium you scanned, defaults to ``barcode``. Optional.
:<json raw_barcode: The raw barcode you scanned. Required.
:<json datetime: Date and time of the scan. Optional.
:<json type: Type of scan, defaults to ``"entry"``.
:<json position: Internal ID of an order position you matched. Optional.
-6
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.
Ticket secrets of order positions that have been used to issue a gift card can not
be changed. Only the link (``web_secret``) will be changed in this case.
**Example request**:
.. sourcecode:: http
@@ -898,9 +895,6 @@ Generating new secrets
Triggers generation of a new ``secret`` and ``web_secret`` attribute for a single order position.
Ticket secrets of order positions that have been used to issue a gift card can not
be changed. Only the link (``web_secret``) will be changed in this case.
**Example request**:
.. sourcecode:: http
+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::
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
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
+10 -11
View File
@@ -40,11 +40,11 @@ dependencies = [
"Django[argon2]==5.2.*",
"django-bootstrap3==26.1",
"django-compressor==4.6.0",
"django-countries==9.0.*",
"django-filter==26.1",
"django-countries==8.2.*",
"django-filter==25.1",
"django-formset-js-improved==0.5.0.5",
"django-formtools==2.7",
"django-hierarkey==2.0.*,>=2.0.2",
"django-formtools==2.6.1",
"django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*",
"django-i18nfield==1.11.*",
"django-libsass==0.9",
@@ -53,8 +53,7 @@ dependencies = [
"django-oauth-toolkit==2.3.*",
"django-otp==1.7.*",
"django-phonenumber-field==8.4.*",
"django-querytagger==0.0.3",
"django-redis==7.0.*",
"django-redis==6.0.*",
"django-scopes==2.0.*",
"django-statici18n==2.7.*",
"djangorestframework==3.17.*",
@@ -77,7 +76,7 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.13.*",
"phonenumberslite==9.0.*",
"Pillow==12.3.*",
"Pillow==12.2.*",
"pretix-plugin-build",
"protobuf==7.35.*",
"psycopg2-binary",
@@ -92,9 +91,9 @@ dependencies = [
"pyuca",
"qrcode==8.2",
"redis==7.4.*",
"reportlab==5.0.*",
"requests==2.34.*",
"sentry-sdk==2.66.*",
"reportlab==4.5.*",
"requests==2.32.*",
"sentry-sdk==2.63.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -102,7 +101,7 @@ dependencies = [
"tqdm==4.*",
"ua-parser==1.0.*",
"vobject==0.9.*",
"webauthn==3.0.*",
"webauthn==2.8.*",
"zeep==4.3.*"
]
+2 -2
View File
@@ -6,8 +6,8 @@ localecompile:
./manage.py compilemessages
localegen:
./manage.py makemessages --keep-pot --add-location file --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot --add-location file -e js,ts,vue -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
staticfiles: npminstall npmbuild jsi18n
./manage.py collectstatic --noinput
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2026.7.0.dev0"
__version__ = "2026.6.0.dev0"
-1
View File
@@ -118,7 +118,6 @@ ALL_LANGUAGES = [
('sv', _('Swedish')),
('es', _('Spanish')),
('es-419', _('Spanish (Latin America)')),
('th', _('Thai')),
('tr', _('Turkish')),
('uk', _('Ukrainian')),
]
-3
View File
@@ -108,7 +108,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-lookup'),
@@ -147,7 +146,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('GET', 'api-v1:checkinrpc.search'),
)
@@ -184,7 +182,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('GET', 'api-v1:checkinrpc.search'),
)
-28
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
import json
import re
from django.db.models import prefetch_related_objects
from rest_framework import serializers
@@ -136,30 +135,3 @@ class SalesChannelMigrationMixin:
else:
value["sales_channels"] = value["limit_sales_channels"]
return value
class CompatDecimalField(serializers.DecimalField):
"""
Historically, pretix recorded tax rates as decimals with two places. Today, pretix supports tax rates with up to
four places. Since our API outputs decimals with the stored precision, this would have changed the API output from
"19.00" to "19.0000" without warning. While this is semantically the same thing, we need to assume some pretix API
users might run into trouble, either because they treat the value as a string and then map something
(e.g. ``if tax_rate == "19.00"``) or process it with a language where this is a significant difference. For example,
while in Python ``Decimal("19.00") == Decimal("19.0000")`` is true, in Java
``(new BigDecimal("19.00")).equals(new BigDecimal("19.0000"))`` is false and only
``(new BigDecimal("19.00")).compareTo(new BigDecimal("19.0000")) == 0`` is true.
Therefore, we stay backwards compatible by outputting two decimal places *as long as the trailing digits are zero-valued.
"""
regex = re.compile(r"^([0-9]+\.[0-9]{2})0+$")
def to_representation(self, value):
if self.localize:
raise ValueError("localization not supported")
value = super().to_representation(value)
if value and "." not in value:
return f"{value}.00"
if m := self.regex.match(value):
return m.group(1)
return value
+1 -3
View File
@@ -48,7 +48,7 @@ from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField
from pretix.api.serializers import (
CompatDecimalField, CompatibleJSONField, SalesChannelMigrationMixin,
CompatibleJSONField, SalesChannelMigrationMixin,
)
from pretix.api.serializers.fields import PluginsField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -681,7 +681,6 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
required=False,
allow_null=True,
)
rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta:
model = TaxRule
@@ -748,7 +747,6 @@ class EventSettingsSerializer(SettingsSerializer):
'max_items_per_order',
'reservation_time',
'contact_mail',
'contact_url',
'show_variations_expanded',
'hide_sold_out',
'meta_noindex',
+4 -6
View File
@@ -42,9 +42,7 @@ from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from pretix.api.serializers import (
CompatDecimalField, SalesChannelMigrationMixin,
)
from pretix.api.serializers import SalesChannelMigrationMixin
from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -278,10 +276,10 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
return value
class ItemTaxRateField(CompatDecimalField):
class ItemTaxRateField(serializers.Field):
def to_representation(self, i):
if i.tax_rule:
return super().to_representation(Decimal(i.tax_rule.rate))
return str(Decimal(i.tax_rule.rate))
else:
return str(Decimal('0.00'))
@@ -291,7 +289,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False)
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True, max_digits=7, decimal_places=4)
tax_rate = ItemTaxRateField(source='*', read_only=True)
meta_data = MetaDataField(required=False, source='*')
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
'image/png', 'image/jpeg', 'image/gif'
+2 -9
View File
@@ -41,7 +41,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse
from pretix.api.serializers import CompatDecimalField, CompatibleJSONField
from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -52,7 +52,6 @@ from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
@@ -382,7 +381,6 @@ class PrintLogSerializer(serializers.ModelSerializer):
class FailedCheckinSerializer(I18nAwareModelSerializer):
error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False)
raw_barcode = serializers.CharField(required=True, allow_null=False)
raw_source_type = serializers.ChoiceField(choices=[(k, v) for k, v in MEDIA_TYPES.items()], default='barcode')
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none(), required=False, allow_null=True)
raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
@@ -392,7 +390,7 @@ class FailedCheckinSerializer(I18nAwareModelSerializer):
class Meta:
model = Checkin
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):
super().__init__(*args, **kwargs)
@@ -593,7 +591,6 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False)
plugin_data = OrderPositionPluginDataField(source='*', allow_null=True, read_only=True)
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta:
list_serializer_class = OrderPositionListSerializer
@@ -750,8 +747,6 @@ class OrderPaymentDateField(serializers.DateField):
class OrderFeeSerializer(I18nAwareModelSerializer):
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta:
model = OrderFee
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule',
@@ -1916,7 +1911,6 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
position = LinePositionField(read_only=True)
event_date_from = serializers.DateTimeField(read_only=True, source="period_start")
event_date_to = serializers.DateTimeField(read_only=True, source="period_end")
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta:
model = InvoiceLine
@@ -2000,7 +1994,6 @@ class BlockedTicketSecretSerializer(I18nAwareModelSerializer):
class TransactionSerializer(I18nAwareModelSerializer):
order = serializers.SlugRelatedField(slug_field="code", read_only=True)
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta:
model = Transaction
+2 -5
View File
@@ -27,7 +27,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.translation import gettext, gettext_lazy as _
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -492,9 +492,7 @@ class TeamInviteSerializer(serializers.ModelSerializer):
def _send_invite(self, instance):
mail(
instance.email,
gettext('You\'ve been invited to join %(organizer)s') % {
'organizer': self.context['organizer'].name,
},
_('Account invitation'),
'pretixcontrol/email/invitation.txt',
{
'instance': settings.PRETIX_INSTANCE_NAME,
@@ -576,7 +574,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
'contact_url',
'imprint_url',
'organizer_info_text',
'event_list_type',
+1 -3
View File
@@ -1658,7 +1658,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
count_waitinglist=False,
force=request.data.get('force', False),
send_mail=send_mail,
ignore_date=request.data.get('force', False),
)
except Quota.QuotaExceededException:
pass
@@ -1694,8 +1693,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
auth=self.request.auth,
count_waitinglist=False,
send_mail=send_mail,
force=force,
ignore_date=force)
force=force)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e:
+9 -12
View File
@@ -23,7 +23,6 @@ import sys
from django.conf import settings
from django.urls import reverse
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext
@@ -36,23 +35,21 @@ def get_powered_by(request, safelink=True):
d = gs.settings.license_check_input
if d.get('poweredby_name'):
if d.get('poweredby_url'):
msg = format_html(
gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>'),
msg = gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>').format(
name=d['poweredby_name'],
a_name_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
escape(sl(d['poweredby_url'])) if safelink else escape(d['poweredby_url']),
)),
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
a_name_attr='href="{}" target="_blank" rel="noopener"'.format(
sl(d['poweredby_url']) if safelink else d['poweredby_url'],
),
a_attr='href="{}" target="_blank" rel="noopener"'.format(
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
))
)
)
else:
msg = format_html(
gettext('<a {a_attr}>powered by {name} based on pretix</a>'),
msg = gettext('<a {a_attr}>powered by {name} based on pretix</a>').format(
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',
))
)
)
else:
msg = gettext('<a %(a_attr)s>ticketing powered by pretix</a>') % {
+8 -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
# <https://www.gnu.org/licenses/>.
#
import ipaddress
import logging
import smtplib
import socket
@@ -42,7 +43,6 @@ from pretix.base.templatetags.rich_text import (
markdown_compile_email, truelink_callback,
)
from pretix.helpers.format import FormattedString, SafeFormatter, format_map
from pretix.helpers.ssrf import should_block_access
from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext
@@ -252,9 +252,13 @@ def create_connection(address, timeout=socket.getdefaulttimeout(),
af, socktype, proto, canonname, sa = res
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
is_private, msg = should_block_access(sa)
if is_private:
raise socket.error(msg)
ip_addr = ipaddress.ip_address(sa[0])
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")
sock = None
try:
+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
# License for the specific language governing permissions and limitations under the License.
import hashlib
import ipaddress
import logging
from django import forms
@@ -40,12 +42,13 @@ from django.conf import settings
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
)
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix.base.metrics import pretix_failed_logins
from pretix.base.models import User
from pretix.helpers.dicts import move_to_end
from pretix.helpers.ratelimit import rate_limit
from pretix.helpers.http import get_client_ip
logger = logging.getLogger(__name__)
@@ -82,20 +85,40 @@ class LoginForm(forms.Form):
else:
move_to_end(self.fields, 'keep_logged_in')
@cached_property
def ratelimit_key(self):
if not settings.HAS_REDIS:
return None
client_ip = get_client_ip(self.request)
if not client_ip:
return None
try:
client_ip = ipaddress.ip_address(client_ip)
except ValueError:
# Web server not set up correctly
return None
if client_ip.is_private:
# This is the private IP of the server, web server not set up correctly
return None
return 'pretix_login_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
def clean(self):
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
rate_limit_kwargs = dict(include_ip_from_request=self.request, max_num=10, expire_time=300)
if rate_limit("login", **rate_limit_kwargs, increase=False):
# Check rate limit without counting up, we increase below only on failed logins
pretix_failed_logins.inc(1, reason="ratelimit")
logger.info("Backend login rejected due to rate limit.")
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
if self.ratelimit_key:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.get(self.ratelimit_key)
if cnt and int(cnt) > 10:
pretix_failed_logins.inc(1, reason="ratelimit")
logger.info("Backend login rejected due to rate limit.")
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
if self.user_cache is None:
if self.ratelimit_key:
rc.incr(self.ratelimit_key)
rc.expire(self.ratelimit_key, 300)
logger.info("Backend login invalid.")
pretix_failed_logins.inc(1, reason="invalid")
# Count towards rate limit (result is ignored, we are checking above)
rate_limit("login", **rate_limit_kwargs)
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login'
+11 -24
View File
@@ -53,7 +53,6 @@ from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format
from django.utils.functional import lazy
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.text import format_lazy
@@ -325,21 +324,16 @@ class WrappedPhonePrefixSelect(Select):
initial = None
def __init__(self, initial=None):
def _get_choices():
choices = [("", "---------")]
if initial:
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
continue
if initial in values:
self.initial = "+%d" % prefix
break
choices += get_phone_prefixes_sorted_and_localized()
return choices
choices = lazy(_get_choices, list)()
choices = [("", "---------")]
if initial:
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
continue
if initial in values:
self.initial = "+%d" % prefix
break
choices += get_phone_prefixes_sorted_and_localized()
super().__init__(choices=choices, attrs={
'aria-label': pgettext_lazy('phonenumber', 'International area code'),
'autocomplete': 'tel-country-code',
@@ -959,7 +953,7 @@ class BaseQuestionsForm(forms.Form):
label=label, required=required,
help_text=help_text,
initial=_initial,
widget=TimePickerWidget(without_seconds=True),
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
)
elif q.type == Question.TYPE_DATETIME:
if not help_text:
@@ -1116,13 +1110,6 @@ class BaseQuestionsForm(forms.Form):
if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
d['question_%d' % q.pk] = None
# Strip False answers to required yes/no questions even if all_optional is set, as our data model assumes that
# required yes/no questions can only be answered with yes
for q in question_cache.values():
if q.required and q.type == Question.TYPE_BOOLEAN:
if 'question_%d' % q.pk in d and d['question_%d' % q.pk] is False:
del d['question_%d' % q.pk]
return d
@@ -1411,7 +1398,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
elif self.validate_vat_id and vat_id_applicable:
try:
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
self.instance.vat_id_validated = bool(normalized_id)
self.instance.vat_id_validated = True
self.instance.vat_id = data['vat_id'] = normalized_id
except VATIDFinalError as e:
if self.all_optional:
+11 -22
View File
@@ -33,6 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
@@ -45,7 +46,6 @@ from pytz import common_timezones
from pretix.base.models import User
from pretix.control.forms import SingleLanguageWidget
from pretix.helpers.format import format_map
from pretix.helpers.ratelimit import rate_limit
class UserSettingsForm(forms.ModelForm):
@@ -128,11 +128,16 @@ class UserPasswordChangeForm(forms.Form):
def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw')
if rate_limit("pwchange", self.user.pk, max_num=10, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
rc.expire('pretix_pwchange_%s' % self.user.pk, 300)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if not check_password(old_pw, self.user.password):
raise forms.ValidationError(
@@ -170,35 +175,19 @@ class UserEmailChangeForm(forms.Form):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
}
old_email = forms.EmailField(label=_('Old email address'), disabled=True)
new_email = forms.EmailField(label=_('New email address'))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
def clean_new_email(self):
email = self.cleaned_data['new_email']
if rate_limit("emailchange_attempt", include_ip_from_request=self.request, max_num=5, expire_time=300):
# Rate limit lookup for conflicting email addresses to make enumeration harder
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
)
if rate_limit("emailchange", self.user.pk, max_num=2, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
return email
+5 -50
View File
@@ -43,10 +43,6 @@ from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _
from pretix.helpers.format import PlainHtmlAlternativeString
from pretix.helpers.i18n import (
get_format_without_seconds, get_javascript_format,
get_javascript_format_without_seconds,
)
def replace_arabic_numbers(inp):
@@ -112,7 +108,7 @@ class DatePickerWidget(forms.DateInput):
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 {}
if 'placeholder' in attrs:
del attrs['placeholder']
@@ -121,27 +117,8 @@ class TimePickerWidget(forms.TimeInput):
time_attrs['class'] += ' timepickerfield'
time_attrs['autocomplete'] = 'off'
if time_format or without_seconds:
# Explicitly set data-format attributes for the JS layer instead of relying on the body-wide config
def time_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
time_attrs['data-format'] = lazy(time_format_attr, str)
def time_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
time_attrs['data-format'] = lazy(time_format_attr, str)
def placeholder():
if without_seconds:
tf = time_format or get_format_without_seconds('TIME_INPUT_FORMATS')
else:
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf)
@@ -205,7 +182,7 @@ class UploadedFileWidget(forms.ClearableFileInput):
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
template_name = 'pretixbase/forms/widgets/splitdatetime.html'
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None, without_seconds=False):
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None):
attrs = attrs or {}
if 'placeholder' in attrs:
del attrs['placeholder']
@@ -228,36 +205,14 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date()
).isoformat()
if date_format or time_format or without_seconds:
# Explicitly set data-format attributes for the JS layer instead of relying on the body-wide config
def date_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(date_format or "DATE_INPUT_FORMATS")
return get_javascript_format(date_format or "DATE_INPUT_FORMATS")
date_attrs['data-format'] = lazy(date_format_attr, str)
def time_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
time_attrs['data-format'] = lazy(time_format_attr, str)
def date_placeholder():
if without_seconds:
df = date_format or get_format_without_seconds('DATE_INPUT_FORMATS')
else:
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
return now().replace(
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
).strftime(df)
def time_placeholder():
if without_seconds:
tf = time_format or get_format_without_seconds('TIME_INPUT_FORMATS')
else:
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf)
@@ -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.db import close_old_connections
from django.dispatch.dispatcher import NO_RECEIVERS
from django_querytagger.tagging import with_tag
from pretix.helpers.periodic import SKIPPED
@@ -83,8 +82,7 @@ class Command(BaseCommand):
try:
# Check if the DB connection is still good, it might be closed if the previous task took too long.
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:
if isinstance(err, KeyboardInterrupt):
raise err
+56 -183
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
# <https://www.gnu.org/licenses/>.
#
import base64
import hashlib
import logging
import re
from collections import OrderedDict
from urllib.parse import urlparse, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -47,8 +43,6 @@ from pretix.multidomain.urlreverse import (
)
from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
_supported = None
@@ -71,44 +65,11 @@ def get_supported_language(requested_language, allowed_languages, default_langua
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):
"""
This is the full LocaleMiddleware that uses all available information to figure out the correct
language for the request using all available sources, in this order of priority:
- Backend: User settings
- Language cookie
- Frontend: Customer account settings
- Browser settings
- Frontend: Event/Organizer settings
- System default
It needs to run late in the middleware stack to have all information available for these steps.
For some cases, it is even ran a second time since the event is sometimes only figured out after the
middleware stack (can happen for plugin views).
This middleware sets the correct locale and timezone
for a request.
"""
def process_request(self, request: HttpRequest):
@@ -223,24 +184,6 @@ def get_default_language():
return settings.LANGUAGE_CODE
def get_language_from_early_request(request: HttpRequest) -> str:
"""
Analyzes the request to find what language the user wants the system to
show using only WSGI-available information. Only languages listed in
settings.LANGUAGES are taken into account. If the user requests a sublanguage
where we have a main language, we send out the main language.
"""
global _supported
if _supported is None:
_supported = OrderedDict(settings.LANGUAGES)
return (
get_language_from_cookie(request)
or get_language_from_browser(request)
or get_default_language()
)
def get_language_from_request(request: HttpRequest) -> str:
"""
Analyzes the request to find what language the user wants the system to
@@ -255,6 +198,7 @@ def get_language_from_request(request: HttpRequest) -> str:
if request.path.startswith(get_script_prefix() + 'control'):
return (
get_language_from_user_settings(request)
or get_language_from_customer_settings(request)
or get_language_from_cookie(request)
or get_language_from_browser(request)
or get_language_from_event(request)
@@ -279,26 +223,7 @@ def _parse_csp(header):
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):
_sanitize_csp(h)
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():
if "'unsafe-inline'" in v:
# If we need unsafe-inline, drop any hashes or nonce as they will be ignored otherwise
a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha256-")]
def add_to_response_csp(response, csp_to_merge):
if "Content-Security-Policy" in response:
csp = _parse_csp(response["Content-Security-Policy"])
else:
csp = {}
_merge_csp(csp, csp_to_merge)
if csp:
response["Content-Security-Policy"] = _render_csp(csp)
def add_to_response_csp_via_request(request, csp_to_merge):
_merge_csp(request._csp_to_merge, csp_to_merge)
def calculate_csp_hash(data):
hash_str = base64.b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode("ascii")
return f"'sha256-{hash_str}'"
a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha-")]
class SecurityMiddleware(MiddlewareMixin):
SAFE_TYPES = (
# CSP policies are only used for:
# - HTML and SVG in top-level contexts
# - SVG or JS Workers delivered in embedded contexts
# See: https://www.w3.org/TR/CSP2/#which-policy-applies
# Therefore, we can save bandwidth on not including our (sometimes huge) policy
# on API responses or CSS. We do however include it with other types as a precaution
# (whitelist instead of blacklist) and we also do not whitelist JavaScript in
# we ever add service workers to not break the protection of this feature:
# https://www.w3.org/TR/CSP2/#sandboxing-and-workers
'application/json',
'text/css',
# We used to skip CSP for PDF since it was necessary for inline previews in Safari,
# but at the moment it does not seem to be an issue to just send it.
CSP_EXEMPT = (
'/api/v1/docs/',
)
def process_request(self, request):
request._csp_to_merge = {}
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:
# Don't use CSP on debug error page as it breaks of Django's fancy error
# pages
@@ -369,24 +268,18 @@ class SecurityMiddleware(MiddlewareMixin):
# https://github.com/pretix/pretix/issues/765
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
if self._needs_csp(request, resp):
resp['Content-Security-Policy'] = _render_csp(self._build_csp(request, resp))
elif 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']
img_src = []
gs = global_settings_object(request)
if gs.settings.leaflet_tiles:
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
return resp
def _needs_csp(self, request, resp):
if "Content-Type" in resp and resp["Content-Type"].split(";")[0] in self.SAFE_TYPES:
return False
if getattr(resp, '_csp_ignore', False):
return False
return True
def _build_csp(self, request, resp):
url = resolve(request.path_info)
font_src = set()
if hasattr(request, 'event'):
for font in get_fonts(request.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))
h = {
'default-src': ["{static}"],
@@ -394,9 +287,9 @@ class SecurityMiddleware(MiddlewareMixin):
'object-src': ["'none'"],
'frame-src': ['{static}'],
'style-src': ["{static}", "{media}"],
'connect-src': ["{static}", "{dynamic}", "{media}"],
'img-src': ["{static}", "{media}", "data:"],
'font-src': ["{static}"],
'connect-src': ["{dynamic}", "{media}"],
'img-src': ["{static}", "{media}", "data:"] + img_src,
'font-src': ["{static}"] + list(font_src),
'media-src': ["{static}", "data:"],
# 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
@@ -405,18 +298,17 @@ class SecurityMiddleware(MiddlewareMixin):
'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:
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
h['style-src'] += ["'unsafe-inline'"]
h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"]
if hasattr(request, 'csp_nonce'):
nonce = f"'nonce-{request.csp_nonce}'"
h['script-src'].append(nonce)
if not settings.VITE_DEV_MODE:
# can't have 'unsafe-inline' and nonce at the same time
h['style-src'].append(nonce)
# Only include pay.google.com for wallet detection purposes on the Payment selection page
if (
url.url_name == "event.order.pay.change" or
@@ -425,35 +317,27 @@ class SecurityMiddleware(MiddlewareMixin):
h['script-src'].append('https://pay.google.com')
h['frame-src'].append('https://pay.google.com')
h['connect-src'].append('https://google.com/pay')
if settings.LOG_CSP:
h['report-uri'] = ["/csp_report/"]
if request._csp_to_merge:
_merge_csp(h, request._csp_to_merge)
if 'Content-Security-Policy' in resp:
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
if settings.CSP_ADDITIONAL_HEADER:
_merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER))
placeholders = {
"{static}": ["'self'"],
"{dynamic}": ["'self'"],
"{media}": ["'self'"],
}
staticdomain = "'self'"
dynamicdomain = "'self'"
mediadomain = "'self'"
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'):
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.find('/', 9) > 0:
placeholders["{static}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)])
placeholders["{dynamic}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)])
staticdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
dynamicdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
else:
placeholders["{static}"].append(settings.SITE_URL)
placeholders["{dynamic}"].append(settings.SITE_URL)
staticdomain += " " + settings.SITE_URL
dynamicdomain += " " + settings.SITE_URL
if hasattr(request, 'organizer') and request.organizer:
if hasattr(request, 'event') and request.event:
@@ -464,29 +348,18 @@ class SecurityMiddleware(MiddlewareMixin):
siteurlsplit = urlsplit(settings.SITE_URL)
if siteurlsplit.port and siteurlsplit.port not in (80, 443):
domain = '%s:%d' % (domain, siteurlsplit.port)
placeholders["{dynamic}"].append(domain)
dynamicdomain += " " + domain
for k, v in h.items():
h[k] = sorted(set(result for part in v for result in placeholders.get(part, [part])))
if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False):
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
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
return resp
class RejectInvalidInputMiddleware(MiddlewareMixin):
@@ -82,7 +82,7 @@ def make_eventmetaproperties_unique(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0304_tax_rate_decimals"),
("pretixbase", "0301_reusablemedium_remove_orderposition"),
]
operations = [
@@ -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),
]
@@ -6,7 +6,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0305_fixup_eventmetaproperties"),
("pretixbase", "0302_fixup_eventmetaproperties"),
]
operations = [
@@ -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),
),
]
+14 -14
View File
@@ -373,7 +373,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
mail(
email or self.email,
_('Changes to your account'),
_('Account information changed'),
'pretixcontrol/email/security_notice.txt',
{
'user': self,
@@ -400,13 +400,12 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
with language(self.locale):
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,
))
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,
instance=settings.PRETIX_INSTANCE_NAME,
))
else:
raise Exception('Invalid confirmation code reason')
@@ -419,7 +418,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
}
mail(
email or self.email,
_('Your confirmation code'),
_('pretix confirmation code'),
'pretixcontrol/email/confirmation_code.txt',
{
'user': self,
@@ -463,9 +462,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
from pretix.base.services.mail import mail
mail(
self.email,
_('Reset your password'),
'pretixcontrol/email/forgot.txt',
self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
{
'instance': settings.PRETIX_INSTANCE_NAME,
'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)
)
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)
with the given session key.
"""
return self.get_active_staff_session(session_key) is not None
def get_active_staff_session(self, session_key):
if not self.is_staff or not session_key:
def get_active_staff_session(self, session_key=None):
if not self.is_staff:
return None
if not hasattr(self, '_staff_session_cache'):
self._staff_session_cache = {}
if session_key not in self._staff_session_cache:
sess = StaffSession.objects.filter(
user=self, date_end__isnull=True, session_key=session_key
).first()
qs = StaffSession.objects.filter(
user=self, date_end__isnull=True
)
if session_key:
qs = qs.filter(session_key=session_key)
sess = qs.first()
if sess:
if sess.date_start < now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE):
sess.date_end = now()
+1 -1
View File
@@ -395,7 +395,7 @@ class AttendeeProfile(models.Model):
self.company,
self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name if self.country else None,
self.country.name,
]
for a in self.answers:
value = a.get('value')
+11 -24
View File
@@ -40,7 +40,6 @@ import warnings
from collections import Counter, OrderedDict, defaultdict
from datetime import datetime, time, timedelta
from operator import attrgetter
from typing import TYPE_CHECKING
from urllib.parse import urljoin
from zoneinfo import ZoneInfo
@@ -80,16 +79,10 @@ from pretix.helpers.thumb import get_thumbnail
from ..settings import settings_hierarkey
from .organizer import Organizer, Team
if TYPE_CHECKING:
from hierarkey.proxy import HierarkeyProxy
logger = logging.getLogger(__name__)
class EventMixin:
if TYPE_CHECKING:
settings: HierarkeyProxy
def clean(self):
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
@@ -179,12 +172,6 @@ class EventMixin:
self.date_to.astimezone(tz), ("D" if short else "l")
)
def is_same_day(self):
if not self.date_to:
return True
else:
return self.date_from.astimezone(self.timezone).date() == self.date_to.astimezone(self.timezone).date()
def get_date_range_display(self, tz=None, force_show_end=False, as_html=False, try_to_show_times=False) -> str:
"""
Returns a formatted string containing the start date and the end date
@@ -238,9 +225,6 @@ class EventMixin:
@property
def timezone(self):
# If we get rid of the shim, verify that
# https://github.com/py-vobject/vobject/issues/117#issuecomment-5045645314
# has been released and included
return pytz_deprecation_shim.timezone(self.settings.timezone)
@property
@@ -658,7 +642,7 @@ class Event(EventMixin, LoggedModel):
is_remote = models.BooleanField(
default=False,
verbose_name=_("This event is remote or partially remote."),
help_text=_("This will be used to let users know if the event is in a different timezone, and 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(
verbose_name=_("Latitude"),
@@ -915,7 +899,7 @@ class Event(EventMixin, LoggedModel):
self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
if hasattr(other, 'alternative_domain_assignment') and not is_cross_organizer:
if hasattr(other, 'alternative_domain_assignment'):
other.alternative_domain_assignment.domain.event_assignments.create(event=self)
if not self.all_sales_channels:
@@ -1412,12 +1396,15 @@ class Event(EventMixin, LoggedModel):
for mp in self.organizer.meta_properties.all():
if mp.required and not self.meta_data.get(mp.name):
issues.append(format_html(
'<a href="{href}{href_hash}">{text}</a>',
text=gettext('You need to fill the meta parameter "{property}".').format(property=mp.name),
href=reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
href_hash=f'#id_prop-{mp.pk}-value',
))
issues.append(
('<a {a_attr}>' + gettext('You need to fill the meta parameter "{property}".') + '</a>').format(
property=mp.name,
a_attr='href="%s#id_prop-%d-value"' % (
reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
mp.pk
)
)
)
responses = event_live_issues.send(self)
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
+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.helpers.countries import FastCountryField
from pretix.helpers.models import NormalizedDecimalField
def invoice_filename(instance, filename: str) -> str:
@@ -451,7 +450,7 @@ class InvoiceLine(models.Model):
description = models.TextField()
gross_value = models.DecimalField(max_digits=13, decimal_places=2)
tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00'))
tax_rate = 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_code = models.CharField(max_length=190, null=True, blank=True)
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
+36
View File
@@ -885,6 +885,26 @@ class Item(LoggedModel):
return False
return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
now_dt = now_dt or time_machine_now()
subevent_item = subevent and subevent.item_overrides.get(self.pk)
if not self.active:
return 'active'
elif self.available_from and self.available_from > now_dt:
return 'available_from'
elif self.available_until and self.available_until < now_dt:
return 'available_until'
elif (self.require_voucher or self.hide_without_voucher) and not has_voucher:
return 'require_voucher'
elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt:
return 'available_from'
elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt:
return 'available_until'
elif self.hidden_if_item_available and self._dependency_available:
return 'hidden_if_item_available'
else:
return None
def _get_quotas(self, ignored_quotas=None, subevent=None):
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
@@ -1393,6 +1413,22 @@ class ItemVariation(models.Model):
return False
return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
now_dt = now_dt or time_machine_now()
subevent_var = subevent and subevent.var_overrides.get(self.pk)
if not self.active:
return 'active'
elif self.available_from and self.available_from > now_dt:
return 'available_from'
elif self.available_until and self.available_until < now_dt:
return 'available_until'
elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt:
return 'available_from'
elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt:
return 'available_until'
else:
return None
@property
def meta_data(self):
data = self.item.meta_data
+21 -21
View File
@@ -87,7 +87,6 @@ from pretix.base.timemachine import time_machine_now
from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.models import NormalizedDecimalField
from ...helpers.names import build_name
from ...testutils.middleware import debugflags_var
from ._transactions import (
@@ -225,6 +224,8 @@ class Order(LockModel, LoggedModel):
"Organizer",
related_name="orders",
on_delete=models.CASCADE,
null=True,
blank=True,
)
event = models.ForeignKey(
Event,
@@ -328,7 +329,7 @@ class Order(LockModel, LoggedModel):
default="line",
)
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='organizer')
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
class Meta:
verbose_name = _("Order")
@@ -1696,7 +1697,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
self.company,
self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name if self.country else ''
self.country.name
]
lines = [r.strip() for r in lines if r]
return '\n'.join(lines).strip()
@@ -2286,12 +2287,9 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs)
def ActivePositionManager(**scope):
class InnerClass(ScopedManager(**scope).__class__):
def get_queryset(self):
return super().get_queryset().filter(canceled=False)
return InnerClass()
class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__):
def get_queryset(self):
return super().get_queryset().filter(canceled=False)
class OrderFee(RoundingCorrectionMixin, models.Model):
@@ -2358,8 +2356,8 @@ class OrderFee(RoundingCorrectionMixin, models.Model):
)
description = models.CharField(max_length=190, blank=True)
internal_type = models.CharField(max_length=255, blank=True)
tax_rate = NormalizedDecimalField(
max_digits=7, decimal_places=4,
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
)
tax_rule = models.ForeignKey(
@@ -2381,7 +2379,7 @@ class OrderFee(RoundingCorrectionMixin, models.Model):
canceled = models.BooleanField(default=False)
all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager(organizer='order__event__organizer')
objects = ActivePositionManager()
@property
def net_value(self):
@@ -2543,6 +2541,8 @@ class OrderPosition(AbstractPosition):
"Organizer",
related_name="order_positions",
on_delete=models.CASCADE,
null=True,
blank=True,
)
order = models.ForeignKey(
Order,
@@ -2555,8 +2555,8 @@ class OrderPosition(AbstractPosition):
max_digits=13, decimal_places=2, null=True, blank=True,
)
tax_rate = NormalizedDecimalField(
max_digits=7, decimal_places=4,
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
)
tax_rule = models.ForeignKey(
@@ -2599,8 +2599,8 @@ class OrderPosition(AbstractPosition):
blank=True,
)
all = ScopedManager(organizer='organizer')
objects = ActivePositionManager(organizer='organizer')
all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -3074,8 +3074,8 @@ class Transaction(models.Model):
price_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
tax_rate = NormalizedDecimalField(
max_digits=7, decimal_places=4,
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
)
tax_rule = models.ForeignKey(
@@ -3190,8 +3190,8 @@ class CartPosition(AbstractPosition):
verbose_name=_("Limit for extending expiration date"),
null=True
)
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'),
verbose_name=_('Tax rate')
)
tax_code = models.CharField(
@@ -3438,7 +3438,7 @@ class InvoiceAddress(models.Model):
self.name,
self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name if self.country else '',
self.country.name,
self.vat_id,
self.custom_field,
self.internal_reference,
-7
View File
@@ -35,7 +35,6 @@ import operator
import string
from datetime import date, datetime, time
from functools import reduce
from typing import TYPE_CHECKING
import pytz_deprecation_shim
from django.conf import settings
@@ -62,9 +61,6 @@ from ...helpers.permission_migration import (
from ..settings import settings_hierarkey
from .auth import User
if TYPE_CHECKING:
from hierarkey.proxy import HierarkeyProxy
@settings_hierarkey.add(cache_namespace='organizer')
class Organizer(LoggedModel):
@@ -82,9 +78,6 @@ class Organizer(LoggedModel):
"""
settings_namespace = 'organizer'
if TYPE_CHECKING:
settings: HierarkeyProxy
name = models.CharField(max_length=200,
verbose_name=_("Name"))
slug = models.CharField(
+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.templatetags.money import money_filter
from pretix.helpers.countries import FastCountryField
from pretix.helpers.models import NormalizedDecimalField
class TaxedPrice:
@@ -336,9 +335,9 @@ class TaxRule(LoggedModel):
max_length=190,
choices=TAX_CODE_LISTS,
)
rate = NormalizedDecimalField(
max_digits=7,
decimal_places=4,
rate = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[
MaxValueValidator(
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
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import datetime
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from typing import Union
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -423,33 +421,27 @@ class Voucher(LoggedModel):
return False
@staticmethod
def get_affected_quotas(quota, item, variation, subevent):
if quota:
return {quota}
elif item and variation:
return set(variation.quotas.filter(subevent=subevent))
elif item and not item.has_variations:
return set(item.quotas.filter(subevent=subevent))
elif item and item.has_variations:
return set(
Quota.objects.filter(
pk__in=Quota.variations.through.objects.filter(
itemvariation__item=item,
quota__subevent=subevent,
).values('quota_id')
)
)
else:
return set()
@staticmethod
def clean_quota_get_ignored(voucher_data: Union["VoucherBulkData", "Voucher"]):
if voucher_data:
valid = voucher_data.valid_until is None or voucher_data.valid_until >= now()
if valid and voucher_data.block_quota and voucher_data.max_usages > voucher_data.redeemed:
return Voucher.get_affected_quotas(voucher_data.quota, voucher_data.item, voucher_data.variation, voucher_data.subevent)
return set()
def clean_quota_get_ignored(old_instance):
quotas = set()
was_valid = old_instance and (
old_instance.valid_until is None or old_instance.valid_until >= now()
)
if old_instance and old_instance.block_quota and was_valid:
if old_instance.quota:
quotas.add(old_instance.quota)
elif old_instance.variation:
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
elif old_instance.item:
if old_instance.item.has_variations:
quotas |= set(
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
itemvariation__item=old_instance.item,
quota__subevent=old_instance.subevent,
).values('quota_id'))
)
else:
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
return quotas
@staticmethod
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'):
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 not new_quotas:
if quota:
new_quotas = {quota}
elif item and variation:
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
elif item and not item.has_variations:
new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
elif item and item.has_variations:
new_quotas = set(
Quota.objects.filter(
pk__in=Quota.variations.through.objects.filter(
itemvariation__item=item,
quota__subevent=data.get('subevent'),
).values('quota_id')
)
)
else:
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
'tickets.'))
@@ -638,16 +644,3 @@ class Voucher(LoggedModel):
]
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
return ops
@dataclass
class VoucherBulkData:
item: object
variation: object
quota: object
block_quota: bool
valid_until: datetime.datetime
subevent: object
redeemed: int
max_usages: int
allow_ignore_quota: bool
+3 -55
View File
@@ -834,7 +834,7 @@ class BasePaymentProvider:
"""
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
the payment process. This is the place to actually move the money if applicable.
@@ -936,7 +936,7 @@ class BasePaymentProvider:
"""
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.
The default implementation returns an empty string.
@@ -961,7 +961,7 @@ class BasePaymentProvider:
"""
Will be called if the *event administrator* views the details of a refund.
It should return 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.
The default implementation returns an empty string.
@@ -1706,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")
def register_payment_provider(sender, **kwargs):
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.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.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter
@@ -402,7 +401,11 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Event 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", {
"label": _("Printing date"),
"editor_sample": _("2017-05-31"),
@@ -667,14 +670,6 @@ def get_images(event):
def get_variables(event):
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]
concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"])
-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):
if position.pk and position.issued_gift_cards.exists():
return
gen = event.ticket_secret_generator
if gen.use_revocation_list and force_invalidate_if_revokation_list_used:
force_invalidate = True
+1 -3
View File
@@ -22,7 +22,6 @@
import logging
from decimal import Decimal
from django.conf import settings
from django.db import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
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")
mail(
user.email,
subject=gettext('Confirm event cancellation and bulk refund'),
subject=gettext('Bulk-refund confirmation'),
template='pretixbase/email/cancel_confirm.txt',
context={
"event": str(event),
"amount": money_filter(refund_total, event.currency),
"confirmation_code": confirmation_code,
"instance": settings.PRETIX_INSTANCE_NAME,
},
locale=user.locale,
)
+2 -2
View File
@@ -29,7 +29,7 @@ from typing import List
from django.utils.functional import cached_property
from pretix.base.models import CartPosition, ItemCategory, SalesChannel
from pretix.presale.productlist import prepare_item_list_for_shop
from pretix.presale.views.event import get_grouped_items
class DummyCategory:
@@ -162,7 +162,7 @@ class CrossSellingService:
]
def _prepare_items(self, subevent, items_qs, discount_info):
items, _btn = prepare_item_list_for_shop(
items, _btn = get_grouped_items(
self.event,
subevent=subevent,
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:
mail(
email=schedule.owner.email,
subject=gettext('Scheduled export failed'),
subject=gettext('Export failed'),
template='pretixbase/email/export_failed.txt',
context={
'configuration_url': config_url,
'reason': msg,
'soft': soft,
'instance': settings.PRETIX_INSTANCE_NAME,
},
event=context if isinstance(context, Event) else None,
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 django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _
@@ -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
where the valid_from date is not selected yet. In this case, the valid_from date is not checked.
"""
if lock and not transaction.get_connection().in_atomic_block:
raise Exception('validate_memberships_in_order(lock=True) should only be called in atomic transaction!')
tz = event.timezone
applicable_positions = [
p for p in positions
+3 -33
View File
@@ -26,9 +26,8 @@ from typing import List
from django.conf import settings as django_settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.utils import IntegrityError
from django.utils.timezone import now
from django.utils.translation import gettext as _, ngettext
from django.utils.translation import gettext as _
from pretix.base.i18n import language
from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv
@@ -261,7 +260,6 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
# shorter. We'll see what works better in reality…
vouchers = []
codes = set()
lock_seats = []
for i, record in enumerate(data):
try:
@@ -270,14 +268,6 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
if not record.get("code"):
raise ValidationError(_('A voucher cannot be created without a code.'))
code = record.get("code")
if code.upper() in codes:
raise ValidationError(
_('Voucher codes must be unique. Code "{code}" already exists in this import.').format(
code=code,
)
)
codes.add(code.upper())
Voucher.clean_item_properties(
record,
event,
@@ -296,22 +286,8 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
lock_seats.append(voucher.seat)
except (ValidationError, ImportError) as e:
raise DataImportError(
_('Invalid data in row {row}: {message}').format(row=i + 1, message=str(e))
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
)
existing_codes = Voucher.objects.filter(
event=event,
code__in=codes,
).values_list("code", flat=True)
if len(existing_codes):
raise DataImportError(
ngettext(
'Voucher codes must be unique. Import contains existing voucher code {code}.',
'Voucher codes must be unique. Import contains existing voucher codes {code}.',
len(existing_codes)
).format(
code=", ".join(existing_codes)
)
)
with transaction.atomic():
# We don't support quotas here, so we only need to lock if seats are in use
@@ -324,13 +300,7 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
save_logentries = []
for v in vouchers:
try:
v.save()
except IntegrityError:
# should not happen as we check existing codes before, but we did not lock so we might have a race-condition
raise DataImportError(
_('Vouchers could not be imported, probably due to a voucher code already being in use.')
)
v.save()
save_logentries.append(v.log_action(
'pretix.voucher.added',
user=user,
+23 -52
View File
@@ -110,7 +110,6 @@ from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers.models import modelcopy
from pretix.helpers.periodic import minimum_interval
from pretix.presale.productlist import prepare_item_list_for_shop
from pretix.testutils.middleware import debugflags_var
@@ -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],
shared_lock_objects=[event]
)
elif any(cp.voucher and cp.voucher.budget for cp in sorted_positions):
# Voucher budgets are not guaranteed by the cart manager
lock_objects(
[op.voucher for op in sorted_positions if op.voucher and op.voucher.budget],
shared_lock_objects=[event]
)
q_avail = Counter()
v_avail = Counter()
@@ -1606,7 +1599,6 @@ class OrderChangeManager:
'seat_forbidden': gettext_lazy('The selected product does not allow to select a seat.'),
'tax_rule_country_blocked': gettext_lazy('The selected country is blocked by your tax rule.'),
'gift_card_change': gettext_lazy('You cannot change the price of a position that has been used to issue a gift card.'),
'gift_card_secret': gettext_lazy('You cannot change the ticket secret of a position that has been used to issue a gift card.'),
'max_items_per_product': ngettext_lazy(
"You cannot select more than %(max)s item of the product %(product)s.",
"You cannot select more than %(max)s items of the product %(product)s.",
@@ -1764,9 +1756,6 @@ class OrderChangeManager:
self._operations.append(self.RegenerateSecretOperation(position))
def change_ticket_secret(self, position: OrderPosition, new_secret: str):
if position.issued_gift_cards.exists():
raise OrderError(self.error_messages['gift_card_secret'])
self._operations.append(self.ChangeSecretOperation(position, new_secret))
def change_valid_from(self, position: OrderPosition, new_value: datetime):
@@ -1954,18 +1943,13 @@ class OrderChangeManager:
:param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values),
``"count"``, and ``"price"``.
:param limit_main_positions: By default, the method works on all 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
untouched.
"""
if self._operations:
raise ValueError("Setting addons should be the first/only operation")
def _allowed_on_order_sales_channel(item_or_var, order):
return item_or_var.all_sales_channels or (
order.sales_channel.identifier in (s.identifier for s in item_or_var.limit_sales_channels.all())
)
# Prepare containers for min/max check of products
item_counts = Counter()
for p in self.order.positions.all():
@@ -2059,11 +2043,13 @@ class OrderChangeManager:
if not item.is_available() or (variation and not variation.is_available()):
raise OrderError(error_messages['unavailable'])
if not _allowed_on_order_sales_channel(item, self.order):
raise OrderError(error_messages['unavailable'])
if not item.all_sales_channels:
if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if variation and not _allowed_on_order_sales_channel(variation, self.order):
raise OrderError(error_messages['unavailable'])
if variation and not variation.all_sales_channels:
if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available():
raise OrderError(error_messages['not_for_sale'])
@@ -2111,36 +2097,6 @@ class OrderChangeManager:
)
item_counts[item] += 1
def _addon_is_available(a):
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always set voucher=None because that's what's done when generating the form in
# OrderChangeMixin (vouchers for addons are not supported).
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
if not _allowed_on_order_sales_channel(a.item, self.order) or (
a.variation and not _allowed_on_order_sales_channel(a.variation, self.order)
):
return False
items, _ = prepare_item_list_for_shop(
self.order.event,
channel=self.order.sales_channel,
subevent=a.subevent,
voucher=None,
base_qs=Item.objects.filter(pk=a.item.pk),
allow_addons=True
)
if (not items) or items[0].current_unavailability_reason:
return False
if a.variation:
variations = [var for var in items[0].available_variations if var.pk == a.variation.pk]
if (not variations) or variations[0].current_unavailability_reason:
return False
return True
# Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()):
for k, v in al.items():
@@ -2150,7 +2106,22 @@ class OrderChangeManager:
for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled:
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
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
continue
+3 -4
View File
@@ -44,7 +44,7 @@ from django.conf import settings
from django.utils.crypto import get_random_string
from django.utils.formats import date_format
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.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:
with language(user.locale):
event_name = str(event.name)
mail(
user.email,
gettext('Data shredding completed for %(event)s') % {'event': event_name},
_('Data shredding completed'),
'pretixbase/email/shred_completed.txt',
{
'instance': settings.PRETIX_INSTANCE_NAME,
'user': user,
'organizer': event.organizer.name,
'event': event_name,
'event': str(event.name),
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
},
+3 -12
View File
@@ -1276,7 +1276,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'write_permission': 'event.settings.invoicing:write',
'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 "
"recommend to leave this option turned off and always issue a new invoice if a change needs "
"to be made."),
@@ -1924,6 +1924,8 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
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': {
@@ -2284,17 +2286,6 @@ DEFAULTS = {
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': {
'default': None,
'type': str,
+2 -3
View File
@@ -535,9 +535,8 @@ EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
event_live_issues = EventPluginSignal()
"""
This signal is sent out to determine whether an event can be taken live. If you want to
prevent the event from going live, return an error message to display to the user (either
as a SafeString containing HTML, or a string that will be HTML-escaped). If you don't,
your receiver should return ``None``.
prevent the event from going live, return a string that will be displayed to the user
as the error message. If you don't, your receiver should return ``None``.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -1,14 +1,10 @@
{% 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 "Estimated refund" %}: **{{ amount }}**
{% trans "Event" %}: {{ event }}
{% 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 %}
{% blocktrans with instance=instance %}Thanks,
The {{ instance }} Team{% endblocktrans %}
**{{ confirmation_code }}**
@@ -1,13 +1,12 @@
{% 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 }}
{% blocktrans with instance=instance %}Thanks,
The {{ instance }} Team{% endblocktrans %}
@@ -52,13 +52,13 @@
<table cellpadding="20"><tr><td>
<![endif]-->
<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 }}">
{% trans "Manage settings" %}
{% trans "Click here to view and change your notification settings" %}
</a>
{% if disable_url %}<br>
<a href="{{ disable_url }}">
{% trans "Disable all notifications" %}
{% trans "Click here disable all notifications immediately." %}
</a>
{% endif %}
</div>
@@ -1,21 +1,19 @@
{% load i18n %}
{{ 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 }}
{% endfor %}{% endif %}{% for action in notification.actions %}
{{ attr.title }}: {{ attr.value }}{% endfor %}{% for action in notification.actions %}
{{ action.label }}:
{{ action.label }}
{{ action.url }}{% endfor %}
{{ action.url }}{% endfor %}
---
{% trans "You're receiving this email based on your notification settings." %}
- {% trans "Manage settings" %}: {{ settings_url }}
{% if disable_url %}- {% trans "Disable all notifications" %}: {{ disable_url }}
{% 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:" %}
{{ disable_url }}
{% 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 }}
- Event: {{ event }}
- Data selection: {{ shredders }}
- Start time: {{ start_time }}
Organizer: {{ organizer }}
Any data added to the event after the start time may not have been deleted.
Event: {{ event }}
Thanks,
The {{ instance }} Team
Data selection: {{ shredders }}
Start time: {{ start_time }} (new data added after this time might not have been deleted)
Best regards,
Your {{ instance }} team
{% endblocktrans %}
@@ -1,28 +0,0 @@
{% extends "error.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load static %}
{% block content %}
<h1>{% trans "Please continue in a new tab" %}</h1>
<p class="larger">
{% blocktrans trimmed %}
For security reasons, the following step is only possible in a new tab.
{% endblocktrans %}
</p>
<p class="larger">
{% blocktrans trimmed %}
If the new tab did not open automatically, please click the following button:
{% endblocktrans %}
</p>
<div class="text-center">
<a href="{{ url }}"
class="btn btn-primary btn-lg" target="_blank">
<span class="fa fa-external-link-square"></span>
{% trans "Continue in new tab" %}
</a>
{{ url|json_script:"framebreak-url" }}
<script type="text/javascript" src="{% static "pretixbase/js/framebreak.js" %}"></script>
</div>
{% endblock %}
+2 -3
View File
@@ -22,7 +22,6 @@
import importlib
from django import template
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe
from pretix.base.models import Event
@@ -45,7 +44,7 @@ def eventsignal(event: Event, signame: str, **kwargs):
_html = []
for receiver, response in signal.send(event, **kwargs):
if response:
_html.append(conditional_escape(response))
_html.append(response)
return mark_safe("".join(_html))
@@ -64,5 +63,5 @@ def signal(signame: str, request, **kwargs):
_html = []
for receiver, response in signal.send(request, **kwargs):
if response:
_html.append(conditional_escape(response))
_html.append(response)
return mark_safe("".join(_html))
-18
View File
@@ -26,8 +26,6 @@ from babel.numbers import format_currency
from django import template
from django.conf import settings
from django.template.defaultfilters import floatformat
from django.utils import formats
from django.utils.safestring import mark_safe
from pretix.base.i18n import get_babel_locale
@@ -84,19 +82,3 @@ def money_numberfield_filter(value: Decimal, arg=''):
places = settings.CURRENCY_PLACES.get(arg, 2)
return str(value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP))
@register.filter(is_safe=True)
def tax_rate_format(number):
"""
Display a Decimal to its significant decimal places, used for tax rates.
"""
assert isinstance(number, Decimal)
return mark_safe(
formats.number_format(
number.normalize(),
-number.as_tuple().exponent,
use_l10n=True,
force_grouping=False,
)
)
+4 -2
View File
@@ -42,6 +42,8 @@ from bleach import DEFAULT_CALLBACKS, html5lib_shim
from bleach.linkifier import build_email_re
from django import template
from django.conf import settings
from django.core import signing
from django.urls import reverse
from django.utils.functional import SimpleLazyObject
from django.utils.html import escape
from django.utils.http import url_has_allowed_host_and_scheme
@@ -52,7 +54,6 @@ from markdown.postprocessors import Postprocessor
from markdown.treeprocessors import UnescapeTreeprocessor
from tlds import tld_set
from pretix.base.views.redirect import safelink
from pretix.helpers.format import SafeFormatter, format_map
register = template.Library()
@@ -157,7 +158,8 @@ def safelink_callback(attrs, new=False):
"""
url = html.unescape(attrs.get((None, 'href'), '/'))
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
attrs[None, 'href'] = safelink(url)
signer = signing.Signer(salt='safe-redirect')
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
attrs[None, 'target'] = '_blank'
attrs[None, 'rel'] = 'noopener'
return attrs
+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
# <https://www.gnu.org/licenses/>.
#
import json
import logging
import pathlib
import re
import secrets
from urllib.parse import urljoin
from urllib.request import urlopen
@@ -31,10 +33,6 @@ from django import template
from django.conf import settings
from django.utils.safestring import mark_safe
from pretix.base.middleware import (
add_to_response_csp_via_request, calculate_csp_hash,
)
register = template.Library()
LOGGER = logging.getLogger(__name__)
_MANIFEST = {}
@@ -236,16 +234,10 @@ def vite_importmap(context):
if not imports:
return ""
json_string = json.dumps({"imports": imports})
# Calculate hash and add it to the CSP info in the request, so that will be merged into the
# response header later on by the middleware
# Generate a nonce and store it on the request so the CSP middleware can allow it
nonce = secrets.token_urlsafe(16)
request = context.get('request')
if request:
csp_hash = calculate_csp_hash(json_string)
add_to_response_csp_via_request(request, {
'style-src': [csp_hash],
'script-src': [csp_hash],
})
request.csp_nonce = nonce
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.utils.functional import Promise
from django.utils.translation import gettext as _
from django.views.decorators.csrf import requires_csrf_token
from sentry_sdk import last_event_id
from pretix.base.i18n import language
from pretix.base.middleware import get_language_from_request
from pretix.multidomain.middlewares import requires_csrf_token
def csrf_failure(request, reason=""):
+6 -29
View File
@@ -19,7 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
import urllib.parse
from django.core import signing
@@ -27,8 +26,6 @@ from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
logger = logging.getLogger(__name__)
def _is_samesite_referer(request):
referer = request.headers.get('referer')
@@ -45,16 +42,11 @@ def _is_samesite_referer(request):
def redir_view(request):
framebreak = "framebreak" in request.GET
salt = 'framebreak-safelink-url' if framebreak else 'safelink-url'
signer = signing.Signer(salt='safe-redirect')
try:
url = signing.Signer(salt=salt).unsign(request.GET.get('url', ''))
url = signer.unsign(request.GET.get('url', ''))
except signing.BadSignature:
try:
# Backwards-compatibility for a change in 2026-06, remove after a while
url = signing.Signer(salt='safe-redirect').unsign(request.GET.get('url', ''))
except signing.BadSignature:
return HttpResponseBadRequest('Invalid parameter')
return HttpResponseBadRequest('Invalid parameter')
if not _is_samesite_referer(request):
u = urllib.parse.urlparse(url)
@@ -63,26 +55,11 @@ def redir_view(request):
'url': url,
})
if framebreak:
r = render(request, 'pretixbase/framebreak.html', {
'url': url,
})
r.xframe_options_exempt = True
return r
r = HttpResponseRedirect(url)
r['X-Robots-Tag'] = 'noindex'
return r
def safelink(url, framebreak=False):
url = str(url)
if not (url.startswith('https://') or url.startswith('http://') or url.startswith("/")):
logger.warning('Invalid URL passed to safelink: %r', url)
return '#invalid-url'
salt = 'framebreak-safelink-url' if framebreak else 'safelink-url'
signer = signing.Signer(salt=salt)
u = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
if framebreak:
u += "&framebreak=true"
return u
def safelink(url):
signer = signing.Signer(salt='safe-redirect')
return reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
+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
# <https://www.gnu.org/licenses/>.
#
import logging
import os
from celery import Celery, signals
from django.dispatch import receiver
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
logger = logging.getLogger(__name__)
from django.conf import settings
app = Celery('pretix')
app.config_from_object('django.conf:settings', namespace='CELERY')
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,
}
widgets = {
'date_from': SplitDateTimePickerWidget(without_seconds=True),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-date_from_0'}, without_seconds=True),
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-presale_start_0'}, without_seconds=True),
'date_from': SplitDateTimePickerWidget(),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-date_from_0'}),
'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-presale_start_0'}),
'slug': SlugWidget,
}
@@ -521,11 +521,11 @@ class EventUpdateForm(I18nModelForm):
'limit_sales_channels': SafeModelMultipleChoiceField,
}
widgets = {
'date_from': SplitDateTimePickerWidget(without_seconds=True),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}, without_seconds=True),
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}, without_seconds=True),
'date_from': SplitDateTimePickerWidget(),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
}
@@ -625,7 +625,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'max_items_per_order',
'reservation_time',
'contact_mail',
'contact_url',
'show_variations_expanded',
'hide_sold_out',
'meta_noindex',
@@ -672,11 +671,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
base_context = {
'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=''):
@@ -1679,7 +1673,7 @@ class CountriesAndEUAndStates(CountriesAndEU):
class TaxRuleLineForm(I18nForm):
country = LazyTypedChoiceField(
choices=lazy(lambda: CountriesAndEUAndStates(), CountriesAndEUAndStates),
choices=CountriesAndEUAndStates(),
required=False
)
address_type = forms.ChoiceField(
+1 -1
View File
@@ -770,7 +770,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
)
elif q.type == Question.TYPE_TIME:
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'),
**kwargs,
)
+4 -4
View File
@@ -246,8 +246,8 @@ class QuestionForm(I18nModelForm):
'valid_string_length_max',
]
widgets = {
'valid_datetime_min': SplitDateTimePickerWidget(without_seconds=True),
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True),
'valid_datetime_min': SplitDateTimePickerWidget(),
'valid_datetime_max': SplitDateTimePickerWidget(),
'valid_date_min': DatePickerWidget(),
'valid_date_max': DatePickerWidget(),
'items': forms.CheckboxSelectMultiple(
@@ -1427,6 +1427,6 @@ class ItemProgramTimeForm(I18nModelForm):
'end': forms.SplitDateTimeField,
}
widgets = {
'start': SplitDateTimePickerWidget(without_seconds=True),
'end': SplitDateTimePickerWidget(without_seconds=True),
'start': SplitDateTimePickerWidget(),
'end': SplitDateTimePickerWidget(),
}
+3 -1
View File
@@ -562,7 +562,9 @@ class OrderPositionChangeForm(forms.Form):
if instance.addon_to_id:
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']
choices = [
-1
View File
@@ -604,7 +604,6 @@ class OrganizerSettingsForm(SettingsForm):
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
'contact_url',
'imprint_url',
'organizer_info_text',
'event_list_type',
+6 -13
View File
@@ -39,7 +39,6 @@ from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper
from pretix.base.templatetags.money import money_filter
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.rrule import RRuleForm
from pretix.helpers.i18n import get_javascript_format_without_seconds
from pretix.helpers.money import change_decimal_field
@@ -81,11 +80,11 @@ class SubEventForm(I18nModelForm):
'presale_end': SplitDateTimeField,
}
widgets = {
'date_from': SplitDateTimePickerWidget(without_seconds=True),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}, without_seconds=True),
'date_from': SplitDateTimePickerWidget(),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
}
@@ -163,7 +162,7 @@ class SubEventBulkEditForm(I18nModelForm):
self.fields[k + '_time'] = forms.TimeField(
label=self._meta.model._meta.get_field(k).verbose_name,
help_text=self._meta.model._meta.get_field(k).help_text,
widget=TimePickerWidget(without_seconds=True),
widget=TimePickerWidget(),
required=False,
)
@@ -507,12 +506,6 @@ class TimeForm(forms.Form):
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['time_from'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
self.fields['time_to'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
self.fields['time_admission'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
TimeFormSet = formset_factory(
TimeForm,
+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
# License for the specific language governing permissions and limitations under the License.
import copy
import csv
from collections import Counter, namedtuple
from collections import namedtuple
from io import StringIO
from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import EmailValidator
from django.db.models import Count, F, Max
from django.db.models.functions import Upper
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django.utils.translation import gettext_lazy as _
from django_scopes.forms import SafeModelChoiceField
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.i18n import language
from pretix.base.models import Item, ItemVariation, Quota, SubEvent, Voucher
from pretix.base.models.vouchers import VoucherBulkData
from pretix.base.services.locking import lock_objects
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.models import Item, Voucher
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
from pretix.control.signals import voucher_form_validation
@@ -112,22 +105,15 @@ class VoucherForm(I18nModelForm):
except Item.DoesNotExist:
pass
super().__init__(*args, **kwargs)
if not self.event and self.instance:
self.event = self.instance.event
self.fields['tag'].widget.attrs['data-typeahead-url'] = reverse('control:event.vouchers.tags.typeahead', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
})
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
if instance.event.has_subevents:
self.fields['subevent'].queryset = instance.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'event': instance.event.slug,
'organizer': instance.event.organizer.slug,
}),
}
)
@@ -137,19 +123,18 @@ class VoucherForm(I18nModelForm):
del self.fields['subevent']
choices = []
prefix = (self.prefix + '-') if self.prefix else ''
if 'itemvar' in initial or (self.data and prefix + 'itemvar' in self.data):
iv = self.data.get(prefix + 'itemvar', '') or initial.get('itemvar', '') or ''
if 'itemvar' in initial or (self.data and 'itemvar' in self.data):
iv = self.data.get('itemvar') or initial.get('itemvar', '')
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)))
elif '-' in iv:
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)
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value)))
elif iv:
i = self.event.items.get(pk=iv)
i = self.instance.event.items.get(pk=iv)
if i.variations.exists():
choices.append((str(i.pk), _('{product} Any variation').format(product=i)))
else:
@@ -160,8 +145,8 @@ class VoucherForm(I18nModelForm):
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'event': instance.event.slug,
'organizer': instance.event.organizer.slug,
}),
'data-placeholder': _('All products')
}
@@ -169,7 +154,7 @@ class VoucherForm(I18nModelForm):
self.fields['itemvar'].required = False
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(
label=_("Specific seat ID"),
max_length=255,
@@ -179,45 +164,40 @@ class VoucherForm(I18nModelForm):
help_text=str(self.instance.seat) if self.instance.seat else '',
)
def parse_itemvar(self, data):
try:
itemid = quotaid = None
iv = data.get('itemvar', '')
if iv.startswith('q-'):
quotaid = iv[2:]
elif '-' in iv:
itemid, varid = iv.split('-')
elif iv:
itemid, varid = iv, None
else:
itemid, varid = None, None
if itemid:
item = self.event.items.get(pk=itemid)
if varid:
variation = item.variations.get(pk=varid)
else:
variation = None
quota = None
elif quotaid:
quota = self.event.quotas.get(pk=quotaid)
item = None
variation = None
else:
quota = None
item = None
variation = None
return (item, variation, quota)
except ObjectDoesNotExist:
raise ValidationError(_("Invalid product selected."))
def clean(self):
data = super().clean()
if not self._errors:
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:
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
@@ -229,7 +209,7 @@ class VoucherForm(I18nModelForm):
try:
Voucher.clean_item_properties(
data, self.event,
data, self.instance.event,
self.instance.quota, self.instance.item, self.instance.variation,
seats_given=data.get('seat') or data.get('seats'),
block_quota=data.get('block_quota')
@@ -249,7 +229,7 @@ class VoucherForm(I18nModelForm):
try:
Voucher.clean_subevent(
data, self.event
data, self.instance.event
)
except ValidationError as e:
raise ValidationError({"subevent": e.message})
@@ -265,19 +245,19 @@ class VoucherForm(I18nModelForm):
if check_quota:
Voucher.clean_quota_check(
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 data.get('seat'):
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
else:
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
@@ -285,215 +265,6 @@ class VoucherForm(I18nModelForm):
return super().save(commit)
class VoucherBulkEditForm(VoucherForm):
def __init__(self, *args, **kwargs):
self.mixed_values = kwargs.pop('mixed_values')
self.queryset = kwargs.pop('queryset')
super().__init__(**kwargs)
del self.fields["code"]
self.fields.pop("seat", None)
def is_bulk_checked(self, fieldname):
return self.prefix + fieldname in self.data.getlist('_bulk')
def clean(self):
# We skip the parent class because it's not suited for bulk editing and implement custom validation here.
# This does not validate *everything* we validate in VoucherForm. For example, we skip validation that one does
# not create a voucher for an add-on product or that the seat matches the product to save on complexity.
# This is a UX validation only anyway, since one could first create the voucher and then make the product an
# add-on product. However, we need to validate everything that we don't want violated in the database.
data = super(VoucherForm, self).clean()
if self.is_bulk_checked("itemvar"):
data["item"], data["variation"], data["quota"] = self.parse_itemvar(data)
if self.is_bulk_checked("max_usages") and "max_usages" in data:
max_redeemed = self.queryset.aggregate(m=Max("redeemed"))["m"]
if data["max_usages"] < max_redeemed:
raise ValidationError(_(
"You cannot reduce the maximum number of redemptions to %(max_usages)s, because at least one "
"of the selected vouchers has already been redeemed %(max_redeemed)s times."
) % {"max_usages": data["max_usages"], "max_redeemed": max_redeemed})
# Check diff on product and quota usage based on old groups of vouchers
if any(self.is_bulk_checked(k) for k in ("max_usages", "itemvar", "block_quota", "valid_until", "subevent")):
quota_diff = Counter()
current_vouchers = self.queryset.order_by().values(
"item", "variation", "quota", "block_quota", "valid_until", "subevent", "redeemed", "max_usages",
"allow_ignore_quota",
).annotate(c=Count("*"))
item_cache = {i.pk: i for i in Item.objects.filter(pk__in=[c["item"] for c in current_vouchers])}
var_cache = {v.pk: v for v in ItemVariation.objects.filter(pk__in=[c["variation"] for c in current_vouchers])}
quota_cache = {q.pk: q for q in Quota.objects.filter(pk__in=[c["quota"] for c in current_vouchers])}
subevent_cache = {s.pk: s for s in SubEvent.objects.filter(pk__in=[c["subevent"] for c in current_vouchers])}
for current in current_vouchers:
bulk_count = current.pop('c')
current = VoucherBulkData(**current)
# Get quotas that are currently used
if current.item:
current.item = item_cache[current.item]
if current.variation:
current.variation = var_cache[current.variation]
if current.quota:
current.quota = quota_cache[current.quota]
if current.subevent:
current.subevent = subevent_cache[current.subevent]
old_quotas = Voucher.clean_quota_get_ignored(current)
old_amount = max(current.max_usages - current.redeemed, 0) * bulk_count
# Predict state after change
after_change = copy.copy(current)
if self.is_bulk_checked("itemvar") and "itemvar" in data:
after_change.item = data["item"]
after_change.variation = data["variation"]
after_change.quota = data["quota"]
if self.is_bulk_checked("subevent") and "subevent" in data:
after_change.subevent = data["subevent"]
if self.is_bulk_checked("max_usages") and "max_usages" in data:
after_change.max_usages = data["max_usages"]
if self.is_bulk_checked("block_quota") and "block_quota" in data:
after_change.block_quota = data["block_quota"]
if self.is_bulk_checked("valid_until") and "valid_until" in data:
after_change.valid_until = data["valid_until"]
if self.is_bulk_checked("allow_ignore_quota") and "allow_ignore_quota" in data:
after_change.allow_ignore_quota = data["allow_ignore_quota"]
if after_change.quota and self.event.has_subevents and not after_change.subevent:
raise ValidationError(_("You cannot create a voucher that allows selection of a quota but has no date selected."))
if after_change.quota and after_change.subevent and after_change.quota.subevent_id != after_change.subevent.pk:
raise ValidationError(_("The selected quota does not match the selected subevent."))
if after_change.block_quota and self.event.has_subevents and not after_change.subevent:
raise ValidationError(
_('If you want this voucher to block quota, you need to select a specific date.'))
if after_change.block_quota and not after_change.item and not after_change.quota:
raise ValidationError(
_('You need to select a specific product or quota if this voucher should reserve '
'tickets.')
)
if after_change.allow_ignore_quota:
# todo: is this the most useful way to do this?
continue
new_quotas = Voucher.clean_quota_get_ignored(after_change)
new_amount = max(after_change.max_usages - after_change.redeemed, 0) * bulk_count
if new_quotas != old_quotas or new_amount != old_amount:
for q in old_quotas:
quota_diff[q] -= old_amount
for q in new_quotas:
quota_diff[q] += new_amount
if any(v > 0 for q, v in quota_diff.items()):
lock_objects([q for q, v in quota_diff.items() if q.size is not None and v > 0], shared_lock_objects=[self.event])
qa = QuotaAvailability(count_waitinglist=False)
qa.queue(*(q for q, v in quota_diff.items() if v > 0))
qa.compute()
if any(qa.results[q][0] != Quota.AVAILABILITY_OK or (qa.results[q][1] is not None and qa.results[q][1] < required)
for q, required in quota_diff.items() if required > 0):
raise ValidationError(_(
'There is no sufficient quota available to perform this change.'
))
has_seat = self.queryset.filter(seat__isnull=False).exists()
if has_seat:
if self.is_bulk_checked("max_usages"):
raise ValidationError(_(
'Changing the maximum number of usages in bulk is not supported if any of the selected vouchers '
'is assigned a seat.'
))
if self.is_bulk_checked("subevent"):
raise ValidationError(pgettext_lazy(
'subevent',
'Changing the date in bulk is not supported if any of the selected vouchers '
'is assigned a seat.'
))
if self.is_bulk_checked("itemvar") and data["quota"]:
raise ValidationError(_(
'Changing the product to a quota is not supported if any of the selected vouchers '
'is assigned a seat.'
))
if self.is_bulk_checked("valid_until"):
if data["valid_until"] is None or data["valid_until"] >= now():
currently_not_blocked_seats = self.queryset.filter(
seat__isnull=False,
max_usages__gt=F("redeemed"),
valid_until__lt=now(),
)
if self.event.has_subevents:
subevents = self.event.subevents.filter(pk__in=currently_not_blocked_seats.values_list("subevent"))
for se in subevents:
conflicts = currently_not_blocked_seats.filter(
subevent=se
).exclude(
seat_id__in=se.free_seats().values("pk")
)
if conflicts:
raise ValidationError(_(
'This change cannot be completed because not all assigned seats of the vouchers are '
'still available'
))
else:
conflicts = currently_not_blocked_seats.exclude(
seat_id__in=self.event.free_seats().values("pk")
)
if conflicts:
raise ValidationError(_(
'This change cannot be completed because not all assigned seats of the vouchers are '
'still available'
))
return data
def save(self, commit=True):
objs = list(self.queryset)
fields = set()
check_map = {
'price_mode': '__price',
'value': '__price',
}
for k in self.fields:
if not self.is_bulk_checked(check_map.get(k, k)):
continue
if k == 'itemvar':
fields.add("item")
fields.add("variation")
fields.add("quota")
else:
fields.add(k)
for obj in objs:
if k == 'itemvar':
obj.item = self.cleaned_data["item"]
obj.variation = self.cleaned_data["variation"]
obj.quota = self.cleaned_data["quota"]
else:
setattr(obj, k, self.cleaned_data[k])
fields = [f for f in fields if f != 'itemvars']
if fields:
Voucher.objects.bulk_update(objs, fields, 200)
def full_clean(self):
if len(self.data) == 0:
# form wasn't submitted
self._errors = ErrorDict()
return
super().full_clean()
def _post_clean(self):
pass # skip model-level clean
class VoucherBulkForm(VoucherForm):
codes = forms.CharField(
widget=forms.Textarea,
+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
# License for the specific language governing permissions and limitations under the License.
import functools
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
@@ -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):
prefix = _('The order has been changed:')
@@ -103,10 +93,10 @@ class OrderItemChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(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']))
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(
posid=data.get('positionid', '?'),
old_item=old_item, new_item=new_item,
@@ -163,14 +153,14 @@ class OrderTaxRuleChanged(OrderChangeLogEntryType):
if 'positionid' in data:
return _('Tax rule of position #{posid} changed from {old_rule} to {new_rule}.').format(
posid=data.get('positionid', '?'),
old_rule=object_id_to_string(TaxRule, pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=object_id_to_string(TaxRule, pk=data['new_taxrule']),
old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=TaxRule.objects.get(pk=data['new_taxrule']),
)
elif 'fee' in data:
return _('Tax rule of fee #{fee} changed from {old_rule} to {new_rule}.').format(
fee=data.get('fee', '?'),
old_rule=object_id_to_string(TaxRule, pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=object_id_to_string(TaxRule, pk=data['new_taxrule']),
old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '',
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):
old_item = str(event.items.get(pk=data['old_item']))
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(
posid=data.get('positionid', '?'),
old_item=old_item,
@@ -229,7 +219,7 @@ class OrderPositionAdded(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
item = str(event.items.get(pk=data['item']))
if data['variation']:
item += ' - ' + 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']:
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(
@@ -293,7 +283,7 @@ class OrderChangedSplit(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + 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={
'event': event.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.'),
})
class CheckinErrorLogEntryType(OrderLogEntryType):
def display(self, logentry: LogEntry, data):
return self.display_plain(self.plain, logentry, data)
@@ -364,7 +353,10 @@ class CheckinErrorLogEntryType(OrderLogEntryType):
event = logentry.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:
data['list'] = _("(unknown)")
+11 -28
View File
@@ -36,8 +36,7 @@ from urllib.parse import quote, urljoin, urlparse
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, logout
from django.contrib.auth.views import redirect_to_login
from django.http import Http404, HttpResponse
from django.http import Http404
from django.shortcuts import get_object_or_404, resolve_url
from django.template.response import TemplateResponse
from django.urls import get_script_prefix, resolve, reverse
@@ -98,8 +97,6 @@ class PermissionMiddleware:
super().__init__()
def _login_redirect(self, request):
from django.contrib.auth.views import redirect_to_login
# Taken from django/contrib/auth/decorators.py
path = request.build_absolute_uri()
# urlparse chokes on lazy objects in Python 3, force to str
@@ -112,21 +109,10 @@ class PermissionMiddleware:
if ((not login_scheme or login_scheme == current_scheme) and
(not login_netloc or login_netloc == current_netloc)):
path = request.get_full_path()
from django.contrib.auth.views import redirect_to_login
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# It's not useful to return a 302 redirect on a XMLHttpRequest request, because
# the XMLHttpRequest is unable to detect redirects.
return HttpResponse(
"Authentication required",
status=401,
headers={
# Appending ?next= is handled by client, because it should be the top-level context url,
# not the URL called in the background
"X-Login-Url": resolved_login_url,
}
)
return redirect_to_login(path, resolved_login_url, REDIRECT_FIELD_NAME)
return redirect_to_login(
path, resolved_login_url, REDIRECT_FIELD_NAME)
def __call__(self, request):
url = resolve(request.path_info)
@@ -226,17 +212,14 @@ class AuditLogMiddleware:
if request.path.startswith(get_script_prefix() + 'control') and request.user.is_authenticated:
if getattr(request.user, "is_hijacked", 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])
ss = hijacker.get_active_staff_session(request.session.get('hijacker_session'))
if not ss:
# Staff session expired or not found
logout(request)
return redirect_to_login(request.get_full_path())
ss.logs.create(
url=request.path,
method=request.method,
impersonating=request.user
)
if ss:
ss.logs.create(
url=request.path,
method=request.method,
impersonating=request.user
)
else:
ss = request.user.get_active_staff_session(request.session.session_key)
if ss:
+6 -14
View File
@@ -39,8 +39,7 @@ from pretix.base.signals import (
html_page_start = GlobalSignal()
"""
This signal allows you to put code in the beginning of the main page for every
page in the backend. You are expected to return a SafeString containing HTML, or
a string that will be HTML-escaped.
page in the backend. You are expected to return HTML.
The ``sender`` keyword argument will contain the request.
"""
@@ -130,7 +129,7 @@ event_dashboard_top = EventPluginSignal()
Arguments: 'request'
This signal is sent out to include custom HTML in the top part of the the event dashboard.
Receivers should return 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.
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
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"
(quarter of a row). May be ignored on small displays, default is "small")
* 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
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"
(quarter of a row). May be ignored on small displays, default is "small")
* 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.
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.
"""
@@ -211,7 +209,6 @@ Arguments: 'quota'
This signal allows you to append HTML to a Quota's detail view. You receive the
quota as argument in the ``quota`` keyword argument.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -222,7 +219,6 @@ Arguments: 'subevent'
This signal allows you to append HTML to a SubEvent's detail view. You receive the
subevent as argument in the ``subevent`` keyword argument.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -269,8 +265,7 @@ order_info = EventPluginSignal()
"""
Arguments: ``order``, ``request``
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.
This signal is sent out to display additional information on the order detail page
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available.
@@ -280,8 +275,7 @@ order_approve_info = EventPluginSignal()
"""
Arguments: ``order``, ``request``
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.
This signal is sent out to display additional information on the order approve page
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available.
@@ -292,7 +286,6 @@ order_position_buttons = EventPluginSignal()
Arguments: ``order``, ``position``, ``request``
This signal is sent out to display additional buttons for a single position of an order.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available.
@@ -322,7 +315,6 @@ Arguments: 'request'
This signal is sent out to include template snippets on the settings page of an event
that allows generating a pretix Widget code.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
A second keyword argument ``request`` will contain the request object.
@@ -56,4 +56,5 @@
</form>
<script type="text/plain" id="good_origin">{{ good_origin }}</script>
<script type="text/plain" id="bad_origin_report_url">{{ bad_origin_report_url }}</script>
<!-- pretix-login-marker -->{# marker required for ajax calls to detect that user session is over #}
{% endblock %}
@@ -3,7 +3,6 @@
{% load i18n %}
{% load static %}
{% load compress %}
{% load escapejson %}
{% block content %}
<form class="form-signin" action="" method="post" id="webauthn-form">
{% csrf_token %}
@@ -31,7 +30,8 @@
</form>
{% if jsondata %}
<script type="text/json" id="webauthn-login">
{{ jsondata|escapejson }}
{{ jsondata|safe }}
</script>
{% endif %}
{% compress js %}
@@ -28,7 +28,7 @@
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{{ w.content|safe }}
{% endif %}
</div>
</div>
@@ -51,7 +51,7 @@
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{{ w.content|safe }}
{% endif %}
</div>
</div>
@@ -72,7 +72,7 @@
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{{ w.content|safe }}
{% endif %}
</div>
</div>
@@ -94,7 +94,7 @@
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{{ w.content|safe }}
{% endif %}
</a>
{% else %}
@@ -102,7 +102,7 @@
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{{ w.content|safe }}
{% endif %}
</div>
{% 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 }}
{{ 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,
The {{ instance }} Team
Best regards,
Your {{ instance }} team
{% 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.
Thanks,
The {{ instance }} Team
{% endblocktrans %}
Your {{ instance }} team
{% endblocktrans %}
@@ -1,11 +1,9 @@
{% 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 }}
If you didn't request this, you can safely ignore this email — your password won't change.
Thanks,
The {{ instance }} Team
Best regards,
Your {{ instance }} team
{% endblocktrans %}
@@ -1,16 +1,17 @@
{% 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 }}
- Team: {{ team }}
To accept, follow the link below:
Organizer: {{ organizer }}
Team: {{ team }}
If you want to join that team, just click on the following link:
{{ 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,
The {{ instance }} Team
Best regards,
Your {{ instance }} team
{% 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:
{% 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.
a login to your {{ instance }} account from an unusual or new location was detected. The login was performed using {{ agent }} on {{ os }} from {{ country }}.
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 }}
Thanks,
The {{ instance }} Team
Best regards,
Your {{ instance }} team
{% endblocktrans %}
@@ -1,15 +1,16 @@
{% 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 }}
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 }}
Thanks,
The {{ instance }} Team
Best regards,
Your {{ instance }} team
{% endblocktrans %}
@@ -50,46 +50,6 @@
{% endblocktrans %}
</div>
{% endif %}
{% if dkim_warning %}
<div class="alert alert-danger">
<p>
{{ dkim_warning }}
</p>
<p>
{% trans "Your new DKIM record should be set up as a CNAME record like this:" %}
</p>
<pre><code>{{ dkim_hostname }} CNAME {{ dkim_cname }}</code></pre>
<p>
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
</p>
</div>
{% elif dkim_cname %}
<div class="alert alert-success">
{% blocktrans trimmed %}
We found a DKIM record on your domain for this system. Great!
{% endblocktrans %}
</div>
{% endif %}
{% if dmarc_warning %}
<div class="alert alert-danger">
<p>
{{ dmarc_warning }}
</p>
<p>
{% trans "Your new DMARC record could look like this:" %}
</p>
<pre><code>_dmarc.{{ hostname }} TXT "v=DMARC1; p=quarantine; sp=none; adkim=r; aspf=r;"</code></pre>
<p>
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
</p>
</div>
{% elif dkim_cname %}
<div class="alert alert-success">
{% blocktrans trimmed %}
We found a DMARC record on your domain for this system. Great!
{% endblocktrans %}
</div>
{% endif %}
{% if verification %}
<h3>{% trans "Verification" %}</h3>
<p>
@@ -110,7 +70,7 @@
</div>
</div>
{% if spf_warning or dkim_warning or dmarc_warning %}
{% if spf_warning %}
<div class="form-group submit-group">
<a href="" class="btn btn-default btn-save">
{% trans "Cancel" %}
@@ -106,7 +106,7 @@
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{{ w.content|safe }}
{% endif %}
</a>
{% elif w.link %}
@@ -114,7 +114,7 @@
{% if w.lazy %}
<span class="fa fa-cog fa-4x´"></span>
{% else %}
{{ w.content }}
{{ w.content|safe }}
{% endif %}
</a>
{% else %}
@@ -122,7 +122,7 @@
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{{ w.content|safe }}
{% endif %}
</div>
{% endif %}
@@ -19,7 +19,7 @@
</p>
<ul>
{% for issue in issues %}
<li>{{ issue }}</li>
<li>{{ issue|safe }}</li>
{% endfor %}
</ul>
</div>
@@ -42,7 +42,7 @@
</p>
<ul>
{% for issue in issues %}
<li>{{ issue }}</li>
<li>{{ issue|safe }}</li>
{% endfor %}
</ul>
</div>
@@ -12,7 +12,7 @@
<table class="table table-payment-providers">
<tbody>
{% for provider in providers %}
<tr{% if provider.highlight %} class="success-left"{% endif %}>
<tr>
<td>
<strong>{{ provider.verbose_name }}</strong>
</td>
@@ -56,7 +56,7 @@
<td colspan="4">
<br>
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
<a href="{{ plugin_settings_url }}?go=payment#tab-0-1-open" class="btn btn-default">
<a href="{{ plugin_settings_url }}#tab-0-1-open" class="btn btn-default">
<i class="fa fa-plus"></i> {% trans "Enable additional payment plugins" %}
</a>
</td>
@@ -28,7 +28,6 @@
</div>
<form action="" method="post" class="form-horizontal form-plugins">
{% csrf_token %}
<input type="hidden" name="go" value="{{ request.GET.go }}">
<div id="plugin_search_results" class="panel panel-default collapse">
<div class="panel-heading">
<button type="button" class="close" aria-label="Close"><span aria-hidden="true">×</span></button>
@@ -30,7 +30,6 @@
{% bootstrap_field form.date_admission layout="control" %}
{% bootstrap_field form.currency layout="control" %}
{% bootstrap_field sform.contact_mail layout="control" %}
{% bootstrap_field sform.contact_url layout="control" %}
{% bootstrap_field sform.imprint_url layout="control" %}
{% bootstrap_field form.is_public layout="control" %}
{% bootstrap_field form.all_sales_channels layout="control" %}
@@ -156,11 +156,11 @@
<br/>
<small class="text-muted">
{% if not i.tax_rule.price_includes_tax %}
{% blocktrans trimmed with rate=i.tax_rule.rate|tax_rate_format taxname=i.tax_rule.name %}
{% blocktrans trimmed with rate=i.tax_rule.rate|floatformat:-2 taxname=i.tax_rule.name %}
<strong>plus</strong> {{ rate }}% {{ taxname }}
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with rate=i.tax_rule.rate|tax_rate_format taxname=i.tax_rule.name|default:s_taxes %}
{% blocktrans trimmed with rate=i.tax_rule.rate|floatformat:-2 taxname=i.tax_rule.name|default:s_taxes %}
incl. {{ rate }}% {{ taxname }}
{% endblocktrans %}
{% endif %}
@@ -2,7 +2,6 @@
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% load wrap_in %}
{% block title %}
{% trans "Cancel order" %}
{% endblock %}
@@ -27,7 +26,7 @@
{% if form.cancellation_fee %}
{% if fee %}
{% with fee|money:request.event.currency as f %}
<p>{% blocktrans trimmed with fee=f|wrap_in:"strong" %}
<p>{% blocktrans trimmed with fee="<strong>"|add:f|add:"</strong>"|safe %}
The configured cancellation fee for a self-service cancellation would be {{ fee }} for this
order, but for a cancellation performed by you, you need to set the cancellation fee here:
{% endblocktrans %}</p>
@@ -284,14 +284,6 @@
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.operation_secret layout='inline' %}
{% if position.issued_gift_cards.exists %}
<div class="alert alert-info">
{% blocktrans trimmed %}
Ticket secrets of order positions that have been used to issue a gift card can not
be changed. Only the link will be changed in this case.
{% endblocktrans %}
</div>
{% endif %}
</div>
</div>
@@ -705,7 +705,7 @@
{% if line.tax_rate %}
<br/>
<small>
{% blocktrans trimmed with rate=line.tax_rate|tax_rate_format taxname=line.tax_rule.name|default:s_taxes %}
{% blocktrans trimmed with rate=line.tax_rate|floatformat:-2 taxname=line.tax_rule.name|default:s_taxes %}
<strong>plus</strong> {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
@@ -715,7 +715,7 @@
{% if line.tax_rate and line.price %}
<br/>
<small>
{% blocktrans trimmed with rate=line.tax_rate|tax_rate_format taxname=line.tax_rule.name|default:s_taxes %}
{% blocktrans trimmed with rate=line.tax_rate|floatformat:-2 taxname=line.tax_rule.name|default:s_taxes %}
incl. {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
@@ -755,7 +755,7 @@
{% if fee.tax_rate %}
<br/>
<small>
{% blocktrans trimmed with rate=fee.tax_rate|tax_rate_format taxname=fee.tax_rule.name|default:s_taxes %}
{% blocktrans trimmed with rate=fee.tax_rate|floatformat:-2 taxname=fee.tax_rule.name|default:s_taxes %}
<strong>plus</strong> {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
@@ -765,7 +765,7 @@
{% if fee.tax_rate %}
<br/>
<small>
{% blocktrans trimmed with rate=fee.tax_rate|tax_rate_format taxname=fee.tax_rule.name|default:s_taxes %}
{% blocktrans trimmed with rate=fee.tax_rate|floatformat:-2 taxname=fee.tax_rule.name|default:s_taxes %}
incl. {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
@@ -833,7 +833,7 @@
<strong>{% trans "Pending total" %}</strong>
</div>
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
<strong>{{ pending_sum|money:event.currency }}</strong>
<strong>{{ order.pending_sum|money:event.currency }}</strong>
</div>
<div class="clearfix"></div>
</div>
@@ -903,7 +903,7 @@
<tr>
<td colspan="1"></td>
<td colspan="5">
{{ p.html_info }}
{{ p.html_info|safe }}
{% if staff_session %}
<p>
<a href="" class="btn btn-default btn-xs admin-only" data-expandpayment data-id="{{ p.pk }}">
@@ -1018,7 +1018,7 @@
</dl>
{% endif %}
{% if r.html_info %}
{{ r.html_info }}
{{ r.html_info|safe }}
{% endif %}
{% if staff_session %}
<p>
@@ -2,7 +2,6 @@
{% load i18n %}
{% load static %}
{% load bootstrap3 %}
{% load escapejson %}
{% block inner %}
<h1>{% trans "Connect to device:" %} {{ device.name }}</h1>
@@ -19,7 +18,7 @@
{% trans "Open the app that you want to connect and optionally reset it to the original state." %}
</li>
<li>{% trans "Scan the following configuration code:" %}<br><br>
<script type="application/json" data-replace-with-qr>{{ qrdata|escapejson_dumps }}</script><br>
<script type="text/json" data-replace-with-qr>{{ qrdata|safe }}</script><br>
{% trans "If your app/device does not support scanning a QR code, you can also enter the following information:" %}
<br>
<strong>{% trans "System URL:" %}</strong> <code id="system_url">{{ settings.SITE_URL }}</code>
@@ -137,15 +137,6 @@
</td>
<td>
{{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }}
{% if staff_session %}
<details class="admin-only">
<summary>
<i class="fa fa-angle-down collapse-indicator"></i>
{% trans "Details" %}
</summary>
<pre class="admin-only">{{ d.info|pprint }}</pre>
</details>
{% endif %}
</td>
<td>
{% if d.initialized %}
@@ -34,7 +34,6 @@
{% endif %}
{% bootstrap_field sform.imprint_url layout="control" %}
{% bootstrap_field sform.contact_mail layout="control" %}
{% bootstrap_field sform.contact_url layout="control" %}
{% bootstrap_field sform.organizer_info_text layout="control" %}
{% bootstrap_field sform.event_team_provisioning layout="control" %}
{% if sform.allowed_restricted_plugins %}
@@ -560,10 +560,11 @@
</div>
</div>
</div>
<script type="text/plain" id="schema-url">{% static "schema/pdf-layout.schema.json" %}</script>
<script type="text/javascript" src="{% static "pdfjs/pdf.js" %}"></script>
<script type="text/javascript" src="{% static "ajv/ajv2020.bundle.min.js" %}"></script>
<script type="text/javascript" src="{% static "fabric/fabric.min.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/editor.js" %}"></script>
<script type="text/javascript" src="{% static "schema/pdf-layout.validate.js" %}"></script>
<img src="{% static 'pretixpresale/pdf/powered_by_pretix_dark.png' %}" id="poweredby-dark" class="sr-only">
<img src="{% static 'pretixpresale/pdf/powered_by_pretix_white.png' %}" id="poweredby-white" class="sr-only">
{% for family, styles in fonts.items %}
@@ -1,7 +1,6 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load escapejson %}
{% block title %}{% trans "Add a two-factor authentication device" %}{% endblock %}
{% block content %}
<h1>{% trans "Add a two-factor authentication device" %}</h1>
@@ -33,7 +32,7 @@
</li>
<li>
{% trans "Add a new account to the app by scanning the following barcode:" %}
<script type="application/json" data-replace-with-qr>{{ qrdata|escapejson_dumps }}</script>
<div class="qrcode-canvas" data-qrdata="#qrdata"></div>
<p>
<a data-toggle="collapse" href="#no_scan">
{% trans "Can't scan the barcode?" %}
@@ -82,4 +81,9 @@
</li>
</ol>
<script type="text/json" id="qrdata">
{{ qrdata|safe }}
</script>
{% endblock %}
@@ -3,7 +3,6 @@
{% load bootstrap3 %}
{% load static %}
{% load compress %}
{% load escapejson %}
{% block title %}{% trans "Add a two-factor authentication device" %}{% endblock %}
{% block content %}
<h1>{% trans "Add a two-factor authentication device" %}</h1>
@@ -27,7 +26,9 @@
{% trans "Device registration failed." %}
</div>
<script type="text/json" id="webauthn-enroll">
{{ jsondata|escapejson }}
{{ jsondata|safe }}
</script>
{% compress js %}
<script type="text/javascript" src="{% static "pretixcontrol/js/base64js.js" %}"></script>
@@ -3,7 +3,6 @@
{% load bootstrap3 %}
{% load compress %}
{% load static %}
{% load escapejson %}
{% block content %}
<form class="form-signin" id="webauthn-form" action="" method="post">
{% csrf_token %}
@@ -44,7 +43,7 @@
{% if jsondata %}
<script type="text/json" id="webauthn-login">
{{ jsondata|escapejson }}
{{ jsondata|safe }}
</script>
{% endif %}
{% compress js %}
@@ -1,83 +0,0 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load eventsignal %}
{% load eventurl %}
{% block title %}{% trans "Change multiple vouchers" %}{% endblock %}
{% block inside %}
<h1>
{% trans "Change multiple vouchers" %}
<small>
{% blocktrans trimmed with number=vouchers.count %}
{{ number }} selected
{% endblocktrans %}
</small>
</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<div class="hidden">
{% for v in vouchers %}
<input type="hidden" name="voucher" value="{{ v.pk }}">
{% endfor %}
</div>
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "Voucher details" %}</legend>
{% bootstrap_field form.max_usages layout="bulkedit" %}
{% bootstrap_field form.valid_until layout="bulkedit" %}
{% bootstrap_field form.itemvar layout="bulkedit" %}
<div class="bulk-edit-field-group">
<label class="field-toggle">
<input type="checkbox" name="_bulk" value="{{ form.prefix }}__price" {% if form.prefix|add:"__price" in bulk_selected %}checked{% endif %}>
{% trans "change" context "form_bulk" %}
</label>
<div class="field-content">
<div class="form-group">
<label class="col-md-3 control-label" for="id_tag">{% trans "Price effect" %}</label>
<div class="col-md-5">
{% bootstrap_field form.price_mode show_label=False form_group_class="" %}
</div>
<div class="col-md-4">
{% bootstrap_field form.value show_label=False form_group_class="" %}
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="controls">
<div class="alert alert-info">
{% blocktrans trimmed %}
If you choose "any product" for a specific quota and choose to reserve quota for this
voucher above, the product can still be unavailable to the voucher holder if another quota
associated with the product is sold out!
{% endblocktrans %}
</div>
</div>
</div>
</div>
{% if form.subevent %}
{% bootstrap_field form.subevent layout="bulkedit" %}
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced settings" %}</legend>
{% bootstrap_field form.block_quota layout="bulkedit" %}
{% bootstrap_field form.allow_ignore_quota layout="bulkedit" %}
{% bootstrap_field form.min_usages layout="bulkedit" %}
{% bootstrap_field form.budget addon_after=request.event.currency layout="bulkedit" %}
{% bootstrap_field form.tag layout="bulkedit" %}
{% bootstrap_field form.comment layout="bulkedit" %}
{% bootstrap_field form.show_hidden_items layout="bulkedit" %}
{% bootstrap_field form.all_addons_included layout="bulkedit" %}
{% bootstrap_field form.all_bundles_included layout="bulkedit" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -99,9 +99,6 @@
</p>
<form action="{% url "control:event.vouchers.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post">
{% csrf_token %}
{% for field in filter_form %}
{{ field.as_hidden }}
{% endfor %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
@@ -115,50 +112,38 @@
{% endif %}
<th>
{% trans "Voucher code" %}
<a href="?{% url_replace request 'filter-ordering' '-code' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'code' %}"><i class="fa fa-caret-up"></i></a>
<a href="?{% url_replace request 'ordering' '-code' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'code' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Redemptions" %}
<a href="?{% url_replace request 'filter-ordering' '-redeemed' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'redeemed' %}"><i class="fa fa-caret-up"></i></a>
<a href="?{% url_replace request 'ordering' '-redeemed' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'redeemed' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Expiry" %}
<a href="?{% url_replace request 'filter-ordering' '-valid_until' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'valid_until' %}"><i class="fa fa-caret-up"></i></a>
<a href="?{% url_replace request 'ordering' '-valid_until' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'valid_until' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Tag" %}
<a href="?{% url_replace request 'filter-ordering' '-tag' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'tag' %}"><i class="fa fa-caret-up"></i></a>
<a href="?{% url_replace request 'ordering' '-tag' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'tag' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Product" %}
<a href="?{% url_replace request 'filter-ordering' '-item' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'item' %}"><i class="fa fa-caret-up"></i></a>
<a href="?{% url_replace request 'ordering' '-item' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'item' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% if request.event.has_subevents %}
<th>
{% trans "Date" context "subevent" %}
<a href="?{% url_replace request 'filter-ordering' '-subevent' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'subevent' %}"><i class="fa fa-caret-up"></i></a>
<a href="?{% url_replace request 'ordering' '-subevent' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'subevent' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% endif %}
<th></th>
</tr>
{% if "event.vouchers:write" in request.eventpermset and page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all" data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="5">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for v in vouchers %}
@@ -226,10 +211,6 @@
<i class="fa fa-trash" aria-hidden="true"></i>
{% trans "Delete selected" %}
</button>
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit"
formaction="{% url "control:event.vouchers.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-edit"></i>{% trans "Edit selected" %}
</button>
</div>
{% endif %}
</form>

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