Completed
Push — master ( 674ec9...77360c )
by Chris
01:15
created

get_dims()   A

Complexity

Conditions 2

Size

Total Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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