Completed
Push — master ( 58e7a9...dddd97 )
by Chris
45s
created

dashboard()   B

Complexity

Conditions 6

Size

Total Lines 38

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
dl 0
loc 38
rs 7.5384
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, defaultdict
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 next 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
        embeddable=request.args.get('embeddable', False),
167
        demo_mode=request.args.get('jsondash_demo_mode', False),
168
        global_dashuser=setting('JSONDASH_GLOBAL_USER'),
169
        global_dashboards=setting('JSONDASH_GLOBALDASH'),
170
        username=metadata(key='username') if filter_user else None,
171
        filter_dashboards=filter_user,
172
    )
173
174
175
@jinja2.contextfilter
176
@charts.app_template_filter('get_dims')
177
def get_dims(_, config):
178
    """Extract the dimensions from config data. This allows
179
    for overrides for edge-cases to live in one place.
180
    """
181
    if not all([
182
        'width' in config,
183
        'height' in config,
184
        'dataSource' in config,
185
        config.get('dataSource') != '',
186
        config.get('dataSource') is not None,
187
    ]):
188
        raise ValueError('Invalid config!')
189
    fixed_layout = str(config.get('width')).startswith('col-')
190
    if config.get('type') == 'youtube':
191
        # Override all width settings if fixed grid layout
192
        if fixed_layout:
193
            width = config['width'].replace('col-', '')
194
            return dict(width=width, height=int(config['height']))
195
        # We get the dimensions for the widget from YouTube instead,
196
        # which handles aspect ratios, etc... and is likely what the user
197
        # wanted to specify since they will be entering in embed code from
198
        # Youtube directly.
199
        padding_w = 20
200
        padding_h = 60
201
        embed = config['dataSource'].split(' ')
202
        w = int(embed[1].replace('width=', '').replace('"', ''))
203
        h = int(embed[2].replace('height=', '').replace('"', ''))
204
        return dict(width=w + padding_w, height=h + padding_h)
205
    return dict(width=config['width'], height=config['height'])
206
207
208
@jinja2.contextfilter
209
@charts.app_template_filter('jsonstring')
210
def jsonstring(ctx, data):
211
    """Format view json module data for template use.
212
213
    It's automatically converted to unicode key/value pairs,
214
    which is undesirable for the template.
215
    """
216
    if 'date' in data:
217
        data['date'] = str(data['date'])
218
    return json.dumps(data)
219
220
221
@charts.route('/jsondash/<path:filename>')
222
def _static(filename):
223
    """Send static files directly for this blueprint."""
224
    return send_from_directory(STATIC_DIR, filename)
225
226
227
def get_all_assets():
228
    """Load ALL asset files for css/js from config."""
229
    cssfiles, jsfiles = [], []
230
    for c in CHARTS_CONFIG.values():
231
        if c['css_url'] is not None:
232
            cssfiles += c['css_url']
233
        if c['js_url'] is not None:
234
            jsfiles += c['js_url']
235
    return dict(
236
        css=cssfiles,
237
        js=jsfiles
238
    )
239
240
241
def get_active_assets(families):
242
    """Given a list of chart families, determine what needs to be loaded."""
243
    families += REQUIRED_STATIC_FAMILES  # Always load internal, shared libs.
244
    assets = dict(css=[], js=[])
245
    families = set(families)
246
    for family, data in CHARTS_CONFIG.items():
247
        if family in families:
248
            # Also add all dependency assets.
249
            if data['dependencies']:
250
                for dep in data['dependencies']:
251
                    assets['css'] += [
252
                        css for css in CHARTS_CONFIG[dep]['css_url']
253
                        if css not in assets['css']]
254
255
                    assets['js'] += [
256
                        js for js in CHARTS_CONFIG[dep]['js_url']
257
                        if js not in assets['js']
258
                    ]
259
            assets['css'] += [
260
                css for css in data['css_url'] if css not in assets['css']]
261
            assets['js'] += [
262
                js for js in data['js_url'] if js not in assets['js']]
263
    assets['css'] = list(assets['css'])
264
    assets['js'] = list(assets['js'])
