Completed
Push — master ( 50c73f...50957d )
by Chris
01:24
created

is_consecutive_rows()   A

Complexity

Conditions 3

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
dl 0
loc 19
rs 9.4285
c 0
b 0
f 0
1
# -*- coding: utf-8 -*-
2
3
"""
4
flask_jsondash.charts_builder
5
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6
7
The chart blueprint that houses all functionality.
8
9
:copyright: (c) 2016 by Chris Tabor.
10
:license: MIT, see LICENSE for more details.
11
"""
12
13
import json
14
import os
15
import uuid
16
from collections import namedtuple
17
from datetime import datetime as dt
18
19
import jinja2
20
from flask import (Blueprint, current_app, flash, redirect, render_template,
21
                   request, send_from_directory, url_for)
22
23
from flask_jsondash import static, templates
24
25
from . import db
26
from .settings import CHARTS_CONFIG
27
28
template_dir = os.path.dirname(templates.__file__)
29
static_dir = os.path.dirname(static.__file__)
30
31
# Internally required libs that are also shared in `settings.py` for charts.
32
# These follow the same format as what is loaded in `get_active_assets`
33
# so that shared libraries are loaded in the same manner for simplicty
34
# and prevention of duplicate loading. Note these are just LABELS, not files.
35
REQUIRED_STATIC_FAMILES = ['D3']
36
37
Paginator = namedtuple('Paginator',
38
                       'count per_page curr_page num_pages limit skip')
39
40
charts = Blueprint(
41
    'jsondash',
42
    __name__,
43
    template_folder=template_dir,
44
    static_url_path=static_dir,
45
    static_folder=static_dir,
46
)
47
default_config = dict(
48
    JSONDASH_FILTERUSERS=False,
49
    JSONDASH_GLOBALDASH=False,
50
    JSONDASH_GLOBAL_USER='global',
51
    JSONDASH_PERPAGE=25,
52
)
53
adapter = db.get_db_handler()
54
55
56
class InvalidSchemaError(ValueError):
57
    """Wrapper exception for specific raising scenarios."""
58
59
60
def auth(**kwargs):
61
    """Check if general auth functions have been specified.
62
63
    Checks for either a global auth (if authtype is None), or
64
    an action specific auth (specified by authtype).
65
    """
66
    if 'JSONDASH' not in current_app.config:
67
        return True
68
    if 'auth' not in current_app.config['JSONDASH']:
69
        return True
70
    authtype = kwargs.pop('authtype')
71
    auth_conf = current_app.config.get('JSONDASH').get('auth')
72
    # If the user didn't supply an auth function, assume true.
73
    if authtype not in auth_conf:
74
        return True
75
    # Only perform the user-supplied check
76
    # if the authtype is actually enabled.
77
    return auth_conf[authtype](**kwargs)
78
79
80
def metadata(key=None, exclude=[]):
81
    """An abstraction around misc. metadata.
82
83
    This allows loose coupling for enabling and setting
84
    metadata for each chart.
85
86
    Args:
87
        key (None, optional): A key to look up in global config.
88
        exclude (list, optional): A list of fields to exclude when
89
            retrieving metadata.
90
91
    Returns:
92
        _metadata (dict): The metadata configuration.
93
    """
94
    _metadata = dict()
95
    conf = current_app.config
96
    conf_metadata = conf.get('JSONDASH', {}).get('metadata')
97
    # Also useful for getting arbitrary configuration keys.
98
    if key is not None:
99
        if key in conf_metadata:
100
            return conf_metadata[key]()
101
        else:
102
            return None
103
    # Update all metadata values if the function exists.
104
    for k, func in conf_metadata.items():
105
        if k in exclude:
106
            continue
107
        _metadata[k] = conf_metadata[k]()
108
    return _metadata
109
110
111
def setting(name, default=None):
112
    """A simplified getter for namespaced flask config values.
113
114
    Args:
115
        name (str): A setting to retrieve the value for.
116
        default (None, optional): A default value to fall back to
117
            if not specified.
118
    Returns:
119
        str: A value from the app config.
120
    """
