views.py 9.7 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
258
259
260
261
262
263
264
265
266
267
        input_param = request.args.get('input')

        # First form display
        if input_param is None or input_param not in ('url', 'example'):
            return render_template('validator.html', title=val_info['title'],
                                   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 ?'