265
    return assets
266
267
268
def paginator(page=0, per_page=None, count=None):
269
    """Get pagination calculations in a compact format."""
270
    if count is None:
271
        count = adapter.count()
272
    if page is None:
273
        page = 0
274
    if per_page is None:
275
        per_page = setting('JSONDASH_PERPAGE')
276
    per_page = per_page if per_page > 2 else 2  # Prevent division errors
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
    skip = curr_page * per_page
283
    return Paginator(
284
        limit=per_page,
285
        per_page=per_page,
286
        curr_page=curr_page,
287
        skip=skip,
288
        next=min([skip + per_page, count]),
289
        num_pages=pages,
290
        count=count,
291
    )
292
293
294
def get_num_rows(viewconf):
295
    """Get the number of rows for a layout if it's using fixed grid format.
296
297
    Args:
298
        viewconf (dict): The dashboard configuration
299
300
    Returns:
301
        int: returned if the number of modules can be determined
302
        None: returned if viewconf is invalid or the layout type
303
            does not support rows.
304
    """
305
    if viewconf is None:
306
        return None
307
    layout = viewconf.get('layout', 'freeform')
308
    if layout == 'freeform':
309
        return None
310
    return len([m['row'] for m in viewconf.get('modules')])
311
312
313
def order_sort(item):
314
    """Attempt to sort modules by order keys.
315
316
    Always returns an integer for compatibility.
317
318
    Args:
319
        item (dict): The module to sort
320
321
    Returns:
322
        int: The sort order integer, or -1 if the item cannot be sorted.
323
    """
324
    if item is None or item.get('order') is None:
325
        return -1
326
    try:
327
        return int(item['order'])
328
    except (ValueError, TypeError):
329
        return -1
330
331
332
def sort_modules(viewjson):
333
    """Sort module data in various ways.
334
335
    If the layout is freeform, sort by default order in a shallow list.
336
    If the layout is fixed grid, sort by default order, nested in a list
337
        for each row - e.g. [[{}, {}], [{}]]
338
        for row 1 (2 modules) and row 2 (1 module)
339
    """
340
    items = sorted(viewjson['modules'], key=order_sort)
341
    if viewjson.get('layout', 'freeform') == 'freeform':
342
        return items
343
    # Sort them by and group them by rows if layout is fixed grid
344
    # Create a temporary dict to hold the number of rows
345
    modules = list({int(item['row']) - 1: [] for item in items}.values())
346
    for module in items:
347
        modules[int(module['row']) - 1].append(module)
348
    return modules
349
350
351
def get_categories():
352
    """Get all categories."""
353
    views = list(adapter.filter({}, {'category': 1}))
354
    return set([
355
        v['category'] for v in views if v.get('category')
356
        not in [None, 'uncategorized']
357
    ])
358
359
360
def categorize_views(views):
361
    """Return a categorized version of the views.
362
363
    Categories are determined by the view category key, if present.
364
    If not present, then the view is bucketed into a general bucket.
365
366
    Args:
367
        views (list): The list of views.
368
    Returns:
369
        dict: The categorized version
370
    """
371
    buckets = defaultdict(list)
372
    for view in views:
373
        try:
374
            buckets[view.get('category', 'uncategorized')].append(view)
375
        except:
376
            continue
377
    for cat, view in buckets.items():
378
        buckets[cat] = sorted(view, key=lambda v: v['name'].lower())
379
    return buckets
380
381
382
@charts.route('/charts', methods=['GET'])
383
@charts.route('/charts/', methods=['GET'])
384
def dashboard():
385
    """Load all views."""
386
    opts = dict()
387
    views = []
388
    # Allow query parameter overrides.
389
    page = int(request.args.get('page', 0))
390
    per_page = int(request.args.get('per_page', setting('JSONDASH_PERPAGE')))
391
    if setting('JSONDASH_FILTERUSERS'):
392
        opts.update(filter=dict(created_by=metadata(key='username')))
393
        views = list(adapter.read(**opts))
394
        if setting('JSONDASH_GLOBALDASH'):