121
    if default is None:
122
        default = default_config.get(name)
123
    return current_app.config.get(name, default)
124
125
126
def local_static(chart_config, static_config):
127
    """Convert remote cdn urls to local urls, based on user provided paths.
128
129
    The filename must be identical to the one specified in the
130
    `settings.py` configuration.
131
132
    So, for example:
133
    '//cdnjs.cloudflare.com/foo/bar/foo.js'
134
    becomes
135
    '/static/js/vendor/foo.js'
136
    """
137
    js_path = static_config.get('js_path')
138
    css_path = static_config.get('css_path')
139
    for family, config in chart_config.items():
140
        if config['js_url']:
141
            for i, url in enumerate(config['js_url']):
142
                url = '{}{}'.format(js_path, url.split('/')[-1])
143
                config['js_url'][i] = url_for('static', filename=url)
144
        if config['css_url']:
145
            for i, url in enumerate(config['css_url']):
146
                url = '{}{}'.format(css_path, url.split('/')[-1])
147
                config['css_url'][i] = url_for('static', filename=url)
148
    return chart_config
149
150
151
@charts.context_processor
152
def ctx():
153
    """Inject any context needed for this blueprint."""
154
    filter_user = setting('JSONDASH_FILTERUSERS')
155
    static = setting('JSONDASH').get('static')
156
    # Rewrite the static config paths to be local if the overrides are set.
157
    config = (CHARTS_CONFIG if not static
158
              else local_static(CHARTS_CONFIG, static))
159
    return dict(
160
        static_config=static,
161
        charts_config=config,
162
        page_title='dashboards',
163
        docs_url=('https://github.com/christabor/flask_jsondash/'
164
                  'blob/master/docs/'),
165
        demo_mode=request.args.get('jsondash_demo_mode', False),
166
        global_dashuser=setting('JSONDASH_GLOBAL_USER'),
167
        global_dashboards=setting('JSONDASH_GLOBALDASH'),
168
        username=metadata(key='username') if filter_user else None,
169
        filter_dashboards=filter_user,
170
    )
171
172
173
@jinja2.contextfilter
174
@charts.app_template_filter('get_dims')
175
def get_dims(_, config):
176
    """Extract the dimensions from config data. This allows
177
    for overrides for edge-cases to live in one place.
178
    """
179
    if not all([
180
        'width' in config,
181
        'height' in config,
182
        'dataSource' in config,
183
        config.get('dataSource') != '',
184
        config.get('dataSource') is not None,
185
    ]):
186
        raise ValueError('Invalid config!')
187
    fixed_layout = str(config.get('width')).startswith('col-')
188
    if config.get('type') == 'youtube':
189
        # Override all width settings if fixed grid layout
190
        if fixed_layout:
191
            width = config['width'].replace('col-', '')
192
            return dict(width=width, height=int(config['height']))
193
        # We get the dimensions for the widget from YouTube instead,
194
        # which handles aspect ratios, etc... and is likely what the user
195
        # wanted to specify since they will be entering in embed code from
196
        # Youtube directly.
197
        padding_w = 20
198
        padding_h = 60
199
        embed = config['dataSource'].split(' ')
200
        w = int(embed[1].replace('width=', '').replace('"', ''))
201
        h = int(embed[2].replace('height=', '').replace('"', ''))
202
        return dict(width=w + padding_w, height=h + padding_h)
203
    return dict(width=config['width'], height=config['height'])
204
205
206
@jinja2.contextfilter
207
@charts.app_template_filter('jsonstring')
208
def jsonstring(ctx, data):
209
    """Format view json module data for template use.
210
211
    It's automatically converted to unicode key/value pairs,
212
    which is undesirable for the template.
213
    """
214
    if 'date' in data:
215
        data['date'] = str(data['date'])
216
    return json.dumps(data)
217
218
219
@charts.route('/jsondash/<path:filename>')
220
def _static(filename):
221
    """Send static files directly for this blueprint."""
222
    return send_from_directory(static_dir, filename)
