Completed
Push — master ( b02095...4e4277 )
by Chris
01:25
created

view()   C

Complexity

Conditions 7

Size

Total Lines 33

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 7
c 2
b 0
f 0
dl 0
loc 33
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
def order_sort(item):
212
    """Attempt to sort modules by order keys."""
213
    if 'order' in item:
214
        return item['order']
215
    return item
216
217
218
@charts.route('/charts/', methods=['GET'])
219
def dashboard():
220
    """Load all views."""
221
    opts = dict()
222
    views = []
223
    if setting('JSONDASH_FILTERUSERS'):
224
        opts.update(filter=dict(created_by=metadata(key='username')))
225
        views = list(adapter.read(**opts))
226
        if setting('JSONDASH_GLOBALDASH'):
227
            opts.update(
228
                filter=dict(created_by=setting('JSONDASH_GLOBAL_USER')))
229
            views += list(adapter.read(**opts))
230
    else:
231
        views = list(adapter.read(**opts))
232
    if views:
233
        page = request.args.get('page')
234
        per_page = request.args.get('per_page')
235
        paginator_args = dict(count=len(views))
236
        if per_page is not None:
237
            paginator_args.update(per_page=int(per_page))
238
        if page is not None:
239
            paginator_args.update(page=int(page))
240
        pagination = paginator(**paginator_args)
241
        opts.update(limit=pagination.limit, skip=pagination.skip)
242
    else:
243
        pagination = None
244
    kwargs = dict(
245
        views=views,
246
        view=None,
247
        paginator=pagination,
248
        can_edit_global=auth(authtype='edit_global'),
249
        total_modules=sum([len(view['modules']) for view in views]),
250
    )
251
    return render_template('pages/charts_index.html', **kwargs)
252
253
254
@charts.route('/charts/<c_id>', methods=['GET'])
255
def view(c_id):
256
    """Load a json view config from the DB."""
257
    if not auth(authtype='view', view_id=c_id):
258
        flash('You do not have access to view this dashboard.', 'error')
259
        return redirect(url_for('jsondash.dashboard'))
260
    viewjson = adapter.read(c_id=c_id)
261
    if not viewjson:
262
        flash('Could not find view: {}'.format(c_id), 'error')
263
        return redirect(url_for('jsondash.dashboard'))
264
    # Remove _id, it's not JSON serializeable.
265
    if '_id' in viewjson:
266
        viewjson.pop('_id')
267
    # Chart family is encoded in chart type value for lookup.
268
    active_charts = [v.get('family') for
269
                     v in viewjson['modules'] if v.get('family')]
270
    # If the logged in user is also the creator of this dashboard,
271
    # let me edit it. Otherwise, defer to any user-supplied auth function
272
    # for this specific view.
273
    if metadata(key='username') == viewjson.get('created_by'):
274
        can_edit = True
275
    else:
276
        can_edit = auth(authtype='edit_others', view_id=c_id)
277
    kwargs = dict(
278
        id=c_id,
279
        view=viewjson,
280
        modules=sorted(viewjson['modules'], key=order_sort),
281
        active_charts=active_charts,
282
        can_edit=can_edit,
283
        can_edit_global=auth(authtype='edit_global'),
284
        is_global=is_global_dashboard(viewjson),
285
    )
286
    return render_template('pages/chart_detail.html', **kwargs)
287
288
289
@charts.route('/charts/<c_id>/delete', methods=['POST'])
290
def delete(c_id):
291
    """Delete a json dashboard config."""
292
    dash_url = url_for('jsondash.dashboard')
293
    if not auth(authtype='delete'):
294
        flash('You do not have access to delete dashboards.', 'error')
295
        return redirect(dash_url)
296
    adapter.delete(c_id)
297
    flash('Deleted dashboard {}'.format(c_id))
298
    return redirect(dash_url)
299
300
301
@charts.route('/charts/<c_id>/update', methods=['POST'])
302
def update(c_id):
303
    """Normalize the form POST and setup the json view config object."""
