diff --git a/superset/cli/test.py b/superset/cli/test.py index df0142b654094..db065287b008b 100755 --- a/superset/cli/test.py +++ b/superset/cli/test.py @@ -15,7 +15,6 @@ # specific language governing permissions and limitations # under the License. import logging -from datetime import datetime, timedelta import click from colorama import Fore @@ -23,7 +22,6 @@ import superset.utils.database as database_utils from superset import app, security_manager -from superset.utils.celery import session_scope logger = logging.getLogger(__name__) @@ -88,23 +86,3 @@ def load_test_users_run() -> None: password="general", ) sm.get_session.commit() - - -@click.command() -@with_appcontext -def alert() -> None: - """Run the alert scheduler loop""" - # this command is just for testing purposes - # pylint: disable=import-outside-toplevel - from superset.models.schedules import ScheduleType - from superset.tasks.schedules import schedule_window - - click.secho("Processing one alert loop", fg="green") - with session_scope(nullpool=True) as session: - schedule_window( - report_type=ScheduleType.alert, - start_at=datetime.now() - timedelta(1000), - stop_at=datetime.now(), - resolution=6000, - session=session, - ) diff --git a/superset/config.py b/superset/config.py index b578c99354a5b..80224a68c3614 100644 --- a/superset/config.py +++ b/superset/config.py @@ -1051,18 +1051,6 @@ def SQL_QUERY_MUTATOR( # pylint: disable=invalid-name,unused-argument return sql -# Enable / disable scheduled email reports -# -# Warning: This config key is deprecated and will be removed in version 2.0.0" -ENABLE_SCHEDULED_EMAIL_REPORTS = False - -# Enable / disable Alerts, where users can define custom SQL that -# will send emails with screenshots of charts or dashboards periodically -# if it meets the criteria -# -# Warning: This config key is deprecated and will be removed in version 2.0.0" -ENABLE_ALERTS = False - # --------------------------------------------------- # Alerts & Reports # --------------------------------------------------- diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index f6ffd3ec3a09e..2b970b718fadf 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -150,13 +150,7 @@ def init_views(self) -> None: from superset.reports.logs.api import ReportExecutionLogRestApi from superset.security.api import SecurityRestApi from superset.views.access_requests import AccessRequestsModelView - from superset.views.alerts import ( - AlertLogModelView, - AlertModelView, - AlertObservationModelView, - AlertView, - ReportView, - ) + from superset.views.alerts import AlertView from superset.views.annotations import ( AnnotationLayerModelView, AnnotationModelView, @@ -185,10 +179,6 @@ def init_views(self) -> None: from superset.views.log.api import LogRestApi from superset.views.log.views import LogModelView from superset.views.redirects import R - from superset.views.schedules import ( - DashboardEmailScheduleView, - SliceEmailScheduleView, - ) from superset.views.sql_lab import ( SavedQueryView, SavedQueryViewApi, @@ -393,50 +383,6 @@ def init_views(self) -> None: # # Conditionally setup email views # - if self.config["ENABLE_SCHEDULED_EMAIL_REPORTS"]: - logging.warning( - "ENABLE_SCHEDULED_EMAIL_REPORTS " - "is deprecated and will be removed in version 2.0.0" - ) - - appbuilder.add_separator( - "Manage", cond=lambda: self.config["ENABLE_SCHEDULED_EMAIL_REPORTS"] - ) - appbuilder.add_view( - DashboardEmailScheduleView, - "Dashboard Email Schedules", - label=__("Dashboard Emails"), - category="Manage", - category_label=__("Manage"), - icon="fa-search", - menu_cond=lambda: self.config["ENABLE_SCHEDULED_EMAIL_REPORTS"], - ) - appbuilder.add_view( - SliceEmailScheduleView, - "Chart Emails", - label=__("Chart Email Schedules"), - category="Manage", - category_label=__("Manage"), - icon="fa-search", - menu_cond=lambda: self.config["ENABLE_SCHEDULED_EMAIL_REPORTS"], - ) - - if self.config["ENABLE_ALERTS"]: - logging.warning( - "ENABLE_ALERTS is deprecated and will be removed in version 2.0.0" - ) - - appbuilder.add_view( - AlertModelView, - "Alerts", - label=__("Alerts"), - category="Manage", - category_label=__("Manage"), - icon="fa-exclamation-triangle", - menu_cond=lambda: bool(self.config["ENABLE_ALERTS"]), - ) - appbuilder.add_view_no_menu(AlertLogModelView) - appbuilder.add_view_no_menu(AlertObservationModelView) appbuilder.add_view( AlertView, @@ -447,7 +393,6 @@ def init_views(self) -> None: icon="fa-exclamation-triangle", menu_cond=lambda: feature_flag_manager.is_feature_enabled("ALERT_REPORTS"), ) - appbuilder.add_view_no_menu(ReportView) appbuilder.add_view( AccessRequestsModelView, diff --git a/superset/models/__init__.py b/superset/models/__init__.py index 573d22d1094fd..a102a0fff59a4 100644 --- a/superset/models/__init__.py +++ b/superset/models/__init__.py @@ -14,12 +14,4 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from . import ( - alerts, - core, - datasource_access_request, - dynamic_plugins, - schedules, - sql_lab, - user_attributes, -) +from . import core, datasource_access_request, dynamic_plugins, sql_lab, user_attributes diff --git a/superset/models/alerts.py b/superset/models/alerts.py deleted file mode 100644 index 163dcf027de0e..0000000000000 --- a/superset/models/alerts.py +++ /dev/null @@ -1,176 +0,0 @@ -# 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. -"""Models for scheduled execution of jobs""" -import json -import textwrap -from datetime import datetime -from typing import Any, Optional - -from flask_appbuilder import Model -from sqlalchemy import ( - Boolean, - Column, - DateTime, - Float, - ForeignKey, - Integer, - String, - Table, - Text, -) -from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import backref, relationship, RelationshipProperty - -from superset import db, security_manager -from superset.models.helpers import AuditMixinNullable - -metadata = Model.metadata # pylint: disable=no-member - - -alert_owner = Table( - "alert_owner", - metadata, - Column("id", Integer, primary_key=True), - Column("user_id", Integer, ForeignKey("ab_user.id")), - Column("alert_id", Integer, ForeignKey("alerts.id")), -) - - -class Alert(Model, AuditMixinNullable): - - """Schedules for emailing slices / dashboards""" - - __tablename__ = "alerts" - - id = Column(Integer, primary_key=True) - label = Column(String(150), nullable=False) - active = Column(Boolean, default=True, index=True) - # TODO(bkyryliuk): enforce minimal supported frequency - crontab = Column(String(50), nullable=False) - - alert_type = Column(String(50)) - owners = relationship(security_manager.user_model, secondary=alert_owner) - recipients = Column(Text) - slack_channel = Column(Text) - - # TODO(bkyryliuk): implement log_retention - log_retention = Column(Integer, default=90) - grace_period = Column(Integer, default=60 * 60 * 24) - - slice_id = Column(Integer, ForeignKey("slices.id")) - slice = relationship("Slice", backref="alerts", foreign_keys=[slice_id]) - - dashboard_id = Column(Integer, ForeignKey("dashboards.id")) - dashboard = relationship("Dashboard", backref="alert", foreign_keys=[dashboard_id]) - - last_eval_dttm = Column(DateTime, default=datetime.utcnow) - last_state = Column(String(10)) - - # Observation related columns - sql = Column(Text, nullable=False) - - # Validation related columns - validator_type = Column(String(100), nullable=False) - validator_config = Column( - Text, - default=textwrap.dedent( - """ - { - - } - """ - ), - ) - - @declared_attr - def database_id(self) -> int: - return Column(Integer, ForeignKey("dbs.id"), nullable=False) - - @declared_attr - def database(self) -> RelationshipProperty: - return relationship( - "Database", - foreign_keys=[self.database_id], - backref=backref("sql_observers", cascade="all, delete-orphan"), - ) - - def get_last_observation(self) -> Optional[Any]: - observations = list( - db.session.query(SQLObservation) - .filter_by(alert_id=self.id) - .order_by(SQLObservation.dttm.desc()) - .limit(1) - ) - - if observations: - return observations[0] - - return None - - def __str__(self) -> str: - return f"<{self.id}:{self.label}>" - - @property - def pretty_config(self) -> str: - """String representing the comparison that will trigger a validator""" - config = json.loads(self.validator_config) - - if self.validator_type.lower() == "operator": - return f"{config['op']} {config['threshold']}" - - if self.validator_type.lower() == "not null": - return "!= Null or 0" - - return "" - - -class AlertLog(Model): - """Keeps track of alert-related operations""" - - __tablename__ = "alert_logs" - - id = Column(Integer, primary_key=True) - scheduled_dttm = Column(DateTime) - dttm_start = Column(DateTime, default=datetime.utcnow) - dttm_end = Column(DateTime, default=datetime.utcnow) - alert_id = Column(Integer, ForeignKey("alerts.id")) - alert = relationship("Alert", backref="logs", foreign_keys=[alert_id]) - state = Column(String(10)) - - @property - def duration(self) -> int: - return (self.dttm_end - self.dttm_start).total_seconds() - - -# TODO: Currently SQLObservation table will constantly grow with no limit, -# add some retention restriction or more to a more scalable db e.g. -# https://github.com/apache/superset/blob/master/superset/utils/log.py#L32 -class SQLObservation(Model): # pylint: disable=too-few-public-methods - """Keeps track of the collected observations for alerts.""" - - __tablename__ = "sql_observations" - - id = Column(Integer, primary_key=True) - dttm = Column(DateTime, default=datetime.utcnow, index=True) - alert_id = Column(Integer, ForeignKey("alerts.id")) - alert = relationship( - "Alert", - foreign_keys=[alert_id], - backref=backref("observations", cascade="all, delete-orphan"), - ) - value = Column(Float) - error_msg = Column(String(500)) diff --git a/superset/models/schedules.py b/superset/models/schedules.py deleted file mode 100644 index f60890bfc3b56..0000000000000 --- a/superset/models/schedules.py +++ /dev/null @@ -1,104 +0,0 @@ -# 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. -"""Models for scheduled execution of jobs""" -import enum -from typing import Optional, Type - -from flask_appbuilder import Model -from sqlalchemy import Boolean, Column, Enum, ForeignKey, Integer, String, Text -from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import relationship, RelationshipProperty - -from superset import security_manager -from superset.models.alerts import Alert -from superset.models.helpers import AuditMixinNullable, ImportExportMixin - -metadata = Model.metadata # pylint: disable=no-member - - -class ScheduleType(str, enum.Enum): - # pylint: disable=invalid-name - slice = "slice" - dashboard = "dashboard" - alert = "alert" - - -class EmailDeliveryType(str, enum.Enum): - # pylint: disable=invalid-name - attachment = "Attachment" - inline = "Inline" - - -class SliceEmailReportFormat(str, enum.Enum): - # pylint: disable=invalid-name - visualization = "Visualization" - data = "Raw data" - - -class EmailSchedule: - - """Schedules for emailing slices / dashboards""" - - __tablename__ = "email_schedules" - - id = Column(Integer, primary_key=True) - active = Column(Boolean, default=True, index=True) - crontab = Column(String(50)) - - @declared_attr - def user_id(self) -> int: - return Column(Integer, ForeignKey("ab_user.id")) - - @declared_attr - def user(self) -> RelationshipProperty: - return relationship( - security_manager.user_model, - backref=self.__tablename__, - foreign_keys=[self.user_id], - ) - - recipients = Column(Text) - slack_channel = Column(Text) - deliver_as_group = Column(Boolean, default=False) - delivery_type = Column(Enum(EmailDeliveryType)) - - -class DashboardEmailSchedule( - Model, AuditMixinNullable, ImportExportMixin, EmailSchedule -): - __tablename__ = "dashboard_email_schedules" - dashboard_id = Column(Integer, ForeignKey("dashboards.id")) - dashboard = relationship( - "Dashboard", backref="email_schedules", foreign_keys=[dashboard_id] - ) - - -class SliceEmailSchedule(Model, AuditMixinNullable, ImportExportMixin, EmailSchedule): - __tablename__ = "slice_email_schedules" - slice_id = Column(Integer, ForeignKey("slices.id")) - slice = relationship("Slice", backref="email_schedules", foreign_keys=[slice_id]) - email_format = Column(Enum(SliceEmailReportFormat)) - - -def get_scheduler_model(report_type: str) -> Optional[Type[EmailSchedule]]: - if report_type == ScheduleType.dashboard: - return DashboardEmailSchedule - if report_type == ScheduleType.slice: - return SliceEmailSchedule - if report_type == ScheduleType.alert: - return Alert - return None diff --git a/superset/tasks/alerts/__init__.py b/superset/tasks/alerts/__init__.py deleted file mode 100644 index fd9417fe5c1e9..0000000000000 --- a/superset/tasks/alerts/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- -# 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. diff --git a/superset/tasks/alerts/observer.py b/superset/tasks/alerts/observer.py deleted file mode 100644 index cbe73d886ae94..0000000000000 --- a/superset/tasks/alerts/observer.py +++ /dev/null @@ -1,96 +0,0 @@ -# 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 datetime -from typing import Optional - -import pandas as pd -from sqlalchemy.orm import Session - -from superset import jinja_context -from superset.models.alerts import Alert, SQLObservation - -logger = logging.getLogger("tasks.email_reports") - - -# Session needs to be passed along in the celery workers and db.session cannot be used. -# For more info see: https://github.com/apache/superset/issues/10530 -def observe(alert_id: int, session: Session) -> Optional[str]: - """Collect observations for the alert. - Returns an error message if the observer value was not valid - """ - - alert = session.query(Alert).filter_by(id=alert_id).one() - - value = None - - tp = jinja_context.get_template_processor(database=alert.database) - rendered_sql = tp.process_template(alert.sql) - df = alert.database.get_df(rendered_sql) - - error_msg = validate_observer_result(df, alert.id, alert.label) - - if not error_msg and not df.empty and df.to_records()[0][1] is not None: - value = float(df.to_records()[0][1]) - - observation = SQLObservation( - alert_id=alert_id, dttm=datetime.utcnow(), value=value, error_msg=error_msg, - ) - - session.add(observation) - session.commit() - - return error_msg - - -def validate_observer_result( - sql_result: pd.DataFrame, alert_id: int, alert_label: str -) -> Optional[str]: - """ - Verifies if a DataFrame SQL query result to see if - it contains a valid value for a SQLObservation. - Returns an error message if the result is invalid. - """ - try: - if sql_result.empty: - # empty results are used for the not null validator - return None - - rows = sql_result.to_records() - - assert ( - len(rows) == 1 - ), f"Observer for alert <{alert_id}:{alert_label}> returned more than 1 row" - - assert ( - len(rows[0]) == 2 - ), f"Observer for alert <{alert_id}:{alert_label}> returned more than 1 column" - - if rows[0][1] is None: - return None - - float(rows[0][1]) - - except AssertionError as error: - return str(error) - except (TypeError, ValueError): - return ( - f"Observer for alert <{alert_id}:{alert_label}> returned a non-number value" - ) - - return None diff --git a/superset/tasks/alerts/validator.py b/superset/tasks/alerts/validator.py deleted file mode 100644 index 38b5791341591..0000000000000 --- a/superset/tasks/alerts/validator.py +++ /dev/null @@ -1,111 +0,0 @@ -# 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 enum -import json -from operator import eq, ge, gt, le, lt, ne -from typing import Callable, Optional - -import numpy as np - -from superset.exceptions import SupersetException -from superset.models.alerts import Alert - -OPERATOR_FUNCTIONS = {">=": ge, ">": gt, "<=": le, "<": lt, "==": eq, "!=": ne} - - -class AlertValidatorType(str, enum.Enum): - NOT_NULL = "not null" - OPERATOR = "operator" - - @classmethod - def valid_type(cls, validator_type: str) -> bool: - return any(val_type.value == validator_type for val_type in cls) - - -def check_validator(validator_type: str, config: str) -> None: - if not AlertValidatorType.valid_type(validator_type): - raise SupersetException( - f"Error: {validator_type} is not a valid validator type." - ) - - config_dict = json.loads(config) - - if validator_type == AlertValidatorType.OPERATOR.value: - - if not (config_dict.get("op") and config_dict.get("threshold") is not None): - raise SupersetException( - "Error: Operator Validator needs specified operator and threshold " - 'values. Add "op" and "threshold" to config.' - ) - - if not config_dict["op"] in OPERATOR_FUNCTIONS.keys(): - raise SupersetException( - f'Error: {config_dict["op"]} is an invalid operator type. Change ' - f'the "op" value in the config to one of ' - f'["<", "<=", ">", ">=", "==", "!="]' - ) - - if not isinstance(config_dict["threshold"], (int, float)): - raise SupersetException( - f'Error: {config_dict["threshold"]} is an invalid threshold value.' - f' Change the "threshold" value in the config.' - ) - - -def not_null_validator( - alert: Alert, validator_config: str # pylint: disable=unused-argument -) -> bool: - """Returns True if a recent observation is not NULL""" - - observation = alert.get_last_observation() - # TODO: Validate malformed observations/observations with errors separately - if ( - not observation - or observation.error_msg - or observation.value in (0, None, np.nan) - ): - return False - return True - - -def operator_validator(alert: Alert, validator_config: str) -> bool: - """ - Returns True if a recent observation is greater than or equal to - the value given in the validator config - """ - observation = alert.get_last_observation() - if not observation or observation.value in (None, np.nan): - return False - - operator = json.loads(validator_config)["op"] - threshold = json.loads(validator_config)["threshold"] - return OPERATOR_FUNCTIONS[operator](observation.value, threshold) - - -def get_validator_function( - validator_type: str, -) -> Optional[Callable[[Alert, str], bool]]: - """Returns a validation function based on validator_type""" - - alert_validators = { - AlertValidatorType.NOT_NULL.value: not_null_validator, - AlertValidatorType.OPERATOR.value: operator_validator, - } - if alert_validators.get(validator_type.lower()): - return alert_validators[validator_type.lower()] - - return None diff --git a/superset/tasks/celery_app.py b/superset/tasks/celery_app.py index f8b9bef0d7328..850709bfb4866 100644 --- a/superset/tasks/celery_app.py +++ b/superset/tasks/celery_app.py @@ -32,7 +32,7 @@ # Need to import late, as the celery_app will have been setup by "create_app()" # pylint: disable=wrong-import-position, unused-import -from . import cache, schedules, scheduler # isort:skip +from . import cache, scheduler # isort:skip # Export the celery app globally for Celery (as run on the cmd line) to find app = celery_app diff --git a/superset/tasks/schedules.py b/superset/tasks/schedules.py deleted file mode 100644 index 05506d077a973..0000000000000 --- a/superset/tasks/schedules.py +++ /dev/null @@ -1,855 +0,0 @@ -# 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. -""" -DEPRECATION NOTICE: this module is deprecated as of v1.0.0. -It will be removed in future versions of Superset. Please -migrate to the new scheduler: `superset.tasks.scheduler`. -""" - -import logging -import time -import urllib.request -from collections import namedtuple -from datetime import datetime, timedelta -from email.utils import make_msgid, parseaddr -from enum import Enum -from typing import ( - Any, - Callable, - Dict, - Iterator, - NamedTuple, - Optional, - Tuple, - TYPE_CHECKING, - Union, -) -from urllib.error import URLError - -import croniter -import simplejson as json -from celery.app.task import Task -from dateutil.tz import tzlocal -from flask import current_app, render_template, url_for -from flask_babel import gettext as __ -from selenium.common.exceptions import WebDriverException -from selenium.webdriver import chrome, firefox -from selenium.webdriver.remote.webdriver import WebDriver -from sqlalchemy import func -from sqlalchemy.exc import NoSuchColumnError, ResourceClosedError -from sqlalchemy.orm import Session - -from superset import app, security_manager, thumbnail_cache -from superset.extensions import celery_app, machine_auth_provider_factory -from superset.models.alerts import Alert, AlertLog -from superset.models.dashboard import Dashboard -from superset.models.schedules import ( - EmailDeliveryType, - get_scheduler_model, - ScheduleType, - SliceEmailReportFormat, -) -from superset.models.slice import Slice -from superset.tasks.alerts.observer import observe -from superset.tasks.alerts.validator import get_validator_function -from superset.tasks.slack_util import deliver_slack_msg -from superset.utils.celery import session_scope -from superset.utils.core import get_email_address_list, send_email_smtp -from superset.utils.retries import retry_call -from superset.utils.screenshots import ChartScreenshot, WebDriverProxy -from superset.utils.urls import get_url_path - -if TYPE_CHECKING: - from flask_appbuilder.security.sqla.models import User - from werkzeug.datastructures import TypeConversionDict - -# Globals -config = app.config -logger = logging.getLogger("tasks.email_reports") -logger.setLevel(logging.INFO) - -stats_logger = current_app.config["STATS_LOGGER"] -EMAIL_PAGE_RENDER_WAIT = config["EMAIL_PAGE_RENDER_WAIT"] -WEBDRIVER_BASEURL = config["WEBDRIVER_BASEURL"] -WEBDRIVER_BASEURL_USER_FRIENDLY = config["WEBDRIVER_BASEURL_USER_FRIENDLY"] - -ReportContent = namedtuple( - "ReportContent", - [ - "body", # email body - "data", # attachments - "images", # embedded images for the email - "slack_message", # html not supported, only markdown - # attachments for the slack message, embedding not supported - "slack_attachment", - ], -) - - -class ScreenshotData(NamedTuple): - url: str # url to chat/dashboard for this screenshot - image: Optional[bytes] # bytes for the screenshot - - -class AlertContent(NamedTuple): - label: str # alert name - sql: str # sql statement for alert - observation_value: str # value from observation that triggered the alert - validation_error_message: str # a string of the comparison that triggered an alert - alert_url: str # url to alert details - image_data: Optional[ScreenshotData] # data for the alert screenshot - - -def _get_email_to_and_bcc( - recipients: str, deliver_as_group: bool -) -> Iterator[Tuple[str, str]]: - bcc = config["EMAIL_REPORT_BCC_ADDRESS"] - - if deliver_as_group: - to = recipients - yield (to, bcc) - else: - for to in get_email_address_list(recipients): - yield (to, bcc) - - -# TODO(bkyryliuk): move email functionality into a separate module. -def _deliver_email( # pylint: disable=too-many-arguments - recipients: str, - deliver_as_group: bool, - subject: str, - body: str, - data: Optional[Dict[str, Any]], - images: Optional[Dict[str, bytes]], -) -> None: - for (to, bcc) in _get_email_to_and_bcc(recipients, deliver_as_group): - send_email_smtp( - to, - subject, - body, - config, - data=data, - images=images, - bcc=bcc, - mime_subtype="related", - dryrun=config["SCHEDULED_EMAIL_DEBUG_MODE"], - ) - - -def _generate_report_content( - delivery_type: EmailDeliveryType, screenshot: bytes, name: str, url: str -) -> ReportContent: - data: Optional[Dict[str, Any]] - - # how to: https://api.slack.com/reference/surfaces/formatting - slack_message = __( - """ - *%(name)s*\n - <%(url)s|Explore in Superset> - """, - name=name, - url=url, - ) - - if delivery_type == EmailDeliveryType.attachment: - images = None - data = {"screenshot": screenshot} - body = __( - 'Explore in Superset
', - name=name, - url=url, - ) - elif delivery_type == EmailDeliveryType.inline: - # Get the domain from the 'From' address .. - # and make a message id without the < > in the ends - domain = parseaddr(config["SMTP_MAIL_FROM"])[1].split("@")[1] - msgid = make_msgid(domain)[1:-1] - - images = {msgid: screenshot} - data = None - body = __( - """ - Explore in Superset - - """, - name=name, - url=url, - msgid=msgid, - ) - - return ReportContent(body, data, images, slack_message, screenshot) - - -def _get_url_path(view: str, user_friendly: bool = False, **kwargs: Any) -> str: - with app.test_request_context(): - base_url = ( - WEBDRIVER_BASEURL_USER_FRIENDLY if user_friendly else WEBDRIVER_BASEURL - ) - return urllib.parse.urljoin(str(base_url), url_for(view, **kwargs)) - - -def create_webdriver(session: Session) -> WebDriver: - return WebDriverProxy(driver_type=config["WEBDRIVER_TYPE"]).auth( - get_reports_user(session) - ) - - -def get_reports_user(session: Session) -> "User": - return ( - session.query(security_manager.user_model) - .filter( - func.lower(security_manager.user_model.username) - == func.lower(config["EMAIL_REPORTS_USER"]) - ) - .one() - ) - - -def destroy_webdriver( - driver: Union[chrome.webdriver.WebDriver, firefox.webdriver.WebDriver] -) -> None: - """ - Destroy a driver - """ - - # This is some very flaky code in selenium. Hence the retries - # and catch-all exceptions - try: - retry_call(driver.close, max_tries=2) - except Exception: # pylint: disable=broad-except - pass - try: - driver.quit() - except Exception: # pylint: disable=broad-except - pass - - -def deliver_dashboard( # pylint: disable=too-many-locals - dashboard_id: int, - recipients: Optional[str], - slack_channel: Optional[str], - delivery_type: EmailDeliveryType, - deliver_as_group: bool, -) -> None: - - """ - Given a schedule, delivery the dashboard as an email report - """ - with session_scope(nullpool=True) as session: - dashboard = session.query(Dashboard).filter_by(id=dashboard_id).one() - - dashboard_url = _get_url_path( - "Superset.dashboard", dashboard_id_or_slug=dashboard.id - ) - dashboard_url_user_friendly = _get_url_path( - "Superset.dashboard", user_friendly=True, dashboard_id_or_slug=dashboard.id - ) - - # Create a driver, fetch the page, wait for the page to render - driver = create_webdriver(session) - window = config["WEBDRIVER_WINDOW"]["dashboard"] - driver.set_window_size(*window) - driver.get(dashboard_url) - time.sleep(EMAIL_PAGE_RENDER_WAIT) - - # Set up a function to retry once for the element. - # This is buggy in certain selenium versions with firefox driver - get_element = getattr(driver, "find_element_by_class_name") - element = retry_call( - get_element, - fargs=["grid-container"], - max_tries=2, - interval=EMAIL_PAGE_RENDER_WAIT, - ) - - try: - screenshot = element.screenshot_as_png - except WebDriverException: - # Some webdrivers do not support screenshots for elements. - # In such cases, take a screenshot of the entire page. - screenshot = driver.screenshot() - finally: - destroy_webdriver(driver) - - # Generate the email body and attachments - report_content = _generate_report_content( - delivery_type, - screenshot, - dashboard.dashboard_title, - dashboard_url_user_friendly, - ) - - subject = __( - "%(prefix)s %(title)s", - prefix=config["EMAIL_REPORTS_SUBJECT_PREFIX"], - title=dashboard.dashboard_title, - ) - - if recipients: - _deliver_email( - recipients, - deliver_as_group, - subject, - report_content.body, - report_content.data, - report_content.images, - ) - if slack_channel: - deliver_slack_msg( - slack_channel, - subject, - report_content.slack_message, - report_content.slack_attachment, - ) - - -def _get_slice_data( - slc: Slice, delivery_type: EmailDeliveryType, session: Session -) -> ReportContent: - slice_url = _get_url_path( - "Superset.explore_json", csv="true", form_data=json.dumps({"slice_id": slc.id}) - ) - - # URL to include in the email - slice_url_user_friendly = _get_url_path( - "Superset.slice", slice_id=slc.id, user_friendly=True - ) - - # Login on behalf of the "reports" user in order to get cookies to deal with auth - auth_cookies = machine_auth_provider_factory.instance.get_auth_cookies( - get_reports_user(session) - ) - # Build something like "session=cool_sess.val;other-cookie=awesome_other_cookie" - cookie_str = ";".join([f"{key}={val}" for key, val in auth_cookies.items()]) - - opener = urllib.request.build_opener() - opener.addheaders.append(("Cookie", cookie_str)) - response = opener.open(slice_url) - if response.getcode() != 200: - raise URLError(response.getcode()) - - # TODO: Move to the csv module - content = response.read() - rows = [r.split(b",") for r in content.splitlines()] - - if delivery_type == EmailDeliveryType.inline: - data = None - - # Parse the csv file and generate HTML - columns = rows.pop(0) - with app.app_context(): - body = render_template( - "superset/reports/slice_data.html", - columns=columns, - rows=rows, - name=slc.slice_name, - link=slice_url_user_friendly, - ) - - elif delivery_type == EmailDeliveryType.attachment: - data = {__("%(name)s.csv", name=slc.slice_name): content} - body = __( - 'Explore in Superset', - name=slc.slice_name, - url=slice_url_user_friendly, - ) - - # how to: https://api.slack.com/reference/surfaces/formatting - slack_message = __( - """ - *%(slice_name)s*\n - <%(slice_url_user_friendly)s|Explore in Superset> - """, - slice_name=slc.slice_name, - slice_url_user_friendly=slice_url_user_friendly, - ) - - return ReportContent(body, data, None, slack_message, content) - - -def _get_slice_screenshot(slice_id: int, session: Session) -> ScreenshotData: - slice_obj = session.query(Slice).get(slice_id) - - chart_url = get_url_path("Superset.slice", slice_id=slice_obj.id, standalone="true") - screenshot = ChartScreenshot(chart_url, slice_obj.digest) - image_url = _get_url_path( - "Superset.slice", user_friendly=True, slice_id=slice_obj.id, - ) - - user = security_manager.get_user_by_username( - current_app.config["THUMBNAIL_SELENIUM_USER"], session=session - ) - image_data = screenshot.compute_and_cache( - user=user, cache=thumbnail_cache, force=True, - ) - - session.commit() - return ScreenshotData(image_url, image_data) - - -def _get_slice_visualization( - slc: Slice, delivery_type: EmailDeliveryType, session: Session -) -> ReportContent: - # Create a driver, fetch the page, wait for the page to render - driver = create_webdriver(session) - window = config["WEBDRIVER_WINDOW"]["slice"] - driver.set_window_size(*window) - - slice_url = _get_url_path("Superset.slice", slice_id=slc.id) - slice_url_user_friendly = _get_url_path( - "Superset.slice", slice_id=slc.id, user_friendly=True - ) - - driver.get(slice_url) - time.sleep(EMAIL_PAGE_RENDER_WAIT) - - # Set up a function to retry once for the element. - # This is buggy in certain selenium versions with firefox driver - element = retry_call( - driver.find_element_by_class_name, - fargs=["chart-container"], - max_tries=2, - interval=EMAIL_PAGE_RENDER_WAIT, - ) - - try: - screenshot = element.screenshot_as_png - except WebDriverException: - # Some webdrivers do not support screenshots for elements. - # In such cases, take a screenshot of the entire page. - screenshot = driver.screenshot() - finally: - destroy_webdriver(driver) - - # Generate the email body and attachments - return _generate_report_content( - delivery_type, screenshot, slc.slice_name, slice_url_user_friendly - ) - - -def deliver_slice( # pylint: disable=too-many-arguments - slice_id: int, - recipients: Optional[str], - slack_channel: Optional[str], - delivery_type: EmailDeliveryType, - email_format: SliceEmailReportFormat, - deliver_as_group: bool, - session: Session, -) -> None: - """ - Given a schedule, delivery the slice as an email report - """ - slc = session.query(Slice).filter_by(id=slice_id).one() - - if email_format == SliceEmailReportFormat.data: - report_content = _get_slice_data(slc, delivery_type, session) - elif email_format == SliceEmailReportFormat.visualization: - report_content = _get_slice_visualization(slc, delivery_type, session) - else: - raise RuntimeError("Unknown email report format") - - subject = __( - "%(prefix)s %(title)s", - prefix=config["EMAIL_REPORTS_SUBJECT_PREFIX"], - title=slc.slice_name, - ) - - if recipients: - _deliver_email( - recipients, - deliver_as_group, - subject, - report_content.body, - report_content.data, - report_content.images, - ) - if slack_channel: - deliver_slack_msg( - slack_channel, - subject, - report_content.slack_message, - report_content.slack_attachment, - ) - - -@celery_app.task( - name="email_reports.send", - bind=True, - soft_time_limit=config["EMAIL_ASYNC_TIME_LIMIT_SEC"], -) -def schedule_email_report( - _task: Task, - report_type: ScheduleType, - schedule_id: int, - recipients: Optional[str] = None, - slack_channel: Optional[str] = None, -) -> None: - model_cls = get_scheduler_model(report_type) - with session_scope(nullpool=True) as session: - schedule = session.query(model_cls).get(schedule_id) - - # The user may have disabled the schedule. If so, ignore this - if not schedule or not schedule.active: - logger.info("Ignoring deactivated schedule") - return - - recipients = recipients or schedule.recipients - slack_channel = slack_channel or schedule.slack_channel - logger.info( - "Starting report for slack: %s and recipients: %s.", - slack_channel, - recipients, - ) - - if report_type == ScheduleType.dashboard: - deliver_dashboard( - schedule.dashboard_id, - recipients, - slack_channel, - schedule.delivery_type, - schedule.deliver_as_group, - ) - elif report_type == ScheduleType.slice: - deliver_slice( - schedule.slice_id, - recipients, - slack_channel, - schedule.delivery_type, - schedule.email_format, - schedule.deliver_as_group, - session, - ) - else: - raise RuntimeError("Unknown report type") - - -@celery_app.task( - name="alerts.run_query", - bind=True, - # TODO: find cause of https://github.com/apache/superset/issues/10530 - # and remove retry - autoretry_for=(NoSuchColumnError, ResourceClosedError,), - retry_kwargs={"max_retries": 1}, - retry_backoff=True, -) -def schedule_alert_query( - _task: Task, - report_type: ScheduleType, - schedule_id: int, - recipients: Optional[str] = None, - slack_channel: Optional[str] = None, -) -> None: - model_cls = get_scheduler_model(report_type) - with session_scope(nullpool=True) as session: - schedule = session.query(model_cls).get(schedule_id) - - # The user may have disabled the schedule. If so, ignore this - if not schedule or not schedule.active: - logger.info("Ignoring deactivated alert") - return - - if report_type == ScheduleType.alert: - evaluate_alert( - schedule.id, schedule.label, session, recipients, slack_channel - ) - else: - raise RuntimeError("Unknown report type") - - -class AlertState(str, Enum): - ERROR = "error" - TRIGGER = "trigger" - PASS = "pass" - - -def deliver_alert( - alert_id: int, - session: Session, - recipients: Optional[str] = None, - slack_channel: Optional[str] = None, -) -> None: - """ - Gathers alert information and sends out the alert - to its respective email and slack recipients - """ - - alert = session.query(Alert).get(alert_id) - - logging.info("Triggering alert: %s", alert) - - # Set all the values for the alert report - # Alternate values are used in the case of a test alert - # where an alert might not have a validator - recipients = recipients or alert.recipients - slack_channel = slack_channel or alert.slack_channel - validation_error_message = ( - str(alert.observations[-1].value) + " " + alert.pretty_config - ) - - if alert.slice: - alert_content = AlertContent( - alert.label, - alert.sql, - str(alert.observations[-1].value), - validation_error_message, - _get_url_path("AlertModelView.show", user_friendly=True, pk=alert_id), - _get_slice_screenshot(alert.slice.id, session), - ) - else: - # TODO: dashboard delivery! - alert_content = AlertContent( - alert.label, - alert.sql, - str(alert.observations[-1].value), - validation_error_message, - _get_url_path("AlertModelView.show", user_friendly=True, pk=alert_id), - None, - ) - - if recipients: - deliver_email_alert(alert_content, recipients) - if slack_channel: - deliver_slack_alert(alert_content, slack_channel) - - -def deliver_email_alert(alert_content: AlertContent, recipients: str) -> None: - """Delivers an email alert to the given email recipients""" - subject = f"[Superset] Triggered alert: {alert_content.label}" - deliver_as_group = False - data = None - images = {} - # TODO(JasonD28): add support for emails with no screenshot - image_url = None - if alert_content.image_data: - image_url = alert_content.image_data.url - if alert_content.image_data.image: - images = {"screenshot": alert_content.image_data.image} - - body = render_template( - "email/alert.txt", - alert_url=alert_content.alert_url, - label=alert_content.label, - sql=alert_content.sql, - observation_value=alert_content.observation_value, - validation_error_message=alert_content.validation_error_message, - image_url=image_url, - ) - - _deliver_email(recipients, deliver_as_group, subject, body, data, images) - - -def deliver_slack_alert(alert_content: AlertContent, slack_channel: str) -> None: - """Delivers a slack alert to the given slack channel""" - - subject = __("[Alert] %(label)s", label=alert_content.label) - - image = None - if alert_content.image_data: - slack_message = render_template( - "slack/alert.txt", - label=alert_content.label, - sql=alert_content.sql, - observation_value=alert_content.observation_value, - validation_error_message=alert_content.validation_error_message, - url=alert_content.image_data.url, - alert_url=alert_content.alert_url, - ) - image = alert_content.image_data.image - else: - slack_message = render_template( - "slack/alert_no_screenshot.txt", - label=alert_content.label, - sql=alert_content.sql, - observation_value=alert_content.observation_value, - validation_error_message=alert_content.validation_error_message, - alert_url=alert_content.alert_url, - ) - - deliver_slack_msg( - slack_channel, subject, slack_message, image, - ) - - -def evaluate_alert( - alert_id: int, - label: str, - session: Session, - recipients: Optional[str] = None, - slack_channel: Optional[str] = None, -) -> None: - """Processes an alert to see if it should be triggered""" - - logger.info("Processing alert ID: %i", alert_id) - - state = None - dttm_start = datetime.utcnow() - - try: - logger.info("Querying observers for alert <%s:%s>", alert_id, label) - error_msg = observe(alert_id, session) - if error_msg: - state = AlertState.ERROR - logging.error(error_msg) - except Exception as exc: # pylint: disable=broad-except - state = AlertState.ERROR - logging.exception(exc) - logging.error("Failed at query observers for alert: %s (%s)", label, alert_id) - - dttm_end = datetime.utcnow() - - if state != AlertState.ERROR: - # Don't validate alert on test runs since it may not be triggered - if recipients or slack_channel: - deliver_alert(alert_id, session, recipients, slack_channel) - state = AlertState.TRIGGER - # Validate during regular workflow and deliver only if triggered - elif validate_observations(alert_id, label, session): - deliver_alert(alert_id, session, recipients, slack_channel) - state = AlertState.TRIGGER - else: - state = AlertState.PASS - - session.commit() - alert = session.query(Alert).get(alert_id) - if state != AlertState.ERROR: - alert.last_eval_dttm = dttm_end - alert.last_state = state - alert.logs.append( - AlertLog( - scheduled_dttm=dttm_start, - dttm_start=dttm_start, - dttm_end=dttm_end, - state=state, - ) - ) - session.commit() - - -def validate_observations(alert_id: int, label: str, session: Session) -> bool: - """ - Runs an alert's validators to check if it should be triggered or not - If so, return the name of the validator that returned true - """ - - logger.info("Validating observations for alert <%s:%s>", alert_id, label) - alert = session.query(Alert).get(alert_id) - validate = get_validator_function(alert.validator_type) - return bool(validate and validate(alert, alert.validator_config)) - - -def next_schedules( - crontab: str, start_at: datetime, stop_at: datetime, resolution: int = 0 -) -> Iterator[datetime]: - crons = croniter.croniter(crontab, start_at - timedelta(seconds=1)) - previous = start_at - timedelta(days=1) - - for eta in crons.all_next(datetime): - # Do not cross the time boundary - if eta >= stop_at: - break - - if eta < start_at: - continue - - # Do not allow very frequent tasks - if eta - previous < timedelta(seconds=resolution): - continue - - yield eta - previous = eta - - -def schedule_window( - report_type: str, - start_at: datetime, - stop_at: datetime, - resolution: int, - session: Session, -) -> None: - """ - Find all active schedules and schedule celery tasks for - each of them with a specific ETA (determined by parsing - the cron schedule for the schedule) - """ - model_cls = get_scheduler_model(report_type) - - if not model_cls: - return None - - schedules = session.query(model_cls).filter(model_cls.active.is_(True)) - - for schedule in schedules: - logging.info("Processing schedule %s", schedule) - args = (report_type, schedule.id) - schedule_start_at = start_at - - if ( - hasattr(schedule, "last_eval_dttm") - and schedule.last_eval_dttm - and schedule.last_eval_dttm > start_at - ): - schedule_start_at = schedule.last_eval_dttm + timedelta(seconds=1) - - # Schedule the job for the specified time window - for eta in next_schedules( - schedule.crontab, schedule_start_at, stop_at, resolution=resolution - ): - logging.info("Scheduled eta %s", eta) - get_scheduler_action(report_type).apply_async(args, eta=eta) # type: ignore - - return None - - -def get_scheduler_action(report_type: str) -> Optional[Callable[..., Any]]: - if report_type == ScheduleType.dashboard: - return schedule_email_report - if report_type == ScheduleType.slice: - return schedule_email_report - if report_type == ScheduleType.alert: - return schedule_alert_query - return None - - -@celery_app.task(name="email_reports.schedule_hourly") -def schedule_hourly() -> None: - """Celery beat job meant to be invoked hourly""" - if not config["ENABLE_SCHEDULED_EMAIL_REPORTS"]: - logger.info("Scheduled email reports not enabled in config") - return - - resolution = config["EMAIL_REPORTS_CRON_RESOLUTION"] * 60 - - # Get the top of the hour - start_at = datetime.now(tzlocal()).replace(microsecond=0, second=0, minute=0) - stop_at = start_at + timedelta(seconds=3600) - - with session_scope(nullpool=True) as session: - schedule_window(ScheduleType.dashboard, start_at, stop_at, resolution, session) - schedule_window(ScheduleType.slice, start_at, stop_at, resolution, session) - - -@celery_app.task(name="alerts.schedule_check") -def schedule_alerts() -> None: - """Celery beat job meant to be invoked every minute to check alerts""" - resolution = 0 - now = datetime.utcnow() - start_at = now - timedelta( - seconds=300 - ) # process any missed tasks in the past few minutes - stop_at = now + timedelta(seconds=1) - with session_scope(nullpool=True) as session: - schedule_window(ScheduleType.alert, start_at, stop_at, resolution, session) diff --git a/superset/views/__init__.py b/superset/views/__init__.py index c3a349ce495c6..c33601f7278d6 100644 --- a/superset/views/__init__.py +++ b/superset/views/__init__.py @@ -27,7 +27,6 @@ dynamic_plugins, health, redirects, - schedules, sql_lab, tags, ) diff --git a/superset/views/alerts.py b/superset/views/alerts.py index 04640fa223fe8..b97587ec71855 100644 --- a/superset/views/alerts.py +++ b/superset/views/alerts.py @@ -14,76 +14,19 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -""" -DEPRECATION NOTICE: this module is deprecated and will be removed on 2.0. -""" -from croniter import croniter -from flask import abort, current_app as app, flash, Markup -from flask_appbuilder import CompactCRUDMixin, permission_name +from flask import abort +from flask_appbuilder import permission_name from flask_appbuilder.api import expose -from flask_appbuilder.hooks import before_request -from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_appbuilder.security.decorators import has_access -from flask_babel import lazy_gettext as _ -from werkzeug.exceptions import NotFound from superset import is_feature_enabled -from superset.constants import RouteMethod -from superset.models.alerts import Alert, AlertLog, SQLObservation from superset.superset_typing import FlaskResponse -from superset.tasks.alerts.validator import check_validator -from superset.utils import core as utils -from superset.utils.core import get_email_address_str, markdown -from ..exceptions import SupersetException -from .base import BaseSupersetView, SupersetModelView +from .base import BaseSupersetView # TODO: access control rules for this module -class EnsureEnabledMixin: - @staticmethod - def is_enabled() -> bool: - return bool(app.config["ENABLE_ALERTS"]) - - @before_request - def ensure_enabled(self) -> None: - if not self.is_enabled(): - raise NotFound() - - -class AlertLogModelView( - CompactCRUDMixin, EnsureEnabledMixin, SupersetModelView -): # pylint: disable=too-many-ancestors - datamodel = SQLAInterface(AlertLog) - include_route_methods = {RouteMethod.LIST} | {"show"} - base_order = ("dttm_start", "desc") - list_columns = ( - "scheduled_dttm", - "dttm_start", - "duration", - "state", - ) - - -class AlertObservationModelView( - CompactCRUDMixin, EnsureEnabledMixin, SupersetModelView -): # pylint: disable=too-many-ancestors - datamodel = SQLAInterface(SQLObservation) - include_route_methods = {RouteMethod.LIST} | {"show"} - base_order = ("dttm", "desc") - list_title = _("List Observations") - show_title = _("Show Observation") - list_columns = ( - "dttm", - "value", - "error_msg", - ) - label_columns = { - "error_msg": _("Error Message"), - } - - class BaseAlertReportView(BaseSupersetView): route_base = "/report" class_permission_name = "ReportSchedule" @@ -109,144 +52,3 @@ def log(self, pk: int) -> FlaskResponse: # pylint: disable=unused-argument class AlertView(BaseAlertReportView): route_base = "/alert" class_permission_name = "ReportSchedule" - - -class ReportView(BaseAlertReportView): - route_base = "/report" - class_permission_name = "ReportSchedule" - - -class AlertModelView(EnsureEnabledMixin, SupersetModelView): - datamodel = SQLAInterface(Alert) - route_base = "/alerts" - include_route_methods = RouteMethod.CRUD_SET | {"log"} - - list_columns = ( - "label", - "owners", - "database", - "sql", - "pretty_config", - "crontab", - "last_eval_dttm", - "last_state", - "active", - "owners", - ) - show_columns = ( - "label", - "database", - "sql", - "validator_type", - "validator_config", - "active", - "crontab", - "owners", - "slice", - "recipients", - "slack_channel", - "log_retention", - "grace_period", - "last_eval_dttm", - "last_state", - ) - order_columns = ["label", "last_eval_dttm", "last_state", "active"] - add_columns = ( - "label", - "database", - "sql", - "validator_type", - "validator_config", - "active", - "crontab", - # TODO: implement different types of alerts - # "alert_type", - "owners", - "recipients", - "slack_channel", - "slice", - # TODO: implement dashboard screenshots with alerts - # "dashboard", - "log_retention", - "grace_period", - ) - label_columns = { - "log_retention": _("Log Retentions (days)"), - } - description_columns = { - "crontab": markdown( - "A CRON-like expression. " - "[Crontab Guru](https://crontab.guru/) is " - "a helpful resource that can help you craft a CRON expression.", - True, - ), - "recipients": _("A semicolon ';' delimited list of email addresses"), - "log_retention": _("How long to keep the logs around for this alert"), - "grace_period": _( - "Once an alert is triggered, how long, in seconds, before " - "Superset nags you again." - ), - "sql": _( - "A SQL statement that defines whether the alert should get triggered or " - "not. The query is expected to return either NULL or a number value." - ), - "validator_type": utils.markdown( - "Determines when to trigger alert based off value from alert query. " - "Alerts will be triggered with these validator types:" - "