Skip to content

Commit f1b9860

Browse files
committed
Merge branch 'master' into feature/sysadmin-ds-dump
2 parents 71921bd + 4600942 commit f1b9860

File tree

3 files changed

+141
-44
lines changed

3 files changed

+141
-44
lines changed

changes/137.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed an issue with the Delete records view function not redirecting to the Preview page.

changes/137.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improved the constraint error messages by including the referenced/referencing key values and resource. Add `{refKeys}`, `{refValues}`, and/or `{refTable}` in a defined constraint error message for dynamic string replacements. The `{refTable}` with be a URL link to the CKAN resource.

ckanext/recombinant/views.py

Lines changed: 139 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
from flask_babel import force_locale
33
import re
44
import simplejson as json
5+
from sqlalchemy import text as sql_text
56

6-
from typing import Union, Optional, Dict, Tuple, Any
7+
from typing import Union, Dict, Tuple, Any, List
78
from ckan.types import Response
89

910
from werkzeug.datastructures import FileStorage as FlaskFileStorage
@@ -46,8 +47,14 @@
4647

4748
from io import BytesIO
4849

50+
# use import incase of ckan.datastore.sqlsearch.enabled = False
51+
from ckanext.datastore.logic.action import datastore_search_sql
4952
from ckanext.datastore.backend import DatastoreBackend
50-
from ckanext.datastore.backend.postgres import DatastorePostgresqlBackend
53+
from ckanext.datastore.backend.postgres import (
54+
DatastorePostgresqlBackend,
55+
identifier as sql_identifier,
56+
_parse_constraint_error_from_psql_error
57+
)
5158

5259
from ckanapi import NotFound, LocalCKAN
5360

@@ -98,7 +105,7 @@ def upload(id: str) -> Response:
98105
owner_org=org['name'])
99106

100107

101-
@recombinant.route('/recombinant/delete/<id>/<resource_id>', methods=['POST'])
108+
@recombinant.route('/recombinant/delete/<id>/<resource_id>', methods=['GET', 'POST'])
102109
def delete_records(id: str, resource_id: str) -> Union[str, Response]:
103110
lc = LocalCKAN(username=g.user)
104111
filters = {}
@@ -116,19 +123,26 @@ def delete_records(id: str, resource_id: str) -> Union[str, Response]:
116123
dataset = lc.action.recombinant_show(
117124
dataset_type=pkg['type'], owner_org=org['name'])
118125

119-
def delete_error(err: str) -> str:
120-
return render('recombinant/resource_edit.html',
121-
extra_vars={'delete_errors': [err],
122-
'dataset': dataset,
123-
'dataset_type': dataset['dataset_type'],
126+
# type_ignore_reason: [] default is allowed
127+
def delete_error(err: str, _records: List[str] = []) -> str: # type: ignore
128+
return render('recombinant/confirm_delete.html',
129+
extra_vars={'dataset': dataset,
124130
'resource': res,
125-
'organization': org,
126-
'filters': filters,
127-
'action': 'edit'})
131+
'errors': [err],
132+
'num': len(_records),
133+
'bulk_delete': '\n'.join(
134+
_records
135+
# extra blank is needed to prevent field
136+
# from being completely empty
137+
+ ([''] if '' in _records else []))})
128138

129139
form_text = request.form.get('bulk-delete', '')
130140
if not form_text:
131-
return delete_error(_('Required field'))
141+
# we can just silently refresh
142+
return h.redirect_to(
143+
'recombinant.preview_table',
144+
resource_name=res['name'],
145+
owner_org=org['name'])
132146

133147
pk_fields = recombinant_primary_key_fields(res['name'])
134148

@@ -142,7 +156,7 @@ def record_fail(err: str) -> str:
142156
# move bad record to the top of the pile
143157
filters['bulk-delete'] = '\n'.join(
144158
[r] + list(records) + ok_records)
145-
return delete_error(err)
159+
return delete_error(err, ok_records)
146160

