mirror of
https://github.com/apache/superset.git
synced 2026-07-09 16:25:36 +00:00
472 lines
14 KiB
Python
472 lines
14 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.
|
|
# pylint: disable=import-outside-toplevel, unused-argument, redefined-outer-name, invalid-name
|
|
|
|
from functools import partial
|
|
from typing import Any, TYPE_CHECKING
|
|
|
|
import pytest
|
|
from pytest_mock import MockerFixture
|
|
from sqlalchemy.orm.session import Session
|
|
|
|
from superset import db
|
|
from tests.conftest import with_config
|
|
|
|
if TYPE_CHECKING:
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
|
|
FULL_DTTM_DEFAULTS_EXAMPLE = {
|
|
"main_dttm_col": "id",
|
|
"dttm_columns": {
|
|
"dttm": {
|
|
"python_date_format": "epoch_s",
|
|
"expression": "CAST(dttm as INTEGER)",
|
|
},
|
|
"id": {"python_date_format": "epoch_ms"},
|
|
"month": {
|
|
"python_date_format": "%Y-%m-%d",
|
|
"expression": (
|
|
"CASE WHEN length(month) = 7 THEN month || '-01' ELSE month END"
|
|
),
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def apply_dttm_defaults(table: "SqlaTable", dttm_defaults: dict[str, Any]) -> None:
|
|
"""Applies dttm defaults to the table, mutates in place."""
|
|
for dbcol in table.columns:
|
|
# Set is_dttm is column is listed in dttm_columns.
|
|
if dbcol.column_name in dttm_defaults.get("dttm_columns", {}):
|
|
dbcol.is_dttm = True
|
|
|
|
# Skip non dttm columns.
|
|
if dbcol.column_name not in dttm_defaults.get("dttm_columns", {}):
|
|
continue
|
|
|
|
# Set table main_dttm_col.
|
|
if dbcol.column_name == dttm_defaults.get("main_dttm_col"):
|
|
table.main_dttm_col = dbcol.column_name
|
|
|
|
# Apply defaults if empty.
|
|
dttm_column_defaults = dttm_defaults.get("dttm_columns", {}).get(
|
|
dbcol.column_name, {}
|
|
)
|
|
dbcol.is_dttm = True
|
|
if (
|
|
not dbcol.python_date_format
|
|
and "python_date_format" in dttm_column_defaults
|
|
):
|
|
dbcol.python_date_format = dttm_column_defaults["python_date_format"]
|
|
if not dbcol.expression and "expression" in dttm_column_defaults:
|
|
dbcol.expression = dttm_column_defaults["expression"]
|
|
|
|
|
|
@pytest.fixture
|
|
def test_table(session: Session) -> "SqlaTable":
|
|
"""
|
|
Fixture that generates an in-memory table.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
from superset.models.core import Database
|
|
|
|
engine = db.session.get_bind()
|
|
SqlaTable.metadata.create_all(engine) # pylint: disable=no-member
|
|
|
|
columns = [
|
|
TableColumn(column_name="ds", is_dttm=1, type="TIMESTAMP"),
|
|
TableColumn(column_name="event_time", is_dttm=1, type="TIMESTAMP"),
|
|
TableColumn(column_name="id", type="INTEGER"),
|
|
TableColumn(column_name="dttm", type="INTEGER"),
|
|
TableColumn(column_name="duration_ms", type="INTEGER"),
|
|
]
|
|
|
|
return SqlaTable(
|
|
table_name="test_table",
|
|
columns=columns,
|
|
metrics=[],
|
|
main_dttm_col=None,
|
|
database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"),
|
|
)
|
|
|
|
|
|
@with_config(
|
|
{
|
|
"SQLA_TABLE_MUTATOR": partial(
|
|
apply_dttm_defaults,
|
|
dttm_defaults={
|
|
"main_dttm_col": "event_time",
|
|
"dttm_columns": {"ds": {}, "event_time": {}},
|
|
},
|
|
)
|
|
}
|
|
)
|
|
def test_main_dttm_col(mocker: MockerFixture, test_table: "SqlaTable") -> None:
|
|
"""
|
|
Test the ``SQLA_TABLE_MUTATOR`` config.
|
|
"""
|
|
mocker.patch(
|
|
"superset.connectors.sqla.models.get_physical_table_metadata",
|
|
return_value=[
|
|
{"column_name": "ds", "type": "TIMESTAMP", "is_dttm": True},
|
|
{"column_name": "event_time", "type": "TIMESTAMP", "is_dttm": True},
|
|
{"column_name": "id", "type": "INTEGER", "is_dttm": False},
|
|
],
|
|
)
|
|
|
|
assert test_table.main_dttm_col is None
|
|
test_table.fetch_metadata()
|
|
assert test_table.main_dttm_col == "event_time"
|
|
|
|
|
|
@with_config(
|
|
{
|
|
"SQLA_TABLE_MUTATOR": partial(
|
|
apply_dttm_defaults,
|
|
dttm_defaults={
|
|
"main_dttm_col": "nonexistent",
|
|
},
|
|
)
|
|
}
|
|
)
|
|
def test_main_dttm_col_nonexistent(
|
|
mocker: MockerFixture,
|
|
test_table: "SqlaTable",
|
|
) -> None:
|
|
"""
|
|
Test the ``SQLA_TABLE_MUTATOR`` config when main datetime column doesn't exist.
|
|
"""
|
|
mocker.patch(
|
|
"superset.connectors.sqla.models.get_physical_table_metadata",
|
|
return_value=[
|
|
{"column_name": "ds", "type": "TIMESTAMP", "is_dttm": True},
|
|
{"column_name": "event_time", "type": "TIMESTAMP", "is_dttm": True},
|
|
{"column_name": "id", "type": "INTEGER", "is_dttm": False},
|
|
],
|
|
)
|
|
|
|
assert test_table.main_dttm_col is None
|
|
test_table.fetch_metadata()
|
|
# fall back to ds
|
|
assert test_table.main_dttm_col == "ds"
|
|
|
|
|
|
@with_config(
|
|
{
|
|
"SQLA_TABLE_MUTATOR": partial(
|
|
apply_dttm_defaults,
|
|
dttm_defaults={
|
|
"main_dttm_col": "id",
|
|
},
|
|
)
|
|
}
|
|
)
|
|
def test_main_dttm_col_nondttm(
|
|
mocker: MockerFixture,
|
|
test_table: "SqlaTable",
|
|
) -> None:
|
|
"""
|
|
Test the ``SQLA_TABLE_MUTATOR`` config when main datetime column has wrong type.
|
|
"""
|
|
mocker.patch(
|
|
"superset.connectors.sqla.models.get_physical_table_metadata",
|
|
return_value=[
|
|
{"column_name": "ds", "type": "TIMESTAMP", "is_dttm": True},
|
|
{"column_name": "event_time", "type": "TIMESTAMP", "is_dttm": True},
|
|
{"column_name": "id", "type": "INTEGER", "is_dttm": False},
|
|
],
|
|
)
|
|
|
|
assert test_table.main_dttm_col is None
|
|
test_table.fetch_metadata()
|
|
# fall back to ds
|
|
assert test_table.main_dttm_col == "ds"
|
|
|
|
|
|
@with_config(
|
|
{
|
|
"SQLA_TABLE_MUTATOR": partial(
|
|
apply_dttm_defaults,
|
|
dttm_defaults={
|
|
"dttm_columns": {
|
|
"id": {"python_date_format": "epoch_ms"},
|
|
"dttm": {"python_date_format": "epoch_s"},
|
|
},
|
|
},
|
|
)
|
|
}
|
|
)
|
|
def test_python_date_format_by_column_name(
|
|
mocker: MockerFixture,
|
|
test_table: "SqlaTable",
|
|
) -> None:
|
|
"""
|
|
Test the ``SQLA_TABLE_MUTATOR`` setting for "python_date_format".
|
|
"""
|
|
mocker.patch(
|
|
"superset.connectors.sqla.models.get_physical_table_metadata",
|
|
return_value=[
|
|
{"column_name": "id", "type": "INTEGER", "is_dttm": False},
|
|
{"column_name": "dttm", "type": "INTEGER", "is_dttm": False},
|
|
],
|
|
)
|
|
|
|
test_table.fetch_metadata()
|
|
|
|
id_col = [c for c in test_table.columns if c.column_name == "id"][0]
|
|
assert id_col.is_dttm
|
|
assert id_col.python_date_format == "epoch_ms"
|
|
|
|
dttm_col = [c for c in test_table.columns if c.column_name == "dttm"][0]
|
|
assert dttm_col.is_dttm
|
|
assert dttm_col.python_date_format == "epoch_s"
|
|
|
|
|
|
@with_config(
|
|
{
|
|
"SQLA_TABLE_MUTATOR": partial(
|
|
apply_dttm_defaults,
|
|
dttm_defaults={
|
|
"dttm_columns": {
|
|
"dttm": {"expression": "CAST(dttm as INTEGER)"},
|
|
"duration_ms": {"expression": "CAST(duration_ms as DOUBLE)"},
|
|
},
|
|
},
|
|
)
|
|
}
|
|
)
|
|
def test_expression_by_column_name(
|
|
mocker: MockerFixture,
|
|
test_table: "SqlaTable",
|
|
) -> None:
|
|
"""
|
|
Test the ``SQLA_TABLE_MUTATOR`` setting for expression.
|
|
"""
|
|
mocker.patch(
|
|
"superset.connectors.sqla.models.get_physical_table_metadata",
|
|
return_value=[
|
|
{"column_name": "dttm", "type": "INTEGER", "is_dttm": False},
|
|
{"column_name": "duration_ms", "type": "INTEGER", "is_dttm": False},
|
|
],
|
|
)
|
|
|
|
test_table.fetch_metadata()
|
|
|
|
dttm_col = [c for c in test_table.columns if c.column_name == "dttm"][0]
|
|
assert dttm_col.is_dttm
|
|
assert dttm_col.expression == "CAST(dttm as INTEGER)"
|
|
|
|
duration_ms_col = [c for c in test_table.columns if c.column_name == "duration_ms"][
|
|
0
|
|
]
|
|
assert duration_ms_col.is_dttm
|
|
assert duration_ms_col.expression == "CAST(duration_ms as DOUBLE)"
|
|
|
|
|
|
@with_config(
|
|
{
|
|
"SQLA_TABLE_MUTATOR": partial(
|
|
apply_dttm_defaults,
|
|
dttm_defaults=FULL_DTTM_DEFAULTS_EXAMPLE,
|
|
)
|
|
}
|
|
)
|
|
def test_full_setting(
|
|
mocker: MockerFixture,
|
|
test_table: "SqlaTable",
|
|
) -> None:
|
|
"""
|
|
Test the ``SQLA_TABLE_MUTATOR`` with full settings.
|
|
"""
|
|
mocker.patch(
|
|
"superset.connectors.sqla.models.get_physical_table_metadata",
|
|
return_value=[
|
|
{"column_name": "id", "type": "INTEGER", "is_dttm": False},
|
|
{"column_name": "dttm", "type": "INTEGER", "is_dttm": False},
|
|
{"column_name": "duration_ms", "type": "INTEGER", "is_dttm": False},
|
|
],
|
|
)
|
|
|
|
test_table.fetch_metadata()
|
|
|
|
id_col = [c for c in test_table.columns if c.column_name == "id"][0]
|
|
assert id_col.is_dttm
|
|
assert id_col.python_date_format == "epoch_ms"
|
|
assert id_col.expression == ""
|
|
|
|
dttm_col = [c for c in test_table.columns if c.column_name == "dttm"][0]
|
|
assert dttm_col.is_dttm
|
|
assert dttm_col.python_date_format == "epoch_s"
|
|
assert dttm_col.expression == "CAST(dttm as INTEGER)"
|
|
|
|
|
|
def test_sync_theme_logo_href() -> None:
|
|
"""
|
|
Verify LOGO_TARGET_PATH is wired into a theme's brandLogoHref.
|
|
|
|
THEME_DEFAULT is built before superset_config.py overrides load, so the link
|
|
is re-synced afterwards via sync_theme_logo_href. A provided LOGO_TARGET_PATH
|
|
must update brandLogoHref; None must leave the existing value untouched.
|
|
"""
|
|
from copy import deepcopy
|
|
|
|
from superset.config import sync_theme_logo_href, THEME_DEFAULT
|
|
|
|
# A user-provided LOGO_TARGET_PATH propagates to the logo link.
|
|
theme = deepcopy(THEME_DEFAULT)
|
|
theme["token"]["brandLogoHref"] = "/"
|
|
sync_theme_logo_href(theme, "https://custom.url")
|
|
assert theme["token"]["brandLogoHref"] == "https://custom.url"
|
|
|
|
# The default (None) leaves the existing link untouched.
|
|
default_theme = deepcopy(THEME_DEFAULT)
|
|
default_theme["token"]["brandLogoHref"] = "/"
|
|
sync_theme_logo_href(default_theme, None)
|
|
assert default_theme["token"]["brandLogoHref"] == "/"
|
|
|
|
# A disabled theme (None) is a no-op rather than an error.
|
|
sync_theme_logo_href(None, "https://custom.url")
|
|
|
|
|
|
def test_theme_default_logo_defaults() -> None:
|
|
"""With the shipped defaults, brandLogoHref is "/" and brandLogoUrl is APP_ICON."""
|
|
from superset import config
|
|
|
|
assert config.LOGO_TARGET_PATH is None
|
|
assert config.THEME_DEFAULT["token"]["brandLogoHref"] == "/"
|
|
assert config.THEME_DEFAULT["token"]["brandLogoUrl"] == config.APP_ICON
|
|
|
|
|
|
def test_smtp_ssl_server_auth_defaults_to_true() -> None:
|
|
"""
|
|
The shipped default for SMTP_SSL_SERVER_AUTH validates the SMTP server's
|
|
TLS certificate. Operators can still opt out by overriding it to False.
|
|
"""
|
|
from superset import config
|
|
|
|
assert config.SMTP_SSL_SERVER_AUTH is True
|
|
|
|
|
|
def _smtp_config(**overrides: Any) -> dict[str, Any]:
|
|
"""
|
|
Build a minimal SMTP config dict for ``send_mime_email`` tests, with
|
|
plaintext transport defaults; keyword ``overrides`` replace any key.
|
|
"""
|
|
config = {
|
|
"SMTP_HOST": "localhost",
|
|
"SMTP_PORT": 25,
|
|
"SMTP_USER": "",
|
|
"SMTP_PASSWORD": "",
|
|
"SMTP_STARTTLS": False,
|
|
"SMTP_SSL": False,
|
|
"SMTP_SSL_SERVER_AUTH": True,
|
|
}
|
|
config.update(overrides)
|
|
return config
|
|
|
|
|
|
def test_send_mime_email_ssl_server_auth_passes_context(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
With SMTP_SSL and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
|
|
default SSL context and threads it through to ``smtplib.SMTP_SSL`` so the
|
|
server certificate is validated.
|
|
"""
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
|
from superset.utils import core as utils
|
|
|
|
create_default_context = mocker.patch(
|
|
"superset.utils.core.ssl.create_default_context"
|
|
)
|
|
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
|
|
smtp = mocker.patch("smtplib.SMTP")
|
|
|
|
utils.send_mime_email(
|
|
"from",
|
|
["to"],
|
|
MIMEMultipart(),
|
|
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=True),
|
|
dryrun=False,
|
|
)
|
|
|
|
create_default_context.assert_called_once_with()
|
|
assert not smtp.called
|
|
smtp_ssl.assert_called_once_with(
|
|
"localhost", 25, context=create_default_context.return_value, timeout=30
|
|
)
|
|
|
|
|
|
def test_send_mime_email_starttls_server_auth_passes_context(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
With STARTTLS and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
|
|
default SSL context and threads it through to ``starttls`` so the server
|
|
certificate is validated.
|
|
"""
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
|
from superset.utils import core as utils
|
|
|
|
create_default_context = mocker.patch(
|
|
"superset.utils.core.ssl.create_default_context"
|
|
)
|
|
smtp = mocker.patch("smtplib.SMTP")
|
|
|
|
utils.send_mime_email(
|
|
"from",
|
|
["to"],
|
|
MIMEMultipart(),
|
|
_smtp_config(SMTP_STARTTLS=True, SMTP_SSL_SERVER_AUTH=True),
|
|
dryrun=False,
|
|
)
|
|
|
|
create_default_context.assert_called_once_with()
|
|
smtp.return_value.starttls.assert_called_once_with(
|
|
context=create_default_context.return_value
|
|
)
|
|
|
|
|
|
def test_send_mime_email_server_auth_disabled_skips_context(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
When SMTP_SSL_SERVER_AUTH is disabled no SSL context is built and ``None`` is
|
|
passed through, preserving the opt-out (certificate validation skipped).
|
|
"""
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
|
from superset.utils import core as utils
|
|
|
|
create_default_context = mocker.patch(
|
|
"superset.utils.core.ssl.create_default_context"
|
|
)
|
|
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
|
|
|
|
utils.send_mime_email(
|
|
"from",
|
|
["to"],
|
|
MIMEMultipart(),
|
|
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=False),
|
|
dryrun=False,
|
|
)
|
|
|
|
assert not create_default_context.called
|
|
smtp_ssl.assert_called_once_with("localhost", 25, context=None, timeout=30)
|