Completed
Push — master ( 636c5d...905128 )
by Chris
01:05
created

get_all_assets()   A

Complexity

Conditions 4

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
c 0
b 0
f 0
dl 0
loc 11
rs 9.2
1
# -*- coding: utf-8 -*-
2
3
"""
4
flask_jsondash.charts_builder
5
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6
7
The chart blueprint that houses all functionality.
8
"""
9
10
import json
11
import os
12
import uuid
13
from collections import namedtuple
14
from datetime import datetime as dt
15
16
from flask_jsondash import static, templates
17
18
from flask import (
19
    Blueprint,
20
    current_app,
21
    flash,
22
    redirect,
23
    render_template,
24
    request,
25
    send_from_directory,
26
    url_for,
27
)
28
import jinja2
29
30
from . import db
31
from .settings import (
32
    CHARTS_CONFIG,
33
)
34
template_dir = os.path.dirname(templates.__file__)
35
static_dir = os.path.dirname(static.__file__)
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
def auth(**kwargs):
57
    """Check if general auth functions have been specified.
58
59
    Checks for either a global auth (if authtype is None), or
60
    an action specific auth (specified by authtype).
61
    """
62
    if 'JSONDASH' not in current_app.config:
63
        return True
64
    if 'auth' not in current_app.config['JSONDASH']:
65
        return True
66
    authtype = kwargs.pop('authtype')
67
    auth_conf = current_app.config.get('JSONDASH').get('auth')
68
    # If the user didn't supply an auth function, assume true.
69
    if authtype not in auth_conf:
70
        return True
71
    # Only perform the user-supplied check
72
    # if the authtype is actually enabled.
73
    return auth_conf[authtype](**kwargs)
74
75
76
def metadata(key=None, exclude=[]):
77
    """An abstraction around misc. metadata.
78
79
    This allows loose coupling for enabling and setting
80
    metadata for each chart.
81
    """
82
    _metadata = dict()
83
    conf = current_app.config
84
    conf_metadata = conf.get('JSONDASH', {}).get('metadata')
85
    # Also useful for getting arbitrary configuration keys.
86
    if key is not None:
87
        if key in conf_metadata:
88
            return conf_metadata[key]()
89
        else:
90
            return None
91
    # Update all metadata values if the function exists.
92
    for k, func in conf_metadata.items():
93
        if k in exclude:
94
            continue
95
        _metadata[k] = conf_metadata[k]()
96
    return _metadata
97
98
99
def setting(name, default=None):
100
    """A simplified getter for namespaced flask config values."""
101
    if default is None:
102
        default = default_config.get(name)
103
    return current_app.config.get(name, default)
104
105
106
def local_static(chart_config, static_config):
107
    """Convert remote cdn urls to local urls, based on user provided paths.
108
109
    The filename must be identical to the one specified in the
110
    `settings.py` configuration.
111
112
    So, for example:
113
    '//cdnjs.cloudflare.com/foo/bar/foo.js'
114
    becomes
115
    '/static/js/vendor/foo.js'
116
    """
117
    js_path = static_config.get('js_path')
118
    css_path = static_config.get('css_path')
119
    for family, config in chart_config.items():
120
        if config['js_url']:
121
            for i, url in enumerate(config['js_url']):
122
                url = '{}{}'.format(js_path, url.split('/')[-1])
123
                config['js_url'][i] = url_for('static', filename=url)
124
        if config['css_url']:
125
            for i, url in enumerate(config['css_url']):
126
                url = '{}{}'.format(css_path, url.split('/')[-1])
127
                config['css_url'][i] = url_for('static', filename=url)
128
    return chart_config
129
130
131
@charts.context_processor
132
def ctx():
133
    """Inject any context needed for this blueprint."""
134
    filter_user = setting('JSONDASH_FILTERUSERS')
135
    static = setting('JSONDASH').get('static')
136
    # Rewrite the static config paths to be local if the overrides are set.
137
    config = (CHARTS_CONFIG if not static
138
              else local_static(CHARTS_CONFIG, static))