147161
split_on = '\t' if '\t' in r else ','
148162
fields = [f for f in r.split(split_on)]
@@ -175,8 +189,8 @@ def record_fail(err: str) -> str:
175189
return h.redirect_to(
176190
'recombinant.preview_table',
177191
resource_name=res['name'],
178-
owner_org=org['name'],)
179-
if 'confirm' not in request.form:
192+
owner_org=org['name'])
193+
if 'confirm' not in request.form or request.method == 'GET':
180194
return render('recombinant/confirm_delete.html',
181195
extra_vars={'dataset': dataset,
182196
'resource': res,
@@ -186,27 +200,23 @@ def record_fail(err: str) -> str:
186200
# extra blank is needed to prevent field
187201
# from being completely empty
188202
+ ([''] if '' in ok_records else []))})
203+
if request.method == 'POST':
204+
for f in ok_filters:
205+
try:
206+
lc.action.datastore_delete(
207+
resource_id=resource_id,
208+
filters=f,
209+
)
210+
except ValidationError as e:
211+
if 'constraint_info' in e.error_dict:
212+
error_message = _render_recombinant_constraint_errors(
213+
lc, e, get_chromo(res['name']), 'delete')
214+
h.flash_error(error_message)
215+
# type_ignore_reason: incomplete typing
216+
return record_fail(error_message) # type: ignore
217+
raise
189218

190-
for f in ok_filters:
191-
try:
192-
lc.action.datastore_delete(
193-
resource_id=resource_id,
194-
filters=f,
195-
)
196-
except ValidationError as e:
197-
if 'foreign_constraints' in e.error_dict:
198-
records = []
199-
ok_records = []
200-
chromo = get_chromo(res['name'])
201-
# type_ignore_reason: incomplete typing
202-
error_message = chromo.get('datastore_constraint_errors', {}).get(
203-
'delete', e.error_dict['foreign_constraints'][0]) # type: ignore
204-
h.flash_error(_(error_message))
205-
# type_ignore_reason: incomplete typing
206-
return record_fail(_(error_message)) # type: ignore
207-
raise
208-
209-
h.flash_success(_("{num} deleted.").format(num=len(ok_filters)))
219+
h.flash_success(_("{num} deleted.").format(num=len(ok_filters)))
210220

211221
return h.redirect_to(
212222
'recombinant.preview_table',
@@ -299,6 +309,44 @@ def template(dataset_type: str, lang: str, owner_org: str) -> Response:
299309

300310
append_data(book, record_data, chromo)
301311

312+
resource_names = dict((r['id'], r['name']) for r in dataset['resources'])
313+
ds_info = lc.action.datastore_info(id=resource['id'])
314+
if 'foreignkeys' in ds_info['meta']:
315+
for fk in ds_info['meta']['foreignkeys']:
316+
f_chromo = None
317+
foreign_constraints_sql = None
318+
if resource['id'] == fk['child_table']:
319+
f_chromo = get_chromo(resource_names[fk['parent_table']])
320+
foreign_constraints_sql = sql_text('''
321+
SELECT parent.* FROM {0} parent
322+
JOIN {1} child ON {2}
323+
ORDER BY parent._id
324+
'''.format(sql_identifier(fk['parent_table']),
325+
sql_identifier(fk['child_table']),
326+
' AND '.join(
327+
['parent.{0} = child.{1}'.format(
328+
fk_c, fk['child_columns'][fk_i])
329+
for fk_i, fk_c in enumerate(
330+
fk['parent_columns'])])))
331+
elif resource['id'] == fk['parent_table']:
332+
f_chromo = get_chromo(resource_names[fk['child_table']])
333+
foreign_constraints_sql = sql_text('''
334+
SELECT child.* FROM {0} child
335+
JOIN {1} parent ON {2}
336+
ORDER BY child._id
337+
'''.format(sql_identifier(fk['child_table']),
338+
sql_identifier(fk['parent_table']),
339+
' AND '.join(
340+
['child.{0} = parent.{1}'.format(
341+
fk_c, fk['parent_columns'][fk_i])
342+
for fk_i, fk_c in enumerate(
343+
fk['child_columns'])])))
344+
if foreign_constraints_sql is not None and f_chromo is not None:
345+
results = datastore_search_sql(
346+
{'ignore_auth': True}, {'sql': str(foreign_constraints_sql)})
347+
if results:
348+
append_data(book, results['records'], f_chromo)
349+
302350
blob = BytesIO()
303351
book.save(blob)
304352
response = FlaskResponse(blob.getvalue())
@@ -490,8 +538,7 @@ def resource_redirect(package_type: str, id: str, resource_id: str) -> Response:
490538

491539
@recombinant.route('/recombinant/<resource_name>/<owner_org>', methods=['GET', 'POST'])
492540
def preview_table(resource_name: str,
493-
owner_org: str,
494-
errors: Optional[Dict[str, Any]] = None) -> Union[str, Response]:
541+
owner_org: str) -> Union[str, Response]:
495542
if not g.user:
496543
return h.redirect_to('user.login')
497544

