diff --git a/SECURITY.md b/SECURITY.md index eef106cd1..5e77ac9bb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,11 +2,11 @@ ## Reporting a vulnerability -If you discover a vulnerability with our software or server systems, please report it to us in private. Do not to attempt to harm our users, customer's data or our system's availability when looking for vulneratbilities. +If you discover a vulnerability with our software or server systems, please report it to us in private. Do not to attempt to harm our users, customer's data or our system's availability when looking for vulnerabilities. Please contact us at security@pretix.eu with full details and steps to reproduce and allow reasonable time for us to resolve the issue before publishing your findings. If you wish to encrypt your email, you can find our GPG key [here](https://pretix.eu/.well-known/security@pretix.eu.asc). -We're not large enough to run a formal bug bounty program, but if you find a serious vulnerability in our service, we will find a way to show our gratitude. +Please also see our [Responsible disclosure policy](https://docs.pretix.eu/trust/security/disclosure/). ## Version support @@ -18,3 +18,5 @@ subscribe to our [newsletter](https://pretix.eu/about/en/blog/) in the "News abo category, we will also send you an email on security issues. Past security issues are listed [on our website](https://pretix.eu/about/en/security). + +Please also see our [Release cycle](https://docs.pretix.eu/trust/lifecycle/release-cycle/) documentation. diff --git a/pyproject.toml b/pyproject.toml index ff553d1cd..c90d6a485 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,18 +80,18 @@ dependencies = [ "psycopg2-binary", "pycountry", "pycparser==2.22", - "pycryptodome==3.22.*", + "pycryptodome==3.23.*", "pypdf==5.4.*", "python-bidi==0.6.*", # Support for Arabic in reportlab "python-dateutil==2.9.*", "pytz", "pytz-deprecation-shim==0.1.*", "pyuca", - "qrcode==8.1", + "qrcode==8.2", "redis==5.2.*", - "reportlab==4.3.*", + "reportlab==4.4.*", "requests==2.31.*", - "sentry-sdk==2.25.*", + "sentry-sdk==2.29.*", "sepaxml==2.6.*", "stripe==7.9.*", "text-unidecode==1.*", @@ -114,7 +114,7 @@ dev = [ "flake8==7.2.*", "freezegun", "isort==6.0.*", - "pep8-naming==0.14.*", + "pep8-naming==0.15.*", "potypo", "pytest-asyncio>=0.24", "pytest-cache", diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py index 3321de8ff..af6c99d6b 100644 --- a/src/pretix/__init__.py +++ b/src/pretix/__init__.py @@ -19,4 +19,4 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -__version__ = "2025.4.0" +__version__ = "2025.5.0" diff --git a/src/pretix/_base_settings.py b/src/pretix/_base_settings.py index 061147051..8d4a90f93 100644 --- a/src/pretix/_base_settings.py +++ b/src/pretix/_base_settings.py @@ -101,6 +101,7 @@ ALL_LANGUAGES = [ ('fi', _('Finnish')), ('gl', _('Galician')), ('el', _('Greek')), + ('he', _('Hebrew')), ('id', _('Indonesian')), ('it', _('Italian')), ('ja', _('Japanese')), @@ -122,7 +123,7 @@ LANGUAGES_OFFICIAL = { } LANGUAGES_RTL = { # When adding more right-to-left languages, also update pretix/static/pretixbase/scss/_rtl.scss - 'ar', 'hw' + 'ar', 'he' } LANGUAGES_INCUBATING = { 'pt-br', 'gl', diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 465b4b2cf..616122179 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -378,6 +378,8 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): if prop.name not in meta_data: current_object.delete() + instance._prefetched_objects_cache.clear() + # Item Meta properties if item_meta_properties is not None: current = list(event.item_meta_properties.all()) @@ -398,6 +400,8 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): if prop.name not in list(item_meta_properties.keys()): prop.delete() + instance._prefetched_objects_cache.clear() + # Seats if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None): current_mappings = { diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index ad262415f..acff75904 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -426,6 +426,9 @@ class OrganizerSettingsSerializer(SettingsSerializer): 'organizer_logo_image_inherit', 'organizer_logo_image', 'privacy_url', + 'accessibility_url', + 'accessibility_title', + 'accessibility_text', 'cookie_consent', 'cookie_consent_dialog_title', 'cookie_consent_dialog_text', diff --git a/src/pretix/base/context.py b/src/pretix/base/context.py index 4a0c024b8..ce1addb1f 100644 --- a/src/pretix/base/context.py +++ b/src/pretix/base/context.py @@ -35,19 +35,22 @@ def get_powered_by(request, safelink=True): d = gs.settings.license_check_input if d.get('poweredby_name'): if d.get('poweredby_url'): - n = '{}'.format( - sl(d['poweredby_url']) if safelink else d['poweredby_url'], - d['poweredby_name'] + msg = gettext('powered by {name} based on pretix').format( + name=d['poweredby_name'], + a_name_attr='href="{}" target="_blank" rel="noopener"'.format( + sl(d['poweredby_url']) if safelink else d['poweredby_url'], + ), + a_attr='href="{}" target="_blank" rel="noopener"'.format( + sl('https://pretix.eu') if safelink else 'https://pretix.eu', + ) ) else: - n = d['poweredby_name'] - - msg = gettext('powered by {name} based on pretix').format( - name=n, - a_attr='href="{}" target="_blank" rel="noopener"'.format( - sl('https://pretix.eu') if safelink else 'https://pretix.eu', + msg = gettext('powered by {name} based on pretix').format( + name=d['poweredby_name'], + a_attr='href="{}" target="_blank" rel="noopener"'.format( + sl('https://pretix.eu') if safelink else 'https://pretix.eu', + ) ) - ) else: msg = gettext('ticketing powered by pretix') % { 'a_attr': 'href="{}" target="_blank" rel="noopener"'.format( @@ -71,7 +74,7 @@ def contextprocessor(request): try: ctx['poweredby'] = get_powered_by(request, safelink=True) except Exception: - ctx['poweredby'] = 'powered by pretix' + ctx['poweredby'] = 'powered by pretix' if settings.DEBUG and 'runserver' not in sys.argv: ctx['debug_warning'] = True elif 'runserver' in sys.argv: diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index 610074f54..c1d90c8cf 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -712,7 +712,7 @@ class OrderListExporter(MultiSheetListExporter): if name_scheme and len(name_scheme['fields']) > 1: for k, label, w in name_scheme['fields']: row.append( - get_name_parts_localized(op.attendee_name_parts, k) + get_name_parts_localized(op.attendee_name_parts, k) if op.attendee_name_parts else '' ) row += [ op.attendee_email, diff --git a/src/pretix/base/exporters/waitinglist.py b/src/pretix/base/exporters/waitinglist.py index 19d015e16..1b1cf1faf 100644 --- a/src/pretix/base/exporters/waitinglist.py +++ b/src/pretix/base/exporters/waitinglist.py @@ -108,8 +108,10 @@ class WaitingListExporter(ListExporter): _('Name'), _('Email'), _('Phone number'), - _('Product name'), + _('Product'), + _('Product ID'), _('Variation'), + _('Variation ID'), _('Event slug'), _('Event name'), pgettext_lazy('subevents', 'Date'), # Name of subevent @@ -146,7 +148,9 @@ class WaitingListExporter(ListExporter): entry.email, entry.phone, str(entry.item) if entry.item else "", + str(entry.item.pk) if entry.item else "", str(entry.variation) if entry.variation else "", + str(entry.variation.pk) if entry.variation else "", entry.event.slug, entry.event.name, entry.subevent.name if entry.subevent else "", diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 774060c9f..5064dd5a3 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -58,6 +58,7 @@ from django.urls import reverse from django.utils.formats import date_format from django.utils.html import escape from django.utils.safestring import mark_safe +from django.utils.text import format_lazy from django.utils.timezone import get_current_timezone from django.utils.translation import gettext_lazy as _, pgettext_lazy from django_countries import countries @@ -870,6 +871,23 @@ class BaseQuestionsForm(forms.Form): attrs['data-min'] = q.valid_date_min.isoformat() if q.valid_date_max: attrs['data-max'] = q.valid_date_max.isoformat() + if not help_text: + if q.valid_date_min and q.valid_date_max: + help_text = format_lazy( + 'Please enter a date between {min} and {max}.', + min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), + max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), + ) + elif q.valid_date_min: + help_text = format_lazy( + 'Please enter a date no earlier than {min}.', + min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), + ) + elif q.valid_date_max: + help_text = format_lazy( + 'Please enter a date no later than {max}.', + max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), + ) field = forms.DateField( label=label, required=required, help_text=help_text, @@ -888,6 +906,23 @@ class BaseQuestionsForm(forms.Form): widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')), ) elif q.type == Question.TYPE_DATETIME: + if not help_text: + if q.valid_datetime_min and q.valid_datetime_max: + help_text = format_lazy( + 'Please enter a date and time between {min} and {max}.', + min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), + max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), + ) + elif q.valid_datetime_min: + help_text = format_lazy( + 'Please enter a date and time no earlier than {min}.', + min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), + ) + elif q.valid_datetime_max: + help_text = format_lazy( + 'Please enter a date and time no later than {max}.', + max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), + ) field = SplitDateTimeField( label=label, required=required, help_text=help_text, diff --git a/src/pretix/base/migrations/0280_cartposition_max_extend.py b/src/pretix/base/migrations/0280_cartposition_max_extend.py new file mode 100644 index 000000000..16024151f --- /dev/null +++ b/src/pretix/base/migrations/0280_cartposition_max_extend.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.20 on 2025-05-14 14:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0279_discount_event_date_from_discount_event_date_until'), + ] + + operations = [ + migrations.AddField( + model_name='cartposition', + name='max_extend', + field=models.DateTimeField(null=True), + ), + ] diff --git a/src/pretix/base/migrations/0281_event_is_remote.py b/src/pretix/base/migrations/0281_event_is_remote.py new file mode 100644 index 000000000..57c49e365 --- /dev/null +++ b/src/pretix/base/migrations/0281_event_is_remote.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2025-05-20 11:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0280_cartposition_max_extend"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="is_remote", + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index db687f248..5267073b5 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -616,6 +616,11 @@ class Event(EventMixin, LoggedModel): max_length=200, verbose_name=_("Location"), ) + is_remote = models.BooleanField( + default=False, + verbose_name=_("This event is remote or partially remote."), + help_text=_("This will be used to let users know if the event is in a different timezone and let’s us calculate users’ local times."), + ) geo_lat = models.FloatField( verbose_name=_("Latitude"), null=True, blank=True, diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 7ac8beff1..9db278422 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -3098,7 +3098,10 @@ class CartPosition(AbstractPosition): verbose_name=_("Expiration date"), db_index=True ) - + max_extend = models.DateTimeField( + verbose_name=_("Limit for extending expiration date"), + null=True + ) tax_rate = models.DecimalField( max_digits=7, decimal_places=2, default=Decimal('0.00'), verbose_name=_('Tax rate') diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 02dc95991..95f303dc8 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -43,7 +43,6 @@ from zoneinfo import ZoneInfo from django import forms from django.conf import settings -from django.contrib import messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db import transaction from django.dispatch import receiver @@ -93,15 +92,73 @@ class PaymentProviderForm(Form): cleaned_data = super().clean() for k, v in self.fields.items(): val = cleaned_data.get(k) - if v._required and not val: + if hasattr(v, '_required') and v._required and not val: self.add_error(k, _('This field is required.')) return cleaned_data +class GiftCardPaymentForm(PaymentProviderForm): + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event') + self.testmode = kwargs.pop('testmode') + self.positions = kwargs.pop('positions') + self.used_cards = kwargs.pop('used_cards') + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + if "code" not in cleaned_data: + return cleaned_data + + code = cleaned_data["code"].strip() + msg = "" + for p in self.positions: + if p.item.issue_giftcard: + msg = _("You cannot pay with gift cards when buying a gift card.") + self.add_error('code', msg) + return cleaned_data + try: + event = self.event + gc = event.organizer.accepted_gift_cards.get( + secret=code + ) + if gc.currency != event.currency: + msg = _("This gift card does not support this currency.") + elif gc.testmode and not self.testmode: + msg = _("This gift card can only be used in test mode.") + elif not gc.testmode and self.testmode: + msg = _("Only test gift cards can be used in test mode.") + elif gc.expires and gc.expires < time_machine_now(): + msg = _("This gift card is no longer valid.") + elif gc.value <= Decimal("0.00"): + msg = _("All credit on this gift card has been used.") + + if msg: + self.add_error('code', msg) + return cleaned_data + + if gc.pk in self.used_cards: + self.add_error('code', _("This gift card is already used for your payment.")) + return cleaned_data + except GiftCard.DoesNotExist: + if event.vouchers.filter(code__iexact=code).exists(): + msg = _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " + "the product selection.") + self.add_error('code', msg) + else: + msg = _("This gift card is not known.") + self.add_error('code', msg) + except GiftCard.MultipleObjectsReturned: + msg = _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.") + self.add_error('code', msg) + return cleaned_data + + class BasePaymentProvider: """ This is the base class for all payment providers. """ + payment_form_template_name = 'pretixpresale/event/checkout_payment_form_default.html' def __init__(self, event: Event): self.event = event @@ -694,7 +751,7 @@ class BasePaymentProvider: :param order: Only set when this is a change to a new payment method for an existing order. """ form = self.payment_form(request) - template = get_template('pretixpresale/event/checkout_payment_form_default.html') + template = get_template(self.payment_form_template_name) ctx = {'request': request, 'form': form} return template.render(ctx) @@ -1318,6 +1375,49 @@ class GiftCardPayment(BasePaymentProvider): multi_use_supported = True execute_payment_needs_user = False verbose_name = _("Gift card") + payment_form_class = GiftCardPaymentForm + payment_form_template_name = 'pretixcontrol/giftcards/checkout.html' + + def payment_form(self, request: HttpRequest) -> Form: + # Unfortunately, in payment_form we do not know if we're in checkout + # or in an existing order. But we need to do the validation logic in the + # form to get the error messages in the right places for accessbility :-( + if 'checkout' in request.resolver_match.url_name: + cs = cart_session(request) + used_cards = [ + p.get('info_data', {}).get('gift_card') + for p in cs.get('payments', []) + if p.get('info_data', {}).get('gift_card') + ] + positions = get_cart(request) + testmode = self.event.testmode + else: + used_cards = [] + order = self.event.orders.get(code=request.resolver_match.kwargs["order"]) + positions = order.positions.all() + testmode = order.testmode + + form = self.payment_form_class( + event=self.event, + used_cards=used_cards, + positions=positions, + testmode=testmode, + data=(request.POST if request.method == 'POST' and request.POST.get("payment") == self.identifier else None), + prefix='payment_%s' % self.identifier, + initial={ + k.replace('payment_%s_' % self.identifier, ''): v + for k, v in request.session.items() + if k.startswith('payment_%s_' % self.identifier) + } + ) + form.fields = self.payment_form_fields + + for k, v in form.fields.items(): + v._required = v.required + v.required = False + v.widget.is_required = False + + return form @property def public_name(self) -> str: @@ -1350,6 +1450,19 @@ class GiftCardPayment(BasePaymentProvider): f.move_to_end("_enabled", last=False) return f + @property + def payment_form_fields(self): + fields = [ + ( + "code", + forms.CharField( + label=_("Gift card code"), + required=True, + ), + ), + ] + return OrderedDict(fields) + @property def test_mode_message(self) -> str: return _("In test mode, only test cards will work.") @@ -1360,11 +1473,6 @@ class GiftCardPayment(BasePaymentProvider): def order_change_allowed(self, order: Order) -> bool: return super().order_change_allowed(order) and self.event.organizer.has_gift_cards - def payment_form_render(self, request: HttpRequest, total: Decimal) -> str: - return get_template('pretixcontrol/giftcards/checkout.html').render({ - 'request': request, - }) - def checkout_confirm_render(self, request, order=None, info_data=None) -> str: return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({ 'info_data': info_data, @@ -1432,21 +1540,6 @@ class GiftCardPayment(BasePaymentProvider): def _add_giftcard_to_cart(self, cs, gc): from pretix.base.services.cart import add_payment_to_cart_session - if gc.currency != self.event.currency: - raise ValidationError(_("This gift card does not support this currency.")) - if gc.testmode and not self.event.testmode: - raise ValidationError(_("This gift card can only be used in test mode.")) - if not gc.testmode and self.event.testmode: - raise ValidationError(_("Only test gift cards can be used in test mode.")) - if gc.expires and gc.expires < time_machine_now(): - raise ValidationError(_("This gift card is no longer valid.")) - if gc.value <= Decimal("0.00"): - raise ValidationError(_("All credit on this gift card has been used.")) - - for p in cs.get('payments', []): - if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk: - raise ValidationError(_("This gift card is already used for your payment.")) - add_payment_to_cart_session( cs, self, @@ -1458,73 +1551,32 @@ class GiftCardPayment(BasePaymentProvider): ) def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]: - for p in get_cart(request): - if p.item.issue_giftcard: - messages.error(request, _("You cannot pay with gift cards when buying a gift card.")) - return + form = self.payment_form(request) + if not form.is_valid(): + return False - try: - gc = self.event.organizer.accepted_gift_cards.get( - secret=request.POST.get("giftcard").strip() - ) - cs = cart_session(request) - try: - self._add_giftcard_to_cart(cs, gc) - return True - except ValidationError as e: - messages.error(request, str(e.message)) - return - except GiftCard.DoesNotExist: - if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists(): - messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " - "the product selection.")) - else: - messages.error(request, _("This gift card is not known.")) - except GiftCard.MultipleObjectsReturned: - messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) + gc = self.event.organizer.accepted_gift_cards.get( + secret=form.cleaned_data["code"] + ) + cs = cart_session(request) + self._add_giftcard_to_cart(cs, gc) + return True def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str, None]: - for p in payment.order.positions.all(): - if p.item.issue_giftcard: - messages.error(request, _("You cannot pay with gift cards when buying a gift card.")) - return - - try: - gc = self.event.organizer.accepted_gift_cards.get( - secret=request.POST.get("giftcard").strip() - ) - if gc.currency != self.event.currency: - messages.error(request, _("This gift card does not support this currency.")) - return - if gc.testmode and not payment.order.testmode: - messages.error(request, _("This gift card can only be used in test mode.")) - return - if not gc.testmode and payment.order.testmode: - messages.error(request, _("Only test gift cards can be used in test mode.")) - return - if gc.expires and gc.expires < time_machine_now(): - messages.error(request, _("This gift card is no longer valid.")) - return - if gc.value <= Decimal("0.00"): - messages.error(request, _("All credit on this gift card has been used.")) - return - payment.info_data = { - 'gift_card': gc.pk, - 'gift_card_secret': gc.secret, - 'retry': True - } - payment.amount = min(payment.amount, gc.value) - payment.save() - - return True - except GiftCard.DoesNotExist: - if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard").strip()).exists(): - messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " - "the product selection.")) - else: - messages.error(request, _("This gift card is not known.")) - except GiftCard.MultipleObjectsReturned: - messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) + form = self.payment_form(request) + if not form.is_valid(): + return False + gc = self.event.organizer.accepted_gift_cards.get( + secret=form.cleaned_data["code"] + ) + payment.info_data = { + 'gift_card': gc.pk, + 'gift_card_secret': gc.secret, + 'retry': True + } + payment.amount = min(payment.amount, gc.value) + payment.save() + return True def execute_payment(self, request: HttpRequest, payment: OrderPayment, is_early_special_case=False) -> str: for p in payment.order.positions.all(): diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index d3e359013..5212b1307 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -45,6 +45,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import DatabaseError, transaction from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value +from django.db.models.aggregates import Min from django.dispatch import receiver from django.utils.timezone import make_aware, now from django.utils.translation import ( @@ -275,7 +276,10 @@ class CartManager: } def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel, - invoice_address: InvoiceAddress=None, widget_data=None, expiry=None): + invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None): + """ + Creates a new CartManager for an event. + """ self.event = event self.cart_id = cart_id self.real_now_dt = now() @@ -286,11 +290,17 @@ class CartManager: self._subevents_cache = {} self._variations_cache = {} self._seated_cache = {} - self._expiry = None - self._explicit_expiry = expiry self.invoice_address = invoice_address self._widget_data = widget_data or {} self._sales_channel = sales_channel + self.num_extended_positions = 0 + + if reservation_time: + self._reservation_time = reservation_time + else: + self._reservation_time = timedelta(minutes=self.event.settings.get('reservation_time', as_type=int)) + self._expiry = self.real_now_dt + self._reservation_time + self._max_expiry_extend = self.real_now_dt + (self._reservation_time * 11) @property def positions(self): @@ -305,14 +315,6 @@ class CartManager: self._seated_cache[item, subevent] = item.seat_category_mappings.filter(subevent=subevent).exists() return self._seated_cache[item, subevent] - def _calculate_expiry(self): - if self._explicit_expiry: - self._expiry = self._explicit_expiry - else: - self._expiry = self.real_now_dt + timedelta( - minutes=self.event.settings.get('reservation_time', as_type=int) - ) - def _check_presale_dates(self): if self.event.presale_start and time_machine_now(self.real_now_dt) < self.event.presale_start: raise CartError(error_messages['not_started']) @@ -329,9 +331,27 @@ class CartManager: raise CartError(error_messages['payment_ended']) def _extend_expiry_of_valid_existing_positions(self): + # real_now_dt is initialized at CartManager instantiation, so it's slightly in the past. Add a small + # delta to reduce risk of extending already expired CartPositions. + padded_now_dt = self.real_now_dt + timedelta(seconds=5) + + # Make sure we do not extend past the max_extend timestamp, allowing users to extend their valid positions up + # to 11 times the reservation time. If we add new positions to the cart while valid positions exist, the new + # positions' reservation will also be limited to max_extend of the oldest position. + # Only after all positions expire, an ExtendOperation may reset max_extend to another 11x reservation_time. + max_extend_existing = self.positions.filter(expires__gt=padded_now_dt).aggregate(m=Min('max_extend'))['m'] + if max_extend_existing: + self._expiry = min(self._expiry, max_extend_existing) + self._max_expiry_extend = max_extend_existing + # Extend this user's cart session to ensure all items in the cart expire at the same time # We can extend the reservation of items which are not yet expired without risk - self.positions.filter(expires__gt=self.real_now_dt).update(expires=self._expiry) + if self._expiry > padded_now_dt: + self.num_extended_positions += self.positions.filter( + expires__gt=padded_now_dt, expires__lt=self._expiry, + ).update( + expires=self._expiry, + ) def _delete_out_of_timeframe(self): err = None @@ -1246,6 +1266,7 @@ class CartManager: item=op.item, variation=op.variation, expires=self._expiry, + max_extend=self._max_expiry_extend, cart_id=self.cart_id, voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None, @@ -1294,7 +1315,9 @@ class CartManager: event=self.event, item=b.item, variation=b.variation, - expires=self._expiry, cart_id=self.cart_id, + expires=self._expiry, + max_extend=self._max_expiry_extend, + cart_id=self.cart_id, voucher=None, addon_to=cp, subevent=b.subevent, @@ -1321,12 +1344,14 @@ class CartManager: op.position.delete() elif available_count == 1: op.position.expires = self._expiry + op.position.max_extend = self._max_expiry_extend op.position.listed_price = op.listed_price op.position.price_after_voucher = op.price_after_voucher # op.position.price will be updated by recompute_final_prices_and_taxes() if op.position.pk not in deleted_positions: try: - op.position.save(force_update=True, update_fields=['expires', 'listed_price', 'price_after_voucher']) + op.position.save(force_update=True, update_fields=['expires', 'max_extend', 'listed_price', 'price_after_voucher']) + self.num_extended_positions += 1 except DatabaseError: # Best effort... The position might have been deleted in the meantime! pass @@ -1416,14 +1441,11 @@ class CartManager: def commit(self): self._check_presale_dates() self._check_max_cart_size() - self._calculate_expiry() err = self._delete_out_of_timeframe() err = self.extend_expired_positions() or err err = err or self._check_min_per_voucher() - self.real_now_dt = now() - self._extend_expiry_of_valid_existing_positions() err = self._perform_operations() or err self.recompute_final_prices_and_taxes() @@ -1632,6 +1654,31 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel raise CartError(error_messages['busy']) +@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) +def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> dict: + """ + Resets the expiry time of a cart to the configured reservation time of this event. + Limited to 11x the reservation time. + + :param event: The event ID in question + :param cart_id: The cart ID of the cart to modify + """ + with language(locale), time_machine_now_assigned(override_now_dt): + try: + sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) + except SalesChannel.DoesNotExist: + raise CartError("Invalid sales channel.") + try: + try: + cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) + cm.commit() + return {"success": cm.num_extended_positions, "expiry": cm._expiry, "max_expiry_extend": cm._max_expiry_extend} + except LockTimeoutException: + self.retry() + except (MaxRetriesExceededError, LockTimeoutException): + raise CartError(error_messages['busy']) + + @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en', invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None: diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index c7559b6c0..ce612003c 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -531,7 +531,7 @@ def send_invoices_to_organizer(sender, **kwargs): if i.event.settings.invoice_email_organizer: with language(i.event.settings.locale): mail( - email=i.event.settings.invoice_email_organizer, + email=[e.strip() for e in i.event.settings.invoice_email_organizer.split(",")], subject=_('New invoice: {number}').format(number=i.number), template=LazyI18nString.from_gettext(_( 'Hello,\n\n' diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index e82c75c00..32d14da3e 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -2221,73 +2221,79 @@ class OrderChangeManager: nextposid = self.order.all_positions.aggregate(m=Max('positionid'))['m'] + 1 split_positions = [] secret_dirty = set() + position_cache = {} + fee_cache = {} for op in self._operations: if isinstance(op, self.ItemOperation): + position = position_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.item', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_item': op.position.item.pk, - 'old_variation': op.position.variation.pk if op.position.variation else None, + 'position': position.pk, + 'positionid': position.positionid, + 'old_item': position.item.pk, + 'old_variation': position.variation.pk if position.variation else None, 'new_item': op.item.pk, 'new_variation': op.variation.pk if op.variation else None, - 'old_price': op.position.price, - 'addon_to': op.position.addon_to_id, - 'new_price': op.position.price + 'old_price': position.price, + 'addon_to': position.addon_to_id, + 'new_price': position.price }) - op.position.item = op.item - op.position.variation = op.variation - op.position._calculate_tax() + position.item = op.item + position.variation = op.variation + position._calculate_tax() - if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id: - listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent) - if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax: - price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price)) + if position.voucher_budget_use is not None and position.voucher and not position.addon_to_id: + listed_price = get_listed_price(position.item, position.variation, position.subevent) + if not position.item.tax_rule or position.item.tax_rule.price_includes_tax: + price_after_voucher = max(position.price, position.voucher.calculate_price(listed_price)) else: - price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price)) - op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00')) - secret_dirty.add(op.position) - op.position.save() + price_after_voucher = max(position.price - position.tax_value, position.voucher.calculate_price(listed_price)) + position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00')) + secret_dirty.add(position) + position.save() elif isinstance(op, self.MembershipOperation): + position = position_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.membership', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_membership_id': op.position.used_membership_id, + 'position': position.pk, + 'positionid': position.positionid, + 'old_membership_id': position.used_membership_id, 'new_membership_id': op.membership.pk if op.membership else None, }) - op.position.used_membership = op.membership - op.position.save() + position.used_membership = op.membership + position.save() elif isinstance(op, self.SeatOperation): + position = position_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_seat': op.position.seat.name if op.position.seat else "-", + 'position': position.pk, + 'positionid': position.positionid, + 'old_seat': position.seat.name if position.seat else "-", 'new_seat': op.seat.name if op.seat else "-", - 'old_seat_id': op.position.seat.pk if op.position.seat else None, + 'old_seat_id': position.seat.pk if position.seat else None, 'new_seat_id': op.seat.pk if op.seat else None, }) - op.position.seat = op.seat - secret_dirty.add(op.position) - op.position.save() + position.seat = op.seat + secret_dirty.add(position) + position.save() elif isinstance(op, self.SubeventOperation): + position = position_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_subevent': op.position.subevent.pk, + 'position': position.pk, + 'positionid': position.positionid, + 'old_subevent': position.subevent.pk, 'new_subevent': op.subevent.pk, - 'old_price': op.position.price, - 'new_price': op.position.price + 'old_price': position.price, + 'new_price': position.price }) - op.position.subevent = op.subevent - secret_dirty.add(op.position) - if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id: - listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent) - if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax: - price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price)) + position.subevent = op.subevent + secret_dirty.add(position) + if position.voucher_budget_use is not None and position.voucher and not position.addon_to_id: + listed_price = get_listed_price(position.item, position.variation, position.subevent) + if not position.item.tax_rule or position.item.tax_rule.price_includes_tax: + price_after_voucher = max(position.price, position.voucher.calculate_price(listed_price)) else: - price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price)) - op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00')) - op.position.save() + price_after_voucher = max(position.price - position.tax_value, position.voucher.calculate_price(listed_price)) + position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00')) + position.save() elif isinstance(op, self.AddFeeOperation): self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={ 'fee': op.fee.pk, @@ -2296,70 +2302,79 @@ class OrderChangeManager: op.fee._calculate_tax() op.fee.save() elif isinstance(op, self.FeeValueOperation): + fee = fee_cache.setdefault(op.fee.pk, op.fee) self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={ - 'fee': op.fee.pk, - 'old_price': op.fee.value, + 'fee': fee.pk, + 'old_price': fee.value, 'new_price': op.value.gross }) - op.fee.value = op.value.gross - op.fee._calculate_tax() - op.fee.save() + fee.value = op.value.gross + fee._calculate_tax() + fee.save() elif isinstance(op, self.PriceOperation): + position = position_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_price': op.position.price, - 'addon_to': op.position.addon_to_id, + 'position': position.pk, + 'positionid': position.positionid, + 'old_price': position.price, + 'addon_to': position.addon_to_id, 'new_price': op.price.gross }) - op.position.price = op.price.gross - op.position.tax_rate = op.price.rate - op.position.tax_value = op.price.tax - op.position.tax_code = op.price.code - op.position.save(update_fields=['price', 'tax_rate', 'tax_value', 'tax_code']) + position.price = op.price.gross + position.tax_rate = op.price.rate + position.tax_value = op.price.tax + position.tax_code = op.price.code + position.save(update_fields=['price', 'tax_rate', 'tax_value', 'tax_code']) elif isinstance(op, self.TaxRuleOperation): if isinstance(op.position, OrderPosition): + position = position_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'addon_to': op.position.addon_to_id, - 'old_taxrule': op.position.tax_rule.pk if op.position.tax_rule else None, + 'position': position.pk, + 'positionid': position.positionid, + 'addon_to': position.addon_to_id, + 'old_taxrule': position.tax_rule.pk if position.tax_rule else None, 'new_taxrule': op.tax_rule.pk }) + position._calculate_tax(op.tax_rule) + position.save() elif isinstance(op.position, OrderFee): + fee = fee_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={ - 'fee': op.position.pk, - 'fee_type': op.position.fee_type, - 'old_taxrule': op.position.tax_rule.pk if op.position.tax_rule else None, + 'fee': fee.pk, + 'fee_type': fee.fee_type, + 'old_taxrule': fee.tax_rule.pk if fee.tax_rule else None, 'new_taxrule': op.tax_rule.pk }) - op.position._calculate_tax(op.tax_rule) - op.position.save() + fee._calculate_tax(op.tax_rule) + fee.save() elif isinstance(op, self.CancelFeeOperation): + fee = fee_cache.setdefault(op.fee.pk, op.fee) self.order.log_action('pretix.event.order.changed.cancelfee', user=self.user, auth=self.auth, data={ - 'fee': op.fee.pk, - 'fee_type': op.fee.fee_type, - 'old_price': op.fee.value, + 'fee': fee.pk, + 'fee_type': fee.fee_type, + 'old_price': fee.value, }) - op.fee.canceled = True - op.fee.save(update_fields=['canceled']) + fee.canceled = True + fee.save(update_fields=['canceled']) elif isinstance(op, self.CancelOperation): - for gc in op.position.issued_gift_cards.all(): + position = position_cache.setdefault(op.position.pk, op.position) + for gc in position.issued_gift_cards.all(): gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk) - if gc.value < op.position.price: + if gc.value < position.price: raise OrderError(_( 'A position can not be canceled since the gift card {card} purchased in this order has ' 'already been redeemed.').format( card=gc.secret )) else: - gc.transactions.create(value=-op.position.price, order=self.order, acceptor=self.order.event.organizer) + gc.transactions.create(value=-position.price, order=self.order, acceptor=self.order.event.organizer) - for m in op.position.granted_memberships.with_usages().all(): + for m in position.granted_memberships.with_usages().all(): m.canceled = True m.save() - for opa in op.position.addons.all(): + for opa in position.addons.all(): + opa = position_cache.setdefault(opa.pk, opa) for gc in opa.issued_gift_cards.all(): gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk) if gc.value < opa.position.price: @@ -2393,22 +2408,22 @@ class OrderChangeManager: ) opa.save(update_fields=['canceled', 'secret']) self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_item': op.position.item.pk, - 'old_variation': op.position.variation.pk if op.position.variation else None, - 'old_price': op.position.price, + 'position': position.pk, + 'positionid': position.positionid, + 'old_item': position.item.pk, + 'old_variation': position.variation.pk if position.variation else None, + 'old_price': position.price, 'addon_to': None, }) - op.position.canceled = True - if op.position.voucher: - Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) + position.canceled = True + if position.voucher: + Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) assign_ticket_secret( - event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False + event=self.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False ) - if op.position in secret_dirty: - secret_dirty.remove(op.position) - op.position.save(update_fields=['canceled', 'secret']) + if position in secret_dirty: + secret_dirty.remove(position) + position.save(update_fields=['canceled', 'secret']) elif isinstance(op, self.AddOperation): pos = OrderPosition.objects.create( item=op.item, variation=op.variation, addon_to=op.addon_to, @@ -2433,20 +2448,22 @@ class OrderChangeManager: 'valid_until': op.valid_until.isoformat() if op.valid_until else None, }) elif isinstance(op, self.SplitOperation): - split_positions.append(op.position) + position = position_cache.setdefault(op.position.pk, op.position) + split_positions.append(position) elif isinstance(op, self.RegenerateSecretOperation): - op.position.web_secret = generate_secret() - op.position.save(update_fields=["web_secret"]) + position = position_cache.setdefault(op.position.pk, op.position) + position.web_secret = generate_secret() + position.save(update_fields=["web_secret"]) assign_ticket_secret( - event=self.event, position=op.position, force_invalidate=True, save=True + event=self.event, position=position, force_invalidate=True, save=True ) - if op.position in secret_dirty: - secret_dirty.remove(op.position) + if position in secret_dirty: + secret_dirty.remove(position) tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk, 'order': self.order.pk}) self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, + 'position': position.pk, + 'positionid': position.positionid, }) elif isinstance(op, self.ChangeSecretOperation): if OrderPosition.all.filter(order__event=self.event, secret=op.new_secret).exists(): @@ -2462,64 +2479,68 @@ class OrderChangeManager: 'positionid': op.position.positionid, }) elif isinstance(op, self.ChangeValidFromOperation): + position = position_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.valid_from', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, + 'position': position.pk, + 'positionid': position.positionid, 'new_value': op.valid_from.isoformat() if op.valid_from else None, - 'old_value': op.position.valid_from.isoformat() if op.position.valid_from else None, + 'old_value': position.valid_from.isoformat() if position.valid_from else None, }) - op.position.valid_from = op.valid_from - op.position.save(update_fields=['valid_from']) - secret_dirty.add(op.position) + position.valid_from = op.valid_from + position.save(update_fields=['valid_from']) + secret_dirty.add(position) elif isinstance(op, self.ChangeValidUntilOperation): + position = position_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.valid_until', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, + 'position': position.pk, + 'positionid': position.positionid, 'new_value': op.valid_until.isoformat() if op.valid_until else None, - 'old_value': op.position.valid_until.isoformat() if op.position.valid_until else None, + 'old_value': position.valid_until.isoformat() if position.valid_until else None, }) - op.position.valid_until = op.valid_until - op.position.save(update_fields=['valid_until']) - secret_dirty.add(op.position) + position.valid_until = op.valid_until + position.save(update_fields=['valid_until']) + secret_dirty.add(position) elif isinstance(op, self.AddBlockOperation): + position = position_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.add_block', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, + 'position': position.pk, + 'positionid': position.positionid, 'block_name': op.block_name, }) - if op.position.blocked: - if op.block_name not in op.position.blocked: - op.position.blocked = op.position.blocked + [op.block_name] + if position.blocked: + if op.block_name not in position.blocked: + position.blocked = position.blocked + [op.block_name] else: - op.position.blocked = [op.block_name] + position.blocked = [op.block_name] if op.ignore_from_quota_while_blocked is not None: - op.position.ignore_from_quota_while_blocked = op.ignore_from_quota_while_blocked - op.position.save(update_fields=['blocked', 'ignore_from_quota_while_blocked']) - if op.position.blocked: - op.position.blocked_secrets.update_or_create( + position.ignore_from_quota_while_blocked = op.ignore_from_quota_while_blocked + position.save(update_fields=['blocked', 'ignore_from_quota_while_blocked']) + if position.blocked: + position.blocked_secrets.update_or_create( event=self.event, - secret=op.position.secret, + secret=position.secret, defaults={ 'blocked': True, 'updated': now(), } ) elif isinstance(op, self.RemoveBlockOperation): + position = position_cache.setdefault(op.position.pk, op.position) self.order.log_action('pretix.event.order.changed.remove_block', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, + 'position': position.pk, + 'positionid': position.positionid, 'block_name': op.block_name, }) - if op.position.blocked and op.block_name in op.position.blocked: - op.position.blocked = [b for b in op.position.blocked if b != op.block_name] - if not op.position.blocked: - op.position.blocked = None + if position.blocked and op.block_name in position.blocked: + position.blocked = [b for b in position.blocked if b != op.block_name] + if not position.blocked: + position.blocked = None if op.ignore_from_quota_while_blocked is not None: - op.position.ignore_from_quota_while_blocked = op.ignore_from_quota_while_blocked - op.position.save(update_fields=['blocked', 'ignore_from_quota_while_blocked']) - if not op.position.blocked: + position.ignore_from_quota_while_blocked = op.ignore_from_quota_while_blocked + position.save(update_fields=['blocked', 'ignore_from_quota_while_blocked']) + if not position.blocked: try: - bs = op.position.blocked_secrets.get(secret=op.position.secret) + bs = position.blocked_secrets.get(secret=position.secret) bs.blocked = False bs.save() except BlockedTicketSecret.DoesNotExist: @@ -2747,7 +2768,11 @@ class OrderChangeManager: def _check_complete_cancel(self): current = self.order.positions.count() - cancels = len([o for o in self._operations if isinstance(o, (self.CancelOperation, self.SplitOperation))]) + cancels = sum([ + 1 + o.position.addons.count() for o in self._operations if isinstance(o, self.CancelOperation) + ]) + len([ + o for o in self._operations if isinstance(o, self.SplitOperation) + ]) adds = len([o for o in self._operations if isinstance(o, self.AddOperation)]) if current > 0 and current - cancels + adds < 1: raise OrderError(self.error_messages['complete_cancel']) diff --git a/src/pretix/base/services/tax.py b/src/pretix/base/services/tax.py index 70446dfdc..5d442c504 100644 --- a/src/pretix/base/services/tax.py +++ b/src/pretix/base/services/tax.py @@ -62,6 +62,9 @@ class VATIDTemporaryError(VATIDError): def _validate_vat_id_NO(vat_id, country_code): # Inspired by vat_moss library + if not vat_id.startswith("NO"): + # prefix is not usually used in Norway, but expected by vat_moss library + vat_id = "NO" + vat_id try: vat_id = vat_moss.id.normalize(vat_id) except ValueError: diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 6d5a24b9f..50495abc1 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -71,6 +71,7 @@ from pretix.base.reldate import ( RelativeDateField, RelativeDateTimeField, RelativeDateWrapper, SerializerRelativeDateField, SerializerRelativeDateTimeField, ) +from pretix.base.validators import multimail_validate from pretix.control.forms import ( ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget, ) @@ -1233,14 +1234,18 @@ DEFAULTS = { 'invoice_email_organizer': { 'default': '', 'type': str, - 'form_class': forms.EmailField, - 'serializer_class': serializers.EmailField, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, 'form_kwargs': dict( label=_("Email address to receive a copy of each invoice"), help_text=_("Each newly created invoice will be sent to this email address shortly after creation. You can " "use this for an automated import of invoices to your accounting system. The invoice will be " "the only attachment of the email."), - ) + validators=[multimail_validate], + ), + 'serializer_kwargs': dict( + validators=[multimail_validate], + ), }, 'show_items_outside_presale_period': { 'default': 'True', @@ -2058,6 +2063,38 @@ DEFAULTS = { ), 'serializer_class': I18nURLField, }, + 'accessibility_url': { + 'default': None, + 'type': LazyI18nString, + 'form_class': I18nURLFormField, + 'form_kwargs': dict( + label=_("Accessibility information URL"), + help_text=_("This should point e.g. to a part of your website that explains how your ticket shop complies " + "with accessibility regulation."), + widget=I18nTextInput, + ), + 'serializer_class': I18nURLField, + }, + 'accessibility_title': { + 'default': LazyI18nString.from_gettext(gettext_noop("Accessibility information")), + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_("Accessibility information title"), + widget=I18nTextInput, + ), + 'serializer_class': I18nURLField, + }, + 'accessibility_text': { + 'default': None, + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_("Accessibility information text"), + widget=I18nMarkdownTextarea, + ), + 'serializer_class': I18nURLField, + }, 'confirm_texts': { 'default': LazyI18nStringList(), 'type': LazyI18nStringList, @@ -2778,7 +2815,7 @@ Your {organizer} team""")) # noqa: W291 ), }, 'theme_color_success': { - 'default': '#50a167', + 'default': '#408252', 'type': str, 'form_class': forms.CharField, 'serializer_class': serializers.CharField, diff --git a/src/pretix/base/templates/icons/seat.svg b/src/pretix/base/templates/icons/seat.svg new file mode 100644 index 000000000..cf1ecee25 --- /dev/null +++ b/src/pretix/base/templates/icons/seat.svg @@ -0,0 +1,6 @@ +{% load i18n %} + + + + diff --git a/src/pretix/base/templatetags/dialog.py b/src/pretix/base/templatetags/dialog.py new file mode 100644 index 000000000..0cc422455 --- /dev/null +++ b/src/pretix/base/templatetags/dialog.py @@ -0,0 +1,61 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +from django import template +from django.utils.html import format_html +from django.utils.safestring import mark_safe + +from pretix.helpers.templatetags.simple_block_tag import ( + register_simple_block_tag, +) + +from django.utils.translation import gettext_lazy as _ # NOQA + + +register = template.Library() + + +@register_simple_block_tag(register) +def dialog(content, html_id, title, description, *args, **kwargs): + format_kwargs = { + "id": html_id, + "title": title, + "description": description, + "icon": format_html('', kwargs["icon"]) if "icon" in kwargs else "", + "alert": mark_safe('role="alertdialog"') if kwargs.get("alert", "False") != "False" else "", + "content": content, + } + result = """ + + + + """ + return format_html(result, **format_kwargs) diff --git a/src/pretix/base/validators.py b/src/pretix/base/validators.py index 254e6778a..3b2a63531 100644 --- a/src/pretix/base/validators.py +++ b/src/pretix/base/validators.py @@ -73,6 +73,7 @@ class EventSlugBanlistValidator(BanlistValidator): 'customer', 'account', 'lead', + 'accessibility', ] diff --git a/src/pretix/base/views/metrics.py b/src/pretix/base/views/metrics.py index c61e55352..8df859146 100644 --- a/src/pretix/base/views/metrics.py +++ b/src/pretix/base/views/metrics.py @@ -80,4 +80,4 @@ def serve_metrics(request): content = "\n".join(output) + "\n" - return HttpResponse(content) + return HttpResponse(content, content_type="text/plain;version=1.0.0;escaping=allow-utf-8") diff --git a/src/pretix/base/views/tasks.py b/src/pretix/base/views/tasks.py index 8a9e6e909..949cef639 100644 --- a/src/pretix/base/views/tasks.py +++ b/src/pretix/base/views/tasks.py @@ -68,7 +68,7 @@ class AsyncMixin: def get_check_url(self, task_id, ajax): return self.request.path + '?async_id=%s' % task_id + ('&ajax=1' if ajax else '') - def _ajax_response_data(self): + def _ajax_response_data(self, value): return {} def _return_ajax_result(self, res, timeout=.5): @@ -85,7 +85,7 @@ class AsyncMixin: logger.warning('Ignored ResponseError in AsyncResult.get()') except ConnectionError: # Redis probably just restarted, let's just report not ready and retry next time - data = self._ajax_response_data() + data = self._ajax_response_data(None) data.update({ 'async_id': res.id, 'ready': False @@ -93,7 +93,7 @@ class AsyncMixin: return data state, info = res.state, res.info - data = self._ajax_response_data() + data = self._ajax_response_data(info) data.update({ 'async_id': res.id, 'ready': ready, @@ -102,23 +102,21 @@ class AsyncMixin: if ready: if state == states.SUCCESS and not isinstance(info, Exception): smes = self.get_success_message(info) - if smes: + if smes and 'ajax_dont_redirect' not in self.request.GET and 'ajax_dont_redirect' not in self.request.POST: messages.success(self.request, smes) - # TODO: Do not store message if the ajax client states that it will not redirect - # but handle the message itself data.update({ 'redirect': self.get_success_url(info), 'success': True, - 'message': str(self.get_success_message(info)) + 'message': str(smes) }) else: - messages.error(self.request, self.get_error_message(info)) - # TODO: Do not store message if the ajax client states that it will not redirect - # but handle the message itself + smes = self.get_error_message(info) + if smes and 'ajax_dont_redirect' not in self.request.GET and 'ajax_dont_redirect' not in self.request.POST: + messages.error(self.request, smes) data.update({ 'redirect': self.get_error_url(), 'success': False, - 'message': str(self.get_error_message(info)) + 'message': str(smes) }) elif state == 'PROGRESS': data.update({ diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index f94763816..7e5b59468 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -175,6 +175,7 @@ class EventWizardBasicsForm(I18nModelForm): 'presale_start', 'presale_end', 'location', + 'is_remote', 'geo_lat', 'geo_lon', ] @@ -448,6 +449,7 @@ class EventUpdateForm(I18nModelForm): 'presale_start', 'presale_end', 'location', + 'is_remote', 'geo_lat', 'geo_lon', 'all_sales_channels', diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 8978eb20e..688f66629 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -498,6 +498,9 @@ class OrganizerSettingsForm(SettingsForm): 'theme_round_borders', 'primary_font', 'privacy_url', + 'accessibility_url', + 'accessibility_title', + 'accessibility_text', 'cookie_consent', 'cookie_consent_dialog_title', 'cookie_consent_dialog_text', diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index 5e1f28f77..1329a1bc8 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -178,6 +178,13 @@ class SubEventBulkEditForm(I18nModelForm): widgets = { } + def clean(self): + data = super().clean() + if self.prefix + "name" in self.data.getlist('_bulk'): + if not data.get("name"): + self.add_error("name", _("This field is required.")) + return data + def save(self, commit=True): objs = list(self.queryset) fields = set() diff --git a/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html b/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html index daea99f0d..760769b3a 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html +++ b/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html @@ -14,7 +14,7 @@ -
+ {% if jsondata %} diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 79d9c5f52..576f6e410 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -4,6 +4,8 @@ {% load statici18n %} {% load eventsignal %} {% load eventurl %} +{% load dialog %} +{% load icon %} @@ -33,6 +35,7 @@ + @@ -463,25 +466,16 @@
-
+ -
- @@ -142,4 +171,4 @@ SCT

