Skip to content

Commit f266b19

Browse files
authored
Merge pull request #139 from open-data/feature/field-errors-n-refresh
Feature/field errors n refresh
2 parents 9db64c0 + 074558b commit f266b19

File tree

9 files changed

+172
-15
lines changed

9 files changed

+172
-15
lines changed

changes/139.changes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Now gracefully handles browser page refreshes on `POST` views.

changes/139.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Now handles `KeyError` and `fields` errors when downloading and uploading Excel records, prompting the user to refresh the resource.

ckanext/recombinant/cli.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,20 +122,27 @@ def remove_empty(dataset_type: Optional[List[str]] = None,
122122
is_flag=True,
123123
help="Deletes fields that are no longer in the Schema (requires --force-update)",
124124
)
125+
@click.option(
126+
"-D",
127+
"--dataset",
128+
help="A dataset ID to update all resource tables for."
129+
)
125130
@click.option('-v', '--verbose', is_flag=True,
126131
type=click.BOOL, help='Increase verbosity.')
127132
def update(dataset_type: Optional[List[str]] = None,
128133
all_types: bool = False,
129134
force_update: bool = False,
130135
delete_fields: bool = False,
136+
dataset: Optional[str] = None,
131137
verbose: bool = False):
132138
"""
133139
Triggers recombinant update for recombinant resources
134140
135141
Full Usage:\n
136142
recombinant update (-a | DATASET_TYPE ...) [-f]
137143
"""
138-
_update(dataset_type, all_types, force_update, delete_fields, verbose=verbose)
144+
_update(dataset_type, all_types, force_update, delete_fields,
145+
dataset_id=dataset, verbose=verbose)
139146

140147

141148
@recombinant.command(short_help="Delete recombinant datasets and all their data.")
@@ -357,12 +364,26 @@ def _update(dataset_types: Optional[List[str]],
357364
all_types: bool = False,
358365
force_update: bool = False,
359366
delete_fields: bool = False,
367+
dataset_id: Optional[str] = None,
360368
verbose: bool = False):
361369
"""
362370
Triggers recombinant update for recombinant resources
363371
"""
364372
orgs = _get_orgs()
365373
lc = LocalCKAN()
374+
if dataset_id:
375+
dataset_dict = lc.action.package_show(id=dataset_id)
376+
click.echo('%s %s updating single dataset %s' % (
377+
dataset_dict['type'],
378+
dataset_dict['organization']['name'],
379+
dataset_id))
380+
lc.action.recombinant_update(
381+
owner_org=dataset_dict['organization']['name'],
382+
dataset_type=dataset_dict['type'],
383+
dataset_id=dataset_id,
384+
force_update=force_update,
385+
delete_fields=delete_fields)
386+
return
366387
for dtype in _expand_dataset_types(dataset_types, all_types):
367388
packages = _get_packages(dtype, orgs)
368389
existing = dict((p['owner_org'], p) for p in packages)

ckanext/recombinant/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ class RecombinantException(Exception):
77
pass
88

99

10+
class RecombinantFieldError(Exception):
11+
pass
12+
13+
1014
class RecombinantConfigurationError(Exception):
1115
pass
1216

