diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml new file mode 100644 index 0000000000..9845bd20e0 --- /dev/null +++ b/.github/workflows/sbom.yml @@ -0,0 +1,43 @@ +name: SBOM + +on: + push: + branches: [ master, sbom ] + tags: [ 'v.*' ] + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + FORCE_COLOR: 1 + +jobs: + test: + runs-on: ubuntu-22.04 + name: Submission + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Use Node.js + uses: actions/setup-node@v7 + with: + node-version: '24.x' + - uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install system dependencies + run: sudo apt update && sudo apt install -y gettext unzip + - name: Install Python dependencies + run: pip3 install -U "prisma-sbom-submit[python]" + - name: Create SBOM + run: NPM=$(which npm) prisma-sbom-submit collect . sbom.json + - name: Submit SBOM + run: prisma-sbom-submit upload --server https://prisma.pretix.com sbom.json + env: + PRISMA_UPLOAD_TOKEN: ${{ secrets.PRISMA_UPLOAD_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a1c1ba2977..69a80dcc30 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -123,7 +123,24 @@ jobs: working-directory: ./src run: make all compress - name: Install Playwright browsers - run: playwright install + run: playwright install --with-deps - name: Run E2E tests working-directory: ./src - run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10 + run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10 --tracing=retain-on-failure + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-traces + path: test-results/ + - name: Log trace instructions + if: steps.check-traces.outputs.found == 'true' + run: | + { + echo "## 🎭 Playwright traces available" + echo "" + echo "Some tests failed or retried and produced traces." + echo "" + echo "1. Download the **playwright-traces-${{ github.run_id }}** artifact from this run (link in the **Summary** tab, under Artifacts)." + echo "2. Unzip it." + echo "3. Go to https://trace.playwright.dev and drag \`trace.zip\` into the page — or run \`npx playwright show-trace trace.zip\` locally." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst index 5cc5fa32f3..4f10d49841 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -65,6 +65,7 @@ valid_date_max date Maximum value f valid_datetime_min datetime Minimum value for date and time questions (optional) valid_datetime_max datetime Maximum value for date and time questions (optional) valid_file_portrait boolean Turn on file validation for portrait photos +valid_string_length_min integer Minimum length for string questions (optional) valid_string_length_max integer Maximum length for string questions (optional) dependency_question integer Internal ID of a different question. The current question will only be shown if the question given in @@ -130,6 +131,7 @@ Endpoints "valid_date_max": null, "valid_datetime_min": null, "valid_datetime_max": null, + "valid_string_length_min": null, "valid_string_length_max": null, "valid_file_portrait": false, "dependency_question": null, @@ -211,6 +213,7 @@ Endpoints "valid_datetime_min": null, "valid_datetime_max": null, "valid_file_portrait": false, + "valid_string_length_min": null, "valid_string_length_max": null, "dependency_question": null, "dependency_value": null, @@ -315,6 +318,7 @@ Endpoints "valid_datetime_min": null, "valid_datetime_max": null, "valid_file_portrait": false, + "valid_string_length_min": null, "valid_string_length_max": null, "options": [ { @@ -399,6 +403,7 @@ Endpoints "valid_datetime_min": null, "valid_datetime_max": null, "valid_file_portrait": false, + "valid_string_length_min": null, "valid_string_length_max": null, "options": [ { diff --git a/pyproject.toml b/pyproject.toml index bc81bd5919..112c42f609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ dependencies = [ "phonenumberslite==9.0.*", "Pillow==12.3.*", "pretix-plugin-build", - "protobuf==7.35.*", + "protobuf==7.36.*", "psycopg2-binary", "pycountry", "pycparser==3.0", @@ -94,7 +94,7 @@ dependencies = [ "redis==7.4.*", "reportlab==5.0.*", "requests==2.34.*", - "sentry-sdk==2.66.*", + "sentry-sdk==2.68.*", "sepaxml==2.7.*", "stripe==7.9.*", "text-unidecode==1.*", diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 73e0b9c518..5ffa7cfbfb 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -702,8 +702,12 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer): return super().save(**kwargs) def validate_default(self, value): - if not value and self.instance.default: - raise ValidationError("You can't remove the default property, instead set it on another tax rule.") + if not value: + if self.instance: + if self.instance.default: + raise ValidationError("You can't remove the default property, instead set it on another tax rule.") + elif not self.context["event"].tax_rules.exists(): + raise ValidationError("You can't remove the default property as there is only one tax rule.") return value diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index b34858c49b..08a83f8cf1 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -550,7 +550,7 @@ class QuestionSerializer(I18nAwareModelSerializer): 'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values', 'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min', 'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max', - 'valid_string_length_max', 'valid_file_portrait') + 'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait') def validate_identifier(self, value): Question._clean_identifier(self.context['event'], value, self.instance) diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index c62b91a3d8..671c25d8e0 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -426,6 +426,8 @@ class TeamSerializer(serializers.ModelSerializer): for k, v in OLD_TO_NEW_ORGANIZER_MIGRATION.items(): if full_data.get(k) is True: data["limit_organizer_permissions"].update({kk: True for kk in v}) + for key in list(k for k in data if k.startswith("can_")): + del data[key] if full_data.get('limit_events') and full_data.get('all_events'): raise ValidationError('Do not set both limit_events and all_events.') diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 98c3727e28..73cdb2998b 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -708,6 +708,7 @@ class BaseQuestionsForm(forms.Form): elif q.type == Question.TYPE_STRING: field = forms.CharField( label=escape(q.question), required=required, + min_length=q.valid_string_length_min, max_length=q.valid_string_length_max, help_text=rich_text(q.help_text), initial=initial.answer if initial else None, @@ -715,6 +716,7 @@ class BaseQuestionsForm(forms.Form): elif q.type == Question.TYPE_TEXT: field = forms.CharField( label=escape(q.question), required=required, + min_length=q.valid_string_length_min, max_length=q.valid_string_length_max, help_text=rich_text(q.help_text), widget=forms.Textarea, diff --git a/src/pretix/base/migrations/0310_question_valid_string_length_min.py b/src/pretix/base/migrations/0310_question_valid_string_length_min.py new file mode 100644 index 0000000000..618f974f7d --- /dev/null +++ b/src/pretix/base/migrations/0310_question_valid_string_length_min.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.12 on 2026-08-19 11:31 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0309_alter_questionanswer_unique_together_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="question", + name="valid_string_length_min", + field=models.PositiveIntegerField(null=True), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 088c84789c..6d4a5291ad 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -50,7 +50,7 @@ from dateutil.tz import datetime_exists from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import ( - MaxLengthValidator, MinValueValidator, RegexValidator, + MaxLengthValidator, MinLengthValidator, MinValueValidator, RegexValidator, ) from django.db import models from django.db.models import Q @@ -1731,6 +1731,11 @@ class Question(LoggedModel): valid_datetime_max = models.DateTimeField(null=True, blank=True, verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps and during check-in')) + valid_string_length_min = models.PositiveIntegerField(null=True, blank=True, + verbose_name=_('Minimum length'), + help_text=_( + 'Currently not supported in our apps and during check-in' + )) valid_string_length_max = models.PositiveIntegerField(null=True, blank=True, verbose_name=_('Maximum length'), help_text=_( @@ -1885,6 +1890,11 @@ class Question(LoggedModel): else: raise ValidationError(_('Unknown country code.')) elif self.type in (Question.TYPE_STRING, Question.TYPE_TEXT): + if self.valid_string_length_min is not None and len(answer) < self.valid_string_length_min: + raise ValidationError(MinLengthValidator.message % { + 'limit_value': self.valid_string_length_min, + 'show_value': len(answer) + }) if self.valid_string_length_max is not None and len(answer) > self.valid_string_length_max: raise ValidationError(MaxLengthValidator.message % { 'limit_value': self.valid_string_length_max, @@ -1906,6 +1916,8 @@ class Question(LoggedModel): 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.")) + if self.valid_string_length_max and self.valid_string_length_min and self.valid_string_length_min > self.valid_string_length_max: + raise ValidationError(_("The maximum length must not be shorter than the minimum length.")) super().clean() def clean_type_change(self, old_type, new_type): diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 34300993b8..096c2ece60 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -628,9 +628,14 @@ class Order(LockModel, LoggedModel): def set_expires(self, now_dt=None, subevents=None): now_dt = now_dt or now() tz = ZoneInfo(self.event.settings.timezone) - mode = self.event.settings.get('payment_term_mode') + + sales_channel_suffix = "_" + self.sales_channel.identifier.replace(".", "_") + if not (mode := self.event.settings.get(f'payment_term_mode{sales_channel_suffix}')): + mode = self.event.settings.get('payment_term_mode') + sales_channel_suffix = "" + if mode == 'days': - exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get('payment_term_days', as_type=int)) + exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get(f'payment_term_days{sales_channel_suffix}', as_type=int)) exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0) if self.event.settings.get('payment_term_weekdays'): if exp_by_date.weekday() == 5: @@ -638,7 +643,7 @@ class Order(LockModel, LoggedModel): elif exp_by_date.weekday() == 6: exp_by_date += timedelta(days=1) elif mode == 'minutes': - exp_by_date = now_dt.astimezone(tz) + timedelta(minutes=self.event.settings.get('payment_term_minutes', as_type=int)) + exp_by_date = now_dt.astimezone(tz) + timedelta(minutes=self.event.settings.get(f'payment_term_minutes{sales_channel_suffix}', as_type=int)) else: raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode)) diff --git a/src/pretix/base/reldate.py b/src/pretix/base/reldate.py index abcb152209..735d5c8eb6 100644 --- a/src/pretix/base/reldate.py +++ b/src/pretix/base/reldate.py @@ -751,9 +751,9 @@ class RelativeDateField(RelativeDateTimeField): if data.rel_days_number is None or not data.rel_days_relationto: raise ValidationError(self.error_messages['incomplete']) elif data.rel_days_relation == "before" and not choice.supports_before: - raise ValidationError(_("A relative date cannot be expressed as 'before' for '{}'".format(choice.text))) + raise ValidationError(_('A relative date cannot be expressed as "before" for "{}"'.format(choice.text))) elif data.rel_days_relation == "after" and not choice.supports_after: - raise ValidationError(_("A relative date cannot be expressed as 'after' for '{}'".format(choice.text))) + raise ValidationError(_('A relative date cannot be expressed as "after" for "{}"'.format(choice.text))) return forms.MultiValueField.clean(self, value) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 5c59b8c18e..bb6cb6886e 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -3474,7 +3474,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay } ) - new_invoice_created = False + new_invoice = None if recreate_invoices: # Lock to prevent duplicate invoice creation order = Order.objects.select_for_update(of=OF_SELF).get(pk=order.pk) @@ -3485,13 +3485,16 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay if has_active_invoice and order.total != oldtotal: try: generate_cancellation(i) - generate_invoice(order) + new_invoice = generate_invoice(order) except Exception as e: logger.exception("Could not generate invoice.") order.log_action("pretix.event.order.invoice.failed", data={ "exception": str(e) }) - new_invoice_created = True + else: + order.log_action('pretix.event.order.invoice.generated', data={ + 'invoice': new_invoice.pk + }) elif (not has_active_invoice or order.invoice_dirty) and invoice_qualified(order): if order.event.settings.get('invoice_generate') == 'True' or ( @@ -3501,10 +3504,9 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay try: if has_active_invoice: generate_cancellation(i) - i = generate_invoice(order) - new_invoice_created = True + new_invoice = generate_invoice(order) order.log_action('pretix.event.order.invoice.generated', data={ - 'invoice': i.pk + 'invoice': new_invoice.pk }) except Exception as e: logger.exception("Could not generate invoice.") @@ -3512,8 +3514,11 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay "exception": str(e) }) + if new_invoice and invoice_transmission_separately(new_invoice): + transmit_invoice.apply_async(args=(order.event_id, new_invoice.pk, False)) + order.create_transactions() - return old_fee, new_fee, fee, new_payment, new_invoice_created + return old_fee, new_fee, fee, new_payment, bool(new_invoice) @receiver(order_paid, dispatch_uid="pretixbase_order_paid_giftcards") diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index bdf8813598..f2229a8212 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -933,6 +933,23 @@ DEFAULTS = { "is over. You can use it to describe other options to get a ticket, such as a box office.") ) }, + 'event_list_empty_text': { + 'default': LazyI18nString.from_gettext( + gettext_noop('No dates match your criteria.'), + ), + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'serializer_class': I18nField, + 'form_kwargs': dict( + label=pgettext_lazy("subevents", "Text for empty date results"), + widget=I18nMarkdownTextarea, + widget_kwargs={'attrs': {'rows': '2'}}, + help_text=pgettext_lazy("subevents", "This text will be shown if the calendar or list of dates is empty, " + "e.g. because a month does not contain any dates or a filter chosen by the user does " + "not find any results. You can use this to advertise ways to get in touch with you to " + "arrange further dates. We do not recommend more than one or two sentences.") + ) + }, 'payment_explanation': { 'default': '', 'type': LazyI18nString, @@ -979,12 +996,12 @@ DEFAULTS = { 'form_class': forms.IntegerField, 'serializer_class': serializers.IntegerField, 'write_permission': 'event.settings.payment:write', - 'form_kwargs': dict( + 'form_kwargs': lambda suffix="", parent=0: dict( label=_('Payment term in days'), widget=forms.NumberInput( attrs={ - 'data-display-dependency': '#id_payment_term_mode_0', - 'data-required-if': '#id_payment_term_mode_0' + 'data-display-dependency': f'#id_payment_term_mode{suffix}_{parent}', + 'data-required-if': f'#id_payment_term_mode{suffix}_{parent}' }, ), help_text=_("The number of days after placing an order the user has to pay to preserve their reservation. If " @@ -1023,7 +1040,7 @@ DEFAULTS = { 'form_class': forms.IntegerField, 'serializer_class': serializers.IntegerField, 'write_permission': 'event.settings.payment:write', - 'form_kwargs': dict( + 'form_kwargs': lambda suffix="", parent=1: dict( label=_('Payment term in minutes'), help_text=_("The number of minutes after placing an order the user has to pay to preserve their reservation. " "Only use this if you exclusively offer real-time payment methods. Please note that for technical reasons, " @@ -1032,8 +1049,8 @@ DEFAULTS = { MaxValueValidator(1440)], widget=forms.NumberInput( attrs={ - 'data-display-dependency': '#id_payment_term_mode_1', - 'data-required-if': '#id_payment_term_mode_1' + 'data-display-dependency': f'#id_payment_term_mode{suffix}_{parent}', + 'data-required-if': f'#id_payment_term_mode{suffix}_{parent}' }, ), ), diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 82087bbe67..f524ea3181 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -600,6 +600,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett 'imprint_url', 'checkout_email_helptext', 'presale_has_ended_text', + 'event_list_empty_text', 'voucher_explanation_text', 'checkout_success_text', 'show_dates_on_frontpage', @@ -734,6 +735,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett del self.fields['event_list_available_only'] del self.fields['event_list_filters'] del self.fields['event_calendar_future_only'] + del self.fields['event_list_empty_text'] self.fields['primary_font'].choices = [('Open Sans', 'Open Sans')] + sorted([ (a, FontSelect.FontOption(title=a, data=v)) for a, v in get_fonts(self.event, pdf_support_required=False).items() ], key=lambda a: a[0]) @@ -856,6 +858,50 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm): 'tax_rule_payment', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.term_channel_fields = {} + for c in self.obj.organizer.sales_channels.all(): + if c.type_instance.payment_restrictions_supported and c.identifier != "web": + # At the moment, it seems sufficient to allow this for the same channel types as other payment settings + # We can always introduce more flags later if needed + suffix = '_' + c.identifier.replace(".", "_") + self.term_channel_fields[c] = [ + 'payment_term_mode' + suffix, + 'payment_term_days' + suffix, + 'payment_term_minutes' + suffix, + ] + self.fields['payment_term_mode' + suffix] = DEFAULTS['payment_term_mode']['form_class']( + label=_("Payment term"), + widget=forms.RadioSelect, + required=False, + choices=( + ('', _("same as above")), + ('days', _("different payment term in days")), + ('minutes', _("different payment term in minutes")) + ), + ) + self.fields['payment_term_days' + suffix] = DEFAULTS['payment_term_days']['form_class']( + required=False, + **DEFAULTS['payment_term_days']['form_kwargs'](suffix, 1), + ) + self.fields['payment_term_minutes' + suffix] = DEFAULTS['payment_term_minutes']['form_class']( + required=False, + **DEFAULTS['payment_term_minutes']['form_kwargs'](suffix, 2), + ) + + def clean(self): + data = super().clean() + for c in self.term_channel_fields.keys(): + suffix = '_' + c.identifier.replace(".", "_") + mode = self.cleaned_data.get(f'payment_term_mode{suffix}') + if mode == 'days' and self.cleaned_data.get(f'payment_term_days{suffix}') is None: + raise ValidationError({f'payment_term_days{suffix}': _("This field is required.")}) + if mode == 'minutes' and self.cleaned_data.get(f'payment_term_minutes{suffix}') is None: + raise ValidationError({f'payment_term_minutes{suffix}': _("This field is required.")}) + return data + def clean_payment_term_days(self): value = self.cleaned_data.get('payment_term_days') if self.cleaned_data.get('payment_term_mode') == 'days' and value is None: diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 1f2fceca1c..b90592ffbd 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -251,6 +251,7 @@ class QuestionForm(I18nModelForm): 'valid_date_min', 'valid_date_max', 'valid_file_portrait', + 'valid_string_length_min', 'valid_string_length_max', ] widgets = { diff --git a/src/pretix/control/templates/pretixcontrol/event/payment.html b/src/pretix/control/templates/pretixcontrol/event/payment.html index fef1ae3593..55a53f35f8 100644 --- a/src/pretix/control/templates/pretixcontrol/event/payment.html +++ b/src/pretix/control/templates/pretixcontrol/event/payment.html @@ -2,9 +2,10 @@ {% load i18n %} {% load static %} {% load bootstrap3 %} +{% load getitem %} {% block inside %}