Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

datastore Data Dictionary as postgresql comments #3414

Merged
merged 10 commits into from Mar 14, 2017
4 changes: 1 addition & 3 deletions ckan/templates/package/resource_edit_base.html
Expand Up @@ -22,9 +22,7 @@

{% block content_primary_nav %}
{{ h.build_nav_icon('resource_edit', _('Edit resource'), id=pkg.name, resource_id=res.id) }}
{% if 'datapusher' in g.plugins %}
{{ h.build_nav_icon('resource_data', _('DataStore'), id=pkg.name, resource_id=res.id) }}
{% endif %}
{% block inner_primary_nav %}{% endblock %}
{{ h.build_nav_icon('views', _('Views'), id=pkg.name, resource_id=res.id) }}
{% endblock %}

Expand Down
6 changes: 5 additions & 1 deletion ckanext/datapusher/plugin.py
Expand Up @@ -67,11 +67,12 @@ def resource_data(self, id, resource_id):
except logic.NotAuthorized:
base.abort(403, _('Not authorized to see this page'))

return base.render('package/resource_data.html',
return base.render('datapusher/resource_data.html',
extra_vars={'status': datapusher_status})


class DatapusherPlugin(p.SingletonPlugin):
p.implements(p.IConfigurer, inherit=True)
p.implements(p.IConfigurable, inherit=True)
p.implements(p.IActions)
p.implements(p.IAuthFunctions)
Expand All @@ -83,6 +84,9 @@ class DatapusherPlugin(p.SingletonPlugin):
legacy_mode = False
resource_show_action = None

def update_config(self, config):
p.toolkit.add_template_directory(config, 'templates')

def configure(self, config):
self.config = config

Expand Down
6 changes: 6 additions & 0 deletions ckanext/datapusher/templates/package/resource_edit_base.html
@@ -0,0 +1,6 @@
{% ckan_extends %}

{% block inner_primary_nav %}
{{ super() }}
{{ h.build_nav_icon('resource_data', _('DataStore'), id=pkg.name, resource_id=res.id) }}
{% endblock %}
44 changes: 44 additions & 0 deletions ckanext/datastore/controller.py
@@ -1,19 +1,24 @@
# encoding: utf-8

import StringIO
import md5

import pylons

from ckan.plugins.toolkit import (
Invalid,
ObjectNotFound,
NotAuthorized,
get_action,
get_validator,
_,
request,
response,
BaseController,
abort,
render,
c,
h,
)
from ckanext.datastore.writer import (
csv_writer,
Expand Down Expand Up @@ -84,3 +89,42 @@ def result_page(offset, limit):
limit -= PAGINATE_BY

result = result_page(offset, limit)

def dictionary(self, id, resource_id):
u'''data dictionary view: show/edit field labels and descriptions'''

try:
# resource_edit_base template uses these
c.pkg_dict = get_action('package_show')(
None, {'id': id})
c.resource = get_action('resource_show')(
None, {'id': resource_id})
rec = get_action('datastore_search')(None, {
'resource_id': resource_id,
'limit': 0})
except (ObjectNotFound, NotAuthorized):
abort(404, _('Resource not found'))

fields = [f for f in rec['fields'] if not f['id'].startswith('_')]

if request.method == 'POST':
get_action('datastore_create')(None, {
'resource_id': resource_id,
'force': True,
'fields': [{
'id': f['id'],
'type': f['type'],
'info': {
'label': request.POST.get('f{0}label'.format(i)),
'notes': request.POST.get('f{0}notes'.format(i)),
}} for i, f in enumerate(fields, 1)]})

h.redirect_to(
controller='ckanext.datastore.controller:DatastoreController',
action='dictionary',
id=id,
resource_id=resource_id)

return render(
'datastore/dictionary.html',
extra_vars={'fields': fields})
80 changes: 65 additions & 15 deletions ckanext/datastore/db.py
Expand Up @@ -226,6 +226,24 @@ def _guess_type(field):
return 'text'


def _get_field_info(connection, resource_id):
u'''return a dictionary mapping column names to their info data,
when present'''
qtext = sqlalchemy.text(u'''
select pa.attname as name, pd.description as info
from pg_class pc, pg_attribute pa, pg_description pd
where pa.attrelid = pc.oid and pd.objoid = pc.oid
and pd.objsubid = pa.attnum and pc.relname = :res_id
and pa.attnum > 0
''')
try:
return dict(
(n, json.loads(v)) for (n, v) in
connection.execute(qtext, res_id=resource_id).fetchall())
except ValueError: # don't die on non-json comments
return {}


def _get_fields(context, data_dict):
fields = []
all_fields = context['connection'].execute(
Expand Down Expand Up @@ -339,15 +357,26 @@ def create_table(context, data_dict):
})

fields = datastore_fields + supplied_fields + extra_fields
sql_fields = u", ".join([u'"{0}" {1}'.format(
f['id'], f['type']) for f in fields])
sql_fields = u", ".join([u'{0} {1}'.format(
datastore_helpers.identifier(f['id']), f['type']) for f in fields])

sql_string = u'CREATE TABLE "{0}" ({1});'.format(
data_dict['resource_id'],
sql_string = u'CREATE TABLE {0} ({1});'.format(
datastore_helpers.identifier(data_dict['resource_id']),
sql_fields
)

context['connection'].execute(sql_string.replace('%', '%%'))
info_sql = []
for f in supplied_fields:
info = f.get(u'info')
if isinstance(info, dict):
info_sql.append(u'COMMENT ON COLUMN {0}.{1} is {2}'.format(
datastore_helpers.identifier(data_dict['resource_id']),
datastore_helpers.identifier(f['id']),
datastore_helpers.literal_string(
json.dumps(info, ensure_ascii=False))))

context['connection'].execute(
(sql_string + u';'.join(info_sql)).replace(u'%', u'%%'))


def _get_aliases(context, data_dict):
Expand Down Expand Up @@ -607,12 +636,29 @@ def alter_table(context, data_dict):
'type': _guess_type(records[0][field_id])
})

