Completed
Push — master ( 684a71...5716d7 )
by Chris
01:36
created

get_num_rows()   A

Complexity

Conditions 3

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
c 0
b 0
f 0
dl 0
loc 6
rs 9.4285
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
    if config.get('type') == 'youtube':
172
        # We get the dimensions for the widget from YouTube instead,
173
        # which handles aspect ratios, etc... and is likely what the user
174
        # wanted to specify since they will be entering in embed code from
175
        # Youtube directly.
176
        embed = config['dataSource'].split(' ')
177
        padding_w = 20
178
        padding_h = 60
179
        w = int(embed[1].replace('width=', '').replace('"', ''))
180
        h = int(embed[2].replace('height=', '').replace('"', ''))
181
        return dict(width=w + padding_w, height=h + padding_h)
182
    return dict(width=config['width'], height=config['height'])
183
184
185
@jinja2.contextfilter
186
@charts.app_template_filter('jsonstring')
187
def jsonstring(ctx, data):
188
    """Format view json module data for template use.
189
190
    It's automatically converted to unicode key/value pairs,
191
    which is undesirable for the template.
192
    """
193
    if 'date' in data:
194
        data['date'] = str(data['date'])
195
    return json.dumps(data)
196
197
198
@charts.route('/jsondash/<path:filename>')
199
def _static(filename):
200
    """Send static files directly for this blueprint."""
201
    return send_from_directory(static_dir, filename)
202
203
204
def get_all_assets():
205
    """Load ALL asset files for css/js from config."""
206
    cssfiles, jsfiles = [], []
207
    for c in CHARTS_CONFIG.values():
208
        if c['css_url'] is not None:
209
            cssfiles += c['css_url']
210
        if c['js_url'] is not None:
211
            jsfiles += c['js_url']
212
    return dict(
213
        css=cssfiles,
214
        js=jsfiles
215
    )
216
217
218
def get_active_assets(families):
219
    """Given a list of chart families, determine what needs to be loaded."""
220
    families += REQUIRED_STATIC_FAMILES  # Always load internal, shared libs.
221
    assets = dict(css=[], js=[])
222
    families = set(families)
223
    for family, data in CHARTS_CONFIG.items():
224
        if family in families:
225
            # Also add all dependency assets.
226
            if data['dependencies']:
227
                for dep in data['dependencies']:
228
                    assets['css'] += [
229
                        css for css in CHARTS_CONFIG[dep]['css_url']
230
                        if css not in assets['css']]
231
232
                    assets['js'] += [
233
                        js for js in CHARTS_CONFIG[dep]['js_url']
234
                        if js not in assets['js']
235
                    ]
236
            assets['css'] += [
237
                css for css in data['css_url'] if css not in assets['css']]
238
            assets['js'] += [
239
                js for js in data['js_url'] if js not in assets['js']]
240
    assets['css'] = list(assets['css'])
241
    assets['js'] = list(assets['js'])
242
    return assets
243
244
245
def paginator(page=0, per_page=None, count=None):
246
    """Get pagination calculations in a compact format."""
247
    if count is None:
248
        count = adapter.count()
249
    if page is None:
250
        page = 0
251
    default_per_page = setting('JSONDASH_PERPAGE')
252
    # Allow query parameter overrides.
253
    per_page = per_page if per_page is not None else default_per_page
254
    per_page = per_page if per_page > 2 else 2  # Prevent division errors etc
255
    curr_page = page - 1 if page > 0 else 0
256
    num_pages = count // per_page
257
    rem = count % per_page
258
    extra_pages = 2 if rem else 1
259
    pages = list(range(1, num_pages + extra_pages))
260
    return Paginator(
261
        limit=per_page,
262
        per_page=per_page,
263
        curr_page=curr_page,
264
        skip=curr_page * per_page,
265
        num_pages=pages,
266
        count=count,
267
    )
268
269
270
def get_num_rows(viewconf):
271
    """Get the number of rows for a layout if it's using fixed grid format."""
272
    layout = viewconf.get('layout', 'freeform')
273
    if layout == 'freeform':
274
        return None
275
    return len([m['row'] for m in viewconf.get('modules')])
276
277
278
def order_sort(item):
279
    """Attempt to sort modules by order keys.
280
281
    Always returns an integer for compatibility.
282
    """
283
    if item.get('order') is not None:
284
        try:
285
            return int(item['order'])
286
        except (ValueError, TypeError):
287
            return -1
288
    return -1
289
290
291
def sort_modules(viewjson):
292
    """Sort module data in various ways.
293
294
    If the layout is freeform, sort by default order in a shallow list.
295
    If the layout is fixed grid, sort by default order, nested in a list
296
        for each row - e.g. [[{}, {}], [{}]]
297
        for row 1 (2 modules) and row 2 (1 module)
298
    """