@@ -576,7 +623,9 @@ def preview_table(resource_name: str,
576623
'resource_name': chromo['resource_name'],
577624
'resource': r,
578625
'organization': org,
579-
'errors': errors,
626+
'errors': None,
627+
'delete_errors': None,
628+
'filters': None
580629
})
581630

582631

@@ -690,7 +739,11 @@ def _process_upload_file(lc: LocalCKAN,
690739
context={'connection': ds_write_connection}
691740
)
692741
except ValidationError as e:
693-
if 'info' in e.error_dict:
742+
if 'constraint_info' in e.error_dict:
743+
# type_ignore_reason: incomplete typing
744+
pgerror = e.error_dict['errors'][ # type: ignore
745+
'foreign_constraint'][0]
746+
elif 'info' in e.error_dict:
694747
# because, where else would you put the error text?
695748
# XXX improve this in datastore, please
696749
# type_ignore_reason: incomplete typing
@@ -712,11 +765,9 @@ def _process_upload_file(lc: LocalCKAN,
712765
pgerror = re.sub(r'\nLINE \d+:', '', pgerror)
713766
pgerror = re.sub(r'\n *\^\n$', '', pgerror)
714767
if 'records_row' in e.error_dict:
715-
if 'violates foreign key constraint' in pgerror:
716-
foreign_error = chromo.get(
717-
'datastore_constraint_errors', {}).get('upsert')
718-
if foreign_error:
719-
pgerror = _(foreign_error)
768+
if 'constraint_info' in e.error_dict:
769+
pgerror = _render_recombinant_constraint_errors(
770+
lc, e, chromo, 'upsert')
720771
elif 'invalid input syntax for type integer' in pgerror:
721772
if ':' in pgerror:
722773
pgerror = _('Invalid input syntax for type integer: {}')\
@@ -744,3 +795,47 @@ def _process_upload_file(lc: LocalCKAN,
744795
raise
745796
finally:
746797
ds_write_connection.close()
798+
799+
800+
def _render_recombinant_constraint_errors(lc: LocalCKAN,
801+
exception: Exception,
802+
chromo: Dict[str, Any],
803+
action: str) -> str:
804+
# type_ignore_reason: incomplete typing
805+
orig_errmsg = exception.error_dict['errors'][ # type: ignore
806+
'foreign_constraint'][0]
807+
foreign_error = chromo.get(
808+
'datastore_constraint_errors', {}).get(action)
809+
fk_err_template = chromo.get(
810+
'datastore_constraint_error_templates', {}).get(action)
811+
if foreign_error and not fk_err_template:
812+
error_message = _parse_constraint_error_from_psql_error(
813+
exception, foreign_error)['errors'][
814+
'foreign_constraint'][0]
815+
elif fk_err_template:
816+
ref_res_dict = lc.action.resource_show(
817+
# type_ignore_reason: incomplete typing
818+
id=exception.error_dict['constraint_info']['ref_resource']) # type: ignore
819+
ref_pkg_dict = lc.action.package_show(
820+
id=ref_res_dict['package_id'])
821+
dt_query = {}
822+
# type_ignore_reason: incomplete typing
823+
_ref_keys = exception.error_dict['constraint_info'][ # type: ignore
824+
'ref_keys'].replace(' ', '').split(',')
825+
# type_ignore_reason: incomplete typing
826+
_ref_values = exception.error_dict['constraint_info'][ # type: ignore
827+
'ref_values'].replace(' ', '').split(',')
828+
for _i, key in enumerate(_ref_keys):
829+
dt_query[key] = _ref_values[_i]
830+
dt_query = json.dumps(dt_query, separators=(',', ':'))
831+
error_message = render(
832+
fk_err_template,
833+
extra_vars=dict(
834+
# type_ignore_reason: incomplete typing
835+
exception.error_dict['constraint_info'], # type: ignore
836+
ref_resource=ref_res_dict,
837+
ref_dataset=ref_pkg_dict,
838+
dt_query=dt_query))
839+
else:
840+
error_message = orig_errmsg
841+
return error_message

0 commit comments

Comments
 (0)