Completed
Push — master ( 92b6ae...8b403b )
by Chris
01:14
created

validate_raw_json()   F

Complexity

Conditions 9

Size

Total Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 9
dl 0
loc 25
rs 3
c 1
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
    _metadata = dict()
87
    conf = current_app.config
88
    conf_metadata = conf.get('JSONDASH', {}).get('metadata')
89
    # Also useful for getting arbitrary configuration keys.
90
    if key is not None:
91
        if key in conf_metadata:
92
            return conf_metadata[key]()
93
        else:
94
            return None
95
    # Update all metadata values if the function exists.
96
    for k, func in conf_metadata.items():
97
        if k in exclude:
98
            continue
99
        _metadata[k] = conf_metadata[k]()
100
    return _metadata
101
102
103
def setting(name, default=None):
104
    """A simplified getter for namespaced flask config values."""
105
    if default is None:
106
        default = default_config.get(name)
107
    return current_app.config.get(name, default)
108
109
110
def local_static(chart_config, static_config):
111
    """Convert remote cdn urls to local urls, based on user provided paths.
112
113
    The filename must be identical to the one specified in the
114
    `settings.py` configuration.
115
116
    So, for example:
117
    '//cdnjs.cloudflare.com/foo/bar/foo.js'
118
    becomes
119
    '/static/js/vendor/foo.js'
120
    """
121
    js_path = static_config.get('js_path')
122
    css_path = static_config.get('css_path')
123
    for family, config in chart_config.items():
124
        if config['js_url']:
125
            for i, url in enumerate(config['js_url']):
126
                url = '{}{}'.format(js_path, url.split('/')[-1])
127
                config['js_url'][i] = url_for('static', filename=url)
128
        if config['css_url']:
129
            for i, url in enumerate(config['css_url']):
130
                url = '{}{}'.format(css_path, url.split('/')[-1])
131
                config['css_url'][i] = url_for('static', filename=url)
132
    return chart_config
133
134
135
@charts.context_processor
136
def ctx():
137
    """Inject any context needed for this blueprint."""
138
    filter_user = setting('JSONDASH_FILTERUSERS')
139
    static = setting('JSONDASH').get('static')
140
    # Rewrite the static config paths to be local if the overrides are set.
141
    config = (CHARTS_CONFIG if not static
142
              else local_static(CHARTS_CONFIG, static))
143
    return dict(
144
        static_config=static,
145
        charts_config=config,
146
        page_title='dashboards',
147
        docs_url=('https://github.com/christabor/flask_jsondash/'
148
                  'blob/master/docs/'),
149
        demo_mode=request.args.get('jsondash_demo_mode', False),
150
        global_dashuser=setting('JSONDASH_GLOBAL_USER'),
151
        global_dashboards=setting('JSONDASH_GLOBALDASH'),
152
        username=metadata(key='username') if filter_user else None,
153
        filter_dashboards=filter_user,
154
    )
155
156
157
@jinja2.contextfilter
158
@charts.app_template_filter('get_dims')
159
def get_dims(_, config):
160
    """Extract the dimensions from config data. This allows
161
    for overrides for edge-cases to live in one place.
162
    """
163
    if not all([
164
        'width' in config,
165
        'height' in config,
166
        'dataSource' in config,
167
        config.get('dataSource') != '',
168
        config.get('dataSource') is not None,
169
    ]):
170
        raise ValueError('Invalid config!')
171
    fixed_layout = str(config.get('width')).startswith('col-')
172
    if config.get('type') == 'youtube':
173
        # Override all width settings if fixed grid layout
174
        if fixed_layout:
175
            width = config['width'].replace('col-', '')
176
            return dict(width=width, height=int(config['height']))
177
        # We get the dimensions for the widget from YouTube instead,
178
        # which handles aspect ratios, etc... and is likely what the user
179
        # wanted to specify since they will be entering in embed code from
180
        # Youtube directly.
181
        padding_w = 20
182
        padding_h = 60
183
        embed = config['dataSource'].split(' ')
184
        w = int(embed[1].replace('width=', '').replace('"', ''))
185
        h = int(embed[2].replace('height=', '').replace('"', ''))
186
        return dict(width=w + padding_w, height=h + padding_h)
187
    return dict(width=config['width'], height=config['height'])
188
189
190
@jinja2.contextfilter
191
@charts.app_template_filter('jsonstring')
192
def jsonstring(ctx, data):
193
    """Format view json module data for template use.
194
195
    It's automatically converted to unicode key/value pairs,
196
    which is undesirable for the template.
197
    """
198
    if 'date' in data:
