get_active_assets()   D
last analyzed

Complexity

Conditions 13

Size

Total Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 13
dl 0
loc 25
rs 4.2
c 0
b 0
f 0

How to fix   Complexity   

Complexity

Complex classes like get_active_assets() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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 datetime import datetime as dt
17
18
import jinja2
19
from flask import (Blueprint, current_app, flash, redirect, render_template,
20
                   request, send_from_directory, url_for)
21
22
from flask_jsondash import static, templates
23
24
from flask_jsondash import db
25
from flask_jsondash import settings
26
from flask_jsondash.utils import setting
27
from flask_jsondash.utils import adapter
28
from flask_jsondash import utils
29
from flask_jsondash.schema import (
30
    validate_raw_json, InvalidSchemaError,
31
)
32
33
TEMPLATE_DIR = os.path.dirname(templates.__file__)
34
STATIC_DIR = os.path.dirname(static.__file__)
35
36
# Internally required libs that are also shared in `settings.py`
37
# for charts. These follow the same format as what is loaded in
38
# `get_active_assets` so that shared libraries are loaded in the same manner
39
# for simplicty and prevention of duplicate loading.
40
# Note these are just LABELS, not files.
41
REQUIRED_STATIC_FAMILES = ['D3']
42
43
charts = Blueprint(
44
    'jsondash',
45
    __name__,
46
    template_folder=TEMPLATE_DIR,
47
    static_url_path=STATIC_DIR,
48
    static_folder=STATIC_DIR,
49
)
50
51
52
def auth(**kwargs):
53
    """Check if general auth functions have been specified.
54
55
    Checks for either a global auth (if authtype is None), or
56
    an action specific auth (specified by authtype).
57
    """
58
    if 'JSONDASH' not in current_app.config:
59
        return True
60
    if 'auth' not in current_app.config['JSONDASH']:
61
        return True
62
    authtype = kwargs.pop('authtype')
63
    auth_conf = current_app.config.get('JSONDASH').get('auth')
64
    # If the user didn't supply an auth function, assume true.
65
    if authtype not in auth_conf:
66
        return True
67
    # Only perform the user-supplied check
68
    # if the authtype is actually enabled.
69
    return auth_conf[authtype](**kwargs)
70
71
72
def metadata(key=None, exclude=[]):
73
    """An abstraction around misc. metadata.
74
75
    This allows loose coupling for enabling and setting
76
    metadata for each chart.
77
78
    Args:
79
        key (None, optional): A key to look up in global config.
80
        exclude (list, optional): A list of fields to exclude when
81
            retrieving metadata.
82
83
    Returns:
84
        _metadata (dict): The metadata configuration.
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 local_static(chart_config, static_config):
104
    """Convert remote cdn urls to local urls, based on user provided paths.
105
106
    The filename must be identical to the one specified in the
107
    `settings.py` configuration.
108
109
    So, for example:
110
    '//cdnjs.cloudflare.com/foo/bar/foo.js'
111
    becomes
112
    '/static/js/vendor/foo.js'
113
    """
114
    js_path = static_config.get('js_path')
115
    css_path = static_config.get('css_path')
116
    for family, config in chart_config.items():
117
        if config['js_url']:
118
            for i, url in enumerate(config['js_url']):
119
                url = '{}{}'.format(js_path, url.split('/')[-1])
120
                config['js_url'][i] = url_for('static', filename=url)
121
        if config['css_url']:
122
            for i, url in enumerate(config['css_url']):
123
                url = '{}{}'.format(css_path, url.split('/')[-1])
124
                config['css_url'][i] = url_for('static', filename=url)
125
    return chart_config
126
127
128
@charts.context_processor
129
def ctx():
130
    """Inject any context needed for this blueprint."""
131
    filter_user = setting('JSONDASH_FILTERUSERS')
132
    static = setting('JSONDASH').get('static')
133
    # Rewrite the static config paths to be local if the overrides are set.
134
    config = (settings.CHARTS_CONFIG if not static
135
              else local_static(settings.CHARTS_CONFIG, static))
136
    return dict(
137
        static_config=static,
138
        charts_config=config,
139
        page_title='dashboards',
140
        docs_url=('https://github.com/christabor/flask_jsondash/'
141
                  'blob/master/docs/'),
142
        embeddable=request.args.get('embeddable', False),
143
        demo_mode=request.args.get('jsondash_demo_mode', False),
144
        global_dashuser=setting('JSONDASH_GLOBAL_USER'),
145
        global_dashboards=setting('JSONDASH_GLOBALDASH'),
146
        username=metadata(key='username') if filter_user else None,
147
        filter_dashboards=filter_user,
148
    )
149
150
151
@jinja2.contextfilter
152
@charts.app_template_filter('get_dims')
153
def get_dims(_, config):
154
    """Extract the dimensions from config data. This allows
