views.py 9.71 KB
Newer Older
1 2 3 4
#!/usr/bin/env python3
"""
    Routes
"""
5
import copy
6 7 8 9 10
import json
import os
from collections import OrderedDict
from pathlib import Path

11
from validata_validate import csv_helpers
12
from validata_validate.loaders import custom_loaders
13
from validata_ui_next import app
14
from validata_ui_next.util import flash_error, flash_info, flash_success, flash_warning, ValidataSource
15
from validata_ui_next.validate_helper import ValidatorHelper
16
from validata_ui_next import error_messages
17

Pierre Dittgen's avatar
Pierre Dittgen committed
18
from flask import Flask, jsonify, redirect, render_template, request, url_for
19 20
import tabulator

21
from io import BytesIO
22

23 24

def extract_source_data(source: ValidataSource, preview_rows_nb=5):
25
    """ Computes table preview """
26 27 28 29 30 31 32

    def stringify(val):
        """ Transform value into string """
        if val is None:
            return ''
        return str(val)

33 34
    header = None
    rows = []
Pierre Dittgen's avatar
Pierre Dittgen committed
35
    nb_rows = 0
36 37 38

    delimiter = None
    if source.format == "csv":
39 40 41 42
        delimiter = csv_helpers.detect_dialect(source.data, format=source.format, scheme=source.scheme,
                                               custom_loaders=custom_loaders).delimiter
    with tabulator.Stream(source.data, format=source.format, scheme=source.scheme, custom_loaders=custom_loaders,
                          delimiter=delimiter) as stream:
43 44 45 46
        for row in stream:
            if header is None:
                header = row
            else:
47
                rows.append(list(map(stringify, row)))
Pierre Dittgen's avatar
Pierre Dittgen committed
48
                nb_rows += 1
49
    preview_rows_nb = min(preview_rows_nb, nb_rows)
50 51
    return {'header': header,
            'rows_nb': nb_rows,
52 53 54
            'data_rows': rows,
            'preview_rows_nb': preview_rows_nb,
            'preview_rows': rows[:preview_rows_nb]}
55 56


57
ERROR_MESSAGE_FUNC = {
Pierre Dittgen's avatar
Pierre Dittgen committed
58

59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
    # Core checks
    'blank-row': error_messages.blank_row,
    'duplicate-row': error_messages.duplicate_row,
    'enumerable-constraint': error_messages.enumerable_constraint,
    'maximum-constraint': error_messages.maximum_constraint,
    'required-constraint': error_messages.required_constraint,
    'type-or-format-error': error_messages.type_or_format_error,
    'pattern-constraint': error_messages.pattern_constraint,

    # Validata pre-checks
    'extra-headers': error_messages.extra_headers,
    'invalid-column-delimiter': error_messages.invalid_column_delimiter,
    'wrong-headers-order': error_messages.wrong_headers_order,
    'missing-headers': error_messages.missing_headers,

    # Validata custom checks
    'french-siret-value': error_messages.french_siret_value,
}


def improve_messages(errors, headers, schema):
80 81
    """ Translates and improve error messages """

82 83 84 85 86
    def error_message_default_func(error, headers, schema):
        """ Sets a new better error message """
        data_str = json.dumps(error)
        error['message'] = '[{}] {} ({})'.format(error['code'], error.get('message', ''), data_str)
        return error
87

88
    improved_errors = []
89 90 91

    for error in errors:

92 93
        improve_func = ERROR_MESSAGE_FUNC.get(error['code'], error_message_default_func)
        improved_errors.append(improve_func(error, headers, schema))
94

95
    return improved_errors
96 97


98
def contextualize(errors):
Pierre Dittgen's avatar
Pierre Dittgen committed
99 100
    """ add context to errors """

101 102
    def add_context(err):
        """ Adds context info based on row-nb presence """
103
        context = 'body' if 'row-number' in err and not err['row-number'] is None else 'table'
104 105 106
        return {**err, 'context': context}

    return list(map(add_context, errors))
107 108


109
def create_validata_report(goodtables_report, schema):
110 111 112 113 114 115
    """ Creates an error report easier to handle and display in templates:
        - only one table
        - errors are contextualized
        - error-counts is ok
        - errors are grouped by lines
        - errors are separated into "structure" and "body"
116
        - error messages are improved
117 118 119 120 121 122 123 124 125 126 127
    """
    report = copy.deepcopy(goodtables_report)

    # One table is enough
    del report['table-count']
    report['table'] = report['tables'][0]
    del report['tables']
    del report['table']['error-count']
    del report['table']['time']
    del report['table']['valid']
    del report['valid']
128 129 130
    # use _ instead of - to ease information picking in jinja2 template
    report['table']['row_count'] = report['table']['row-count']

Pierre Dittgen's avatar
Pierre Dittgen committed
131 132 133 134 135
    # Handy col_count info
    headers = report['table'].get('headers', [])
    report['table']['col_count'] = len(headers)

    # Headers title
136
    schema_fields = schema.get('fields', [])
Pierre Dittgen's avatar
Pierre Dittgen committed
137
    fields_dict = {f['name']: f['title'] for f in schema_fields}
138
    report['table']['headers_title'] = [fields_dict.get(h, r'/!\ colonne inconnue dans le schéma') for h in headers]
139 140 141 142 143

    # Add context to errors
    errors = contextualize(report['table']['errors'])
    del report['table']['errors']

144
    # Provide better (french) messages
145
    errors = improve_messages(errors, headers, schema)
146