223
224
225
def get_all_assets():
226
    """Load ALL asset files for css/js from config."""
227
    cssfiles, jsfiles = [], []
228
    for c in CHARTS_CONFIG.values():
229
        if c['css_url'] is not None:
230
            cssfiles += c['css_url']
231
        if c['js_url'] is not None:
232
            jsfiles += c['js_url']
233
    return dict(
234
        css=cssfiles,
235
        js=jsfiles
236
    )
237
238
239
def get_active_assets(families):
240
    """Given a list of chart families, determine what needs to be loaded."""
241
    families += REQUIRED_STATIC_FAMILES  # Always load internal, shared libs.
242
    assets = dict(css=[], js=[])
243
    families = set(families)
244
    for family, data in CHARTS_CONFIG.items():
245
        if family in families:
246
            # Also add all dependency assets.
247
            if data['dependencies']:
248
                for dep in data['dependencies']:
249
                    assets['css'] += [
250
                        css for css in CHARTS_CONFIG[dep]['css_url']
251
                        if css not in assets['css']]
252
253
                    assets['js'] += [
254
                        js for js in CHARTS_CONFIG[dep]['js_url']
255
                        if js not in assets['js']
256
                    ]
257
            assets['css'] += [
258
                css for css in data['css_url'] if css not in assets['css']]
259
            assets['js'] += [
260
                js for js in data['js_url'] if js not in assets['js']]
261
    assets['css'] = list(assets['css'])
262
    assets['js'] = list(assets['js'])
263
    return assets
264
265
266
def paginator(page=0, per_page=None, count=None):
267
    """Get pagination calculations in a compact format."""
268
    if count is None:
269
        count = adapter.count()
270
    if page is None:
271
        page = 0
272
    default_per_page = setting('JSONDASH_PERPAGE')
273
    # Allow query parameter overrides.
274
    per_page = per_page if per_page is not None else default_per_page
275
    per_page = per_page if per_page > 2 else 2  # Prevent division errors etc
276
    curr_page = page - 1 if page > 0 else 0
277
    num_pages = count // per_page
278
    rem = count % per_page
279
    extra_pages = 2 if rem else 1
280
    pages = list(range(1, num_pages + extra_pages))
281
    return Paginator(
282
        limit=per_page,
283
        per_page=per_page,
284
        curr_page=curr_page,
285
        skip=curr_page * per_page,
286
        num_pages=pages,
287
        count=count,
288
    )
289
290
291
def get_num_rows(viewconf):
292
    """Get the number of rows for a layout if it's using fixed grid format."""
293
    if viewconf is None:
294
        return None
295
    layout = viewconf.get('layout', 'freeform')
296
    if layout == 'freeform':
297
        return None
298
    return len([m['row'] for m in viewconf.get('modules')])
299
300
301
def order_sort(item):
302
    """Attempt to sort modules by order keys.
303
304
    Always returns an integer for compatibility.
305
    """
306
    if item is None or item.get('order') is None:
307
        return -1
308
    try:
309
        return int(item['order'])
310
    except (ValueError, TypeError):
311
        return -1
312
    return -1
313
314
315
def sort_modules(viewjson):
316
    """Sort module data in various ways.
317
318
    If the layout is freeform, sort by default order in a shallow list.
319
    If the layout is fixed grid, sort by default order, nested in a list
320
        for each row - e.g. [[{}, {}], [{}]]
321
        for row 1 (2 modules) and row 2 (1 module)
322
    """
323
    items = sorted(viewjson['modules'], key=order_sort)
324
    if viewjson.get('layout', 'freeform') == 'freeform':
325
        return items
326
    # Sort them by and group them by rows if layout is fixed grid
327
    # Create a temporary dict to hold the number of rows
328
    modules = {int(item['row']) - 1: [] for item in items}.values()
329
    for module in items:
330
        modules[int(module['row']) - 1].append(module)
331
    return modules
332
333
334
@charts.route('/charts', methods=['GET'])
335
@charts.route('/charts/', methods=['GET'])
336
def dashboard():
337
    """Load all views."""
