christabor/flask_jsondash

View on GitHub
flask_jsondash/schema.py

Summary

Maintainability
B
5 hrs
Test Coverage
# -*- coding: utf-8 -*-

"""
flask_jsondash.schema
~~~~~~~~~~~~~~~~~~~~~

The core schema definition and validation rules.

:copyright: (c) 2016 by Chris Tabor.
:license: MIT, see LICENSE for more details.
"""

import json

import cerberus

from flask_jsondash.settings import CHARTS_CONFIG


class InvalidSchemaError(ValueError):
    """Wrapper exception for specific raising scenarios."""


def get_chart_types():
    """Get all available chart 'type' names from core config.

    Returns:
        types (list): A list of all possible chart types, under all families.
    """
    types = []
    charts = [chart['charts'] for chart in CHARTS_CONFIG.values()]
    for group in charts:
        for chart in group:
            types.append(chart[0])
    return types


CHART_INPUT_SCHEMA = {
    'btn_classes': {
        'type': 'list',
        'schema': {
            'type': 'string',
        },
    },
    'submit_text': {
        'type': 'string',
    },
    'options': {
        'type': 'list',
        'schema': {
            'type': 'dict',
            'schema': {
                'options': {
                    'type': 'list',
                    'schema': {
                        # E.g. [10, 'Select 10'],
                        'type': 'list',
                        'minlength': 2,
                        'maxlength': 2,
                    },
                },
                'type': {
                    'type': 'string',
                    'allowed': [
                        'number', 'select', 'radio',
                        'checkbox', 'text',
                        'password',
                        # HTML5
                        'color',
                        'date',
                        'datetime-local',
                        'month',
                        'week',
                        'time',
                        'email',
                        'number',
                        'range',
                        'search',
                        'tel',
                        'url',
                    ],
                    'default': 'text',
                },
                'name': {
                    'type': 'string',
                    'required': True,
                },
                'default': {
                    'anyof': [
                        {'type': 'string'},
                        {'type': 'number'},
                        {'type': 'boolean'},
                    ],
                    'nullable': True,
                },
                'validator_regex': {
                    'nullable': True,
                    'type': 'string',
                },
                'placeholder': {
                    'nullable': True,
                    'anyof': [
                        {'type': 'string'},
                        {'type': 'number'},
                    ]
                },
                'label': {
                    'type': 'string',
                },
                'input_classes': {
                    'type': 'list',
                    'schema': {
                        'type': 'string',
                    },
                },
            },
        },
    },
}
CHART_SCHEMA = {
    'type': 'dict',
    'schema': {
        'name': {
            'type': 'string',
            'required': True,
        },
        'guid': {
            'type': 'string',
            'required': True,
            # 5-"section" regex
            # this might not be necessary but it's
            # useful to enforce a truly globally unique id,
            # especially for usage with the js API.
            'regex': (
                '[a-zA-Z0-9]+-[a-zA-Z0-9]+-'
                '[a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-zA-Z0-9]+'
            ),
        },
        'order': {
            'type': 'number',
            'nullable': True,
            'default': 0,
        },
        'row': {
            'type': 'number',
            'nullable': True,
            'default': 0,
        },
        'refresh': {
            'type': 'boolean',
            'nullable': True,
        },
        'refreshInterval': {
            'type': 'number',
            'nullable': True,
        },
        'height': {
            'type': 'number',
            'required': True,
        },
        'width': {
            'anyof': [
                {'type': 'string'},
                {'type': 'number'},
            ],
            # TODO: check this works
            # This regex allows the overloading
            # of this property to allow for
            # grid widths (e.g. "col-8") vs number widths.
            'regex': '(col-[0-9]+)|([0-9]+)',
            'required': True,
        },
        'dataSource': {
            'type': 'string',
            'required': True,
        },
        'family': {
            'type': 'string',
            'required': True,
            'allowed': list(CHARTS_CONFIG.keys()),
        },
        'key': {
            'type': 'string',
            'required': False,
        },
        'type': {
            'type': 'string',
            'required': True,
            'allowed': get_chart_types(),
        },
        'inputs': {
            'type': 'dict',
            'schema': CHART_INPUT_SCHEMA
        },
    },
}
DASHBOARD_SCHEMA = {
    'name': {
        'type': 'string',
        'required': True,
    },
    'id': {
        'type': 'string',
        'required': True,
        'regex': (
            '[a-zA-Z0-9]+-[a-zA-Z0-9]+-'
            '[a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-zA-Z0-9]+'
        ),
    },
    'date': {
        'type': 'string',
        'required': True,
    },
    'layout': {
        'type': 'string',
        'required': True,
        'allowed': ['freeform', 'grid'],
    },
    'category': {
        'type': 'string',
        'required': False,
    },
    'modules': {
        'type': 'list',
        'schema': CHART_SCHEMA,
        'required': True,
    },
}