for field in new_fields:
sql = 'ALTER TABLE "{0}" ADD "{1}" {2}'.format(
data_dict['resource_id'],
field['id'],
field['type'])
context['connection'].execute(sql.replace('%', '%%'))
alter_sql = []
for f in new_fields:
alter_sql.append(u'ALTER TABLE {0} ADD {1} {2};'.format(
datastore_helpers.identifier(data_dict['resource_id']),
datastore_helpers.identifier(f['id']),
f['type']))

for f in supplied_fields:
if u'info' in f:
info = f.get(u'info')
if isinstance(info, dict):
info_sql = datastore_helpers.literal_string(
json.dumps(info, ensure_ascii=False))
else:
info_sql = 'NULL'
alter_sql.append(u'COMMENT ON COLUMN {0}.{1} is {2}'.format(
datastore_helpers.identifier(data_dict['resource_id']),
datastore_helpers.identifier(f['id']),
info_sql))

if alter_sql:
context['connection'].execute(
u';'.join(alter_sql).replace(u'%', u'%%'))


def insert_data(context, data_dict):
Expand Down Expand Up @@ -993,7 +1039,8 @@ def search_data(context, data_dict):
results = _execute_single_statement(context, sql_string, where_values)

_insert_links(data_dict, limit, offset)
return format_results(context, results, data_dict)
return format_results(context, results, data_dict, _get_field_info(
context['connection'], data_dict['resource_id']))


def _execute_single_statement(context, sql_string, where_values):
Expand All @@ -1007,13 +1054,16 @@ def _execute_single_statement(context, sql_string, where_values):
return results


def format_results(context, results, data_dict):
def format_results(context, results, data_dict, field_info=None):
result_fields = []
for field in results.cursor.description:
result_fields.append({
f = {
'id': field[0].decode('utf-8'),
'type': _get_type(context, field[1])
})
}
if field_info and f['id'] in field_info:
f['info'] = field_info[f['id']]
result_fields.append(f)
if len(result_fields) and result_fields[-1]['id'] == '_full_count':
result_fields.pop() # remove _full_count

Expand Down
15 changes: 15 additions & 0 deletions ckanext/datastore/helpers.py
Expand Up @@ -6,6 +6,8 @@
import paste.deploy.converters as converters
import sqlparse

from ckan.plugins.toolkit import get_action, ObjectNotFound, NotAuthorized

log = logging.getLogger(__name__)


