diff --git a/src/pretix/api/serializers/orderchange.py b/src/pretix/api/serializers/orderchange.py index 3574047396..b824b1bd03 100644 --- a/src/pretix/api/serializers/orderchange.py +++ b/src/pretix/api/serializers/orderchange.py @@ -158,12 +158,14 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer): a.question_id: a for a in instance.answers.all() } for answ_data in answers_data: + if not answ_data.get('answer'): + continue options = answ_data.pop('options', []) if answ_data['question'].pk in qs_seen: raise ValidationError(f'Question {answ_data["question"]} was sent twice.') if answ_data['question'].pk in answercache: a = answercache[answ_data['question'].pk] - if isinstance(answ_data['answer'], File): + if isinstance(answ_data.get('answer'), File): a.file.save(answ_data['answer'].name, answ_data['answer'], save=False) a.answer = 'file://' + a.file.name elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep": @@ -173,7 +175,7 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer): setattr(a, attr, value) a.save() else: - if isinstance(answ_data['answer'], File): + if isinstance(answ_data.get('answer'), File): an = answ_data.pop('answer') a = instance.answers.create(**answ_data, answer='') a.file.save(os.path.basename(an.name), an, save=False) diff --git a/src/pretix/base/migrations/0228_scheduledeventexport_scheduledorganizerexport.py b/src/pretix/base/migrations/0228_scheduledeventexport_scheduledorganizerexport.py new file mode 100644 index 0000000000..130daaa73a --- /dev/null +++ b/src/pretix/base/migrations/0228_scheduledeventexport_scheduledorganizerexport.py @@ -0,0 +1,68 @@ +# Generated by Django 3.2.16 on 2023-01-18 11:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import pretix.base.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0227_item_personalized'), + ] + + operations = [ + migrations.CreateModel( + name='ScheduledOrganizerExport', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('export_identifier', models.CharField(max_length=190)), + ('export_form_data', models.JSONField(default=dict)), + ('locale', models.CharField(max_length=250)), + ('mail_additional_recipients', models.TextField()), + ('mail_additional_recipients_cc', models.TextField()), + ('mail_additional_recipients_bcc', models.TextField()), + ('mail_subject', models.CharField(max_length=250)), + ('mail_template', models.TextField()), + ('schedule_rrule', models.TextField(null=True)), + ('schedule_rrule_time', models.TimeField()), + ('schedule_next_run', models.DateTimeField(blank=True, null=True)), + ('error_counter', models.IntegerField(default=0)), + ('error_last_message', models.TextField(null=True)), + ('timezone', models.CharField(default='UTC', max_length=100)), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_exports', to='pretixbase.organizer')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='ScheduledEventExport', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('export_identifier', models.CharField(max_length=190)), + ('export_form_data', models.JSONField(default=dict)), + ('locale', models.CharField(max_length=250)), + ('mail_additional_recipients', models.TextField()), + ('mail_additional_recipients_cc', models.TextField()), + ('mail_additional_recipients_bcc', models.TextField()), + ('mail_subject', models.CharField(max_length=250)), + ('mail_template', models.TextField()), + ('schedule_rrule', models.TextField(null=True)), + ('schedule_rrule_time', models.TimeField()), + ('schedule_next_run', models.DateTimeField(blank=True, null=True)), + ('error_counter', models.IntegerField(default=0)), + ('error_last_message', models.TextField(null=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_exports', to='pretixbase.event')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 626d8263a5..fe395bcf5a 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -30,6 +30,7 @@ from .event import ( Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue, SubEvent, SubEventMetaValue, generate_invite_token, ) +from .exports import ScheduledEventExport, ScheduledOrganizerExport from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction from .invoices import Invoice, InvoiceLine, invoice_filename from .items import ( diff --git a/src/pretix/base/models/exports.py b/src/pretix/base/models/exports.py new file mode 100644 index 0000000000..a6350e8807 --- /dev/null +++ b/src/pretix/base/models/exports.py @@ -0,0 +1,130 @@ +# +# 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 datetime import datetime, timedelta + +import pytz +from dateutil.rrule import rrulestr +from django.conf import settings +from django.db import models +from django.utils.timezone import make_aware, now +from django.utils.translation import gettext_lazy as _ + +from pretix.base.models import LoggedModel +from pretix.base.validators import RRuleValidator, multimail_validate + + +class AbstractScheduledExport(LoggedModel): + id = models.BigAutoField(primary_key=True) + + export_identifier = models.CharField( + max_length=190, + verbose_name=_("Export"), + ) + export_form_data = models.JSONField(default=dict) + + owner = models.ForeignKey( + "pretixbase.User", + on_delete=models.PROTECT, + ) + locale = models.CharField( + verbose_name=_('Language'), + max_length=250 + ) + + mail_additional_recipients = models.TextField( + verbose_name=_('Additional recipients'), + null=False, blank=True, validators=[multimail_validate], + help_text=_("You can specify multiple recipients separated by commas.") + ) + mail_additional_recipients_cc = models.TextField( + verbose_name=_('Additional recipients (Cc)'), + null=False, blank=True, validators=[multimail_validate], + help_text=_("You can specify multiple recipients separated by commas.") + ) + mail_additional_recipients_bcc = models.TextField( + verbose_name=_('Additional recipients (Bcc)'), + null=False, blank=True, validators=[multimail_validate], + help_text=_("You can specify multiple recipients separated by commas.") + ) + mail_subject = models.CharField( + verbose_name=_('Subject'), + max_length=250 + ) + mail_template = models.TextField( + verbose_name=_('Message'), + ) + + schedule_rrule = models.TextField( + null=True, blank=True, validators=[RRuleValidator()] + ) + schedule_rrule_time = models.TimeField( + verbose_name=_("Requested start time"), + help_text=_("The actual start time might be delayed depending on system load."), + ) + schedule_next_run = models.DateTimeField(null=True, blank=True) + + error_counter = models.IntegerField(default=0) + error_last_message = models.TextField(null=True, blank=True) + + class Meta: + abstract = True + + def __str__(self): + return self.mail_subject + + def compute_next_run(self): + tz = self.tz + r = rrulestr(self.schedule_rrule) + new_d = r.after(now().astimezone(tz).replace(tzinfo=None), inc=False) + if not new_d: + self.schedule_next_run = None + return + + try: + self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz) + except pytz.exceptions.AmbiguousTimeError: + self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz, is_dst=False) + except pytz.exceptions.NonExistentTimeError: + self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time) + timedelta(hours=1), tz) + + +class ScheduledEventExport(AbstractScheduledExport): + event = models.ForeignKey( + "pretixbase.Event", on_delete=models.CASCADE, related_name="scheduled_exports" + ) + + @property + def tz(self): + return self.event.timezone + + +class ScheduledOrganizerExport(AbstractScheduledExport): + organizer = models.ForeignKey( + "pretixbase.Organizer", on_delete=models.CASCADE, related_name="scheduled_exports" + ) + timezone = models.CharField(max_length=100, + default=settings.TIME_ZONE, + verbose_name=_('Timezone')) + + @property + def tz(self): + return pytz.timezone(self.timezone) diff --git a/src/pretix/base/services/export.py b/src/pretix/base/services/export.py index 41c9ffcdc0..de0206ea3c 100644 --- a/src/pretix/base/services/export.py +++ b/src/pretix/base/services/export.py @@ -19,31 +19,48 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from typing import Any, Dict +import logging +from datetime import timedelta +from typing import Any, Dict, Union +from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.core.files.base import ContentFile -from django.utils.timezone import override +from django.dispatch import receiver +from django.utils.timezone import now, override from django.utils.translation import gettext +from django_scopes import scopes_disabled +from i18nfield.strings import LazyI18nString +from pretix.base.email import get_email_context from pretix.base.exporter import OrganizerLevelExportMixin from pretix.base.i18n import LazyLocaleException, language from pretix.base.models import ( - CachedFile, Device, Event, Organizer, TeamAPIToken, User, cachedfile_name, + CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken, + User, cachedfile_name, ) +from pretix.base.models.exports import ScheduledOrganizerExport +from pretix.base.services.mail import mail from pretix.base.services.tasks import ( - ProfiledEventTask, ProfiledOrganizerUserTask, + EventTask, OrganizerTask, ProfiledEventTask, ProfiledOrganizerUserTask, ) from pretix.base.signals import ( - register_data_exporters, register_multievent_data_exporters, + periodic_task, register_data_exporters, register_multievent_data_exporters, ) from pretix.celery_app import app +from pretix.helpers.urls import build_absolute_uri + +logger = logging.getLogger(__name__) class ExportError(LazyLocaleException): pass +class ExportEmptyError(ExportError): + pass + + @app.task(base=ProfiledEventTask, throws=(ExportError,), bind=True) def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None: def set_progress(val): @@ -56,7 +73,7 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, file = CachedFile.objects.get(id=fileid) with language(event.settings.locale, event.settings.region), override(event.settings.timezone): responses = register_data_exporters.send(event) - for receiver, response in responses: + for recv, response in responses: if not response: continue ex = response(event, event.organizer, set_progress) @@ -106,16 +123,16 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int, timezone = organizer.settings.timezone or settings.TIME_ZONE region = organizer.settings.region with language(locale, region), override(timezone): - if form_data.get('events') is not None: + if form_data.get('events') is not None and not form_data.get('all_events'): if isinstance(form_data['events'][0], str): events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer) else: - events = allowed_events.filter(pk__in=form_data.get('events')) + events = allowed_events.filter(pk__in=form_data.get('events'), organizer=organizer) else: - events = allowed_events + events = allowed_events.filter(organizer=organizer) responses = register_multievent_data_exporters.send(organizer) - for receiver, response in responses: + for recv, response in responses: if not response: continue ex = response(events, organizer, set_progress) @@ -138,3 +155,198 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int, f = ContentFile(data) file.file.save(cachedfile_name(file, file.filename), f) return file.pk + + +def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, config_url, retry_func, has_permission): + with language(schedule.locale, context.settings.region), override(schedule.tz): + file = CachedFile(web_download=False) + file.date = now() + file.expires = now() + timedelta(hours=24) + file.save() + + def _handle_error(msg, soft=False): + context.log_action( + 'pretix.event.export.schedule.failed', + data={ + 'id': schedule.id, + 'export_identifier': schedule.export_identifier, + 'export_form_data': schedule.export_form_data, + 'reason': msg, + 'soft': soft, + } + ) + if schedule.owner.is_active: + mail( + email=schedule.owner.email, + subject=gettext('Export failed'), + template='pretixbase/email/export_failed.txt', + context={ + 'configuration_url': config_url, + 'reason': msg, + 'soft': soft, + }, + event=context if isinstance(context, Event) else None, + organizer=context.organizer if isinstance(context, Event) else context, + locale=schedule.locale, + ) + if not soft: + schedule.error_counter += 1 + schedule.error_last_message = msg + schedule.save(update_fields=['error_counter', 'error_last_message']) + + if not has_permission: + _handle_error(gettext('Permission denied.')) + return + + try: + if not exporter: + raise ExportError("Export type not found.") + d = exporter.render(schedule.export_form_data) + if d is None: + raise ExportEmptyError( + gettext('Your export did not contain any data.') + ) + file.filename, file.type, data = d + filesize = len(data) + if filesize > 20 * 1024 * 1024: # 20 MB + raise ExportError( + gettext('Your exported data exceeded the size limit for scheduled exports.') + ) + f = ContentFile(data) + file.file.save(cachedfile_name(file, file.filename), f) + except ExportEmptyError as e: + _handle_error(str(e), soft=True) + except ExportError as e: + _handle_error(str(e), soft=False) + except Exception: + logger.exception("Scheduled export failed.") + try: + retry_func() + except MaxRetriesExceededError: + _handle_error('Internal Error') + else: + schedule.error_counter = 0 + schedule.save(update_fields=['error_counter']) + mail( + email=[schedule.owner.email] + [r for r in schedule.mail_additional_recipients.split(",") if r], + cc=[r for r in schedule.mail_additional_recipients_cc.split(",") if r], + bcc=[r for r in schedule.mail_additional_recipients_bcc.split(",") if r], + subject=schedule.mail_subject, + template=LazyI18nString(schedule.mail_template), + context=get_email_context(event=context) if isinstance(context, Event) else {}, + organizer=context.organizer if isinstance(context, Event) else context, + locale=schedule.locale, + attach_cached_files=[file], + ) + context.log_action( + 'pretix.event.export.schedule.executed', + data={ + 'id': schedule.id, + 'export_identifier': schedule.export_identifier, + 'export_form_data': schedule.export_form_data, + 'result_file_size': filesize, + 'result_file_name': file.file.name, + } + ) + + +@app.task(base=OrganizerTask, bind=True, max_retries=5, default_retry_delay=120) +def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> None: + schedule = organizer.scheduled_exports.get(pk=schedule) + + allowed_events = schedule.owner.get_events_with_permission('can_view_orders') + if schedule.export_form_data.get('events') is not None and not schedule.export_form_data.get('all_events'): + if isinstance(schedule.export_form_data['events'][0], str): + events = allowed_events.filter(slug__in=schedule.export_form_data.get('events'), organizer=organizer) + else: + events = allowed_events.filter(pk__in=schedule.export_form_data.get('events'), organizer=organizer) + else: + events = allowed_events.filter(organizer=organizer) + + responses = register_multievent_data_exporters.send(organizer) + exporter = None + for recv, response in responses: + if not response: + continue + ex = response(events, organizer) + if ex.identifier == schedule.export_identifier: + exporter = ex + break + + has_permission = schedule.owner.is_active + if isinstance(exporter, OrganizerLevelExportMixin): + if not schedule.owner.has_organizer_permission(organizer, exporter.organizer_required_permission): + has_permission = False + + _run_scheduled_export( + schedule, + organizer, + exporter, + build_absolute_uri( + 'control:organizer.export', + kwargs={ + 'organizer': organizer.slug, + } + ) + f'?identifier={schedule.export_identifier}&scheduled={schedule.pk}', + self.retry, + has_permission, + ) + + +@app.task(base=EventTask, bind=True, max_retries=5, default_retry_delay=120) +def scheduled_event_export(self, event: Event, schedule: int) -> None: + schedule = event.scheduled_exports.get(pk=schedule) + + responses = register_data_exporters.send(event) + exporter = None + for recv, response in responses: + if not response: + continue + ex = response(event, event.organizer) + if ex.identifier == schedule.export_identifier: + exporter = ex + break + + has_permission = schedule.owner.is_active and schedule.owner.has_event_permission(event.organizer, event, 'can_view_orders') + + _run_scheduled_export( + schedule, + event, + exporter, + build_absolute_uri( + 'control:event.orders.export', + kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + } + ) + f'?identifier={schedule.export_identifier}&scheduled={schedule.pk}', + self.retry, + has_permission, + ) + + +@receiver(signal=periodic_task) +@scopes_disabled() +def run_scheduled_exports(sender, **kwargs): + qs = ScheduledEventExport.objects.filter( + schedule_next_run__lt=now(), + error_counter__lt=5, + ).select_related('event') + for s in qs: + scheduled_event_export.apply_async(kwargs={ + 'event': s.event_id, + 'schedule': s.pk, + }) + s.compute_next_run() + s.save(update_fields=['schedule_next_run']) + qs = ScheduledOrganizerExport.objects.filter( + schedule_next_run__lt=now(), + error_counter__lt=5, + ).select_related('organizer') + for s in qs: + scheduled_organizer_export.apply_async(kwargs={ + 'organizer': s.organizer_id, + 'schedule': s.pk, + }) + s.compute_next_run() + s.save(update_fields=['schedule_next_run']) diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index f15a7a6cfd..b78df38028 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -100,7 +100,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None, customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None, attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None, - plain_text_only=False, no_order_links=False): + plain_text_only=False, no_order_links=False, cc: Sequence[str]=None, bcc: Sequence[str]=None): """ Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation. @@ -211,7 +211,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La subject = raw_subject = str(subject).replace('\n', ' ').replace('\r', '')[:900] signature = "" - bcc = [] + bcc = list(bcc or []) settings_holder = event or organizer @@ -305,6 +305,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La send_task = mail_send_task.si( to=[email] if isinstance(email, str) else list(email), + cc=cc, bcc=bcc, subject=subject, body=body_plain, @@ -357,11 +358,11 @@ class CustomEmail(EmailMultiAlternatives): @app.task(base=TransactionAwareTask, bind=True, acks_late=True) def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str, - event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None, + event: int = None, position: int = None, headers: dict = None, cc: List[str] = None, bcc: List[str] = None, invoices: List[int] = None, order: int = None, attach_tickets=False, user=None, organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None, attach_other_files: List[str] = None) -> bool: - email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers) + email = CustomEmail(subject, body, sender, to=to, cc=cc, bcc=bcc, headers=headers) if html is not None: html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET) html_with_cid, cid_images = replace_images_with_cid_paths(html) diff --git a/src/pretix/base/services/tasks.py b/src/pretix/base/services/tasks.py index 7e3951f6ae..56673c4fa7 100644 --- a/src/pretix/base/services/tasks.py +++ b/src/pretix/base/services/tasks.py @@ -109,6 +109,31 @@ class EventTask(app.Task): return ret +class OrganizerTask(app.Task): + def __call__(self, *args, **kwargs): + if 'organizer_id' in kwargs: + organizer_id = kwargs.get('organizer_id') + with scopes_disabled(): + organizer = Organizer.objects.get(pk=organizer_id) + del kwargs['organizer_id'] + kwargs['organizer'] = organizer + elif 'organizer' in kwargs: + organizer_id = kwargs.get('organizer') + with scopes_disabled(): + organizer = Organizer.objects.get(pk=organizer_id) + kwargs['organizer'] = organizer + else: + args = list(args) + organizer_id = args[0] + with scopes_disabled(): + organizer = Organizer.objects.get(pk=organizer_id) + args[0] = organizer + + with scope(organizer=organizer): + ret = super().__call__(*args, **kwargs) + return ret + + class OrganizerUserTask(app.Task): def __call__(self, *args, **kwargs): organizer_id = kwargs['organizer'] diff --git a/src/pretix/base/templates/pretixbase/email/export_failed.txt b/src/pretix/base/templates/pretixbase/email/export_failed.txt new file mode 100644 index 0000000000..f11d8e2c26 --- /dev/null +++ b/src/pretix/base/templates/pretixbase/email/export_failed.txt @@ -0,0 +1,12 @@ +{% load i18n %} +{% trans "Your export failed." %} + +{% trans "Reason:" %} {{ reason }} + +{% if not soft %} +{% trans "If your export fails five times in a row, it will no longer be sent." %} +{% endif %} + +{% trans "Configuration link:" %} + +{{ configuration_url }} diff --git a/src/pretix/base/validators.py b/src/pretix/base/validators.py index 1ccd6078a2..35671a1cd1 100644 --- a/src/pretix/base/validators.py +++ b/src/pretix/base/validators.py @@ -19,6 +19,12 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +from dateutil.rrule import rrulestr +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.utils.deconstruct import deconstructible +from django.utils.translation import gettext_lazy as _ # This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of # the Apache License 2.0 can be obtained at . @@ -32,11 +38,6 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. -from django.conf import settings -from django.core.exceptions import ValidationError -from django.utils.deconstruct import deconstructible -from django.utils.translation import gettext_lazy as _ - class BanlistValidator: @@ -101,3 +102,18 @@ class EmailBanlistValidator(BanlistValidator): banlist = [ settings.PRETIX_EMAIL_NONE_VALUE, ] + + +def multimail_validate(val): + s = val.split(',') + for part in s: + validate_email(part.strip()) + return s + + +class RRuleValidator: + def __call__(self, value): + try: + rrulestr(value) + except Exception: + raise ValidationError("Not a valid rrule.") diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 98843b9796..d53e5e9898 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -40,7 +40,7 @@ from urllib.parse import urlencode, urlparse from django import forms from django.conf import settings from django.core.exceptions import ValidationError -from django.core.validators import MaxValueValidator, validate_email +from django.core.validators import MaxValueValidator from django.db.models import Prefetch, Q, prefetch_related_objects from django.forms import ( CheckboxSelectMultiple, formset_factory, inlineformset_factory, @@ -66,6 +66,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.settings import ( PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings, ) +from pretix.base.validators import multimail_validate from pretix.control.forms import ( MultipleLanguagesWidget, SlugWidget, SplitDateTimeField, SplitDateTimePickerWidget, @@ -864,13 +865,6 @@ class InvoiceSettingsForm(SettingsForm): return data -def multimail_validate(val): - s = val.split(',') - for part in s: - validate_email(part.strip()) - return s - - def contains_web_channel_validate(val): if "web" not in val: raise ValidationError(_("The online shop must be selected to receive these emails.")) diff --git a/src/pretix/control/forms/exports.py b/src/pretix/control/forms/exports.py new file mode 100644 index 0000000000..a1864280e8 --- /dev/null +++ b/src/pretix/control/forms/exports.py @@ -0,0 +1,103 @@ +# +# 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 forms +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from pytz import common_timezones + +from pretix.base.models import ScheduledEventExport +from pretix.base.models.exports import ScheduledOrganizerExport + + +class ScheduledEventExportForm(forms.ModelForm): + class Meta: + model = ScheduledEventExport + fields = ['mail_additional_recipients', 'mail_additional_recipients_cc', 'mail_additional_recipients_bcc', + 'mail_subject', 'mail_template', 'schedule_rrule_time', 'locale'] + widgets = { + 'mail_additional_recipients': forms.TextInput, + 'mail_additional_recipients_cc': forms.TextInput, + 'mail_additional_recipients_bcc': forms.TextInput, + 'schedule_rrule_time': forms.TimeInput(attrs={'class': 'timepickerfield', 'autocomplete': 'off'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + locale_names = dict(settings.LANGUAGES) + self.fields['locale'] = forms.ChoiceField( + label=_('Language'), + choices=[(a, locale_names[a]) for a in self.instance.event.settings.locales] + ) + + def clean_mail_additional_recipients(self): + d = self.cleaned_data['mail_additional_recipients'].replace(' ', '') + if len(d.split(',')) > 25: + raise ValidationError(_('Please enter less than 25 recipients.')) + return d + + def clean_mail_additional_recipients_cc(self): + d = self.cleaned_data['mail_additional_recipients_cc'].replace(' ', '') + if len(d.split(',')) > 25: + raise ValidationError(_('Please enter less than 25 recipients.')) + return d + + def clean_mail_additional_recipients_bcc(self): + d = self.cleaned_data['mail_additional_recipients_bcc'].replace(' ', '') + if len(d.split(',')) > 25: + raise ValidationError(_('Please enter less than 25 recipients.')) + return d + + +class ScheduledOrganizerExportForm(forms.ModelForm): + class Meta: + model = ScheduledOrganizerExport + fields = ['mail_additional_recipients', 'mail_additional_recipients_cc', 'mail_additional_recipients_bcc', + 'mail_subject', 'mail_template', 'schedule_rrule_time', 'locale', 'timezone'] + widgets = { + 'mail_additional_recipients': forms.TextInput, + 'mail_additional_recipients_cc': forms.TextInput, + 'mail_additional_recipients_bcc': forms.TextInput, + 'schedule_rrule_time': forms.TimeInput(attrs={'class': 'timepickerfield', 'autocomplete': 'off'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + locale_names = dict(settings.LANGUAGES) + self.fields['locale'] = forms.ChoiceField( + label=_('Language'), + choices=[(a, locale_names[a]) for a in self.instance.organizer.settings.locales] + ) + self.fields['timezone'] = forms.ChoiceField( + choices=((a, a) for a in common_timezones), + label=_("Timezone"), + ) + + def clean_mail_additional_recipients(self): + return self.cleaned_data['mail_additional_recipients'].replace(' ', '') + + def clean_mail_additional_recipients_cc(self): + return self.cleaned_data['mail_additional_recipients_cc'].replace(' ', '') + + def clean_mail_additional_recipients_bcc(self): + return self.cleaned_data['mail_additional_recipients_bcc'].replace(' ', '') diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index 139f3c536b..afff4481b6 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -227,6 +227,10 @@ class ExporterForm(forms.Form): elif isinstance(v, models.QuerySet): data[k] = [m.pk for m in v] + if 'all_events' in self.fields and 'events' in self.fields: + if not data.get('all_events') and not data.get('events'): + raise ValidationError(_('Please select some events.')) + return data diff --git a/src/pretix/control/forms/rrule.py b/src/pretix/control/forms/rrule.py new file mode 100644 index 0000000000..40e9db3620 --- /dev/null +++ b/src/pretix/control/forms/rrule.py @@ -0,0 +1,251 @@ +# +# 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 datetime import timedelta + +from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr +from django import forms +from django.utils.dates import MONTHS, WEEKDAYS +from django.utils.timezone import get_current_timezone, now +from django.utils.translation import gettext_lazy as _, pgettext_lazy + + +class RRuleForm(forms.Form): + # TODO: calendar.setfirstweekday + freq = forms.ChoiceField( + choices=[ + ('yearly', _('year(s)')), + ('monthly', _('month(s)')), + ('weekly', _('week(s)')), + ('daily', _('day(s)')), + ], + initial='weekly' + ) + interval = forms.IntegerField( + label=_('Interval'), + initial=1, + min_value=1, + widget=forms.NumberInput(attrs={'min': '1'}) + ) + dtstart = forms.DateField( + label=_('Start date'), + widget=forms.DateInput( + attrs={ + 'class': 'datepickerfield', + 'required': 'required' + } + ), + initial=lambda: now().astimezone(get_current_timezone()).date() + ) + + end = forms.ChoiceField( + choices=[ + ('count', ''), + ('until', ''), + ('forever', ''), + ], + initial='count', + widget=forms.RadioSelect + ) + count = forms.IntegerField( + label=_('Number of repetitions'), + initial=10 + ) + until = forms.DateField( + widget=forms.DateInput( + attrs={ + 'class': 'datepickerfield', + 'required': 'required' + } + ), + label=_('Last date'), + required=True, + initial=lambda: now() + timedelta(days=30) + ) + + yearly_bysetpos = forms.ChoiceField( + choices=[ + ('1', pgettext_lazy('rrule', 'first')), + ('2', pgettext_lazy('rrule', 'second')), + ('3', pgettext_lazy('rrule', 'third')), + ('-1', pgettext_lazy('rrule', 'last')), + ], + required=False + ) + yearly_same = forms.ChoiceField( + choices=[ + ('on', ''), + ('off', ''), + ], + initial='on', + widget=forms.RadioSelect + ) + yearly_byweekday = forms.ChoiceField( + choices=[ + ('MO', WEEKDAYS[0]), + ('TU', WEEKDAYS[1]), + ('WE', WEEKDAYS[2]), + ('TH', WEEKDAYS[3]), + ('FR', WEEKDAYS[4]), + ('SA', WEEKDAYS[5]), + ('SU', WEEKDAYS[6]), + ('MO,TU,WE,TH,FR,SA,SU', _('Day')), + ('MO,TU,WE,TH,FR', _('Weekday')), + ('SA,SU', _('Weekend day')), + ], + required=False + ) + yearly_bymonth = forms.ChoiceField( + choices=[ + (str(i), MONTHS[i]) for i in range(1, 13) + ], + required=False + ) + + monthly_same = forms.ChoiceField( + choices=[ + ('on', ''), + ('off', ''), + ], + initial='on', + widget=forms.RadioSelect + ) + monthly_bysetpos = forms.ChoiceField( + choices=[ + ('1', pgettext_lazy('rrule', 'first')), + ('2', pgettext_lazy('rrule', 'second')), + ('3', pgettext_lazy('rrule', 'third')), + ('-1', pgettext_lazy('rrule', 'last')), + ], + required=False + ) + monthly_byweekday = forms.ChoiceField( + choices=[ + ('MO', WEEKDAYS[0]), + ('TU', WEEKDAYS[1]), + ('WE', WEEKDAYS[2]), + ('TH', WEEKDAYS[3]), + ('FR', WEEKDAYS[4]), + ('SA', WEEKDAYS[5]), + ('SU', WEEKDAYS[6]), + ('MO,TU,WE,TH,FR,SA,SU', _('Day')), + ('MO,TU,WE,TH,FR', _('Weekday')), + ('SA,SU', _('Weekend day')), + ], + required=False + ) + + weekly_byweekday = forms.MultipleChoiceField( + choices=[ + ('MO', WEEKDAYS[0]), + ('TU', WEEKDAYS[1]), + ('WE', WEEKDAYS[2]), + ('TH', WEEKDAYS[3]), + ('FR', WEEKDAYS[4]), + ('SA', WEEKDAYS[5]), + ('SU', WEEKDAYS[6]), + ], + required=False, + widget=forms.CheckboxSelectMultiple + ) + + def parse_weekdays(self, value): + m = { + 'MO': 0, + 'TU': 1, + 'WE': 2, + 'TH': 3, + 'FR': 4, + 'SA': 5, + 'SU': 6 + } + if ',' in value: + return [m.get(a) for a in value.split(',')] + else: + return m.get(value) + + def to_rrule(self): + rule_kwargs = {} + rule_kwargs['dtstart'] = self.cleaned_data['dtstart'] + rule_kwargs['interval'] = self.cleaned_data['interval'] + + if self.cleaned_data['freq'] == 'yearly': + freq = YEARLY + if self.cleaned_data['yearly_same'] == "off": + rule_kwargs['bysetpos'] = int(self.cleaned_data['yearly_bysetpos']) + rule_kwargs['byweekday'] = self.parse_weekdays(self.cleaned_data['yearly_byweekday']) + rule_kwargs['bymonth'] = int(self.cleaned_data['yearly_bymonth']) + + elif self.cleaned_data['freq'] == 'monthly': + freq = MONTHLY + + if self.cleaned_data['monthly_same'] == "off": + rule_kwargs['bysetpos'] = int(self.cleaned_data['monthly_bysetpos']) + rule_kwargs['byweekday'] = self.parse_weekdays(self.cleaned_data['monthly_byweekday']) + elif self.cleaned_data['freq'] == 'weekly': + freq = WEEKLY + + if self.cleaned_data['weekly_byweekday']: + rule_kwargs['byweekday'] = [self.parse_weekdays(a) for a in self.cleaned_data['weekly_byweekday']] + + elif self.cleaned_data['freq'] == 'daily': + freq = DAILY + + if self.cleaned_data['end'] == 'count': + rule_kwargs['count'] = self.cleaned_data['count'] + elif self.cleaned_data['end'] == 'until': + rule_kwargs['until'] = self.cleaned_data['until'] + return rrule(freq, **rule_kwargs) + + @staticmethod + def initial_from_rrule(rule: rrule): + initial = {} + if isinstance(rule, str): + rule = rrulestr(rule) + + _rule = rule._original_rule + initial['dtstart'] = rule._dtstart + initial['interval'] = rule._interval + + if rule._freq == YEARLY: + initial['freq'] = 'yearly' + initial['yearly_bysetpos'] = _rule.get('bysetpos') + initial['yearly_byweekday'] = _rule.get('byweekday') + initial['yearly_bymonth'] = _rule.get('bymonth') + elif rule._freq == MONTHLY: + initial['freq'] = 'monthly' + initial['monthly_bysetpos'] = _rule.get('bysetpos') + initial['monthly_byweekday'] = _rule.get('byweekday') + elif rule._freq == WEEKLY: + initial['freq'] = 'weekly' + initial['weekly_byweekday'] = _rule.get('byweekday') + elif rule._freq == DAILY: + initial['freq'] = 'daily' + + if rule._count: + initial['end'] = 'count' + initial['count'] = rule._count + elif rule._until: + initial['end'] = 'until' + initial['until'] = rule._until + else: + initial['end'] = 'forever' + return initial diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index 494fd11245..4b2fb39142 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -19,17 +19,15 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from datetime import datetime, timedelta +from datetime import datetime from urllib.parse import urlencode from django import forms from django.forms import formset_factory from django.forms.utils import ErrorDict from django.urls import reverse -from django.utils.dates import MONTHS, WEEKDAYS from django.utils.functional import cached_property -from django.utils.timezone import now -from django.utils.translation import gettext_lazy as _, pgettext_lazy +from django.utils.translation import gettext_lazy as _ from i18nfield.forms import I18nInlineFormSet from pretix.base.forms import I18nModelForm @@ -39,6 +37,7 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper from pretix.base.templatetags.money import money_filter from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget +from pretix.control.forms.rrule import RRuleForm from pretix.helpers.money import change_decimal_field @@ -440,166 +439,15 @@ class CheckinListFormSet(I18nInlineFormSet): return form -class RRuleForm(forms.Form): - # TODO: calendar.setfirstweekday +class RRuleFormSetForm(RRuleForm): exclude = forms.BooleanField( label=_('Exclude these dates instead of adding them.'), required=False ) - freq = forms.ChoiceField( - choices=[ - ('yearly', _('year(s)')), - ('monthly', _('month(s)')), - ('weekly', _('week(s)')), - ('daily', _('day(s)')), - ], - initial='weekly' - ) - interval = forms.IntegerField( - label=_('Interval'), - initial=1, - min_value=1, - widget=forms.NumberInput(attrs={'min': '1'}) - ) - dtstart = forms.DateField( - label=_('Start date'), - widget=forms.DateInput( - attrs={ - 'class': 'datepickerfield', - 'required': 'required' - } - ), - initial=lambda: now().date() - ) - - end = forms.ChoiceField( - choices=[ - ('count', ''), - ('until', ''), - ], - initial='count', - widget=forms.RadioSelect - ) - count = forms.IntegerField( - label=_('Number of repetitions'), - initial=10 - ) - until = forms.DateField( - widget=forms.DateInput( - attrs={ - 'class': 'datepickerfield', - 'required': 'required' - } - ), - label=_('Last date'), - required=True, - initial=lambda: now() + timedelta(days=30) - ) - - yearly_bysetpos = forms.ChoiceField( - choices=[ - ('1', pgettext_lazy('rrule', 'first')), - ('2', pgettext_lazy('rrule', 'second')), - ('3', pgettext_lazy('rrule', 'third')), - ('-1', pgettext_lazy('rrule', 'last')), - ], - required=False - ) - yearly_same = forms.ChoiceField( - choices=[ - ('on', ''), - ('off', ''), - ], - initial='on', - widget=forms.RadioSelect - ) - yearly_byweekday = forms.ChoiceField( - choices=[ - ('MO', WEEKDAYS[0]), - ('TU', WEEKDAYS[1]), - ('WE', WEEKDAYS[2]), - ('TH', WEEKDAYS[3]), - ('FR', WEEKDAYS[4]), - ('SA', WEEKDAYS[5]), - ('SU', WEEKDAYS[6]), - ('MO,TU,WE,TH,FR,SA,SU', _('Day')), - ('MO,TU,WE,TH,FR', _('Weekday')), - ('SA,SU', _('Weekend day')), - ], - required=False - ) - yearly_bymonth = forms.ChoiceField( - choices=[ - (str(i), MONTHS[i]) for i in range(1, 13) - ], - required=False - ) - - monthly_same = forms.ChoiceField( - choices=[ - ('on', ''), - ('off', ''), - ], - initial='on', - widget=forms.RadioSelect - ) - monthly_bysetpos = forms.ChoiceField( - choices=[ - ('1', pgettext_lazy('rrule', 'first')), - ('2', pgettext_lazy('rrule', 'second')), - ('3', pgettext_lazy('rrule', 'third')), - ('-1', pgettext_lazy('rrule', 'last')), - ], - required=False - ) - monthly_byweekday = forms.ChoiceField( - choices=[ - ('MO', WEEKDAYS[0]), - ('TU', WEEKDAYS[1]), - ('WE', WEEKDAYS[2]), - ('TH', WEEKDAYS[3]), - ('FR', WEEKDAYS[4]), - ('SA', WEEKDAYS[5]), - ('SU', WEEKDAYS[6]), - ('MO,TU,WE,TH,FR,SA,SU', _('Day')), - ('MO,TU,WE,TH,FR', _('Weekday')), - ('SA,SU', _('Weekend day')), - ], - required=False - ) - - weekly_byweekday = forms.MultipleChoiceField( - choices=[ - ('MO', WEEKDAYS[0]), - ('TU', WEEKDAYS[1]), - ('WE', WEEKDAYS[2]), - ('TH', WEEKDAYS[3]), - ('FR', WEEKDAYS[4]), - ('SA', WEEKDAYS[5]), - ('SU', WEEKDAYS[6]), - ], - required=False, - widget=forms.CheckboxSelectMultiple - ) - - def parse_weekdays(self, value): - m = { - 'MO': 0, - 'TU': 1, - 'WE': 2, - 'TH': 3, - 'FR': 4, - 'SA': 5, - 'SU': 6 - } - if ',' in value: - return [m.get(a) for a in value.split(',')] - else: - return m.get(value) RRuleFormSet = formset_factory( - RRuleForm, + RRuleFormSetForm, can_order=False, can_delete=True, extra=1 ) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 89b26e782a..2dd0ca3d41 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -315,6 +315,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.organizer.changed': _('The organizer has been changed.'), 'pretix.organizer.settings': _('The organizer settings have been changed.'), 'pretix.organizer.footerlinks.changed': _('The footer links have been changed.'), + 'pretix.organizer.export.schedule.added': _('A scheduled export has been added.'), + 'pretix.organizer.export.schedule.changed': _('A scheduled export has been changed.'), + 'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'), + 'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'), + 'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'), 'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'), 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), 'pretix.webhook.created': _('The webhook has been created.'), @@ -409,6 +414,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'), 'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'), 'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'), + 'pretix.event.export.schedule.added': _('A scheduled export has been added.'), + 'pretix.event.export.schedule.changed': _('A scheduled export has been changed.'), + 'pretix.event.export.schedule.deleted': _('A scheduled export has been deleted.'), + 'pretix.event.export.schedule.executed': _('A scheduled export has been executed.'), + 'pretix.event.export.schedule.failed': _('A scheduled export has failed: {reason}.'), 'pretix.control.auth.user.created': _('The user has been created.'), 'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'), 'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'), diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 94b98d3d20..95ea282858 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -40,6 +40,7 @@ + diff --git a/src/pretix/control/templates/pretixcontrol/orders/export.html b/src/pretix/control/templates/pretixcontrol/orders/export.html index 0e7e9bf287..c98c8bc7f3 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/export.html +++ b/src/pretix/control/templates/pretixcontrol/orders/export.html @@ -7,6 +7,86 @@