338
    opts = dict()
339
    views = []
340
    if setting('JSONDASH_FILTERUSERS'):
341
        opts.update(filter=dict(created_by=metadata(key='username')))
342
        views = list(adapter.read(**opts))
343
        if setting('JSONDASH_GLOBALDASH'):
344
            opts.update(
345
                filter=dict(created_by=setting('JSONDASH_GLOBAL_USER')))
346
            views += list(adapter.read(**opts))
347
    else:
348
        views = list(adapter.read(**opts))
349
    if views:
350
        page = request.args.get('page')
351
        per_page = request.args.get('per_page')
352
        paginator_args = dict(count=len(views))
353
        if per_page is not None:
354
            paginator_args.update(per_page=int(per_page))
355
        if page is not None:
356
            paginator_args.update(page=int(page))
357
        pagination = paginator(**paginator_args)
358
        opts.update(limit=pagination.limit, skip=pagination.skip)
359
    else:
360
        pagination = None
361
    kwargs = dict(
362
        views=views,
363
        view=None,
364
        paginator=pagination,
365
        creating=True,
366
        can_edit_global=auth(authtype='edit_global'),
367
        total_modules=sum([
368
            len(view.get('modules', [])) for view in views
369
            if isinstance(view, dict)
370
        ]),
371
    )
372
    return render_template('pages/charts_index.html', **kwargs)
373
374
375
@charts.route('/charts/<c_id>', methods=['GET'])
376
def view(c_id):
377
    """Load a json view config from the DB."""
378
    if not auth(authtype='view', view_id=c_id):
379
        flash('You do not have access to view this dashboard.', 'error')
380
        return redirect(url_for('jsondash.dashboard'))
381
    viewjson = adapter.read(c_id=c_id)
382
    if not viewjson:
383
        flash('Could not find view: {}'.format(c_id), 'error')
384
        return redirect(url_for('jsondash.dashboard'))
385
    # Remove _id, it's not JSON serializeable.
386
    if '_id' in viewjson:
387
        viewjson.pop('_id')
388
    if 'modules' not in viewjson:
389
        flash('Invalid configuration - missing modules.', 'error')
390
        return redirect(url_for('jsondash.dashboard'))
391
    # Chart family is encoded in chart type value for lookup.
392
    active_charts = [v.get('family') for v in viewjson['modules']
393
                     if v.get('family') is not None]
394
    # If the logged in user is also the creator of this dashboard,
395
    # let me edit it. Otherwise, defer to any user-supplied auth function
396
    # for this specific view.
397
    if metadata(key='username') == viewjson.get('created_by'):
398
        can_edit = True
399
    else:
400
        can_edit = auth(authtype='edit_others', view_id=c_id)
401
    # Backwards compatible layout type
402
    layout_type = viewjson.get('layout', 'freeform')
403
    kwargs = dict(
404
        id=c_id,
405
        view=viewjson,
406
        num_rows=None if layout_type == 'freeform' else get_num_rows(viewjson),
407
        modules=sort_modules(viewjson),
408
        assets=get_active_assets(active_charts),
409
        can_edit=can_edit,
410
        can_edit_global=auth(authtype='edit_global'),
411
        is_global=is_global_dashboard(viewjson),
412
    )
413
    return render_template('pages/chart_detail.html', **kwargs)
414
415
416
@charts.route('/charts/<c_id>/delete', methods=['POST'])
417
def delete(c_id):
418
    """Delete a json dashboard config."""
419
    dash_url = url_for('jsondash.dashboard')
420
    if not auth(authtype='delete'):
421
        flash('You do not have access to delete dashboards.', 'error')
422
        return redirect(dash_url)
423
    adapter.delete(c_id)
424
    flash('Deleted dashboard "{}"'.format(c_id))
425
    return redirect(dash_url)
426
427
428
def is_consecutive_rows(lst):
429
    """Check if a list of integers is consecutive.
430
431
    Args:
432
        lst (list): The list of integers.
433
434
    Returns:
435
        True/False: If the list contains consecutive integers.
436
437
    Originally taken from and modified:
438
        http://stackoverflow.com/
439
            questions/40091617/test-for-consecutive-numbers-in-list
440
    """