155
    for overrides for edge-cases to live in one place.
156
    """
157
    if not all([
158
        'width' in config,
159
        'height' in config,
160
        'dataSource' in config,
161
        config.get('dataSource') != '',
162
        config.get('dataSource') is not None,
163
    ]):
164
        raise ValueError('Invalid config!')
165
    fixed_layout = str(config.get('width')).startswith('col-')
166
    if config.get('type') == 'youtube':
167
        # Override all width settings if fixed grid layout
168
        if fixed_layout:
169
            width = config['width'].replace('col-', '')
170
            return dict(width=width, height=int(config['height']))
171
        # We get the dimensions for the widget from YouTube instead,
172
        # which handles aspect ratios, etc... and is likely what the user
173
        # wanted to specify since they will be entering in embed code from
174
        # Youtube directly.
175
        padding_w = 20
176
        padding_h = 60
177
        embed = config['dataSource'].split(' ')
178
        w = int(embed[1].replace('width=', '').replace('"', ''))
179
        h = int(embed[2].replace('height=', '').replace('"', ''))
180
        return dict(width=w + padding_w, height=h + padding_h)
181
    return dict(width=config['width'], height=config['height'])
182
183
184
@jinja2.contextfilter
185
@charts.app_template_filter('jsonstring')
186
def jsonstring(ctx, data):
187
    """Format view json module data for template use.
188
189
    It's automatically converted to unicode key/value pairs,
190
    which is undesirable for the template.
