Completed
Push — master ( 4244aa...92b6ae )
by Chris
01:15
created

view()   F

Complexity

Conditions 9

Size

Total Lines 39

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 9
c 3
b 0
f 0
dl 0
loc 39
rs 3
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
                raise InvalidSchemaError(
423
                    'Invalid JSON. "{}" must be '
424
                    'included in "{}"'.format(field, module))
425
        for field in fixed_only_required:
426
            if field not in module and layout == 'grid':
427
                raise InvalidSchemaError(
428
                    'Invalid JSON. "{}" must be '
429
                    'included in "{}" for '
430
                    'fixed grid layouts'.format(field, module))
431
    return data
432
433
434
@charts.route('/charts/<c_id>/update', methods=['POST'])
435
def update(c_id):
436
    """Normalize the form POST and setup the json view config object."""
437
    if not auth(authtype='update'):
438
        flash('You do not have access to update dashboards.', 'error')
439
        return redirect(url_for('jsondash.dashboard'))
440
    viewjson = adapter.read(c_id=c_id)
441
    if not viewjson:
442
        flash('Could not find view: {}'.format(c_id), 'error')
443
        return redirect(url_for('jsondash.dashboard'))
444
    form_data = request.form
445
    view_url = url_for('jsondash.view', c_id=c_id)
446
    edit_raw = 'edit-raw' in request.form
447
    if edit_raw:
448
        try:
449
            data = validate_raw_json(form_data.get('config'))
450
            data = db.reformat_data(data, c_id)
451
        except InvalidSchemaError as e:
452
            flash(str(e), 'error')
453
            return redirect(view_url)
454
        except (TypeError, ValueError):
455
            flash('Invalid JSON config.', 'error')
456
            return redirect(view_url)
457
    else:
458
        data = dict(
459
            name=form_data['name'],
460
            modules=db.format_charts(form_data),
461
            date=str(dt.now()),
462
            id=c_id,
463
        )
464
    # Update metadata, but exclude some fields that should never
465
    # be overwritten by user, once the view has been created.
466
    data.update(**metadata(exclude=['created_by']))
467
    # Possibly override global user, if configured and valid.
468
    data.update(**check_global())
469
    # Update db
470
    if edit_raw:
471
        adapter.update(c_id, data=data, fmt_charts=False)
472
    else:
473
        adapter.update(c_id, data=data)
474
    flash('Updated view "{}"'.format(c_id))
475
    return redirect(view_url)
476
477
478
def is_global_dashboard(view):
479
    """Check if a dashboard is considered global."""
480
    return all([
481
        setting('JSONDASH_GLOBALDASH'),
482
        view.get('created_by') == setting('JSONDASH_GLOBAL_USER'),
483
    ])
484
485
486
def check_global():
487
    """Allow overriding of the user by making it global.
488
    This also checks if the setting is enabled for the app,
489
    otherwise it will not allow it.
490
    """
491
    global_enabled = setting('JSONDASH_GLOBALDASH')
492
    global_flag = request.form.get('is_global', '') == 'on'
493
    can_make_global = auth(authtype='edit_global')
494
    if all([global_flag, global_enabled, can_make_global]):
495
        return dict(created_by=setting('JSONDASH_GLOBAL_USER'))
496
    return dict()
497
498
499
@charts.route('/charts/create', methods=['POST'])
500
def create():
501
    """Normalize the form POST and setup the json view config object."""
502
    if not auth(authtype='create'):
503
        flash('You do not have access to create dashboards.', 'error')
504
        return redirect(url_for('jsondash.dashboard'))
505
    data = request.form
506
    new_id = str(uuid.uuid1())
507
    d = dict(
508
        name=data['name'],
509
        modules=db.format_charts(data),
510
        date=dt.now(),
511
        id=new_id,
512
        layout=data.get('mode', 'grid'),
513
    )
514
    d.update(**metadata())
515
    # Possibly override global user, if configured and valid.
516
    d.update(**check_global())
517
    # Add to DB
518
    adapter.create(data=d)
519
    flash('Created new dashboard "{}"'.format(data['name']))
520
    return redirect(url_for('jsondash.view', c_id=new_id))
521
522
523
@charts.route('/charts/<c_id>/clone', methods=['POST'])
524
def clone(c_id):
525
    """Clone a json view config from the DB."""
526
    if not auth(authtype='clone'):
527
        flash('You do not have access to clone dashboards.', 'error')
528
        return redirect(url_for('jsondash.dashboard'))
529
    viewjson = adapter.read(c_id=c_id)
530
    if not viewjson:
531
        flash('Could not find view: {}'.format(c_id), 'error')
532
        return redirect(url_for('jsondash.dashboard'))
533
    # Update some fields.
534
    newname = 'Clone of {}'.format(viewjson['name'])
535
    data = dict(
536
        name=newname,
537
        modules=viewjson['modules'],
538
        date=str(dt.now()),
539
        id=str(uuid.uuid1()),
540
        layout=viewjson['layout'],
541
    )
542
    data.update(**metadata())
543
    # Add to DB
544
    adapter.create(data=data)
545
    flash('Created new dashboard clone "{}"'.format(newname))
546
    return redirect(url_for('jsondash.view', c_id=data['id']))
547