395
            opts.update(
396
                filter=dict(created_by=setting('JSONDASH_GLOBAL_USER')))
397
            views += list(adapter.read(**opts))
398
    else:
399
        views = list(adapter.read(**opts))
400
    if views:
401
        pagination = paginator(count=len(views), page=page, per_page=per_page)
402
        opts.update(limit=pagination.limit, skip=pagination.skip)
403
        views = views[pagination.skip:pagination.next]
404
    else:
405
        pagination = None
406
    categorized = categorize_views(views)
407
    kwargs = dict(
408
        total=len(views),
409
        views=categorized,
410
        view=None,
411
        paginator=pagination,
412
        creating=True,
413
        can_edit_global=auth(authtype='edit_global'),
414
        total_modules=sum([
415
            len(view.get('modules', [])) for view in views
416
            if isinstance(view, dict)
417
        ]),
418
    )
419
    return render_template('pages/charts_index.html', **kwargs)
420
421
422
@charts.route('/charts/<c_id>', methods=['GET'])
423
def view(c_id):
424
    """Load a json view config from the DB."""
425
    if not auth(authtype='view', view_id=c_id):
426
        flash('You do not have access to view this dashboard.', 'error')
427
        return redirect(url_for('jsondash.dashboard'))
428
    viewjson = adapter.read(c_id=c_id)
429
    if not viewjson:
430
        flash('Could not find view: {}'.format(c_id), 'error')
431
        return redirect(url_for('jsondash.dashboard'))
432
    # Remove _id, it's not JSON serializeable.
433
    if '_id' in viewjson:
434
        viewjson.pop('_id')
435
    if 'modules' not in viewjson:
436
        flash('Invalid configuration - missing modules.', 'error')
437
        return redirect(url_for('jsondash.dashboard'))
438
    # Chart family is encoded in chart type value for lookup.
439
    active_charts = [v.get('family') for v in viewjson['modules']
440
                     if v.get('family') is not None]
441
    # If the logged in user is also the creator of this dashboard,
442
    # let me edit it. Otherwise, defer to any user-supplied auth function
443
    # for this specific view.
444
    if metadata(key='username') == viewjson.get('created_by'):
445
        can_edit = True
446
    else:
447
        can_edit = auth(authtype='edit_others', view_id=c_id)
448
    # Backwards compatible layout type
449
    layout_type = viewjson.get('layout', 'freeform')
450
    kwargs = dict(
451
        id=c_id,
452
        view=viewjson,
453
        categories=get_categories(),
454
        num_rows=None if layout_type == 'freeform' else get_num_rows(viewjson),
455
        modules=sort_modules(viewjson),
456
        assets=get_active_assets(active_charts),
457
        can_edit=can_edit,
458
        can_edit_global=auth(authtype='edit_global'),
459
        is_global=is_global_dashboard(viewjson),
460
    )
461
    return render_template('pages/chart_detail.html', **kwargs)
462
463
464
@charts.route('/charts/<c_id>/delete', methods=['POST'])
465
def delete(c_id):
466
    """Delete a json dashboard config."""
467
    dash_url = url_for('jsondash.dashboard')
468
    if not auth(authtype='delete'):
469
        flash('You do not have access to delete dashboards.', 'error')
470
        return redirect(dash_url)
471
    adapter.delete(c_id)
472
    flash('Deleted dashboard "{}"'.format(c_id))
473
    return redirect(dash_url)
474
475
476
def is_consecutive_rows(lst):
477
    """Check if a list of integers is consecutive.
478
479
    Args:
480
        lst (list): The list of integers.
481
482
    Returns:
483
        True/False: If the list contains consecutive integers.
484
485
    Originally taken from and modified:
486
        http://stackoverflow.com/
487
            questions/40091617/test-for-consecutive-numbers-in-list
488
    """
489
    assert 0 not in lst, '0th index is invalid!'
490
    lst = list(set(lst))
491
    if not lst:
492
        return True
493
    setl = set(lst)
494
    return len(lst) == len(setl) and setl == set(range(min(lst), max(lst) + 1))