299
    items = sorted(viewjson['modules'], key=order_sort)
300
    if viewjson.get('layout', 'freeform') == 'freeform':
301
        return items
302
    # Sort them by and group them by rows if layout is fixed grid
303
    # Create a temporary dict to hold the number of rows
304
    modules = {int(item['row']) - 1: [] for item in items}.values()
305
    for module in items:
306
        modules[int(module['row']) - 1].append(module)
307
    return modules
308
309
310
@charts.route('/charts', methods=['GET'])
311
@charts.route('/charts/', methods=['GET'])
312
def dashboard():
313
    """Load all views."""
314
    opts = dict()
315
    views = []
316
    if setting('JSONDASH_FILTERUSERS'):
317
        opts.update(filter=dict(created_by=metadata(key='username')))
318
        views = list(adapter.read(**opts))
319
        if setting('JSONDASH_GLOBALDASH'):
320
            opts.update(
321
                filter=dict(created_by=setting('JSONDASH_GLOBAL_USER')))
322
            views += list(adapter.read(**opts))
323
    else:
324
        views = list(adapter.read(**opts))
325
    if views:
326
        page = request.args.get('page')
327
        per_page = request.args.get('per_page')
328
        paginator_args = dict(count=len(views))
329
        if per_page is not None:
330
            paginator_args.update(per_page=int(per_page))
331
        if page is not None:
332
            paginator_args.update(page=int(page))
333
        pagination = paginator(**paginator_args)
334
        opts.update(limit=pagination.limit, skip=pagination.skip)
335
    else:
336
        pagination = None
337
    kwargs = dict(
338
        views=views,
339
        view=None,
340
        paginator=pagination,
341
        creating=True,
342
        can_edit_global=auth(authtype='edit_global'),
343
        total_modules=sum([
344
            len(view.get('modules', [])) for view in views
345
            if isinstance(view, dict)
346
        ]),
347
    )
348
    return render_template('pages/charts_index.html', **kwargs)
349
350
351
@charts.route('/charts/<c_id>', methods=['GET'])
352
def view(c_id):
353
    """Load a json view config from the DB."""
354
    if not auth(authtype='view', view_id=c_id):
355
        flash('You do not have access to view this dashboard.', 'error')
356
        return redirect(url_for('jsondash.dashboard'))
357
    viewjson = adapter.read(c_id=c_id)
358
    if not viewjson:
359
        flash('Could not find view: {}'.format(c_id), 'error')
360
        return redirect(url_for('jsondash.dashboard'))
361
    # Remove _id, it's not JSON serializeable.
362
    if '_id' in viewjson:
363
        viewjson.pop('_id')
364
    if 'modules' not in viewjson:
365
        flash('Invalid configuration - missing modules.', 'error')
366
        return redirect(url_for('jsondash.dashboard'))
367
    # Chart family is encoded in chart type value for lookup.
368
    active_charts = [v.get('family') for v in viewjson['modules']
369
                     if v.get('family') is not None]
370
    # If the logged in user is also the creator of this dashboard,
371
    # let me edit it. Otherwise, defer to any user-supplied auth function
372
    # for this specific view.
373
    if metadata(key='username') == viewjson.get('created_by'):
374
        can_edit = True
375
    else:
376
        can_edit = auth(authtype='edit_others', view_id=c_id)
377
    kwargs = dict(
378
        id=c_id,
379
        view=viewjson,
380
        num_rows=get_num_rows(viewjson),
381
        modules=sort_modules(viewjson),
382
        assets=get_active_assets(active_charts),
383
        can_edit=can_edit,
384
        can_edit_global=auth(authtype='edit_global'),
385
        is_global=is_global_dashboard(viewjson),
386
    )
387
    return render_template('pages/chart_detail.html', **kwargs)
388
389
390
@charts.route('/charts/<c_id>/delete', methods=['POST'])
391
def delete(c_id):
392
    """Delete a json dashboard config."""
393
    dash_url = url_for('jsondash.dashboard')
394
    if not auth(authtype='delete'):
395
        flash('You do not have access to delete dashboards.', 'error')
396
        return redirect(dash_url)
397
    adapter.delete(c_id)
398
    flash('Deleted dashboard "{}"'.format(c_id))
399
    return redirect(dash_url)
400
401
402
def validate_raw_json(jsonstr):
403
    """Validate the raw json for a config."""
404
    data = json.loads(jsonstr)
405
    layout = data.get('layout', 'freeform')
406
    for module in data.get('modules'):
407
        required = ['family', 'name', 'width', 'height', 'dataSource', 'type']
408
        fixed_required = ['row']
409
        for field in required:
410
            if field not in module:
411
                raise InvalidSchemaError(
412
                    'Invalid JSON. "{}" must be '
413
                    'included in "{}"'.format(field, module))
414
        for field in fixed_required:
