Compare commits

..

4 Commits

Author SHA1 Message Date
Kara Engelhardt
34a818dd66 Add test for skipping conflicting dates in bulk-creation 2026-04-20 16:08:33 +02:00
Richard Schreiber
65b8236302 Fix overlap calc for consecutive subevents 2026-04-13 10:26:36 +02:00
Richard Schreiber
c74b3471bf Update src/pretix/control/templates/pretixcontrol/subevents/bulk.html 2026-04-13 09:11:21 +02:00
Raphael Michel
77d07049aa Subevents: Allow to skip conflicting dates in bulk-creation 2026-04-11 18:09:17 +02:00
72 changed files with 15874 additions and 18442 deletions

View File

@@ -1,31 +0,0 @@
name: Build Deploy email notification tool
run-name: ${{ gitea.actor }} building new version of the email notification tool
on:
push: # Baut bei jedem Push (Branches + Tags)
workflow_dispatch:
jobs:
Apply-Kubernetes-Resources:
runs-on: podman
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Login to Docker Registry
run: podman login -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_TOKEN }} cr.ortlerstrasse.de
- name: Set Docker Image Tag
run: |
if [[ "${{ gitea.ref }}" == refs/tags/* ]]; then
echo "TAG_NAME=${{ gitea.ref_name }}" >> $GITHUB_ENV
else
echo "TAG_NAME=latest" >> $GITHUB_ENV
fi
- name: Build Docker image
run: podman build -t cr.ortlerstrasse.de/cgo/pretix:${{ env.TAG_NAME }} .
- name: Push Docker image
run: |
podman push cr.ortlerstrasse.de/cgo/pretix:${{ env.TAG_NAME }}
echo "Image pushed successfully: cr.ortlerstrasse.de/cgo/pretix:${{ env.TAG_NAME }}"

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2026.3.1"
__version__ = "2026.3.0.dev0"

View File

@@ -1122,7 +1122,7 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
permission = 'event.orders:read'
def get_queryset(self):
qs = Checkin.all.filter(list__event=self.request.event).select_related(
qs = Checkin.all.filter().select_related(
"position",
"device",
)

View File

@@ -28,6 +28,5 @@ from .items import * # noqa
from .json import * # noqa
from .mail import * # noqa
from .orderlist import * # noqa
from .relevant_orderlist import * # noqa
from .reusablemedia import * # noqa
from .waitinglist import * # noqa

View File

@@ -89,7 +89,7 @@ class OrderListExporter(MultiSheetListExporter):
description = gettext_lazy('Download a spreadsheet of all orders. The spreadsheet will include three sheets, one '
'with a line for every order, one with a line for every order position, and one with '
'a line for every additional fee charged in an order.')
featured = False
featured = True
repeatable_read = False
@cached_property

File diff suppressed because it is too large Load Diff

View File

@@ -196,7 +196,8 @@ class RegistrationForm(forms.Form):
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
user = User(email=self.cleaned_data.get('email'))
validate_password(password1, user=user)
if validate_password(password1, user=user) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1
def clean_email(self):

View File

@@ -411,7 +411,7 @@ def mail_send_task(self, **kwargs) -> bool:
try:
outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail)
except OutgoingMail.DoesNotExist:
logger.info(f"Ignoring job for non existing email {outgoing_mail}")
logger.info(f"Ignoring job for non existing email {outgoing_mail.guid}")
return False
if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT:
logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}")

View File

@@ -28,7 +28,7 @@ from django.forms import formset_factory
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from i18nfield.forms import I18nInlineFormSet
from pretix.base.forms import I18nModelForm
@@ -102,6 +102,16 @@ class SubEventBulkForm(SubEventForm):
required=False,
limit_choices=('date_from', 'date_to'),
)
skip_if_overlap = forms.BooleanField(
label=pgettext_lazy('subevent', 'Skip dates that overlap with any existing date'),
help_text=pgettext_lazy(
'subevent',
'This can be useful if all your dates happen in the same location and no repeated dates should '
'be created in conflict with existing special events. This respects even inactive dates and works best if '
'all dates have both a start and end time.'
),
required=False,
)
def __init__(self, *args, **kwargs):
self.event = kwargs['event']

View File

@@ -379,6 +379,8 @@
<i class="fa fa-calendar"></i> {% trans "Add many time slots" %}</button>
</p>
</div>
<hr />
{% bootstrap_field form.skip_if_overlap layout="control" horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %}
</fieldset>
<fieldset>
<legend>{% trans "General information" %}</legend>

View File

@@ -917,6 +917,35 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn
if len(subevents) > 100_000:
raise ValidationError(_('Please do not create more than 100.000 dates at once.'))
if form.cleaned_data.get("skip_if_overlap") and subevents:
def overlaps(a_from, a_to, b_from, b_to):
if a_from == b_from:
return True
if a_from > b_from:
# a starts after b
# check if it starts before b ends
return b_to and a_from < b_to
# a starts before b
# check if it ends before b starts
return a_to and a_to > b_from
date_min = min(se.date_from for se in subevents)
date_max = max(se.date_to or se.date_from for se in subevents)
dates_existing = list(self.request.event.subevents.annotate(
date_fromto=Coalesce('date_to', 'date_from'),
).filter(
date_from__lte=date_max,
date_fromto__gte=date_min,
).values('date_from', 'date_to'))
subevents = [
se for se in subevents if not any(
overlaps(se.date_from, se.date_to, other['date_from'], other['date_to'])
for other in dates_existing
)
]
if not subevents:
raise ValidationError(_('All dates would be skipped because they conflict with existing dates.'))
for i, se in enumerate(subevents):
se.save(clear_cache=False)
if i % 100 == 0:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,6 @@ ausgecheckt
ausgeklappt
auswahl
Authentication
Authenticator
Authenticator-App
Autorisierungscode
Autorisierungs-Endpunktes
@@ -131,7 +130,6 @@ Eingangsscan
Einlassbuchung
Einlassdatum
Einlasskontrolle
Einmalpasswörter
einzuchecken
email
E-Mail-Renderer
@@ -165,7 +163,6 @@ Explorer
FA
Favicon
F-Droid
freeOTP
Footer
Footer-Link
Footer-Text
@@ -560,7 +557,6 @@ Zahlungs-ID
Zahlungspflichtig
Zehnerkarten
Zeitbasiert
zeitbasierte
Zeitslotbuchung
Zimpler
ZIP-Datei

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,6 @@ ausgecheckt
ausgeklappt
auswahl
Authentication
Authenticator
Authenticator-App
Autorisierungscode
Autorisierungs-Endpunktes
@@ -131,7 +130,6 @@ Eingangsscan
Einlassbuchung
Einlassdatum
Einlasskontrolle
Einmalpasswörter
einzuchecken
email
E-Mail-Renderer
@@ -165,7 +163,6 @@ Explorer
FA
Favicon
F-Droid
freeOTP
Footer
Footer-Link
Footer-Text
@@ -560,7 +557,6 @@ Zahlungs-ID
Zahlungspflichtig
Zehnerkarten
Zeitbasiert
zeitbasierte
Zeitslotbuchung
Zimpler
ZIP-Datei

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-30 11:25+0000\n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2026-03-30 03:00+0000\n"
"PO-Revision-Date: 2026-03-18 12:23+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"
"js/es/>\n"
@@ -329,7 +329,7 @@ msgstr "Pedido no aprobado"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:68
msgid "Checked-in Tickets"
msgstr "Billetes registrados"
msgstr "Registro de código QR"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:69
msgid "Valid Tickets"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@ anonymized
Auth
authentification
authenticator
Authenticator
automatical
availabilities
backend
@@ -23,7 +22,6 @@ barcodes
Bcc
BCC
BezahlCode
biometric
BLIK
blocklist
BN
@@ -58,7 +56,6 @@ EPS
eps
favicon
filetype
freeOTP
frontend
frontpage
Galician

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@ from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.contrib.auth.password_validation import (
MinimumLengthValidator, get_password_validators, validate_password,
get_password_validators, password_validators_help_texts, validate_password,
)
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.core import signing
@@ -300,12 +300,13 @@ class SetPasswordForm(forms.Form):
)
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
max_length=4096,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
max_length=4096,
)
@@ -315,14 +316,6 @@ class SetPasswordForm(forms.Form):
kwargs['initial']['email'] = self.customer.email
super().__init__(*args, **kwargs)
pw_min_len_validators = [v for v in get_customer_password_validators() if isinstance(v, MinimumLengthValidator)]
if pw_min_len_validators:
self.fields['password'].widget.attrs['minlength'] = max(v.min_length for v in pw_min_len_validators)
self.fields['password_repeat'].widget.attrs['minlength'] = max(v.min_length for v in pw_min_len_validators)
if 'password' not in self.data:
self.fields['password'].help_text = ' '.join(v.get_help_text() for v in pw_min_len_validators)
def clean(self):
password1 = self.cleaned_data.get('password', '')
password2 = self.cleaned_data.get('password_repeat')
@@ -336,7 +329,8 @@ class SetPasswordForm(forms.Form):
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
validate_password(password1, user=self.customer, password_validators=get_customer_password_validators())
if validate_password(password1, user=self.customer, password_validators=get_customer_password_validators()) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1
@@ -401,13 +395,13 @@ class ChangePasswordForm(forms.Form):
)
password = forms.CharField(
label=_('New password'),
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
max_length=4096,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
max_length=4096,
)
@@ -417,14 +411,6 @@ class ChangePasswordForm(forms.Form):
kwargs['initial']['email'] = self.customer.email
super().__init__(*args, **kwargs)
pw_min_len_validators = [v for v in get_customer_password_validators() if isinstance(v, MinimumLengthValidator)]
if pw_min_len_validators:
self.fields['password'].widget.attrs['minlength'] = max(v.min_length for v in pw_min_len_validators)
self.fields['password_repeat'].widget.attrs['minlength'] = max(v.min_length for v in pw_min_len_validators)
if 'password' not in self.data:
self.fields['password'].help_text = ' '.join(v.get_help_text() for v in pw_min_len_validators)
def clean(self):
password1 = self.cleaned_data.get('password', '')
password2 = self.cleaned_data.get('password_repeat')
@@ -438,7 +424,8 @@ class ChangePasswordForm(forms.Form):
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
validate_password(password1, user=self.customer, password_validators=get_customer_password_validators())
if validate_password(password1, user=self.customer, password_validators=get_customer_password_validators()) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1
def clean_password_current(self):

View File

@@ -747,6 +747,92 @@ class SubEventsTest(SoupTest):
assert ses[1].date_from.isoformat() == "2018-04-12T11:29:31+00:00"
assert ses[-1].date_from.isoformat() == "2019-03-28T12:29:31+00:00"
def test_create_bulk_skip_existing(self):
with scopes_disabled():
self.event1.subevents.all().delete()
# SubEvent ends at rrule start time
self.event1.subevents.create(
date_from=datetime.datetime(2018, 4, 4, 9, 0, tzinfo=datetime.timezone.utc),
date_to=datetime.datetime(2018, 4, 4, 10, 0, tzinfo=datetime.timezone.utc),
)
# SubEvent overlaps rrule start
self.event1.subevents.create(
date_from=datetime.datetime(2018, 4, 5, 9, 30, tzinfo=datetime.timezone.utc),
date_to=datetime.datetime(2018, 4, 5, 10, 30, tzinfo=datetime.timezone.utc),
)
# SubEvent times are same as rrule
self.event1.subevents.create(
date_from=datetime.datetime(2018, 4, 6, 10, 0, tzinfo=datetime.timezone.utc),
date_to=datetime.datetime(2018, 4, 6, 11, 0, tzinfo=datetime.timezone.utc),
)
# SubEvent starts at rrule end time
self.event1.subevents.create(
date_from=datetime.datetime(2018, 4, 7, 11, 0, tzinfo=datetime.timezone.utc),
date_to=datetime.datetime(2018, 4, 7, 12, 0, tzinfo=datetime.timezone.utc),
)
# SubEvent overlaps entire rrule time
self.event1.subevents.create(
date_from=datetime.datetime(2018, 4, 8, 9, 0, tzinfo=datetime.timezone.utc),
date_to=datetime.datetime(2018, 4, 8, 12, 0, tzinfo=datetime.timezone.utc),
)
# SubEvent has before rrule time and no end
self.event1.subevents.create(
date_from=datetime.datetime(2018, 4, 9, 9, 0, tzinfo=datetime.timezone.utc),
)
existing_events = list(self.event1.subevents.values_list('pk', flat=True))
self.event1.settings.timezone = 'Europe/Berlin'
doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', {
'rruleformset-TOTAL_FORMS': '1',
'rruleformset-INITIAL_FORMS': '0',
'rruleformset-MIN_NUM_FORMS': '0',
'rruleformset-MAX_NUM_FORMS': '1000',
'rruleformset-0-end': 'count',
'rruleformset-0-count': '10',
'rruleformset-0-interval': '1',
'rruleformset-0-freq': 'weekly',
'rruleformset-0-dtstart': '2018-04-03',
'rruleformset-0-weekly_byweekday': ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'],
'rruleformset-0-yearly_same': 'on',
'rruleformset-0-monthly_same': 'on',
'timeformset-TOTAL_FORMS': '1',
'timeformset-INITIAL_FORMS': '0',
'timeformset-MIN_NUM_FORMS': '1',
'timeformset-MAX_NUM_FORMS': '1000',
'timeformset-0-time_from': '12:00:00',
'timeformset-0-time_to': '13:00:00',
'rruleformset-0-until': '2019-04-03',
'skip_if_overlap': 'on',
'name_0': 'Foo',
'active': 'on',
'frontpage_text_0': '',
'quotas-TOTAL_FORMS': '1',
'quotas-INITIAL_FORMS': '0',
'quotas-MIN_NUM_FORMS': '0',
'quotas-MAX_NUM_FORMS': '1000',
'quotas-0-name': 'Q1',
'quotas-0-size': '50',
'quotas-0-itemvars': str(self.ticket.pk),
'checkinlist_set-TOTAL_FORMS': '0',
'checkinlist_set-INITIAL_FORMS': '0',
'checkinlist_set-MIN_NUM_FORMS': '0',
'checkinlist_set-MAX_NUM_FORMS': '1000',
})
assert doc.select(".alert-success")
with scopes_disabled():
ses = list(self.event1.subevents.exclude(pk__in=existing_events).order_by('date_from'))
assert len(ses) == 7
assert [s.date_from.date().isoformat() for s in ses] == [
'2018-04-03',
'2018-04-04',
'2018-04-07',
'2018-04-09',
'2018-04-10',
'2018-04-11',
'2018-04-12'
]
def test_delete_bulk(self):
self.subevent2.active = True
self.subevent2.save()