This is an automated email from the ASF dual-hosted git repository.

elizabeth pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 1bf04015c6 feat(reports): Set a minimum interval for each report's 
execution (#28176)
1bf04015c6 is described below

commit 1bf04015c6ba120e9bb7b07380634018ab09f631
Author: Vitor Avila <[email protected]>
AuthorDate: Tue May 7 22:28:12 2024 -0300

    feat(reports): Set a minimum interval for each report's execution (#28176)
---
 docs/docs/configuration/alerts-reports.mdx    |   9 +
 superset/commands/report/base.py              |  47 ++++-
 superset/commands/report/create.py            |  54 ++++--
 superset/commands/report/exceptions.py        |  27 +++
 superset/commands/report/update.py            |  69 ++++---
 superset/config.py                            |   4 +
 tests/integration_tests/reports/api_tests.py  | 217 +++++++++++++++++++++-
 tests/unit_tests/commands/report/base_test.py | 253 ++++++++++++++++++++++++++
 8 files changed, 631 insertions(+), 49 deletions(-)

diff --git a/docs/docs/configuration/alerts-reports.mdx 
b/docs/docs/configuration/alerts-reports.mdx
index 06d8e600f3..55349e33c6 100644
--- a/docs/docs/configuration/alerts-reports.mdx
+++ b/docs/docs/configuration/alerts-reports.mdx
@@ -195,6 +195,15 @@ Please refer to `ExecutorType` in the codebase for other 
executor types.
   its default value of `http://0.0.0.0:8080/`.
 
 
+It's also possible to specify a minimum interval between each report's 
execution through the config file:
+
+``` python
+# Set a minimum interval threshold between executions (for each Alert/Report)
+# Value should be an integer
+ALERT_MINIMUM_INTERVAL = int(timedelta(minutes=10).total_seconds())
+REPORT_MINIMUM_INTERVAL = int(timedelta(minutes=5).total_seconds())
+```
+
 ## Custom Dockerfile
 
 If you're running the dev version of a released Superset image, like 
`apache/superset:3.1.0-dev`, you should be set with the above.
diff --git a/superset/commands/report/base.py b/superset/commands/report/base.py
index 3b2f280816..3086023f03 100644
--- a/superset/commands/report/base.py
+++ b/superset/commands/report/base.py
@@ -17,6 +17,8 @@
 import logging
 from typing import Any
 
+from croniter import croniter
+from flask import current_app
 from marshmallow import ValidationError
 
 from superset.commands.base import BaseCommand
@@ -26,11 +28,12 @@ from superset.commands.report.exceptions import (
     DashboardNotFoundValidationError,
     DashboardNotSavedValidationError,
     ReportScheduleEitherChartOrDashboardError,
+    ReportScheduleFrequencyNotAllowed,
     ReportScheduleOnlyChartOrDashboardError,
 )
 from superset.daos.chart import ChartDAO
 from superset.daos.dashboard import DashboardDAO
-from superset.reports.models import ReportCreationMethod
+from superset.reports.models import ReportCreationMethod, ReportScheduleType
 
 logger = logging.getLogger(__name__)
 
@@ -76,3 +79,45 @@ class BaseReportScheduleCommand(BaseCommand):
             self._properties["dashboard"] = dashboard
         elif not update:
             exceptions.append(ReportScheduleEitherChartOrDashboardError())
+
+    def validate_report_frequency(
+        self,
+        cron_schedule: str,
+        report_type: str,
+    ) -> None:
+        """
+        Validates if the report scheduled frequency doesn't exceed a limit
+        configured in `config.py`.
+
+        :param cron_schedule: The cron schedule configured.
+        :param report_type: The report type (Alert/Report).
+        """
+        config_key = (
+            "ALERT_MINIMUM_INTERVAL"
+            if report_type == ReportScheduleType.ALERT
+            else "REPORT_MINIMUM_INTERVAL"
+        )
+        minimum_interval = current_app.config.get(config_key, 0)
+
+        if not isinstance(minimum_interval, int):
+            logger.error(
+                "Invalid value for %s: %s", config_key, minimum_interval, 
exc_info=True
+            )
+            return
+
+        # Since configuration is in minutes, we only need to validate
+        # in case `minimum_interval` is <= 120 (2min)
+        if minimum_interval < 120:
+            return
+
+        iterations = 60 if minimum_interval <= 3660 else 24
+        schedule = croniter(cron_schedule)
+        current_exec = next(schedule)
+
+        for _ in range(iterations):
+            next_exec = next(schedule)
+            diff, current_exec = next_exec - current_exec, next_exec
+            if int(diff) < minimum_interval:
+                raise ReportScheduleFrequencyNotAllowed(
+                    report_type=report_type, minimum_interval=minimum_interval
+                )
diff --git a/superset/commands/report/create.py 
b/superset/commands/report/create.py
index aa9bfefc6e..e73da467d1 100644
--- a/superset/commands/report/create.py
+++ b/superset/commands/report/create.py
@@ -30,7 +30,6 @@ from superset.commands.report.exceptions import (
     ReportScheduleCreationMethodUniquenessValidationError,
     ReportScheduleInvalidError,
     ReportScheduleNameUniquenessValidationError,
-    ReportScheduleRequiredTypeValidationError,
 )
 from superset.daos.database import DatabaseDAO
 from superset.daos.exceptions import DAOCreateFailedError
@@ -58,38 +57,53 @@ class CreateReportScheduleCommand(CreateMixin, 
BaseReportScheduleCommand):
             raise ReportScheduleCreateFailedError() from ex
 
     def validate(self) -> None:
-        exceptions: list[ValidationError] = []
-        owner_ids: Optional[list[int]] = self._properties.get("owners")
-        name = self._properties.get("name", "")
-        report_type = self._properties.get("type")
-        creation_method = self._properties.get("creation_method")
+        """
+        Validates the properties of a report schedule configuration, including 
uniqueness
+        of name and type, relations based on the report type, frequency, etc. 
Populates
+        a list of `ValidationErrors` to be returned in the API response if any.
+
+        Fields were loaded according to the `ReportSchedulePostSchema` schema.
+        """
+        # Required fields
+        cron_schedule = self._properties["crontab"]
+        name = self._properties["name"]
+        report_type = self._properties["type"]
+
+        # Optional fields
         chart_id = self._properties.get("chart")
+        creation_method = self._properties.get("creation_method")
         dashboard_id = self._properties.get("dashboard")
+        owner_ids: Optional[list[int]] = self._properties.get("owners")
 
-        # Validate type is required
-        if not report_type:
-            exceptions.append(ReportScheduleRequiredTypeValidationError())
+        exceptions: list[ValidationError] = []
 
         # Validate name type uniqueness
-        if report_type and not ReportScheduleDAO.validate_update_uniqueness(
-            name, report_type
-        ):
+        if not ReportScheduleDAO.validate_update_uniqueness(name, report_type):
             exceptions.append(
                 ReportScheduleNameUniquenessValidationError(
                     report_type=report_type, name=name
                 )
             )
 
-        # validate relation by report type
+        # Validate if DB exists (for alerts)
         if report_type == ReportScheduleType.ALERT:
-            database_id = self._properties.get("database")
-            if not database_id:
-                
exceptions.append(ReportScheduleAlertRequiredDatabaseValidationError())
-            else:
-                database = DatabaseDAO.find_by_id(database_id)
-                if not database:
+            try:
+                database_id = self._properties["database"]
+                if database := DatabaseDAO.find_by_id(database_id):
+                    self._properties["database"] = database
+                else:
                     exceptions.append(DatabaseNotFoundValidationError())
-                self._properties["database"] = database
+            except KeyError:
+                
exceptions.append(ReportScheduleAlertRequiredDatabaseValidationError())
+
+        # validate report frequency
+        try:
+            self.validate_report_frequency(
+                cron_schedule,
+                report_type,
+            )
+        except ValidationError as exc:
+            exceptions.append(exc)
 
         # Validate chart or dashboard relations
         self.validate_chart_dashboard(exceptions)
diff --git a/superset/commands/report/exceptions.py 
b/superset/commands/report/exceptions.py
index db929c63a2..495e0bff9a 100644
--- a/superset/commands/report/exceptions.py
+++ b/superset/commands/report/exceptions.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import math
+
 from flask_babel import lazy_gettext as _
 
 from superset.commands.exceptions import (
@@ -93,6 +95,31 @@ class 
ReportScheduleEitherChartOrDashboardError(ValidationError):
         )
 
 
+class ReportScheduleFrequencyNotAllowed(ValidationError):
+    """
+    Marshmallow validation error for report schedule configured to run more
+    frequently than allowed
+    """
+
+    def __init__(
+        self,
+        report_type: str = "Report",
+        minimum_interval: int = 120,
+    ) -> None:
+        interval_in_minutes = math.ceil(minimum_interval / 60)
+
+        super().__init__(
+            _(
+                "%(report_type)s schedule frequency exceeding limit."
+                " Please configure a schedule with a minimum interval of"
+                " %(minimum_interval)d minutes per execution.",
+                report_type=report_type,
+                minimum_interval=interval_in_minutes,
+            ),
+            field_name="crontab",
+        )
+
+
 class ChartNotSavedValidationError(ValidationError):
     """
     Marshmallow validation error for charts that haven't been saved yet
diff --git a/superset/commands/report/update.py 
b/superset/commands/report/update.py
index cb63ec5011..f5ff0ca158 100644
--- a/superset/commands/report/update.py
+++ b/superset/commands/report/update.py
@@ -59,17 +59,29 @@ class UpdateReportScheduleCommand(UpdateMixin, 
BaseReportScheduleCommand):
         return report_schedule
 
     def validate(self) -> None:
-        exceptions: list[ValidationError] = []
-        owner_ids: Optional[list[int]] = self._properties.get("owners")
-        report_type = self._properties.get("type", ReportScheduleType.ALERT)
-
-        name = self._properties.get("name", "")
+        """
+        Validates the properties of a report schedule configuration, including 
uniqueness
+        of name and type, relations based on the report type, frequency, etc. 
Populates
+        a list of `ValidationErrors` to be returned in the API response if any.
+
+        Fields were loaded according to the `ReportSchedulePutSchema` schema.
+        """
+        # Load existing report schedule config
         self._model = ReportScheduleDAO.find_by_id(self._model_id)
-
-        # Does the report exist?
         if not self._model:
             raise ReportScheduleNotFoundError()
 
+        # Required fields for validation
+        cron_schedule = self._properties.get("crontab", self._model.crontab)
+        name = self._properties.get("name", self._model.name)
+        report_type = self._properties.get("type", self._model.type)
+
+        # Optional fields
+        database_id = self._properties.get("database")
+        owner_ids: Optional[list[int]] = self._properties.get("owners")
+
+        exceptions: list[ValidationError] = []
+
         # Change the state to not triggered when the user deactivates
         # A report that is currently in a working state. This prevents
         # an alert/report from being kept in a working state if activated back
@@ -80,28 +92,31 @@ class UpdateReportScheduleCommand(UpdateMixin, 
BaseReportScheduleCommand):
         ):
             self._properties["last_state"] = ReportState.NOOP
 
-        # validate relation by report type
-        if not report_type:
-            report_type = self._model.type
-
-        # Validate name type uniqueness
-        if not ReportScheduleDAO.validate_update_uniqueness(
-            name, report_type, expect_id=self._model_id
-        ):
-            exceptions.append(
-                ReportScheduleNameUniquenessValidationError(
-                    report_type=report_type, name=name
+        # Validate name/type uniqueness if either is changing
+        if name != self._model.name or report_type != self._model.type:
+            if not ReportScheduleDAO.validate_update_uniqueness(
+                name, report_type, expect_id=self._model_id
+            ):
+                exceptions.append(
+                    ReportScheduleNameUniquenessValidationError(
+                        report_type=report_type, name=name
+                    )
                 )
-            )
 
-        if report_type == ReportScheduleType.ALERT:
-            database_id = self._properties.get("database")
-            # If database_id was sent let's validate it exists
-            if database_id:
-                database = DatabaseDAO.find_by_id(database_id)
-                if not database:
-                    exceptions.append(DatabaseNotFoundValidationError())
-                self._properties["database"] = database
+        # Validate if DB exists (for alerts)
+        if report_type == ReportScheduleType.ALERT and database_id:
+            if not (database := DatabaseDAO.find_by_id(database_id)):
+                exceptions.append(DatabaseNotFoundValidationError())
+            self._properties["database"] = database
+
+        # validate report frequency
+        try:
+            self.validate_report_frequency(
+                cron_schedule,
+                report_type,
+            )
+        except ValidationError as exc:
+            exceptions.append(exc)
 
         # Validate chart or dashboard relations
         self.validate_chart_dashboard(exceptions, update=True)
diff --git a/superset/config.py b/superset/config.py
index 6421781288..10f075bb5f 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1328,6 +1328,10 @@ ALERT_REPORTS_QUERY_EXECUTION_MAX_TRIES = 1
 # Custom width for screenshots
 ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH = 600
 ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH = 2400
+# Set a minimum interval threshold between executions (for each Alert/Report)
+# Value should be an integer i.e. int(timedelta(minutes=5).total_seconds())
+ALERT_MINIMUM_INTERVAL = int(timedelta(minutes=0).total_seconds())
+REPORT_MINIMUM_INTERVAL = int(timedelta(minutes=0).total_seconds())
 
 # A custom prefix to use on all Alerts & Reports emails
 EMAIL_REPORTS_SUBJECT_PREFIX = "[Report] "
diff --git a/tests/integration_tests/reports/api_tests.py 
b/tests/integration_tests/reports/api_tests.py
index 1e1d91f77f..4f63a5ab7e 100644
--- a/tests/integration_tests/reports/api_tests.py
+++ b/tests/integration_tests/reports/api_tests.py
@@ -17,7 +17,8 @@
 # isort:skip_file
 """Unit tests for Superset"""
 
-from datetime import datetime
+from datetime import datetime, timedelta
+from unittest.mock import patch
 import json
 
 import pytz
@@ -1259,6 +1260,220 @@ class TestReportSchedulesApi(SupersetTestCase):
         }
         assert rv.status_code == 400
 
+    @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+    def test_create_report_schedule_valid_schedule(self):
+        """
+        ReportSchedule API: Test create report schedule when a minimum
+        interval is set in config.
+        """
+        self.login(ADMIN_USERNAME)
+
+        chart = db.session.query(Slice).first()
+        example_db = get_example_database()
+        report_schedule_data = {
+            "type": ReportScheduleType.ALERT,
+            "name": "Alert with a valid frequency",
+            "description": "description",
+            "creation_method": "alerts_reports",
+            "crontab": "5,10 9 * * *",
+            "recipients": [
+                {
+                    "type": ReportRecipientType.EMAIL,
+                    "recipient_config_json": {"target": "[email protected]"},
+                },
+                {
+                    "type": ReportRecipientType.SLACK,
+                    "recipient_config_json": {"target": "channel"},
+                },
+            ],
+            "grace_period": 14400,
+            "working_timeout": 3600,
+            "chart": chart.id,
+            "database": example_db.id,
+        }
+        with patch.dict(
+            "superset.commands.report.base.current_app.config",
+            {
+                "ALERT_MINIMUM_INTERVAL": 
int(timedelta(minutes=2).total_seconds()),
+                "REPORT_MINIMUM_INTERVAL": 
int(timedelta(minutes=5).total_seconds()),
+            },
+        ):
+            uri = "api/v1/report/"
+            rv = self.post_assert_metric(uri, report_schedule_data, "post")
+            assert rv.status_code == 201
+            report_schedule_data["type"] = ReportScheduleType.REPORT
+            report_schedule_data["name"] = "Report with a valid frequency"
+            del report_schedule_data["database"]
+            rv = self.post_assert_metric(uri, report_schedule_data, "post")
+            assert rv.status_code == 201
+
+    @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+    def test_create_report_schedule_invalid_schedule(self):
+        """
+        ReportSchedule API: Test create report schedule when a minimum
+        interval is set in config and the scheduled frequency exceeds it.
+        """
+        self.login(ADMIN_USERNAME)
+
+        chart = db.session.query(Slice).first()
+        example_db = get_example_database()
+        report_schedule_data = {
+            "type": ReportScheduleType.ALERT,
+            "name": "Invalid Frequency",
+            "description": "description",
+            "creation_method": "alerts_reports",
+            "crontab": "5,10 9 * * *",
+            "recipients": [
+                {
+                    "type": ReportRecipientType.EMAIL,
+                    "recipient_config_json": {"target": "[email protected]"},
+                },
+                {
+                    "type": ReportRecipientType.SLACK,
+                    "recipient_config_json": {"target": "channel"},
+                },
+            ],
+            "grace_period": 14400,
+            "working_timeout": 3600,
+            "chart": chart.id,
+            "database": example_db.id,
+        }
+        with patch.dict(
+            "superset.commands.report.base.current_app.config",
+            {
+                "ALERT_MINIMUM_INTERVAL": 
int(timedelta(minutes=6).total_seconds()),
+                "REPORT_MINIMUM_INTERVAL": 
int(timedelta(minutes=8).total_seconds()),
+            },
+        ):
+            uri = "api/v1/report/"
+            rv = self.post_assert_metric(uri, report_schedule_data, "post")
+            response = json.loads(rv.data.decode("utf-8"))
+            assert response == {
+                "message": {
+                    "crontab": (
+                        "Alert schedule frequency exceeding limit. "
+                        "Please configure a schedule with a minimum interval 
of 6 minutes per execution."
+                    )
+                }
+            }
+            assert rv.status_code == 422
+            report_schedule_data["type"] = ReportScheduleType.REPORT
+            del report_schedule_data["database"]
+            rv = self.post_assert_metric(uri, report_schedule_data, "post")
+            response = json.loads(rv.data.decode("utf-8"))
+            assert response == {
+                "message": {
+                    "crontab": (
+                        "Report schedule frequency exceeding limit. "
+                        "Please configure a schedule with a minimum interval 
of 8 minutes per execution."
+                    )
+                }
+            }
+            assert rv.status_code == 422
+
+    @pytest.mark.usefixtures("create_report_schedules")
+    def test_update_report_schedule_valid_schedule(self) -> None:
+        """
+        ReportSchedule API: Test update report schedule when a minimum
+        interval is set in config.
+        """
+        self.login(ADMIN_USERNAME)
+        report_schedule = (
+            db.session.query(ReportSchedule)
+            .filter(ReportSchedule.name == "name2")
+            .one_or_none()
+        )
+        assert report_schedule.type == ReportScheduleType.ALERT
+        previous_cron = report_schedule.crontab
+        update_payload = {
+            "crontab": "5,10 * * * *",
+        }
+        with patch.dict(
+            "superset.commands.report.base.current_app.config",
+            {
+                "ALERT_MINIMUM_INTERVAL": 
int(timedelta(minutes=5).total_seconds()),
+                "REPORT_MINIMUM_INTERVAL": 
int(timedelta(minutes=3).total_seconds()),
+            },
+        ):
+            # Test alert minimum interval
+            uri = f"api/v1/report/{report_schedule.id}"
+            rv = self.put_assert_metric(uri, update_payload, "put")
+            assert rv.status_code == 200
+
+            # Test report minimum interval
+            update_payload["crontab"] = "5,8 * * * *"
+            update_payload["type"] = ReportScheduleType.REPORT
+            uri = f"api/v1/report/{report_schedule.id}"
+            rv = self.put_assert_metric(uri, update_payload, "put")
+            assert rv.status_code == 200
+
+        with patch.dict(
+            "superset.commands.report.base.current_app.config",
+            {
+                "ALERT_MINIMUM_INTERVAL": 0,
+                "REPORT_MINIMUM_INTERVAL": 0,
+            },
+        ):
+            # Undo changes
+            update_payload["crontab"] = previous_cron
+            update_payload["type"] = ReportScheduleType.ALERT
+            uri = f"api/v1/report/{report_schedule.id}"
+            rv = self.put_assert_metric(uri, update_payload, "put")
+            assert rv.status_code == 200
+
+    @pytest.mark.usefixtures("create_report_schedules")
+    def test_update_report_schedule_invalid_schedule(self) -> None:
+        """
+        ReportSchedule API: Test update report schedule when a minimum
+        interval is set in config and the scheduled frequency exceeds it.
+        """
+        self.login(ADMIN_USERNAME)
+        report_schedule = (
+            db.session.query(ReportSchedule)
+            .filter(ReportSchedule.name == "name2")
+            .one_or_none()
+        )
+        assert report_schedule.type == ReportScheduleType.ALERT
+        update_payload = {
+            "crontab": "5,10 * * * *",
+        }
+        with patch.dict(
+            "superset.commands.report.base.current_app.config",
+            {
+                "ALERT_MINIMUM_INTERVAL": 
int(timedelta(minutes=6).total_seconds()),
+                "REPORT_MINIMUM_INTERVAL": 
int(timedelta(minutes=4).total_seconds()),
+            },
+        ):
+            # Exceed alert minimum interval
+            uri = f"api/v1/report/{report_schedule.id}"
+            rv = self.put_assert_metric(uri, update_payload, "put")
+            assert rv.status_code == 422
+            response = json.loads(rv.data.decode("utf-8"))
+            assert response == {
+                "message": {
+                    "crontab": (
+                        "Alert schedule frequency exceeding limit. "
+                        "Please configure a schedule with a minimum interval 
of 6 minutes per execution."
+                    )
+                }
+            }
+
+            # Exceed report minimum interval
+            update_payload["crontab"] = "5,8 * * * *"
+            update_payload["type"] = ReportScheduleType.REPORT
+            uri = f"api/v1/report/{report_schedule.id}"
+            rv = self.put_assert_metric(uri, update_payload, "put")
+            assert rv.status_code == 422
+            response = json.loads(rv.data.decode("utf-8"))
+            assert response == {
+                "message": {
+                    "crontab": (
+                        "Report schedule frequency exceeding limit. "
+                        "Please configure a schedule with a minimum interval 
of 4 minutes per execution."
+                    )
+                }
+            }
+
     @pytest.mark.usefixtures("create_report_schedules")
     def test_update_report_schedule(self):
         """
diff --git a/tests/unit_tests/commands/report/base_test.py 
b/tests/unit_tests/commands/report/base_test.py
new file mode 100644
index 0000000000..499682a1e6
--- /dev/null
+++ b/tests/unit_tests/commands/report/base_test.py
@@ -0,0 +1,253 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is 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.
+
+import logging
+from datetime import timedelta
+from functools import wraps
+from typing import Any, Callable
+from unittest.mock import patch
+
+import pytest
+
+from superset.commands.report.base import BaseReportScheduleCommand
+from superset.commands.report.exceptions import 
ReportScheduleFrequencyNotAllowed
+from superset.reports.models import ReportScheduleType
+
+REPORT_TYPES = {
+    ReportScheduleType.ALERT,
+    ReportScheduleType.REPORT,
+}
+
+TEST_SCHEDULES_EVERY_MINUTE = {
+    "* * * * *",
+    "1-5 * * * *",
+    "10-20 * * * *",
+    "0,45,10-20 * * * *",
+    "23,45,50,51 * * * *",
+    "10,20,30,40-45 * * * *",
+}
+
+TEST_SCHEDULES_SINGLE_MINUTES = {
+    "1,5,8,10,12 * * * *",
+    "10 1 * * *",
+    "27,2 1-5 * * *",
+}
+
+TEST_SCHEDULES = 
TEST_SCHEDULES_EVERY_MINUTE.union(TEST_SCHEDULES_SINGLE_MINUTES)
+
+
+def app_custom_config(
+    alert_minimum_interval: int | str = 0,
+    report_minimum_interval: int | str = 0,
+) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
+    """
+    Decorator to mock the current_app.config values dynamically for each test.
+
+    :param alert_minimum_interval: Minimum interval. Defaults to None.
+    :param report_minimum_interval: Minimum interval. Defaults to None.
+
+    :returns: A decorator that wraps a function.
+    """
+
+    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
+        @wraps(func)
+        def wrapper(*args: Any, **kwargs: Any) -> Any:
+            with patch(
+                "superset.commands.report.base.current_app.config"
+            ) as mock_config:
+                mock_config.get.side_effect = lambda key, default=0: {
+                    "ALERT_MINIMUM_INTERVAL": alert_minimum_interval,
+                    "REPORT_MINIMUM_INTERVAL": report_minimum_interval,
+                }.get(key, default)
+                return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator
+
+
[email protected]("report_type", REPORT_TYPES)
[email protected]("schedule", TEST_SCHEDULES)
+@app_custom_config()
+def test_validate_report_frequency(report_type: str, schedule: str) -> None:
+    """
+    Test the ``validate_report_frequency`` method when there's
+    no minimum frequency configured.
+    """
+    BaseReportScheduleCommand().validate_report_frequency(
+        schedule,
+        report_type,
+    )
+
+
+@app_custom_config(
+    alert_minimum_interval=int(timedelta(minutes=4).total_seconds()),
+    report_minimum_interval=int(timedelta(minutes=5).total_seconds()),
+)
+def test_validate_report_frequency_minimum_set() -> None:
+    """
+    Test the ``validate_report_frequency`` method when there's
+    minimum frequencies configured.
+    """
+
+    BaseReportScheduleCommand().validate_report_frequency(
+        "1,5 * * * *",
+        ReportScheduleType.ALERT,
+    )
+    BaseReportScheduleCommand().validate_report_frequency(
+        "6,11 * * * *",
+        ReportScheduleType.REPORT,
+    )
+
+
+@app_custom_config(
+    alert_minimum_interval=int(timedelta(minutes=2).total_seconds()),
+    report_minimum_interval=int(timedelta(minutes=5).total_seconds()),
+)
+def test_validate_report_frequency_invalid_schedule() -> None:
+    """
+    Test the ``validate_report_frequency`` method when the configured
+    schedule exceeds the limit.
+    """
+    with pytest.raises(ReportScheduleFrequencyNotAllowed):
+        BaseReportScheduleCommand().validate_report_frequency(
+            "1,2 * * * *",
+            ReportScheduleType.ALERT,
+        )
+
+    with pytest.raises(ReportScheduleFrequencyNotAllowed):
+        BaseReportScheduleCommand().validate_report_frequency(
+            "1,5 * * * *",
+            ReportScheduleType.REPORT,
+        )
+
+
[email protected]("schedule", TEST_SCHEDULES)
+@app_custom_config(
+    alert_minimum_interval=int(timedelta(minutes=10).total_seconds()),
+)
+def test_validate_report_frequency_alert_only(schedule: str) -> None:
+    """
+    Test the ``validate_report_frequency`` method when there's
+    only a configuration for alerts and user is creating report.
+    """
+    BaseReportScheduleCommand().validate_report_frequency(
+        schedule,
+        ReportScheduleType.REPORT,
+    )
+
+
[email protected]("schedule", TEST_SCHEDULES)
+@app_custom_config(
+    report_minimum_interval=int(timedelta(minutes=10).total_seconds()),
+)
+def test_validate_report_frequency_report_only(schedule: str) -> None:
+    """
+    Test the ``validate_report_frequency`` method when there's
+    only a configuration for reports and user is creating alert.
+    """
+    BaseReportScheduleCommand().validate_report_frequency(
+        schedule,
+        ReportScheduleType.ALERT,
+    )
+
+
[email protected]("report_type", REPORT_TYPES)
[email protected]("schedule", TEST_SCHEDULES)
+@app_custom_config(
+    alert_minimum_interval=int(timedelta(minutes=1).total_seconds()),
+    report_minimum_interval=int(timedelta(minutes=1).total_seconds()),
+)
+def test_validate_report_frequency_accepts_every_minute_with_one(
+    report_type: str, schedule: str
+) -> None:
+    """
+    Test the ``validate_report_frequency`` method when configuration
+    is set to `1`. Validates the usage of `-` and `*` in the cron.
+    """
+    BaseReportScheduleCommand().validate_report_frequency(
+        schedule,
+        report_type,
+    )
+
+
[email protected]("report_type", REPORT_TYPES)
[email protected]("schedule", TEST_SCHEDULES_SINGLE_MINUTES)
+@app_custom_config(
+    alert_minimum_interval=int(timedelta(minutes=2).total_seconds()),
+    report_minimum_interval=int(timedelta(minutes=2).total_seconds()),
+)
+def test_validate_report_frequency_accepts_every_minute_with_two(
+    report_type: str,
+    schedule: str,
+) -> None:
+    """
+    Test the ``validate_report_frequency`` method when configuration
+    is set to `2`.
+    """
+    BaseReportScheduleCommand().validate_report_frequency(
+        schedule,
+        report_type,
+    )
+
+
[email protected]("report_type", REPORT_TYPES)
[email protected]("schedule", TEST_SCHEDULES_EVERY_MINUTE)
+@app_custom_config(
+    alert_minimum_interval=int(timedelta(minutes=2).total_seconds()),
+    report_minimum_interval=int(timedelta(minutes=2).total_seconds()),
+)
+def test_validate_report_frequency_accepts_every_minute_with_two_raises(
+    report_type: str,
+    schedule: str,
+) -> None:
+    """
+    Test the ``validate_report_frequency`` method when configuration
+    is set to `2`. Validates the usage of `-` and `*` in the cron.
+    """
+    # Should fail for schedules with `-` and `*`
+    with pytest.raises(ReportScheduleFrequencyNotAllowed):
+        BaseReportScheduleCommand().validate_report_frequency(
+            schedule,
+            report_type,
+        )
+
+
[email protected]("report_type", REPORT_TYPES)
[email protected]("schedule", TEST_SCHEDULES)
+@app_custom_config(
+    alert_minimum_interval="10 minutes",
+    report_minimum_interval="10 minutes",
+)
+def test_validate_report_frequency_invalid_config(
+    caplog: pytest.LogCaptureFixture,
+    report_type: str,
+    schedule: str,
+) -> None:
+    """
+    Test the ``validate_report_frequency`` method when the configuration
+    is invalid.
+    """
+    caplog.set_level(logging.ERROR)
+    BaseReportScheduleCommand().validate_report_frequency(
+        schedule,
+        report_type,
+    )
+    expected_error_message = (
+        f"invalid value for {report_type}_MINIMUM_INTERVAL: 10 minutes"
+    )
+    assert expected_error_message.lower() in caplog.text.lower()

Reply via email to