Completed
Push — master ( 36e2ac...f77fe9 )
by Chris
01:19
created

validate_raw_json()   B

Complexity

Conditions 6

Size

Total Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

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