diff --git a/doc/api/resources/certificates.rst b/doc/api/resources/certificates.rst index 243af4f87..91d1110d9 100644 --- a/doc/api/resources/certificates.rst +++ b/doc/api/resources/certificates.rst @@ -23,7 +23,7 @@ Certificate download .. sourcecode:: http - GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/ HTTP/1.1 + GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/certificate/ HTTP/1.1 Host: pretix.eu Accept: application/json, text/javascript @@ -38,7 +38,7 @@ Certificate download .. sourcecode:: http - GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5 HTTP/1.1 + GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5 HTTP/1.1 Host: pretix.eu Accept: application/json, text/javascript diff --git a/doc/api/resources/checkin.rst b/doc/api/resources/checkin.rst index 36549fe10..7f6861da8 100644 --- a/doc/api/resources/checkin.rst +++ b/doc/api/resources/checkin.rst @@ -54,6 +54,11 @@ Checking a ticket in this request twice with the same nonce, the second request will also succeed but will always create only one check-in object even when the previous request was successful as well. This allows for a certain level of idempotency and enables you to re-try after a connection failure. + :json string status: ``"ok"``, ``"incomplete"``, or ``"error"`` :>json string reason: Reason code, only set on status ``"error"``, see below for possible values. :>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null. @@ -62,7 +67,9 @@ Checking a ticket in will only include check-ins for the selected list. (2) An additional boolean property ``require_attention`` will inform you whether either the order or the item have the ``checkin_attention`` flag set. (3) If ``attendee_name`` is empty, it may automatically fall - back to values from a parent product or from invoice addresses. + back to values from a parent product or from invoice addresses. (4) Additional properties + ``order__status``, ``order__valid_if_pending``, ``order__require_approval``, and + ``order__locale`` are included with details form the order for convenience. :>json boolean require_attention: Whether or not the ``require_attention`` flag is set on the item or order. :>json list checkin_texts: List of additional texts to show to the user. :>json object list: Excerpt of information about the matching :ref:`check-in list ` (if any was found), diff --git a/doc/api/resources/discounts.rst b/doc/api/resources/discounts.rst index 70f5c2400..1e89d9184 100644 --- a/doc/api/resources/discounts.rst +++ b/doc/api/resources/discounts.rst @@ -35,6 +35,10 @@ subevent_mode strings Determines h ``"same"`` (discount is only applied for groups within the same date), or ``"distinct"`` (discount is only applied for groups with no two same dates). +subevent_date_from datetime The first date time of a subevent to which this discount can be applied + (or ``null``). Ignored in non-series events. +subevent_date_until datetime The last date time of a subevent to which this discount can be applied + (or ``null``). Ignored in non-series events. condition_all_products boolean If ``true``, the discount condition applies to all items. condition_limit_products list of integers If ``condition_all_products`` is not set, this is a list of internal item IDs that the discount condition applies to. @@ -105,6 +109,8 @@ Endpoints "available_from": null, "available_until": null, "subevent_mode": "mixed", + "subevent_date_from": null, + "subevent_date_until": null, "condition_all_products": true, "condition_limit_products": [], "condition_apply_to_addons": true, @@ -163,6 +169,8 @@ Endpoints "available_from": null, "available_until": null, "subevent_mode": "mixed", + "subevent_date_from": null, + "subevent_date_until": null, "condition_all_products": true, "condition_limit_products": [], "condition_apply_to_addons": true, @@ -207,6 +215,8 @@ Endpoints "available_from": null, "available_until": null, "subevent_mode": "mixed", + "subevent_date_from": null, + "subevent_date_until": null, "condition_all_products": true, "condition_limit_products": [], "condition_apply_to_addons": true, @@ -240,6 +250,8 @@ Endpoints "available_from": null, "available_until": null, "subevent_mode": "mixed", + "subevent_date_from": null, + "subevent_date_until": null, "condition_all_products": true, "condition_limit_products": [], "condition_apply_to_addons": true, @@ -302,6 +314,8 @@ Endpoints "available_from": null, "available_until": null, "subevent_mode": "mixed", + "subevent_date_from": null, + "subevent_date_until": null, "condition_all_products": true, "condition_limit_products": [], "condition_apply_to_addons": true, diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index de6da4fd4..df5e00e6d 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -1943,9 +1943,14 @@ Manipulating individual positions * ``valid_until`` + * ``secret`` + Changing parameters such as ``item`` or ``price`` will **not** automatically trigger creation of a new invoice, you need to take care of that yourself. + Changing ``secret`` does not cause a new PDF ticket to be sent to the customer, nor does it cause the old secret + to be added to the revocation list, even if your ticket generator uses one. + **Example request**: .. sourcecode:: http diff --git a/pyproject.toml b/pyproject.toml index a99ea20ac..ff553d1cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "babel", "BeautifulSoup4==4.13.*", "bleach==6.2.*", - "celery==5.4.*", + "celery==5.5.*", "chardet==5.2.*", "cryptography>=44.0.0", "css-inline==0.14.*", @@ -49,12 +49,12 @@ dependencies = [ "django-localflavor==4.0", "django-markup", "django-oauth-toolkit==2.3.*", - "django-otp==1.5.*", + "django-otp==1.6.*", "django-phonenumber-field==7.3.*", "django-redis==5.4.*", "django-scopes==2.0.*", "django-statici18n==2.6.*", - "djangorestframework==3.15.*", + "djangorestframework==3.16.*", "dnspython==2.7.*", "drf_ujson2==1.7.*", "geoip2==5.*", @@ -64,7 +64,7 @@ dependencies = [ "kombu==5.5.*", "libsass==0.23.*", "lxml", - "markdown==3.7", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3. + "markdown==3.8", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3. # We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7 "mt-940==4.30.*", "oauthlib==3.2.*", @@ -74,7 +74,7 @@ dependencies = [ "paypal-checkout-serversdk==1.0.*", "PyJWT==2.10.*", "phonenumberslite==9.0.*", - "Pillow==11.1.*", + "Pillow==11.2.*", "pretix-plugin-build", "protobuf==6.30.*", "psycopg2-binary", @@ -87,11 +87,11 @@ dependencies = [ "pytz", "pytz-deprecation-shim==0.1.*", "pyuca", - "qrcode==8.0", + "qrcode==8.1", "redis==5.2.*", "reportlab==4.3.*", "requests==2.31.*", - "sentry-sdk==2.24.*", + "sentry-sdk==2.25.*", "sepaxml==2.6.*", "stripe==7.9.*", "text-unidecode==1.*", @@ -111,7 +111,7 @@ dev = [ "coverage", "coveralls", "fakeredis==2.26.*", - "flake8==7.1.*", + "flake8==7.2.*", "freezegun", "isort==6.0.*", "pep8-naming==0.14.*", diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py index 69fc637da..3321de8ff 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.3.0" +__version__ = "2025.4.0" diff --git a/src/pretix/_base_settings.py b/src/pretix/_base_settings.py index 6341f880c..061147051 100644 --- a/src/pretix/_base_settings.py +++ b/src/pretix/_base_settings.py @@ -121,6 +121,7 @@ LANGUAGES_OFFICIAL = { 'en', 'de', 'de-informal' } LANGUAGES_RTL = { + # When adding more right-to-left languages, also update pretix/static/pretixbase/scss/_rtl.scss 'ar', 'hw' } LANGUAGES_INCUBATING = { diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index c13a14a62..564665f3a 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -84,6 +84,7 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer): type = serializers.ChoiceField(choices=Checkin.CHECKIN_TYPES, default=Checkin.TYPE_ENTRY) ignore_unpaid = serializers.BooleanField(default=False, required=False) questions_supported = serializers.BooleanField(default=True, required=False) + use_order_locale = serializers.BooleanField(default=False, required=False) nonce = serializers.CharField(required=False, allow_null=True) datetime = serializers.DateTimeField(required=False, allow_null=True) answers = serializers.JSONField(required=False, allow_null=True) diff --git a/src/pretix/api/serializers/discount.py b/src/pretix/api/serializers/discount.py index 1f632c1b7..9cf4ae748 100644 --- a/src/pretix/api/serializers/discount.py +++ b/src/pretix/api/serializers/discount.py @@ -38,11 +38,12 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): class Meta: model = Discount fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels', - 'available_from', 'available_until', 'subevent_mode', 'condition_all_products', - 'condition_limit_products', 'condition_apply_to_addons', 'condition_min_count', 'condition_min_value', - 'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches', - 'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons', - 'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted') + 'available_from', 'available_until', 'subevent_mode', 'subevent_date_from', 'subevent_date_until', + 'condition_all_products', 'condition_limit_products', 'condition_apply_to_addons', + 'condition_min_count', 'condition_min_value', 'benefit_discount_matching_percent', + 'benefit_only_apply_to_cheapest_n_matches', 'benefit_same_products', 'benefit_limit_products', + 'benefit_apply_to_addons', 'benefit_ignore_voucher_discounted', + 'condition_ignore_voucher_discounted') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 162a21d6a..147810a43 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -607,6 +607,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer): order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order') order__valid_if_pending = serializers.SlugRelatedField(read_only=True, slug_field='valid_if_pending', source='order') order__require_approval = serializers.SlugRelatedField(read_only=True, slug_field='require_approval', source='order') + order__locale = serializers.SlugRelatedField(read_only=True, slug_field='locale', source='order') class Meta: model = OrderPosition @@ -615,7 +616,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer): 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention', 'order__status', 'order__valid_if_pending', 'order__require_approval', - 'valid_from', 'valid_until', 'blocked') + 'order__locale', 'valid_from', 'valid_until', 'blocked') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1518,7 +1519,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer): self.context['event'], order.sales_channel, [ - (cp.item_id, cp.subevent_id, cp.price, bool(cp.addon_to), cp.is_bundled, pos._voucher_discount) + (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price, + bool(cp.addon_to), cp.is_bundled, pos._voucher_discount) for cp in order_positions ] ) diff --git a/src/pretix/api/serializers/orderchange.py b/src/pretix/api/serializers/orderchange.py index 5998f7eeb..8c3cb896a 100644 --- a/src/pretix/api/serializers/orderchange.py +++ b/src/pretix/api/serializers/orderchange.py @@ -251,7 +251,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer): class Meta: model = OrderPosition fields = ( - 'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule', 'valid_from', 'valid_until' + 'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule', 'valid_from', 'valid_until', 'secret' ) def __init__(self, *args, **kwargs): @@ -319,6 +319,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer): tax_rule = validated_data.get('tax_rule', instance.tax_rule) valid_from = validated_data.get('valid_from', instance.valid_from) valid_until = validated_data.get('valid_until', instance.valid_until) + secret = validated_data.get('secret', instance.secret) change_item = None if item != instance.item or variation != instance.variation: @@ -351,6 +352,9 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer): if valid_until != instance.valid_until: ocm.change_valid_until(instance, valid_until) + if secret != instance.secret: + ocm.change_ticket_secret(instance, secret) + if self.context.get('commit', True): ocm.commit() instance.refresh_from_db() diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 311a6bd60..521687caa 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -420,7 +420,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce, untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported, - source_type='barcode', legacy_url_support=False, simulate=False, gate=None): + source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False): if not checkinlists: raise ValidationError('No check-in list passed.') @@ -694,7 +694,11 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, pass # 6. Pass to our actual check-in logic - with language(op.order.event.settings.locale): + if use_order_locale: + locale = op.order.locale + else: + locale = op.order.event.settings.locale + with language(locale): try: perform_checkin( op=op, @@ -909,6 +913,7 @@ class CheckinRPCRedeemView(views.APIView): expand=self.request.query_params.getlist('expand'), pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true', questions_supported=s.validated_data['questions_supported'], + use_order_locale=s.validated_data['use_order_locale'], canceled_supported=True, request=self.request, # this is not clean, but we need it in the serializers for URL generation legacy_url_support=False, diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 5148f29df..2ed339a6e 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -185,7 +185,7 @@ with scopes_disabled(): | Q(full_invoice_no__iexact=u) ).values_list('order_id', flat=True) - matching_positions = OrderPosition.objects.filter( + matching_positions = OrderPosition.all.filter( Q(order=OuterRef('pk')) & Q( Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u) | Q(secret__istartswith=u) @@ -452,10 +452,9 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): comment = request.data.get('comment', None) cancellation_fee = request.data.get('cancellation_fee', None) if cancellation_fee: - try: - cancellation_fee = float(Decimal(cancellation_fee)) - except: - cancellation_fee = None + cancellation_fee = serializers.DecimalField(max_digits=13, decimal_places=2).to_internal_value( + cancellation_fee, + ) order = self.get_object() if not order.cancel_allowed(): diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index f223ee952..774060c9f 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -127,7 +127,13 @@ class NamePartsWidget(forms.MultiWidget): if fname == 'title' and self.titles: widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]])) elif fname == 'salutation': - widgets.append(Select(attrs=a, choices=[('', '---'), ('empty', '')] + PERSON_NAME_SALUTATIONS)) + widgets.append(Select( + attrs=a, + choices=[ + ('', '---'), + ('empty', '({})'.format(pgettext_lazy("name_salutation", "not specified"))), + ] + PERSON_NAME_SALUTATIONS + )) else: widgets.append(self.widget(attrs=a)) super().__init__(widgets, attrs) @@ -245,7 +251,10 @@ class NamePartsFormField(forms.MultiValueField): d.pop('validators', None) field = forms.ChoiceField( **d, - choices=[('', '---'), ('empty', '')] + PERSON_NAME_SALUTATIONS + choices=[ + ('', '---'), + ('empty', '({})'.format(pgettext_lazy("name_salutation", "not specified"))), + ] + PERSON_NAME_SALUTATIONS ) else: field = forms.CharField(**defaults) diff --git a/src/pretix/base/migrations/0279_discount_event_date_from_discount_event_date_until.py b/src/pretix/base/migrations/0279_discount_event_date_from_discount_event_date_until.py new file mode 100644 index 000000000..609230e8a --- /dev/null +++ b/src/pretix/base/migrations/0279_discount_event_date_from_discount_event_date_until.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.19 on 2025-03-18 09:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0278_login_source_add_unique_together'), + ] + + operations = [ + migrations.AddField( + model_name='discount', + name='subevent_date_from', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='discount', + name='subevent_date_until', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py index be2b3d0ac..dc99e4bda 100644 --- a/src/pretix/base/models/discount.py +++ b/src/pretix/base/models/discount.py @@ -36,7 +36,9 @@ from django_scopes import ScopedManager from pretix.base.decimal import round_decimal from pretix.base.models.base import LoggedModel -PositionInfo = namedtuple('PositionInfo', ['item_id', 'subevent_id', 'line_price_gross', 'is_addon_to', 'voucher_discount']) +PositionInfo = namedtuple('PositionInfo', + ['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to', + 'voucher_discount']) class Discount(LoggedModel): @@ -171,6 +173,17 @@ class Discount(LoggedModel): "access to sold-out quota will still receive the discount."), ) + subevent_date_from = models.DateTimeField( + verbose_name=pgettext_lazy("subevent", "Available for dates starting from"), + null=True, + blank=True, + ) + subevent_date_until = models.DateTimeField( + verbose_name=pgettext_lazy("subevent", "Available for dates starting until"), + null=True, + blank=True, + ) + # more feature ideas: # - max_usages_per_order # - promote_to_user_if_almost_satisfied @@ -355,11 +368,15 @@ class Discount(LoggedModel): # First, filter out everything not even covered by our product scope condition_candidates = [ idx - for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items() + for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in + positions.items() if ( (self.condition_all_products or item_id in limit_products) and (self.condition_apply_to_addons or not is_addon_to) and (not self.condition_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00')) + and (not subevent_id or ( + self.subevent_date_from is None or subevent_date_from >= self.subevent_date_from)) and ( + self.subevent_date_until is None or subevent_date_from <= self.subevent_date_until) ) ] @@ -369,7 +386,8 @@ class Discount(LoggedModel): benefit_products = {p.pk for p in self.benefit_limit_products.all()} benefit_candidates = [ idx - for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items() + for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in + positions.items() if ( item_id in benefit_products and (self.benefit_apply_to_addons or not is_addon_to) and diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 35deab286..acd2bd775 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -821,7 +821,8 @@ class Item(LoggedModel): def ask_attendee_data(self): return self.admission and self.personalized - def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False): + def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, + include_bundled=False, force_fixed_gross_price=False): price = price if price is not None else self.default_price bundled_sum = Decimal('0.00') @@ -850,7 +851,7 @@ class Item(LoggedModel): else: t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address, override_tax_rate=override_tax_rate, currency=currency or self.event.currency, - subtract_from_gross=bundled_sum) + subtract_from_gross=bundled_sum, force_fixed_gross_price=force_fixed_gross_price) if bundled_sum: t.name = "MIXED!" @@ -1836,7 +1837,7 @@ class Question(LoggedModel): )) llen = len(answer.split(',')) elif all(isinstance(o, QuestionOption) for o in answer): - return o + return answer else: l_ = list(self.options.filter( Q(pk__in=[a for a in answer if isinstance(a, int) or a.isdigit()]) | @@ -1915,6 +1916,15 @@ class Question(LoggedModel): if event != item.event: raise ValidationError(_('One or more items do not belong to this event.')) + def clean(self): + if self.valid_date_max and self.valid_date_min and self.valid_date_min > self.valid_date_max: + raise ValidationError(_("The maximum date must not be before the minimum value.")) + if self.valid_datetime_max and self.valid_datetime_min and self.valid_datetime_min > self.valid_datetime_max: + raise ValidationError(_("The maximum date must not be before the minimum value.")) + if self.valid_number_max and self.valid_number_min and self.valid_number_min > self.valid_number_max: + raise ValidationError(_("The maximum value must not be lower than the minimum value.")) + super().clean() + class QuestionOption(models.Model): question = models.ForeignKey('Question', related_name='options', on_delete=models.CASCADE) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 1263a24c0..d3e359013 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -1398,7 +1398,8 @@ class CartManager: self.event, self._sales_channel.identifier, [ - (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) + (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, + bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) for cp in positions ] ) diff --git a/src/pretix/base/services/cross_selling.py b/src/pretix/base/services/cross_selling.py index e81abd665..fe48d1e01 100644 --- a/src/pretix/base/services/cross_selling.py +++ b/src/pretix/base/services/cross_selling.py @@ -120,7 +120,8 @@ class CrossSellingService: self.event, self.sales_channel, [ - (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, + (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, + bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) for cp in self.cartpositions ], diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index afa71dad1..e82c75c00 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -875,7 +875,8 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti event, sales_channel.identifier, [ - (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) + (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, + bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) for cp in sorted_positions ] ) @@ -1563,6 +1564,7 @@ class OrderChangeManager: AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee', 'price_diff')) RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',)) + ChangeSecretOperation = namedtuple('ChangeSecretOperation', ('position', 'new_secret')) ChangeValidFromOperation = namedtuple('ChangeValidFromOperation', ('position', 'valid_from')) ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until')) AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) @@ -1670,6 +1672,9 @@ class OrderChangeManager: def regenerate_secret(self, position: OrderPosition): self._operations.append(self.RegenerateSecretOperation(position)) + def change_ticket_secret(self, position: OrderPosition, new_secret: str): + self._operations.append(self.ChangeSecretOperation(position, new_secret)) + def change_valid_from(self, position: OrderPosition, new_value: datetime): self._operations.append(self.ChangeValidFromOperation(position, new_value)) @@ -1684,7 +1689,8 @@ class OrderChangeManager: def change_price(self, position: OrderPosition, price: Decimal): tax_rule = self._current_tax_rules().get(position.pk, position.tax_rule) or TaxRule.zero() - price = tax_rule.tax(price, base_price_is='gross') + price = tax_rule.tax(price, base_price_is='gross', invoice_address=self._invoice_address, + force_fixed_gross_price=True) if position.issued_gift_cards.exists(): raise OrderError(self.error_messages['gift_card_change']) @@ -1749,7 +1755,8 @@ class OrderChangeManager: self._operations.append(self.AddFeeOperation(fee, fee.value)) def change_fee(self, fee: OrderFee, value: Decimal): - value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross') + value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross', invoice_address=self._invoice_address, + force_fixed_gross_price=True) self._totaldiff += value.gross - fee.value self._invoice_dirty = True self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value)) @@ -1784,7 +1791,8 @@ class OrderChangeManager: if price is None: price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address) elif not isinstance(price, TaxedPrice): - price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address) + price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address, + force_fixed_gross_price=True) except TaxRule.SaleNotAllowed: raise OrderError(self.error_messages['tax_rule_country_blocked']) @@ -2308,7 +2316,7 @@ class OrderChangeManager: op.position.tax_rate = op.price.rate op.position.tax_value = op.price.tax op.position.tax_code = op.price.code - op.position.save() + op.position.save(update_fields=['price', 'tax_rate', 'tax_value', 'tax_code']) elif isinstance(op, self.TaxRuleOperation): if isinstance(op.position, OrderPosition): self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={ @@ -2440,6 +2448,19 @@ class OrderChangeManager: 'position': op.position.pk, 'positionid': op.position.positionid, }) + elif isinstance(op, self.ChangeSecretOperation): + if OrderPosition.all.filter(order__event=self.event, secret=op.new_secret).exists(): + raise OrderError('You cannot assign a position secret that already exists.') + op.position.secret = op.new_secret + op.position.save(update_fields=["secret"]) + if op.position in secret_dirty: + secret_dirty.remove(op.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, + }) elif isinstance(op, self.ChangeValidFromOperation): self.order.log_action('pretix.event.order.changed.valid_from', user=self.user, auth=self.auth, data={ 'position': op.position.pk, diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index ee9390030..ac6b1a13e 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -21,6 +21,7 @@ # import re from collections import defaultdict +from datetime import datetime from decimal import Decimal from typing import List, Optional, Tuple, Union @@ -162,14 +163,14 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], - positions: List[Tuple[int, Optional[int], Decimal, bool, bool, Decimal]], + positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]], collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]: """ Applies any dynamic discounts to a cart :param event: Event the cart belongs to :param sales_channel: Sales channel the cart was created with - :param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)`` + :param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)`` :param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart based on the "consumed" items, but lack matching "benefitting" items will be collected therein. The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list @@ -191,12 +192,14 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') for discount in discount_qs: result = discount.apply({ - idx: PositionInfo(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) - for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions) + idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) + for + idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount) + in enumerate(positions) if not is_bundled and idx not in new_prices }, collect_potential_discounts) for k in result.keys(): result[k] = (result[k], discount) new_prices.update(result) - return [new_prices.get(idx, (p[2], None)) for idx, p in enumerate(positions)] + return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)] diff --git a/src/pretix/base/services/stats.py b/src/pretix/base/services/stats.py index 52f4c9870..fd27e8735 100644 --- a/src/pretix/base/services/stats.py +++ b/src/pretix/base/services/stats.py @@ -134,13 +134,13 @@ def order_overview( qs = qs.filter(item__admission=True) items = items.filter(admission=True) - if date_from and isinstance(date_from, date): + if date_from and isinstance(date_from, date) and not isinstance(date_from, datetime): date_from = make_aware(datetime.combine( date_from, time(hour=0, minute=0, second=0, microsecond=0) ), event.timezone) - if date_until and isinstance(date_until, date): + if date_until and isinstance(date_until, date) and not isinstance(date_until, datetime): date_until = make_aware(datetime.combine( date_until + timedelta(days=1), time(hour=0, minute=0, second=0, microsecond=0) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 9faa33722..6d5a24b9f 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -2883,7 +2883,8 @@ Your {organizer} team""")) # noqa: W291 ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_IMAGE, max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE, help_text=_('If you provide a logo image, we will by default not show your event name and date ' - 'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You ' + 'in the page header. If you use a white background, we show your logo with a size of up ' + 'to 1140x120 pixels. Otherwise the maximum size is 1120x120 pixels. You ' 'can increase the size with the setting below. We recommend not using small details on the picture ' 'as it will be resized on smaller screens.') ), @@ -2926,7 +2927,8 @@ Your {organizer} team""")) # noqa: W291 ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_IMAGE, max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE, help_text=_('If you provide a logo image, we will by default not show your organization name ' - 'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You ' + 'in the page header. If you use a white background, we show your logo with a size of up ' + 'to 1140x120 pixels. Otherwise the maximum size is 1120x120 pixels. You ' 'can increase the size with the setting below. We recommend not using small details on the picture ' 'as it will be resized on smaller screens.') ), @@ -3705,7 +3707,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = { # are actually *used* in postal addresses. This is obviously not complete and opinionated. # Country: [(List of subdivision types as defined by pycountry), (short or long form to be used)] 'AU': (['State', 'Territory'], 'short'), - 'BR': (['State'], 'short'), + 'BR': (['Federal district', 'State'], 'short'), 'CA': (['Province', 'Territory'], 'short'), # 'CN': (['Province', 'Autonomous region', 'Munincipality'], 'long'), 'JP': (['Prefecture'], 'long'), diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 71a920b59..7b996cffe 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -46,7 +46,6 @@ app_cache = {} def _populate_app_cache(): - global app_cache apps.check_apps_ready() for ac in apps.app_configs.values(): app_cache[ac.name] = ac diff --git a/src/pretix/base/templates/pretixbase/email/base.html b/src/pretix/base/templates/pretixbase/email/base.html index ef8809ac1..43a14a5d9 100644 --- a/src/pretix/base/templates/pretixbase/email/base.html +++ b/src/pretix/base/templates/pretixbase/email/base.html @@ -4,7 +4,7 @@ - +