{% trans "Data export" %}

+ {% if scheduled %} +

{% trans "Scheduled exports" %}

+
    + {% for s in scheduled %} +
  • +
    +
    + + {{ s.export_verbose_name }} +
    + + + {{ s.owner.fullname|default:s.owner.email }} + +
    +
    + {% if s.schedule_next_run %} + + {% trans "Next run:" %} + {{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }} + {% else %} + + {% trans "No next run scheduled" %} + {% endif %} + {% if s.export_verbose_name == "?" %} + + + {% trans "Exporter not found" %} + + {% elif s.error_counter >= 5 %} + + + {% trans "Disabled due to multiple failures" %} + + {% elif s.error_counter > 0 %} + + + {% trans "Failed recently" %} + + {% endif %} + +
    + + {{ s.mail_subject }} +
    +
    +
    +
    + {% csrf_token %} + + + {% if s.export_verbose_name != "?" %} + + + + + + {% endif %} + + + +
    +
    +
    +
  • + {% endfor %} +
+ {% if is_paginated %} + {% include "pretixcontrol/pagination.html" %} + {% endif %} + {% endif %} {% regroup exporters by category as category_list %} {% for c, c_ex in category_list %} {% if c %} @@ -20,7 +100,8 @@

{{ e.verbose_name }} {% if e.featured %} - + {% endif %}

