From 3b64e6046c44e5dbf8ad031fa95379b2c879fd02 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 27 Oct 2023 17:15:53 +0200 Subject: [PATCH] API: Add endpoints for scheduled exports (#3659) * API: Add endpoints for scheduled exports * ADd note to docs --- doc/api/resources/exporters.rst | 2 + doc/api/resources/index.rst | 1 + doc/api/resources/questions.rst | 8 +- doc/api/resources/scheduled_exports.rst | 556 ++++++++++++++++++++++++ src/pretix/api/serializers/exporters.py | 92 ++++ src/pretix/api/urls.py | 2 + src/pretix/api/views/exporters.py | 159 ++++++- src/pretix/base/models/exports.py | 2 +- src/pretix/base/validators.py | 47 +- src/tests/api/test_exporters.py | 534 ++++++++++++++++++++++- 10 files changed, 1389 insertions(+), 14 deletions(-) create mode 100644 doc/api/resources/scheduled_exports.rst diff --git a/doc/api/resources/exporters.rst b/doc/api/resources/exporters.rst index ae1025691..e15d7e41c 100644 --- a/doc/api/resources/exporters.rst +++ b/doc/api/resources/exporters.rst @@ -1,5 +1,7 @@ .. spelling:word-list:: checkin +.. _rest-exporters: + Data exporters ============== diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index de5f876aa..c3a6449b8 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -40,6 +40,7 @@ at :ref:`plugin-docs`. webhooks seatingplans exporters + scheduled_exports shredders sendmail_rules billing_invoices diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst index a08d70428..d6d7eccee 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -348,7 +348,7 @@ Endpoints .. sourcecode:: http - PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/ HTTP/1.1 + PATCH /api/v1/organizers/bigevents/events/sampleconf/questions/1/ HTTP/1.1 Host: pretix.eu Accept: application/json, text/javascript Content-Type: application/json @@ -415,7 +415,7 @@ Endpoints :param event: The ``slug`` field of the event to modify :param id: The ``id`` field of the question to modify :statuscode 200: no error - :statuscode 400: The item could not be modified due to invalid submitted data + :statuscode 400: The question could not be modified due to invalid submitted data :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. @@ -427,7 +427,7 @@ Endpoints .. sourcecode:: http - DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/ HTTP/1.1 + DELETE /api/v1/organizers/bigevents/events/sampleconf/questions/1/ HTTP/1.1 Host: pretix.eu Accept: application/json, text/javascript @@ -440,7 +440,7 @@ Endpoints :param organizer: The ``slug`` field of the organizer to modify :param event: The ``slug`` field of the event to modify - :param id: The ``id`` field of the item to delete + :param id: The ``id`` field of the question to delete :statuscode 204: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. diff --git a/doc/api/resources/scheduled_exports.rst b/doc/api/resources/scheduled_exports.rst new file mode 100644 index 000000000..aaeae7dde --- /dev/null +++ b/doc/api/resources/scheduled_exports.rst @@ -0,0 +1,556 @@ +.. spelling:word-list:: checkin + +Scheduled data exports +====================== + +pretix and it's plugins include a number of data exporters that allow you to bulk download various data from pretix in +different formats. You should read :ref:`rest-exporters` first to get an understanding of the basic mechanism. + +Exports can be scheduled to be sent at specific times automatically, both on organizer level and event level. + +Scheduled export resource +------------------------- + +The scheduled export contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the schedule +owner string Email address of the user who created this schedule (read-only). + This address will always receive the export and the export + will only contain data that this user has permission + to access at the time of the export. **We consider this + field experimental, it's behaviour might change in the future. + Note that the email address of a user can change at any time.** +export_identifier string Identifier of the export to run, see :ref:`rest-exporters` +export_form_data object Input data for the export, format depends on the export, + see :ref:`rest-exporters` for more details. +locale string Language to run the export in +mail_additional_recipients string Email addresses to receive the export, comma-separated (or empty string) +mail_additional_recipients_cc string Email addresses to receive the export in copy, comma-separated (or empty string) +mail_additional_recipients_bcc string Email addresses to receive the exportin blind copy, comma-separated (or empty string) +mail_subject string Subject to use for the email (currently no variables supported) +mail_template string Text to use for the email (currently no variables supported) +schedule_rrule string Recurrence specification to determine the **days** this + schedule runs on in ``RRULE`` syntax following `RFC 5545`_ + with some restrictions. Only one rule is allowed, only + one occurrence per day is allowed, and some features + are not supported (``BYMONTHDAY``, ``BYYEARDAY``, + ``BYEASTER``, ``BYWEEKNO``). +schedule_rrule_time time Time of day to run this on on the specified days. + Will be interpreted as local time of the event for event-level + exports. For organizer-level exports, the timezone is given + in the field ``timezone``. The export will never run **before** + this time but it **may** run **later**. +timezone string Time zone to interpret the schedule in (only for organizer-level exports) +schedule_next_run datetime Next planned execution (read-only, computed by server) +error_counter integer Number of consecutive times this export failed (read-only). + After a number of failures (currently 5), the schedule no + longer is executed. Changing parameters resets the value. +===================================== ========================== ======================================================= + +Special notes on permissions +---------------------------- + +Permission handling for scheduled exports is more complex than for most other objects. The reason for this is that +there are two levels of access control involved here: First, you need permission to access or change the configuration +of the scheduled exports in the moment you are doing it. Second, you **continuously** need permission to access the +**data** that is exported as part of the schedule. For this reason, scheduled exports always need one user account +to be their **owner**. + +Therefore, scheduled exports **must** be created by an API client using :ref:`OAuth authentication `. +It is impossible to create a scheduled export using token authentication. After the export is created, it can also be +modified using token authentication. + +A user or token with the "can change settings" permission for a given organizer or event can see and change +**all** scheduled exports created for the respective organizer or event, regardless of who created them. +A user without this permission can only see **their own** scheduled exports. +A token without this permission can not see scheduled exports as all. + + + +Endpoints for event exports +--------------------------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/scheduled_exports/ + + Returns a list of all scheduled exports the client has access to. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/scheduled_exports/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "owner": "john@example.com", + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "week_previous"}, + "locale": "en", + "mail_additional_recipients": "mary@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Order list", + "mail_template": "Here is last week's order list\n\nCheers\nJohn", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "schedule_next_run": "2023-10-26T02:00:00Z", + "error_counter": 0 + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``export_identifier``, and ``schedule_next_run``. + Default: ``id`` + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/scheduled_exports/(id)/ + + Returns information on one scheduled export, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/scheduled_exports/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "owner": "john@example.com", + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "week_previous"}, + "locale": "en", + "mail_additional_recipients": "mary@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Order list", + "mail_template": "Here is last week's order list\n\nCheers\nJohn", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "schedule_next_run": "2023-10-26T02:00:00Z", + "error_counter": 0 + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the scheduled export to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/scheduled_exports/ + + Schedule a new export. + + .. note:: See above for special notes on permissions. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/scheduled_exports/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "week_previous"}, + "locale": "en", + "mail_additional_recipients": "mary@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Order list", + "mail_template": "Here is last week's order list\n\nCheers\nJohn", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + + { + "id": 1, + "owner": "john@example.com", + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "week_previous"}, + "locale": "en", + "mail_additional_recipients": "mary@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Order list", + "mail_template": "Here is last week's order list\n\nCheers\nJohn", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "schedule_next_run": "2023-10-26T02:00:00Z", + "error_counter": 0 + } + + :param organizer: The ``slug`` field of the organizer of the event to create an item for + :param event: The ``slug`` field of the event to create an item for + :statuscode 201: no error + :statuscode 400: The item could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/scheduled_exports/(id)/ + + Update a scheduled export. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/scheduled_exports/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "export_form_data": {"_format": "xlsx", "date_range": "week_this"}, + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "owner": "john@example.com", + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "week_this"}, + "locale": "en", + "mail_additional_recipients": "mary@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Order list", + "mail_template": "Here is last week's order list\n\nCheers\nJohn", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "schedule_next_run": "2023-10-26T02:00:00Z", + "error_counter": 0 + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the export to modify + :statuscode 200: no error + :statuscode 400: The export could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. + +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/scheduled_exports/(id)/ + + Delete a scheduled export. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/scheduled_exports/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the export to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. + +Endpoints for organizer exports +--------------------------- + +.. http:get:: /api/v1/organizers/(organizer)/scheduled_exports/ + + Returns a list of all scheduled exports the client has access to. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/scheduled_exports/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "owner": "john@example.com", + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "week_previous"}, + "locale": "en", + "mail_additional_recipients": "mary@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Order list", + "mail_template": "Here is last week's order list\n\nCheers\nJohn", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "schedule_next_run": "2023-10-26T02:00:00Z", + "timezone": "Europe/Berlin", + "error_counter": 0 + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``export_identifier``, and ``schedule_next_run``. + Default: ``id`` + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/scheduled_exports/(id)/ + + Returns information on one scheduled export, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/scheduled_exports/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "owner": "john@example.com", + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "week_previous"}, + "locale": "en", + "mail_additional_recipients": "mary@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Order list", + "mail_template": "Here is last week's order list\n\nCheers\nJohn", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "schedule_next_run": "2023-10-26T02:00:00Z", + "timezone": "Europe/Berlin", + "error_counter": 0 + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param id: The ``id`` field of the scheduled export to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/scheduled_exports/ + + Schedule a new export. + + .. note:: See above for special notes on permissions. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/scheduled_exports/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "week_previous"}, + "locale": "en", + "mail_additional_recipients": "mary@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Order list", + "mail_template": "Here is last week's order list\n\nCheers\nJohn", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "timezone": "Europe/Berlin" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + + { + "id": 1, + "owner": "john@example.com", + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "week_previous"}, + "locale": "en", + "mail_additional_recipients": "mary@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Order list", + "mail_template": "Here is last week's order list\n\nCheers\nJohn", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "schedule_next_run": "2023-10-26T02:00:00Z", + "timezone": "Europe/Berlin", + "error_counter": 0 + } + + :param organizer: The ``slug`` field of the organizer of the event to create an item for + :statuscode 201: no error + :statuscode 400: The item could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/scheduled_exports/(id)/ + + Update a scheduled export. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/scheduled_exports/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "export_form_data": {"_format": "xlsx", "date_range": "week_this"}, + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "owner": "john@example.com", + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "week_this"}, + "locale": "en", + "mail_additional_recipients": "mary@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Order list", + "mail_template": "Here is last week's order list\n\nCheers\nJohn", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "schedule_next_run": "2023-10-26T02:00:00Z", + "timezone": "Europe/Berlin", + "error_counter": 0 + } + + :param organizer: The ``slug`` field of the organizer to modify + :param id: The ``id`` field of the export to modify + :statuscode 200: no error + :statuscode 400: The export could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource. + +.. http:delete:: /api/v1/organizers/(organizer)/scheduled_exports/(id)/ + + Delete a scheduled export. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/scheduled_exports/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to modify + :param id: The ``id`` field of the export to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource. + + +.. _RFC 5545: https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5.3 \ No newline at end of file diff --git a/src/pretix/api/serializers/exporters.py b/src/pretix/api/serializers/exporters.py index 3e2fd835a..52827b99a 100644 --- a/src/pretix/api/serializers/exporters.py +++ b/src/pretix/api/serializers/exporters.py @@ -20,11 +20,14 @@ # . # from django import forms +from django.conf import settings from django.http import QueryDict +from pytz import common_timezones from rest_framework import serializers from rest_framework.exceptions import ValidationError from pretix.base.exporter import OrganizerLevelExportMixin +from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport from pretix.base.timeframes import DateFrameField, SerializerDateFrameField @@ -197,3 +200,92 @@ class JobRunSerializer(serializers.Serializer): raise ValidationError(self.errors) return not bool(self._errors) + + +class ScheduledExportSerializer(serializers.ModelSerializer): + schedule_next_run = serializers.DateTimeField(read_only=True) + export_identifier = serializers.ChoiceField(choices=[]) + locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en') + owner = serializers.SlugRelatedField(slug_field='email', read_only=True) + error_counter = serializers.IntegerField(read_only=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['export_identifier'].choices = [(e, e) for e in self.context['exporters']] + + def validate(self, attrs): + if attrs.get("export_form_data"): + identifier = attrs.get('export_identifier', self.instance.export_identifier if self.instance else None) + exporter = self.context['exporters'].get(identifier) + if exporter: + try: + JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"]) + except ValidationError as e: + raise ValidationError({"export_form_data": e.detail}) + else: + raise ValidationError({"export_identifier": ["Unknown exporter."]}) + return attrs + + def validate_mail_additional_recipients(self, value): + d = value.replace(' ', '') + if len(d.split(',')) > 25: + raise ValidationError('Please enter less than 25 recipients.') + return d + + def validate_mail_additional_recipients_cc(self, value): + d = value.replace(' ', '') + if len(d.split(',')) > 25: + raise ValidationError('Please enter less than 25 recipients.') + return d + + def validate_mail_additional_recipients_bcc(self, value): + d = value.replace(' ', '') + if len(d.split(',')) > 25: + raise ValidationError('Please enter less than 25 recipients.') + return d + + +class ScheduledEventExportSerializer(ScheduledExportSerializer): + + class Meta: + model = ScheduledEventExport + fields = [ + 'id', + 'owner', + 'export_identifier', + 'export_form_data', + 'locale', + 'mail_additional_recipients', + 'mail_additional_recipients_cc', + 'mail_additional_recipients_bcc', + 'mail_subject', + 'mail_template', + 'schedule_rrule', + 'schedule_rrule_time', + 'schedule_next_run', + 'error_counter', + ] + + +class ScheduledOrganizerExportSerializer(ScheduledExportSerializer): + timezone = serializers.ChoiceField(default=settings.TIME_ZONE, choices=[(a, a) for a in common_timezones]) + + class Meta: + model = ScheduledOrganizerExport + fields = [ + 'id', + 'owner', + 'export_identifier', + 'export_form_data', + 'locale', + 'mail_additional_recipients', + 'mail_additional_recipients_cc', + 'mail_additional_recipients_bcc', + 'mail_subject', + 'mail_template', + 'schedule_rrule', + 'schedule_rrule_time', + 'schedule_next_run', + 'timezone', + 'error_counter', + ] diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 2434dd531..c8ba633a6 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -63,6 +63,7 @@ orga_router.register(r'teams', organizer.TeamViewSet) orga_router.register(r'devices', organizer.DeviceViewSet) orga_router.register(r'orders', order.OrganizerOrderViewSet) orga_router.register(r'invoices', order.InvoiceViewSet) +orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet) orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') team_router = routers.DefaultRouter() @@ -88,6 +89,7 @@ event_router.register(r'taxrules', event.TaxRuleViewSet) event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet) event_router.register(r'checkinlists', checkin.CheckinListViewSet) event_router.register(r'cartpositions', cart.CartPositionViewSet) +event_router.register(r'scheduled_exports', exporters.ScheduledEventExportViewSet) event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters') event_router.register(r'shredders', shredders.EventShreddersViewSet, basename='shredders') event_router.register(r'item_meta_properties', event.ItemMetaPropertiesViewSet) diff --git a/src/pretix/api/views/exporters.py b/src/pretix/api/views/exporters.py index e641e7d89..af21077c2 100644 --- a/src/pretix/api/views/exporters.py +++ b/src/pretix/api/views/exporters.py @@ -29,14 +29,20 @@ from django.utils.functional import cached_property from django.utils.timezone import now from rest_framework import status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.reverse import reverse +from pretix.api.pagination import TotalOrderingFilter from pretix.api.serializers.exporters import ( - ExporterSerializer, JobRunSerializer, + ExporterSerializer, JobRunSerializer, ScheduledEventExportSerializer, + ScheduledOrganizerExportSerializer, ) from pretix.base.exporter import OrganizerLevelExportMixin -from pretix.base.models import CachedFile, Device, Event, TeamAPIToken +from pretix.base.models import ( + CachedFile, Device, Event, ScheduledEventExport, ScheduledOrganizerExport, + TeamAPIToken, +) from pretix.base.services.export import export, multiexport from pretix.base.signals import ( register_data_exporters, register_multievent_data_exporters, @@ -199,3 +205,152 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet): 'provider': instance.identifier, 'form_data': data }) + + +class ScheduledExportersViewSet(viewsets.ModelViewSet): + filter_backends = (TotalOrderingFilter,) + ordering = ('id',) + ordering_fields = ('id', 'export_identifier', 'schedule_next_run') + + +class ScheduledEventExportViewSet(ScheduledExportersViewSet): + serializer_class = ScheduledEventExportSerializer + queryset = ScheduledEventExport.objects.none() + permission = 'can_view_orders' + + def get_queryset(self): + perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user + if not perm_holder.has_event_permission(self.request.organizer, self.request.event, 'can_change_event_settings', + request=self.request): + if self.request.user.is_authenticated: + qs = self.request.event.scheduled_exports.filter(owner=self.request.user) + else: + raise PermissionDenied('Scheduled exports require either permission to change event settings or ' + 'user-specific API access.') + else: + qs = self.request.event.scheduled_exports + return qs.select_related("owner") + + def perform_create(self, serializer): + if not self.request.user.is_authenticated: + raise PermissionDenied('Creation of exports requires user-specific API access.') + serializer.save(event=self.request.event, owner=self.request.user) + serializer.instance.compute_next_run() + serializer.instance.save(update_fields=["schedule_next_run"]) + self.request.event.log_action( + 'pretix.event.export.schedule.added', + user=self.request.user, + auth=self.request.auth, + data=self.request.data + ) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + ctx['exporters'] = self.exporters + return ctx + + @cached_property + def exporters(self): + responses = register_data_exporters.send(self.request.event) + exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response] + return {e.identifier: e for e in exporters} + + def perform_update(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.compute_next_run() + serializer.instance.error_counter = 0 + serializer.instance.error_last_message = None + serializer.instance.save(update_fields=["schedule_next_run", "error_counter", "error_last_message"]) + self.request.event.log_action( + 'pretix.event.export.schedule.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data + ) + + def perform_destroy(self, instance): + self.request.event.log_action( + 'pretix.event.export.schedule.deleted', + user=self.request.user, + auth=self.request.auth, + ) + super().perform_destroy(instance) + + +class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet): + serializer_class = ScheduledOrganizerExportSerializer + queryset = ScheduledOrganizerExport.objects.none() + permission = None + + def get_queryset(self): + perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user + if not perm_holder.has_organizer_permission(self.request.organizer, 'can_change_organizer_settings', + request=self.request): + if self.request.user.is_authenticated: + qs = self.request.organizer.scheduled_exports.filter(owner=self.request.user) + else: + raise PermissionDenied('Scheduled exports require either permission to change organizer settings or ' + 'user-specific API access.') + else: + qs = self.request.organizer.scheduled_exports + return qs.select_related("owner") + + def perform_create(self, serializer): + if not self.request.user.is_authenticated: + raise PermissionDenied('Creation of exports requires user-specific API access.') + serializer.save(organizer=self.request.organizer, owner=self.request.user) + serializer.instance.compute_next_run() + serializer.instance.save(update_fields=["schedule_next_run"]) + self.request.organizer.log_action( + 'pretix.organizer.export.schedule.added', + user=self.request.user, + auth=self.request.auth, + data=self.request.data + ) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + ctx['exporters'] = self.exporters + return ctx + + @cached_property + def events(self): + if isinstance(self.request.auth, (TeamAPIToken, Device)): + return self.request.auth.get_events_with_permission('can_view_orders') + elif self.request.user.is_authenticated: + return self.request.user.get_events_with_permission('can_view_orders', self.request).filter( + organizer=self.request.organizer + ) + + @cached_property + def exporters(self): + responses = register_multievent_data_exporters.send(self.request.organizer) + exporters = [ + response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events, + self.request.organizer) + for r, response in responses if response + ] + return {e.identifier: e for e in exporters} + + def perform_update(self, serializer): + serializer.save(organizer=self.request.organizer) + serializer.instance.compute_next_run() + serializer.instance.error_counter = 0 + serializer.instance.error_last_message = None + serializer.instance.save(update_fields=["schedule_next_run", "error_counter", "error_last_message"]) + self.request.organizer.log_action( + 'pretix.organizer.export.schedule.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data + ) + + def perform_destroy(self, instance): + self.request.organizer.log_action( + 'pretix.organizer.export.schedule.deleted', + user=self.request.user, + auth=self.request.auth, + ) + super().perform_destroy(instance) diff --git a/src/pretix/base/models/exports.py b/src/pretix/base/models/exports.py index b01f26825..6104ff2b1 100644 --- a/src/pretix/base/models/exports.py +++ b/src/pretix/base/models/exports.py @@ -79,7 +79,7 @@ class AbstractScheduledExport(LoggedModel): ) schedule_rrule = models.TextField( - null=True, blank=True, validators=[RRuleValidator()] + null=True, blank=True, validators=[RRuleValidator(enforce_simple=True)] ) schedule_rrule_time = models.TimeField( verbose_name=_("Requested start time"), diff --git a/src/pretix/base/validators.py b/src/pretix/base/validators.py index 35671a1cd..80cf9ab53 100644 --- a/src/pretix/base/validators.py +++ b/src/pretix/base/validators.py @@ -19,7 +19,9 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from dateutil.rrule import rrulestr +import calendar + +from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import validate_email @@ -40,7 +42,6 @@ from django.utils.translation import gettext_lazy as _ class BanlistValidator: - banlist = [] def __call__(self, value): @@ -55,7 +56,6 @@ class BanlistValidator: @deconstructible class EventSlugBanlistValidator(BanlistValidator): - banlist = [ 'download', 'healthcheck', @@ -77,7 +77,6 @@ class EventSlugBanlistValidator(BanlistValidator): @deconstructible class OrganizerSlugBanlistValidator(BanlistValidator): - banlist = [ 'download', 'healthcheck', @@ -98,7 +97,6 @@ class OrganizerSlugBanlistValidator(BanlistValidator): @deconstructible class EmailBanlistValidator(BanlistValidator): - banlist = [ settings.PRETIX_EMAIL_NONE_VALUE, ] @@ -112,8 +110,45 @@ def multimail_validate(val): class RRuleValidator: + def __init__(self, enforce_simple=False): + self.enforce_simple = enforce_simple + def __call__(self, value): try: - rrulestr(value) + parsed = rrulestr(value) except Exception: raise ValidationError("Not a valid rrule.") + + if self.enforce_simple: + # Validate that only things are used that we can represent in our UI for later editing + + if not isinstance(parsed, rrule): + raise ValidationError("Only a single RRULE is allowed, no combination of rules.") + + if parsed._freq not in (YEARLY, MONTHLY, WEEKLY, DAILY): + raise ValidationError("Unsupported FREQ value") + if parsed._wkst != calendar.firstweekday(): + raise ValidationError("Unsupported WKST value") + if parsed._bysetpos: + if len(parsed._bysetpos) > 1: + raise ValidationError("Only one BYSETPOS value allowed") + if parsed._freq == YEARLY and parsed._bysetpos not in (1, 2, 3, -1): + raise ValidationError("BYSETPOS value not allowed, should be 1, 2, 3 or -1") + elif parsed._freq == MONTHLY and parsed._bysetpos not in (1, 2, 3, -1): + raise ValidationError("BYSETPOS value not allowed, should be 1, 2, 3 or -1") + elif parsed._freq not in (YEARLY, MONTHLY): + raise ValidationError("BYSETPOS not allowed for this FREQ") + if parsed._bymonthday: + raise ValidationError("BYMONTHDAY not supported") + if parsed._byyearday: + raise ValidationError("BYYEARDAY not supported") + if parsed._byeaster: + raise ValidationError("BYEASTER not supported") + if parsed._byweekno: + raise ValidationError("BYWEEKNO not supported") + if len(parsed._byhour) > 1 or set(parsed._byhour) != {parsed._dtstart.hour}: + raise ValidationError("BYHOUR not supported") + if len(parsed._byminute) > 1 or set(parsed._byminute) != {parsed._dtstart.minute}: + raise ValidationError("BYMINUTE not supported") + if len(parsed._bysecond) > 1 or set(parsed._bysecond) != {parsed._dtstart.second}: + raise ValidationError("BYSECOND not supported") diff --git a/src/tests/api/test_exporters.py b/src/tests/api/test_exporters.py index c2606fe36..822fcad84 100644 --- a/src/tests/api/test_exporters.py +++ b/src/tests/api/test_exporters.py @@ -34,10 +34,13 @@ import copy import uuid +import zoneinfo +from datetime import time import pytest +from django.utils.timezone import now -from pretix.base.models import CachedFile +from pretix.base.models import CachedFile, User SAMPLE_EXPORTER_CONFIG = { "identifier": "orderlist", @@ -277,3 +280,532 @@ def test_org_level_export(token_client, organizer, team, event): '_format': 'xlsx', }, format='json') assert resp.status_code == 404 + + +@pytest.fixture +def event_scheduled_export(event, user): + e = event.scheduled_exports.create( + owner=user, + export_identifier="orderlist", + export_form_data={ + "_format": "xlsx", + "date_range": "year_this" + }, + locale="en", + mail_additional_recipients="foo@example.org", + mail_subject="Current order list", + mail_template="Here is the current order list", + schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + schedule_rrule_time=time(4, 0, 0), + ) + e.compute_next_run() + e.save() + return e + + +TEST_SCHEDULED_EXPORT_RES = { + "owner": "dummy@dummy.dummy", + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "foo@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "error_counter": 0, +} + + +@pytest.mark.django_db +def test_event_scheduled_export_list_token(token_client, organizer, event, user, team, event_scheduled_export): + res = dict(TEST_SCHEDULED_EXPORT_RES) + res["id"] = event_scheduled_export.pk + res["schedule_next_run"] = event_scheduled_export.schedule_next_run.astimezone(zoneinfo.ZoneInfo("UTC")). \ + isoformat().replace("+00:00", "Z") + + # Token can see it because it has change permission + resp = token_client.get('/api/v1/organizers/{}/events/{}/scheduled_exports/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + team.can_change_event_settings = False + team.save() + + # Token can no longer sees it an gets error message + resp = token_client.get('/api/v1/organizers/{}/events/{}/scheduled_exports/'.format(organizer.slug, event.slug)) + assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_event_scheduled_export_list_user(user_client, organizer, event, user, team, event_scheduled_export): + user2 = User.objects.create_user('dummy2@dummy.dummy', 'dummy') + team.members.add(user2) + + res = dict(TEST_SCHEDULED_EXPORT_RES) + res["id"] = event_scheduled_export.pk + res["schedule_next_run"] = event_scheduled_export.schedule_next_run.astimezone(zoneinfo.ZoneInfo("UTC")).\ + isoformat().replace("+00:00", "Z") + + # User can see it because its their own + resp = user_client.get('/api/v1/organizers/{}/events/{}/scheduled_exports/'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + + team.can_change_event_settings = False + team.save() + + # Owner still can + resp = user_client.get('/api/v1/organizers/{}/events/{}/scheduled_exports/'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + + # Other user can't see it and gets empty list + user_client.force_authenticate(user=user2) + resp = user_client.get('/api/v1/organizers/{}/events/{}/scheduled_exports/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + +@pytest.mark.django_db +def test_event_scheduled_export_detail(token_client, organizer, event, user, event_scheduled_export): + res = dict(TEST_SCHEDULED_EXPORT_RES) + res["id"] = event_scheduled_export.pk + res["schedule_next_run"] = event_scheduled_export.schedule_next_run.astimezone(zoneinfo.ZoneInfo("UTC")).\ + isoformat().replace("+00:00", "Z") + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/scheduled_exports/{}/'.format( + organizer.slug, event.slug, event_scheduled_export.pk + ) + ) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_event_scheduled_export_create(user_client, organizer, event, user): + resp = user_client.post( + '/api/v1/organizers/{}/events/{}/scheduled_exports/'.format(organizer.slug, event.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "foo@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 201 + created = event.scheduled_exports.get(id=resp.data["id"]) + assert created.export_form_data == {"_format": "xlsx", "date_range": "year_this"} + assert created.owner == user + assert created.schedule_next_run > now() + + +@pytest.mark.django_db +def test_event_scheduled_export_create_requires_user(token_client, organizer, event, user): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/scheduled_exports/'.format(organizer.slug, event.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "foo@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_event_scheduled_export_delete_token(token_client, organizer, event, user, event_scheduled_export): + resp = token_client.delete( + '/api/v1/organizers/{}/events/{}/scheduled_exports/{}/'.format( + organizer.slug, event.slug, event_scheduled_export.pk, + ), + ) + assert resp.status_code == 204 + assert not event.scheduled_exports.exists() + + +@pytest.mark.django_db +def test_event_scheduled_export_update_token(token_client, organizer, event, user, event_scheduled_export): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/scheduled_exports/{}/'.format( + organizer.slug, event.slug, event_scheduled_export.pk, + ), + data={ + "export_form_data": {"_format": "xlsx", "date_range": "month_this"}, + }, + format='json' + ) + assert resp.status_code == 200 + created = event.scheduled_exports.get(id=resp.data["id"]) + assert created.export_form_data == {"_format": "xlsx", "date_range": "month_this"} + + +@pytest.fixture +def org_scheduled_export(organizer, user): + e = organizer.scheduled_exports.create( + owner=user, + export_identifier="orderlist", + export_form_data={ + "_format": "xlsx", + "date_range": "year_this" + }, + locale="en", + mail_additional_recipients="foo@example.org", + mail_subject="Current order list", + mail_template="Here is the current order list", + schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + schedule_rrule_time=time(4, 0, 0), + ) + e.compute_next_run() + e.save() + return e + + +@pytest.mark.django_db +def test_org_scheduled_export_list_token(token_client, organizer, user, team, org_scheduled_export): + res = dict(TEST_SCHEDULED_EXPORT_RES) + res["id"] = org_scheduled_export.pk + res["schedule_next_run"] = org_scheduled_export.schedule_next_run.astimezone(zoneinfo.ZoneInfo("UTC")). \ + isoformat().replace("+00:00", "Z") + res["timezone"] = "UTC" + + # Token can see it because it has change permission + resp = token_client.get('/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + team.can_change_organizer_settings = False + team.save() + + # Token can no longer sees it an gets error message + resp = token_client.get('/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug)) + assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_org_scheduled_export_list_user(user_client, organizer, user, team, org_scheduled_export): + user2 = User.objects.create_user('dummy2@dummy.dummy', 'dummy') + team.members.add(user2) + + res = dict(TEST_SCHEDULED_EXPORT_RES) + res["id"] = org_scheduled_export.pk + res["schedule_next_run"] = org_scheduled_export.schedule_next_run.astimezone(zoneinfo.ZoneInfo("UTC")). \ + isoformat().replace("+00:00", "Z") + res["timezone"] = "UTC" + + # User can see it because its their own + resp = user_client.get('/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug)) + assert [res] == resp.data['results'] + + team.can_change_organizer_settings = False + team.save() + + # Owner still can + resp = user_client.get('/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug)) + assert [res] == resp.data['results'] + + # Other user can't see it and gets empty list + user_client.force_authenticate(user=user2) + resp = user_client.get('/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + +@pytest.mark.django_db +def test_org_scheduled_export_detail(token_client, organizer, user, org_scheduled_export): + res = dict(TEST_SCHEDULED_EXPORT_RES) + res["id"] = org_scheduled_export.pk + res["schedule_next_run"] = org_scheduled_export.schedule_next_run.astimezone(zoneinfo.ZoneInfo("UTC")). \ + isoformat().replace("+00:00", "Z") + res["timezone"] = "UTC" + + resp = token_client.get( + '/api/v1/organizers/{}/scheduled_exports/{}/'.format( + organizer.slug, org_scheduled_export.pk + ) + ) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_org_scheduled_export_create(user_client, organizer, user): + resp = user_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "foo@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 201 + created = organizer.scheduled_exports.get(id=resp.data["id"]) + assert created.export_form_data == {"_format": "xlsx", "date_range": "year_this", "event_date_range": "/"} + assert created.owner == user + assert created.schedule_next_run > now() + + +@pytest.mark.django_db +def test_org_scheduled_export_create_requires_user(token_client, organizer, user): + resp = token_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "foo@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_org_scheduled_export_delete_token(token_client, organizer, user, org_scheduled_export): + resp = token_client.delete( + '/api/v1/organizers/{}/scheduled_exports/{}/'.format( + organizer.slug, org_scheduled_export.pk, + ), + ) + assert resp.status_code == 204 + assert not organizer.scheduled_exports.exists() + + +@pytest.mark.django_db +def test_org_scheduled_export_update_token(token_client, organizer, user, org_scheduled_export): + resp = token_client.patch( + '/api/v1/organizers/{}/scheduled_exports/{}/'.format( + organizer.slug, org_scheduled_export.pk, + ), + data={ + "export_form_data": {"_format": "xlsx", "date_range": "month_this"}, + "timezone": "America/New_York" + }, + format='json' + ) + assert resp.status_code == 200 + created = organizer.scheduled_exports.get(id=resp.data["id"]) + assert created.export_form_data == {"_format": "xlsx", "date_range": "month_this", "event_date_range": "/"} + assert created.timezone == "America/New_York" + + +@pytest.mark.django_db +def test_org_scheduled_export_validate_identifier(user_client, organizer, user): + resp = user_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "unknownorg", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "foo@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"export_identifier": ["\"unknownorg\" is not a valid choice."]} + + +@pytest.mark.django_db +def test_org_scheduled_export_validate_form_data(user_client, organizer, user): + resp = user_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "UNKNOWN"}, + "locale": "en", + "mail_additional_recipients": "foo@example.org", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"export_form_data": {"date_range": ["Invalid date frame"]}} + + +@pytest.mark.django_db +def test_org_scheduled_export_validate_locale(user_client, organizer, user): + resp = user_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "BLÖDSINN", + "mail_additional_recipients": "", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"locale": ["\"BLÖDSINN\" is not a valid choice."]} + + +@pytest.mark.django_db +def test_org_scheduled_export_validate_timezone(user_client, organizer, user): + resp = user_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "de", + "mail_additional_recipients": "", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + "timezone": "Invalid" + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"timezone": ["\"Invalid\" is not a valid choice."]} + + +@pytest.mark.django_db +def test_org_scheduled_export_validate_additional_recipients(user_client, organizer, user): + resp = user_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "aaaaaa", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"mail_additional_recipients": ["Enter a valid email address."]} + + resp = user_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com," + "a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com," + "a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com,a@b.com", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"mail_additional_recipients": ["Please enter less than 25 recipients."]} + + +@pytest.mark.django_db +def test_org_scheduled_export_validate_rrule(user_client, organizer, user): + resp = user_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "invalid content", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"schedule_rrule": ["Not a valid rrule."]} + + resp = user_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH\nEXRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=TU,TH", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"schedule_rrule": ["Only a single RRULE is allowed, no combination of rules."]} + + resp = user_client.post( + '/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug), + data={ + "export_identifier": "orderlist", + "export_form_data": {"_format": "xlsx", "date_range": "year_this"}, + "locale": "en", + "mail_additional_recipients": "", + "mail_additional_recipients_cc": "", + "mail_additional_recipients_bcc": "", + "mail_subject": "Current order list", + "mail_template": "Here is the current order list", + "schedule_rrule": "DTSTART:20230118T000000\nRRULE:FREQ=YEARLY;BYEASTER=0", + "schedule_rrule_time": "04:00:00", + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"schedule_rrule": ["BYEASTER not supported"]}