139
    return dict(
140
        static_config=static,
141
        charts_config=config,
142
        page_title='dashboards',
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(['width' in config, 'height' in config]):
158
        raise ValueError('Invalid config!')
159
    if config.get('type') == 'youtube':
160
        # We get the dimensions for the widget from YouTube instead,
161
        # which handles aspect ratios, etc... and is likely what the user
162
        # wanted to specify since they will be entering in embed code from
163
        # Youtube directly.
164
        embed = config['dataSource'].split(' ')
165
        padding_w = 20
166
        padding_h = 60
167
        w = int(embed[1].replace('width=', '').replace('"', ''))
168
        h = int(embed[2].replace('height=', '').replace('"', ''))
169
        return dict(width=w + padding_w, height=h + padding_h)
170
    return dict(width=config['width'], height=config['height'])
171
172
173
@jinja2.contextfilter
174
@charts.app_template_filter('jsonstring')
175
def jsonstring(ctx, data):
176
    """Format view json module data for template use.
177
178
    It's automatically converted to unicode key/value pairs,
179
    which is undesirable for the template.
180
    """
181
    if 'date' in data:
182
        data['date'] = str(data['date'])
183
    return json.dumps(data)
184
185
186
@charts.route('/jsondash/<path:filename>')
187
def _static(filename):
188
    """Send static files directly for this blueprint."""
189
    return send_from_directory(static_dir, filename)
190
191
192
def get_all_assets():
193
    """Load ALL asset files for css/js from config."""
194
    cssfiles, jsfiles = [], []
195
    for c in CHARTS_CONFIG.values():
196
        if c['css_url'] is not None:
197
            cssfiles += c['css_url']
198
        if c['js_url'] is not None:
199
            jsfiles += c['js_url']
200
    return dict(
201
        css=cssfiles,
202
        js=jsfiles
203
    )
204
205
206
def get_active_assets(families):
207
    """Given a list of chart families, determine what needs to be loaded."""
208
    if not families:
209
        # Load ALL assets for backwards compat.
210
        return get_all_assets()
211
    assets = dict(
212
        css=set(),
213
        js=set()
214
    )
215
    families = set(families)
216
    for family, data in CHARTS_CONFIG.items():
217
        if family in families:
218
            for cssfile in data['css_url']:
219
                assets['css'].add(cssfile)
220
            for jsfile in data['js_url']:
221
                assets['js'].add(jsfile)
222
            # Also add all dependency assets.
223
            if data['dependencies']:
224
                for dep in data['dependencies']:
225
                    for cssfile in CHARTS_CONFIG[dep]['css_url']:
226
                        assets['css'].add(cssfile)
227
                    for jsfile in CHARTS_CONFIG[dep]['js_url']:
228
                        assets['js'].add(jsfile)
229
    assets['css'] = list(assets['css'])
230
    assets['js'] = list(assets['js'])
231
    return assets
232
233
234
def paginator(page=0, per_page=None, count=None):
235
    """Get pagination calculations in a compact format."""
236
    if count is None:
237
        count = adapter.count()
238
    if page is None:
239
        page = 0
240
    default_per_page = setting('JSONDASH_PERPAGE')
241
    # Allow query parameter overrides.
242
    per_page = per_page if per_page is not None else default_per_page
243
    per_page = per_page if per_page > 2 else 2  # Prevent division errors etc
244
    curr_page = page - 1 if page > 0 else 0
245
    num_pages = count // per_page
246
    rem = count % per_page
247
    extra_pages = 2 if rem else 1
248
    pages = list(range(1, num_pages + extra_pages))
249
    return Paginator(
250
        limit=per_page,
251
        per_page=per_page,
252
        curr_page=curr_page,
253
        skip=curr_page * per_page,
254
        num_pages=pages,
255
        count=count,
256
    )
257
258
259
def order_sort(item):
260
    """Attempt to sort modules by order keys.
261
262
    Always returns an integer for compatibility.
263
    """
264
    if item.get('order') is not None:
265
        try:
266
            return int(item['order'])
267
        except ValueError:
268
            return -1
269
    return -1
270
271
272
@charts.route('/charts', methods=['GET'])
273
@charts.route('/charts/', methods=['GET'])
274
def dashboard():
275
    """Load all views."""
276
    opts = dict()
277
    views = []
278
    if setting('JSONDASH_FILTERUSERS'):
279
        opts.update(filter=dict(created_by=metadata(key='username')))
280
        views = list(adapter.read(**opts))
281
        if setting('JSONDASH_GLOBALDASH'):
282
            opts.update(
283
                filter=dict(created_by=setting('JSONDASH_GLOBAL_USER')))
284
            views += list(adapter.read(**opts))
285
    else:
286
        views = list(adapter.read(**opts))
287
    if views:
288
        page = request.args.get('page')
289
        per_page = request.args.get('per_page')
290
        paginator_args = dict(count=len(views))
291
        if per_page is not None:
292
            paginator_args.update(per_page=int(per_page))
293
        if page is not None:
294
            paginator_args.update(page=int(page))
295
        pagination = paginator(**paginator_args)
296
        opts.update(limit=pagination.limit, skip=pagination.skip)
297
    else:
298
        pagination = None
299
    kwargs = dict(
300
        views=views,
301
        view=None,
302
        paginator=pagination,
303
        can_edit_global=auth(authtype='edit_global'),
304
        total_modules=sum([
305
            len(view.get('modules', [])) for view in views
306
            if isinstance(view, dict)
307
        ]),
308
    )
309
    return render_template('pages/charts_index.html', **kwargs)
310
311
312
@charts.route('/charts/<c_id>', methods=['GET'])
313
def view(c_id):
314
    """Load a json view config from the DB."""
315
    if not auth(authtype='view', view_id=c_id):
316
        flash('You do not have access to view this dashboard.', 'error')
317
        return redirect(url_for('jsondash.dashboard'))
318
    viewjson = adapter.read(c_id=c_id)
319
    if not viewjson:
320
        flash('Could not find view: {}'.format(c_id), 'error')
321
        return redirect(url_for('jsondash.dashboard'))
322
    # Remove _id, it's not JSON serializeable.
323
    if '_id' in viewjson:
324
        viewjson.pop('_id')
325
    if 'modules' not in viewjson:
326
        flash('Invalid configuration - missing modules.', 'error')
327
        return redirect(url_for('jsondash.dashboard'))
328
    # Chart family is encoded in chart type value for lookup.
329
    active_charts = [v.get('family') for
330
                     v in viewjson['modules'] if v.get('family')]
331
    # If the logged in user is also the creator of this dashboard,
332
    # let me edit it. Otherwise, defer to any user-supplied auth function
333
    # for this specific view.
334
    if metadata(key='username') == viewjson.get('created_by'):
335
        can_edit = True
336
    else:
337
        can_edit = auth(authtype='edit_others', view_id=c_id)
338
    kwargs = dict(
339
        id=c_id,
340
        view=viewjson,
341
        modules=sorted(viewjson['modules'], key=order_sort),
342
        assets=get_active_assets(active_charts),
343
        can_edit=can_edit,
344
        can_edit_global=auth(authtype='edit_global'),
345
        is_global=is_global_dashboard(viewjson),
346
    )
347
    return render_template('pages/chart_detail.html', **kwargs)
348
349
350
@charts.route('/charts/<c_id>/delete', methods=['POST'])
351
def delete(c_id):
352
    """Delete a json dashboard config."""
353
    dash_url = url_for('jsondash.dashboard')
354
    if not auth(authtype='delete'):
355
        flash('You do not have access to delete dashboards.', 'error')
356
        return redirect(dash_url)
357
    adapter.delete(c_id)
358
    flash('Deleted dashboard "{}"'.format(c_id))
359
    return redirect(dash_url)
360
361
362
@charts.route('/charts/<c_id>/update', methods=['POST'])
363
def update(c_id):
364
    """Normalize the form POST and setup the json view config object."""
365
    if not auth(authtype='update'):
366
        flash('You do not have access to update dashboards.', 'error')
367
        return redirect(url_for('jsondash.dashboard'))
368
    viewjson = adapter.read(c_id=c_id)
369
    if not viewjson:
370
        flash('Could not find view: {}'.format(c_id), 'error')
371
        return redirect(url_for('jsondash.dashboard'))
372
    form_data = request.form
373
    view_url = url_for('jsondash.view', c_id=c_id)
374
    edit_raw = 'edit-raw' in request.form
375
    if edit_raw:
376
        try:
377
            data = json.loads(form_data.get('config'))
378
            data = db.reformat_data(data, c_id)
379
        except (TypeError, ValueError):
380
            flash('Invalid JSON config.', 'error')
381 View Code Duplication
            return redirect(view_url)
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
382
    else:
383
        data = dict(
384
            name=form_data['name'],
385
            modules=db.format_charts(form_data),
386
            date=str(dt.now()),
387
            id=c_id,
388
        )
389
    # Update metadata, but exclude some fields that should never
390
    # be overwritten by user, once the view has been created.
391
    data.update(**metadata(exclude=['created_by']))
392
    # Possibly override global user, if configured and valid.
393
    data.update(**check_global())
394
    # Update db
395
    if edit_raw:
396
        adapter.update(c_id, data=data, fmt_charts=False)
397
    else:
398
        adapter.update(c_id, data=data)
399
    flash('Updated view "{}"'.format(c_id))
400
    return redirect(view_url)
401
402
403
def is_global_dashboard(view):
404 View Code Duplication
    """Check if a dashboard is considered global."""
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
405
    return all([
406
        setting('JSONDASH_GLOBALDASH'),
407
        view.get('created_by') == setting('JSONDASH_GLOBAL_USER'),
408
    ])
409
410
411
def check_global():
412
    """Allow overriding of the user by making it global.
413
    This also checks if the setting is enabled for the app,
414
    otherwise it will not allow it.
415
    """
416
    global_enabled = setting('JSONDASH_GLOBALDASH')
417
    global_flag = request.form.get('is_global', '') == 'on'
418
    can_make_global = auth(authtype='edit_global')
419
    if all([global_flag, global_enabled, can_make_global]):
420
        return dict(created_by=setting('JSONDASH_GLOBAL_USER'))
421
    return dict()
422
423
424
@charts.route('/charts/create', methods=['POST'])
425
def create():
426
    """Normalize the form POST and setup the json view config object."""
427
    if not auth(authtype='create'):
428
        flash('You do not have access to create dashboards.', 'error')
429
        return redirect(url_for('jsondash.dashboard'))
430
    data = request.form
431
    new_id = str(uuid.uuid1())
432
    d = dict(
433
        name=data['name'],
434
        modules=db.format_charts(data),
435
        date=dt.now(),
436
        id=new_id,
437
    )
438
    d.update(**metadata())
439
    # Possibly override global user, if configured and valid.
440
    d.update(**check_global())
441
    # Add to DB
442
    adapter.create(data=d)
443
    flash('Created new dashboard "{}"'.format(data['name']))
444
    return redirect(url_for('jsondash.view', c_id=new_id))
445
446
447
@charts.route('/charts/<c_id>/clone', methods=['POST'])
448
def clone(c_id):
449
    """Clone a json view config from the DB."""
450
    if not auth(authtype='clone'):
451
        flash('You do not have access to clone dashboards.', 'error')
452
        return redirect(url_for('jsondash.dashboard'))
453
    viewjson = adapter.read(c_id=c_id)
454
    if not viewjson:
455
        flash('Could not find view: {}'.format(c_id), 'error')
456
        return redirect(url_for('jsondash.dashboard'))
457
    # Update some fields.
458
    newname = 'Clone of {}'.format(viewjson['name'])
459
    data = dict(
460
        name=newname,
461
        modules=viewjson['modules'],
462
        date=str(dt.now()),
463
        id=str(uuid.uuid1()),
464
    )
465
    data.update(**metadata())
466
    # Add to DB
467
    adapter.create(data=data)
468
    flash('Created new dashboard clone "{}"'.format(newname))
469
    return redirect(url_for('jsondash.view', c_id=data['id']))
470