441
    assert 0 not in lst, '0th index is invalid!'
442
    lst = list(set(lst))
443
    if not lst:
444
        return True
445
    setl = set(lst)
446
    return len(lst) == len(setl) and setl == set(range(min(lst), max(lst) + 1))
447
448
449
def validate_raw_json_grid(conf):
450
    """Grid mode specific validations.
451
452
    Args:
453
        conf (dict): The dashboard configuration.
454
455
    Raises:
456
        InvalidSchemaError: If there are any issues with the schema
457
458
    Returns:
459
        None: If no errors were found.
460
    """
461
    layout = conf.get('layout', 'freeform')
462
    fixed_only_required = ['row']
463
    rows = []
464
    modules = conf.get('modules', [])
465
    for module in modules:
466
        try:
467
            rows.append(int(module.get('row')))
468
        except TypeError:
469
            raise InvalidSchemaError(
470
                'Invalid row value for module "{}"'.format(module.get('name')))
471
    if not is_consecutive_rows(rows):
472
        raise InvalidSchemaError(
473
            'Row order is not consecutive: "{}"!'.format(sorted(rows)))
474
    if not modules:
475
        return
476
    for module in modules:
477
        for field in fixed_only_required:
478
            if field not in module and layout == 'grid':
479
                ident = module.get('name', module)
480
                raise InvalidSchemaError(
481
                    'Invalid JSON. "{}" must be '
482
                    'included in "{}" for '
483
                    'fixed grid layouts'.format(field, ident))
484
485
486
def validate_raw_json(jsonstr):
487
    """Validate the raw json for a config.
488
489
    Args:
490
        jsonstr (str): The raw json configuration
491
492
    Raises:
493
        InvalidSchemaError: If there are any issues with the schema
494
495
    Returns:
496
        data (dict): The parsed configuration data
497
    """
498
    data = json.loads(jsonstr)
499
    layout = data.get('layout', 'freeform')
500
    main_required_fields = ['name', 'modules']
501
    families = CHARTS_CONFIG.keys()
502
    modules = data.get('modules')
503
504
    for field in main_required_fields:
505
        if field not in data.keys():
506
            raise InvalidSchemaError('Missing "{}" key'.format(field))
507
    if modules:
508
        first = modules[0]
509
        if layout != 'grid' and first.get('row') is not None:
510
            raise ValueError(
511
                'Cannot mix `row` fields with freeform layout! '
512
                'Either use `freeform` without rows, or use `grid` layout.'
513
            )
514
    for module in modules:
515
        required = ['family', 'name', 'width', 'height', 'dataSource', 'type']
516
        fam = module.get('family')
517
        for field in required:
518
            if field not in module:
519
                ident = module.get('name', module)
520
                raise InvalidSchemaError(
521
                    'Invalid JSON. "{}" must be '
522
                    'included in module "{}"'.format(field, ident))
523
        if fam not in families:
524
            raise InvalidSchemaError('Invalid family name "{}"'.format(fam))
525
    if layout == 'grid':
526
        validate_raw_json_grid(data)
527
    return data
528
529
530
@charts.route('/charts/<c_id>/update', methods=['POST'])
531
def update(c_id):
532
    """Normalize the form POST and setup the json view config object."""
533
    if not auth(authtype='update'):
534
        flash('You do not have access to update dashboards.', 'error')
535
        return redirect(url_for('jsondash.dashboard'))
536
    viewjson = adapter.read(c_id=c_id)
537
    if not viewjson:
538
        flash('Could not find view: {}'.format(c_id), 'error')
539
        return redirect(url_for('jsondash.dashboard'))
540
    form_data = request.form
541
    view_url = url_for('jsondash.view', c_id=c_id)
542
    edit_raw = 'edit-raw' in request.form
543
    if edit_raw:
544
        try:
545
            data = validate_raw_json(form_data.get('config'))
546
            data = db.reformat_data(data, c_id)