199
        data['date'] = str(data['date'])
200
    return json.dumps(data)
201
202
203
@charts.route('/jsondash/<path:filename>')
204
def _static(filename):
205
    """Send static files directly for this blueprint."""
206
    return send_from_directory(static_dir, filename)
207
208
209
def get_all_assets():
210
    """Load ALL asset files for css/js from config."""
211
    cssfiles, jsfiles = [], []
212
    for c in CHARTS_CONFIG.values():
213
        if c['css_url'] is not None:
214
            cssfiles += c['css_url']
215
        if c['js_url'] is not None:
216
            jsfiles += c['js_url']
217
    return dict(
218
        css=cssfiles,
219
        js=jsfiles
220
    )
221
222
223
def get_active_assets(families):
224
    """Given a list of chart families, determine what needs to be loaded."""
225
    families += REQUIRED_STATIC_FAMILES  # Always load internal, shared libs.
226
    assets = dict(css=[], js=[])
227
    families = set(families)
228
    for family, data in CHARTS_CONFIG.items():
229
        if family in families:
230
            # Also add all dependency assets.
231
            if data['dependencies']:
232
                for dep in data['dependencies']:
233
                    assets['css'] += [
234
                        css for css in CHARTS_CONFIG[dep]['css_url']
235
                        if css not in assets['css']]
236
237
                    assets['js'] += [
238
                        js for js in CHARTS_CONFIG[dep]['js_url']
239
                        if js not in assets['js']
240
                    ]
241
            assets['css'] += [
242
                css for css in data['css_url'] if css not in assets['css']]
243
            assets['js'] += [
244
                js for js in data['js_url'] if js not in assets['js']]
245
    assets['css'] = list(assets['css'])
246
    assets['js'] = list(assets['js'])
247
    return assets
248
249
250
def paginator(page=0, per_page=None, count=None):
251
    """Get pagination calculations in a compact format."""
252
    if count is None:
253
        count = adapter.count()
254
    if page is None:
255
        page = 0
256
    default_per_page = setting('JSONDASH_PERPAGE')
257
    # Allow query parameter overrides.
258
    per_page = per_page if per_page is not None else default_per_page
259
    per_page = per_page if per_page > 2 else 2  # Prevent division errors etc
260
    curr_page = page - 1 if page > 0 else 0
261
    num_pages = count // per_page
262
    rem = count % per_page
263
    extra_pages = 2 if rem else 1
264
    pages = list(range(1, num_pages + extra_pages))
265
    return Paginator(
266
        limit=per_page,
267
        per_page=per_page,
268
        curr_page=curr_page,
269
        skip=curr_page * per_page,
270
        num_pages=pages,
271
        count=count,
272
    )
273
274
275
def get_num_rows(viewconf):
276
    """Get the number of rows for a layout if it's using fixed grid format."""
277
    layout = viewconf.get('layout', 'freeform')
278
    if layout == 'freeform':
279
        return None
280
    return len([m['row'] for m in viewconf.get('modules')])
281
282
283
def order_sort(item):
284
    """Attempt to sort modules by order keys.
285
286
    Always returns an integer for compatibility.
287
    """
288
    if item.get('order') is not None:
289
        try:
290
            return int(item['order'])
291
        except (ValueError, TypeError):
292
            return -1
293
    return -1
294
295
296
def sort_modules(viewjson):
297
    """Sort module data in various ways.
298
299
    If the layout is freeform, sort by default order in a shallow list.
300
    If the layout is fixed grid, sort by default order, nested in a list
301
        for each row - e.g. [[{}, {}], [{}]]
302
        for row 1 (2 modules) and row 2 (1 module)
303
    """
304
    items = sorted(viewjson['modules'], key=order_sort)
305
    if viewjson.get('layout', 'freeform') == 'freeform':
306
        return items
307
    # Sort them by and group them by rows if layout is fixed grid
308
    # Create a temporary dict to hold the number of rows
309
    modules = {int(item['row']) - 1: [] for item in items}.values()
310
    for module in items:
311
        modules[int(module['row']) - 1].append(module)
312
    return modules
313
314
315
@charts.route('/charts', methods=['GET'])
316
@charts.route('/charts/', methods=['GET'])
317
def dashboard():
318
    """Load all views."""
319
    opts = dict()
320
    views = []
321
    if setting('JSONDASH_FILTERUSERS'):
322
        opts.update(filter=dict(created_by=metadata(key='username')))
323
        views = list(adapter.read(**opts))
324
        if setting('JSONDASH_GLOBALDASH'):