{% if e.description %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/export_delete.html b/src/pretix/control/templates/pretixcontrol/orders/export_delete.html new file mode 100644 index 0000000000..a239e1d02f --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/orders/export_delete.html @@ -0,0 +1,19 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Delete scheduled export" %}{% endblock %} +{% block content %} +

{% trans "Delete scheduled export" %}

+
+ {% csrf_token %} +

{% blocktrans %}Are you sure you want to delete the scheduled export {{ export }}?{% endblocktrans %}

+
+ + {% trans "Cancel" %} + + +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/export_form.html b/src/pretix/control/templates/pretixcontrol/orders/export_form.html index 0d7a0caf11..467be34a53 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/export_form.html +++ b/src/pretix/control/templates/pretixcontrol/orders/export_form.html @@ -1,7 +1,6 @@ {% extends "pretixcontrol/event/base.html" %} {% load i18n %} {% load bootstrap3 %} -{% load order_overview %} {% block title %}{% trans "Data export" %}{% endblock %} {% block content %}

@@ -15,16 +14,38 @@ {% if exporter.description %}

{{ exporter.description }}

{% endif %} + {% if schedule_form %} + {% bootstrap_form_errors schedule_form layout='control' %} + {% endif %}
{% csrf_token %} - {% bootstrap_form exporter.form layout='control' %} -
- -
+
+ {% trans "Export options" %} + {% bootstrap_form exporter.form layout='control' %} +
+ {% if schedule_form %} + {% include "pretixcontrol/orders/fragment_export_schedule_form.html" %} +
+ +
+ {% else %} +
+ + +
+ {% endif %}
{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/fragment_export_schedule_form.html b/src/pretix/control/templates/pretixcontrol/orders/fragment_export_schedule_form.html new file mode 100644 index 0000000000..e9bf46ff0a --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/orders/fragment_export_schedule_form.html @@ -0,0 +1,141 @@ +{% load i18n %} +{% load bootstrap3 %} +{% load captureas %} + +
+ {% trans "Schedule" %} + {% bootstrap_field schedule_form.schedule_rrule_time layout='control' %} + {% if schedule_form.timezone %} + {% bootstrap_field schedule_form.timezone layout='control' %} + {% endif %} + {% bootstrap_form_errors rrule_form layout='control' %} + + {% bootstrap_field rrule_form.dtstart layout="control" %} +
+ +
+
+ {% captureas ffield_freq %} + {% bootstrap_field rrule_form.freq layout="inline" %} + {% endcaptureas %} + {% captureas ffield_interval %} + {% bootstrap_field rrule_form.interval layout="inline" %} + {% endcaptureas %} + {% captureas ffield_yearly_bysetpos %} + {% bootstrap_field rrule_form.yearly_bysetpos layout="inline" %} + {% endcaptureas %} + {% captureas ffield_yearly_byweekday %} + {% bootstrap_field rrule_form.yearly_byweekday layout="inline" %} + {% endcaptureas %} + {% captureas ffield_yearly_bymonth %} + {% bootstrap_field rrule_form.yearly_bymonth layout="inline" %} + {% endcaptureas %} + {% captureas ffield_monthly_bysetpos %} + {% bootstrap_field rrule_form.monthly_bysetpos layout="inline" %} + {% endcaptureas %} + {% captureas ffield_monthly_byweekday %} + {% bootstrap_field rrule_form.monthly_byweekday layout="inline" %} + {% endcaptureas %} + {% captureas ffield_count %} + {% bootstrap_field rrule_form.count layout="inline" %} + {% endcaptureas %} + {% captureas ffield_until %} + {% bootstrap_field rrule_form.until layout="inline" %} + {% endcaptureas %} + + {% blocktrans trimmed with freq=ffield_freq interval=ffield_interval start=ffield_dtstart %} + Repeat every {{ interval }} {{ freq }} + {% endblocktrans %}
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+ {% bootstrap_field rrule_form.weekly_byweekday layout="inline" %} +
+
+
+
+
+ +
+
+
+
+
+
+
+ {% trans "Email" %} +
+ {% trans "Every time your schedule is executed, the report will be sent via email." %} + {% trans "Please note the following limitations:" %} +
    +
  • + {% trans "Email is not a strongly encrypted medium. We only recommend using this for exports that output e.g. statistical data, not for reports that include sensitive personal data." %} +
  • +
  • + {% trans "Email is not made for large files. If your export ends up to be larger than 20 megabytes, it will not be sent." %} +
  • +
+
+
+ +
+ +
+ {% trans "The export will be performed using the owner's permission level, i.e. if the owner loses access to the data, the report will stop." %} + {% trans "The owner will receive the result as well as any error messages." %} + {% trans "The additional recipients you add below will only receive an email if the report was successful." %} + {% trans "All recipients of the export will be able to see who the owner of the report is." %} +
+
+
+ {% bootstrap_field schedule_form.mail_additional_recipients layout='control' %} + {% bootstrap_field schedule_form.mail_additional_recipients_cc layout='control' %} + {% bootstrap_field schedule_form.mail_additional_recipients_bcc layout='control' %} + {% bootstrap_field schedule_form.locale layout='control' %} + {% bootstrap_field schedule_form.mail_subject layout='control' %} + {% bootstrap_field schedule_form.mail_template layout='control' %} +
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/export.html b/src/pretix/control/templates/pretixcontrol/organizers/export.html index 5371b02925..781aab18b8 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/export.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/export.html @@ -1,4 +1,4 @@ -{% extends "pretixcontrol/event/base.html" %} +{% extends "pretixcontrol/organizers/base.html" %} {% load i18n %} {% load bootstrap3 %} {% load order_overview %} @@ -7,6 +7,86 @@

{% trans "Data export" %}

+ {% if scheduled %} +

{% trans "Scheduled exports" %}

+
    + {% for s in scheduled %} +
  • +
    +
    + + {{ s.export_verbose_name }} +
    + + + {{ s.owner.fullname|default:s.owner.email }} + +
    +
    + {% if s.schedule_next_run %} + + {% trans "Next run:" %} + {{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }} + {% else %} + + {% trans "No next run scheduled" %} + {% endif %} + {% if s.export_verbose_name == "?" %} + + + {% trans "Exporter not found" %} + + {% elif s.error_counter >= 5 %} + + + {% trans "Disabled due to multiple failures" %} + + {% elif s.error_counter > 0 %} + + + {% trans "Failed recently" %} + + {% endif %} + +
    + + {{ s.mail_subject }} +
    +
    +
    +
    + {% csrf_token %} + + + {% if s.export_verbose_name != "?" %} + + + + + + {% endif %} + + + +
    +
    +
    +
  • + {% endfor %} +
+ {% if is_paginated %} + {% include "pretixcontrol/pagination.html" %} + {% endif %} + {% endif %} {% regroup exporters by category as category_list %} {% for c, c_ex in category_list %} {% if c %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/export_delete.html b/src/pretix/control/templates/pretixcontrol/organizers/export_delete.html new file mode 100644 index 0000000000..0834cba62b --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/export_delete.html @@ -0,0 +1,19 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Delete scheduled export" %}{% endblock %} +{% block content %} +

{% trans "Delete scheduled export" %}

+
+ {% csrf_token %} +

{% blocktrans %}Are you sure you want to delete the scheduled export {{ export }}?{% endblocktrans %}

+
+ + {% trans "Cancel" %} + + +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/export_form.html b/src/pretix/control/templates/pretixcontrol/organizers/export_form.html index ea7552f6b0..dc948936b1 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/export_form.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/export_form.html @@ -1,7 +1,6 @@ -{% extends "pretixcontrol/event/base.html" %} +{% extends "pretixcontrol/organizers/base.html" %} {% load i18n %} {% load bootstrap3 %} -{% load order_overview %} {% block title %}{% trans "Data export" %}{% endblock %} {% block content %}

@@ -15,16 +14,39 @@ {% if exporter.description %}

{{ exporter.description }}

{% endif %} + {% if schedule_form %} + {% bootstrap_form_errors schedule_form layout='control' %} + {% endif %}
{% csrf_token %} - {% bootstrap_form exporter.form layout='control' %} -
- -
+ +
+ {% trans "Export options" %} + {% bootstrap_form exporter.form layout='control' %} +
+ {% if schedule_form %} + {% include "pretixcontrol/orders/fragment_export_schedule_form.html" %} +
+ +
+ {% else %} +
+ + +
+ {% endif %}
{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index a3ece0290a..3bf1adecbf 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -207,6 +207,10 @@ urlpatterns = [ re_path(r'^organizer/(?P[^/]+)/logs', organizer.LogView.as_view(), name='organizer.log'), re_path(r'^organizer/(?P[^/]+)/export/$', organizer.ExportView.as_view(), name='organizer.export'), re_path(r'^organizer/(?P[^/]+)/export/do$', organizer.ExportDoView.as_view(), name='organizer.export.do'), + re_path(r'^organizer/(?P[^/]+)/export/(?P[^/]+)/run$', organizer.RunScheduledExportView.as_view(), + name='organizer.export.scheduled.run'), + re_path(r'^organizer/(?P[^/]+)/export/(?P[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(), + name='organizer.export.scheduled.delete'), re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'), re_path(r'^events/$', main.EventList.as_view(), name='events'), re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'), @@ -386,6 +390,8 @@ urlpatterns = [ re_path(r'^orders/import/$', orderimport.ImportView.as_view(), name='event.orders.import'), re_path(r'^orders/import/(?P[^/]+)/$', orderimport.ProcessView.as_view(), name='event.orders.import.process'), re_path(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'), + re_path(r'^orders/export/(?P[^/]+)/run$', orders.RunScheduledExportView.as_view(), name='event.orders.export.scheduled.run'), + re_path(r'^orders/export/(?P[^/]+)/delete$', orders.DeleteScheduledExportView.as_view(), name='event.orders.export.scheduled.delete'), re_path(r'^orders/export/do$', orders.ExportDoView.as_view(), name='event.orders.export.do'), re_path(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'), re_path(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index d6cce780c0..1a9b86b5b5 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -66,7 +66,7 @@ from django.utils.http import url_has_allowed_host_and_scheme from django.utils.timezone import make_aware, now from django.utils.translation import gettext, gettext_lazy as _, ngettext from django.views.generic import ( - DetailView, FormView, ListView, TemplateView, View, + DeleteView, DetailView, FormView, ListView, TemplateView, View, ) from i18nfield.strings import LazyI18nString @@ -77,7 +77,7 @@ from pretix.base.i18n import language from pretix.base.models import ( CachedCombinedTicket, CachedFile, CachedTicket, Checkin, Invoice, InvoiceAddress, Item, ItemVariation, LogEntry, Order, QuestionAnswer, - Quota, generate_secret, + Quota, ScheduledEventExport, generate_secret, ) from pretix.base.models.orders import ( CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund, @@ -87,7 +87,7 @@ from pretix.base.payment import PaymentException from pretix.base.secrets import assign_ticket_secret from pretix.base.services import tickets from pretix.base.services.cancelevent import cancel_event -from pretix.base.services.export import export +from pretix.base.services.export import export, scheduled_event_export from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task, invoice_qualified, regenerate_invoice, @@ -111,6 +111,7 @@ from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.views.mixins import OrderQuestionsViewMixin from pretix.base.views.tasks import AsyncAction +from pretix.control.forms.exports import ScheduledEventExportForm from pretix.control.forms.filter import ( EventOrderExpertFilterForm, EventOrderFilterForm, OverviewFilterForm, RefundFilterForm, @@ -122,6 +123,7 @@ from pretix.control.forms.orders import ( OrderPositionAddFormset, OrderPositionChangeForm, OrderPositionMailForm, OrderRefundForm, OtherOperationsForm, ReactivateOrderForm, ) +from pretix.control.forms.rrule import RRuleForm from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.signals import order_search_forms from pretix.control.views import PaginationMixin @@ -2252,13 +2254,16 @@ class ExportMixin: if id != ex.identifier: continue - # Use form parse cycle to generate useful defaults - test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier) - test_form.fields = ex.export_form_fields - test_form.is_valid() - initial = { - k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET - } + if self.scheduled: + initial = self.scheduled.export_form_data + else: + # Use form parse cycle to generate useful defaults + test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier) + test_form.fields = ex.export_form_fields + test_form.is_valid() + initial = { + k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET + } ex.form = ExporterForm( data=(self.request.POST if self.request.method == 'POST' else None), @@ -2268,6 +2273,21 @@ class ExportMixin: ex.form.fields = ex.export_form_fields return ex + def get_scheduled_queryset(self): + if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_change_event_settings', + request=self.request): + qs = self.request.event.scheduled_exports.filter(owner=self.request.user) + else: + qs = self.request.event.scheduled_exports + return qs.select_related('owner').order_by('export_identifier', 'schedule_next_run') + + @cached_property + def scheduled(self): + if "scheduled" in self.request.POST: + return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.POST.get("scheduled")) + elif "scheduled" in self.request.GET: + return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled")) + def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['exporters'] = self.exporters @@ -2309,25 +2329,165 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, Templ 'organizer': self.request.event.organizer.slug })) - if not self.exporter.form.is_valid(): - messages.error(self.request, _('There was a problem processing your input. See below for error details.')) - return self.get(request, *args, **kwargs) + if self.scheduled: + data = self.scheduled.export_form_data + else: + if not self.exporter.form.is_valid(): + messages.error(self.request, _('There was a problem processing your input. See below for error details.')) + return self.get(request, *args, **kwargs) + data = self.exporter.form.cleaned_data cf = CachedFile(web_download=True, session_key=request.session.session_key) cf.date = now() cf.expires = now() + timedelta(hours=24) cf.save() - return self.do(self.request.event.id, str(cf.id), self.exporter.identifier, self.exporter.form.cleaned_data) + return self.do(self.request.event.id, str(cf.id), self.exporter.identifier, data) -class ExportView(EventPermissionRequiredMixin, ExportMixin, TemplateView): +class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView): permission = 'can_view_orders' + paginate_by = 25 + context_object_name = 'scheduled' def get_template_names(self): if self.exporter: return ['pretixcontrol/orders/export_form.html'] return ['pretixcontrol/orders/export.html'] + @transaction.atomic() + def post(self, request, *args, **kwargs): + if request.POST.get("schedule") == "save": + if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid(): + self.schedule_form.instance.export_identifier = self.exporter.identifier + self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data + self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule()) + self.schedule_form.instance.error_counter = 0 + self.schedule_form.instance.error_last_message = None + self.schedule_form.instance.compute_next_run() + self.schedule_form.instance.save() + if self.schedule_form.instance.schedule_next_run: + messages.success( + request, + _('Your export schedule has been saved. The next export will start around {datetime}.').format( + datetime=date_format(self.schedule_form.instance.schedule_next_run, 'SHORT_DATETIME_FORMAT') + ) + ) + else: + messages.warning(request, _('Your export schedule has been saved, but no next export is planned.')) + self.request.event.log_action( + 'pretix.event.export.schedule.changed' if self.scheduled else 'pretix.event.export.schedule.added', + user=self.request.user, data={ + 'id': self.schedule_form.instance.id, + 'export_identifier': self.exporter.identifier, + 'export_form_data': self.exporter.form.cleaned_data, + 'schedule_rrule': self.schedule_form.instance.schedule_rrule, + **self.schedule_form.cleaned_data, + } + ) + return redirect(reverse('control:event.orders.export', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug + })) + else: + return super().get(request, *args, **kwargs) + return super().get(request, *args, **kwargs) + + @cached_property + def rrule_form(self): + if self.scheduled: + initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule) + else: + initial = {} + return RRuleForm( + data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None, + prefix="rrule", + initial=initial + ) + + @cached_property + def schedule_form(self): + instance = self.scheduled or ScheduledEventExport( + event=self.request.event, + owner=self.request.user, + ) + if not self.scheduled: + initial = { + "mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name), + "mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format( + name=str(self.request.event.name) + ), + "schedule_rrule_time": time(4, 0, 0), + } + else: + initial = {} + return ScheduledEventExportForm( + data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None, + prefix="schedule", + instance=instance, + initial=initial, + ) + + def get_queryset(self): + return self.get_scheduled_queryset() + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + if "schedule" in self.request.POST or self.scheduled: + ctx['schedule_form'] = self.schedule_form + ctx['rrule_form'] = self.rrule_form + elif not self.exporter: + for s in ctx['scheduled']: + try: + s.export_verbose_name = [e for e in self.exporters if e.identifier == s.export_identifier][0].verbose_name + except IndexError: + s.export_verbose_name = "?" + return ctx + + +class DeleteScheduledExportView(EventPermissionRequiredMixin, ExportMixin, DeleteView): + permission = 'can_view_orders' + template_name = 'pretixcontrol/orders/export_delete.html' + context_object_name = 'export' + + def get_queryset(self): + return self.get_scheduled_queryset() + + def get_success_url(self): + return reverse('control:event.orders.export', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug + }) + + @transaction.atomic() + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + self.object.delete() + self.request.event.log_action('pretix.event.export.schedule.deleted', user=self.request.user, data={ + 'id': self.object.id, + }) + return redirect(self.get_success_url()) + + +class RunScheduledExportView(EventPermissionRequiredMixin, ExportMixin, View): + + def post(self, request, *args, **kwargs): + s = get_object_or_404(self.get_scheduled_queryset(), pk=kwargs.get('pk')) + scheduled_event_export.apply_async( + kwargs={ + 'event': s.event_id, + 'schedule': s.pk, + }, + # Scheduled exports usually run on the low-prio queue "background" but if they're manually triggered, + # we run them with normal priority + queue='default', + ) + messages.success(self.request, _('Your export is queued to start soon. The results will be send via email. ' + 'Depending on system load and type and size of export, this may take a few ' + 'minutes.')) + return redirect(reverse('control:organizer.export', kwargs={ + 'organizer': self.request.organizer.slug + })) + class RefundList(EventPermissionRequiredMixin, PaginationMixin, ListView): model = OrderRefund diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index e3b2735490..0c9ff966e7 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -34,7 +34,7 @@ import json import re -from datetime import timedelta +from datetime import time, timedelta from decimal import Decimal import bleach @@ -53,9 +53,10 @@ from django.forms import DecimalField from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse +from django.utils.formats import date_format from django.utils.functional import cached_property -from django.utils.timezone import now -from django.utils.translation import gettext_lazy as _ +from django.utils.timezone import get_current_timezone, now +from django.utils.translation import gettext, gettext_lazy as _ from django.views import View from django.views.generic import ( CreateView, DeleteView, DetailView, FormView, ListView, TemplateView, @@ -71,7 +72,7 @@ from pretix.base.i18n import language from pretix.base.models import ( CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry, Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer, - Team, TeamInvite, User, + ScheduledOrganizerExport, Team, TeamInvite, User, ) from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue @@ -81,12 +82,13 @@ from pretix.base.models.giftcards import ( from pretix.base.models.orders import CancellationRequest from pretix.base.models.organizer import TeamAPIToken from pretix.base.payment import PaymentException -from pretix.base.services.export import multiexport +from pretix.base.services.export import multiexport, scheduled_organizer_export from pretix.base.services.mail import SendMailException, mail from pretix.base.settings import SETTINGS_AFFECTING_CSS from pretix.base.signals import register_multievent_data_exporters from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.views.tasks import AsyncAction +from pretix.control.forms.exports import ScheduledOrganizerExportForm from pretix.control.forms.filter import ( CustomerFilterForm, DeviceFilterForm, EventFilterForm, GiftCardFilterForm, OrganizerFilterForm, TeamFilterForm, @@ -100,6 +102,7 @@ from pretix.control.forms.organizer import ( OrganizerSettingsForm, OrganizerUpdateForm, SSOClientForm, SSOProviderForm, TeamForm, WebHookForm, ) +from pretix.control.forms.rrule import RRuleForm from pretix.control.logdisplay import OVERVIEW_BANLIST from pretix.control.permissions import ( AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin, @@ -1521,13 +1524,18 @@ class ExportMixin: for ex in self.exporters: if id != ex.identifier: continue - # Use form parse cycle to generate useful defaults - test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier) - test_form.fields = ex.export_form_fields - test_form.is_valid() - initial = { - k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET - } + if self.scheduled: + initial = self.scheduled.export_form_data + else: + # Use form parse cycle to generate useful defaults + test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier) + test_form.fields = ex.export_form_fields + test_form.is_valid() + initial = { + k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET + } + if 'events' not in initial: + initial.setdefault('all_events', True) ex.form = ExporterForm( data=(self.request.POST if self.request.method == 'POST' else None), @@ -1537,15 +1545,22 @@ class ExportMixin: ex.form.fields = ex.export_form_fields if not isinstance(ex, OrganizerLevelExportMixin): ex.form.fields.update([ + ('all_events', + forms.BooleanField( + label=_("All events (that I have access to)"), + required=False + )), ('events', forms.ModelMultipleChoiceField( queryset=self.events, - initial=self.events, widget=forms.CheckboxSelectMultiple( - attrs={'class': 'scrolling-multiple-choice'} + attrs={ + 'class': 'scrolling-multiple-choice', + 'data-inverse-dependency': f'#id_{ex.identifier}-all_events', + } ), label=_('Events'), - required=True + required=False )), ]) return ex @@ -1582,6 +1597,21 @@ class ExportMixin: ctx['exporters'] = self.exporters return ctx + def get_scheduled_queryset(self): + if not self.request.user.has_organizer_permission(self.request.organizer, 'can_change_organizer_settings', + request=self.request): + qs = self.request.organizer.scheduled_exports.filter(owner=self.request.user) + else: + qs = self.request.organizer.scheduled_exports + return qs.select_related('owner').order_by('export_identifier', 'schedule_next_run') + + @cached_property + def scheduled(self): + if "scheduled" in self.request.POST: + return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.POST.get("scheduled")) + elif "scheduled" in self.request.GET: + return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled")) + class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView): known_errortypes = ['ExportError'] @@ -1611,9 +1641,13 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, T 'organizer': self.request.organizer.slug }) - if not self.exporter.form.is_valid(): - messages.error(self.request, _('There was a problem processing your input. See below for error details.')) - return self.get(request, *args, **kwargs) + if self.scheduled: + data = self.scheduled.export_form_data + else: + if not self.exporter.form.is_valid(): + messages.error(self.request, _('There was a problem processing your input. See below for error details.')) + return self.get(request, *args, **kwargs) + data = self.exporter.form.cleaned_data cf = CachedFile(web_download=True, session_key=request.session.session_key) cf.date = now() @@ -1626,17 +1660,152 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, T provider=self.exporter.identifier, device=None, token=None, - form_data=self.exporter.form.cleaned_data, + form_data=data, staff_session=self.request.user.has_active_staff_session(self.request.session.session_key) ) -class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, TemplateView): +class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView): + paginate_by = 25 + context_object_name = 'scheduled' + def get_template_names(self): if self.exporter: return ['pretixcontrol/organizers/export_form.html'] return ['pretixcontrol/organizers/export.html'] + @transaction.atomic() + def post(self, request, *args, **kwargs): + if request.POST.get("schedule") == "save": + if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid(): + self.schedule_form.instance.export_identifier = self.exporter.identifier + self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data + self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule()) + self.schedule_form.instance.error_counter = 0 + self.schedule_form.instance.error_last_message = None + self.schedule_form.instance.compute_next_run() + self.schedule_form.instance.save() + if self.schedule_form.instance.schedule_next_run: + messages.success( + request, + _('Your export schedule has been saved. The next export will start around {datetime}.').format( + datetime=date_format(self.schedule_form.instance.schedule_next_run, 'SHORT_DATETIME_FORMAT') + ) + ) + else: + messages.warning(request, _('Your export schedule has been saved, but no next export is planned.')) + self.request.organizer.log_action( + 'pretix.organizer.export.schedule.changed' if self.scheduled else 'pretix.organizer.export.schedule.added', + user=self.request.user, data={ + 'id': self.schedule_form.instance.id, + 'export_identifier': self.exporter.identifier, + 'export_form_data': self.exporter.form.cleaned_data, + 'schedule_rrule': self.schedule_form.instance.schedule_rrule, + **self.schedule_form.cleaned_data, + } + ) + return redirect(reverse('control:organizer.export', kwargs={ + 'organizer': self.request.organizer.slug + })) + else: + return super().get(request, *args, **kwargs) + return super().get(request, *args, **kwargs) + + @cached_property + def rrule_form(self): + if self.scheduled: + initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule) + else: + initial = {} + return RRuleForm( + data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None, + prefix="rrule", + initial=initial + ) + + @cached_property + def schedule_form(self): + instance = self.scheduled or ScheduledOrganizerExport( + organizer=self.request.organizer, + owner=self.request.user, + timezone=get_current_timezone().zone, + ) + if not self.scheduled: + initial = { + "mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name), + "mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format( + name=str(self.request.organizer.name) + ), + "schedule_rrule_time": time(4, 0, 0), + } + else: + initial = {} + return ScheduledOrganizerExportForm( + data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None, + prefix="schedule", + instance=instance, + initial=initial, + ) + + def get_queryset(self): + return self.get_scheduled_queryset() + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + if "schedule" in self.request.POST or self.scheduled: + ctx['schedule_form'] = self.schedule_form + ctx['rrule_form'] = self.rrule_form + elif not self.exporter: + for s in ctx['scheduled']: + try: + s.export_verbose_name = [e for e in self.exporters if e.identifier == s.export_identifier][0].verbose_name + except IndexError: + s.export_verbose_name = "?" + return ctx + + +class DeleteScheduledExportView(OrganizerPermissionRequiredMixin, ExportMixin, DeleteView): + template_name = 'pretixcontrol/organizers/export_delete.html' + context_object_name = 'export' + + def get_queryset(self): + return self.get_scheduled_queryset() + + def get_success_url(self): + return reverse('control:organizer.export', kwargs={ + 'organizer': self.request.organizer.slug + }) + + @transaction.atomic() + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + self.object.delete() + self.request.organizer.log_action('pretix.organizer.export.schedule.deleted', user=self.request.user, data={ + 'id': self.object.id, + }) + return redirect(self.get_success_url()) + + +class RunScheduledExportView(OrganizerPermissionRequiredMixin, ExportMixin, View): + + def post(self, request, *args, **kwargs): + s = get_object_or_404(self.get_scheduled_queryset(), pk=kwargs.get('pk')) + scheduled_organizer_export.apply_async( + kwargs={ + 'organizer': s.organizer_id, + 'schedule': s.pk, + }, + # Scheduled exports usually run on the low-prio queue "background" but if they're manually triggered, + # we run them with normal priority + queue='default', + ) + messages.success(self.request, _('Your export is queued to start soon. The results will be send via email. ' + 'Depending on system load and type and size of export, this may take a few ' + 'minutes.')) + return redirect(reverse('control:organizer.export', kwargs={ + 'organizer': self.request.organizer.slug + })) + class GateListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): model = Gate diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index de56f1b695..5652d79979 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -36,7 +36,7 @@ import copy from collections import defaultdict from datetime import datetime, time, timedelta -from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rruleset +from dateutil.rrule import rruleset from django.contrib import messages from django.core.exceptions import ValidationError from django.core.files import File @@ -789,41 +789,10 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn if f in self.rrule_formset.deleted_forms: continue - rule_kwargs = {} - rule_kwargs['dtstart'] = f.cleaned_data['dtstart'] - rule_kwargs['interval'] = f.cleaned_data['interval'] - - if f.cleaned_data['freq'] == 'yearly': - freq = YEARLY - if f.cleaned_data['yearly_same'] == "off": - rule_kwargs['bysetpos'] = int(f.cleaned_data['yearly_bysetpos']) - rule_kwargs['byweekday'] = f.parse_weekdays(f.cleaned_data['yearly_byweekday']) - rule_kwargs['bymonth'] = int(f.cleaned_data['yearly_bymonth']) - - elif f.cleaned_data['freq'] == 'monthly': - freq = MONTHLY - - if f.cleaned_data['monthly_same'] == "off": - rule_kwargs['bysetpos'] = int(f.cleaned_data['monthly_bysetpos']) - rule_kwargs['byweekday'] = f.parse_weekdays(f.cleaned_data['monthly_byweekday']) - elif f.cleaned_data['freq'] == 'weekly': - freq = WEEKLY - - if f.cleaned_data['weekly_byweekday']: - rule_kwargs['byweekday'] = [f.parse_weekdays(a) for a in f.cleaned_data['weekly_byweekday']] - - elif f.cleaned_data['freq'] == 'daily': - freq = DAILY - - if f.cleaned_data['end'] == 'count': - rule_kwargs['count'] = f.cleaned_data['count'] - else: - rule_kwargs['until'] = f.cleaned_data['until'] - if f.cleaned_data['exclude']: - s.exrule(rrule(freq, **rule_kwargs)) + s.exrule(f.to_rrule()) else: - s.rrule(rrule(freq, **rule_kwargs)) + s.rrule(f.to_rrule()) return s diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 0e415a7f2f..2709abbcc0 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -822,6 +822,8 @@ CELERY_TASK_QUEUES = ( ) CELERY_TASK_ROUTES = ([ ('pretix.base.services.cart.*', {'queue': 'checkout'}), + ('pretix.base.services.export.scheduled_organizer_export', {'queue': 'background'}), + ('pretix.base.services.export.scheduled_event_export', {'queue': 'background'}), ('pretix.base.services.orders.*', {'queue': 'checkout'}), ('pretix.base.services.mail.*', {'queue': 'mail'}), ('pretix.base.services.update_check.*', {'queue': 'background'}), diff --git a/src/pretix/static/pretixbase/js/asynctask.js b/src/pretix/static/pretixbase/js/asynctask.js index 0d987a2b38..59bbca6cb9 100644 --- a/src/pretix/static/pretixbase/js/asynctask.js +++ b/src/pretix/static/pretixbase/js/asynctask.js @@ -192,6 +192,13 @@ function async_task_error(jqXHR, textStatus, errorThrown) { $(function () { "use strict"; $("body").on('submit', 'form[data-asynctask]', function (e) { + // Not supported on IE, may lead to wrong results, but we don't support IE in the backend anymore + var submitter = e.originalEvent ? e.originalEvent.submitter : null; + + if (submitter && submitter.hasAttribute("data-no-asynctask")) { + return; + } + e.preventDefault(); $(this).removeClass("dirty"); // Avoid problems with are-you-sure.js if ($("body").data('ajaxing')) { @@ -218,17 +225,19 @@ $(function () { 'this page and try again.' )); + var action = this.action; var formData = new FormData(this); formData.append('ajax', '1'); - // Not supported on IE, may lead to wrong results, but we don't support IE in the backend anymore - var submitter = e.originalEvent ? e.originalEvent.submitter : null; if (submitter && submitter.name) { formData.append(submitter.name, submitter.value); } + if (submitter && submitter.getAttribute("formaction")) { + action = submitter.getAttribute("formaction"); + } $.ajax( { 'type': 'POST', - 'url': this.action, + 'url': action, 'data': formData, processData: false, contentType: false, diff --git a/src/pretix/static/pretixcontrol/js/ui/rrule.js b/src/pretix/static/pretixcontrol/js/ui/rrule.js new file mode 100644 index 0000000000..965c737d49 --- /dev/null +++ b/src/pretix/static/pretixcontrol/js/ui/rrule.js @@ -0,0 +1,21 @@ +/*globals $, Morris, gettext, RRule, RRuleSet*/ +function rrule_form_toggles($form) { + var freq = $form.find("select[name*=freq]").val(); + $form.find(".repeat-yearly").toggle(freq === "yearly"); + $form.find(".repeat-monthly").toggle(freq === "monthly"); + $form.find(".repeat-weekly").toggle(freq === "weekly"); +} + +function rrule_bind_form($form) { + $form.find("select[name*=freq]").change(function () { + rrule_form_toggles($form); + }); + rrule_form_toggles($form); +} + + +$(document).on("pretix:bind-forms", function () { + $(".rrule-form").each(function () { + rrule_bind_form($(this)); + }); +}); diff --git a/src/pretix/static/pretixcontrol/js/ui/subevent.js b/src/pretix/static/pretixcontrol/js/ui/subevent.js index 85e4a8ca8b..2e3f6793c4 100644 --- a/src/pretix/static/pretixcontrol/js/ui/subevent.js +++ b/src/pretix/static/pretixcontrol/js/ui/subevent.js @@ -122,20 +122,6 @@ $(document).on("pretix:bind-forms", function () { } } - function rrule_form_toggles($form) { - var freq = $form.find("select[name*=freq]").val(); - $form.find(".repeat-yearly").toggle(freq === "yearly"); - $form.find(".repeat-monthly").toggle(freq === "monthly"); - $form.find(".repeat-weekly").toggle(freq === "weekly"); - } - - function rrule_bind_form($form) { - $form.find("select[name*=freq]").change(function () { - rrule_form_toggles($form); - }); - rrule_form_toggles($form); - } - $("#subevent_add_many_slots_go").on("click", function () { $("#time-formset [data-formset-form]").each(function () { var tf = $(this).find("[name$=time_from]").val() @@ -186,7 +172,6 @@ $(document).on("pretix:bind-forms", function () { }); rrule_preview(); - $(".rrule-form").each(function () { rrule_bind_form($(this)); }); $("#rrule-formset").on("formAdded", "div", function (event) { rrule_bind_form($(event.target)); }); var $namef = $("input[id^=id_name]").first(); diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index f36c921c5c..a8f8057c95 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -78,7 +78,7 @@ div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], d // btn-lg @include button-size($padding-large-vertical, $padding-large-horizontal, $font-size-large, $line-height-large, $btn-border-radius-large); } - .btn-cancel { + .btn-cancel, .btn-alternative { float: left !important; // btn-lg @include button-size($padding-large-vertical, $padding-large-horizontal, $font-size-large, $line-height-large, $btn-border-radius-large); @@ -461,7 +461,7 @@ table td > .checkbox input[type="checkbox"] { width: 100px; display: inline; } -.form-horizontal [data-formset] .rrule-form .form-group { +.form-horizontal .rrule-form .form-group { margin: 0; width: auto; } diff --git a/src/tests/base/test_export.py b/src/tests/base/test_export.py new file mode 100644 index 0000000000..68b68a7e86 --- /dev/null +++ b/src/tests/base/test_export.py @@ -0,0 +1,362 @@ +# +# 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 datetime import datetime, time, timedelta + +import pytest +import pytz +from django.core import mail as djmail +from django.utils.timezone import now +from django_scopes import scope +from freezegun import freeze_time + +from pretix.base.models import ( + Event, Organizer, ScheduledEventExport, ScheduledOrganizerExport, User, +) +from pretix.base.services.export import run_scheduled_exports + + +@pytest.fixture(scope='function') +def event(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=datetime(2023, 1, 19, 2, 30, 0, tzinfo=pytz.UTC), + plugins='pretix.plugins.banktransfer' + ) + with scope(organizer=o): + yield event + + +@pytest.fixture +def team(event): + return event.organizer.teams.create(all_events=True, can_view_orders=True) + + +@pytest.fixture +def user(team): + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + team.members.add(user) + return user + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_event_run_sets_new_time(event, user): + s = ScheduledEventExport(event=event, owner=user) + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run == event.timezone.localize(datetime(2023, 1, 19, 2, 30, 0)) + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_event_not_run_when_failed_5_times(event, user): + s = ScheduledEventExport(event=event, owner=user) + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = event.timezone.localize(datetime(2023, 1, 18, 2, 30, 0)) + s.error_counter = 5 + s.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run == event.timezone.localize(datetime(2023, 1, 18, 2, 30, 0)) + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_event_fail_invalid_config(event, user): + djmail.outbox = [] + s = ScheduledEventExport(event=event, owner=user) + s.export_identifier = " invalid " + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.error_counter = 0 + s.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run > now() + assert s.error_counter == 1 + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Export failed" + assert "Reason: Export type not found." in djmail.outbox[0].body + assert djmail.outbox[0].to == [user.email] + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_event_fail_user_inactive(event, user): + djmail.outbox = [] + s = ScheduledEventExport(event=event, owner=user) + s.export_identifier = "orderlist" + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.error_counter = 0 + s.save() + + user.is_active = False + user.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run > now() + assert s.error_counter == 1 + assert len(djmail.outbox) == 0 # no mails sent to inactive user + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_event_fail_user_no_permission(event, user, team): + djmail.outbox = [] + s = ScheduledEventExport(event=event, owner=user) + s.export_identifier = "orderlist" + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.error_counter = 0 + s.save() + + team.can_view_orders = False + team.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run > now() + assert s.error_counter == 1 + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Export failed" + assert "Reason: Permission denied." in djmail.outbox[0].body + assert djmail.outbox[0].to == [user.email] + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_event_ok(event, user, team): + djmail.outbox = [] + s = ScheduledEventExport(event=event, owner=user) + s.export_identifier = "orderlist" + s.export_form_data = {"_format": "xlsx", "paid_only": False} + s.mail_additional_recipients = "boss@example.org,boss@example.net" + s.mail_additional_recipients_cc = "assistant@example.net" + s.mail_additional_recipients_bcc = "archive@example.net" + s.mail_subject = "Report 1" + s.mail_template = "Here is the report." + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.error_counter = 1 + s.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run > now() + assert s.error_counter == 0 + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Report 1" + assert "Here is the report." in djmail.outbox[0].body + assert djmail.outbox[0].to == [user.email, "boss@example.org", "boss@example.net"] + assert djmail.outbox[0].cc == ["assistant@example.net"] + assert djmail.outbox[0].bcc == ["archive@example.net"] + assert len(djmail.outbox[0].attachments) == 1 + assert djmail.outbox[0].attachments[0][0] == "dummy_orders.xlsx" + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_organizer_run_sets_new_time(event, user): + s = ScheduledOrganizerExport(organizer=event.organizer, owner=user) + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run == event.timezone.localize(datetime(2023, 1, 19, 2, 30, 0)) + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_organizer_not_run_when_failed_5_times(event, user): + s = ScheduledOrganizerExport(organizer=event.organizer, owner=user) + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = event.timezone.localize(datetime(2023, 1, 18, 2, 30, 0)) + s.error_counter = 5 + s.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run == event.timezone.localize(datetime(2023, 1, 18, 2, 30, 0)) + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_organizer_fail_invalid_config(event, user): + djmail.outbox = [] + s = ScheduledOrganizerExport(organizer=event.organizer, owner=user) + s.export_identifier = " invalid " + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.error_counter = 0 + s.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run > now() + assert s.error_counter == 1 + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Export failed" + assert "Reason: Export type not found." in djmail.outbox[0].body + assert djmail.outbox[0].to == [user.email] + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_organizer_fail_user_inactive(event, user): + djmail.outbox = [] + s = ScheduledOrganizerExport(organizer=event.organizer, owner=user) + s.export_identifier = "orderlist" + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.error_counter = 0 + s.save() + + user.is_active = False + user.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run > now() + assert s.error_counter == 1 + assert len(djmail.outbox) == 0 # no mails sent to inactive user + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_organizer_fail_user_does_not_have_specific_permission(event, user, team): + djmail.outbox = [] + s = ScheduledOrganizerExport(organizer=event.organizer, owner=user) + s.export_identifier = "customerlist" + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.error_counter = 0 + s.save() + + team.can_manage_customers = False + team.save() + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run > now() + assert s.error_counter == 1 + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Export failed" + assert "Reason: Permission denied." in djmail.outbox[0].body + assert djmail.outbox[0].to == [user.email] + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_organizer_limited_to_events(event, user, team): + djmail.outbox = [] + s = ScheduledOrganizerExport(organizer=event.organizer, owner=user) + s.export_identifier = "eventdata" + s.export_form_data = {"_format": "default", "all_events": True} + s.mail_subject = "Report 1" + s.mail_template = "Here is the report." + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.error_counter = 0 + s.save() + + event2 = Event.objects.create( + organizer=event.organizer, name='Dummy', slug='dummy2', + date_from=datetime(2023, 1, 19, 2, 30, 0, tzinfo=pytz.UTC), + plugins='pretix.plugins.banktransfer' + ) + team.all_events = False + team.save() + team.limit_events.add(event2) + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run > now() + assert s.error_counter == 0 + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Report 1" + assert "Here is the report." in djmail.outbox[0].body + assert djmail.outbox[0].to == [user.email] + assert len(djmail.outbox[0].attachments) == 1 + assert djmail.outbox[0].attachments[0][0] == "dummy_events.csv" + assert len(djmail.outbox[0].attachments[0][1].splitlines()) == 2 + + +@pytest.mark.django_db +@freeze_time("2023-01-18 03:00:00+01:00") +def test_organizer_ok(event, user, team): + djmail.outbox = [] + s = ScheduledOrganizerExport(organizer=event.organizer, owner=user) + s.export_identifier = "eventdata" + s.export_form_data = {"_format": "default", "all_events": True} + s.mail_additional_recipients = "boss@example.org,boss@example.net" + s.mail_additional_recipients_cc = "assistant@example.net" + s.mail_additional_recipients_bcc = "archive@example.net" + s.mail_subject = "Report 1" + s.mail_template = "Here is the report." + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = time(2, 30, 0) + s.schedule_next_run = now() - timedelta(minutes=5) + s.error_counter = 1 + s.save() + + Event.objects.create( + organizer=event.organizer, name='Dummy', slug='dummy2', + date_from=datetime(2023, 1, 19, 2, 30, 0, tzinfo=pytz.UTC), + plugins='pretix.plugins.banktransfer' + ) + + run_scheduled_exports(None) + s.refresh_from_db() + assert s.schedule_next_run > now() + assert s.error_counter == 0 + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Report 1" + assert "Here is the report." in djmail.outbox[0].body + assert djmail.outbox[0].to == [user.email, "boss@example.org", "boss@example.net"] + assert djmail.outbox[0].cc == ["assistant@example.net"] + assert djmail.outbox[0].bcc == ["archive@example.net"] + assert len(djmail.outbox[0].attachments) == 1 + assert djmail.outbox[0].attachments[0][0] == "dummy_events.csv" + assert len(djmail.outbox[0].attachments[0][1].splitlines()) == 3 diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index b6af9a0f84..fd9d696322 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -48,12 +48,14 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.utils.timezone import now from django_scopes import scope, scopes_disabled +from freezegun import freeze_time from pretix.base.i18n import language from pretix.base.models import ( CachedFile, CartPosition, Checkin, CheckinList, Event, Item, ItemCategory, ItemVariation, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, - Organizer, Question, Quota, SeatingPlan, User, Voucher, WaitingListEntry, + Organizer, Question, Quota, ScheduledEventExport, SeatingPlan, User, + Voucher, WaitingListEntry, ) from pretix.base.models.event import SubEvent from pretix.base.models.items import ( @@ -2766,3 +2768,44 @@ def test_subevent_date_updates_order_date(): assert order1.last_modified > o1lm assert order2.last_modified == o2lm + + +class ScheduledExportTestCase(TestCase): + + @scopes_disabled() + def setUp(self): + self.organizer = Organizer.objects.create(name='Dummy', slug='dummy') + self.event = Event.objects.create( + organizer=self.organizer, name='Dummy', slug='dummy', + date_from=now(), date_to=now() - timedelta(hours=1), has_subevents=True + ) + self.event.settings.timezone = 'Europe/Berlin' + + @classscope(attr='organizer') + def test_compute_next_time(self): + s = ScheduledEventExport(event=self.event) + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;COUNT=30;INTERVAL=1;WKST=MO;BYDAY=TU,TH" + s.schedule_rrule_time = datetime.time(6, 30, 0) + + with freeze_time("2023-01-18 15:08:00+01:00"): + s.compute_next_run() + assert s.schedule_next_run == self.event.timezone.localize(datetime.datetime(2023, 1, 19, 6, 30, 0)) + + with freeze_time("2024-01-18 15:08:00+01:00"): + s.compute_next_run() + assert s.schedule_next_run is None + + @classscope(attr='organizer') + def test_compute_next_time_handle_dst(self): + s = ScheduledEventExport(event=self.event) + s.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s.schedule_rrule_time = datetime.time(2, 30, 0) + with freeze_time("2023-03-25 18:00:00+01:00"): + s.compute_next_run() + assert s.schedule_next_run == self.event.timezone.localize(datetime.datetime(2023, 3, 26, 3, 30, 0)) + with freeze_time("2023-03-26 18:00:00+01:00"): + s.compute_next_run() + assert s.schedule_next_run == self.event.timezone.localize(datetime.datetime(2023, 3, 27, 2, 30, 0)) + with freeze_time("2023-10-28 18:00:00+01:00"): + s.compute_next_run() + assert s.schedule_next_run == self.event.timezone.localize(datetime.datetime(2023, 10, 29, 2, 30, 0, fold=0)) diff --git a/src/tests/control/test_export.py b/src/tests/control/test_export.py new file mode 100644 index 0000000000..008ce6ef81 --- /dev/null +++ b/src/tests/control/test_export.py @@ -0,0 +1,380 @@ +# +# 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 +# . +# +import datetime +import json + +import pytest +from django.utils.timezone import now + +from pretix.base.models import ( + Event, Item, Organizer, ScheduledEventExport, ScheduledOrganizerExport, + Team, User, +) + + +@pytest.fixture +def env(): + o = Organizer.objects.create(name="Dummy", slug="dummy") + event = Event.objects.create( + organizer=o, name="Dummy", slug="dummy", + date_from=now(), plugins="pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy" + ) + event.settings.set("ticketoutput_testdummy__enabled", True) + user = User.objects.create_user("dummy@dummy.dummy", "dummy") + t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True, can_manage_customers=True, + can_change_event_settings=True) + t.members.add(user) + t.limit_events.add(event) + + Item.objects.create( + event=event, name="Early-bird ticket", category=None, default_price=23, + admission=True, personalized=True + ) + return event, user, t + + +@pytest.mark.django_db +def test_event_export(client, env): + client.login(email="dummy@dummy.dummy", password="dummy") + response = client.get("/control/event/dummy/dummy/orders/export/?identifier=itemdata") + assert b"Export format" in response.content + response = client.post("/control/event/dummy/dummy/orders/export/do", { + "exporter": "itemdata", + "itemdata-_format": "default", + "ajax": "1" + }) + d = json.loads(response.content) + assert d["ready"] + assert d["success"] + response = client.get(d["redirect"]) + assert len(b"".join(response.streaming_content).split(b"\n")) == 3 + + +@pytest.mark.django_db +def test_event_export_schedule(client, env): + client.login(email="dummy@dummy.dummy", password="dummy") + response = client.get("/control/event/dummy/dummy/orders/export/?identifier=itemdata") + assert b"Export format" in response.content + assert b"Repetition schedule" not in response.content + + # Start editing + response = client.post("/control/event/dummy/dummy/orders/export/?identifier=itemdata", { + "schedule": "yes", + "exporter": "itemdata", + "itemdata-_format": "default", + }) + assert b"Export format" in response.content + assert b"Repetition schedule" in response.content + + # Create schedule + response = client.post("/control/event/dummy/dummy/orders/export/?identifier=itemdata", { + "schedule": "save", + "exporter": "itemdata", + "itemdata-_format": "default", + "schedule-schedule_rrule_time": "03:30", + "rrule-dtstart": "2023-01-19", + "rrule-interval": "1", + "rrule-freq": "weekly", + "rrule-end": "forever", + "rrule-until": "2022-01-01", # ignored + "rrule-count": "10", # ignored + "rrule-monthly_same": "on", # ignored + "rrule-yearly_same": "on", # ignored + "schedule-mail_additional_recipients": "boss@example.net, friend@example.com", + "schedule-locale": "en", + "schedule-mail_subject": "Product data, my friend!", + "schedule-mail_template": "Mail body" + }, follow=True) + assert b"Your export schedule has been saved. The next export will start around" in response.content + + s = env[0].scheduled_exports.get() + assert s.owner == env[1] + assert s.schedule_rrule == "DTSTART:20230119T000000\nRRULE:FREQ=WEEKLY" + assert s.schedule_rrule_time == datetime.time(3, 30, 0) + assert s.schedule_next_run > now() + assert s.export_identifier == "itemdata" + assert s.export_form_data == {"_format": "default"} + assert s.locale == "en" + assert s.mail_additional_recipients == "boss@example.net,friend@example.com" + assert s.mail_subject == "Product data, my friend!" + assert s.mail_template == "Mail body" + + # Schedule is in list + response = client.get("/control/event/dummy/dummy/orders/export/") + assert b"Product data, my friend!" in response.content + + # Edit schedule + response = client.get(f"/control/event/dummy/dummy/orders/export/?identifier=itemdata&scheduled={s.pk}") + assert b"Mail body" in response.content + + # Submit edited schedule + response = client.post(f"/control/event/dummy/dummy/orders/export/?identifier=itemdata&scheduled={s.pk}", { + "schedule": "save", + "exporter": "itemdata", + "itemdata-_format": "xlsx", + "schedule-schedule_rrule_time": "03:30", + "rrule-dtstart": "2023-01-10", + "rrule-interval": "1", + "rrule-freq": "weekly", + "rrule-end": "until", + "rrule-until": "2022-01-01", # ignored + "rrule-count": "1", + "rrule-monthly_same": "on", # ignored + "rrule-yearly_same": "on", # ignored + "schedule-mail_additional_recipients": "boss@example.net, friend@example.com", + "schedule-locale": "en", + "schedule-mail_subject": "Product data, my friend!", + "schedule-mail_template": "Mail body" + }, follow=True) + assert b"Your export schedule has been saved, but no next export is planned" in response.content + s.refresh_from_db() + assert s.schedule_next_run is None + assert s.export_form_data == {"_format": "xlsx"} + + # Run + response = client.post(f"/control/event/dummy/dummy/orders/export/{s.pk}/run") + assert response.status_code == 302 + + # Delete schedule + response = client.get(f"/control/event/dummy/dummy/orders/export/{s.pk}/delete") + assert b"Product data, my friend!" in response.content + client.post(f"/control/event/dummy/dummy/orders/export/{s.pk}/delete") + assert env[0].scheduled_exports.count() == 0 + + +@pytest.mark.django_db +def test_event_limited_permission(client, env): + env[2].can_change_event_settings = False + env[2].save() + user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy") + + s1 = ScheduledEventExport(event=env[0], owner=env[1]) + s1.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s1.schedule_rrule_time = datetime.time(2, 30, 0) + s1.error_counter = 5 + s1.mail_subject = "RULE1" + s1.save() + s2 = ScheduledEventExport(event=env[0], owner=user2) + s2.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s2.schedule_rrule_time = datetime.time(2, 30, 0) + s2.error_counter = 5 + s2.mail_subject = "RULE2" + s2.save() + + client.login(email="dummy@dummy.dummy", password="dummy") + response = client.get("/control/event/dummy/dummy/orders/export/") + assert b"RULE1" in response.content + assert b"RULE2" not in response.content + + response = client.get(f"/control/event/dummy/dummy/orders/export/?identifier=itemdata&scheduled={s1.pk}") + assert response.status_code == 200 + response = client.get(f"/control/event/dummy/dummy/orders/export/?identifier=itemdata&scheduled={s2.pk}") + assert response.status_code == 404 + + response = client.post(f"/control/event/dummy/dummy/orders/export/{s1.pk}/run") + assert response.status_code == 302 + response = client.get(f"/control/event/dummy/dummy/orders/export/{s1.pk}/delete") + assert response.status_code == 200 + response = client.post(f"/control/event/dummy/dummy/orders/export/{s2.pk}/run") + assert response.status_code == 404 + response = client.get(f"/control/event/dummy/dummy/orders/export/{s2.pk}/delete") + assert response.status_code == 404 + + env[2].can_change_event_settings = True + env[2].save() + response = client.get("/control/event/dummy/dummy/orders/export/") + assert b"RULE1" in response.content + assert b"RULE2" in response.content + response = client.get(f"/control/event/dummy/dummy/orders/export/?identifier=itemdata&scheduled={s2.pk}") + assert response.status_code == 200 + response = client.get(f"/control/event/dummy/dummy/orders/export/{s2.pk}/delete") + assert response.status_code == 200 + response = client.post(f"/control/event/dummy/dummy/orders/export/{s2.pk}/run") + assert response.status_code == 302 + + +@pytest.mark.django_db +def test_organizer_export(client, env): + client.login(email="dummy@dummy.dummy", password="dummy") + response = client.get("/control/organizer/dummy/export/?identifier=eventdata") + assert b"Export format" in response.content + response = client.post("/control/organizer/dummy/export/do", { + "exporter": "eventdata", + "eventdata-_format": "default", + "eventdata-all_events": "on", + "ajax": "1" + }) + d = json.loads(response.content) + assert d["ready"] + assert d["success"] + response = client.get(d["redirect"]) + assert len(b"".join(response.streaming_content).split(b"\n")) == 3 + + +@pytest.mark.django_db +def test_organizer_export_schedule(client, env): + client.login(email="dummy@dummy.dummy", password="dummy") + response = client.get("/control/organizer/dummy/export/?identifier=eventdata") + assert b"Export format" in response.content + assert b"Repetition schedule" not in response.content + + # Start editing + response = client.post("/control/organizer/dummy/export/?identifier=eventdata", { + "schedule": "yes", + "exporter": "eventdata", + "eventdata-_format": "default", + "eventdata-all_events": "on", + }) + assert b"Export format" in response.content + assert b"Repetition schedule" in response.content + + # Create schedule + response = client.post("/control/organizer/dummy/export/?identifier=eventdata", { + "schedule": "save", + "exporter": "eventdata", + "eventdata-_format": "default", + "eventdata-all_events": "on", + "schedule-schedule_rrule_time": "03:30", + "schedule-timezone": "Australia/Sydney", + "rrule-dtstart": "2023-01-19", + "rrule-interval": "1", + "rrule-freq": "weekly", + "rrule-end": "forever", + "rrule-until": "2022-01-01", # ignored + "rrule-count": "10", # ignored + "rrule-monthly_same": "on", # ignored + "rrule-yearly_same": "on", # ignored + "schedule-mail_additional_recipients": "boss@example.net, friend@example.com", + "schedule-locale": "en", + "schedule-mail_subject": "Product data, my friend!", + "schedule-mail_template": "Mail body" + }, follow=True) + assert b"Your export schedule has been saved. The next export will start around" in response.content + + s = env[0].organizer.scheduled_exports.get() + assert s.owner == env[1] + assert s.schedule_rrule == "DTSTART:20230119T000000\nRRULE:FREQ=WEEKLY" + assert s.schedule_rrule_time == datetime.time(3, 30, 0) + assert s.schedule_next_run > now() + assert s.export_identifier == "eventdata" + assert s.export_form_data == {"_format": "default", "all_events": True, "events": []} + assert s.locale == "en" + assert s.timezone == "Australia/Sydney" + assert s.mail_additional_recipients == "boss@example.net,friend@example.com" + assert s.mail_subject == "Product data, my friend!" + assert s.mail_template == "Mail body" + + # Schedule is in list + response = client.get("/control/organizer/dummy/export/") + assert b"Product data, my friend!" in response.content + + # Edit schedule + response = client.get(f"/control/organizer/dummy/export/?identifier=eventdata&scheduled={s.pk}") + assert b"Mail body" in response.content + + # Submit edited schedule + response = client.post(f"/control/organizer/dummy/export/?identifier=eventdata&scheduled={s.pk}", { + "schedule": "save", + "exporter": "eventdata", + "eventdata-all_events": "on", + "eventdata-_format": "xlsx", + "schedule-schedule_rrule_time": "03:30", + "schedule-timezone": "Australia/Sydney", + "rrule-dtstart": "2023-01-10", + "rrule-interval": "1", + "rrule-freq": "weekly", + "rrule-end": "until", + "rrule-until": "2022-01-01", # ignored + "rrule-count": "1", + "rrule-monthly_same": "on", # ignored + "rrule-yearly_same": "on", # ignored + "schedule-mail_additional_recipients": "boss@example.net, friend@example.com", + "schedule-locale": "en", + "schedule-mail_subject": "Product data, my friend!", + "schedule-mail_template": "Mail body" + }, follow=True) + print(response.content) + assert b"Your export schedule has been saved, but no next export is planned" in response.content + s.refresh_from_db() + assert s.schedule_next_run is None + assert s.export_form_data == {"_format": "xlsx", "all_events": True, "events": []} + + # Delete schedule + response = client.post(f"/control/organizer/dummy/export/{s.pk}/run") + assert response.status_code == 302 + + # Delete schedule + response = client.get(f"/control/organizer/dummy/export/{s.pk}/delete") + assert b"Product data, my friend!" in response.content + client.post(f"/control/organizer/dummy/export/{s.pk}/delete") + assert env[0].organizer.scheduled_exports.count() == 0 + + +@pytest.mark.django_db +def test_organizer_limited_permission(client, env): + env[2].can_change_organizer_settings = False + env[2].save() + user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy") + + s1 = ScheduledOrganizerExport(organizer=env[0].organizer, owner=env[1]) + s1.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s1.schedule_rrule_time = datetime.time(2, 30, 0) + s1.error_counter = 5 + s1.mail_subject = "RULE1" + s1.save() + s2 = ScheduledOrganizerExport(organizer=env[0].organizer, owner=user2) + s2.schedule_rrule = "DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO" + s2.schedule_rrule_time = datetime.time(2, 30, 0) + s2.error_counter = 5 + s2.mail_subject = "RULE2" + s2.save() + + client.login(email="dummy@dummy.dummy", password="dummy") + response = client.get("/control/organizer/dummy/export/") + assert b"RULE1" in response.content + assert b"RULE2" not in response.content + + response = client.get(f"/control/organizer/dummy/export/?identifier=eventdata&scheduled={s1.pk}") + assert response.status_code == 200 + response = client.get(f"/control/organizer/dummy/export/?identifier=eventdata&scheduled={s2.pk}") + assert response.status_code == 404 + + response = client.get(f"/control/organizer/dummy/export/{s1.pk}/delete") + assert response.status_code == 200 + response = client.post(f"/control/organizer/dummy/export/{s1.pk}/run") + assert response.status_code == 302 + response = client.get(f"/control/organizer/dummy/export/{s2.pk}/delete") + assert response.status_code == 404 + response = client.post(f"/control/organizer/dummy/export/{s2.pk}/run") + assert response.status_code == 404 + + env[2].can_change_organizer_settings = True + env[2].save() + response = client.get("/control/organizer/dummy/export/") + assert b"RULE1" in response.content + assert b"RULE2" in response.content + response = client.get(f"/control/organizer/dummy/export/?identifier=eventdata&scheduled={s2.pk}") + assert response.status_code == 200 + response = client.get(f"/control/organizer/dummy/export/{s2.pk}/delete") + assert response.status_code == 200 + response = client.post(f"/control/organizer/dummy/export/{s2.pk}/run") + assert response.status_code == 302 diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 0a7f108166..2ff80659f7 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -352,6 +352,7 @@ event_permission_urls = [ ("can_change_event_settings", "subevents/add", 200, HTTP_GET), ("can_view_orders", "orders/overview/", 200, HTTP_GET), ("can_view_orders", "orders/export/", 200, HTTP_GET), + ("can_view_orders", "orders/export/do", 302, HTTP_POST), ("can_view_orders", "orders/", 200, HTTP_GET), ("can_view_orders", "orders/FOO/", 200, HTTP_GET), ("can_change_orders", "orders/FOO/extend", 200, HTTP_GET),