"""application"""
import asyncio
import base64
import glob
import io
import os
import platform
from itertools import islice
import dash
import dash_bootstrap_components as dbc
import pandas as pd
from dash import ALL, MATCH, Dash, Input, Output, State, callback, ctx, dcc, html
from dash.dash_table import DataTable
from dash.exceptions import PreventUpdate
from furl import furl
import yaml
from src.draw import ChartGenerator
from src.sdmx import (
SDMXData,
SDMXMetadata,
get_components_async,
get_translation,
get_url_cl,
retreive_codes_from_data,
translate_df,
)
from src.utils import cleanhtml, error_box, snake_case, validate_yamlfile
external_stylesheets = [
dbc.themes.COSMO,
dbc.icons.FONT_AWESOME,
dbc.icons.BOOTSTRAP,
]
DOC_LINK = "https://bis-med-it.github.io/SDMX-dashboard-generator"
app = Dash(
__name__,
external_stylesheets=external_stylesheets,
prevent_initial_callbacks="initial_duplicate",
suppress_callback_exceptions=True,
meta_tags=[
{
"name": "viewport",
"content": "width=device-width, initial-scale=1.0",
}
],
)
app.title = "SDMX Dashboard Generator"
application = app.server
dash.register_page("Home", layout="Home", path="/")
app.layout = html.Div(
[
dcc.Location(id="url", refresh=False),
dbc.NavbarSimple(
class_name="mb-3",
brand="SDMX Dashboard Generator.\
A web application for SMDX data and metadata rendering",
color="primary",
dark=True,
children=[
dbc.DropdownMenu(
label="Language",
children=[
dbc.DropdownMenuItem("English", id="en"),
dbc.DropdownMenuItem(divider=True),
dbc.DropdownMenuItem("Français", id="fr"),
dbc.DropdownMenuItem("Deutsch", id="de"),
dbc.DropdownMenuItem("Español", id="es"),
],
direction="down",
),
dbc.Button(
children=[html.I(className="fa fa-cog")],
id="collapse-button",
className="mb-3",
n_clicks=0,
),
dbc.Button(
children=[html.I(className="bi bi-info-circle")],
id="open",
className="mb-3",
n_clicks=0,
),
dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("SDMX Dashboard Generator")),
dbc.ModalBody(
[
html.Div(
[
html.B("SDMX Dashboard Generator"),
" is an open-source Dash \
application that generates dynamic dashboards by\
pulling data and metadata from SDMX Rest API. \
It supports the version 2.1 of the standard.",
html.Br(),
html.Br(),
"It leverages the open-source library SDMXthon\
to retrieve and parse data and metadata in SDMX.\
A dashboard is composed of several visualizations \
as defined by the specifications provided in \
a .yaml file stored in the /yaml folder.",
html.Br(),
html.Br(),
"The full documentation is available at ",
html.A(
["GitHub Pages"],
href=DOC_LINK,
target="_blank",
),
]
)
]
),
dbc.ModalFooter(
dbc.Button(
"Close", id="close", className="ms-auto", n_clicks=0
)
),
],
id="modal",
is_open=False,
),
],
),
dcc.Store(id="locale"),
dcc.Store(id="yaml_file"),
dcc.Store(id="settings"),
dcc.Store(id="is_loaded"),
dcc.Store(id="spinner"),
dcc.Store(id="spinner2"),
dcc.Store(id="get_data"),
dcc.Store(id="get_data_complete"),
dcc.Store(id="footer"),
dbc.Container(
[
dbc.Collapse(
dbc.Card(
dbc.CardBody(
dcc.Upload(
id="upload-data",
children=html.Div(
[
"Drag and drop or ",
html.A("select the configuration YAML file"),
]
),
style={
"width": "100%",
"height": "60px",
"lineHeight": "60px",
"borderWidth": "1px",
"borderStyle": "dashed",
"borderRadius": "5px",
"textAlign": "center",
"margin": "10px",
},
multiple=False,
),
)
),
id="collapse",
is_open=True,
),
html.Div(id="title_div"),
html.Div(
children=[
dcc.Loading(
id="loading-1",
type="default",
children=html.Div(id="spinner-id"),
)
]
),
html.Div(
children=[
dcc.Loading(
id="loading-2",
type="default",
children=html.Div(id="spinner-id2"),
)
]
),
dcc.Download(id="download_data"),
html.Div(id="charts_div"),
html.Div(id="footer_div"),
html.Div(id="yaml_file_invalid"),
]
),
]
)
@callback(
Output("locale", "data"),
[
Input("en", "n_clicks"),
Input("fr", "n_clicks"),
Input("de", "n_clicks"),
Input("es", "n_clicks"),
],
)
def get_language(*args):
"""Get the language code as returned by the callback
:param *args: the language code clicked in the dropdown
:returns: string with the language code requested which is cached
"""
ctx = dash.callback_context
if not ctx.triggered:
button_id = "en"
else:
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
return button_id
@callback(
Output("collapse", "is_open"),
Input("collapse-button", "n_clicks"),
Input("is_loaded", "data"),
[State("collapse", "is_open")],
prevent_initial_call=True,
)
def toggle_collapse(n: int, is_open: bool, is_loaded: bool):
"""Control the behaviour of the toggle menu
of the settings
:param n: int: the cumulative number of clicks since the start of the session
:param is_open: bool: whether the toggle menu is open
:param is_loaded: bool: whether the settings are loaded
:returns: a boolean to control the behaviour of the toggle menu of the settings
"""
if is_loaded:
is_open = False
elif n:
is_open = True
return is_open
@app.callback(
Output("modal", "is_open"),
[Input("open", "n_clicks"), Input("close", "n_clicks")],
[State("modal", "is_open")],
)
def toggle_modal(open_clicks: int, close_clicks: int, is_open: bool):
"""Control the behaviour of the modal (Info)
:param open_clicks: int: the cumulative number of clicks to open the modal
:param close_clicks: int: the cumulative number of clicks to close the modal
:param is_open: bool: whether the modal is open
:returns: a boolean that contol the behaviour of the modal (Info)
"""
if open_clicks or close_clicks:
return not is_open
return is_open
[docs]
def load_yamlfile(filename: str, folder: str = None) -> dict:
"""Load the settings from the YAML file
:param filename: str: the YAML file
:param folder: str, optional: the YAML file folder location
:returns: a dictionary with loaded settings from the YAML file
"""
try:
path = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
if folder:
fpath = os.path.join(path, folder, filename)
else:
fpath = os.path.join(path, filename)
with open(fpath, encoding="utf-8") as f:
settings = yaml.safe_load(f)
return settings
except yaml.YAMLError as exc:
print(exc)
except Exception as e:
print(e)
raise PreventUpdate from e
@callback(
Output("settings", "data"),
Output("is_loaded", "data"),
Output("yaml_file_invalid", "children"),
Input("upload-data", "contents"),
State("upload-data", "filename"),
)
def load_content_uploaded(uploaded_file, filename):
"""update_output returns a dictionary with the settings from the uploaded
YAML file and a boolean on whether the settings are loaded
:param uploaded_file: the path of the YAML file
:returns: a dictionary with the settings and a boolean when the loading is completed
"""
if uploaded_file is None:
raise PreventUpdate
elif ".yaml" not in filename:
raise PreventUpdate
try:
content_type, content_string = uploaded_file.split(",")
decoded = base64.b64decode(content_string)
data = yaml.safe_load(io.BytesIO(decoded))
validation = validate_yamlfile(data)
if validation:
out = None, None, error_box(f"Invalid YAML file. Error:{validation.code}")
else:
is_loaded = True
out = data, is_loaded, ""
return out
except Exception as e:
print(e)
raise PreventUpdate from e
@callback(
Output("yaml_file", "data"), [Input("url", "href")], prevent_initial_call=True
)
def load_yaml(href: str):
"""Get the location of the YAML file whose dashID matches
the string provided in the href of the URL
:param href: str: the dashID
:returns: a string with the location of the requested YAML file
"""
try:
files = glob.glob(r"yaml/*.yaml")
yaml_files = []
for file in files:
yaml_content = load_yamlfile(file)
dash_file_map = {"dash_id": yaml_content["DashID"], "location": file}
yaml_files.append(dash_file_map)
f = furl(href)
dash_id_url = str(f.path)
dash_id_url_s = "".join(dash_id_url.split("/", 1))
if dash_id_url != "/":
yaml_location = ''.join([d["location"] for d in yaml_files if d['dash_id'] == dash_id_url_s])
if dash_id_url == "/":
raise PreventUpdate
yaml_location = "".join(
[d["location"] for d in yaml_files if d["dash_id"] == dash_id_url_s]
)
return yaml_location
except Exception as e:
print(e)
@callback(
Output("settings", "data", allow_duplicate=True),
Output("is_loaded", "data", allow_duplicate=True),
Output("yaml_file_invalid", "children", allow_duplicate=True),
[Input("yaml_file", "data")],
prevent_initial_call=True,
)
def load_content(yaml_file):
"""Load a dictionary with the settings from the YAML file
and a boolean on whether the settings are loaded
:param yaml_file: the relative path of the YAML file
:returns: a dictionary with the settings and a boolean when the loading is completed
"""
try:
if yaml_file is None:
raise PreventUpdate
data = load_yamlfile(yaml_file)
validation = validate_yamlfile(data)
if validation:
out = None, None, error_box(f"Invalid YAML file. Error:{validation.code}")
else:
is_loaded = True
out = data, is_loaded, ""
return out
except Exception as e:
print(e)
raise PreventUpdate from e
@callback(
Output("url", "pathname"), Input("settings", "data"), prevent_initial_call=True
)
def get_dash_id(i):
"""Apply snake case to the DashID from the YAML file settings
:param i: a dictionary with settings from the YAML file
:returns: a string with the snaked cased DashId
"""
if i is None:
raise PreventUpdate
try:
return snake_case(i["DashID"])
except Exception as e:
print(e)
return str(e)
[docs]
def generate_title(data, key: str):
"""Generate the title from the YAML file
:param data: a dictionary with settings from the YAML file
:param key: str: the key (TITLE) from settings from the YAML file
:returns: a html.Span with the title of the dashboard as specified in the YAML file
"""
try:
dash_title_footer = get_title_footer(data)
element1 = "".join(
[d["Title"] for d in dash_title_footer if d["chartType"] == key]
)
element2 = "".join(
[d["Subtitle"] for d in dash_title_footer if d["chartType"] == key]
)
if element2:
element = html.Span(
[
html.Br(),
html.Br(),
html.H1(element1, style={"textAlign": "center"}),
html.H4(element2, style={"textAlign": "center"}),
html.Br(),
]
)
else:
element = html.Span(
[
html.Br(),
html.Br(),
html.H1(element1, style={"textAlign": "center"}),
html.Br(),
]
)
return element
except Exception as e:
print(e)
return str(e)
@callback(
Output("title_div", "children"),
Output("spinner", "data"),
Input("settings", "data"),
prevent_initial_call=True,
)
def get_dashboard_title(data):
"""Return the title to the dashboard and control
the behaviour of the loading spinner
:param data: a dictionary with settings from the YAML file
:returns: a html.Span with the title of the dashboard and an integer
that controls the behaviour of the loading spinner
"""
if data is None:
raise PreventUpdate
try:
title = generate_title(data["Rows"], key="TITLE")
spinner = ""
return title, spinner
except Exception as e:
print(e)
return str(e)
[docs]
def get_text_kpi(kpi, code, chart):
"""Build the text for KPI card
:param kpi: the ChartGenerator kpi object
:param code: the code of the element used as subtitle in the KPI card
:param chart: the chart settings
:returns: the html element with the kpi text
"""
try:
unit_show = chart["UnitShow"]
except Exception as e:
print(e)
unit_show = None
if unit_show == "Yes":
out = html.P(
[
html.H2(
[str(kpi[code][1]) + " " + chart["Unit"]],
className="card-title",
style={"text-align": "center"},
)
]
)
else:
out = html.P(
[
html.H2(
[str(kpi[code][1])],
className="card-title",
style={"text-align": "center"},
)
]
)
return out
[docs]
def get_icon_kpi(kpi, code, chart):
"""Build the icon for KPI card
:param kpi: The ChartGenerator kpi object
:param code: the code of the element used as subtitle in the KPI card
:param chart: The chart settings
:returns: The html element containing the kpi icon
"""
try:
unit_icon = chart["UnitIcon"]
except Exception as e:
print(e)
unit_icon = None
if unit_icon:
out = [
html.P(html.I(className=unit_icon), style={"text-align": "center"}),
html.H5(
str(kpi[code][0]),
className="card-title",
style={"text-align": "center"},
),
get_text_kpi(kpi, code, chart),
]
else:
out = [
html.H5(
str(kpi[code][0]),
className="card-title",
style={"text-align": "center"},
),
get_text_kpi(kpi, code, chart),
]
return out
[docs]
def draw_chart(df, chart):
"""Generate the chart element
:param df: the pandas.DataFrame containing data
:param chart: the chart settings loaded from the yaml file
:raises ValueError: in case x or y axis is not specified.
:returns: the html element containing the graph
"""
error_message = "Error in fetching the data, please check the YAML file: "
if chart["xAxisConcept"] is None or chart["yAxisConcept"] is None:
raise ValueError("Please provide xAxisConcept")
chart_type = chart["chartType"]
config = {"displayModeBar": False}
if df.empty or not isinstance(df, pd.DataFrame):
return error_box("Data is empty. Please check the YAML file")
if chart_type == "VALUE":
try:
kpi = ChartGenerator().calculate_kpi(
df,
yAxisConcept=chart["yAxisConcept"],
xAxisConcept=chart["xAxisConcept"],
legendConcept=chart["legendConcept"],
decimals=chart["Decimals"],
)
code = list(kpi.keys())[0]
return dbc.Col(
dbc.CardBody(
get_icon_kpi(kpi, code, chart),
className="shadow-lg p-3 mb-5 bg-transparent rounded",
)
)
except Exception as e:
return error_box("Something went wrong. Please check the YAML file: ", e)
else:
try:
if chart_type == "PIE":
fig = ChartGenerator().pie_chart(
df,
yAxisConcept=chart["yAxisConcept"],
xAxisConcept=chart["xAxisConcept"],
legendLoc=chart["legendLoc"],
LabelsYN=chart["LabelsYN"],
)
elif chart_type == "BAR":
fig = ChartGenerator().bar_chart(
df,
yAxisConcept=chart["yAxisConcept"],
xAxisConcept=chart["xAxisConcept"],
color=chart["legendConcept"],
legendLoc=chart["legendLoc"],
)
else:
fig = ChartGenerator().time_series_chart(
df,
yAxisConcept=chart["yAxisConcept"],
xAxisConcept=chart["xAxisConcept"],
color=chart["legendConcept"],
legendLoc=chart["legendLoc"],
)
return dbc.Col(
html.Div(
dbc.CardBody(
dcc.Graph(figure=fig, config=config),
className="shadow-lg p-3 mb-5 bg-transparent rounded",
)
),
align="start",
)
except Exception as e:
return dbc.Col(
html.Div(dbc.CardBody([html.P(str(error_message + str(e)))]))
)
@callback(
Output({"type": "off-canvas", "index": MATCH}, "is_open"),
Input({"type": "list-item", "index": MATCH}, "n_clicks"),
prevent_initial_call=True,
)
def open_metadata_offcanvas(click):
"""Open metadata offcanvas on click if closed"""
open_offcanvas = bool(click)
return open_offcanvas
[docs]
def create_offcanvas(data, chart_id, df_metadata):
"""Create an off-canvas user interface component for displaying dataflow metadata
:param data: the chart datastructure
:param chart_id: reference chart
:param df_metadata: metadata for the chart
:returns: the offcanvas element shown on info button click
"""
try:
return html.Div(
[
dbc.Offcanvas(
[get_dataflow_metadata(data, df_metadata)],
id={"type": "off-canvas", "index": chart_id},
is_open=False,
scrollable=True,
)
]
)
except Exception as e:
print(e)
return html.Div(data["Title"])
@callback(
Output({"type": "off-canvas2", "index": MATCH}, "is_open"),
Input({"type": "list-item2", "index": MATCH}, "n_clicks"),
prevent_initial_call=True,
)
def open_table_offcanvas(click):
"""Open table offcanvas on click
:param click: a boolean that controls the behabiour of the off-canvas
:returns: the offcanvas element shown on button click
"""
open_offcanvas = bool(click)
return open_offcanvas
[docs]
def create_download(chart_id):
"""Create the download section of the offcanvas
:param chart_id: reference chart
:returns: dashboard component with download section
"""
listgroup_item_down = dbc.ListGroupItem(
[
html.P(
[
"Click the icon to download the table :",
html.Br(),
create_download_button(chart_id),
],
n_clicks=0,
)
],
id={"type": "list-download", "index": chart_id},
n_clicks=0,
className="border-0 text-nowrap list-group-item-action",
)
toast = dbc.Col(
[
html.Div(
[
dbc.Toast(
[html.P(listgroup_item_down, className="mb-0")],
header="Download",
)
]
)
]
)
return toast
[docs]
def create_filter_dropdown(df: pd.DataFrame, concept: str, chart_id: str, valuelist):
"""update_filter_output updates the filter dropdown for the data
Args:
n_clicks: the clicks on the filter dropdown
data: a pd.DataFrame with the data without any fikter applied
Returns:
data: a dictionary with the data filtered
"""
try:
if len(valuelist) > 1:
try:
lst_value = list(set(list(df[concept + "_id"])))
return html.Div(
[
dbc.Row(
[
dbc.Col(
dcc.Dropdown(
valuelist,
lst_value,
multi=True,
id={"type": "list-dropdown", "index": chart_id},
),
width=9,
),
dbc.Col(
dbc.Button(
"OK",
id={
"type": "list-dropdown-btn",
"index": chart_id,
},
n_clicks=0,
size="sm",
),
width=1,
),
]
)
]
)
except Exception:
return html.Div("")
else:
return html.Div("")
except Exception:
return html.Div("")
@callback(
Output("get_data", "data", allow_duplicate=True),
Input({"type": "list-dropdown-btn", "index": ALL}, "n_clicks"),
Input("get_data_complete", "data"),
State({"type": "list-dropdown", "index": ALL}, "value"),
prevent_initial_call=True,
)
def update_output(n_clicks, data, values):
"""create_filter_dropdown creates the filter dropdown for the data
Args:
df: pd.DataFrame containg the data
concept: the legendConcept as specified in the YAML file
chart_id: the chart ID
valuelist: the valuelist containing the unique values of the legendConcept
Returns:
html.Div: a dcc.Dropdown and a dbc.Button containing the values to filter
"""
if sum(filter(None, n_clicks)) == 0:
raise PreventUpdate
chart_id = ctx.triggered_id.index
row = int(chart_id[0])
pos = int(chart_id[1])
states_list = ctx.states_list[0]
val = [i["value"] for i in states_list if i["id"]["index"] == chart_id][0]
legendConcept = data[row][pos]["settings"]["legendConcept"]
df = pd.DataFrame(data[row][pos]["data"])
df_filtered = df.loc[df[legendConcept].isin(val)]
data[row][pos]["data"] = df_filtered.to_dict("records")
return data
[docs]
def create_offcanvas_table(data, chart_id, df, valuelist):
"""Create the table offcanvas element of the dashboard.
The structure depends on whether the data can be downloaded or not.
:param data: the chart data structure
:param chart_id: reference chart
:param df: the chart data
:param valuelist: values for filtering
:returns: the offcanvas element shown when the table button is clicked
"""
try:
# Check if the data has a "downloadYN" flag set to "Y" for enabling downloads.
if data["downloadYN"] == "Yes":
# Create an HTML strucesture with an off-canvas element for a chart
# with download capability.
out = html.Div(
[
dbc.Offcanvas(
children=[
dbc.Row(
[
# Create a column for displaying unit information
# and source URL as toasts.
dbc.Col(
[
create_toast(
data=data["Unit"], header="Unit"
),
create_toast(
data=data["DATA"],
header="Source URL",
href=True,
),
create_download(chart_id),
],
align="start",
width=3,
),
# Create a column for displaying the table
# associated with the chart.
dbc.Col(
[
create_filter_dropdown(
df,
data["legendConcept"],
chart_id,
valuelist,
),
html.Hr(className="my-2"),
create_table(chart_id, df),
],
id=chart_id,
width=9,
),
],
id=chart_id,
)
],
id={"type": "off-canvas2", "index": chart_id},
is_open=False,
scrollable=True,
placement="bottom",
style={"height": "500px"},
)
]
)
else:
# Create an HTML structure with an off-canvas element for a chart
# without download capability.
out = html.Div(
[
dbc.Offcanvas(
children=[
dbc.Row(
[
# Create a column for displaying unit information
# and source URL as toasts.
dbc.Col(
[
create_toast(data["Unit"], "Unit"),
create_toast(data["DATA"], "Source URL"),
],
align="center",
width=3,
),
# Create a column for displaying the table
# associated with the chart.
dbc.Col(
[create_table(chart_id, df)],
id=chart_id,
width=9,
),
]
)
],
id={"type": "off-canvas2", "index": chart_id},
is_open=False,
scrollable=True,
placement="bottom",
style={"height": "500px"},
)
]
)
return out
except Exception as e:
# Handle exceptions and print the error message.
print(e)
return html.Div("")
@callback(
Output("download_data", "data"),
Input({"type": "list-download", "index": ALL}, "n_clicks"),
State({"type": "data_table", "index": ALL}, "data"),
prevent_initial_call=True,
)
def download_table(n_clicks, data):
"""download_table returns the export table in CSV.
:param n_clicks: int: the number of times that the list-download
chart-id has been clicked on
:param data: the DataTable returned by create_table()
:returns: triggers dcc.send_data_frame to download the table as CSV
"""
id_tag = ctx.triggered_id.index
states_list = ctx.states_list[0]
data = [i["value"] for i in states_list if i["id"]["index"] == id_tag][0]
if sum(filter(None, n_clicks)) == 0:
raise PreventUpdate
df = pd.DataFrame(data)
return dcc.send_data_frame(df.to_csv, "export_table.csv", index=False)
[docs]
def create_table(chart_id: str, df: pd.DataFrame):
"""create_table returns the Dash DataTable to be displayed in the offcanvas
and exported.
:param chart_id: str: the unique chart ID which correspond to the row and the
sequential number (int) of the chart as specified in the YAML
:param df: pd.DataFrame: the pd.DataFrame associated with the chart ID
:returns: Dash DataTable
"""
# Convert the DataFrame to a list of records and configure DataTable
return DataTable(
data=df.to_dict("records"),
id={"type": "data_table", "index": chart_id},
sort_action="native",
style_table={"overflowX": "auto"},
style_cell={"backgroundColor": "white", "color": "black"},
style_header={
"backgroundColor": "black",
"color": "white",
"fontWeight": "bold",
}
# editable=False # Enable editing
# row_deletable=False # Enable row deletion
)
[docs]
def create_chart_item(
data: dict, chart_id: str, df_metadata: list, df: pd.DataFrame, valuelist: list
):
"""create_chart_item returns the HTML div for the info and table buttons
:param data: dict: the settings of the chart as specified in the YAML
:param chart_id: str: the unique chart ID which correspond to the row and the
sequential number (int) of the chart as specified in the YAML
:param df_metadata: list: the list of metadata associated with the chart
:param df: pd.DataFrame: the DataFrame associated with the chart ID
:returns: html.Div with the info and table buttons
"""
# Create a list group item with an info button for displaying metadata.
listgroup_item = dbc.ListGroupItem(
[html.Div([create_info_button(chart_id)])],
id={"type": "list-item", "index": chart_id},
n_clicks=0,
className="border-0 text-nowrap list-group-item-action",
)
# Create a list group item with a table button for displaying chart-related tables.
listgroup_item_down = dbc.ListGroupItem(
[html.Div([create_table_button(chart_id)])],
id={"type": "list-item2", "index": chart_id},
n_clicks=0,
className="border-0 text-nowrap list-group-item-action",
)
# Check if metadata link exists and metadata is available.
if (data["metadataLink"]) and (df_metadata):
try:
# Create a row with two columns: one for info button and off-canvas,
# and another for table button and off-canvas.
return dbc.Row(
[
dbc.Col(
html.Div(
children=[
listgroup_item,
create_offcanvas(data, chart_id, df_metadata),
]
)
),
dbc.Col(
html.Div(
children=[
listgroup_item_down,
create_offcanvas_table(data, chart_id, df, valuelist),
]
)
),
]
)
except Exception as e:
# Handle exceptions and print the error message.
print(e)
return html.Div([])
else:
try:
# Create a row with a single column for the table button and off-canvas.
return dbc.Row(
[
dbc.Col(
[
html.Div(
[
listgroup_item_down,
create_offcanvas_table(
data, chart_id, df, valuelist
),
]
)
]
)
]
)
except Exception as e:
# Handle exceptions and print the error message.
print(e)
return html.Div([])
return html.Div([])
[docs]
def create_toast(data, header: str, href=False):
"""create_toast returns the dbc.Toast with the statistic metadata set in the YAML
:param data: the corresponding value of the chart as specified in the YAML
:param header: str: the corresponding key associated to each chart in the YAML file
:param href: bool: whether the text shall be encoded as link
:returns: dbc.Toast with the statistic metadata,
Unit and source (DATA) set in the YAML
"""
if href:
toast = dbc.Col(
[
html.Div(
[
dbc.Toast(
[
html.P(
html.A(
children=[str(data)],
href=str(data),
target="_blank",
),
className="mb-0",
)
],
header=header,
)
]
)
]
)
else:
toast = dbc.Col(
[
html.Div(
[dbc.Toast([html.P(str(data), className="mb-0")], header=header)]
)
]
)
return toast
[docs]
def get_rows(data: dict, max_charts_per_row: int = 3):
"""get_rows returns the distribution of the charts per row in the dashboard
:param data: dict: the settings of the chart as specified in the YAML
:param max_charts_per_row: int: the maximum charts per row (Default value = 3)
:returns: list with the distribution of the charts per row
"""
try:
rows = list({i["Row"] for i in data})
charts_per_row = []
for row in rows:
charts_in_row = [d for d in data if d["Row"] == row]
if len(charts_in_row) > max_charts_per_row:
charts_in_row = charts_in_row[:max_charts_per_row]
charts_per_row.append(charts_in_row)
return charts_per_row
except Exception as e:
print(e)
[docs]
async def download_single_data_chart(chart_id, data, concept):
"""Download data for a single chart
:param chart_id: the chart ID corresponding to row number and position in row
:param data: the DATA link specified in the YAML file
:param concept: the concept specified in the YAML file
:returns: list of couroutines with downloaded data as pd.DataFrame
"""
print("Getting data", chart_id)
try:
df = await SDMXData(data=data).get_data_async(yAxisConcept=concept)
except Exception as e:
print(
f"There has been a problem in downloading data for {chart_id}. Error: {e}"
)
df = pd.DataFrame
return df
[docs]
async def download_single_chart(data_chart, row: int, pos: int):
"""Download data and metadata for a single chart
:param data_chart: the settings of the single chart
:param z: int: Row number
:param y: int: Position in row
:returns: dict with chart settings and data
"""
chart_id = f"{row}{pos}"
# Data
task = asyncio.create_task(
download_single_data_chart(
chart_id, data_chart["DATA"], data_chart["yAxisConcept"]
)
)
try:
df = await asyncio.wait_for(task, timeout=30)
except asyncio.exceptions.TimeoutError:
df = pd.DataFrame()
print(f"Data download for chart {chart_id} was cancelled due to a timeout")
except Exception as e:
df = pd.DataFrame()
print(
f"There has been a problem dowloading data for chart {chart_id}. Error:{e}"
)
print("Getting metadata", chart_id)
concept = data_chart["legendConcept"]
try:
# If dsdLink is provided, this increases significantly the overall performance
dsdLink = data_chart["dsdLink"]
metadata_components = await get_components_async(
url=data_chart["metadataLink"], descendants=False
)
metadata_dataflow = SDMXMetadata(
components=metadata_components
).dataflow_metadata()
if concept:
components = await get_components_async(dsdLink, descendants=False)
cl_name = SDMXMetadata(components, concept=concept).get_codelist_name()
cl_url = get_url_cl(dsdLink, cl_name)
cl_id_all = await get_components_async(cl_url, descendants=False)
cl_id = cl_id_all["Codelists"][cl_name]
metadata_codelist = retreive_codes_from_data(df, concept, cl_id)
else:
metadata_codelist = None
# Fallback to descendants but less performant
except Exception as e:
print(f"Invalid dsdLink for {chart_id}. Falling back to default. Error:{e}")
if data_chart["metadataLink"]:
# Metadata
try:
metadata_components = await get_components_async(
url=data_chart["metadataLink"]
)
# Dataflow
metadata_dataflow = SDMXMetadata(
components=metadata_components
).dataflow_metadata()
# Codelist
if concept:
cl_id = SDMXMetadata(
metadata_components, concept
).get_codelist_name()
metadata_codelist = retreive_codes_from_data(df, concept, cl_id)
else:
metadata_codelist = None
except Exception as e_n:
print(f"There has been an issue with the metadata. Error{e_n}")
metadata_dataflow = {
"name": {"en": data_chart["Title"]},
"description": {
"en": str(
data_chart["Subtitle"]
+ " Please provide a valid dataflow link\
to retreive the metadata"
)
},
}
metadata_codelist = None
else:
metadata_dataflow = {
"name": {"en": data_chart["Title"]},
"description": {
"en": str(data_chart["Subtitle"])
+ " Please add a dataflow link in your configuration file\
to retreive the metadata"
},
}
metadata_codelist = None
try:
valuelist_obj = list(set(list(df[concept]))) if df is not None else None
except Exception as valuelist_err:
print(f"There has been an issue with the metadata. Error{valuelist_err}")
valuelist_obj = None
result = {
"chart_id": chart_id,
"settings": data_chart,
"data": df.to_dict("records") if df is not None else None,
"valuelist": valuelist_obj,
"metadata_dataflow": metadata_dataflow,
"metadata_codelist": metadata_codelist,
}
print("Done with", chart_id)
return result
[docs]
async def download_charts(chart_per_rows):
"""Download chart data asyncronously
:param chart_per_rows: output of get_rows()
:returns: chart data and metadata, split in row lists
"""
all_cors = []
row_lengths = []
for row, chart in enumerate(chart_per_rows):
data_charts = list(chart)
row_lengths.append(len(data_charts))
for pos, data_chart in enumerate(data_charts):
cor = download_single_chart(data_chart, row, pos)
all_cors.append(cor)
all_charts = await asyncio.gather(*all_cors)
all_charts = iter(all_charts)
charts_per_r = [list(islice(all_charts, i)) for i in row_lengths]
return charts_per_r
@callback(
Output("get_data", "data"),
Output("get_data_complete", "data"),
Output("footer", "data"),
Output("spinner-id", "children"),
Output("spinner2", "data"),
[Input("settings", "data")],
Input("spinner", "data"),
prevent_initial_call=True,
)
def download_data(settings, value):
"""download_data returns the cached data and the footer required to build the
charts_div and footer_div
:param settings: the settings of the chart as specified in the YAML
:param value: any value that controls the behaviour of the spinner
:returns: a dictionary with the cached data required to build the charts_div,
the footer, the spinners 1 and 2
"""
if settings is None:
raise PreventUpdate
if settings:
charts = [d for d in settings["Rows"] if d["Row"] != 0]
chart_per_rows = get_rows(charts)
footer = generate_footer(settings["Rows"], key="FOOTER")
if platform.system() == "Windows":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
charts_per_r = asyncio.run(download_charts(chart_per_rows))
spinner = ""
return charts_per_r, charts_per_r, footer, value, spinner
@callback(
Output("charts_div", "children"),
Output("footer_div", "children"),
Output("spinner-id2", "children"),
[Input("get_data", "data")],
[Input("footer", "data")],
[Input("locale", "data")],
Input("spinner2", "data"),
prevent_initial_call=True,
)
def add_graphs(data, footer, lang, value):
"""add_graphs returns the charts div in the dashboard page
:param data: the cached data of the charts as returned by download_data
:param footer: the footer div generated by download_data
:param lang: the language selected in the dropdown
:param value: anyvalue that controls the behaviour of the spinner
:returns: html.Div with the charts loaded in the cache
"""
global LANG
LANG = lang
if data is None:
raise PreventUpdate
if data:
try:
charts_per_r = []
for data_per_row in data:
graphs = []
texts = []
for data_per_row_pos in data_per_row:
chart_id = data_per_row_pos["chart_id"]
data_chart = data_per_row_pos["settings"]
df = pd.DataFrame(data_per_row_pos["data"])
valuelist = data_per_row_pos["valuelist"]
metadata_dataflow = data_per_row_pos["metadata_dataflow"]
try:
metadata_dataflow_translated = [
get_translation(metadata_dataflow[i], lang)
for i in list(metadata_dataflow.keys())
]
except Exception as e:
print(e)
metadata_codelist = data_per_row_pos["metadata_codelist"]
if metadata_codelist:
try:
metadata_codelist_items_translated = {
i: get_translation(metadata_codelist["items"][i], lang)
for i in list(metadata_codelist["items"].keys())
}
except Exception as e:
metadata_codelist_items_translated = metadata_codelist
print(f"Could not translate codes in codelist. Error:{e}")
concept = data_chart["legendConcept"]
df = translate_df(
df, concept, metadata_codelist_items_translated
)
fig = draw_chart(df, data_chart)
text = get_static_metatada(
data_chart,
chart_id,
metadata_dataflow_translated,
df,
valuelist,
)
texts.append(text)
graphs.append(fig)
chart = dbc.Card(
[
dbc.Row(texts, className="d-flex justify-content-around"),
dbc.Row(graphs, className="d-flex justify-content-around"),
]
)
charts_per_r.append(chart)
return charts_per_r, footer, value
except Exception as e:
print(e)
if __name__ == "__main__":
app.run(debug=False, dev_tools_ui=False, dev_tools_props_check=False)