mirror of
https://github.com/apache/superset.git
synced 2026-04-10 11:55:24 +00:00
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Vitor Avila <vitorfragadeavila@gmail.com>
1078 lines
41 KiB
Python
1078 lines
41 KiB
Python
# 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, timedelta
|
|
from typing import Any, Optional, Union
|
|
from uuid import UUID
|
|
|
|
import pandas as pd
|
|
from celery.exceptions import SoftTimeLimitExceeded
|
|
from flask import current_app as app
|
|
|
|
from superset import db, security_manager
|
|
from superset.commands.base import BaseCommand
|
|
from superset.commands.dashboard.permalink.create import CreateDashboardPermalinkCommand
|
|
from superset.commands.exceptions import CommandException, UpdateFailedError
|
|
from superset.commands.report.alert import AlertCommand
|
|
from superset.commands.report.exceptions import (
|
|
ReportScheduleAlertGracePeriodError,
|
|
ReportScheduleClientErrorsException,
|
|
ReportScheduleCsvFailedError,
|
|
ReportScheduleCsvTimeout,
|
|
ReportScheduleDataFrameFailedError,
|
|
ReportScheduleDataFrameTimeout,
|
|
ReportScheduleExecuteUnexpectedError,
|
|
ReportScheduleNotFoundError,
|
|
ReportSchedulePreviousWorkingError,
|
|
ReportScheduleScreenshotFailedError,
|
|
ReportScheduleScreenshotTimeout,
|
|
ReportScheduleStateNotFoundError,
|
|
ReportScheduleSystemErrorsException,
|
|
ReportScheduleUnexpectedError,
|
|
ReportScheduleWorkingTimeoutError,
|
|
)
|
|
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
|
|
from superset.daos.report import (
|
|
REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER,
|
|
ReportScheduleDAO,
|
|
)
|
|
from superset.dashboards.permalink.types import DashboardPermalinkState
|
|
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
|
from superset.exceptions import SupersetErrorsException, SupersetException
|
|
from superset.extensions import feature_flag_manager, machine_auth_provider_factory
|
|
from superset.reports.models import (
|
|
ReportDataFormat,
|
|
ReportExecutionLog,
|
|
ReportRecipients,
|
|
ReportRecipientType,
|
|
ReportSchedule,
|
|
ReportScheduleType,
|
|
ReportSourceFormat,
|
|
ReportState,
|
|
)
|
|
from superset.reports.notifications import create_notification
|
|
from superset.reports.notifications.base import NotificationContent
|
|
from superset.reports.notifications.exceptions import (
|
|
NotificationError,
|
|
NotificationParamException,
|
|
SlackV1NotificationError,
|
|
)
|
|
from superset.tasks.utils import get_executor
|
|
from superset.utils import json
|
|
from superset.utils.core import HeaderDataType, override_user, recipients_string_to_list
|
|
from superset.utils.csv import get_chart_csv_data, get_chart_dataframe
|
|
from superset.utils.decorators import logs_context, transaction
|
|
from superset.utils.pdf import build_pdf_from_screenshots
|
|
from superset.utils.screenshots import ChartScreenshot, DashboardScreenshot
|
|
from superset.utils.slack import get_channels_with_search, SlackChannelTypes
|
|
from superset.utils.urls import get_url_path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BaseReportState:
|
|
current_states: list[ReportState] = []
|
|
initial: bool = False
|
|
|
|
@logs_context()
|
|
def __init__(
|
|
self,
|
|
report_schedule: ReportSchedule,
|
|
scheduled_dttm: datetime,
|
|
execution_id: UUID,
|
|
) -> None:
|
|
self._report_schedule = report_schedule
|
|
self._scheduled_dttm = scheduled_dttm
|
|
self._start_dttm = datetime.utcnow()
|
|
self._execution_id = execution_id
|
|
|
|
def update_report_schedule_and_log(
|
|
self,
|
|
state: ReportState,
|
|
error_message: Optional[str] = None,
|
|
) -> None:
|
|
"""
|
|
Update the report schedule state et al. and reflect the change in the execution
|
|
log.
|
|
"""
|
|
self.update_report_schedule(state)
|
|
self.create_log(error_message)
|
|
|
|
def update_report_schedule(self, state: ReportState) -> None:
|
|
"""
|
|
Update the report schedule state et al.
|
|
|
|
When the report state is WORKING we must ensure that the values from the last
|
|
execution run are cleared to ensure that they are not propagated to the
|
|
execution log.
|
|
"""
|
|
|
|
if state == ReportState.WORKING:
|
|
self._report_schedule.last_value = None
|
|
self._report_schedule.last_value_row_json = None
|
|
|
|
self._report_schedule.last_state = state
|
|
self._report_schedule.last_eval_dttm = datetime.utcnow()
|
|
|
|
def update_report_schedule_slack_v2(self) -> None:
|
|
"""
|
|
Update the report schedule type and channels for all slack recipients to v2.
|
|
V2 uses ids instead of names for channels.
|
|
"""
|
|
try:
|
|
for recipient in self._report_schedule.recipients:
|
|
if recipient.type == ReportRecipientType.SLACK:
|
|
recipient.type = ReportRecipientType.SLACKV2
|
|
slack_recipients = json.loads(recipient.recipient_config_json)
|
|
# V1 method allowed to use leading `#` in the channel name
|
|
channel_names = (slack_recipients["target"] or "").replace("#", "")
|
|
# we need to ensure that existing reports can also fetch
|
|
# ids from private channels
|
|
channels = get_channels_with_search(
|
|
search_string=channel_names,
|
|
types=[
|
|
SlackChannelTypes.PRIVATE,
|
|
SlackChannelTypes.PUBLIC,
|
|
],
|
|
exact_match=True,
|
|
)
|
|
channels_list = recipients_string_to_list(channel_names)
|
|
if len(channels_list) != len(channels):
|
|
missing_channels = set(channels_list) - {
|
|
channel["name"] for channel in channels
|
|
}
|
|
msg = (
|
|
"Could not find the following channels: "
|
|
f"{', '.join(missing_channels)}"
|
|
)
|
|
raise UpdateFailedError(msg)
|
|
channel_ids = ",".join(channel["id"] for channel in channels)
|
|
recipient.recipient_config_json = json.dumps(
|
|
{
|
|
"target": channel_ids,
|
|
}
|
|
)
|
|
except Exception as ex:
|
|
# Revert to v1 to preserve configuration (requires manual fix)
|
|
recipient.type = ReportRecipientType.SLACK
|
|
msg = f"Failed to update slack recipients to v2: {str(ex)}"
|
|
logger.exception(msg)
|
|
raise UpdateFailedError(msg) from ex
|
|
|
|
def create_log(self, error_message: Optional[str] = None) -> None:
|
|
"""
|
|
Creates a Report execution log, uses the current computed last_value for Alerts
|
|
"""
|
|
from sqlalchemy.orm.exc import StaleDataError
|
|
|
|
try:
|
|
log = ReportExecutionLog(
|
|
scheduled_dttm=self._scheduled_dttm,
|
|
start_dttm=self._start_dttm,
|
|
end_dttm=datetime.utcnow(),
|
|
value=self._report_schedule.last_value,
|
|
value_row_json=self._report_schedule.last_value_row_json,
|
|
state=self._report_schedule.last_state,
|
|
error_message=error_message,
|
|
report_schedule=self._report_schedule,
|
|
uuid=self._execution_id,
|
|
)
|
|
db.session.add(log)
|
|
db.session.commit() # pylint: disable=consider-using-transaction
|
|
except StaleDataError as ex:
|
|
# Report schedule was modified or deleted by another process
|
|
db.session.rollback()
|
|
logger.warning(
|
|
"Report schedule (execution %s) was modified or deleted "
|
|
"during execution. This can occur when a report is deleted "
|
|
"while running.",
|
|
self._execution_id,
|
|
)
|
|
raise ReportScheduleUnexpectedError(
|
|
"Report schedule was modified or deleted by another process "
|
|
"during execution"
|
|
) from ex
|
|
|
|
def _get_url(
|
|
self,
|
|
user_friendly: bool = False,
|
|
result_format: Optional[ChartDataResultFormat] = None,
|
|
**kwargs: Any,
|
|
) -> str:
|
|
"""
|
|
Get the url for this report schedule: chart or dashboard
|
|
"""
|
|
force = "true" if self._report_schedule.force_screenshot else "false"
|
|
if self._report_schedule.chart:
|
|
if result_format in {
|
|
ChartDataResultFormat.CSV,
|
|
ChartDataResultFormat.JSON,
|
|
}:
|
|
return get_url_path(
|
|
"ChartDataRestApi.get_data",
|
|
pk=self._report_schedule.chart_id,
|
|
format=result_format.value,
|
|
type=ChartDataResultType.POST_PROCESSED.value,
|
|
force=force,
|
|
)
|
|
return get_url_path(
|
|
"ExploreView.root",
|
|
user_friendly=user_friendly,
|
|
form_data=json.dumps({"slice_id": self._report_schedule.chart_id}),
|
|
force=force,
|
|
**kwargs,
|
|
)
|
|
# If we need to render dashboard in a specific state, use stateful permalink
|
|
if (
|
|
dashboard_state := self._report_schedule.extra.get("dashboard")
|
|
) and feature_flag_manager.is_feature_enabled("ALERT_REPORT_TABS"):
|
|
return self._get_tab_url(dashboard_state, user_friendly=user_friendly)
|
|
|
|
dashboard = self._report_schedule.dashboard
|
|
dashboard_id_or_slug = (
|
|
dashboard.uuid if dashboard and dashboard.uuid else dashboard.id
|
|
)
|
|
return get_url_path(
|
|
"Superset.dashboard",
|
|
user_friendly=user_friendly,
|
|
dashboard_id_or_slug=dashboard_id_or_slug,
|
|
force=force,
|
|
**kwargs,
|
|
)
|
|
|
|
def get_dashboard_urls(
|
|
self, user_friendly: bool = False, **kwargs: Any
|
|
) -> list[str]:
|
|
"""
|
|
Retrieve the URL for the dashboard tabs, or return the dashboard URL if no tabs are available.
|
|
""" # noqa: E501
|
|
force = "true" if self._report_schedule.force_screenshot else "false"
|
|
|
|
if (
|
|
dashboard_state := self._report_schedule.extra.get("dashboard")
|
|
) and feature_flag_manager.is_feature_enabled("ALERT_REPORT_TABS"):
|
|
native_filter_params = self._report_schedule.get_native_filters_params()
|
|
if anchor := dashboard_state.get("anchor"):
|
|
try:
|
|
anchor_list: list[str] = json.loads(anchor)
|
|
urls = self._get_tabs_urls(
|
|
anchor_list,
|
|
native_filter_params=native_filter_params,
|
|
user_friendly=user_friendly,
|
|
)
|
|
return urls
|
|
except json.JSONDecodeError:
|
|
logger.debug("Anchor value is not a list, Fall back to single tab")
|
|
|
|
return [
|
|
self._get_tab_url(
|
|
{
|
|
"urlParams": [
|
|
["native_filters", native_filter_params] # type: ignore
|
|
],
|
|
**dashboard_state,
|
|
},
|
|
user_friendly=user_friendly,
|
|
)
|
|
]
|
|
|
|
dashboard = self._report_schedule.dashboard
|
|
dashboard_id_or_slug = (
|
|
dashboard.uuid if dashboard and dashboard.uuid else dashboard.id
|
|
)
|
|
|
|
return [
|
|
get_url_path(
|
|
"Superset.dashboard",
|
|
user_friendly=user_friendly,
|
|
dashboard_id_or_slug=dashboard_id_or_slug,
|
|
force=force,
|
|
**kwargs,
|
|
)
|
|
]
|
|
|
|
def _get_tab_url(
|
|
self, dashboard_state: DashboardPermalinkState, user_friendly: bool = False
|
|
) -> str:
|
|
"""
|
|
Get one tab url
|
|
"""
|
|
permalink_key = CreateDashboardPermalinkCommand(
|
|
dashboard_id=str(self._report_schedule.dashboard.uuid),
|
|
state=dashboard_state,
|
|
).run()
|
|
|
|
return get_url_path(
|
|
"Superset.dashboard_permalink",
|
|
key=permalink_key,
|
|
user_friendly=user_friendly,
|
|
)
|
|
|
|
def _get_tabs_urls(
|
|
self,
|
|
tab_anchors: list[str],
|
|
native_filter_params: Optional[str] = None,
|
|
user_friendly: bool = False,
|
|
) -> list[str]:
|
|
"""
|
|
Get multple tabs urls
|
|
"""
|
|
return [
|
|
self._get_tab_url(
|
|
{
|
|
"anchor": tab_anchor,
|
|
"dataMask": None,
|
|
"activeTabs": None,
|
|
"urlParams": [
|
|
["native_filters", native_filter_params] # type: ignore
|
|
],
|
|
},
|
|
user_friendly=user_friendly,
|
|
)
|
|
for tab_anchor in tab_anchors
|
|
]
|
|
|
|
def _get_screenshots(self) -> list[bytes]:
|
|
"""
|
|
Get chart or dashboard screenshots
|
|
:raises: ReportScheduleScreenshotFailedError
|
|
"""
|
|
start_time = datetime.utcnow()
|
|
|
|
_, username = get_executor(
|
|
executors=app.config["ALERT_REPORTS_EXECUTORS"],
|
|
model=self._report_schedule,
|
|
)
|
|
user = security_manager.find_user(username)
|
|
|
|
max_width = app.config["ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH"]
|
|
|
|
if self._report_schedule.chart:
|
|
url = self._get_url()
|
|
|
|
window_width, window_height = app.config["WEBDRIVER_WINDOW"]["slice"]
|
|
width = min(max_width, self._report_schedule.custom_width or window_width)
|
|
height = self._report_schedule.custom_height or window_height
|
|
window_size = (width, height)
|
|
|
|
screenshots: list[Union[ChartScreenshot, DashboardScreenshot]] = [
|
|
ChartScreenshot(
|
|
url,
|
|
self._report_schedule.chart.digest,
|
|
window_size=window_size,
|
|
thumb_size=app.config["WEBDRIVER_WINDOW"]["slice"],
|
|
)
|
|
]
|
|
else:
|
|
urls = self.get_dashboard_urls()
|
|
window_width, window_height = app.config["WEBDRIVER_WINDOW"]["dashboard"]
|
|
width = min(max_width, self._report_schedule.custom_width or window_width)
|
|
height = self._report_schedule.custom_height or window_height
|
|
window_size = (width, height)
|
|
|
|
screenshots = [
|
|
DashboardScreenshot(
|
|
url,
|
|
self._report_schedule.dashboard.digest,
|
|
window_size=window_size,
|
|
thumb_size=app.config["WEBDRIVER_WINDOW"]["dashboard"],
|
|
)
|
|
for url in urls
|
|
]
|
|
try:
|
|
imges = []
|
|
for screenshot in screenshots:
|
|
if imge := screenshot.get_screenshot(user=user):
|
|
imges.append(imge)
|
|
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
|
logger.info(
|
|
"Screenshot capture took %.2fs - execution_id: %s",
|
|
elapsed_seconds,
|
|
self._execution_id,
|
|
)
|
|
except SoftTimeLimitExceeded as ex:
|
|
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
|
logger.warning(
|
|
"Screenshot timeout after %.2fs - execution_id: %s",
|
|
elapsed_seconds,
|
|
self._execution_id,
|
|
)
|
|
raise ReportScheduleScreenshotTimeout() from ex
|
|
except Exception as ex:
|
|
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
|
logger.error(
|
|
"Screenshot failed after %.2fs - execution_id: %s",
|
|
elapsed_seconds,
|
|
self._execution_id,
|
|
)
|
|
raise ReportScheduleScreenshotFailedError(
|
|
f"Failed taking a screenshot {str(ex)}"
|
|
) from ex
|
|
if not imges:
|
|
raise ReportScheduleScreenshotFailedError()
|
|
return imges
|
|
|
|
def _get_pdf(self) -> bytes:
|
|
"""
|
|
Get chart or dashboard pdf
|
|
:raises: ReportSchedulePdfFailedError
|
|
"""
|
|
screenshots = self._get_screenshots()
|
|
pdf = build_pdf_from_screenshots(screenshots)
|
|
|
|
return pdf
|
|
|
|
def _get_csv_data(self) -> bytes:
|
|
start_time = datetime.utcnow()
|
|
url = self._get_url(result_format=ChartDataResultFormat.CSV)
|
|
_, username = get_executor(
|
|
executors=app.config["ALERT_REPORTS_EXECUTORS"],
|
|
model=self._report_schedule,
|
|
)
|
|
user = security_manager.find_user(username)
|
|
auth_cookies = machine_auth_provider_factory.instance.get_auth_cookies(user)
|
|
|
|
if self._report_schedule.chart.query_context is None:
|
|
logger.warning("No query context found, taking a screenshot to generate it")
|
|
self._update_query_context()
|
|
|
|
try:
|
|
csv_data = get_chart_csv_data(chart_url=url, auth_cookies=auth_cookies)
|
|
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
|
logger.info(
|
|
"CSV data generation from %s as user %s took %.2fs - execution_id: %s",
|
|
url,
|
|
username,
|
|
elapsed_seconds,
|
|
self._execution_id,
|
|
)
|
|
except SoftTimeLimitExceeded as ex:
|
|
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
|
logger.warning(
|
|
"CSV generation timeout after %.2fs - execution_id: %s",
|
|
elapsed_seconds,
|
|
self._execution_id,
|
|
)
|
|
raise ReportScheduleCsvTimeout() from ex
|
|
except Exception as ex:
|
|
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
|
logger.error(
|
|
"CSV generation failed after %.2fs - execution_id: %s",
|
|
elapsed_seconds,
|
|
self._execution_id,
|
|
)
|
|
raise ReportScheduleCsvFailedError(
|
|
f"Failed generating csv {str(ex)}"
|
|
) from ex
|
|
if not csv_data:
|
|
raise ReportScheduleCsvFailedError()
|
|
return csv_data
|
|
|
|
def _get_embedded_data(self) -> pd.DataFrame:
|
|
"""
|
|
Return data as a Pandas dataframe, to embed in notifications as a table.
|
|
"""
|
|
start_time = datetime.utcnow()
|
|
|
|
url = self._get_url(result_format=ChartDataResultFormat.JSON)
|
|
_, username = get_executor(
|
|
executors=app.config["ALERT_REPORTS_EXECUTORS"],
|
|
model=self._report_schedule,
|
|
)
|
|
user = security_manager.find_user(username)
|
|
auth_cookies = machine_auth_provider_factory.instance.get_auth_cookies(user)
|
|
|
|
if self._report_schedule.chart.query_context is None:
|
|
logger.warning("No query context found, taking a screenshot to generate it")
|
|
self._update_query_context()
|
|
|
|
try:
|
|
dataframe = get_chart_dataframe(url, auth_cookies)
|
|
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
|
logger.info(
|
|
"DataFrame generation from %s as user %s took %.2fs - execution_id: %s",
|
|
url,
|
|
username,
|
|
elapsed_seconds,
|
|
self._execution_id,
|
|
)
|
|
except SoftTimeLimitExceeded as ex:
|
|
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
|
logger.warning(
|
|
"DataFrame generation timeout after %.2fs - execution_id: %s",
|
|
elapsed_seconds,
|
|
self._execution_id,
|
|
)
|
|
raise ReportScheduleDataFrameTimeout() from ex
|
|
except Exception as ex:
|
|
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
|
logger.error(
|
|
"DataFrame generation failed after %.2fs - execution_id: %s",
|
|
elapsed_seconds,
|
|
self._execution_id,
|
|
)
|
|
raise ReportScheduleDataFrameFailedError(
|
|
f"Failed generating dataframe {str(ex)}"
|
|
) from ex
|
|
if dataframe is None:
|
|
raise ReportScheduleCsvFailedError()
|
|
return dataframe
|
|
|
|
def _update_query_context(self) -> None:
|
|
"""
|
|
Update chart query context.
|
|
|
|
To load CSV data from the endpoint the chart must have been saved
|
|
with its query context. For charts without saved query context we
|
|
get a screenshot to force the chart to produce and save the query
|
|
context.
|
|
"""
|
|
try:
|
|
self._get_screenshots()
|
|
except (
|
|
ReportScheduleScreenshotFailedError,
|
|
ReportScheduleScreenshotTimeout,
|
|
) as ex:
|
|
raise ReportScheduleCsvFailedError(
|
|
"Unable to fetch data because the chart has no query context "
|
|
"saved, and an error occurred when fetching it via a screenshot. "
|
|
"Please try loading the chart and saving it again."
|
|
) from ex
|
|
|
|
def _get_log_data(self) -> HeaderDataType:
|
|
chart_id = None
|
|
dashboard_id = None
|
|
report_source = None
|
|
slack_channels = None
|
|
if self._report_schedule.chart:
|
|
report_source = ReportSourceFormat.CHART
|
|
chart_id = self._report_schedule.chart_id
|
|
else:
|
|
report_source = ReportSourceFormat.DASHBOARD
|
|
dashboard_id = self._report_schedule.dashboard_id
|
|
|
|
if self._report_schedule.recipients:
|
|
slack_channels = [
|
|
recipient.recipient_config_json
|
|
for recipient in self._report_schedule.recipients
|
|
if recipient.type
|
|
in [ReportRecipientType.SLACK, ReportRecipientType.SLACKV2]
|
|
]
|
|
|
|
log_data: HeaderDataType = {
|
|
"notification_type": self._report_schedule.type,
|
|
"notification_source": report_source,
|
|
"notification_format": self._report_schedule.report_format,
|
|
"chart_id": chart_id,
|
|
"dashboard_id": dashboard_id,
|
|
"owners": self._report_schedule.owners,
|
|
"slack_channels": slack_channels,
|
|
"execution_id": str(self._execution_id),
|
|
}
|
|
return log_data
|
|
|
|
def _get_notification_content(self) -> NotificationContent: # noqa: C901
|
|
"""
|
|
Gets a notification content, this is composed by a title and a screenshot
|
|
|
|
:raises: ReportScheduleScreenshotFailedError
|
|
"""
|
|
csv_data = None
|
|
screenshot_data = []
|
|
pdf_data = None
|
|
embedded_data = None
|
|
error_text = None
|
|
header_data = self._get_log_data()
|
|
url = self._get_url(user_friendly=True)
|
|
|
|
if (
|
|
feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
|
|
or self._report_schedule.type == ReportScheduleType.REPORT
|
|
):
|
|
if self._report_schedule.report_format == ReportDataFormat.PNG:
|
|
screenshot_data = self._get_screenshots()
|
|
if not screenshot_data:
|
|
error_text = "Unexpected missing screenshot"
|
|
elif self._report_schedule.report_format == ReportDataFormat.PDF:
|
|
pdf_data = self._get_pdf()
|
|
if not pdf_data:
|
|
error_text = "Unexpected missing pdf"
|
|
elif (
|
|
self._report_schedule.chart
|
|
and self._report_schedule.report_format == ReportDataFormat.CSV
|
|
):
|
|
csv_data = self._get_csv_data()
|
|
if not csv_data:
|
|
error_text = "Unexpected missing csv file"
|
|
if error_text:
|
|
return NotificationContent(
|
|
name=self._report_schedule.name,
|
|
text=error_text,
|
|
header_data=header_data,
|
|
url=url,
|
|
)
|
|
|
|
if (
|
|
self._report_schedule.chart
|
|
and self._report_schedule.report_format == ReportDataFormat.TEXT
|
|
):
|
|
embedded_data = self._get_embedded_data()
|
|
|
|
if self._report_schedule.email_subject:
|
|
name = self._report_schedule.email_subject
|
|
else:
|
|
if self._report_schedule.chart:
|
|
name = (
|
|
f"{self._report_schedule.name}: "
|
|
f"{self._report_schedule.chart.slice_name}"
|
|
)
|
|
else:
|
|
name = (
|
|
f"{self._report_schedule.name}: "
|
|
f"{self._report_schedule.dashboard.dashboard_title}"
|
|
)
|
|
|
|
return NotificationContent(
|
|
name=name,
|
|
url=url,
|
|
screenshots=screenshot_data,
|
|
pdf=pdf_data,
|
|
description=self._report_schedule.description,
|
|
csv=csv_data,
|
|
embedded_data=embedded_data,
|
|
header_data=header_data,
|
|
)
|
|
|
|
def _send(
|
|
self,
|
|
notification_content: NotificationContent,
|
|
recipients: list[ReportRecipients],
|
|
) -> None:
|
|
"""
|
|
Sends a notification to all recipients
|
|
|
|
:raises: CommandException
|
|
"""
|
|
notification_errors: list[SupersetError] = []
|
|
for recipient in recipients:
|
|
notification = create_notification(recipient, notification_content)
|
|
try:
|
|
try:
|
|
if app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"]:
|
|
logger.info(
|
|
"Would send notification for alert %s, to %s. "
|
|
"ALERT_REPORTS_NOTIFICATION_DRY_RUN is enabled, "
|
|
"set it to False to send notifications.",
|
|
self._report_schedule.name,
|
|
recipient.recipient_config_json,
|
|
)
|
|
else:
|
|
notification.send()
|
|
except SlackV1NotificationError as ex:
|
|
# The slack notification should be sent with the v2 api
|
|
logger.info(
|
|
"Attempting to upgrade the report to Slackv2: %s", str(ex)
|
|
)
|
|
self.update_report_schedule_slack_v2()
|
|
recipient.type = ReportRecipientType.SLACKV2
|
|
notification = create_notification(recipient, notification_content)
|
|
notification.send()
|
|
except (
|
|
UpdateFailedError,
|
|
NotificationParamException,
|
|
NotificationError,
|
|
SupersetException,
|
|
) as ex:
|
|
# collect errors but keep processing them
|
|
notification_errors.append(
|
|
SupersetError(
|
|
message=ex.message,
|
|
error_type=SupersetErrorType.REPORT_NOTIFICATION_ERROR,
|
|
level=(
|
|
ErrorLevel.ERROR if ex.status >= 500 else ErrorLevel.WARNING
|
|
),
|
|
)
|
|
)
|
|
if notification_errors:
|
|
# log all errors but raise based on the most severe
|
|
for error in notification_errors:
|
|
logger.warning(str(error))
|
|
|
|
if any(error.level == ErrorLevel.ERROR for error in notification_errors):
|
|
raise ReportScheduleSystemErrorsException(errors=notification_errors)
|
|
if any(error.level == ErrorLevel.WARNING for error in notification_errors):
|
|
raise ReportScheduleClientErrorsException(errors=notification_errors)
|
|
|
|
def send(self) -> None:
|
|
"""
|
|
Creates the notification content and sends them to all recipients
|
|
|
|
:raises: CommandException
|
|
"""
|
|
notification_content = self._get_notification_content()
|
|
self._send(notification_content, self._report_schedule.recipients)
|
|
|
|
def send_error(self, name: str, message: str) -> None:
|
|
"""
|
|
Creates and sends a notification for an error, to all recipients
|
|
|
|
:raises: CommandException
|
|
"""
|
|
header_data = self._get_log_data()
|
|
url = self._get_url(user_friendly=True)
|
|
logger.info(
|
|
"header_data in notifications for alerts and reports %s, taskid, %s",
|
|
header_data,
|
|
self._execution_id,
|
|
)
|
|
notification_content = NotificationContent(
|
|
name=name, text=message, header_data=header_data, url=url
|
|
)
|
|
|
|
# filter recipients to recipients who are also owners
|
|
owner_recipients = [
|
|
ReportRecipients(
|
|
type=ReportRecipientType.EMAIL,
|
|
recipient_config_json=json.dumps({"target": owner.email}),
|
|
)
|
|
for owner in self._report_schedule.owners
|
|
]
|
|
|
|
self._send(notification_content, owner_recipients)
|
|
|
|
def is_in_grace_period(self) -> bool:
|
|
"""
|
|
Checks if an alert is in it's grace period
|
|
"""
|
|
last_success = ReportScheduleDAO.find_last_success_log(self._report_schedule)
|
|
return (
|
|
last_success is not None
|
|
and self._report_schedule.grace_period
|
|
and datetime.utcnow()
|
|
- timedelta(seconds=self._report_schedule.grace_period)
|
|
< last_success.end_dttm
|
|
)
|
|
|
|
def is_in_error_grace_period(self) -> bool:
|
|
"""
|
|
Checks if an alert/report on error is in it's notification grace period
|
|
"""
|
|
last_success = ReportScheduleDAO.find_last_error_notification(
|
|
self._report_schedule
|
|
)
|
|
if not last_success:
|
|
return False
|
|
return (
|
|
last_success is not None
|
|
and self._report_schedule.grace_period
|
|
and datetime.utcnow()
|
|
- timedelta(seconds=self._report_schedule.grace_period)
|
|
< last_success.end_dttm
|
|
)
|
|
|
|
def is_on_working_timeout(self) -> bool:
|
|
"""
|
|
Checks if an alert is in a working timeout
|
|
"""
|
|
last_working = ReportScheduleDAO.find_last_entered_working_log(
|
|
self._report_schedule
|
|
)
|
|
if not last_working:
|
|
return False
|
|
return (
|
|
self._report_schedule.working_timeout is not None
|
|
and self._report_schedule.last_eval_dttm is not None
|
|
and datetime.utcnow()
|
|
- timedelta(seconds=self._report_schedule.working_timeout)
|
|
> last_working.end_dttm
|
|
)
|
|
|
|
def next(self) -> None:
|
|
raise NotImplementedError()
|
|
|
|
|
|
class ReportNotTriggeredErrorState(BaseReportState):
|
|
"""
|
|
Handle Not triggered and Error state
|
|
next final states:
|
|
- Not Triggered
|
|
- Success
|
|
- Error
|
|
"""
|
|
|
|
current_states = [ReportState.NOOP, ReportState.ERROR]
|
|
initial = True
|
|
|
|
def next(self) -> None: # noqa: C901
|
|
self.update_report_schedule_and_log(ReportState.WORKING)
|
|
try:
|
|
# If it's an alert check if the alert is triggered
|
|
if self._report_schedule.type == ReportScheduleType.ALERT:
|
|
if not AlertCommand(self._report_schedule, self._execution_id).run():
|
|
self.update_report_schedule_and_log(ReportState.NOOP)
|
|
return
|
|
self.send()
|
|
self.update_report_schedule_and_log(ReportState.SUCCESS)
|
|
except (SupersetErrorsException, Exception) as first_ex:
|
|
error_message = str(first_ex)
|
|
if isinstance(first_ex, SupersetErrorsException):
|
|
error_message = ";".join([error.message for error in first_ex.errors])
|
|
|
|
try:
|
|
self.update_report_schedule_and_log(
|
|
ReportState.ERROR, error_message=error_message
|
|
)
|
|
except ReportScheduleUnexpectedError as logging_ex:
|
|
# Logging failed (likely StaleDataError), but we still want to
|
|
# raise the original error so the root cause remains visible
|
|
logger.warning(
|
|
"Failed to log error for report schedule (execution %s) "
|
|
"due to database issue",
|
|
self._execution_id,
|
|
exc_info=True,
|
|
)
|
|
# Re-raise the original exception, not the logging failure
|
|
raise first_ex from logging_ex
|
|
|
|
# TODO (dpgaspar) convert this logic to a new state eg: ERROR_ON_GRACE
|
|
if not self.is_in_error_grace_period():
|
|
second_error_message = REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER
|
|
try:
|
|
self.send_error(
|
|
f"Error occurred for {self._report_schedule.type}:"
|
|
f" {self._report_schedule.name}",
|
|
str(first_ex),
|
|
)
|
|
|
|
except SupersetErrorsException as second_ex:
|
|
second_error_message = ";".join(
|
|
[error.message for error in second_ex.errors]
|
|
)
|
|
except ReportScheduleUnexpectedError:
|
|
# send_error failed due to logging issue, log and continue
|
|
# to raise the original error
|
|
logger.warning(
|
|
"Failed to send error notification due to database issue",
|
|
exc_info=True,
|
|
)
|
|
except Exception as second_ex: # pylint: disable=broad-except
|
|
second_error_message = str(second_ex)
|
|
finally:
|
|
try:
|
|
self.update_report_schedule_and_log(
|
|
ReportState.ERROR, error_message=second_error_message
|
|
)
|
|
except ReportScheduleUnexpectedError:
|
|
# Logging failed again, log it but don't let it hide first_ex
|
|
logger.warning(
|
|
"Failed to log final error state due to database issue",
|
|
exc_info=True,
|
|
)
|
|
raise
|
|
|
|
|
|
class ReportWorkingState(BaseReportState):
|
|
"""
|
|
Handle Working state
|
|
next states:
|
|
- Error
|
|
- Working
|
|
"""
|
|
|
|
current_states = [ReportState.WORKING]
|
|
|
|
def next(self) -> None:
|
|
if self.is_on_working_timeout():
|
|
last_working = ReportScheduleDAO.find_last_entered_working_log(
|
|
self._report_schedule
|
|
)
|
|
elapsed_seconds = (
|
|
(datetime.utcnow() - last_working.end_dttm).total_seconds()
|
|
if last_working
|
|
else None
|
|
)
|
|
logger.error(
|
|
"Working state timeout after %.2fs - execution_id: %s",
|
|
elapsed_seconds if elapsed_seconds else 0,
|
|
self._execution_id,
|
|
)
|
|
exception_timeout = ReportScheduleWorkingTimeoutError()
|
|
self.update_report_schedule_and_log(
|
|
ReportState.ERROR,
|
|
error_message=str(exception_timeout),
|
|
)
|
|
raise exception_timeout
|
|
logger.warning(
|
|
"Report still in working state, refusing to re-compute - execution_id: %s",
|
|
self._execution_id,
|
|
)
|
|
exception_working = ReportSchedulePreviousWorkingError()
|
|
self.update_report_schedule_and_log(
|
|
ReportState.WORKING,
|
|
error_message=str(exception_working),
|
|
)
|
|
raise exception_working
|
|
|
|
|
|
class ReportSuccessState(BaseReportState):
|
|
"""
|
|
Handle Success, Grace state
|
|
next states:
|
|
- Grace
|
|
- Not triggered
|
|
- Success
|
|
"""
|
|
|
|
current_states = [ReportState.SUCCESS, ReportState.GRACE]
|
|
|
|
def next(self) -> None:
|
|
if self._report_schedule.type == ReportScheduleType.ALERT:
|
|
if self.is_in_grace_period():
|
|
self.update_report_schedule_and_log(
|
|
ReportState.GRACE,
|
|
error_message=str(ReportScheduleAlertGracePeriodError()),
|
|
)
|
|
return
|
|
self.update_report_schedule_and_log(ReportState.WORKING)
|
|
try:
|
|
if not AlertCommand(self._report_schedule, self._execution_id).run():
|
|
self.update_report_schedule_and_log(ReportState.NOOP)
|
|
return
|
|
except Exception as ex:
|
|
self.send_error(
|
|
f"Error occurred for {self._report_schedule.type}:"
|
|
f" {self._report_schedule.name}",
|
|
str(ex),
|
|
)
|
|
self.update_report_schedule_and_log(
|
|
ReportState.ERROR,
|
|
error_message=REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER,
|
|
)
|
|
raise
|
|
|
|
try:
|
|
self.send()
|
|
self.update_report_schedule_and_log(ReportState.SUCCESS)
|
|
except Exception as ex: # pylint: disable=broad-except
|
|
try:
|
|
self.update_report_schedule_and_log(
|
|
ReportState.ERROR, error_message=str(ex)
|
|
)
|
|
except ReportScheduleUnexpectedError as logging_ex:
|
|
# Logging failed (likely StaleDataError), but we still want to
|
|
# raise the original error so the root cause remains visible
|
|
logger.warning(
|
|
"Failed to log error for report schedule (execution %s) "
|
|
"due to database issue",
|
|
self._execution_id,
|
|
exc_info=True,
|
|
)
|
|
# Re-raise the original exception, not the logging failure
|
|
raise ex from logging_ex
|
|
raise
|
|
|
|
|
|
class ReportScheduleStateMachine: # pylint: disable=too-few-public-methods
|
|
"""
|
|
Simple state machine for Alerts/Reports states
|
|
"""
|
|
|
|
states_cls = [ReportWorkingState, ReportNotTriggeredErrorState, ReportSuccessState]
|
|
|
|
def __init__(
|
|
self,
|
|
task_uuid: UUID,
|
|
report_schedule: ReportSchedule,
|
|
scheduled_dttm: datetime,
|
|
):
|
|
self._execution_id = task_uuid
|
|
self._report_schedule = report_schedule
|
|
self._scheduled_dttm = scheduled_dttm
|
|
|
|
@transaction()
|
|
def run(self) -> None:
|
|
for state_cls in self.states_cls:
|
|
if (self._report_schedule.last_state is None and state_cls.initial) or (
|
|
self._report_schedule.last_state in state_cls.current_states
|
|
):
|
|
state_cls(
|
|
self._report_schedule,
|
|
self._scheduled_dttm,
|
|
self._execution_id,
|
|
).next()
|
|
break
|
|
else:
|
|
raise ReportScheduleStateNotFoundError()
|
|
|
|
|
|
class AsyncExecuteReportScheduleCommand(BaseCommand):
|
|
"""
|
|
Execute all types of report schedules.
|
|
- On reports takes chart or dashboard screenshots and sends configured notifications
|
|
- On Alerts uses related Command AlertCommand and sends configured notifications
|
|
"""
|
|
|
|
def __init__(self, task_id: str, model_id: int, scheduled_dttm: datetime):
|
|
self._model_id = model_id
|
|
self._model: Optional[ReportSchedule] = None
|
|
self._scheduled_dttm = scheduled_dttm
|
|
self._execution_id = UUID(task_id)
|
|
|
|
@transaction()
|
|
def run(self) -> None:
|
|
try:
|
|
self.validate()
|
|
if not self._model:
|
|
raise ReportScheduleExecuteUnexpectedError()
|
|
|
|
_, username = get_executor(
|
|
executors=app.config["ALERT_REPORTS_EXECUTORS"],
|
|
model=self._model,
|
|
)
|
|
user = security_manager.find_user(username)
|
|
|
|
start_time = datetime.utcnow()
|
|
with override_user(user):
|
|
ReportScheduleStateMachine(
|
|
self._execution_id, self._model, self._scheduled_dttm
|
|
).run()
|
|
|
|
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
|
logger.info(
|
|
"Report execution as user %s completed in %.2fs - execution_id: %s",
|
|
username,
|
|
elapsed_seconds,
|
|
self._execution_id,
|
|
)
|
|
except CommandException:
|
|
raise
|
|
except Exception as ex:
|
|
raise ReportScheduleUnexpectedError(str(ex)) from ex
|
|
|
|
def validate(self) -> None:
|
|
# Validate/populate model exists
|
|
logger.info(
|
|
"session is validated: id %s, executionid: %s",
|
|
self._model_id,
|
|
self._execution_id,
|
|
)
|
|
self._model = (
|
|
db.session.query(ReportSchedule).filter_by(id=self._model_id).one_or_none()
|
|
)
|
|
if not self._model:
|
|
raise ReportScheduleNotFoundError()
|