Completed
Push — master ( d3e7d3...8a0ddb )
by Chris
01:14
created

view()   D

Complexity

Conditions 8

Size

Total Lines 36

Duplication

Lines 0
Ratio 0 %

Importance

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