191
    """
192
    if 'date' in data:
193
        data['date'] = str(data['date'])
194
    return json.dumps(data)
195
196
197
@charts.route('/jsondash/<path:filename>')
198
def _static(filename):
199
    """Send static files directly for this blueprint."""
200
    return send_from_directory(STATIC_DIR, filename)
201
202
203
def get_all_assets():
204
    """Load ALL asset files for css/js from config."""
205
    cssfiles, jsfiles = [], []
206
    for c in settings.CHARTS_CONFIG.values():
207
        if c['css_url'] is not None:
208
            cssfiles += c['css_url']
209
        if c['js_url'] is not None:
210
            jsfiles += c['js_url']
211
    return dict(
212
        css=cssfiles,
213
        js=jsfiles
214
    )
215
216
217
def get_active_assets(families):
218
    """Given a list of chart families, determine what needs to be loaded."""
219
    families += REQUIRED_STATIC_FAMILES  # Always load internal, shared libs.
220
    assets = dict(css=[], js=[])
221
    families = set(families)
222
    for family, data in settings.CHARTS_CONFIG.items():
223
        if family in families:
224
            # Also add all dependency assets.
225
            if data['dependencies']:
226
                for dep in data['dependencies']:
227
                    assets['css'] += [
228
                        css for css in settings.CHARTS_CONFIG[dep]['css_url']
229
                        if css not in assets['css']]
230
231
                    assets['js'] += [
232
                        js for js in settings.CHARTS_CONFIG[dep]['js_url']
233
                        if js not in assets['js']
234
                    ]
235
            assets['css'] += [
236
                css for css in data['css_url'] if css not in assets['css']]
237
            assets['js'] += [
238
                js for js in data['js_url'] if js not in assets['js']]
239
    assets['css'] = list(assets['css'])
240
    assets['js'] = list(assets['js'])
241
    return assets
242
243
244
def get_categories():
245
    """Get all categories."""
246
    views = list(adapter.filter({}, {'category': 1}))
247
    return set([
248
        v['category'] for v in views if v.get('category')
249
        not in [None, 'uncategorized']
250
    ])
251
252
253
@charts.route('/charts', methods=['GET'])
254
@charts.route('/charts/', methods=['GET'])
255
def dashboard():
256
    """Load all views."""
257
    opts = dict()
258
    views = []
259
    # Allow query parameter overrides.
260
    page = int(request.args.get('page', 0))
261
    per_page = int(request.args.get(
262
        'per_page', setting('JSONDASH_PERPAGE')))
263
    if setting('JSONDASH_FILTERUSERS'):
264
        opts.update(filter=dict(created_by=metadata(key='username')))
265
        views = list(adapter.read(**opts))
266
        if setting('JSONDASH_GLOBALDASH'):
267
            opts.update(
268
                filter=dict(created_by=setting('JSONDASH_GLOBAL_USER')))
269
            views += list(adapter.read(**opts))
270
    else:
271
        views = list(adapter.read(**opts))
272
    if views:
273
        pagination = utils.paginator(count=len(views),
274
                                     page=page, per_page=per_page)
275
        opts.update(limit=pagination.limit, skip=pagination.skip)
276
        views = views[pagination.skip:pagination.next]
277
    else:
278
        pagination = None
279
    categorized = utils.categorize_views(views)
280
    kwargs = dict(
281
        total=len(views),
282
        views=categorized,
283
        view=None,
284
        paginator=pagination,
285
        creating=True,
286
        can_edit_global=auth(authtype='edit_global'),
287
        total_modules=sum([
288
            len(view.get('modules', [])) for view in views
289
            if isinstance(view, dict)
290
        ]),
291
    )
292
    return render_template('pages/charts_index.html', **kwargs)
293
294
295
@charts.route('/charts/<c_id>', methods=['GET'])
296
def view(c_id):
297
    """Load a json view config from the DB."""
298
    if not auth(authtype='view', view_id=c_id):
299
        flash('You do not have access to view this dashboard.', 'error')
300
        return redirect(url_for('jsondash.dashboard'))
301
    viewjson = adapter.read(c_id=c_id)
302
    if not viewjson:
303
        flash('Could not find view: {}'.format(c_id), 'error')
304
        return redirect(url_for('jsondash.dashboard'))
305
    # Remove _id, it's not JSON serializeable.
306
    if '_id' in viewjson:
307
        viewjson.pop('_id')
308
    if 'modules' not in viewjson:
309
        flash('Invalid configuration - missing modules.', 'error')
310
        return redirect(url_for('jsondash.dashboard'))
311
    # Chart family is encoded in chart type value for lookup.
312
    active_charts = [v.get('family') for v in viewjson['modules']
313
                     if v.get('family') is not None]
314
    # If the logged in user is also the creator of this dashboard,
315
    # let me edit it. Otherwise, defer to any user-supplied auth function
316
    # for this specific view.
317
    if metadata(key='username') == viewjson.get('created_by'):
318
        can_edit = True
319
    else:
320
        can_edit = auth(authtype='edit_others', view_id=c_id)
321
    # Backwards compatible layout type
322
    layout_type = viewjson.get('layout', 'freeform')
323
    kwargs = dict(
324
        id=c_id,
325
        view=viewjson,
326
        categories=get_categories(),
327
        num_rows=(
328
            None if layout_type == 'freeform' else utils.get_num_rows(viewjson)
329
        ),
330
        modules=utils.sort_modules(viewjson),
331
        assets=get_active_assets(active_charts),
332
        can_edit=can_edit,
333
        can_edit_global=auth(authtype='edit_global'),
334
        is_global=utils.is_global_dashboard(viewjson),
335
    )
336
    return render_template('pages/chart_detail.html', **kwargs)
337
338
339
@charts.route('/charts/<c_id>/delete', methods=['POST'])
340
def delete(c_id):
341
    """Delete a json dashboard config."""
342
    dash_url = url_for('jsondash.dashboard')
343
    if not auth(authtype='delete'):
344
        flash('You do not have access to delete dashboards.', 'error')
345
        return redirect(dash_url)
346
    adapter.delete(c_id)
347
    flash('Deleted dashboard "{}"'.format(c_id))
348
    return redirect(dash_url)
349
350
351
@charts.route('/charts/<c_id>/update', methods=['POST'])
352
def update(c_id):
353
    """Normalize the form POST and setup the json view config object."""
354
    if not auth(authtype='update'):
355
        flash('You do not have access to update dashboards.', '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
    form_data = request.form
362
    view_url = url_for('jsondash.view', c_id=c_id)
363
    edit_raw = 'edit-raw' in request.form
364
    now = str(dt.now())
365
    if edit_raw:
366
        try:
367
            conf = form_data.get('config')
368
            data = validate_raw_json(conf, date=now, id=c_id)
369
            data = db.reformat_data(data, c_id)
370
        except InvalidSchemaError as e:
371
            flash(str(e), 'error')
372
            return redirect(view_url)
373
        except (TypeError, ValueError) as e:
374
            flash('Invalid JSON config. "{}"'.format(e), 'error')
375
            return redirect(view_url)
376
    else:
377
        modules = db.format_charts(form_data)
378
        layout = form_data['mode']
379
        # Disallow any values if they would cause an invalid layout.
380
        if layout == 'grid' and modules and modules[0].get('row') is None:
381
            flash('Cannot use grid layout without '
382
                  'specifying row(s)! Edit JSON manually '
383
                  'to override this.', 'error')
384
            return redirect(view_url)
385
        category = form_data.get('category', '')
386
        category_override = form_data.get('category_new', '')
387
        category = category_override if category_override != '' else category
388
        data = dict(
389
            category=category if category != '' else 'uncategorized',
390
            name=form_data['name'],
391
            layout=layout,
392
            modules=modules,
393
            id=c_id,
394
            date=now,
395
        )
396
    # Update metadata, but exclude some fields that should never
397
    # be overwritten by user, once the view has been created.
398
    data.update(**metadata(exclude=['created_by']))
399
    # Possibly override global user, if configured and valid.
400
    data.update(**check_global())
401
    # Update db
402
    if edit_raw:
403
        adapter.update(c_id, data=data, fmt_charts=False)
404
    else:
405
        adapter.update(c_id, data=data)
406
    flash('Updated view "{}"'.format(c_id))
407
    return redirect(view_url)
408
409
410
def check_global():
411
    """Allow overriding of the user by making it global.
