blob: 865a8e59a06e413fbf8c472c1fe2dfcf5370e8e3 [file] [log] [blame]
# 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")