Completed
Push — master ( 99cd20...0ff6d7 )
by Chris
58s
created

dashboard()   C

Complexity

Conditions 7

Size

Total Lines 34

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 7
c 3
b 0
f 0
dl 0
loc 34
rs 5.5
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 config['type'] == 'youtube':
156
        # We get the dimensions for the widget from YouTube instead,
157
        # which handles aspect ratios, etc... and is likely what the user
158
        # wanted to specify since they will be entering in embed code from
159
        # Youtube directly.
160
        embed = config['dataSource'].split(' ')
161
        padding_w = 20
162
        padding_h = 60
163
        w = int(embed[1].replace('width=', '').replace('"', ''))
164
        h = int(embed[2].replace('height=', '').replace('"', ''))
165
        return dict(width=w + padding_w, height=h + padding_h)
166
    return dict(width=config['width'], height=config['height'])
167
168
169
@jinja2.contextfilter
170
@charts.app_template_filter('jsonstring')
171
def jsonstring(ctx, data):
172
    """Format view json module data for template use.
173
174
    It's automatically converted to unicode key/value pairs,
175
    which is undesirable for the template.
176
    """
177
    if 'date' in data:
178
        data['date'] = str(data['date'])
179
    return json.dumps(data)
180
181
182
@charts.route('/jsondash/<path:filename>')
183
def _static(filename):
184
    """Send static files directly for this blueprint."""
185
    return send_from_directory(static_dir, filename)
186
187
188
def paginator(page=0, per_page=None, count=None):
189
    """Get pagination calculations in a compact format."""
190
    if count is None:
191
        count = adapter.count()
192
    default_per_page = setting('JSONDASH_PERPAGE')
193
    # Allow query parameter overrides.
194
    per_page = per_page if per_page is not None else default_per_page
195
    per_page = per_page if per_page > 2 else 2  # Prevent division errors etc
196
    curr_page = page - 1 if page > 0 else 0
197
    num_pages = count // per_page
198
    rem = count % per_page
199
    extra_pages = 2 if rem else 1
200
    pages = range(1, num_pages + extra_pages)
201
    return Paginator(
202
        limit=per_page,
203
        per_page=per_page,
204
        curr_page=curr_page,
205
        skip=curr_page * per_page,
206
        num_pages=pages,
207
        count=count,
208
    )
209
210
211
@charts.route('/charts/', methods=['GET'])
212
def dashboard():
213
    """Load all views."""
214
    opts = dict()
215
    views = []
216
    if setting('JSONDASH_FILTERUSERS'):
217
        opts.update(filter=dict(created_by=metadata(key='username')))
218
        views = list(adapter.read(**opts))
219
        if setting('JSONDASH_GLOBALDASH'):
220
            opts.update(
221
                filter=dict(created_by=setting('JSONDASH_GLOBAL_USER')))
222
            views += list(adapter.read(**opts))
223
    else:
224
        views = list(adapter.read(**opts))
225
    if views:
226
        page = request.args.get('page')
227
        per_page = request.args.get('per_page')
228
        paginator_args = dict(count=len(views))
229
        if per_page is not None:
230
            paginator_args.update(per_page=int(per_page))
231
        if page is not None:
232
            paginator_args.update(page=int(page))
233
        pagination = paginator(**paginator_args)
234
        opts.update(limit=pagination.limit, skip=pagination.skip)
235
    else:
236
        pagination = None
237
    kwargs = dict(
238
        views=views,
239
        view=None,
240
        paginator=pagination,
241
        can_edit_global=auth(authtype='edit_global'),
242
        total_modules=sum([len(view['modules']) for view in views]),
243
    )
244
    return render_template('pages/charts_index.html', **kwargs)
245
246
247
@charts.route('/charts/<c_id>', methods=['GET'])
248
def view(c_id):
249
    """Load a json view config from the DB."""
250
    if not auth(authtype='view', view_id=c_id):
251
        flash('You do not have access to view this dashboard.', 'error')
252
        return redirect(url_for('jsondash.dashboard'))
253
    viewjson = adapter.read(c_id=c_id)
254
    if not viewjson:
255
        flash('Could not find view: {}'.format(c_id), 'error')
256
        return redirect(url_for('jsondash.dashboard'))
257
    # Remove _id, it's not JSON serializeable.
258
    viewjson.pop('_id')
259
    # Chart family is encoded in chart type value for lookup.
260
    active_charts = [v.get('family') for
261
                     v in viewjson['modules'] if v.get('family')]
262
    # If the logged in user is also the creator of this dashboard,
263
    # let me edit it. Otherwise, defer to any user-supplied auth function
264
    # for this specific view.
265
    if metadata(key='username') == viewjson.get('created_by'):
266
        can_edit = True
267
    else:
268
        can_edit = auth(authtype='edit_others', view_id=c_id)