495
496
497
def validate_raw_json_grid(conf):
498
    """Grid mode specific validations.
499
500
    Args:
501
        conf (dict): The dashboard configuration.
502
503
    Raises:
504
        InvalidSchemaError: If there are any issues with the schema
505
506
    Returns:
507
        None: If no errors were found.
508
    """
509
    layout = conf.get('layout', 'freeform')
510
    fixed_only_required = ['row']
511
    rows = []
512
    modules = conf.get('modules', [])
513
    valid_cols = ['col-{}'.format(i) for i in range(1, 13)]
514
    if not modules:
515
        return
516
    for module in modules:
517
        try:
518
            rows.append(int(module.get('row')))
519
        except TypeError:
520
            raise InvalidSchemaError(
521
                'Invalid row value for module "{}"'.format(module.get('name')))
522
    if not is_consecutive_rows(rows):
523
        raise InvalidSchemaError(
524
            'Row order is not consecutive: "{}"!'.format(sorted(rows)))
525
    for module in modules:
526
        width = module.get('width')
527
        if width not in valid_cols:
528
            raise InvalidSchemaError(
529
                'Invalid width for grid format: "{}"'.format(width))
530
        for field in fixed_only_required:
531
            if field not in module and layout == 'grid':
532
                ident = module.get('name', module)
533
                raise InvalidSchemaError(
534
                    'Invalid JSON. "{}" must be '
535
                    'included in "{}" for '
536
                    'fixed grid layouts'.format(field, ident))
537
538
539
def validate_raw_json(jsonstr, **overrides):
540
    """Validate the raw json for a config.
541
542
    Args:
543
        jsonstr (str): The raw json configuration
544
        **overrides: Any key/value pairs to override in the config.
545
            Used only for setting default values that the user should
546
            never enter but are required to validate the schema.
547
548
    Raises:
549
        InvalidSchemaError: If there are any issues with the schema
550
551
    Returns:
552
        data (dict): The parsed configuration data
553
    """
554
    data = json.loads(jsonstr)
555
    data.update(**overrides)
556
    layout = data.get('layout', 'freeform')
557
558
    if layout == 'grid':
559
        validate_raw_json_grid(data)
560
    else:
561
        for module in data.get('modules', []):
562
            width = module.get('width')
563
            try:
564
                int(width)
565
            except (TypeError, ValueError):
566
                raise InvalidSchemaError(
567
                    'Invalid value for width in `freeform` layout.')
568
            if module.get('row') is not None:
569
                raise InvalidSchemaError(
570
                    'Cannot mix `row` with `freeform` layout.')
571
    results = validate(data)
572
    if results is not None:
573
        raise InvalidSchemaError(results)
574
    return data
575
576
577
@charts.route('/charts/<c_id>/update', methods=['POST'])
578
def update(c_id):
579
    """Normalize the form POST and setup the json view config object."""
580
    if not auth(authtype='update'):
581
        flash('You do not have access to update dashboards.', 'error')
582
        return redirect(url_for('jsondash.dashboard'))
583
    viewjson = adapter.read(c_id=c_id)
584
    if not viewjson:
585
        flash('Could not find view: {}'.format(c_id), 'error')
586
        return redirect(url_for('jsondash.dashboard'))
587
    form_data = request.form
588
    view_url = url_for('jsondash.view', c_id=c_id)
589
    edit_raw = 'edit-raw' in request.form
590
    now = str(dt.now())
591
    if edit_raw:
592
        try:
593
            conf = form_data.get('config')
594
            data = validate_raw_json(conf, date=now, id=c_id)
595
            data = db.reformat_data(data, c_id)
596
        except InvalidSchemaError as e:
597
            flash(str(e), 'error')
598
            return redirect(view_url)
599
        except (TypeError, ValueError) as e:
600
            flash('Invalid JSON config. "{}"'.format(e), 'error')
601
            return redirect(view_url)
602
    else:
603
        modules = db.format_charts(form_data)
604
        layout = form_data['mode']
605
        # Disallow any values if they would cause an invalid layout.
606
        if layout == 'grid' and modules and modules[0].get('row') is None:
607
            flash('Cannot use grid layout without '
608
                  'specifying row(s)! Edit JSON manually '
609
                  'to override this.', 'error')
610
            return redirect(view_url)
611
        category = form_data.get('category', '')
612
        category_override = form_data.get('category_new', '')
613
        category = category_override if category_override != '' else category
614
        data = dict(
615
            category=category if category != '' else 'uncategorized',
616
            name=form_data['name'],
617
            layout=layout,
618
            modules=modules,
619
            id=c_id,
620
            date=now,
621
        )
622
    # Update metadata, but exclude some fields that should never
623
    # be overwritten by user, once the view has been created.
624
    data.update(**metadata(exclude=['created_by']))
625
    # Possibly override global user, if configured and valid.
626
    data.update(**check_global())
627
    # Update db
628
    if edit_raw:
629
        adapter.update(c_id, data=data, fmt_charts=False)
630
    else:
631
        adapter.update(c_id, data=data)
632
    flash('Updated view "{}"'.format(c_id))
633
    return redirect(view_url)
634
635
636
def is_global_dashboard(view):
637
    """Check if a dashboard is considered global.
638
639
    Args:
640
        view (dict): The dashboard configuration
641
642
    Returns:
643
        bool: If all criteria was met to be included as a global dashboard.
644
    """
645
    return all([
646
        setting('JSONDASH_GLOBALDASH'),
647
        view.get('created_by') == setting('JSONDASH_GLOBAL_USER'),
648
    ])
649
650
651
def check_global():
652
    """Allow overriding of the user by making it global.
653
654
    This also checks if the setting is enabled for the app,
655
    otherwise it will not allow it.
656
657
    Returns:
658
        dict: A dictionary with certain global flags overriden.
659
    """
660
    global_enabled = setting('JSONDASH_GLOBALDASH')
661
    global_flag = request.form.get('is_global') is not None
662
    can_make_global = auth(authtype='edit_global')
663
    if all([global_flag, global_enabled, can_make_global]):
664
        return dict(created_by=setting('JSONDASH_GLOBAL_USER'))
665
    return dict()
666
667
668
@charts.route('/charts/create', methods=['POST'])
669
def create():
670
    """Normalize the form POST and setup the json view config object."""
671
    if not auth(authtype='create'):
672
        flash('You do not have access to create dashboards.', 'error')
673
        return redirect(url_for('jsondash.dashboard'))
674
    data = request.form
675
    new_id = str(uuid.uuid1())
676
    d = dict(
677
        name=data['name'],
678
        modules=db.format_charts(data),
679
        date=str(dt.now()),
680
        id=new_id,
681
        layout=data.get('mode', 'grid'),
682
    )
683
    d.update(**metadata())
684
    # Possibly override global user, if configured and valid.
685
    d.update(**check_global())
686
    # Add to DB
687
    adapter.create(data=d)
688
    flash('Created new dashboard "{}"'.format(data['name']))
689
    return redirect(url_for('jsondash.view', c_id=new_id))
690
691
692
@charts.route('/charts/<c_id>/clone', methods=['POST'])
693
def clone(c_id):
694
    """Clone a json view config from the DB."""
695
    if not auth(authtype='clone'):
696
        flash('You do not have access to clone dashboards.', 'error')
697
        return redirect(url_for('jsondash.dashboard'))
698
    viewjson = adapter.read(c_id=c_id)
699
    if not viewjson:
700
        flash('Could not find view: {}'.format(c_id), 'error')
701
        return redirect(url_for('jsondash.dashboard'))
702
    # Update some fields.
703
    newname = 'Clone of {}'.format(viewjson['name'])
704
    data = dict(
705
        name=newname,
706
        modules=viewjson['modules'],
707
        date=str(dt.now()),
708
        id=str(uuid.uuid1()),
709
        layout=viewjson['layout'],
710
    )
711
    data.update(**metadata())
712
    # Add to DB
713
    adapter.create(data=data)
714
    flash('Created new dashboard clone "{}"'.format(newname))
715
    return redirect(url_for('jsondash.view', c_id=data['id']))
716