-{% endif %} \ No newline at end of file +{% endif %} diff --git a/src/pretix/plugins/checkinlists/exporters.py b/src/pretix/plugins/checkinlists/exporters.py index 43a495e87..53fff19fe 100644 --- a/src/pretix/plugins/checkinlists/exporters.py +++ b/src/pretix/plugins/checkinlists/exporters.py @@ -801,7 +801,13 @@ class CheckinLogList(ListExporter): ia = ci.position.order.invoice_address except InvoiceAddress.DoesNotExist: ia = InvoiceAddress() - + name = ( + ci.position.attendee_name or + (ci.position.addon_to.attendee_name if ci.position.addon_to else '') or + ia.name + ) + else: + name = "" yield [ date_format(ci.datetime.astimezone(self.timezone), 'SHORT_DATE_FORMAT'), date_format(ci.datetime.astimezone(self.timezone), 'TIME_FORMAT'), @@ -811,7 +817,7 @@ class CheckinLogList(ListExporter): ci.position.positionid if ci.position else '', ci.raw_barcode or ci.position.secret, str(ci.position.item) if ci.position else (str(ci.raw_item) if ci.raw_item else ''), - (ci.position.attendee_name or ia.name) if ci.position else '', + name, str(ci.device) if ci.device else '', _('Yes') if ci.force_sent is True else (_('No') if ci.force_sent is False else '?'), _('Yes') if ci.forced else _('No'), diff --git a/src/pretix/plugins/paypal2/payment.py b/src/pretix/plugins/paypal2/payment.py index f494d9c1b..677cbe003 100644 --- a/src/pretix/plugins/paypal2/payment.py +++ b/src/pretix/plugins/paypal2/payment.py @@ -625,8 +625,22 @@ class PaypalMethod(BasePaymentProvider): } return template.render(ctx) - @transaction.atomic + # We are wrapping the actual _execute_payment() here, since PaymentExceptions + # within the atomic transaction would rollback any changes to the payment-object, + # this throwing away any logentries and payment.fail() def execute_payment(self, request: HttpRequest, payment: OrderPayment): + ex = None + with transaction.atomic(): + try: + return self._execute_payment(request, payment) + except PaymentException as e: + ex = e + if ex: + raise ex + + return False + + def _execute_payment(self, request: HttpRequest, payment: OrderPayment): payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=payment.pk) if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED: logger.warning('payment is already confirmed; possible return-view/webhook race-condition') diff --git a/src/pretix/presale/forms/customer.py b/src/pretix/presale/forms/customer.py index 2b6e4f835..825892bea 100644 --- a/src/pretix/presale/forms/customer.py +++ b/src/pretix/presale/forms/customer.py @@ -44,6 +44,7 @@ from pretix.base.forms.questions import ( from pretix.base.i18n import get_language_without_region from pretix.base.models import Customer from pretix.helpers.http import get_client_ip +from pretix.multidomain.urlreverse import build_absolute_uri class TokenGenerator(PasswordResetTokenGenerator): @@ -65,19 +66,29 @@ class AuthenticationForm(forms.Form): error_messages = { 'incomplete': _('You need to fill out all fields.'), + 'empty_email': _('You need to enter an email address.'), + 'empty_password': _('You need to enter a password.'), 'invalid_login': _( "We have not found an account with this email address and password." ), + 'invalid_login_email': _('Please verify that you entered the correct email address.'), + 'invalid_login_password': _('Please enter the correct password.'), 'inactive': _("This account is disabled."), 'unverified': _("You have not yet activated your account and set a password. Please click the link in the " - "email we sent you. Click \"Reset password\" to receive a new email in case you cannot find " - "it again."), + "email we sent you. In case you cannot find it, click \"Forgot your password?\" to receive " + "a new email."), } def __init__(self, request=None, *args, **kwargs): self.request = request self.customer_cache = None super().__init__(*args, **kwargs) + self.fields['password'].help_text = "{}".format( + build_absolute_uri(False, 'presale:organizer.customer.resetpw', kwargs={ + 'organizer': request.organizer.slug, + }), + _('Forgot your password?') + ) def clean(self): email = self.cleaned_data.get('email') @@ -94,6 +105,8 @@ class AuthenticationForm(forms.Form): if u.check_password(password): self.customer_cache = u if self.customer_cache is None: + self.add_error("email", self.error_messages['invalid_login_email']) + self.add_error("password", self.error_messages['invalid_login_password']) raise forms.ValidationError( self.error_messages['invalid_login'], code='invalid_login', @@ -101,6 +114,10 @@ class AuthenticationForm(forms.Form): else: self.confirm_login_allowed(self.customer_cache) else: + if not email: + self.add_error("email", self.error_messages['empty_email']) + if not password: + self.add_error("password", self.error_messages['empty_password']) raise forms.ValidationError( self.error_messages['incomplete'], code='incomplete' @@ -110,15 +127,9 @@ class AuthenticationForm(forms.Form): def confirm_login_allowed(self, user): if not user.is_active: - raise forms.ValidationError( - self.error_messages['inactive'], - code='inactive', - ) - if not user.is_verified: - raise forms.ValidationError( - self.error_messages['unverified'], - code='unverified', - ) + self.add_error("email", self.error_messages['inactive']) + elif not user.is_verified: + self.add_error("password", self.error_messages['unverified']) def get_customer(self): return self.customer_cache diff --git a/src/pretix/presale/forms/renderers.py b/src/pretix/presale/forms/renderers.py index 9c177197c..8106fc63b 100644 --- a/src/pretix/presale/forms/renderers.py +++ b/src/pretix/presale/forms/renderers.py @@ -33,6 +33,7 @@ def render_label(content, label_for=None, label_class=None, label_title='', labe """ Render a label with content """ + tag = 'label' attrs = attrs or {} if label_for: attrs['for'] = label_for @@ -58,15 +59,16 @@ def render_label(content, label_for=None, label_class=None, label_title='', labe attrs['class'] += ' label-empty' # usually checkboxes have overall empty labels and special labels per checkbox # => remove for-attribute as well as "required"-text appended to label + tag = 'div' if 'for' in attrs: del attrs['for'] elif not optional: - opt += ', {}'.format(pgettext('form', 'required')) + opt += '{}'.format(pgettext('form', 'required')) builder = '<{tag}{attrs}>{content}{opt}' return format_html( builder, - tag='label', + tag=tag, attrs=mark_safe(flatatt(attrs)) if attrs else '', opt=mark_safe(opt), content=text_value(content), diff --git a/src/pretix/presale/management/commands/updateassets.py b/src/pretix/presale/management/commands/updateassets.py index 8acfefecf..0aabae8d8 100644 --- a/src/pretix/presale/management/commands/updateassets.py +++ b/src/pretix/presale/management/commands/updateassets.py @@ -29,7 +29,9 @@ from django.core.management.base import BaseCommand from django_scopes import scopes_disabled from pretix.base.settings import GlobalSettingsObject -from pretix.presale.views.widget import generate_widget_js +from pretix.presale.views.widget import ( + generate_widget_js, version_max, version_min, +) class Command(BaseCommand): @@ -43,19 +45,22 @@ class Command(BaseCommand): def handle(self, *args, **options): gs = GlobalSettingsObject() for lc, ll in settings.LANGUAGES: - data = generate_widget_js(lc).encode() - checksum = hashlib.sha1(data).hexdigest() - fname = gs.settings.get('widget_file_{}'.format(lc)) - if not fname or gs.settings.get('widget_checksum_{}'.format(lc), '') != checksum: - newname = default_storage.save( - 'pub/widget/widget.{}.{}.js'.format(lc, checksum), - ContentFile(data) - ) - gs.settings.set('widget_file_{}'.format(lc), 'file://' + newname) - gs.settings.set('widget_checksum_{}'.format(lc), checksum) - cache.delete('widget_js_data_{}'.format(lc)) - if fname: - if isinstance(fname, File): - default_storage.delete(fname.name) - else: - default_storage.delete(fname) + for version in range(version_min, version_max + 1): + data = generate_widget_js(version, lc).encode() + checksum = hashlib.sha1(data).hexdigest() + settings_file_key = 'widget_file_v{}_{}'.format(version, lc) + settings_checksum_key = 'widget_checksum_v{}_{}'.format(version, lc) + fname = gs.settings.get(settings_file_key) + if not fname or gs.settings.get(settings_checksum_key, '') != checksum: + newname = default_storage.save( + 'pub/widget/widget.v{}.{}.{}.js'.format(version, lc, checksum), + ContentFile(data) + ) + gs.settings.set(settings_file_key, 'file://' + newname) + gs.settings.set(settings_checksum_key, checksum) + cache.delete('widget_js_data_v{}_{}'.format(version, lc)) + if fname: + if isinstance(fname, File): + default_storage.delete(fname.name) + else: + default_storage.delete(fname) diff --git a/src/pretix/presale/templates/pretixpresale/base.html b/src/pretix/presale/templates/pretixpresale/base.html index 3c6b5ed84..a967247be 100644 --- a/src/pretix/presale/templates/pretixpresale/base.html +++ b/src/pretix/presale/templates/pretixpresale/base.html @@ -44,6 +44,9 @@ {{ html_page_header|safe }} +
{% if ie_deprecation_warning %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/base.html b/src/pretix/presale/templates/pretixpresale/event/base.html index 272149b7c..8cc81165b 100644 --- a/src/pretix/presale/templates/pretixpresale/event/base.html +++ b/src/pretix/presale/templates/pretixpresale/event/base.html @@ -94,10 +94,11 @@ {% else %}

- {{ event.name }} + {{ event.name }} {% if request.event.settings.show_dates_on_frontpage and not event.has_subevents %} - {{ event.get_date_range_display_as_html }} + {{ event.get_date_range_display_as_html }} {% endif %} +

{% endif %}
@@ -223,6 +224,17 @@ {% if request.event.settings.privacy_url %}
  • {% trans "Privacy policy" %}
  • {% endif %} + {% if request.event.settings.accessibility_url %} + {% trans "Accessibility information" as accessibility_title %} +
  • + {{ request.event.settings.accessibility_title|default:accessibility_title }} +
  • + {% elif request.event.settings.accessibility_text %} + {% trans "Accessibility information" as accessibility_title %} +
  • + {{ request.event.settings.accessibility_title|default:accessibility_title }} +
  • + {% endif %} {% if request.event.settings.cookie_consent and cookie_providers %}
  • {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_base.html b/src/pretix/presale/templates/pretixpresale/event/checkout_base.html index f9022a96d..2bcb9e6ec 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_base.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_base.html @@ -13,7 +13,7 @@ {% endblock %} {% block content %}
    + {% endif %} + {% endif %} {% if subevent %}

    {{ subevent.name }}

    @@ -171,7 +175,7 @@ {% endif %} {% if not cart_namespace or subevent %} -
    + {% include "pretixpresale/event/fragment_event_info.html" with event=request.event subevent=subevent ev=ev show_location=True %}
    {% eventsignal event "pretix.presale.signals.front_page_top" request=request subevent=subevent %} @@ -181,7 +185,6 @@
    {% csrf_token %} @@ -250,7 +253,7 @@ {% if not cart_namespace %} {% eventsignal event "pretix.presale.signals.front_page_bottom" subevent=subevent request=request %}