412
413
    This also checks if the setting is enabled for the app,
414
    otherwise it will not allow it.
415
416
    Returns:
417
        dict: A dictionary with certain global flags overriden.
418
    """
419
    global_enabled = setting('JSONDASH_GLOBALDASH')
420
    global_flag = request.form.get('is_global') is not None
421
    can_make_global = auth(authtype='edit_global')
422
    if all([global_flag, global_enabled, can_make_global]):
423
        return dict(created_by=setting('JSONDASH_GLOBAL_USER'))
424
    return dict()
425
426
427
@charts.route('/charts/create', methods=['POST'])
428
def create():
429
    """Normalize the form POST and setup the json view config object."""
430
    if not auth(authtype='create'):
431
        flash('You do not have access to create dashboards.', 'error')
432
        return redirect(url_for('jsondash.dashboard'))
433
    data = request.form
434
    new_id = str(uuid.uuid1())
435
    d = dict(
436
        name=data['name'],
437
        modules=db.format_charts(data),
438
        date=str(dt.now()),
439
        id=new_id,
440
        layout=data.get('mode', 'grid'),
441
    )
442
    d.update(**metadata())
443
    # Possibly override global user, if configured and valid.
444
    d.update(**check_global())
445
    # Add to DB
446
    adapter.create(data=d)
447
    flash('Created new dashboard "{}"'.format(data['name']))
448
    return redirect(url_for('jsondash.view', c_id=new_id))
449
450
451
@charts.route('/charts/<c_id>/clone', methods=['POST'])
452
def clone(c_id):
453
    """Clone a json view config from the DB."""
454
    if not auth(authtype='clone'):
455
        flash('You do not have access to clone dashboards.', 'error')
456
        return redirect(url_for('jsondash.dashboard'))
457
    viewjson = adapter.read(c_id=c_id)
458
    if not viewjson:
459
        flash('Could not find view: {}'.format(c_id), 'error')
460
        return redirect(url_for('jsondash.dashboard'))
461
    # Update some fields.
462
    newname = 'Clone of {}'.format(viewjson['name'])
463
    data = dict(
464
        name=newname,
465
        modules=viewjson['modules'],
466
        date=str(dt.now()),
467
        id=str(uuid.uuid1()),
468
        layout=viewjson['layout'],
469
    )
470
    data.update(**metadata())
471
    # Add to DB
472
    adapter.create(data=data)
473
    flash('Created new dashboard clone "{}"'.format(newname))
474
    return redirect(url_for('jsondash.view', c_id=data['id']))
475