269
    kwargs = dict(
270
        id=c_id,
271
        view=viewjson,
272
        active_charts=active_charts,
273
        can_edit=can_edit,
274
        can_edit_global=auth(authtype='edit_global'),
275
        is_global=is_global_dashboard(viewjson),
276
    )
277
    return render_template('pages/chart_detail.html', **kwargs)
278
279
280
@charts.route('/charts/<c_id>/delete', methods=['POST'])
281
def delete(c_id):
282
    """Delete a json dashboard config."""
283
    dash_url = url_for('jsondash.dashboard')
284
    if not auth(authtype='delete'):
285
        flash('You do not have access to delete dashboards.', 'error')
286
        return redirect(dash_url)
287
    adapter.delete(c_id)
288
    flash('Deleted dashboard {}'.format(c_id))
289
    return redirect(dash_url)
290
291
292
@charts.route('/charts/<c_id>/update', methods=['POST'])
293
def update(c_id):
294
    """Normalize the form POST and setup the json view config object."""
295
    if not auth(authtype='update'):
296
        flash('You do not have access to update dashboards.', 'error')
297
        return redirect(url_for('jsondash.dashboard'))
298
    form_data = request.form
299
    view_url = url_for('jsondash.view', c_id=c_id)
300
    edit_raw = 'edit-raw' in request.form
301
    if edit_raw:
302
        try:
303
            data = json.loads(form_data.get('config'))
304
            data = adapter.reformat_data(data, c_id)
305
        except (TypeError, ValueError):
306
            flash('Invalid JSON config.', 'error')
307
            return redirect(view_url)
308
    else:
309
        data = dict(
310
            name=form_data['name'],
311
            modules=adapter._format_modules(form_data),
312
            date=dt.now(),
313
            id=c_id,
314
        )
315
    # Update metadata, but exclude some fields that should never
316
    # be overwritten by user, once the view has been created.
317 View Code Duplication
    data.update(**metadata(exclude=['created_by']))
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
318
    # Possibly override global user, if configured and valid.
319
    data.update(**check_global())
320
    # Update db
321
    if edit_raw:
322
        adapter.update(c_id, data=data, fmt_modules=False)
323
    else:
324
        adapter.update(c_id, data=data)
325
    flash('Updated view "{}"'.format(c_id))
326
    return redirect(view_url)
327
328
329
def is_global_dashboard(view):
330
    """Check if a dashboard is considered global."""
331
    return all([
332
        setting('JSONDASH_GLOBALDASH'),
333
        view.get('created_by') == setting('JSONDASH_GLOBAL_USER'),
334
    ])
335
336
337
def check_global():
338
    """Allow overriding of the user by making it global.
339
    This also checks if the setting is enabled for the app,
340 View Code Duplication
    otherwise it will not allow it.
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
341
    """
342
    global_enabled = setting('JSONDASH_GLOBALDASH')
343
    global_flag = request.form.get('is_global', '') == 'on'
344
    can_make_global = auth(authtype='edit_global')
345
    if all([global_flag, global_enabled, can_make_global]):
346
        return dict(created_by=setting('JSONDASH_GLOBAL_USER'))
347
    return dict()
348
349
350
@charts.route('/charts/create', methods=['POST'])
351
def create():
352
    """Normalize the form POST and setup the json view config object."""
353
    if not auth(authtype='create'):
354
        flash('You do not have access to create dashboards.', 'error')
355
        return redirect(url_for('jsondash.dashboard'))
356
    data = request.form
357
    new_id = str(uuid.uuid1())
358
    d = dict(
359
        name=data['name'],
360
        modules=adapter._format_modules(data),
361
        date=dt.now(),
362
        id=new_id,
363
    )
364
    d.update(**metadata())
365
    # Possibly override global user, if configured and valid.
366
    d.update(**check_global())
367
    # Add to DB
368
    adapter.create(data=d)
369
    flash('Created new view "{}"'.format(data['name']))
370
    return redirect(url_for('jsondash.view', c_id=new_id))
371
372
373
@charts.route('/charts/<c_id>/clone', methods=['POST'])
374
def clone(c_id):
375
    """Clone a json view config from the DB."""
376
    if not auth(authtype='clone'):
377
        flash('You do not have access to clone dashboards.', 'error')
378
        return redirect(url_for('jsondash.dashboard'))
379
    viewjson = adapter.read(c_id=c_id)
380
    if not viewjson:
381
        flash('Could not find view: {}'.format(id), 'error')
382
        return redirect(url_for('jsondash.dashboard'))
383
    # Update some fields.
384
    data = dict(
385
        name='Clone of {}'.format(viewjson['name']),
386
        modules=viewjson['modules'],
387
        date=dt.now(),
388
        id=str(uuid.uuid1()),
389
    )
390
    data.update(**metadata())
391
    # Add to DB
392
    adapter.create(data=data)
393
    return redirect(url_for('jsondash.view', c_id=data['id']))
394