147 148 149 150 151 152 153 154 155 156 157 158
    # Count errors
    report['error_count'] = len(errors)
    del report['error-count']

    # Then group them in 2 groups : structure and body
    report['table']['errors'] = {'structure': [], 'body': []}
    for err in errors:
        if err['context'] != 'body':
            report['table']['errors']['structure'].append(err)
        else:
            report['table']['errors']['body'].append(err)

Pierre Dittgen's avatar
Pierre Dittgen committed
159
    # Checks if there are structure errors different to invalid-column-delimiter
160 161 162
    report['table']['display_body_errors'] = all(err['code'] == 'invalid-column-delimiter'
                                                 for err in report['table']['errors']['structure'])

Pierre Dittgen's avatar
Pierre Dittgen committed
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182
    # Group body errors by row id
    rows = []
    current_row_id = 0
    for err in report['table']['errors']['body']:
        if not 'row-number' in err:
            print('ERR', err)
        row_id = err['row-number']
        del err['row-number']
        del err['context']
        if row_id != current_row_id:
            current_row_id = row_id
            rows.append({'row_id': current_row_id, 'errors': {}})

        column_id = err.get('column-number')
        if column_id is not None:
            del err['column-number']
            rows[-1]['errors'][column_id] = err
        else:
            rows[-1]['errors']['row'] = err
    report['table']['errors']['by_rows'] = rows
Pierre Dittgen's avatar
Pierre Dittgen committed
183 184 185 186

    return report


187
def validate(schema_code, source: ValidataSource):
188 189
    """ Validate source and display report """

Pierre Dittgen's avatar
Pierre Dittgen committed
190 191 192 193 194
    try:
        goodtables_report = ValidatorHelper.validate(schema_code, **source.get_goodtables_source())
    except tabulator.exceptions.FormatError:
        flash_error('Erreur : format de fichier non supporté')
        return redirect(url_for('scdl_validator', val_code=schema_code))
195 196

    source_data = extract_source_data(source)
197

198
    validata_report = create_validata_report(goodtables_report, ValidatorHelper.schema(schema_code))
199

200
    # return jsonify(validata_report)
Pierre Dittgen's avatar
Pierre Dittgen committed
201

Pierre Dittgen's avatar
Pierre Dittgen committed
202 203
    # Complete report
    val_info = ValidatorHelper.schema_info(schema_code)
204
    return render_template('validation_report.html', title='Rapport de validation',
205
                           val_info=ValidatorHelper.schema_info(schema_code), report=validata_report,
206
                           source=source, source_type=source.type, source_data=source_data,
207
                           report_str=json.dumps(validata_report, sort_keys=True, indent=2),
Pierre Dittgen's avatar
Pierre Dittgen committed
208 209
                           breadcrumbs=[{'url': url_for('home'), 'title': 'Accueil'},
                                        {'url': url_for('scdl_validator', val_code=schema_code), 'title': val_info['title']}])
210 211


212 213 214 215 216 217 218 219
def bytes_data(f):
    """ Gets bytes data from Werkzeug FileStorage instance """
    iob = BytesIO()
    f.save(iob)
    iob.seek(0)
    return iob.getvalue()


220 221 222 223 224 225
# Routes


@app.route('/')
def home():
    """ Home page """
Pierre Dittgen's avatar
Pierre Dittgen committed
226
    validators = ValidatorHelper.schema_info_list()
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252
    return render_template('home.html', title='Accueil', validators=validators)


@app.route('/about')
def about():
    """ Help -> About page """
    return render_template('about.html', title='À propos',
                           breadcrumbs=[{'url': url_for('home'), 'title': 'Accueil'}, ])


@app.route('/validators')
def validators():
    """ No validators page """
    return redirect(url_for('home'))


@app.route('/validators/<val_code>', methods=['GET', 'POST'])
def scdl_validator(val_code):
    """ Validator page """

    if not ValidatorHelper.schema_exist(val_code):
        flash_error('Validateur [{}] inconnu'.format(val_code))
        return redirect(url_for('home'))

    if request.method == 'GET':

Pierre Dittgen's avatar
Pierre Dittgen committed
253
        val_info = ValidatorHelper.schema_info(val_code)
254 255 256 257
        input_param = request.args.get('input')

        # First form display
        if input_param is None or input_param not in ('url', 'example'):
258
            return render_template('validation_form.html', title=val_info['title'],
259 260 261 262 263 264 265 266 267
                                   val_info=val_info,
                                   breadcrumbs=[{'url': url_for('home'), 'title': 'Accueil'}, ])

        # Process URL
        else:
            url = request.args.get('url')
            if url is None or url == '':
                flash_error("Vous n'avez pas indiqué d'url à valider")
                return redirect(url_for('scdl_validator', val_code=val_code))
268
            return validate(val_code, ValidataSource(url, url, 'url'))
269 270 271 272

    else:  # POST
        input_param = request.form.get('input')
        if input_param is None:
Pierre Dittgen's avatar
Pierre Dittgen committed
273
            flash_error('Aucun fichier à valider')
274 275 276 277 278 279 280 281
            return redirect(url_for('scdl_validator', val_code=val_code))

        # File validation
        if input_param == 'file':
            f = request.files.get('file')
            if f is None:
                flash_warning("Vous n'avez pas indiqué de fichier à valider")
                return redirect(url_for('scdl_validator', val_code=val_code))
282 283

            return validate(val_code, ValidataSource(bytes_data(f), f.filename, 'file'))
284 285

        return 'Bizarre, vous avez dit bizarre ?'