Expand Down Expand Up @@ -107,3 +109,16 @@ def identifier(s):
Return s as a quoted postgres identifier
"""
return u'"' + s.replace(u'"', u'""').replace(u'\0', '') + u'"'


def datastore_dictionary(resource_id):
"""
Return the data dictionary info for a resource
"""
try:
return [
f for f in get_action('datastore_search')(
None, {u'resource_id': resource_id, u'limit': 0})['fields']
if not f['id'].startswith(u'_')]
except (ObjectNotFound, NotAuthorized):
return []
3 changes: 2 additions & 1 deletion ckanext/datastore/logic/schema.py
Expand Up @@ -101,7 +101,8 @@ def datastore_create_schema():
'aliases': [ignore_missing, list_of_strings_or_string],
'fields': {
'id': [not_empty, unicode],
'type': [ignore_missing]
'type': [ignore_missing],
'info': [ignore_missing],
},
'primary_key': [ignore_missing, list_of_strings_or_string],
'indexes': [ignore_missing, list_of_strings_or_string],
Expand Down
16 changes: 13 additions & 3 deletions ckanext/datastore/plugin.py
Expand Up @@ -55,6 +55,7 @@ class DatastorePlugin(p.SingletonPlugin):
p.implements(p.IDomainObjectModification, inherit=True)
p.implements(p.IRoutes, inherit=True)
p.implements(p.IResourceController, inherit=True)
p.implements(p.ITemplateHelpers)
p.implements(interfaces.IDatastore, inherit=True)

legacy_mode = False
Expand Down Expand Up @@ -246,9 +247,14 @@ def get_auth_functions(self):
'datastore_change_permissions': auth.datastore_change_permissions}

def before_map(self, m):
m.connect('/datastore/dump/{resource_id}',
controller='ckanext.datastore.controller:DatastoreController',
action='dump')
m.connect(
'/datastore/dump/{resource_id}',
controller='ckanext.datastore.controller:DatastoreController',
action='dump')
m.connect(
'resource_dictionary', '/dataset/{id}/dictionary/{resource_id}',
controller='ckanext.datastore.controller:DatastoreController',
action='dictionary', ckan_icon='book')
return m

def before_show(self, resource_dict):
Expand Down Expand Up @@ -508,3 +514,7 @@ def _ts_rank_alias(self, field=None):
if field:
rank_alias += u' ' + field
return u'"{0}"'.format(rank_alias)

def get_helpers(self):
return {
'datastore_dictionary': datastore_helpers.datastore_dictionary}
29 changes: 29 additions & 0 deletions ckanext/datastore/templates/datastore/dictionary.html
@@ -0,0 +1,29 @@
{% extends "package/resource_edit_base.html" %}

{% import 'macros/form.html' as form %}

{% block subtitle %}{{ h.dataset_display_name(pkg) }} - {{ h.resource_display_name(res) }}{% endblock %}

{% block primary_content_inner %}

{% set action = h.url_for(
controller='ckanext.datastore.controller:DatastoreController',
action='dictionary', id=pkg.name, resource_id=res.id) %}

<form method="post" action="{{ action }}" >
{% block dictionary_form %}
{% for f in fields %}
<h3>{{ _( "Field {num}.").format(num=loop.index) }} {{ f.id }} ({{ f.type }})</h3>
{{ form.input('f' ~ loop.index ~ 'label',
label=_('Label'), id='field-f' ~ loop.index ~ 'label',
value=f.get('info', {}).get('label', ''), classes=['control-full']) }}
{{ form.markdown('f' ~ loop.index ~ 'notes',
label=_('Description'), id='field-d' ~ loop.index ~ 'notes',
value=f.get('info', {}).get('notes', '')) }}
{% endfor %}
{% endblock %}
<button class="btn btn-primary" name="save" type="submit">
<i class="fa fa-book"></i> {{ _('Save') }}
</button>
</form>
{% endblock %}
8 changes: 8 additions & 0 deletions ckanext/datastore/templates/package/resource_edit_base.html
@@ -0,0 +1,8 @@
{% ckan_extends %}

{% block inner_primary_nav %}
{{ super() }}
{% if res.datastore_active %}
{{ h.build_nav_icon('resource_dictionary', _('Data Dictionary'), id=pkg.name, resource_id=res.id) }}
{% endif %}
{% endblock %}
35 changes: 33 additions & 2 deletions ckanext/datastore/templates/package/resource_read.html
Expand Up @@ -2,5 +2,36 @@

{% block resource_actions_inner %}
{{ super() }}
<li>{% snippet 'package/snippets/data_api_button.html', resource=res, datastore_root_url=c.datastore_api %}</li>
{% endblock %}
{% if res.datastore_active %}
<li>{% snippet 'package/snippets/data_api_button.html', resource=res, datastore_root_url=c.datastore_api %}</li>
{% endif %}
{% endblock %}

{% block resource_additional_information_inner %}
{% if res.datastore_active %}
{% set ddict=h.datastore_dictionary(res.id) %}
<div class="module-content">
<h2>{{ _('Data Dictionary') }}</h2>
<table class="table table-striped table-bordered table-condensed" data-module="table-toggle-more">
<thead>
<tr>
<th scope="col">{{ _('Column') }}</th>
<th scope="col">{{ _('Type') }}</th>
<th scope="col">{{ _('Label') }}</th>
<th scope="col">{{ _('Description') }}</th>
</tr>
</thead>
{% for f in ddict %}
<tr>
<td>{{ f.id }}</td>
<td>{{ f.type }}</td>
<td>{{ h.get_translated(f.get('info', {}), 'label') }}</td>
<td>{{ h.render_markdown(
h.get_translated(f.get('info', {}), 'notes')) }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{{ super() }}
{% endblock %}