304
    if not auth(authtype='update'):
305
        flash('You do not have access to update dashboards.', 'error')
306
        return redirect(url_for('jsondash.dashboard'))
307
    form_data = request.form
308
    view_url = url_for('jsondash.view', c_id=c_id)
309
    edit_raw = 'edit-raw' in request.form
310
    if edit_raw:
311
        try:
312
            data = json.loads(form_data.get('config'))
313
            data = adapter.reformat_data(data, c_id)
314
        except (TypeError, ValueError):
315
            flash('Invalid JSON config.', 'error')
316
            return redirect(view_url)
317 View Code Duplication
    else:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
318
        data = dict(
319
            name=form_data['name'],
320
            modules=adapter._format_modules(form_data),
321
            date=dt.now(),
322
            id=c_id,
323
        )
324
    # Update metadata, but exclude some fields that should never
325
    # be overwritten by user, once the view has been created.
326
    data.update(**metadata(exclude=['created_by']))
327
    # Possibly override global user, if configured and valid.
328
    data.update(**check_global())
329
    # Update db
330
    if edit_raw:
331
        adapter.update(c_id, data=data, fmt_modules=False)
332
    else:
333
        adapter.update(c_id, data=data)
334
    flash('Updated view "{}"'.format(c_id))
335
    return redirect(view_url)
336
337
338
def is_global_dashboard(view):
339
    """Check if a dashboard is considered global."""
340 View Code Duplication
    return all([
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
341
        setting('JSONDASH_GLOBALDASH'),
342
        view.get('created_by') == setting('JSONDASH_GLOBAL_USER'),
343
    ])
344
345
346
def check_global():
347
    """Allow overriding of the user by making it global.
348
    This also checks if the setting is enabled for the app,
349
    otherwise it will not allow it.
350
    """
351
    global_enabled = setting('JSONDASH_GLOBALDASH')
352
    global_flag = request.form.get('is_global', '') == 'on'
353
    can_make_global = auth(authtype='edit_global')
354
    if all([global_flag, global_enabled, can_make_global]):
355
        return dict(created_by=setting('JSONDASH_GLOBAL_USER'))
356
    return dict()
357
358
359
@charts.route('/charts/create', methods=['POST'])
360
def create():
361
    """Normalize the form POST and setup the json view config object."""
362
    if not auth(authtype='create'):
363
        flash('You do not have access to create dashboards.', 'error')
364
        return redirect(url_for('jsondash.dashboard'))
365
    data = request.form
366
    new_id = str(uuid.uuid1())
367
    d = dict(
368
        name=data['name'],
369
        modules=adapter._format_modules(data),
370
        date=dt.now(),
371
        id=new_id,
372
    )
373
    d.update(**metadata())
374
    # Possibly override global user, if configured and valid.
375
    d.update(**check_global())
376
    # Add to DB
377
    adapter.create(data=d)
378
    flash('Created new view "{}"'.format(data['name']))
379
    return redirect(url_for('jsondash.view', c_id=new_id))
380
381
382
@charts.route('/charts/<c_id>/clone', methods=['POST'])
383
def clone(c_id):
384
    """Clone a json view config from the DB."""
385
    if not auth(authtype='clone'):
386
        flash('You do not have access to clone dashboards.', 'error')
387
        return redirect(url_for('jsondash.dashboard'))
388
    viewjson = adapter.read(c_id=c_id)
389
    if not viewjson:
390
        flash('Could not find view: {}'.format(id), 'error')
391
        return redirect(url_for('jsondash.dashboard'))
392
    # Update some fields.
393
    data = dict(
394
        name='Clone of {}'.format(viewjson['name']),
395
        modules=viewjson['modules'],
396
        date=dt.now(),
397
        id=str(uuid.uuid1()),
398
    )
399
    data.update(**metadata())
400
    # Add to DB
401
    adapter.create(data=data)
402
    return redirect(url_for('jsondash.view', c_id=data['id']))
403