Completed
Push — master ( ccc333...1eb115 )
by Chris
01:08
created

paginator()   C

Complexity

Conditions 7

Size

Total Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

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