forked from CGM_Public/pretix_original
Scheduled exports (#3033)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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 (
|
||||
|
||||
130
src/pretix/base/models/exports.py
Normal file
130
src/pretix/base/models/exports.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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)
|
||||
@@ -19,31 +19,48 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
12
src/pretix/base/templates/pretixbase/email/export_failed.txt
Normal file
12
src/pretix/base/templates/pretixbase/email/export_failed.txt
Normal file
@@ -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 }}
|
||||
@@ -19,6 +19,12 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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 <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
@@ -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.")
|
||||
|
||||
@@ -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."))
|
||||
|
||||
103
src/pretix/control/forms/exports.py
Normal file
103
src/pretix/control/forms/exports.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
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(' ', '')
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
251
src/pretix/control/forms/rrule.py
Normal file
251
src/pretix/control/forms/rrule.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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
|
||||
@@ -19,17 +19,15 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/sb-admin-2.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/rrule.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/subevent.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/variations.js" %}"></script>
|
||||
|
||||
@@ -7,6 +7,86 @@
|
||||
<h1>
|
||||
{% trans "Data export" %}
|
||||
</h1>
|
||||
{% if scheduled %}
|
||||
<h2>{% trans "Scheduled exports" %}</h2>
|
||||
<ul class="list-group">
|
||||
{% for s in scheduled %}
|
||||
<li class="list-group-item logentry">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 col-md-4 col-xs-12">
|
||||
<span class="fa fa-fw fa-folder"></span>
|
||||
{{ s.export_verbose_name }}
|
||||
<br>
|
||||
<span class="text-muted">
|
||||
<span class="fa fa-fw fa-user"></span>
|
||||
{{ s.owner.fullname|default:s.owner.email }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-5 col-md-6 col-xs-12">
|
||||
{% if s.schedule_next_run %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "Next run:" %}
|
||||
{{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% else %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "No next run scheduled" %}
|
||||
{% endif %}
|
||||
{% if s.export_verbose_name == "?" %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Exporter not found" %}
|
||||
</strong>
|
||||
{% elif s.error_counter >= 5 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Disabled due to multiple failures" %}
|
||||
</strong>
|
||||
{% elif s.error_counter > 0 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Failed recently" %}
|
||||
</strong>
|
||||
{% endif %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<span class="fa fa-fw fa-envelope-o"></span>
|
||||
{{ s.mail_subject }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
|
||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ s.export_identifier }}"/>
|
||||
<input type="hidden" name="scheduled" value="{{ s.pk }}"/>
|
||||
{% if s.export_verbose_name != "?" %}
|
||||
<button type="submit" class="btn btn-default" title="{% trans "Run export now" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-download"></span>
|
||||
</button>
|
||||
<button formaction="{% url "control:event.orders.export.scheduled.run" organizer=request.organizer.slug event=request.event.slug pk=s.pk %}"
|
||||
type="submit"
|
||||
title="{% trans "Run export and send via email now. This will not change the next scheduled execution." %}"
|
||||
data-toggle="tooltip" class="btn btn-default" data-no-asynctask>
|
||||
<span class="fa fa-play" aria-hidden="true"></span>
|
||||
</button>
|
||||
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-trash"></span>
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% 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 @@
|
||||
<h4>
|
||||
{{ e.verbose_name }}
|
||||
{% if e.featured %}
|
||||
<span class="fa fa-star text-success" data-toggle="tooltip" title="{% trans "Recommended for new users" %}" aria-hidden="true"></span>
|
||||
<span class="fa fa-star text-success" data-toggle="tooltip"
|
||||
title="{% trans "Recommended for new users" %}" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
{% if e.description %}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Delete scheduled export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Delete scheduled export" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the scheduled export <strong>{{ export }}</strong>?{% endblocktrans %}</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:event.orders.export" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load order_overview %}
|
||||
{% block title %}{% trans "Data export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
@@ -15,16 +14,38 @@
|
||||
{% if exporter.description %}
|
||||
<p class="help-block">{{ exporter.description }}</p>
|
||||
{% endif %}
|
||||
{% if schedule_form %}
|
||||
{% bootstrap_form_errors schedule_form layout='control' %}
|
||||
{% endif %}
|
||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ exporter.identifier }}"/>
|
||||
{% bootstrap_form exporter.form layout='control' %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Start export" %}
|
||||
</button>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>{% trans "Export options" %}</legend>
|
||||
{% bootstrap_form exporter.form layout='control' %}
|
||||
</fieldset>
|
||||
{% if schedule_form %}
|
||||
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
|
||||
<div class="form-group submit-group">
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
|
||||
class="btn btn-primary btn-save" data-no-asynctask>
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
{% trans "Start export" %}
|
||||
</button>
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="start" type="submit"
|
||||
class="btn btn-default btn-alternative" data-no-asynctask>
|
||||
<span class="fa fa-clock-o" aria-hidden="true"></span>
|
||||
{% trans "Schedule export" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load captureas %}
|
||||
|
||||
<fieldset>
|
||||
<legend>{% trans "Schedule" %}</legend>
|
||||
{% 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" %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">{% trans "Repetition schedule" %}</label>
|
||||
<div class="col-md-9">
|
||||
<div class="form-inline rrule-form">
|
||||
{% 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 %}<br>
|
||||
|
||||
<div class="repeat-yearly">
|
||||
<div class="radio">
|
||||
<label>
|
||||
{{ rrule_form.yearly_same.0 }}
|
||||
{% trans "At the same date every year" %}
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.yearly_same.1 }}
|
||||
{% blocktrans trimmed with setpos=ffield_yearly_bysetpos weekday=ffield_yearly_byweekday month=ffield_yearly_bymonth %}
|
||||
On the {{ setpos }} {{ weekday }} of {{ month }}
|
||||
{% endblocktrans %}<br>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repeat-monthly">
|
||||
<div class="radio">
|
||||
<label>
|
||||
{{ rrule_form.monthly_same.0 }}
|
||||
{% trans "At the same date every month" %}
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.monthly_same.1 }}
|
||||
{% blocktrans trimmed with setpos=ffield_monthly_bysetpos weekday=ffield_monthly_byweekday %}
|
||||
On the {{ setpos }} {{ weekday }}
|
||||
{% endblocktrans %}<br>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repeat-weekly">
|
||||
{% bootstrap_field rrule_form.weekly_byweekday layout="inline" %}
|
||||
</div>
|
||||
<div class="repeat-until">
|
||||
<div class="radio">
|
||||
<label>
|
||||
{{ rrule_form.end.0 }}
|
||||
{% blocktrans trimmed with count=ffield_count %}
|
||||
Repeat for {{ count }} times
|
||||
{% endblocktrans %}
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.end.1 }}
|
||||
{% blocktrans trimmed with until=ffield_until %}
|
||||
Repeat until {{ until }}
|
||||
{% endblocktrans %}<br>
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.end.2 }}
|
||||
{% blocktrans trimmed %}
|
||||
Forever
|
||||
{% endblocktrans %}<br>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Email" %}</legend>
|
||||
<div class="alert alert-info">
|
||||
{% trans "Every time your schedule is executed, the report will be sent via email." %}
|
||||
{% trans "Please note the following limitations:" %}
|
||||
<ul>
|
||||
<li>
|
||||
{% 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." %}
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Email is not made for large files. If your export ends up to be larger than 20 megabytes, it will not be sent." %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_schedule-owner">{% trans "Owner" %}</label>
|
||||
<div class="col-md-9">
|
||||
<input type="text" name="schedule-owner" value="{{ schedule_form.instance.owner.email }}" disabled
|
||||
class="form-control" title=""
|
||||
id="id_schedule-owner">
|
||||
<div class="help-block">
|
||||
{% 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." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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' %}
|
||||
</fieldset>
|
||||
@@ -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 @@
|
||||
<h1>
|
||||
{% trans "Data export" %}
|
||||
</h1>
|
||||
{% if scheduled %}
|
||||
<h2>{% trans "Scheduled exports" %}</h2>
|
||||
<ul class="list-group">
|
||||
{% for s in scheduled %}
|
||||
<li class="list-group-item logentry">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 col-md-4 col-xs-12">
|
||||
<span class="fa fa-fw fa-folder"></span>
|
||||
{{ s.export_verbose_name }}
|
||||
<br>
|
||||
<span class="text-muted">
|
||||
<span class="fa fa-fw fa-user"></span>
|
||||
{{ s.owner.fullname|default:s.owner.email }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-5 col-md-6 col-xs-12">
|
||||
{% if s.schedule_next_run %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "Next run:" %}
|
||||
{{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% else %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "No next run scheduled" %}
|
||||
{% endif %}
|
||||
{% if s.export_verbose_name == "?" %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Exporter not found" %}
|
||||
</strong>
|
||||
{% elif s.error_counter >= 5 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Disabled due to multiple failures" %}
|
||||
</strong>
|
||||
{% elif s.error_counter > 0 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Failed recently" %}
|
||||
</strong>
|
||||
{% endif %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<span class="fa fa-fw fa-envelope-o"></span>
|
||||
{{ s.mail_subject }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
|
||||
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ s.export_identifier }}"/>
|
||||
<input type="hidden" name="scheduled" value="{{ s.pk }}"/>
|
||||
{% if s.export_verbose_name != "?" %}
|
||||
<button type="submit" class="btn btn-default" title="{% trans "Run export now and download result" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-download"></span>
|
||||
</button>
|
||||
<button formaction="{% url "control:organizer.export.scheduled.run" organizer=request.organizer.slug pk=s.pk %}"
|
||||
type="submit"
|
||||
title="{% trans "Run export and send via email now. This will not change the next scheduled execution." %}"
|
||||
data-toggle="tooltip" class="btn btn-default" data-no-asynctask>
|
||||
<span class="fa fa-play" aria-hidden="true"></span>
|
||||
</button>
|
||||
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-trash"></span>
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% 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 %}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Delete scheduled export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Delete scheduled export" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the scheduled export <strong>{{ export }}</strong>?{% endblocktrans %}</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:organizer.export" organizer=request.organizer.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
<h1>
|
||||
@@ -15,16 +14,39 @@
|
||||
{% if exporter.description %}
|
||||
<p class="help-block">{{ exporter.description }}</p>
|
||||
{% endif %}
|
||||
{% if schedule_form %}
|
||||
{% bootstrap_form_errors schedule_form layout='control' %}
|
||||
{% endif %}
|
||||
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ exporter.identifier }}"/>
|
||||
{% bootstrap_form exporter.form layout='control' %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Start export" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>{% trans "Export options" %}</legend>
|
||||
{% bootstrap_form exporter.form layout='control' %}
|
||||
</fieldset>
|
||||
{% if schedule_form %}
|
||||
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
|
||||
<div class="form-group submit-group">
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
|
||||
class="btn btn-primary btn-save" data-no-asynctask>
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
{% trans "Start export" %}
|
||||
</button>
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="start" type="submit"
|
||||
class="btn btn-default btn-alternative" data-no-asynctask>
|
||||
<span class="fa fa-clock-o" aria-hidden="true"></span>
|
||||
{% trans "Schedule export" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -207,6 +207,10 @@ urlpatterns = [
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/logs', organizer.LogView.as_view(), name='organizer.log'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/$', organizer.ExportView.as_view(), name='organizer.export'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/do$', organizer.ExportDoView.as_view(), name='organizer.export.do'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/run$', organizer.RunScheduledExportView.as_view(),
|
||||
name='organizer.export.scheduled.run'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/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<file>[^/]+)/$', 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<pk>[^/]+)/run$', orders.RunScheduledExportView.as_view(), name='event.orders.export.scheduled.run'),
|
||||
re_path(r'^orders/export/(?P<pk>[^/]+)/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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
21
src/pretix/static/pretixcontrol/js/ui/rrule.js
Normal file
21
src/pretix/static/pretixcontrol/js/ui/rrule.js
Normal file
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user