Completed
Push — master ( d5fdf7...a10da3 )
by Chris
01:09
created

clone()   A

Complexity

Conditions 3

Size

Total Lines 21

Duplication

Lines 2
Ratio 9.52 %

Importance

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