ckanext/recombinant/logic.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def recombinant_update(context: Context, data_dict: DataDict):
5959
recombinant dataset type.
6060
6161
:param dataset_type: recombinant dataset type
62+
:param dataset_id: dataset id to update specifically for
6263
:param owner_org: organization name or id
6364
:param delete_resources: True to delete extra resources found
6465
:param force_update: True to force updating of datastore tables
@@ -119,6 +120,8 @@ def recombinant_show(context: Context, data_dict: DataDict) -> Dict[str, Any]:
119120
limit=0)
120121
datastore_correct = _datastore_match(r['fields'], ds['fields'])
121122
out['datastore_correct'] = datastore_correct
123+
schema_correct = _schema_match(r['fields'], ds['fields'])
124+
out['schema_correct'] = schema_correct
122125
resources_correct = resources_correct and datastore_correct
123126
out['datastore_rows'] = ds.get('total', 0)
124127
out['datastore_active'] = True
@@ -150,6 +153,7 @@ def _action_find_dataset(context: Context, data_dict: DataDict) -> Tuple[
150153
'''
151154
dataset_type = get_or_bust(data_dict, 'dataset_type')
152155
owner_org = Group.get(get_or_bust(data_dict, 'owner_org'))
156+
dataset_id = data_dict.get('dataset_id', None)
153157

154158
if not owner_org:
155159
raise ValidationError(
@@ -167,7 +171,9 @@ def _action_find_dataset(context: Context, data_dict: DataDict) -> Tuple[
167171

168172
lc = LocalCKAN(username=context['user'], context=fresh_context)
169173
result = lc.action.package_search(
170-
q="type:%s AND organization:%s" % (dataset_type, owner_org.name),
174+
q="type:%s AND organization:%s %s" % (
175+
dataset_type, owner_org.name,
176+
'AND id:{0}'.format(dataset_id) if dataset_id else ''),
171177
include_private=True,
172178
rows=2)
173179
return lc, geno, result['results']
@@ -433,6 +439,17 @@ def _datastore_match(fs: List[Dict[str, Any]], fields: List[Dict[str, Any]]) ->
433439
if not f.get('published_resource_computed_field', False))
434440

435441

442+
def _schema_match(fs: List[Dict[str, Any]], fields: List[Dict[str, Any]]) -> bool:
443+
"""
444+
return True if datastore column fields are all fields defined in fs.
445+
"""
446+
# XXX: does not check types or extra columns at this time
447+
existing = set(f['datastore_id'] for f in fs
448+
if not f.get('published_resource_computed_field', False))
449+
return all(c['id'] in existing for c in fields
450+
if c['id'] != '_id')
451+
452+
436453
@chained_action
437454
@side_effect_free
438455
def recombinant_datastore_info(up_func: Action,

ckanext/recombinant/templates/recombinant/resource_edit.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
{% block action_panels %}
4646
{% if dataset %}
4747
{% if 'error' not in resource %}
48-
{% if g.userobj.sysadmin and not resource.datastore_correct %}
48+
{% if g.userobj.sysadmin and (not resource.datastore_correct or not resource.schema_correct) %}
4949
{# only let sysadmins refresh the recombinant record via UI #}
5050
<div class="module-alert alert alert-warning">
5151
<h3>{{ _("The Recombinant resource is out of date") }}{% snippet 'snippets/sysadmin_only.html' %}</h3>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<h3>{{_('Errors in Recombinant Resource')}}</h3>
2+
<p>{{ _('The Recombinant resource is out of date:') }}</p>
3+
<ul>
4+
<li>{{ _('Field(s) <em>{0}</em> do not exist in the database for this resource.').format(key_errors) }}</li>
5+
</ul>
6+
{% if not g.userobj.sysadmin %}
7+
<p>{{ _('Please contact <a href="mailto:open-ouvert@tbs-sct.gc.ca">open-ouvert@tbs-sct.gc.ca</a> for assistance.') }}</p>
8+
{% else %}
9+
{# only let sysadmins refresh the recombinant record via UI #}
10+
<div class="clearfix"></div>
11+
<h3>{{ _('Refresh Recombinant Resource') }}{% snippet 'snippets/sysadmin_only.html' %}</h3>
12+
<p>{{ _('You can refresh your resource in the database to try to solve the problem.') }}</p>
13+
<div class="clearfix"></div>
14+
<p class="form-actions">
15+
<form id="recombinant-refresh-form" action="{{ h.url_for('recombinant.refresh_dataset', resource_name=res_name, owner_org=owner_org) }}" method="post">
16+
{{ h.csrf_input() }}
17+
<input type="hidden" name="dataset_id" value="{{ dataset_id }}"/>
18+
<button class="btn btn-danger mrgn-bttm-md m-b-3" type="submit" name="refresh" >{{ _('Refresh…') }}</button>
19+
</form>
20+
</p>
21+
{% endif %}

ckanext/recombinant/views.py

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
from ckanext.recombinant.errors import (
3434
RecombinantException,
35+
RecombinantFieldError,
3536
BadExcelData,
3637
format_trigger_error
3738
)
@@ -59,17 +60,25 @@
5960
from ckanapi import NotFound, LocalCKAN
6061

6162

63+
KEY_ERROR_MATCH = re.compile('"([^"]*)"')
64+
6265
log = getLogger(__name__)
6366
recombinant = Blueprint('recombinant', __name__)
6467

6568

66-
@recombinant.route('/recombinant/upload/<id>', methods=['POST'])
67-
def upload(id: str) -> Response:
69+
@recombinant.route('/recombinant/upload/<id>', methods=['GET', 'POST'])
70+
def upload(id: str) -> Union[Response, str]:
6871
package_type = _get_package_type(id)
6972
geno = get_geno(package_type)
7073
lc = LocalCKAN(username=g.user)
7174
dataset = lc.action.package_show(id=id)
7275
org = lc.action.organization_show(id=dataset['owner_org'])
76+
if request.method != 'POST':
77+
# handle page refreshes
78+
h.flash_notice(_('Form not submitted, please try again.'))
79+
return h.redirect_to('recombinant.preview_table',
80+
resource_name=package_type,
81+
owner_org=org['name'])
7382
dry_run = 'validate' in request.form
7483
# resource_name is only required for redirect,
7584
# so do not need to heavily validate that it exists in the geno.
@@ -98,6 +107,15 @@ def upload(id: str) -> Response:
98107
return h.redirect_to('recombinant.preview_table',
99108
resource_name=resource_name,
100109
owner_org=org['name'])
110+
except RecombinantFieldError as e:
111+
h.flash_error(render('recombinant/snippets/outdated_error.html',
112+
extra_vars={'key_errors': str(e).replace("'", ''),
113+
'dataset_id': dataset['id'],
114+
'res_name': resource_name,
115+
'owner_org': dataset['owner_org']}))
116+
return h.redirect_to('recombinant.preview_table',
117+
resource_name=resource_name,
118+
owner_org=org['name'])
101119
except BadExcelData as e:
102120
h.flash_error(_(e.message))
103121
return h.redirect_to('recombinant.preview_table',
@@ -120,6 +138,13 @@ def delete_records(id: str, resource_id: str) -> Union[str, Response]:
120138
res = lc.action.resource_show(id=resource_id)
121139
org = lc.action.organization_show(id=pkg['owner_org'])
122140

141+
if request.method != 'POST':
142+
# handle page refreshes
143+
h.flash_notice(_('Form not submitted, please try again.'))
144+
return h.redirect_to('recombinant.preview_table',
145+
resource_name=res['name'],
146+
owner_org=org['name'])
147+
123148
dataset = lc.action.recombinant_show(
124149
dataset_type=pkg['type'], owner_org=org['name'])
125150

@@ -190,7 +215,8 @@ def record_fail(err: str) -> str:
190215
'recombinant.preview_table',
191216
resource_name=res['name'],
192217
owner_org=org['name'])
193-
if 'confirm' not in request.form or request.method == 'GET':
218+
# type_ignore_reason: incomplete typing
219+
if 'confirm' not in request.form or request.method == 'GET': # type: ignore
194220
return render('recombinant/confirm_delete.html',
195221
extra_vars={'dataset': dataset,
196222
'resource': res,
@@ -249,7 +275,7 @@ def _xlsx_response_headers() -> Tuple[str, str]:
249275

250276
@recombinant.route('/recombinant-template/<dataset_type>_<lang>_<owner_org>.xlsx',
251277
methods=['GET', 'POST'])
252-
def template(dataset_type: str, lang: str, owner_org: str) -> Response:
278+
def template(dataset_type: str, lang: str, owner_org: str) -> Union[Response, str]:
253279
"""
254280
POST requests to this endpoint contain primary keys of
255281
records that are to be included in the excel file
@@ -307,7 +333,17 @@ def template(dataset_type: str, lang: str, owner_org: str) -> Response:
307333
resource['id'])
308334
record_data += result['records']
309335

310-
append_data(book, record_data, chromo)
336+
try:
337+
append_data(book, record_data, chromo)
338+
except RecombinantFieldError as e:
339+
h.flash_error(render('recombinant/snippets/outdated_error.html',
340+
extra_vars={'key_errors': str(e).replace("'", ''),
341+
'dataset_id': dataset['id'],
342+
'res_name': resource_name,
343+
'owner_org': dataset['owner_org']}))
344+
return h.redirect_to('recombinant.preview_table',
345+
resource_name=resource_name,
346+
owner_org=org['name'])
311347

312348
resource_names = dict((r['id'], r['name']) for r in dataset['resources'])
313349
ds_info = lc.action.datastore_info(id=resource['id'])
@@ -576,7 +612,8 @@ def preview_table(resource_name: str,
576612
# check that the resource has errors
577613
for _r in dataset['resources']:
578614
if _r['name'] == resource_name and ('error' in _r or
579-
not _r['datastore_correct']):
615+
not _r['datastore_correct'] or
616+
not _r['schema_correct']):
580617
raise NotFound
581618
except NotFound:
582619
try:
@@ -632,6 +669,50 @@ def preview_table(resource_name: str,
632669
})
633670

634671

672+
@recombinant.route('/recombinant/refresh/<resource_name>/<owner_org>',
673+
methods=['GET', 'POST'])
674+
def refresh_dataset(resource_name: str, owner_org: str):
675+
if not is_sysadmin(g.user):
676+
return abort(403)
677+
if request.method != 'POST':
678+
# handle page refreshes
679+
h.flash_notice(_('Form not submitted, please try again.'))
680+
return h.redirect_to('recombinant.preview_table',
681+
resource_name=resource_name,
682+
owner_org=owner_org)
683+
if 'refresh' in request.form:
684+
dataset_id = request.form['dataset_id']
685+
if not dataset_id:
686+
h.flash_error(_('Could not determine dataset to update.'))
687+
else:
688+
lc = LocalCKAN()
689+
dataset_dict = lc.action.package_show(id=dataset_id)
690+
for res in dataset_dict['resources']:
691+
if not h.check_access('datastore_delete',
692+
{'resource_id': res['id'],
693+
'filters': {}}):
694+
return abort(403, _('User {0} not authorized '
695+
'to update resource {1}'.format(
696+
str(g.user), res['id'])))
697+
try:
698+
lc.action.recombinant_update(
699+
owner_org=dataset_dict['organization']['name'],
700+
dataset_type=dataset_dict['type'],
701+
dataset_id=dataset_id,
702+
force_update=True)
703+
h.flash_success(_('Resources successfully refreshed.'))
704+
except Exception:
705+
h.flash_error(_(
706+
'Unable to regenerate the resources. Please contact '
707+
'<a href="mailto:open-ouvert@tbs-sct.gc.ca">'
708+
'open-ouvert@tbs-sct.gc.ca</a> for assistance.'),
709+
allow_html=True)
710+
return h.redirect_to(
711+
'recombinant.preview_table',
712+
resource_name=resource_name,
713+
owner_org=owner_org)
714+
715+
635716
def _process_upload_file(lc: LocalCKAN,
636717
dataset: Dict[str, Any],
637718
upload_file: Union[str, FlaskFileStorage, FieldStorage],
@@ -752,6 +833,15 @@ def _process_upload_file(lc: LocalCKAN,
752833
# type_ignore_reason: incomplete typing
753834
pgerror = e.error_dict['info']['orig'][0].decode( # type: ignore
754835
'utf-8')
836+
elif 'fields' in e.error_dict:
837+
# type_ignore_reason: incomplete typing
838+
pgerror = e.error_dict['fields'][0] # type: ignore
839+
key = re.search(KEY_ERROR_MATCH, str(pgerror))
840+
if key:
841+
key = key.group(1)
842+
else:
843+
key = _('unknown')
844+
raise RecombinantFieldError(key)
755845
else:
756846
# type_ignore_reason: incomplete typing
757847
pgerror = e.error_dict['records'][0] # type: ignore

ckanext/recombinant/write_excel.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from typing import Any, Dict, List, Tuple, Optional, Union
2222

2323
from ckanext.recombinant.tables import get_geno
24-
from ckanext.recombinant.errors import RecombinantException
24+
from ckanext.recombinant.errors import RecombinantException, RecombinantFieldError
2525
from ckanext.recombinant.datatypes import datastore_type
2626
from ckanext.recombinant.helpers import (
2727
recombinant_choice_fields, recombinant_language_text)
@@ -32,7 +32,7 @@
3232
from datetime import datetime
3333
from decimal import Decimal
3434

35-
35+
SHEET_PROTECTION = True
3636
DEFAULT_TEMPLATE_VERSION = 3
3737

3838
HEADER_ROW, HEADER_HEIGHT = 1, 27
@@ -132,7 +132,7 @@ def excel_template(dataset_type: str,
132132
_append_resource_ref_header(geno, refs, rnum)
133133
choice_ranges.append(_populate_excel_sheet(
134134
book, sheet, geno, chromo, org, refs, rnum))
135-
sheet.protection.enabled = True
135+
sheet.protection.enabled = SHEET_PROTECTION
136136
sheet.protection.formatRows = False
137137
sheet.protection.formatColumns = False
138138
# type_ignore_reason: incomplete typing
@@ -141,7 +141,7 @@ def excel_template(dataset_type: str,
141141
if version == 3:
142142
_populate_reference_sheet(sheet, geno, refs)
143143
sheet.title = 'reference'
144-
sheet.protection.enabled = True
144+
sheet.protection.enabled = SHEET_PROTECTION
145145

146146
if version == 2:
147147
return book
@@ -152,13 +152,13 @@ def excel_template(dataset_type: str,
152152
sheet: Worksheet = book.create_sheet() # type: ignore
153153
_populate_excel_e_sheet(sheet, chromo, cranges)
154154
sheet.title = 'e{i}'.format(i=i)
155-
sheet.protection.enabled = True
155+
sheet.protection.enabled = SHEET_PROTECTION
156156
sheet.sheet_state = 'hidden'
157157
# type_ignore_reason: incomplete typing
158158
sheet: Worksheet = book.create_sheet() # type: ignore
159159
_populate_excel_r_sheet(sheet, chromo)
160160
sheet.title = 'r{i}'.format(i=i)
161-
sheet.protection.enabled = True
161+
sheet.protection.enabled = SHEET_PROTECTION
162162
sheet.sheet_state = 'hidden'
163163
return book
164164

@@ -175,6 +175,8 @@ def append_data(book: Workbook,
175175
current_row = DATA_FIRST_ROW
176176
for record in record_data:
177177
for col_num, field in template_cols_fields(chromo):
178+
if field['datastore_id'] not in record:
179+
raise RecombinantFieldError(field['datastore_id'])
178180
item = datastore_type_format(
179181
record[field['datastore_id']], field['datastore_type'])
180182
sheet.cell(row=current_row, column=col_num).value = item

0 commit comments

Comments
 (0)