Coverage for /home/ubuntu/shekels/python/shekels/server/components.py: 100%
116 statements
« prev ^ index » next coverage.py v7.1.0, created at 2023-11-15 00:54 +0000
« prev ^ index » next coverage.py v7.1.0, created at 2023-11-15 00:54 +0000
1from typing import Any, Dict, List, Optional # noqa: F401
2import flask # noqa: F401
4from copy import copy
6from lunchbox.enforce import Enforce, EnforceError
7from pandas import DataFrame, DatetimeIndex
8from schematics.exceptions import DataError
9import dash
10from dash import dash_table
11from dash import dcc
12from dash import html
13import lunchbox.tools as lbt
14import rolling_pin.blob_etl as rpb
16import shekels.core.config as cfg
17import shekels.core.data_tools as sdt
18# ------------------------------------------------------------------------------
21# TODO: refactor components tests to use selnium and be less brittle
22# TODO: add JSON editor component for config
23# APP---------------------------------------------------------------------------
24def get_dash_app(server, storage_type='memory'):
25 # type: (flask.Flask, str) -> dash.Dash
26 '''
27 Generate Dash Flask app instance.
29 Args:
30 server (Flask): Flask instance.
31 storage_type (str): Storage type (used for testing). Default: memory.
33 Returns:
34 Dash: Dash app instance.
35 '''
37 store = dcc.Store(id='store', storage_type=storage_type)
39 icon = html.Img(id='icon', src='/assets/icon.svg')
40 tabs = dcc.Tabs(
41 id='tabs',
42 className='tabs',
43 value='plots',
44 children=[
45 dcc.Tab(className='tab', label='plots', value='plots'),
46 dcc.Tab(className='tab', label='data', value='data'),
47 dcc.Tab(className='tab', label='config', value='config'),
48 dcc.Tab(className='tab', label='api', value='api'),
49 dcc.Tab(className='tab', label='docs', value='docs'),
50 dcc.Tab(className='tab', label='monitor', value='monitor'),
51 ],
52 )
53 tabs = html.Div(id='tabs-container', children=[icon, tabs])
55 content = dcc.Loading(
56 id="content",
57 className='content',
58 type="dot",
59 fullscreen=True,
60 )
62 assets = lbt.relative_path(__file__, "../../../resources")
64 app = dash.Dash(
65 name='Shekels',
66 title='Shekels',
67 server=server,
68 external_stylesheets=['/static/style.css'],
69 assets_folder=assets,
70 )
71 app.layout = html.Div(id='layout', children=[store, tabs, content])
72 app.config['suppress_callback_exceptions'] = True
74 return app
77# TABS--------------------------------------------------------------------------
78def get_data_tab(query=None):
79 # type: (Optional[str]) -> List
80 '''
81 Get tab element for Shekels data.
83 Args:
84 query (str, optional): Query string. Default: None.
86 Return:
87 list: List of elements for data tab.
88 '''
89 # dummies must go first for element props behavior to work
90 content = html.Div(id='lower-content', children=[
91 html.Div(id='data-content', className='col', children=[])
92 ])
93 return [*get_dummy_elements(), get_searchbar(query), content]
96def get_plots_tab(query=None):
97 # type: (Optional[str]) -> List
98 '''
99 Get tab element for Shekels plots.
101 Args:
102 query (str, optional): Query string. Default: None.
104 Return:
105 list: List of elements for plots tab.
106 '''
107 # dummies must go first for element props behavior to work
108 content = html.Div(id='lower-content', children=[
109 html.Div(id='plots-content', className='col', children=[
110 dcc.Loading(id="progress-bar", type="circle")
111 ])
112 ])
113 return [*get_dummy_elements(), get_searchbar(query), content]
116def get_config_tab(config):
117 # type: (Dict) -> List
118 '''
119 Get tab element for Shekels config.
121 Args:
122 config (dict): Configuration to be displayed.
124 Return:
125 list: List of elements for config tab.
126 '''
127 # dummies must go first for element props behavior to work
128 content = html.Div(id='lower-content', children=[
129 html.Div(id='config-content', className='col', children=[
130 get_key_value_table(
131 config, id_='config', header='config', editable=True
132 )
133 ])
134 ])
135 return [*get_dummy_elements(), get_configbar(config), content]
138# MENUBARS----------------------------------------------------------------------
139def get_searchbar(query=None):
140 # type: (Optional[str]) -> html.Div
141 '''
142 Get a row of elements used for querying Shekels data.
144 Args:
145 query (str, optional): Query string. Default: None.
147 Returns:
148 Div: Div with query field and buttons.
149 '''
150 if query is None:
151 query = 'select * from data'
153 spacer = html.Div(className='col spacer')
154 query = dcc.Input(
155 id='query',
156 className='col query',
157 value=query,
158 placeholder='SQL query that uses "FROM data"',
159 type='text',
160 autoFocus=True,
161 debounce=True
162 )
164 search = get_button('search')
165 init = get_button('init')
166 update = get_button('update')
168 row = html.Div(
169 className='row',
170 children=[query, spacer, search, spacer, init, spacer, update],
171 )
172 searchbar = html.Div(id='searchbar', className='menubar', children=[row])
173 return searchbar
176def get_dummy_elements():
177 # type: () -> List
178 '''
179 Returns a list of all elements with callbacks so that the client will not
180 throw errors in each tab.
182 Returns:
183 list: List of html elements.
184 '''
185 return [
186 dcc.Input(className='dummy', id='config-query', value=None),
187 html.Div(className='dummy', children=[dash_table.DataTable(id='config-table')]),
188 dcc.Input(className='dummy', id='query', value=None),
189 html.Div(className='dummy', id='config-search-button', n_clicks=None),
190 html.Div(className='dummy', id='search-button', n_clicks=None),
191 html.Div(className='dummy', id='init-button', n_clicks=None),
192 html.Div(className='dummy', id='update-button', n_clicks=None),
193 dcc.Upload(className='dummy', id='upload', contents=None),
194 html.Div(className='dummy', id='save-button', n_clicks=None),
195 ]
198def get_configbar(config, query='select * from config'):
199 # type: (Dict, Optional[str]) -> html.Div
200 '''
201 Get a row of elements used for configuring Shekels.
203 Args:
204 config (dict): Configuration to be displayed.
205 query (str, optional): Query string. Default: None.
207 Returns:
208 Div: Div with buttons and JSON editor.
209 '''
210 spacer = html.Div(className='col spacer')
211 query = dcc.Input(
212 id='config-query',
213 className='col query',
214 value=query,
215 placeholder='SQL query that uses "FROM config"',
216 type='text',
217 autoFocus=True,
218 debounce=True
219 )
221 search = get_button('search')
222 search.id = 'config-search-button'
223 init = get_button('init')
224 upload = dcc.Upload(
225 id='upload',
226 children=[get_button('upload')]
227 )
228 save = get_button('save')
229 row = html.Div(
230 className='row',
231 children=[
232 query, spacer, search, spacer, init, spacer, upload, spacer, save
233 ],
234 )
235 configbar = html.Div(id='configbar', className='menubar', children=[row])
236 return configbar
239# ELEMENTS----------------------------------------------------------------------
240def get_button(title):
241 # type: (str) -> html.Button
242 '''
243 Get a html button with a given title.
245 Args:
246 title (str): Title of button.
248 Raises:
249 TypeError: If title is not a string.
251 Returns:
252 Button: Button element.
253 '''
254 if not isinstance(title, str):
255 msg = f'{title} is not a string.'
256 raise TypeError(msg)
257 return html.Button(id=f'{title}-button', children=[title], n_clicks=0)
260def get_key_value_table(
261 data, id_='key-value', header='', editable=False, key_order=None
262):
263 # type (dict, Optional(str), str, bool, Optional(List[str])) -> DataTable
264 '''
265 Gets a Dash DataTable element representing given dictionary.
267 Args:
268 data (dict): Dictionary.
269 id_ (str, optional): CSS id. Default: 'key-value'.
270 header (str, optional): Table header title. Default: ''.
271 editable (bool, optional): Whether table is editable. Default: False.
272 key_order (list[str], optional): Order in which keys will be displayed.
273 Default: None.
275 Returns:
276 DataTable: Tablular representation of given dictionary.
277 '''
278 data = rpb.BlobETL(data).to_flat_dict()
280 # determine keys
281 keys = sorted(list(data.keys()))
282 if key_order is not None:
283 diff = set(key_order).difference(keys)
284 if len(diff) > 0:
285 diff = list(sorted(diff))
286 msg = f'Invalid key order. Keys not found in data: {diff}.'
287 raise KeyError(msg)
289 keys = set(keys).difference(key_order)
290 keys = sorted(list(keys))
291 keys = key_order + keys
293 # transform data
294 data = [dict(key=k, value=data[k]) for k in keys]
296 cols = []
297 if len(data) > 0:
298 cols = data[0].keys()
299 cols = [{'name': x, 'id': x} for x in cols]
301 table = dash_table.DataTable(
302 data=data,
303 data_previous=data,
304 columns=cols,
305 id=f'{id_}-table',
306 sort_action='native',
307 sort_mode='multi',
308 page_action='none',
309 cell_selectable=True,
310 editable=editable,
311 )
312 head = html.Div(className='key-value-table-header', children=header)
313 return html.Div(
314 id=id_, className='key-value-table-container', children=[head, table]
315 )
318def get_datatable(data, color_scheme=cfg.COLOR_SCHEME, editable=False):
319 # type: (List[Dict], Dict[str, str], bool) -> dash_table.DataTable
320 '''
321 Gets a Dash DataTable element using given data.
322 Assumes dict element has all columns of table as keys.
324 Args:
325 data (list[dict]): List of dicts.
326 color_scheme (dict, optional): Color scheme dictionary.
327 Default: COLOR_SCHEME.
328 editable (bool, optional): Whether table is editable. Default: False.
330 Returns:
331 DataTable: Table of data.
332 '''
333 cs = copy(cfg.COLOR_SCHEME)
334 cs.update(color_scheme)
336 cols = [] # type: Any
337 if len(data) > 0:
338 cols = data[0].keys()
339 cols = [{'name': x, 'id': x} for x in cols]
341 return dash_table.DataTable(
342 data=data,
343 columns=cols,
344 id='datatable',
345 fixed_rows=dict(headers=True),
346 sort_action='native',
347 sort_mode='multi',
348 cell_selectable=editable,
349 editable=editable,
350 )
353def get_plots(data, plots):
354 # type: (List[dict], List[dict]) -> List[dcc.Graph]
355 '''
356 Gets a Dash plots using given dicts.
357 Assumes dict element has all columns of table as keys.
359 Args:
360 data (list[dict]): List of dicts defining data.
361 plots (list[dict]): List of dicts defining plots.
363 Raises:
364 EnforceError: If data is not a list of dicts.
365 EnforceError: If plots is not a list of dicts.
367 Returns:
368 list[dcc.Graph]: Plots.
369 '''
370 msg = 'Data must be a list of dictionaries. Given value: {a}.'
371 Enforce(data, 'instance of', list, message=msg)
372 for item in data:
373 Enforce(item, 'instance of', dict, message=msg)
375 msg = 'Plots must be a list of dictionaries. Given value: {a}.'
376 Enforce(plots, 'instance of', list, message=msg)
377 for item in plots:
378 Enforce(item, 'instance of', dict, message=msg)
379# --------------------------------------------------------------------------
381 data_ = DataFrame(data)
382 if 'date' in data_.columns:
383 data_.date = DatetimeIndex(data_.date)
385 elems = []
386 for i, x in enumerate(plots):
387 plot = cfg.PlotItem(x)
388 plot.validate()
389 plot = plot.to_primitive()
390 min_width = str(plot['min_width']) + '%'
392 try:
393 fig = sdt.get_figure(
394 data_,
395 filters=plot['filters'],
396 group=plot['group'],
397 pivot=plot['pivot'],
398 **plot['figure'],
399 )
400 fig = dcc.Graph(
401 id=f'plot-{i:02d}',
402 className='plot',
403 figure=fig,
404 style={'min-width': min_width},
405 )
406 except (DataError, EnforceError):
407 fig = html.Div(
408 id=f'plot-{i:02d}',
409 className='plot plot-error',
410 style={'min-width': min_width},
411 children=html.Div(
412 className='plot-error-container',
413 children=html.Div(
414 className='plot-error-message',
415 children='no data found'
416 )
417 )
418 )
419 elems.append(fig)
420 return elems