Completed
Push — master ( f694e4...89cac4 )
by Chris
01:34
created

check_global()   A

Complexity

Conditions 2

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

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