547
        except InvalidSchemaError as e:
548
            flash(str(e), 'error')
549
            return redirect(view_url)
550
        except (TypeError, ValueError) as e:
551
            flash('Invalid JSON config. "{}"'.format(e), 'error')
552
            return redirect(view_url)
553
    else:
554
        data = dict(
555
            name=form_data['name'],
556
            modules=db.format_charts(form_data),
557
            date=str(dt.now()),
558
            id=c_id,
559
        )
560
    # Update metadata, but exclude some fields that should never
561
    # be overwritten by user, once the view has been created.
562
    data.update(**metadata(exclude=['created_by']))
563
    # Possibly override global user, if configured and valid.
564
    data.update(**check_global())
565
    # Update db
566
    if edit_raw:
567
        adapter.update(c_id, data=data, fmt_charts=False)
568
    else:
569
        adapter.update(c_id, data=data)
570
    flash('Updated view "{}"'.format(c_id))
571
    return redirect(view_url)
572
573
574
def is_global_dashboard(view):
575
    """Check if a dashboard is considered global.
576
577
    Args:
578
        view (dict): The dashboard configuration
579
580
    Returns:
581
        bool: If all criteria was met to be included as a global dashboard.
582
    """
583
    return all([
584
        setting('JSONDASH_GLOBALDASH'),
585
        view.get('created_by') == setting('JSONDASH_GLOBAL_USER'),
586
    ])
587
588
589
def check_global():
590
    """Allow overriding of the user by making it global.
591
592
    This also checks if the setting is enabled for the app,
593
    otherwise it will not allow it.
594
595
    Returns:
596
        dict: A dictionary with certain global flags overriden.
597
    """
598
    global_enabled = setting('JSONDASH_GLOBALDASH')
599
    global_flag = request.form.get('is_global', '') == 'on'
600
    can_make_global = auth(authtype='edit_global')
601
    if all([global_flag, global_enabled, can_make_global]):
602
        return dict(created_by=setting('JSONDASH_GLOBAL_USER'))
603
    return dict()
604
605
606
@charts.route('/charts/create', methods=['POST'])
607
def create():
608
    """Normalize the form POST and setup the json view config object."""
609
    if not auth(authtype='create'):
610
        flash('You do not have access to create dashboards.', 'error')
611
        return redirect(url_for('jsondash.dashboard'))
612
    data = request.form
613
    new_id = str(uuid.uuid1())
614
    d = dict(
615
        name=data['name'],
616
        modules=db.format_charts(data),
617
        date=dt.now(),
618
        id=new_id,
619
        layout=data.get('mode', 'grid'),
620
    )
621
    d.update(**metadata())
622
    # Possibly override global user, if configured and valid.
623
    d.update(**check_global())
624
    # Add to DB
625
    adapter.create(data=d)
626
    flash('Created new dashboard "{}"'.format(data['name']))
627
    return redirect(url_for('jsondash.view', c_id=new_id))
628
629
630
@charts.route('/charts/<c_id>/clone', methods=['POST'])
631
def clone(c_id):
632
    """Clone a json view config from the DB."""
633
    if not auth(authtype='clone'):
634
        flash('You do not have access to clone dashboards.', 'error')
635
        return redirect(url_for('jsondash.dashboard'))
636
    viewjson = adapter.read(c_id=c_id)
637
    if not viewjson:
638
        flash('Could not find view: {}'.format(c_id), 'error')
639
        return redirect(url_for('jsondash.dashboard'))
640
    # Update some fields.
641
    newname = 'Clone of {}'.format(viewjson['name'])
642
    data = dict(
643
        name=newname,
644
        modules=viewjson['modules'],
645
        date=str(dt.now()),
646
        id=str(uuid.uuid1()),
647
        layout=viewjson['layout'],
648
    )
649
    data.update(**metadata())
650
    # Add to DB
651
    adapter.create(data=data)
652
    flash('Created new dashboard clone "{}"'.format(newname))
653
    return redirect(url_for('jsondash.view', c_id=data['id']))
654