mirror of
https://github.com/apache/superset.git
synced 2026-04-18 15:44:57 +00:00
661 lines
23 KiB
Python
661 lines
23 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.
|
|
"""Migrate dashboard position_json data from V1 to V2
|
|
|
|
Revision ID: bebcf3fed1fe
|
|
Revises: fc480c87706c
|
|
Create Date: 2018-07-22 11:59:07.025119
|
|
|
|
"""
|
|
|
|
# revision identifiers, used by Alembic.
|
|
import collections
|
|
import json
|
|
import sys
|
|
import uuid
|
|
from functools import reduce
|
|
|
|
from alembic import op
|
|
from sqlalchemy import Column, ForeignKey, Integer, String, Table, Text
|
|
from sqlalchemy.ext.declarative import declarative_base
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from superset import db
|
|
|
|
revision = "bebcf3fed1fe"
|
|
down_revision = "fc480c87706c"
|
|
|
|
Base = declarative_base()
|
|
|
|
BACKGROUND_TRANSPARENT = "BACKGROUND_TRANSPARENT"
|
|
CHART_TYPE = "DASHBOARD_CHART_TYPE"
|
|
COLUMN_TYPE = "DASHBOARD_COLUMN_TYPE"
|
|
DASHBOARD_GRID_ID = "DASHBOARD_GRID_ID"
|
|
DASHBOARD_GRID_TYPE = "DASHBOARD_GRID_TYPE"
|
|
DASHBOARD_HEADER_ID = "DASHBOARD_HEADER_ID"
|
|
DASHBOARD_HEADER_TYPE = "DASHBOARD_HEADER_TYPE"
|
|
DASHBOARD_ROOT_ID = "DASHBOARD_ROOT_ID"
|
|
DASHBOARD_ROOT_TYPE = "DASHBOARD_ROOT_TYPE"
|
|
DASHBOARD_VERSION_KEY = "DASHBOARD_VERSION_KEY"
|
|
MARKDOWN_TYPE = "DASHBOARD_MARKDOWN_TYPE"
|
|
ROW_TYPE = "DASHBOARD_ROW_TYPE"
|
|
|
|
GRID_COLUMN_COUNT = 12
|
|
GRID_MIN_COLUMN_COUNT = 1
|
|
GRID_MIN_ROW_UNITS = 5
|
|
GRID_RATIO = 4.0
|
|
NUMBER_OF_CHARTS_PER_ROW = 3
|
|
MAX_RECURSIVE_LEVEL = 6
|
|
ROW_HEIGHT = 8
|
|
TOTAL_COLUMNS = 48
|
|
DEFAULT_CHART_WIDTH = int(TOTAL_COLUMNS / NUMBER_OF_CHARTS_PER_ROW)
|
|
MAX_VALUE = sys.maxsize
|
|
|
|
|
|
class Slice(Base):
|
|
"""Declarative class to do query in upgrade"""
|
|
|
|
__tablename__ = "slices"
|
|
id = Column(Integer, primary_key=True)
|
|
slice_name = Column(String(250))
|
|
params = Column(Text)
|
|
viz_type = Column(String(250))
|
|
|
|
|
|
dashboard_slices = Table(
|
|
"dashboard_slices",
|
|
Base.metadata,
|
|
Column("id", Integer, primary_key=True),
|
|
Column("dashboard_id", Integer, ForeignKey("dashboards.id")),
|
|
Column("slice_id", Integer, ForeignKey("slices.id")),
|
|
)
|
|
|
|
|
|
class Dashboard(Base):
|
|
"""Declarative class to do query in upgrade"""
|
|
|
|
__tablename__ = "dashboards"
|
|
id = Column(Integer, primary_key=True)
|
|
dashboard_title = Column(String(500))
|
|
position_json = Column(Text)
|
|
slices = relationship("Slice", secondary=dashboard_slices, backref="dashboards")
|
|
|
|
|
|
def is_v2_dash(positions):
|
|
return (
|
|
isinstance(positions, dict) and positions.get("DASHBOARD_VERSION_KEY") == "v2"
|
|
)
|
|
|
|
|
|
def get_boundary(positions):
|
|
top = MAX_VALUE
|
|
left = MAX_VALUE
|
|
bottom = 0
|
|
right = 0
|
|
|
|
for position in positions:
|
|
top = min(position["row"], top)
|
|
left = min(position["col"], left)
|
|
bottom = max(position["row"] + position["size_y"], bottom)
|
|
right = max(position["col"] + position["size_x"], right)
|
|
|
|
return {"top": top, "bottom": bottom, "left": left, "right": right}
|
|
|
|
|
|
def generate_id():
|
|
return uuid.uuid4().hex[:8]
|
|
|
|
|
|
def has_overlap(positions, xAxis=True):
|
|
sorted_positions = (
|
|
sorted(positions[:], key=lambda pos: pos["col"])
|
|
if xAxis
|
|
else sorted(positions[:], key=lambda pos: pos["row"])
|
|
)
|
|
|
|
result = False
|
|
for idx, position in enumerate(sorted_positions):
|
|
if idx < len(sorted_positions) - 1:
|
|
if xAxis:
|
|
result = (
|
|
position["col"] + position["size_x"]
|
|
> sorted_positions[idx + 1]["col"]
|
|
)
|
|
else:
|
|
result = (
|
|
position["row"] + position["size_y"]
|
|
> sorted_positions[idx + 1]["row"]
|
|
)
|
|
if result:
|
|
break
|
|
|
|
return result
|
|
|
|
|
|
def get_empty_layout():
|
|
return {
|
|
DASHBOARD_VERSION_KEY: "v2",
|
|
DASHBOARD_ROOT_ID: {
|
|
"type": DASHBOARD_ROOT_TYPE,
|
|
"id": DASHBOARD_ROOT_ID,
|
|
"children": [DASHBOARD_GRID_ID],
|
|
},
|
|
DASHBOARD_GRID_ID: {
|
|
"type": DASHBOARD_GRID_TYPE,
|
|
"id": DASHBOARD_GRID_ID,
|
|
"children": [],
|
|
},
|
|
}
|
|
|
|
|
|
def get_header_component(title):
|
|
return {
|
|
"id": DASHBOARD_HEADER_ID,
|
|
"type": DASHBOARD_HEADER_TYPE,
|
|
"meta": {"text": title},
|
|
}
|
|
|
|
|
|
def get_row_container():
|
|
return {
|
|
"type": ROW_TYPE,
|
|
"id": "DASHBOARD_ROW_TYPE-{}".format(generate_id()),
|
|
"children": [],
|
|
"meta": {"background": BACKGROUND_TRANSPARENT},
|
|
}
|
|
|
|
|
|
def get_col_container():
|
|
return {
|
|
"type": COLUMN_TYPE,
|
|
"id": "DASHBOARD_COLUMN_TYPE-{}".format(generate_id()),
|
|
"children": [],
|
|
"meta": {"background": BACKGROUND_TRANSPARENT},
|
|
}
|
|
|
|
|
|
def get_chart_holder(position):
|
|
size_x = position["size_x"]
|
|
size_y = position["size_y"]
|
|
slice_id = position["slice_id"]
|
|
slice_name = position.get("slice_name")
|
|
code = position.get("code")
|
|
|
|
width = max(GRID_MIN_COLUMN_COUNT, int(round(size_x / GRID_RATIO)))
|
|
height = max(
|
|
GRID_MIN_ROW_UNITS, int(round(((size_y / GRID_RATIO) * 100) / ROW_HEIGHT))
|
|
)
|
|
if code is not None:
|
|
markdown_content = " " # white-space markdown
|
|
if len(code):
|
|
markdown_content = code
|
|
elif slice_name.strip():
|
|
markdown_content = "##### {}".format(slice_name)
|
|
|
|
return {
|
|
"type": MARKDOWN_TYPE,
|
|
"id": "DASHBOARD_MARKDOWN_TYPE-{}".format(generate_id()),
|
|
"children": [],
|
|
"meta": {"width": width, "height": height, "code": markdown_content},
|
|
}
|
|
|
|
return {
|
|
"type": CHART_TYPE,
|
|
"id": "DASHBOARD_CHART_TYPE-{}".format(generate_id()),
|
|
"children": [],
|
|
"meta": {"width": width, "height": height, "chartId": int(slice_id)},
|
|
}
|
|
|
|
|
|
def get_children_max(children, attr, root):
|
|
return max([root[childId]["meta"][attr] for childId in children])
|
|
|
|
|
|
def get_children_sum(children, attr, root):
|
|
return reduce((lambda sum, childId: sum + root[childId]["meta"][attr]), children, 0)
|
|
|
|
|
|
# find column that: width > 2 and
|
|
# each row has at least 1 chart can reduce width
|
|
def get_wide_column_ids(children, root):
|
|
return list(
|
|
filter(lambda childId: can_reduce_column_width(root[childId], root), children)
|
|
)
|
|
|
|
|
|
def is_wide_leaf_component(component):
|
|
return (
|
|
component["type"] in [CHART_TYPE, MARKDOWN_TYPE]
|
|
and component["meta"]["width"] > GRID_MIN_COLUMN_COUNT
|
|
)
|
|
|
|
|
|
def can_reduce_column_width(column_component, root):
|
|
return (
|
|
column_component["type"] == COLUMN_TYPE
|
|
and column_component["meta"]["width"] > GRID_MIN_COLUMN_COUNT
|
|
and all(
|
|
[
|
|
is_wide_leaf_component(root[childId])
|
|
or (
|
|
root[childId]["type"] == ROW_TYPE
|
|
and all(
|
|
[
|
|
is_wide_leaf_component(root[id])
|
|
for id in root[childId]["children"]
|
|
]
|
|
)
|
|
)
|
|
for childId in column_component["children"]
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
def reduce_row_width(row_component, root):
|
|
wide_leaf_component_ids = list(
|
|
filter(
|
|
lambda childId: is_wide_leaf_component(root[childId]),
|
|
row_component["children"],
|
|
)
|
|
)
|
|
|
|
widest_chart_id = None
|
|
widest_width = 0
|
|
for component_id in wide_leaf_component_ids:
|
|
if root[component_id]["meta"]["width"] > widest_width:
|
|
widest_width = root[component_id]["meta"]["width"]
|
|
widest_chart_id = component_id
|
|
|
|
if widest_chart_id:
|
|
root[widest_chart_id]["meta"]["width"] -= 1
|
|
|
|
return get_children_sum(row_component["children"], "width", root)
|
|
|
|
|
|
def reduce_component_width(component):
|
|
if is_wide_leaf_component(component):
|
|
component["meta"]["width"] -= 1
|
|
return component["meta"]["width"]
|
|
|
|
|
|
def convert(positions, level, parent, root):
|
|
if len(positions) == 0:
|
|
return
|
|
|
|
if len(positions) == 1 or level >= MAX_RECURSIVE_LEVEL:
|
|
# special treatment for single chart dash:
|
|
# always wrap chart inside a row
|
|
if parent["type"] == DASHBOARD_GRID_TYPE:
|
|
row_container = get_row_container()
|
|
root[row_container["id"]] = row_container
|
|
parent["children"].append(row_container["id"])
|
|
parent = row_container
|
|
|
|
chart_holder = get_chart_holder(positions[0])
|
|
root[chart_holder["id"]] = chart_holder
|
|
parent["children"].append(chart_holder["id"])
|
|
return
|
|
|
|
current_positions = positions[:]
|
|
boundary = get_boundary(current_positions)
|
|
top = boundary["top"]
|
|
bottom = boundary["bottom"]
|
|
left = boundary["left"]
|
|
right = boundary["right"]
|
|
|
|
# find row dividers
|
|
layers = []
|
|
current_row = top + 1
|
|
while len(current_positions) and current_row <= bottom:
|
|
upper = []
|
|
lower = []
|
|
|
|
is_row_divider = True
|
|
for position in current_positions:
|
|
row = position["row"]
|
|
size_y = position["size_y"]
|
|
if row + size_y <= current_row:
|
|
lower.append(position)
|
|
continue
|
|
elif row >= current_row:
|
|
upper.append(position)
|
|
continue
|
|
is_row_divider = False
|
|
break
|
|
|
|
if is_row_divider:
|
|
current_positions = upper[:]
|
|
layers.append(lower)
|
|
current_row += 1
|
|
|
|
# Each layer is a list of positions belong to same row section
|
|
# they can be a list of charts, or arranged in columns, or mixed
|
|
for layer in layers:
|
|
if len(layer) == 0:
|
|
continue
|
|
|
|
if len(layer) == 1 and parent["type"] == COLUMN_TYPE:
|
|
chart_holder = get_chart_holder(layer[0])
|
|
root[chart_holder["id"]] = chart_holder
|
|
parent["children"].append(chart_holder["id"])
|
|
continue
|
|
|
|
# create a new row
|
|
row_container = get_row_container()
|
|
root[row_container["id"]] = row_container
|
|
parent["children"].append(row_container["id"])
|
|
|
|
current_positions = layer[:]
|
|
if not has_overlap(current_positions):
|
|
# this is a list of charts in the same row
|
|
sorted_by_col = sorted(current_positions, key=lambda pos: pos["col"])
|
|
for position in sorted_by_col:
|
|
chart_holder = get_chart_holder(position)
|
|
root[chart_holder["id"]] = chart_holder
|
|
row_container["children"].append(chart_holder["id"])
|
|
else:
|
|
# this row has columns, find col dividers
|
|
current_col = left + 1
|
|
while len(current_positions) and current_col <= right:
|
|
upper = []
|
|
lower = []
|
|
|
|
is_col_divider = True
|
|
for position in current_positions:
|
|
col = position["col"]
|
|
size_x = position["size_x"]
|
|
if col + size_x <= current_col:
|
|
lower.append(position)
|
|
continue
|
|
elif col >= current_col:
|
|
upper.append(position)
|
|
continue
|
|
is_col_divider = False
|
|
break
|
|
|
|
if is_col_divider:
|
|
# is single chart in the column:
|
|
# add to parent container without create new column container
|
|
if len(lower) == 1:
|
|
chart_holder = get_chart_holder(lower[0])
|
|
root[chart_holder["id"]] = chart_holder
|
|
row_container["children"].append(chart_holder["id"])
|
|
else:
|
|
# create new col container
|
|
col_container = get_col_container()
|
|
root[col_container["id"]] = col_container
|
|
|
|
if not has_overlap(lower, False):
|
|
sorted_by_row = sorted(lower, key=lambda pos: pos["row"])
|
|
for position in sorted_by_row:
|
|
chart_holder = get_chart_holder(position)
|
|
root[chart_holder["id"]] = chart_holder
|
|
col_container["children"].append(chart_holder["id"])
|
|
else:
|
|
convert(lower, level + 2, col_container, root)
|
|
|
|
# add col meta
|
|
if len(col_container["children"]):
|
|
row_container["children"].append(col_container["id"])
|
|
col_container["meta"]["width"] = get_children_max(
|
|
col_container["children"], "width", root
|
|
)
|
|
|
|
current_positions = upper[:]
|
|
current_col += 1
|
|
|
|
# add row meta
|
|
row_container["meta"]["width"] = get_children_sum(
|
|
row_container["children"], "width", root
|
|
)
|
|
|
|
|
|
def convert_to_layout(positions):
|
|
root = get_empty_layout()
|
|
|
|
convert(positions, 0, root[DASHBOARD_GRID_ID], root)
|
|
|
|
# remove row's width, height and col's height from its meta data
|
|
# and make sure every row's width <= GRID_COLUMN_COUNT
|
|
# Each item is a dashboard component:
|
|
# row_container, or col_container, or chart_holder
|
|
for item in root.values():
|
|
if not isinstance(item, dict):
|
|
continue
|
|
|
|
if ROW_TYPE == item["type"]:
|
|
meta = item["meta"]
|
|
if meta.get("width", 0) > GRID_COLUMN_COUNT:
|
|
current_width = meta["width"]
|
|
while current_width > GRID_COLUMN_COUNT and len(
|
|
list(
|
|
filter(
|
|
lambda childId: is_wide_leaf_component(root[childId]),
|
|
item["children"],
|
|
)
|
|
)
|
|
):
|
|
current_width = reduce_row_width(item, root)
|
|
|
|
# because we round v1 chart size to nearest v2 grids count, result
|
|
# in there might be overall row width > GRID_COLUMN_COUNT.
|
|
# So here is an extra step to check row width, and reduce chart
|
|
# or column width if needed and if possible.
|
|
if current_width > GRID_COLUMN_COUNT:
|
|
has_wide_columns = True
|
|
while has_wide_columns:
|
|
col_ids = get_wide_column_ids(item["children"], root)
|
|
idx = 0
|
|
# need 2nd loop since same column may reduce multiple times
|
|
while idx < len(col_ids) and current_width > GRID_COLUMN_COUNT:
|
|
current_column = col_ids[idx]
|
|
for childId in root[current_column]["children"]:
|
|
if root[childId]["type"] == ROW_TYPE:
|
|
root[childId]["meta"]["width"] = reduce_row_width(
|
|
root[childId], root
|
|
)
|
|
else:
|
|
root[childId]["meta"][
|
|
"width"
|
|
] = reduce_component_width(root[childId])
|
|
|
|
root[current_column]["meta"]["width"] = get_children_max(
|
|
root[current_column]["children"], "width", root
|
|
)
|
|
current_width = get_children_sum(
|
|
item["children"], "width", root
|
|
)
|
|
idx += 1
|
|
|
|
has_wide_columns = (
|
|
len(get_wide_column_ids(item["children"], root))
|
|
and current_width > GRID_COLUMN_COUNT
|
|
)
|
|
|
|
meta.pop("width", None)
|
|
|
|
return root
|
|
|
|
|
|
def merge_position(position, bottom_line, last_column_start):
|
|
col = position["col"]
|
|
size_x = position["size_x"]
|
|
size_y = position["size_y"]
|
|
end_column = len(bottom_line) if col + size_x > last_column_start else col + size_x
|
|
|
|
# finding index where index >= col and bottom_line value > bottom_line[col]
|
|
taller_indexes = [
|
|
i
|
|
for i, value in enumerate(bottom_line)
|
|
if (i >= col and value > bottom_line[col])
|
|
]
|
|
|
|
current_row_value = bottom_line[col]
|
|
# if no enough space to fit current position, will start from taller row value
|
|
if len(taller_indexes) > 0 and (taller_indexes[0] - col + 1) < size_x:
|
|
current_row_value = max(bottom_line[col : col + size_x])
|
|
|
|
# add current row value with size_y of this position
|
|
for i in range(col, end_column):
|
|
bottom_line[i] = current_row_value + size_y
|
|
|
|
|
|
# In original position data, a lot of position's row attribute are problematic,
|
|
# for example, same positions are assigned to more than 1 chart.
|
|
# The convert function depends on row id, col id to split the whole dashboard into
|
|
# nested rows and columns. Bad row id will lead to many empty spaces, or a few charts
|
|
# are overlapped in the same row.
|
|
# This function read positions by row first.
|
|
# Then based on previous col id, width and height attribute,
|
|
# re-calculate next position's row id.
|
|
def scan_dashboard_positions_data(positions):
|
|
positions_by_row_id = {}
|
|
for position in positions:
|
|
row = position["row"]
|
|
position["col"] = min(position["col"], TOTAL_COLUMNS)
|
|
if not positions_by_row_id.get(row):
|
|
positions_by_row_id[row] = []
|
|
positions_by_row_id[row].append(position)
|
|
|
|
bottom_line = [0] * (TOTAL_COLUMNS + 1)
|
|
# col index always starts from 1, set a large number for [0] as placeholder
|
|
bottom_line[0] = MAX_VALUE
|
|
last_column_start = max([position["col"] for position in positions])
|
|
|
|
# ordered_raw_positions are arrays of raw positions data sorted by row id
|
|
ordered_raw_positions = []
|
|
row_ids = sorted(positions_by_row_id.keys())
|
|
for row_id in row_ids:
|
|
ordered_raw_positions.append(positions_by_row_id[row_id])
|
|
updated_positions = []
|
|
|
|
while len(ordered_raw_positions):
|
|
next_row = ordered_raw_positions.pop(0)
|
|
next_col = 1
|
|
while len(next_row):
|
|
# special treatment for same (row, col) assigned to more than 1 chart:
|
|
# add one additional row and display wider chart first
|
|
available_columns_index = [
|
|
i
|
|
for i, e in enumerate(
|
|
list(filter(lambda x: x["col"] == next_col, next_row))
|
|
)
|
|
]
|
|
|
|
if len(available_columns_index):
|
|
idx = available_columns_index[0]
|
|
if len(available_columns_index) > 1:
|
|
idx = sorted(
|
|
available_columns_index,
|
|
key=lambda x: next_row[x]["size_x"],
|
|
reverse=True,
|
|
)[0]
|
|
|
|
next_position = next_row.pop(idx)
|
|
merge_position(next_position, bottom_line, last_column_start + 1)
|
|
next_position["row"] = (
|
|
bottom_line[next_position["col"]] - next_position["size_y"]
|
|
)
|
|
updated_positions.append(next_position)
|
|
next_col += next_position["size_x"]
|
|
else:
|
|
next_col = next_row[0]["col"]
|
|
|
|
return updated_positions
|
|
|
|
|
|
def upgrade():
|
|
bind = op.get_bind()
|
|
session = db.Session(bind=bind)
|
|
|
|
dashboards = session.query(Dashboard).all()
|
|
for i, dashboard in enumerate(dashboards):
|
|
print("scanning dashboard ({}/{}) >>>>".format(i + 1, len(dashboards)))
|
|
position_json = json.loads(dashboard.position_json or "[]")
|
|
if not is_v2_dash(position_json):
|
|
print("Converting dashboard... dash_id: {}".format(dashboard.id))
|
|
position_dict = {}
|
|
positions = []
|
|
slices = dashboard.slices
|
|
|
|
if position_json:
|
|
# scan and fix positions data: extra spaces, dup rows, .etc
|
|
position_json = scan_dashboard_positions_data(position_json)
|
|
position_dict = {
|
|
str(position["slice_id"]): position for position in position_json
|
|
}
|
|
|
|
last_row_id = (
|
|
max([pos["row"] + pos["size_y"] for pos in position_json])
|
|
if position_json
|
|
else 0
|
|
)
|
|
new_slice_counter = 0
|
|
for slice in slices:
|
|
position = position_dict.get(str(slice.id))
|
|
|
|
# some dashboard didn't have position_json
|
|
# place 3 charts in a row
|
|
if not position:
|
|
position = {
|
|
"col": (
|
|
new_slice_counter
|
|
% NUMBER_OF_CHARTS_PER_ROW
|
|
* DEFAULT_CHART_WIDTH
|
|
+ 1
|
|
),
|
|
"row": (
|
|
last_row_id
|
|
+ int(new_slice_counter / NUMBER_OF_CHARTS_PER_ROW)
|
|
* DEFAULT_CHART_WIDTH
|
|
),
|
|
"size_x": DEFAULT_CHART_WIDTH,
|
|
"size_y": DEFAULT_CHART_WIDTH,
|
|
"slice_id": str(slice.id),
|
|
}
|
|
new_slice_counter += 1
|
|
|
|
# attach additional parameters to position dict,
|
|
# prepare to replace markup and separator viz_type
|
|
# to dashboard UI component
|
|
form_data = json.loads(slice.params or "{}")
|
|
viz_type = slice.viz_type
|
|
if form_data and viz_type in ["markup", "separator"]:
|
|
position["code"] = form_data.get("code")
|
|
position["slice_name"] = slice.slice_name
|
|
|
|
positions.append(position)
|
|
|
|
v2_layout = convert_to_layout(positions)
|
|
v2_layout[DASHBOARD_HEADER_ID] = get_header_component(
|
|
dashboard.dashboard_title
|
|
)
|
|
|
|
sorted_by_key = collections.OrderedDict(sorted(v2_layout.items()))
|
|
dashboard.position_json = json.dumps(sorted_by_key, indent=2)
|
|
session.merge(dashboard)
|
|
session.commit()
|
|
else:
|
|
print("Skip converted dash_id: {}".format(dashboard.id))
|
|
|
|
session.close()
|
|
|
|
|
|
def downgrade():
|
|
print("downgrade is done")
|