325
            opts.update(
326
                filter=dict(created_by=setting('JSONDASH_GLOBAL_USER')))
327
            views += list(adapter.read(**opts))
328
    else:
329
        views = list(adapter.read(**opts))
330
    if views:
331
        page = request.args.get('page')
332
        per_page = request.args.get('per_page')
333
        paginator_args = dict(count=len(views))
334
        if per_page is not None:
335
            paginator_args.update(per_page=int(per_page))
336
        if page is not None:
337
            paginator_args.update(page=int(page))
338
        pagination = paginator(**paginator_args)
339
        opts.update(limit=pagination.limit, skip=pagination.skip)
340
    else:
341
        pagination = None
342
    kwargs = dict(
343
        views=views,
344
        view=None,
345
        paginator=pagination,
346
        creating=True,
347
        can_edit_global=auth(authtype='edit_global'),
348
        total_modules=sum([
349
            len(view.get('modules', [])) for view in views
350
            if isinstance(view, dict)
351
        ]),
352
    )
353
    return render_template('pages/charts_index.html', **kwargs)
354
355
356
@charts.route('/charts/<c_id>', methods=['GET'])
357
def view(c_id):
358
    """Load a json view config from the DB."""
359
    if not auth(authtype='view', view_id=c_id):
360
        flash('You do not have access to view this dashboard.', 'error')
361
        return redirect(url_for('jsondash.dashboard'))
362
    viewjson = adapter.read(c_id=c_id)
363
    if not viewjson:
364
        flash('Could not find view: {}'.format(c_id), 'error')
365
        return redirect(url_for('jsondash.dashboard'))
366
    # Remove _id, it's not JSON serializeable.
367
    if '_id' in viewjson:
368
        viewjson.pop('_id')
369
    if 'modules' not in viewjson:
370
        flash('Invalid configuration - missing modules.', 'error')
371
        return redirect(url_for('jsondash.dashboard'))
372
    # Chart family is encoded in chart type value for lookup.
373
    active_charts = [v.get('family') for v in viewjson['modules']
374
                     if v.get('family') is not None]
375
    # If the logged in user is also the creator of this dashboard,
376
    # let me edit it. Otherwise, defer to any user-supplied auth function
377
    # for this specific view.
378
    if metadata(key='username') == viewjson.get('created_by'):
379
        can_edit = True
380
    else:
381
        can_edit = auth(authtype='edit_others', view_id=c_id)
382
    # Backwards compatible layout type
383
    layout_type = viewjson.get('layout', 'freeform')
384
    kwargs = dict(
385
        id=c_id,
386
        view=viewjson,
387
        num_rows=None if layout_type == 'freeform' else get_num_rows(viewjson),
388
        modules=sort_modules(viewjson),
389
        assets=get_active_assets(active_charts),
390
        can_edit=can_edit,
391
        can_edit_global=auth(authtype='edit_global'),
392
        is_global=is_global_dashboard(viewjson),
393
    )
394
    return render_template('pages/chart_detail.html', **kwargs)
395
396
397
@charts.route('/charts/<c_id>/delete', methods=['POST'])
398
def delete(c_id):
399
    """Delete a json dashboard config."""
400
    dash_url = url_for('jsondash.dashboard')
401
    if not auth(authtype='delete'):
402
        flash('You do not have access to delete dashboards.', 'error')
403
        return redirect(dash_url)
404
    adapter.delete(c_id)
405
    flash('Deleted dashboard "{}"'.format(c_id))
406
    return redirect(dash_url)
407
408
409
def validate_raw_json(jsonstr):
410
    """Validate the raw json for a config."""
411
    data = json.loads(jsonstr)
412
    layout = data.get('layout', 'freeform')
413
    main_required_fields = ['name', 'modules']
414
    for field in main_required_fields:
415
        if field not in data.keys():
416
            raise InvalidSchemaError('Missing "{}" key'.format(field))
417
    for module in data.get('modules'):
418
        required = ['family', 'name', 'width', 'height', 'dataSource', 'type']
419
        fixed_only_required = ['row']
420
        for field in required:
421
            if field not in module:
422
                ident = module.get('name', module)
423
                raise InvalidSchemaError(
424
                    'Invalid JSON. "{}" must be '
425
                    'included in module "{}"'.format(field, ident))
426
        for field in fixed_only_required:
427
            if field not in module and layout == 'grid':
428
                ident = module.get('name', module)
429
                raise InvalidSchemaError(
430
                    'Invalid JSON. "{}" must be '
431
                    'included in "{}" for '
432
                    'fixed grid layouts'.format(field, ident))
433
    return data