def validate(conf):
    """Validate a json conf."""
    v = cerberus.Validator(DASHBOARD_SCHEMA, allow_unknown=True)
    valid = v.validate(conf)
    if not valid:
        return v.errors


def is_consecutive_rows(lst):
    """Check if a list of integers is consecutive.

    Args:
        lst (list): The list of integers.

    Returns:
        True/False: If the list contains consecutive integers.

    Originally taken from and modified:
        http://stackoverflow.com/
            questions/40091617/test-for-consecutive-numbers-in-list
    """
    assert 0 not in lst, '0th index is invalid!'
    lst = list(set(lst))
    if not lst:
        return True
    setl = set(lst)
    return len(lst) == len(setl) and setl == set(range(min(lst), max(lst) + 1))


def validate_raw_json_grid(conf):
    """Grid mode specific validations.

    Args:
        conf (dict): The dashboard configuration.

    Raises:
        InvalidSchemaError: If there are any issues with the schema

    Returns:
        None: If no errors were found.
    """
    layout = conf.get('layout', 'freeform')
    fixed_only_required = ['row']
    rows = []
    modules = conf.get('modules', [])
    valid_cols = ['col-{}'.format(i) for i in range(1, 13)]
    if not modules:
        return
    for module in modules:
        try:
            rows.append(int(module.get('row')))
        except TypeError:
            raise InvalidSchemaError(
                'Invalid row value for module "{}"'.format(module.get('name')))
    if not is_consecutive_rows(rows):
        raise InvalidSchemaError(
            'Row order is not consecutive: "{}"!'.format(sorted(rows)))
    for module in modules:
        width = module.get('width')
        if width not in valid_cols:
            raise InvalidSchemaError(
                'Invalid width for grid format: "{}"'.format(width))
        for field in fixed_only_required:
            if field not in module and layout == 'grid':
                ident = module.get('name', module)
                raise InvalidSchemaError(
                    'Invalid JSON. "{}" must be '
                    'included in "{}" for '
                    'fixed grid layouts'.format(field, ident))


def validate_raw_json(jsonstr, **overrides):
    """Validate the raw json for a config.

    Args:
        jsonstr (str): The raw json configuration
        **overrides: Any key/value pairs to override in the config.
            Used only for setting default values that the user should
            never enter but are required to validate the schema.

    Raises:
        InvalidSchemaError: If there are any issues with the schema

    Returns:
        data (dict): The parsed configuration data
    """
    data = json.loads(jsonstr)
    data.update(**overrides)
    layout = data.get('layout', 'freeform')

    if layout == 'grid':
        validate_raw_json_grid(data)
    else:
        for module in data.get('modules', []):
            width = module.get('width')
            try:
                int(width)
            except (TypeError, ValueError):
                raise InvalidSchemaError(
                    'Invalid value for width in `freeform` layout.')
            if module.get('row') is not None:
                raise InvalidSchemaError(
                    'Cannot mix `row` with `freeform` layout.')
    results = validate(data)
    if results is not None:
        raise InvalidSchemaError(results)
    return data