415
            if field not in module and layout == 'grid':
416
                raise InvalidSchemaError(
417
                    'Invalid JSON. "{}" must be '
418
                    'included in "{}" for '
419
                    'fixed grid layouts'.format(field, module))
420
    return data
421
422
423
@charts.route('/charts/<c_id>/update', methods=['POST'])
424
def update(c_id):
425
    """Normalize the form POST and setup the json view config object."""
426
    if not auth(authtype='update'):
427
        flash('You do not have access to update dashboards.', 'error')
428
        return redirect(url_for('jsondash.dashboard'))
429
    viewjson = adapter.read(c_id=c_id)
430
    if not viewjson:
431
        flash('Could not find view: {}'.format(c_id), 'error')
432
        return redirect(url_for('jsondash.dashboard'))
433
    form_data = request.form
434
    view_url = url_for('jsondash.view', c_id=c_id)
435
    edit_raw = 'edit-raw' in request.form
436
    if edit_raw:
437
        try:
438
            data = validate_raw_json(form_data.get('config'))
439
            data = db.reformat_data(data, c_id)
440
        except InvalidSchemaError as e:
441
            flash(str(e), 'error')
442
            return redirect(view_url)
443
        except (TypeError, ValueError):
444
            flash('Invalid JSON config.', 'error')
445
            return redirect(view_url)
446
    else:
447
        data = dict(
448
            name=form_data['name'],
449
            modules=db.format_charts(form_data),
450
            date=str(dt.now()),
451
            id=c_id,
452
        )
453
    # Update metadata, but exclude some fields that should never
454
    # be overwritten by user, once the view has been created.
455
    data.update(**metadata(exclude=['created_by']))
456
    # Possibly override global user, if configured and valid.
457
    data.update(**check_global())
458
    # Update db
459
    if edit_raw:
460
        adapter.update(c_id, data=data, fmt_charts=False)
461
    else:
462
        adapter.update(c_id, data=data)
463
    flash('Updated view "{}"'.format(c_id))
464
    return redirect(view_url)
465
466
467
def is_global_dashboard(view):
468
    """Check if a dashboard is considered global."""
469
    return all([
470
        setting('JSONDASH_GLOBALDASH'),
471
        view.get('created_by') == setting('JSONDASH_GLOBAL_USER'),
472
    ])
473
474
475
def check_global():
476
    """Allow overriding of the user by making it global.
477
    This also checks if the setting is enabled for the app,
478
    otherwise it will not allow it.
479
    """
480
    global_enabled = setting('JSONDASH_GLOBALDASH')
481
    global_flag = request.form.get('is_global', '') == 'on'
482
    can_make_global = auth(authtype='edit_global')
483
    if all([global_flag, global_enabled, can_make_global]):
484
        return dict(created_by=setting('JSONDASH_GLOBAL_USER'))
485
    return dict()
486
487
488
@charts.route('/charts/create', methods=['POST'])
489
def create():
490
    """Normalize the form POST and setup the json view config object."""
491
    if not auth(authtype='create'):
492
        flash('You do not have access to create dashboards.', 'error')
493
        return redirect(url_for('jsondash.dashboard'))
494
    data = request.form
495
    new_id = str(uuid.uuid1())
496
    d = dict(
497
        name=data['name'],
498
        modules=db.format_charts(data),
499
        date=dt.now(),
500
        id=new_id,
501
        layout=data.get('mode', 'grid'),
502
    )
503
    d.update(**metadata())
504
    # Possibly override global user, if configured and valid.
505
    d.update(**check_global())
506
    # Add to DB
507
    adapter.create(data=d)
508
    flash('Created new dashboard "{}"'.format(data['name']))
509
    return redirect(url_for('jsondash.view', c_id=new_id))
510
511
512
@charts.route('/charts/<c_id>/clone', methods=['POST'])
513
def clone(c_id):
514
    """Clone a json view config from the DB."""
515
    if not auth(authtype='clone'):
516
        flash('You do not have access to clone dashboards.', 'error')
517
        return redirect(url_for('jsondash.dashboard'))
518
    viewjson = adapter.read(c_id=c_id)
519
    if not viewjson:
520
        flash('Could not find view: {}'.format(c_id), 'error')
521
        return redirect(url_for('jsondash.dashboard'))
522
    # Update some fields.
523
    newname = 'Clone of {}'.format(viewjson['name'])
524
    data = dict(
525
        name=newname,
526
        modules=viewjson['modules'],
527
        date=str(dt.now()),
528
        id=str(uuid.uuid1()),
529
        layout=viewjson['layout'],
530
    )
531
    data.update(**metadata())
532
    # Add to DB
533
    adapter.create(data=data)
534
    flash('Created new dashboard clone "{}"'.format(newname))
535
    return redirect(url_for('jsondash.view', c_id=data['id']))
536