434
435
436
@charts.route('/charts/<c_id>/update', methods=['POST'])
437
def update(c_id):
438
    """Normalize the form POST and setup the json view config object."""
439
    if not auth(authtype='update'):
440
        flash('You do not have access to update dashboards.', 'error')
441
        return redirect(url_for('jsondash.dashboard'))
442
    viewjson = adapter.read(c_id=c_id)
443
    if not viewjson:
444
        flash('Could not find view: {}'.format(c_id), 'error')
445
        return redirect(url_for('jsondash.dashboard'))
446
    form_data = request.form
447
    view_url = url_for('jsondash.view', c_id=c_id)
448
    edit_raw = 'edit-raw' in request.form
449
    if edit_raw:
450
        try:
451
            data = validate_raw_json(form_data.get('config'))
452
            data = db.reformat_data(data, c_id)
453
        except InvalidSchemaError as e:
454
            flash(str(e), 'error')
455
            return redirect(view_url)
456
        except (TypeError, ValueError) as e:
457
            flash('Invalid JSON config. "{}"'.format(e), 'error')
458
            return redirect(view_url)
459
    else:
460
        data = dict(
461
            name=form_data['name'],
462
            modules=db.format_charts(form_data),
463
            date=str(dt.now()),
464
            id=c_id,
465
        )
466
    # Update metadata, but exclude some fields that should never
467
    # be overwritten by user, once the view has been created.
468
    data.update(**metadata(exclude=['created_by']))
469
    # Possibly override global user, if configured and valid.
470
    data.update(**check_global())
471
    # Update db
472
    if edit_raw:
473
        adapter.update(c_id, data=data, fmt_charts=False)
474
    else:
475
        adapter.update(c_id, data=data)
476
    flash('Updated view "{}"'.format(c_id))
477
    return redirect(view_url)
478
479
480
def is_global_dashboard(view):
481
    """Check if a dashboard is considered global."""
482
    return all([
483
        setting('JSONDASH_GLOBALDASH'),
484
        view.get('created_by') == setting('JSONDASH_GLOBAL_USER'),
485
    ])
486
487
488
def check_global():
489
    """Allow overriding of the user by making it global.
490
    This also checks if the setting is enabled for the app,
491
    otherwise it will not allow it.
492
    """
493
    global_enabled = setting('JSONDASH_GLOBALDASH')
494
    global_flag = request.form.get('is_global', '') == 'on'
495
    can_make_global = auth(authtype='edit_global')
496
    if all([global_flag, global_enabled, can_make_global]):
497
        return dict(created_by=setting('JSONDASH_GLOBAL_USER'))
498
    return dict()
499
500
501
@charts.route('/charts/create', methods=['POST'])
502
def create():
503
    """Normalize the form POST and setup the json view config object."""
504
    if not auth(authtype='create'):
505
        flash('You do not have access to create dashboards.', 'error')
506
        return redirect(url_for('jsondash.dashboard'))
507
    data = request.form
508
    new_id = str(uuid.uuid1())
509
    d = dict(
510
        name=data['name'],
511
        modules=db.format_charts(data),
512
        date=dt.now(),
513
        id=new_id,
514
        layout=data.get('mode', 'grid'),
515
    )
516
    d.update(**metadata())
517
    # Possibly override global user, if configured and valid.
518
    d.update(**check_global())
519
    # Add to DB
520
    adapter.create(data=d)
521
    flash('Created new dashboard "{}"'.format(data['name']))
522
    return redirect(url_for('jsondash.view', c_id=new_id))
523
524
525
@charts.route('/charts/<c_id>/clone', methods=['POST'])
526
def clone(c_id):
527
    """Clone a json view config from the DB."""
528
    if not auth(authtype='clone'):
529
        flash('You do not have access to clone dashboards.', 'error')
530
        return redirect(url_for('jsondash.dashboard'))
531
    viewjson = adapter.read(c_id=c_id)
532
    if not viewjson:
533
        flash('Could not find view: {}'.format(c_id), 'error')
534
        return redirect(url_for('jsondash.dashboard'))
535
    # Update some fields.
536
    newname = 'Clone of {}'.format(viewjson['name'])
537
    data = dict(
538
        name=newname,
539
        modules=viewjson['modules'],
540
        date=str(dt.now()),
541
        id=str(uuid.uuid1()),
542
        layout=viewjson['layout'],
543
    )
544
    data.update(**metadata())
545
    # Add to DB
546
    adapter.create(data=data)
547
    flash('Created new dashboard clone "{}"'.format(newname))
548
    return redirect(url_for('jsondash.view', c_id=data['id']))
549