From aca2d6649612a317cab6003563a074bc01c8508b Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Thu, 7 Feb 2013 20:07:00 +0100 Subject: [PATCH 001/149] [#368] Ported revision history page to new template system --- ckan/templates/package/history.html | 12 +++++++ .../package/snippets/history_revisions.html | 12 +++++++ .../package/snippets/revisions_table.html | 31 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 ckan/templates/package/history.html create mode 100644 ckan/templates/package/snippets/history_revisions.html create mode 100644 ckan/templates/package/snippets/revisions_table.html diff --git a/ckan/templates/package/history.html b/ckan/templates/package/history.html new file mode 100644 index 00000000000..0f09674214a --- /dev/null +++ b/ckan/templates/package/history.html @@ -0,0 +1,12 @@ +{% extends "package/read_base.html" %} + +{% block subtitle %}{{ _('History') }} - {{ c.pkg_dict.title or c.pkg_dict.name }}{% endblock %} + +{% block primary_content_inner %} +
+

{{ _('History') }}

+ {% block package_history_revisions %} + {% snippet "package/snippets/history_revisions.html", pkg_dict=pkg, pkg_revisions=c.pkg_revisions %} + {% endblock %} +
+{% endblock %} diff --git a/ckan/templates/package/snippets/history_revisions.html b/ckan/templates/package/snippets/history_revisions.html new file mode 100644 index 00000000000..f75aafc5d3f --- /dev/null +++ b/ckan/templates/package/snippets/history_revisions.html @@ -0,0 +1,12 @@ +{% import 'macros/form.html' as form %} + +
+ + {{ form.errors(error_summary) }} + + + {% snippet 'package/snippets/revisions_table.html', pkg_dict=pkg_dict, pkg_revisions=pkg_revisions %} + + + +
\ No newline at end of file diff --git a/ckan/templates/package/snippets/revisions_table.html b/ckan/templates/package/snippets/revisions_table.html new file mode 100644 index 00000000000..17e056e8e44 --- /dev/null +++ b/ckan/templates/package/snippets/revisions_table.html @@ -0,0 +1,31 @@ +{% import 'macros/form.html' as form %} + + + + + + + + + + + + + {% for rev in pkg_revisions %} + + + + + + + + {% endfor %} + +
{{ _('Revision') }}{{ _('Timestamp') }}{{ _('Author') }}{{ _('Log Message') }}
+ {{ h.radio('selected1', rev.id, checked=(loop.first)) }} + {{ h.radio('selected2', rev.id, checked=(loop.last)) }} + + {{rev.id[:4]}}… + + {{ h.render_datetime(rev.timestamp, with_hours=True) }} + {{ h.linked_user(rev.author) }}{{ rev.message }}
\ No newline at end of file From ebf158e5255a79e3ecde8f88575c18a3d0b66528 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:04:14 +0100 Subject: [PATCH 002/149] [#368] Refactor truncate in revisions table --- ckan/templates/package/snippets/revisions_table.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/templates/package/snippets/revisions_table.html b/ckan/templates/package/snippets/revisions_table.html index 17e056e8e44..d98d0cfb34f 100644 --- a/ckan/templates/package/snippets/revisions_table.html +++ b/ckan/templates/package/snippets/revisions_table.html @@ -18,7 +18,7 @@ {{ h.radio('selected2', rev.id, checked=(loop.last)) }} - {{rev.id[:4]}}… + {% link_for rev.id | truncate(6), controller='revision', action='read', id=rev.id %} {{ h.render_datetime(rev.timestamp, with_hours=True) }} From c227f3e50764b9b2a41ba32e083fdae3068fd0e7 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:05:09 +0100 Subject: [PATCH 003/149] [#368] Revisions table should not be condensed which would otherwise lead to display errors with in radio buttons --- ckan/templates/package/snippets/revisions_table.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/templates/package/snippets/revisions_table.html b/ckan/templates/package/snippets/revisions_table.html index d98d0cfb34f..e8fc3d40a3e 100644 --- a/ckan/templates/package/snippets/revisions_table.html +++ b/ckan/templates/package/snippets/revisions_table.html @@ -1,6 +1,6 @@ {% import 'macros/form.html' as form %} - +
From d1cb93fcca9eaae080ed9024bed96d3ef5666d73 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:05:36 +0100 Subject: [PATCH 004/149] [#368] New templates for revisions --- ckan/templates/revision/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 ckan/templates/revision/__init__.py diff --git a/ckan/templates/revision/__init__.py b/ckan/templates/revision/__init__.py new file mode 100644 index 00000000000..b646540d6be --- /dev/null +++ b/ckan/templates/revision/__init__.py @@ -0,0 +1 @@ +# empty file needed for pylons to find templates in this directory From eda8a85c51d46df2a95a7124b9101071b5af44a6 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:06:12 +0100 Subject: [PATCH 005/149] [#368] New template for revision diff --- ckan/templates/revision/diff.html | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 ckan/templates/revision/diff.html diff --git a/ckan/templates/revision/diff.html b/ckan/templates/revision/diff.html new file mode 100644 index 00000000000..98df4af304d --- /dev/null +++ b/ckan/templates/revision/diff.html @@ -0,0 +1,60 @@ +{% extends "page.html" %} + +{% set pkg = c.pkg %} +{% set group = c.group %} + +{% block subtitle %}{{ _('Differences')}}{% endblock %} + +{% block breadcrumb_content %} + {% if c.diff_entity == 'package' %} + {% set dataset = pkg.title or pkg.name %} +
  • {% link_for _('Datasets'), controller='package', action='search', highlight_actions = 'new index' %}
  • +
  • {% link_for dataset, controller='package', action='read', id=pkg.name %}
  • +
  • {{ _('Revision Differences') }}
  • + {% elif c.diff_entity == 'group' %} + {% set group = group.display_name or group.name %} +
  • {% link_for _('Groups'), controller='group', action='index' %}
  • +
  • {% link_for group, controller='group', action='read', id=group.name %}
  • +
  • {{ _('Revision Differences') }}
  • + {% endif %} +{% endblock %} + +{% block primary %} +
    +
    +

    {{ _('Revision Differences') }} - + {% if c.diff_entity == 'package' %} + {% link_for pkg.title, controller='package', action='read', id=pkg.name %} + {% elif c.diff_entity == 'group' %} + {% link_for group.display_name, controller='group', action='read', id=group.name %} + {% endif %} +

    + +

    + From: {% link_for c.revision_from.id, controller='revision', action='read', id=c.revision_from.id %} - + {{ h.render_datetime(c.revision_from.timestamp, with_hours=True) }} +

    +

    + To: {% link_for c.revision_to.id, controller='revision', action='read', id=c.revision_to.id %} - + {{ h.render_datetime(c.revision_to.timestamp, with_hours=True) }} +

    + + {% if c.diff %} +
    + + + + + {% for field, diff in c.diff %} + + + + + {% endfor %} +
    {{ _('Field') }}{{ _('Difference') }}
    {{ field }}
    {{ diff }}
    + {% else %} +

    {{ _('No Differences') }}

    + {% endif %} + + +{% endblock %} From 414a458f4d342d171d7a33f14d22f715fcf8008e Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:06:38 +0100 Subject: [PATCH 006/149] [#368] New template for revision read --- ckan/templates/revision/read.html | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 ckan/templates/revision/read.html diff --git a/ckan/templates/revision/read.html b/ckan/templates/revision/read.html new file mode 100644 index 00000000000..4596d0d49db --- /dev/null +++ b/ckan/templates/revision/read.html @@ -0,0 +1,90 @@ +{% extends "page.html" %} + +{% set rev = c.revision %} + +{% block subtitle %}{{ _('Revision') }} {{ rev.id }}{% endblock %} + +{% block breadcrumb_content %} +
  • {% link_for _('Revisions'), controller='revision', action='index' %}
  • +
  • {{ rev.id |truncate(35) }}
  • +{% endblock %} + +{% block actions_content %} + {% if c.revision_change_state_allowed %} +
    + {% if rev.state != 'deleted' %} + + {% endif %} + {% if rev.state == 'deleted' %} + + {% endif %} +
    + {% endif %} +{% endblock %} + +{% block primary %} +
    +
    +

    {{ _('Revision') }}: {{ rev.id }}

    + +
    +
    + {% if rev.state != 'active' %} +

    + {{ rev.state }} +

    + {% endif %} + +

    + {{ _('Author') }}: {{ h.linked_user(rev.author) }} +

    +

    + {{ _('Timestamp') }}: {{ h.render_datetime(rev.timestamp, with_hours=True) }} +

    +

    + {{ _('Log Message') }}: +

    +

    + {{ rev.message }} +

    +
    + +
    +

    {{ _('Changes') }}

    +

    {{ _('Datasets') }}

    +
      + {% for pkg in c.packages %} +
    • + {{ h.link_to(pkg.name, h.url_for(controller='package', action='read', id=pkg.name)) }} +
    • + {% endfor %} +
    + +

    {{ _('Datasets\' Tags') }}

    +
      + {% for pkgtag in c.pkgtags %} +
    • + Dataset - {{ h.link_to(pkgtag.package.name, h.url_for(controller='package', action='read', id=pkgtag.package.name)) }}, + Tag - {{ h.link_to(pkgtag.tag.name, h.url_for(controller='tag', action='read', id=pkgtag.tag.name)) }} +
    • + {% endfor %} +
    + +

    {{ _('Groups') }}

    +
      + {% for group in c.groups %} +
    • + {{ h.link_to(group.name, h.url_for(controller='group', action='read', id=group.name)) }} +
    • + {% endfor %} +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file From 968f4b493352d0e21c83299170eae586f97865e5 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:07:14 +0100 Subject: [PATCH 007/149] [#368] New template for revision list --- ckan/templates/revision/list.html | 24 +++++++++++++ .../revision/snippets/revisions_list.html | 34 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 ckan/templates/revision/list.html create mode 100644 ckan/templates/revision/snippets/revisions_list.html diff --git a/ckan/templates/revision/list.html b/ckan/templates/revision/list.html new file mode 100644 index 00000000000..bf5ac54d32e --- /dev/null +++ b/ckan/templates/revision/list.html @@ -0,0 +1,24 @@ +{% extends "page.html" %} + +{% block subtitle %}{{ _('Revision History') }}{% endblock %} + +{% block breadcrumb_content %} +
  • {{ _('Revisions') }}
  • +{% endblock %} + +{% block primary %} +
    +
    +

    {{ _('Revision History') }}

    + + {{ c.page.pager() }} + + {% block revisions_list %} + {% snippet "revision/snippets/revisions_list.html", revisions=c.page.items %} + {% endblock %} + + {{ c.page.pager() }} + +
    +
    +{% endblock %} diff --git a/ckan/templates/revision/snippets/revisions_list.html b/ckan/templates/revision/snippets/revisions_list.html new file mode 100644 index 00000000000..dd815ea4ce6 --- /dev/null +++ b/ckan/templates/revision/snippets/revisions_list.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + {% for rev in revisions %} + + + + + + + + {% endfor %} + +
    {{ _('Revision') }}{{ _('Timestamp') }}{{ _('Author') }}{{ _('Entity') }}{{ _('Log Message') }}
    + {{rev.id | truncate(6)}} + + {{ h.render_datetime(rev.timestamp, with_hours=True) }} + {{ h.linked_user(rev.author) }} + {% for pkg in rev.packages %} + {% set dataset = pkg.title or pkg.name %} + {{ dataset }} + {% endfor %} + {% for group in rev.groups %} + {{ group.display_name }} + {% endfor %} + {{ rev.message }}
    \ No newline at end of file From 867ae736b1c5a40980be902e55cfd05ee5d79cfe Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:07:43 +0100 Subject: [PATCH 008/149] [#368] New template for group history --- ckan/templates/group/history.html | 12 +++++++ .../group/snippets/history_revisions.html | 12 +++++++ .../group/snippets/revisions_table.html | 31 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 ckan/templates/group/history.html create mode 100644 ckan/templates/group/snippets/history_revisions.html create mode 100644 ckan/templates/group/snippets/revisions_table.html diff --git a/ckan/templates/group/history.html b/ckan/templates/group/history.html new file mode 100644 index 00000000000..9a0c5bdf053 --- /dev/null +++ b/ckan/templates/group/history.html @@ -0,0 +1,12 @@ +{% extends "group/read_base.html" %} + +{% block subtitle %}{{ _('History') }} - {{ c.group_dict.display_name }}{% endblock %} + +{% block primary_content_inner %} +
    +

    {{ _('History') }}

    + {% block group_history_revisions %} + {% snippet "group/snippets/history_revisions.html", group_dict=c.group_dict, group_revisions=c.group_revisions %} + {% endblock %} +
    +{% endblock %} diff --git a/ckan/templates/group/snippets/history_revisions.html b/ckan/templates/group/snippets/history_revisions.html new file mode 100644 index 00000000000..babb24a121b --- /dev/null +++ b/ckan/templates/group/snippets/history_revisions.html @@ -0,0 +1,12 @@ +{% import 'macros/form.html' as form %} + +
    + + {{ form.errors(error_summary) }} + + + {% snippet 'group/snippets/revisions_table.html', group_dict=group_dict, group_revisions=group_revisions %} + + + +
    \ No newline at end of file diff --git a/ckan/templates/group/snippets/revisions_table.html b/ckan/templates/group/snippets/revisions_table.html new file mode 100644 index 00000000000..40e90b98b51 --- /dev/null +++ b/ckan/templates/group/snippets/revisions_table.html @@ -0,0 +1,31 @@ +{% import 'macros/form.html' as form %} + + + + + + + + + + + + + {% for rev in group_revisions %} + + + + + + + + {% endfor %} + +
    {{ _('Revision') }}{{ _('Timestamp') }}{{ _('Author') }}{{ _('Log Message') }}
    + {{ h.radio('selected1', rev.id, checked=(loop.first)) }} + {{ h.radio('selected2', rev.id, checked=(loop.last)) }} + + {% link_for rev.id | truncate(6), controller='revision', action='read', id=rev.id %} + + {{ h.render_datetime(rev.timestamp, with_hours=True) }} + {{ h.linked_user(rev.author) }}{{ rev.message }}
    \ No newline at end of file From 87c693879640b256b40d95af49dab568c56bd775 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:17:44 +0100 Subject: [PATCH 009/149] [#368] Fix display of action buttons for revisions which broke after I merged in the latest master with new bootstrap. --- ckan/templates/revision/read.html | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/ckan/templates/revision/read.html b/ckan/templates/revision/read.html index 4596d0d49db..e251b698122 100644 --- a/ckan/templates/revision/read.html +++ b/ckan/templates/revision/read.html @@ -17,12 +17,19 @@ action='edit', id=c.revision.id) }}" > - {% if rev.state != 'deleted' %} - - {% endif %} - {% if rev.state == 'deleted' %} - - {% endif %} +
  • + {% if rev.state != 'deleted' %} + + {% endif %} + {% if rev.state == 'deleted' %} + + {% endif %} +
  • {% endif %} {% endblock %} From c81edd2572d56b0a74c82eb76a7f97cafc3ac9c4 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 20 Feb 2013 10:35:46 +0100 Subject: [PATCH 010/149] [#368] Add `num_followers` after validating the group_dict against a schema. This makes sure that is still there and is necessary because we require a schema in controllers.group.history but there is no `num_followers` field in logic.schema.default_group_schema. --- ckan/logic/action/get.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index e5bbb97d117..3e4c9866feb 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -845,12 +845,12 @@ def _group_or_org_show(context, data_dict, is_org=False): except AttributeError: schema = group_plugin.db_to_form_schema() + if schema: + group_dict, errors = _validate(group_dict, schema, context=context) + group_dict['num_followers'] = logic.get_action('group_follower_count')( {'model': model, 'session': model.Session}, {'id': group_dict['id']}) - - if schema: - group_dict, errors = _validate(group_dict, schema, context=context) return group_dict From c7bc31565811444ef225d3716a6b43d761801b9d Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Thu, 21 Feb 2013 23:11:27 +0100 Subject: [PATCH 011/149] [#368] Add `num_followers` to group schema --- ckan/lib/navl/validators.py | 3 +++ ckan/logic/action/get.py | 7 ++++--- ckan/logic/schema.py | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ckan/lib/navl/validators.py b/ckan/lib/navl/validators.py index f72e2788617..dcfd9f50331 100644 --- a/ckan/lib/navl/validators.py +++ b/ckan/lib/navl/validators.py @@ -92,3 +92,6 @@ def convert_int(value, context): except ValueError: raise Invalid(_('Please enter an integer value')) +def read_only_validator(value, context): + + return value diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 3e4c9866feb..854f88419a9 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -845,12 +845,13 @@ def _group_or_org_show(context, data_dict, is_org=False): except AttributeError: schema = group_plugin.db_to_form_schema() - if schema: - group_dict, errors = _validate(group_dict, schema, context=context) - group_dict['num_followers'] = logic.get_action('group_follower_count')( {'model': model, 'session': model.Session}, {'id': group_dict['id']}) + + if schema: + group_dict, errors = _validate(group_dict, schema, context=context) + return group_dict diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index 53af9fd01ef..0d034bf1542 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -5,7 +5,8 @@ ignore, if_empty_same_as, not_missing, - ignore_empty + ignore_empty, + read_only_validator ) from ckan.logic.validators import (package_id_not_changed, package_id_exists, @@ -306,6 +307,7 @@ def default_group_schema(): def group_form_schema(): schema = default_group_schema() #schema['extras_validation'] = [duplicate_extras_key, ignore] + schema['num_followers'] = [ignore_missing, int_validator, read_only_validator] schema['packages'] = { "name": [not_empty, unicode], "title": [ignore_missing], From 6c6c955687994084d61d3a5fc61f525b44d87ad2 Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 1 Mar 2013 15:02:16 +0000 Subject: [PATCH 012/149] [#517] First try on a separate testing db --- .travis.yml | 16 ++++++++-------- bin/osx-postgres-mem.sh | 1 + doc/install-from-source.rst | 4 ++++ test-core.ini | 6 +++++- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index cef986ccb7f..781d1bf0826 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,21 +9,21 @@ install: - "pip install -r pip-requirements.txt --use-mirrors" - "pip install -r pip-requirements-test.txt --use-mirrors" before_script: - - psql -c 'CREATE DATABASE ckantest;' -U postgres + - psql -c 'CREATE DATABASE ckantesting;' -U postgres - psql -c 'CREATE DATABASE datastore;' -U postgres - psql -c 'CREATE USER readonlyuser;' -U postgres - python setup.py develop - paster make-config ckan development.ini --no-interactive - - sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' development.ini - - sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' development.ini - - sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckantest/' development.ini - - sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@localhost\/datastore/' development.ini - - sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@localhost\/datastore/' development.ini - - cat development.ini + # - sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' development.ini + # - sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' development.ini + # - sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckantest/' development.ini + # - sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@localhost\/datastore/' development.ini + # - sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@localhost\/datastore/' development.ini + # - cat development.ini - echo -e "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty - sudo cp ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml - sudo service jetty restart - - paster --plugin=ckan db init + - paster db init -c test-core.ini - paster datastore set-permissions postgres script: "nosetests --ckan --with-pylons=test-core.ini --nologcapture ckan ckanext" notifications: diff --git a/bin/osx-postgres-mem.sh b/bin/osx-postgres-mem.sh index 75664b7a741..5c6d9caa9ec 100755 --- a/bin/osx-postgres-mem.sh +++ b/bin/osx-postgres-mem.sh @@ -14,6 +14,7 @@ case $1 in ${PGCTL} -D ${PGDATA} start sleep 2; psql -c "CREATE DATABASE ckantest;" postgres + psql -c "CREATE DATABASE ckantesting;" postgres ;; stop) ## stop postgres diff --git a/doc/install-from-source.rst b/doc/install-from-source.rst index 2475503c1b8..8994b9364e5 100644 --- a/doc/install-from-source.rst +++ b/doc/install-from-source.rst @@ -104,6 +104,10 @@ Create the database (owned by ``ckanuser``), which we'll call ``ckantest``:: sudo -u postgres createdb -O ckanuser ckantest -E utf-8 +If you are planning to run the test then create a database for them too:: + + sudo -u postgres createdb -O ckanuser ckantesting -E utf-8 + 4. Create a CKAN config file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/test-core.ini b/test-core.ini index d4372d234c2..2a73718977c 100644 --- a/test-core.ini +++ b/test-core.ini @@ -11,8 +11,12 @@ port = 5000 use = config:development.ini debug = false -#faster_db_test_hacks = True +# Specify the database for SQLAlchemy to use: +# * Postgres is currently required for a production CKAN deployment +# * Sqlite (memory or file) can be used as a quick alternative for testing +sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckantesting #sqlalchemy.url = sqlite:/// +#sqlalchemy.url = sqlite:///%(here)s/somedb.db ckan.auth.user_create_organizations = true ckan.auth.user_create_groups = true From 20a23817d15d1c7a672aa82614dd1ad7a2909207 Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 1 Mar 2013 15:21:06 +0000 Subject: [PATCH 013/149] [#517] sed the fixes --- .travis.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index 781d1bf0826..d5a33c24f1d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,13 +13,12 @@ before_script: - psql -c 'CREATE DATABASE datastore;' -U postgres - psql -c 'CREATE USER readonlyuser;' -U postgres - python setup.py develop - - paster make-config ckan development.ini --no-interactive - # - sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' development.ini - # - sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' development.ini - # - sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckantest/' development.ini - # - sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@localhost\/datastore/' development.ini - # - sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@localhost\/datastore/' development.ini - # - cat development.ini + - sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' test-core.ini + - sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' test-core.ini + - sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckantest/' test-core.ini + - sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@localhost\/datastore/' test-core.ini + - sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@localhost\/datastore/' test-core.ini + - cat test-core.ini - echo -e "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty - sudo cp ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml - sudo service jetty restart From 66a2a3a629a43c3c46fc8084b5deb755182ed5a3 Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 1 Mar 2013 15:39:01 +0000 Subject: [PATCH 014/149] [#517] Stupid config files suck --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index d5a33c24f1d..7922f1d243b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,8 @@ before_script: - psql -c 'CREATE DATABASE datastore;' -U postgres - psql -c 'CREATE USER readonlyuser;' -U postgres - python setup.py develop + # we should really remove the development.ini requirements from test-core.ini + - paster make-config ckan development.ini --no-interactive - sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' test-core.ini - sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' test-core.ini - sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckantest/' test-core.ini From 9f128c0b7709c656acba167e27ed84fdb9971009 Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 1 Mar 2013 15:50:59 +0000 Subject: [PATCH 015/149] [#517] Make Dominik happy plus minor re addition --- test-core.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test-core.ini b/test-core.ini index 2a73718977c..d3b448ea4ce 100644 --- a/test-core.ini +++ b/test-core.ini @@ -11,12 +11,13 @@ port = 5000 use = config:development.ini debug = false +#faster_db_test_hacks = True + # Specify the database for SQLAlchemy to use: # * Postgres is currently required for a production CKAN deployment # * Sqlite (memory or file) can be used as a quick alternative for testing sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckantesting #sqlalchemy.url = sqlite:/// -#sqlalchemy.url = sqlite:///%(here)s/somedb.db ckan.auth.user_create_organizations = true ckan.auth.user_create_groups = true From 92a1f54fb9b077607c18bc18b32d1be19c94f16d Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 1 Mar 2013 16:06:24 +0000 Subject: [PATCH 016/149] [#517] remove the development.ini dependency --- .travis.yml | 2 -- test-core.ini | 15 ++++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7922f1d243b..d5a33c24f1d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,8 +13,6 @@ before_script: - psql -c 'CREATE DATABASE datastore;' -U postgres - psql -c 'CREATE USER readonlyuser;' -U postgres - python setup.py develop - # we should really remove the development.ini requirements from test-core.ini - - paster make-config ckan development.ini --no-interactive - sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' test-core.ini - sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' test-core.ini - sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckantest/' test-core.ini diff --git a/test-core.ini b/test-core.ini index d3b448ea4ce..a0e26361d76 100644 --- a/test-core.ini +++ b/test-core.ini @@ -8,7 +8,10 @@ host = 0.0.0.0 port = 5000 [app:main] -use = config:development.ini +#use = config:development.ini +use = egg:ckan +full_stack = true +cache_dir = %(here)s/data debug = false #faster_db_test_hacks = True @@ -19,6 +22,11 @@ debug = false sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckantesting #sqlalchemy.url = sqlite:/// +## Datastore +## Uncommment to set the datastore urls +ckan.datastore.write_url = postgresql://ckanuser:pass@localhost/ckantesting +ckan.datastore.read_url = postgresql://readonlyuser:pass@localhost/ckantesting + ckan.auth.user_create_organizations = true ckan.auth.user_create_groups = true ckan.auth.create_user_via_api = false @@ -87,6 +95,11 @@ ckan.activity_streams_email_notifications = True ckan.activity_list_limit = 15 +# repoze.who config +who.config_file = %(here)s/who.ini +who.log_level = warning +who.log_file = %(cache_dir)s/who_log.ini + # Logging configuration [loggers] keys = root, ckan, sqlalchemy From c714faf2380d2babcbe5003aff4171fea524d41b Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 1 Mar 2013 16:23:14 +0000 Subject: [PATCH 017/149] [#517] Grrrrrrrrrrrrrrrrrrrrrrrrrrrrr --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d5a33c24f1d..eef4744c609 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ before_script: - python setup.py develop - sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' test-core.ini - sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' test-core.ini - - sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckantest/' test-core.ini + - sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckantesting/' test-core.ini - sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@localhost\/datastore/' test-core.ini - sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@localhost\/datastore/' test-core.ini - cat test-core.ini From 9f7465ebfe44fa1c1fe342f890387e0ccd0d5745 Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 1 Mar 2013 16:43:41 +0000 Subject: [PATCH 018/149] [#517] Travis specify the config --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index eef4744c609..be218355855 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,7 +23,7 @@ before_script: - sudo cp ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml - sudo service jetty restart - paster db init -c test-core.ini - - paster datastore set-permissions postgres + - paster datastore set-permissions postgres -c test-core.ini script: "nosetests --ckan --with-pylons=test-core.ini --nologcapture ckan ckanext" notifications: irc: From 69c5f3cabc6f47992164f020631a823804d48f1d Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 1 Mar 2013 17:17:23 +0000 Subject: [PATCH 019/149] [#517] Tests need beaker secret key --- test-core.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test-core.ini b/test-core.ini index a0e26361d76..2243fad75cc 100644 --- a/test-core.ini +++ b/test-core.ini @@ -95,6 +95,8 @@ ckan.activity_streams_email_notifications = True ckan.activity_list_limit = 15 +beaker.session.key = ckan +beaker.session.secret = This_is_a_secret_or_is_it # repoze.who config who.config_file = %(here)s/who.ini who.log_level = warning From f978f8529b792f6fbb070470f7908e4f50ae3692 Mon Sep 17 00:00:00 2001 From: tobes Date: Tue, 5 Mar 2013 17:27:12 +0000 Subject: [PATCH 020/149] [#517] Rename databases to ckan_dev and ckan_test --- .travis.yml | 4 ++-- bin/osx-postgres-mem.sh | 4 ++-- ckan/config/deployment.ini_tmpl | 2 +- doc/install-from-source.rst | 6 +++--- test-core.ini | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.travis.yml b/.travis.yml index be218355855..5649567dcdd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,13 +9,13 @@ install: - "pip install -r pip-requirements.txt --use-mirrors" - "pip install -r pip-requirements-test.txt --use-mirrors" before_script: - - psql -c 'CREATE DATABASE ckantesting;' -U postgres + - psql -c 'CREATE DATABASE ckan_test;' -U postgres - psql -c 'CREATE DATABASE datastore;' -U postgres - psql -c 'CREATE USER readonlyuser;' -U postgres - python setup.py develop - sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' test-core.ini - sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' test-core.ini - - sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckantesting/' test-core.ini + - sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckan_test/' test-core.ini - sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@localhost\/datastore/' test-core.ini - sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@localhost\/datastore/' test-core.ini - cat test-core.ini diff --git a/bin/osx-postgres-mem.sh b/bin/osx-postgres-mem.sh index 5c6d9caa9ec..58e7f113824 100755 --- a/bin/osx-postgres-mem.sh +++ b/bin/osx-postgres-mem.sh @@ -13,8 +13,8 @@ case $1 in ${PGCTL} -D ${PGDATA} init ${PGCTL} -D ${PGDATA} start sleep 2; - psql -c "CREATE DATABASE ckantest;" postgres - psql -c "CREATE DATABASE ckantesting;" postgres + psql -c "CREATE DATABASE ckan_dev;" postgres + psql -c "CREATE DATABASE ckan_test;" postgres ;; stop) ## stop postgres diff --git a/ckan/config/deployment.ini_tmpl b/ckan/config/deployment.ini_tmpl index 8ab9dac4083..61e598e99e9 100644 --- a/ckan/config/deployment.ini_tmpl +++ b/ckan/config/deployment.ini_tmpl @@ -65,7 +65,7 @@ ckan.plugins = stats json_preview recline_preview # Specify the database for SQLAlchemy to use: # * Postgres is currently required for a production CKAN deployment # * Sqlite (memory or file) can be used as a quick alternative for testing -sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckantest +sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckan_dev #sqlalchemy.url = sqlite:/// #sqlalchemy.url = sqlite:///%(here)s/somedb.db diff --git a/doc/install-from-source.rst b/doc/install-from-source.rst index 8994b9364e5..88ed2363939 100644 --- a/doc/install-from-source.rst +++ b/doc/install-from-source.rst @@ -100,13 +100,13 @@ prompted:: sudo -u postgres createuser -S -D -R -P ckanuser -Create the database (owned by ``ckanuser``), which we'll call ``ckantest``:: +Create the database (owned by ``ckanuser``), which we'll call ``ckan_dev``:: - sudo -u postgres createdb -O ckanuser ckantest -E utf-8 + sudo -u postgres createdb -O ckanuser ckan_dev -E utf-8 If you are planning to run the test then create a database for them too:: - sudo -u postgres createdb -O ckanuser ckantesting -E utf-8 + sudo -u postgres createdb -O ckanuser ckan_test -E utf-8 4. Create a CKAN config file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/test-core.ini b/test-core.ini index 2243fad75cc..156715a7a8e 100644 --- a/test-core.ini +++ b/test-core.ini @@ -19,13 +19,13 @@ debug = false # Specify the database for SQLAlchemy to use: # * Postgres is currently required for a production CKAN deployment # * Sqlite (memory or file) can be used as a quick alternative for testing -sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckantesting +sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckan_test #sqlalchemy.url = sqlite:/// ## Datastore ## Uncommment to set the datastore urls -ckan.datastore.write_url = postgresql://ckanuser:pass@localhost/ckantesting -ckan.datastore.read_url = postgresql://readonlyuser:pass@localhost/ckantesting +ckan.datastore.write_url = postgresql://ckanuser:pass@localhost/ckan_test +ckan.datastore.read_url = postgresql://readonlyuser:pass@localhost/ckan_test ckan.auth.user_create_organizations = true ckan.auth.user_create_groups = true From eb921d07a18fce3a62838ad5e35c8aff394b066a Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 6 Mar 2013 12:46:27 +0100 Subject: [PATCH 021/149] Revert "[#368] Add `num_followers` to group schema" This reverts commit c7bc31565811444ef225d3716a6b43d761801b9d. --- ckan/lib/navl/validators.py | 3 --- ckan/logic/action/get.py | 7 +++---- ckan/logic/schema.py | 4 +--- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/ckan/lib/navl/validators.py b/ckan/lib/navl/validators.py index dcfd9f50331..f72e2788617 100644 --- a/ckan/lib/navl/validators.py +++ b/ckan/lib/navl/validators.py @@ -92,6 +92,3 @@ def convert_int(value, context): except ValueError: raise Invalid(_('Please enter an integer value')) -def read_only_validator(value, context): - - return value diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 854f88419a9..3e4c9866feb 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -845,13 +845,12 @@ def _group_or_org_show(context, data_dict, is_org=False): except AttributeError: schema = group_plugin.db_to_form_schema() - group_dict['num_followers'] = logic.get_action('group_follower_count')( - {'model': model, 'session': model.Session}, - {'id': group_dict['id']}) - if schema: group_dict, errors = _validate(group_dict, schema, context=context) + group_dict['num_followers'] = logic.get_action('group_follower_count')( + {'model': model, 'session': model.Session}, + {'id': group_dict['id']}) return group_dict diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index 0d034bf1542..53af9fd01ef 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -5,8 +5,7 @@ ignore, if_empty_same_as, not_missing, - ignore_empty, - read_only_validator + ignore_empty ) from ckan.logic.validators import (package_id_not_changed, package_id_exists, @@ -307,7 +306,6 @@ def default_group_schema(): def group_form_schema(): schema = default_group_schema() #schema['extras_validation'] = [duplicate_extras_key, ignore] - schema['num_followers'] = [ignore_missing, int_validator, read_only_validator] schema['packages'] = { "name": [not_empty, unicode], "title": [ignore_missing], From 92d8f7d99dd9f9543ebee5b9d318043f20eb07aa Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 6 Mar 2013 12:46:45 +0100 Subject: [PATCH 022/149] Revert "[#368] Add `num_followers` after validating the group_dict against a schema." This reverts commit c81edd2572d56b0a74c82eb76a7f97cafc3ac9c4. --- ckan/logic/action/get.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 3e4c9866feb..e5bbb97d117 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -845,12 +845,12 @@ def _group_or_org_show(context, data_dict, is_org=False): except AttributeError: schema = group_plugin.db_to_form_schema() - if schema: - group_dict, errors = _validate(group_dict, schema, context=context) - group_dict['num_followers'] = logic.get_action('group_follower_count')( {'model': model, 'session': model.Session}, {'id': group_dict['id']}) + + if schema: + group_dict, errors = _validate(group_dict, schema, context=context) return group_dict From 38b8c21634ec0a6f6928fabf2bf286f5fe91ec24 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 6 Mar 2013 13:20:02 +0100 Subject: [PATCH 023/149] [#368] Use the correct schema to validate against in controllers.group.history --- ckan/controllers/group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index a167798fc80..991684cebb4 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -671,7 +671,7 @@ def history(self, id): context = {'model': model, 'session': model.Session, 'user': c.user or c.author, - 'schema': self._form_to_db_schema()} + 'schema': self._db_to_form_schema()} data_dict = {'id': id} try: c.group_dict = self._action('group_show')(context, data_dict) From f9ad67d3cda2e19d894342bd78ef4b9b325f5613 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Thu, 7 Feb 2013 20:07:00 +0100 Subject: [PATCH 024/149] [#368] Ported revision history page to new template system --- ckan/templates/package/history.html | 12 +++++++ .../package/snippets/history_revisions.html | 12 +++++++ .../package/snippets/revisions_table.html | 31 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 ckan/templates/package/history.html create mode 100644 ckan/templates/package/snippets/history_revisions.html create mode 100644 ckan/templates/package/snippets/revisions_table.html diff --git a/ckan/templates/package/history.html b/ckan/templates/package/history.html new file mode 100644 index 00000000000..0f09674214a --- /dev/null +++ b/ckan/templates/package/history.html @@ -0,0 +1,12 @@ +{% extends "package/read_base.html" %} + +{% block subtitle %}{{ _('History') }} - {{ c.pkg_dict.title or c.pkg_dict.name }}{% endblock %} + +{% block primary_content_inner %} +
    +

    {{ _('History') }}

    + {% block package_history_revisions %} + {% snippet "package/snippets/history_revisions.html", pkg_dict=pkg, pkg_revisions=c.pkg_revisions %} + {% endblock %} +
    +{% endblock %} diff --git a/ckan/templates/package/snippets/history_revisions.html b/ckan/templates/package/snippets/history_revisions.html new file mode 100644 index 00000000000..f75aafc5d3f --- /dev/null +++ b/ckan/templates/package/snippets/history_revisions.html @@ -0,0 +1,12 @@ +{% import 'macros/form.html' as form %} + +
    + + {{ form.errors(error_summary) }} + + + {% snippet 'package/snippets/revisions_table.html', pkg_dict=pkg_dict, pkg_revisions=pkg_revisions %} + + + +
    \ No newline at end of file diff --git a/ckan/templates/package/snippets/revisions_table.html b/ckan/templates/package/snippets/revisions_table.html new file mode 100644 index 00000000000..17e056e8e44 --- /dev/null +++ b/ckan/templates/package/snippets/revisions_table.html @@ -0,0 +1,31 @@ +{% import 'macros/form.html' as form %} + + + + + + + + + + + + + {% for rev in pkg_revisions %} + + + + + + + + {% endfor %} + +
    {{ _('Revision') }}{{ _('Timestamp') }}{{ _('Author') }}{{ _('Log Message') }}
    + {{ h.radio('selected1', rev.id, checked=(loop.first)) }} + {{ h.radio('selected2', rev.id, checked=(loop.last)) }} + + {{rev.id[:4]}}… + + {{ h.render_datetime(rev.timestamp, with_hours=True) }} + {{ h.linked_user(rev.author) }}{{ rev.message }}
    \ No newline at end of file From fdceaa0b376722429a38e2fdd1d079a12651576d Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:04:14 +0100 Subject: [PATCH 025/149] [#368] Refactor truncate in revisions table --- ckan/templates/package/snippets/revisions_table.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/templates/package/snippets/revisions_table.html b/ckan/templates/package/snippets/revisions_table.html index 17e056e8e44..d98d0cfb34f 100644 --- a/ckan/templates/package/snippets/revisions_table.html +++ b/ckan/templates/package/snippets/revisions_table.html @@ -18,7 +18,7 @@ {{ h.radio('selected2', rev.id, checked=(loop.last)) }} - {{rev.id[:4]}}… + {% link_for rev.id | truncate(6), controller='revision', action='read', id=rev.id %} {{ h.render_datetime(rev.timestamp, with_hours=True) }} From 13b199d54859ded45eee11d3e73b74a569e0b793 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:05:09 +0100 Subject: [PATCH 026/149] [#368] Revisions table should not be condensed which would otherwise lead to display errors with in radio buttons --- ckan/templates/package/snippets/revisions_table.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/templates/package/snippets/revisions_table.html b/ckan/templates/package/snippets/revisions_table.html index d98d0cfb34f..e8fc3d40a3e 100644 --- a/ckan/templates/package/snippets/revisions_table.html +++ b/ckan/templates/package/snippets/revisions_table.html @@ -1,6 +1,6 @@ {% import 'macros/form.html' as form %} - +
    From 794c6503d4fa7297926f04024eb7e355abdeaf63 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:05:36 +0100 Subject: [PATCH 027/149] [#368] New templates for revisions --- ckan/templates/revision/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 ckan/templates/revision/__init__.py diff --git a/ckan/templates/revision/__init__.py b/ckan/templates/revision/__init__.py new file mode 100644 index 00000000000..b646540d6be --- /dev/null +++ b/ckan/templates/revision/__init__.py @@ -0,0 +1 @@ +# empty file needed for pylons to find templates in this directory From 8ba1d02847ccd0e574da569ab913c1f7f4ed4c6d Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:06:12 +0100 Subject: [PATCH 028/149] [#368] New template for revision diff --- ckan/templates/revision/diff.html | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 ckan/templates/revision/diff.html diff --git a/ckan/templates/revision/diff.html b/ckan/templates/revision/diff.html new file mode 100644 index 00000000000..98df4af304d --- /dev/null +++ b/ckan/templates/revision/diff.html @@ -0,0 +1,60 @@ +{% extends "page.html" %} + +{% set pkg = c.pkg %} +{% set group = c.group %} + +{% block subtitle %}{{ _('Differences')}}{% endblock %} + +{% block breadcrumb_content %} + {% if c.diff_entity == 'package' %} + {% set dataset = pkg.title or pkg.name %} +
  • {% link_for _('Datasets'), controller='package', action='search', highlight_actions = 'new index' %}
  • +
  • {% link_for dataset, controller='package', action='read', id=pkg.name %}
  • +
  • {{ _('Revision Differences') }}
  • + {% elif c.diff_entity == 'group' %} + {% set group = group.display_name or group.name %} +
  • {% link_for _('Groups'), controller='group', action='index' %}
  • +
  • {% link_for group, controller='group', action='read', id=group.name %}
  • +
  • {{ _('Revision Differences') }}
  • + {% endif %} +{% endblock %} + +{% block primary %} +
    +
    +

    {{ _('Revision Differences') }} - + {% if c.diff_entity == 'package' %} + {% link_for pkg.title, controller='package', action='read', id=pkg.name %} + {% elif c.diff_entity == 'group' %} + {% link_for group.display_name, controller='group', action='read', id=group.name %} + {% endif %} +

    + +

    + From: {% link_for c.revision_from.id, controller='revision', action='read', id=c.revision_from.id %} - + {{ h.render_datetime(c.revision_from.timestamp, with_hours=True) }} +

    +

    + To: {% link_for c.revision_to.id, controller='revision', action='read', id=c.revision_to.id %} - + {{ h.render_datetime(c.revision_to.timestamp, with_hours=True) }} +

    + + {% if c.diff %} +
    + + + + + {% for field, diff in c.diff %} + + + + + {% endfor %} +
    {{ _('Field') }}{{ _('Difference') }}
    {{ field }}
    {{ diff }}
    + {% else %} +

    {{ _('No Differences') }}

    + {% endif %} + + +{% endblock %} From 0ca308aa78eda2de7b3312c5733c1740f2776e3c Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:06:38 +0100 Subject: [PATCH 029/149] [#368] New template for revision read --- ckan/templates/revision/read.html | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 ckan/templates/revision/read.html diff --git a/ckan/templates/revision/read.html b/ckan/templates/revision/read.html new file mode 100644 index 00000000000..4596d0d49db --- /dev/null +++ b/ckan/templates/revision/read.html @@ -0,0 +1,90 @@ +{% extends "page.html" %} + +{% set rev = c.revision %} + +{% block subtitle %}{{ _('Revision') }} {{ rev.id }}{% endblock %} + +{% block breadcrumb_content %} +
  • {% link_for _('Revisions'), controller='revision', action='index' %}
  • +
  • {{ rev.id |truncate(35) }}
  • +{% endblock %} + +{% block actions_content %} + {% if c.revision_change_state_allowed %} +
    + {% if rev.state != 'deleted' %} + + {% endif %} + {% if rev.state == 'deleted' %} + + {% endif %} +
    + {% endif %} +{% endblock %} + +{% block primary %} +
    +
    +

    {{ _('Revision') }}: {{ rev.id }}

    + +
    +
    + {% if rev.state != 'active' %} +

    + {{ rev.state }} +

    + {% endif %} + +

    + {{ _('Author') }}: {{ h.linked_user(rev.author) }} +

    +

    + {{ _('Timestamp') }}: {{ h.render_datetime(rev.timestamp, with_hours=True) }} +

    +

    + {{ _('Log Message') }}: +

    +

    + {{ rev.message }} +

    +
    + +
    +

    {{ _('Changes') }}

    +

    {{ _('Datasets') }}

    +
      + {% for pkg in c.packages %} +
    • + {{ h.link_to(pkg.name, h.url_for(controller='package', action='read', id=pkg.name)) }} +
    • + {% endfor %} +
    + +

    {{ _('Datasets\' Tags') }}

    +
      + {% for pkgtag in c.pkgtags %} +
    • + Dataset - {{ h.link_to(pkgtag.package.name, h.url_for(controller='package', action='read', id=pkgtag.package.name)) }}, + Tag - {{ h.link_to(pkgtag.tag.name, h.url_for(controller='tag', action='read', id=pkgtag.tag.name)) }} +
    • + {% endfor %} +
    + +

    {{ _('Groups') }}

    +
      + {% for group in c.groups %} +
    • + {{ h.link_to(group.name, h.url_for(controller='group', action='read', id=group.name)) }} +
    • + {% endfor %} +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file From f0fd58f9197192936b3cfb11d1d0bae023eea87b Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:07:14 +0100 Subject: [PATCH 030/149] [#368] New template for revision list --- ckan/templates/revision/list.html | 24 +++++++++++++ .../revision/snippets/revisions_list.html | 34 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 ckan/templates/revision/list.html create mode 100644 ckan/templates/revision/snippets/revisions_list.html diff --git a/ckan/templates/revision/list.html b/ckan/templates/revision/list.html new file mode 100644 index 00000000000..bf5ac54d32e --- /dev/null +++ b/ckan/templates/revision/list.html @@ -0,0 +1,24 @@ +{% extends "page.html" %} + +{% block subtitle %}{{ _('Revision History') }}{% endblock %} + +{% block breadcrumb_content %} +
  • {{ _('Revisions') }}
  • +{% endblock %} + +{% block primary %} +
    +
    +

    {{ _('Revision History') }}

    + + {{ c.page.pager() }} + + {% block revisions_list %} + {% snippet "revision/snippets/revisions_list.html", revisions=c.page.items %} + {% endblock %} + + {{ c.page.pager() }} + +
    +
    +{% endblock %} diff --git a/ckan/templates/revision/snippets/revisions_list.html b/ckan/templates/revision/snippets/revisions_list.html new file mode 100644 index 00000000000..dd815ea4ce6 --- /dev/null +++ b/ckan/templates/revision/snippets/revisions_list.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + {% for rev in revisions %} + + + + + + + + {% endfor %} + +
    {{ _('Revision') }}{{ _('Timestamp') }}{{ _('Author') }}{{ _('Entity') }}{{ _('Log Message') }}
    + {{rev.id | truncate(6)}} + + {{ h.render_datetime(rev.timestamp, with_hours=True) }} + {{ h.linked_user(rev.author) }} + {% for pkg in rev.packages %} + {% set dataset = pkg.title or pkg.name %} + {{ dataset }} + {% endfor %} + {% for group in rev.groups %} + {{ group.display_name }} + {% endfor %} + {{ rev.message }}
    \ No newline at end of file From dd7b29cd866a278479b265c941304b17d6d4b6cd Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:07:43 +0100 Subject: [PATCH 031/149] [#368] New template for group history --- ckan/templates/group/history.html | 12 +++++++ .../group/snippets/history_revisions.html | 12 +++++++ .../group/snippets/revisions_table.html | 31 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 ckan/templates/group/history.html create mode 100644 ckan/templates/group/snippets/history_revisions.html create mode 100644 ckan/templates/group/snippets/revisions_table.html diff --git a/ckan/templates/group/history.html b/ckan/templates/group/history.html new file mode 100644 index 00000000000..9a0c5bdf053 --- /dev/null +++ b/ckan/templates/group/history.html @@ -0,0 +1,12 @@ +{% extends "group/read_base.html" %} + +{% block subtitle %}{{ _('History') }} - {{ c.group_dict.display_name }}{% endblock %} + +{% block primary_content_inner %} +
    +

    {{ _('History') }}

    + {% block group_history_revisions %} + {% snippet "group/snippets/history_revisions.html", group_dict=c.group_dict, group_revisions=c.group_revisions %} + {% endblock %} +
    +{% endblock %} diff --git a/ckan/templates/group/snippets/history_revisions.html b/ckan/templates/group/snippets/history_revisions.html new file mode 100644 index 00000000000..babb24a121b --- /dev/null +++ b/ckan/templates/group/snippets/history_revisions.html @@ -0,0 +1,12 @@ +{% import 'macros/form.html' as form %} + +
    + + {{ form.errors(error_summary) }} + + + {% snippet 'group/snippets/revisions_table.html', group_dict=group_dict, group_revisions=group_revisions %} + + + +
    \ No newline at end of file diff --git a/ckan/templates/group/snippets/revisions_table.html b/ckan/templates/group/snippets/revisions_table.html new file mode 100644 index 00000000000..40e90b98b51 --- /dev/null +++ b/ckan/templates/group/snippets/revisions_table.html @@ -0,0 +1,31 @@ +{% import 'macros/form.html' as form %} + + + + + + + + + + + + + {% for rev in group_revisions %} + + + + + + + + {% endfor %} + +
    {{ _('Revision') }}{{ _('Timestamp') }}{{ _('Author') }}{{ _('Log Message') }}
    + {{ h.radio('selected1', rev.id, checked=(loop.first)) }} + {{ h.radio('selected2', rev.id, checked=(loop.last)) }} + + {% link_for rev.id | truncate(6), controller='revision', action='read', id=rev.id %} + + {{ h.render_datetime(rev.timestamp, with_hours=True) }} + {{ h.linked_user(rev.author) }}{{ rev.message }}
    \ No newline at end of file From b6fafee46d1cf33daba1c137c4a2411a9432dac5 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 19 Feb 2013 21:17:44 +0100 Subject: [PATCH 032/149] [#368] Fix display of action buttons for revisions which broke after I merged in the latest master with new bootstrap. --- ckan/templates/revision/read.html | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/ckan/templates/revision/read.html b/ckan/templates/revision/read.html index 4596d0d49db..e251b698122 100644 --- a/ckan/templates/revision/read.html +++ b/ckan/templates/revision/read.html @@ -17,12 +17,19 @@ action='edit', id=c.revision.id) }}" > - {% if rev.state != 'deleted' %} - - {% endif %} - {% if rev.state == 'deleted' %} - - {% endif %} +
  • + {% if rev.state != 'deleted' %} + + {% endif %} + {% if rev.state == 'deleted' %} + + {% endif %} +
  • {% endif %} {% endblock %} From b5f69fc2563c690ef1bda65a389e98fa61faf50f Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 6 Mar 2013 13:20:02 +0100 Subject: [PATCH 033/149] [#368] Use the correct schema to validate against in controllers.group.history --- ckan/controllers/group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index a167798fc80..991684cebb4 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -671,7 +671,7 @@ def history(self, id): context = {'model': model, 'session': model.Session, 'user': c.user or c.author, - 'schema': self._form_to_db_schema()} + 'schema': self._db_to_form_schema()} data_dict = {'id': id} try: c.group_dict = self._action('group_show')(context, data_dict) From 2462f8294a4b5c89daff09df6da3e5e3a83e61d2 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 6 Mar 2013 17:57:03 +0100 Subject: [PATCH 034/149] [#368] Groups do not support reading them as of a certain date. So let's not link to it in the revisions table. --- ckan/templates/group/snippets/revisions_table.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/templates/group/snippets/revisions_table.html b/ckan/templates/group/snippets/revisions_table.html index 40e90b98b51..5b7f2eeec66 100644 --- a/ckan/templates/group/snippets/revisions_table.html +++ b/ckan/templates/group/snippets/revisions_table.html @@ -21,7 +21,7 @@ {% link_for rev.id | truncate(6), controller='revision', action='read', id=rev.id %} - {{ h.render_datetime(rev.timestamp, with_hours=True) }} + {{ h.render_datetime(rev.timestamp, with_hours=True) }} {{ h.linked_user(rev.author) }} {{ rev.message }} From 6d2d4997739a691da9240ceba55e43e10c49d449 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 6 Mar 2013 18:34:07 +0100 Subject: [PATCH 035/149] [#368] Show sidebar on revisions pages --- ckan/templates/revision/diff.html | 70 ++++++++-------- ckan/templates/revision/list.html | 21 ++--- ckan/templates/revision/read.html | 108 ++++++++++++------------- ckan/templates/revision/read_base.html | 19 +++++ 4 files changed, 112 insertions(+), 106 deletions(-) create mode 100644 ckan/templates/revision/read_base.html diff --git a/ckan/templates/revision/diff.html b/ckan/templates/revision/diff.html index 98df4af304d..6909da4286b 100644 --- a/ckan/templates/revision/diff.html +++ b/ckan/templates/revision/diff.html @@ -1,4 +1,4 @@ -{% extends "page.html" %} +{% extends "revision/read_base.html" %} {% set pkg = c.pkg %} {% set group = c.group %} @@ -19,42 +19,38 @@ {% endif %} {% endblock %} -{% block primary %} -
    -
    -

    {{ _('Revision Differences') }} - - {% if c.diff_entity == 'package' %} - {% link_for pkg.title, controller='package', action='read', id=pkg.name %} - {% elif c.diff_entity == 'group' %} - {% link_for group.display_name, controller='group', action='read', id=group.name %} - {% endif %} -

    +{% block primary_content_inner %} +

    {{ _('Revision Differences') }} - + {% if c.diff_entity == 'package' %} + {% link_for pkg.title, controller='package', action='read', id=pkg.name %} + {% elif c.diff_entity == 'group' %} + {% link_for group.display_name, controller='group', action='read', id=group.name %} + {% endif %} +

    -

    - From: {% link_for c.revision_from.id, controller='revision', action='read', id=c.revision_from.id %} - - {{ h.render_datetime(c.revision_from.timestamp, with_hours=True) }} -

    -

    - To: {% link_for c.revision_to.id, controller='revision', action='read', id=c.revision_to.id %} - - {{ h.render_datetime(c.revision_to.timestamp, with_hours=True) }} -

    +

    + From: {% link_for c.revision_from.id, controller='revision', action='read', id=c.revision_from.id %} - + {{ h.render_datetime(c.revision_from.timestamp, with_hours=True) }} +

    +

    + To: {% link_for c.revision_to.id, controller='revision', action='read', id=c.revision_to.id %} - + {{ h.render_datetime(c.revision_to.timestamp, with_hours=True) }} +

    - {% if c.diff %} - - - - - - {% for field, diff in c.diff %} - - - - - {% endfor %} -
    {{ _('Field') }}{{ _('Difference') }}
    {{ field }}
    {{ diff }}
    - {% else %} -

    {{ _('No Differences') }}

    - {% endif %} -
    -
    + {% if c.diff %} + + + + + + {% for field, diff in c.diff %} + + + + + {% endfor %} +
    {{ _('Field') }}{{ _('Difference') }}
    {{ field }}
    {{ diff }}
    + {% else %} +

    {{ _('No Differences') }}

    + {% endif %} {% endblock %} diff --git a/ckan/templates/revision/list.html b/ckan/templates/revision/list.html index bf5ac54d32e..84200a0822a 100644 --- a/ckan/templates/revision/list.html +++ b/ckan/templates/revision/list.html @@ -1,4 +1,4 @@ -{% extends "page.html" %} +{% extends "revision/read_base.html" %} {% block subtitle %}{{ _('Revision History') }}{% endblock %} @@ -6,19 +6,14 @@
  • {{ _('Revisions') }}
  • {% endblock %} -{% block primary %} -
    -
    -

    {{ _('Revision History') }}

    +{% block primary_content_inner %} +

    {{ _('Revision History') }}

    - {{ c.page.pager() }} + {{ c.page.pager() }} - {% block revisions_list %} - {% snippet "revision/snippets/revisions_list.html", revisions=c.page.items %} - {% endblock %} + {% block revisions_list %} + {% snippet "revision/snippets/revisions_list.html", revisions=c.page.items %} + {% endblock %} - {{ c.page.pager() }} - -
    -
    + {{ c.page.pager() }} {% endblock %} diff --git a/ckan/templates/revision/read.html b/ckan/templates/revision/read.html index e251b698122..defdeecf57a 100644 --- a/ckan/templates/revision/read.html +++ b/ckan/templates/revision/read.html @@ -1,4 +1,4 @@ -{% extends "page.html" %} +{% extends "revision/read_base.html" %} {% set rev = c.revision %} @@ -34,64 +34,60 @@ {% endif %} {% endblock %} -{% block primary %} -
    -
    -

    {{ _('Revision') }}: {{ rev.id }}

    +{% block primary_content_inner %} +

    {{ _('Revision') }}: {{ rev.id }}

    -
    -
    - {% if rev.state != 'active' %} -

    - {{ rev.state }} -

    - {% endif %} +
    +
    + {% if rev.state != 'active' %} +

    + {{ rev.state }} +

    + {% endif %} -

    - {{ _('Author') }}: {{ h.linked_user(rev.author) }} -

    -

    - {{ _('Timestamp') }}: {{ h.render_datetime(rev.timestamp, with_hours=True) }} -

    -

    - {{ _('Log Message') }}: -

    -

    - {{ rev.message }} -

    -
    +

    + {{ _('Author') }}: {{ h.linked_user(rev.author) }} +

    +

    + {{ _('Timestamp') }}: {{ h.render_datetime(rev.timestamp, with_hours=True) }} +

    +

    + {{ _('Log Message') }}: +

    +

    + {{ rev.message }} +

    +
    -
    -

    {{ _('Changes') }}

    -

    {{ _('Datasets') }}

    -
      - {% for pkg in c.packages %} -
    • - {{ h.link_to(pkg.name, h.url_for(controller='package', action='read', id=pkg.name)) }} -
    • - {% endfor %} -
    +
    +

    {{ _('Changes') }}

    +

    {{ _('Datasets') }}

    +
      + {% for pkg in c.packages %} +
    • + {{ h.link_to(pkg.name, h.url_for(controller='package', action='read', id=pkg.name)) }} +
    • + {% endfor %} +
    -

    {{ _('Datasets\' Tags') }}

    -
      - {% for pkgtag in c.pkgtags %} -
    • - Dataset - {{ h.link_to(pkgtag.package.name, h.url_for(controller='package', action='read', id=pkgtag.package.name)) }}, - Tag - {{ h.link_to(pkgtag.tag.name, h.url_for(controller='tag', action='read', id=pkgtag.tag.name)) }} -
    • - {% endfor %} -
    +

    {{ _('Datasets\' Tags') }}

    +
      + {% for pkgtag in c.pkgtags %} +
    • + Dataset - {{ h.link_to(pkgtag.package.name, h.url_for(controller='package', action='read', id=pkgtag.package.name)) }}, + Tag - {{ h.link_to(pkgtag.tag.name, h.url_for(controller='tag', action='read', id=pkgtag.tag.name)) }} +
    • + {% endfor %} +
    -

    {{ _('Groups') }}

    -
      - {% for group in c.groups %} -
    • - {{ h.link_to(group.name, h.url_for(controller='group', action='read', id=group.name)) }} -
    • - {% endfor %} -
    -
    -
    -
    -
    +

    {{ _('Groups') }}

    +
      + {% for group in c.groups %} +
    • + {{ h.link_to(group.name, h.url_for(controller='group', action='read', id=group.name)) }} +
    • + {% endfor %} +
    + + {% endblock %} \ No newline at end of file diff --git a/ckan/templates/revision/read_base.html b/ckan/templates/revision/read_base.html new file mode 100644 index 00000000000..880e4323264 --- /dev/null +++ b/ckan/templates/revision/read_base.html @@ -0,0 +1,19 @@ +{% extends "page.html" %} + +{% block secondary_content %} + + {% block secondary_help_content %}{% endblock %} + + {% block package_social %} + {% snippet "snippets/social.html" %} + {% endblock %} + +{% endblock %} + +{% block primary_content %} +
    +
    + {% block primary_content_inner %}{% endblock %} +
    +
    +{% endblock %} \ No newline at end of file From e9a17f61dfb37c563be41585cd227f59be7ec971 Mon Sep 17 00:00:00 2001 From: tobes Date: Thu, 7 Mar 2013 09:51:47 +0000 Subject: [PATCH 036/149] [#517] Remove unwanted comment --- test-core.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/test-core.ini b/test-core.ini index 156715a7a8e..dad48a1b9d3 100644 --- a/test-core.ini +++ b/test-core.ini @@ -23,7 +23,6 @@ sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckan_test #sqlalchemy.url = sqlite:/// ## Datastore -## Uncommment to set the datastore urls ckan.datastore.write_url = postgresql://ckanuser:pass@localhost/ckan_test ckan.datastore.read_url = postgresql://readonlyuser:pass@localhost/ckan_test From e430923870516bfabd53f11f997ad81254136d32 Mon Sep 17 00:00:00 2001 From: tobes Date: Thu, 7 Mar 2013 09:57:14 +0000 Subject: [PATCH 037/149] [#517] Remove another unwanted comment --- test-core.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/test-core.ini b/test-core.ini index dad48a1b9d3..be9aa55c07a 100644 --- a/test-core.ini +++ b/test-core.ini @@ -8,7 +8,6 @@ host = 0.0.0.0 port = 5000 [app:main] -#use = config:development.ini use = egg:ckan full_stack = true cache_dir = %(here)s/data From ab8024525053ea665dad26158f1fe3dfcc3dd7ba Mon Sep 17 00:00:00 2001 From: tobes Date: Thu, 7 Mar 2013 10:02:39 +0000 Subject: [PATCH 038/149] [#517] Update testing doc --- doc/install-from-source.rst | 3 --- doc/test.rst | 22 +++++++--------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/doc/install-from-source.rst b/doc/install-from-source.rst index 88ed2363939..17b8584cb94 100644 --- a/doc/install-from-source.rst +++ b/doc/install-from-source.rst @@ -104,9 +104,6 @@ Create the database (owned by ``ckanuser``), which we'll call ``ckan_dev``:: sudo -u postgres createdb -O ckanuser ckan_dev -E utf-8 -If you are planning to run the test then create a database for them too:: - - sudo -u postgres createdb -O ckanuser ckan_test -E utf-8 4. Create a CKAN config file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/test.rst b/doc/test.rst index 9510031d036..088f36d04b4 100644 --- a/doc/test.rst +++ b/doc/test.rst @@ -58,8 +58,12 @@ Or to run the CKAN tests and the core extensions tests together:: Testing with PostgreSQL ----------------------- -First, make sure you have specified a PostgreSQL database with the -``sqlalchemy.url`` parameter in your ``development.ini`` file. +Starting in CKAN 2.1 tests are run in a separate postgres database by default. You should create the test database as follows.:: + + sudo -u postgres createdb -O ckanuser ckan_test -E utf-8 + +This database connection is specified in the ``test-core.ini`` file by the +``sqlalchemy.url`` parameter. CKAN's default nose configuration file (``test.ini``) specifies SQLite as the database library (it also sets ``faster_db_test_hacks``). To run the tests more @@ -92,24 +96,12 @@ With the ``--ckan-migration`` option the tests will run using a database that has been created by running the migration scripts in ``ckan/migration``, which is how the database is created and upgraded in production. -.. caution :: - - Ordinarily, you should set ``development.ini`` to specify a PostgreSQL - database so these also get used when running ``test-core.ini``, since - ``test-core.ini`` inherits from ``development.ini``. If you were to change - the ``sqlalchemy.url`` option in your ``development.ini`` file to use - SQLite, the command above would actually test SQLite rather than - PostgreSQL, so always check the setting in ``development.ini`` to ensure - you are running the full tests. - .. warning :: A common error when wanting to run tests against a particular database is to change ``sqlalchemy.url`` in ``test.ini`` or ``test-core.ini``. The problem is that these are versioned files and people have checked in these by - mistake, creating problems for other developers and the CKAN buildbot. This - is easily avoided by only changing ``sqlalchemy.url`` in your local - ``development.ini`` and testing ``--with-pylons=test-core.ini``. + mistake, creating problems for other developers. Common error messages --------------------- From c337be0ccbb567c5eaca7dd765ff872beb1971f0 Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 15 Mar 2013 11:33:08 +0000 Subject: [PATCH 039/149] [#517] Debug patch --- bin/travis-build | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bin/travis-build b/bin/travis-build index a1d09704b95..97c4a8b0a3f 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -20,10 +20,10 @@ sudo sed -i -e 's/ident/trust/g' /etc/postgresql/$PGVERSION/main/pg_hba.conf sudo service postgresql reload -pip install -r pip-requirements.txt --use-mirrors -pip install -r pip-requirements-test.txt --use-mirrors +#pip install -r pip-requirements.txt --use-mirrors +#pip install -r pip-requirements-test.txt --use-mirrors -psql -c 'CREATE DATABASE ckantest;' -U postgres +psql -c 'CREATE DATABASE ckan_test;' -U postgres psql -c 'CREATE DATABASE datastore;' -U postgres python setup.py develop @@ -31,7 +31,7 @@ python setup.py develop # Configure CKAN's configuration file sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' test-core.ini sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' test-core.ini -sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckantest/' test-core.ini +sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckan_test/' test-core.ini sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@localhost\/datastore/' test-core.ini sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@localhost\/datastore/' test-core.ini @@ -41,6 +41,8 @@ echo "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo cp ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml sudo service jetty restart +cat test-core.ini + paster db init -c test-core.ini # If Postgres >= 9.0, we don't need to use datastore's legacy mode. From d010620fa1b8d0f3be4053784902ec14ce7b94cf Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 15 Mar 2013 11:49:21 +0000 Subject: [PATCH 040/149] [#517] Update correct config --- bin/travis-build | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/travis-build b/bin/travis-build index 97c4a8b0a3f..1e14f9b1fd5 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -49,7 +49,7 @@ paster db init -c test-core.ini if [ $PGVERSION != '8.4' ] then psql -c 'CREATE USER readonlyuser;' -U postgres - sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@\/datastore/' development.ini + sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@\/datastore/' test-core.ini paster datastore set-permissions postgres fi From 671dc0fd865a49f6fd4c96d3af568de51379d14e Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 15 Mar 2013 11:50:23 +0000 Subject: [PATCH 041/149] [#517] Use correct config for paster --- bin/travis-build | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/travis-build b/bin/travis-build index 1e14f9b1fd5..8cba0eb68bc 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -50,7 +50,7 @@ if [ $PGVERSION != '8.4' ] then psql -c 'CREATE USER readonlyuser;' -U postgres sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@\/datastore/' test-core.ini - paster datastore set-permissions postgres + paster datastore set-permissions postgres -c test-core.ini fi From 0863316850bc6adbb1a3832e07ea25d2e9b4bcc4 Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 15 Mar 2013 12:10:18 +0000 Subject: [PATCH 042/149] [#517] Does this work? --- bin/travis-build | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bin/travis-build b/bin/travis-build index 8cba0eb68bc..ee3f8b78b9d 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -53,6 +53,8 @@ then paster datastore set-permissions postgres -c test-core.ini fi +cp test-core.ini development.ini + # And finally, run the tests nosetests --ckan --with-pylons=test-core.ini --nologcapture ckan ckanext From 4e537d4a5eb7475fd190af563fb99af33fd6bbfd Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 15 Mar 2013 12:32:31 +0000 Subject: [PATCH 043/149] [#517] Actually import the requirements --- bin/travis-build | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/bin/travis-build b/bin/travis-build index ee3f8b78b9d..fe1f21ddc17 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -20,8 +20,8 @@ sudo sed -i -e 's/ident/trust/g' /etc/postgresql/$PGVERSION/main/pg_hba.conf sudo service postgresql reload -#pip install -r pip-requirements.txt --use-mirrors -#pip install -r pip-requirements-test.txt --use-mirrors +pip install -r pip-requirements.txt --use-mirrors +pip install -r pip-requirements-test.txt --use-mirrors psql -c 'CREATE DATABASE ckan_test;' -U postgres psql -c 'CREATE DATABASE datastore;' -U postgres @@ -53,8 +53,5 @@ then paster datastore set-permissions postgres -c test-core.ini fi -cp test-core.ini development.ini - - # And finally, run the tests nosetests --ckan --with-pylons=test-core.ini --nologcapture ckan ckanext From 13e7c0674de03aba5ce341b2183898848a4e5946 Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 15 Mar 2013 13:29:42 +0000 Subject: [PATCH 044/149] [#517] Hopefully this fixes 8.4 tests --- bin/travis-build | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bin/travis-build b/bin/travis-build index fe1f21ddc17..ef1f176115a 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -31,9 +31,8 @@ python setup.py develop # Configure CKAN's configuration file sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' test-core.ini sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' test-core.ini -sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@localhost\/ckan_test/' test-core.ini -sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@localhost\/datastore/' test-core.ini -sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@localhost\/datastore/' test-core.ini +sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@\/ckan_test/' test-core.ini +sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@\/datastore/' test-core.ini # Configure Solr echo "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty From c8be95d97b4d39b90c507d431fb7b3c4233c74fa Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 15 Mar 2013 14:33:44 +0000 Subject: [PATCH 045/149] [#517] Remove datastore.read_url for 8.4 --- bin/travis-build | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bin/travis-build b/bin/travis-build index ef1f176115a..2c36f8dc797 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -32,7 +32,7 @@ python setup.py develop sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' test-core.ini sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' test-core.ini sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@\/ckan_test/' test-core.ini -sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@\/datastore/' test-core.ini +sed -i -e 's/^datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@\/datastore/' test-core.ini # Configure Solr echo "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty @@ -52,5 +52,10 @@ then paster datastore set-permissions postgres -c test-core.ini fi +if [ $PGVERSION == '8.4' ] +then + sed -i -e 's/.*datastore.read_url.*//' test-core.ini +fi + # And finally, run the tests nosetests --ckan --with-pylons=test-core.ini --nologcapture ckan ckanext From 00d98784b1e697321cf85186a9fc7b62b4ea688d Mon Sep 17 00:00:00 2001 From: tobes Date: Fri, 15 Mar 2013 16:03:03 +0000 Subject: [PATCH 046/149] [#517] This may be a better fix I hate shell scripts --- bin/travis-build | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/travis-build b/bin/travis-build index 2c36f8dc797..4e873ae3d1a 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -32,7 +32,7 @@ python setup.py develop sed -i -e 's/.*solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' test-core.ini sed -i -e 's/.*ckan\.site_id.*/ckan.site_id = travis_ci/' test-core.ini sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/postgres@\/ckan_test/' test-core.ini -sed -i -e 's/^datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@\/datastore/' test-core.ini +sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/postgres@\/datastore/' test-core.ini # Configure Solr echo "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty @@ -40,8 +40,6 @@ echo "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo cp ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml sudo service jetty restart -cat test-core.ini - paster db init -c test-core.ini # If Postgres >= 9.0, we don't need to use datastore's legacy mode. @@ -57,5 +55,7 @@ then sed -i -e 's/.*datastore.read_url.*//' test-core.ini fi +cat test-core.ini + # And finally, run the tests nosetests --ckan --with-pylons=test-core.ini --nologcapture ckan ckanext From 5a77d67ec5bac917578373b706957072086f5bba Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Sat, 16 Mar 2013 19:34:27 +0100 Subject: [PATCH 047/149] [#368] Return utf-8 encoded string from __repr__ so that pprint which calls repr() does not choke on unicode characters in tags --- ckan/model/tag.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ckan/model/tag.py b/ckan/model/tag.py index d4c16a8fc12..e7a79157ce6 100644 --- a/ckan/model/tag.py +++ b/ckan/model/tag.py @@ -230,7 +230,8 @@ def __init__(self, package=None, tag=None, state=None, **kwargs): setattr(self, k, v) def __repr__(self): - return '' % (self.package.name, self.tag.name) + s = u'' % (self.package.name, self.tag.name) + return s.encode('utf8') def activity_stream_detail(self, activity_id, activity_type): if activity_type == 'new': From 344262c0681f4fc0988a01da322af81c819e134d Mon Sep 17 00:00:00 2001 From: tobes Date: Tue, 19 Mar 2013 17:53:58 +0000 Subject: [PATCH 048/149] [#517] Bash? --- bin/travis-build | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/travis-build b/bin/travis-build index 4e873ae3d1a..4b373e62a1c 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -50,7 +50,7 @@ then paster datastore set-permissions postgres -c test-core.ini fi -if [ $PGVERSION == '8.4' ] +if [ $PGVERSION = '8.4' ] then sed -i -e 's/.*datastore.read_url.*//' test-core.ini fi From 3d41beb3435951a23b17c2e32e6f0dbd3a0183c5 Mon Sep 17 00:00:00 2001 From: joetsoi Date: Wed, 20 Mar 2013 08:59:23 +0000 Subject: [PATCH 049/149] [#618] create symlink from docs/CONTRIBUTING.rst to CONTRIBUTING.txt --- doc/CONTRIBUTING.rst | 1 + 1 file changed, 1 insertion(+) create mode 120000 doc/CONTRIBUTING.rst diff --git a/doc/CONTRIBUTING.rst b/doc/CONTRIBUTING.rst new file mode 120000 index 00000000000..798f2aa2fc5 --- /dev/null +++ b/doc/CONTRIBUTING.rst @@ -0,0 +1 @@ +../CONTRIBUTING.rst \ No newline at end of file From 9f0ff9d64d364440105e00e0ebdf348054b6ef54 Mon Sep 17 00:00:00 2001 From: tobes Date: Wed, 20 Mar 2013 17:24:29 +0000 Subject: [PATCH 050/149] [#606] Fix no resource error to not me flash message --- ckan/controllers/package.py | 13 ++++++++++--- ckan/lib/app_globals.py | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index 00cc0b09a37..8ae03c1094a 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -611,9 +611,16 @@ def new_resource(self, id, data=None, errors=None, error_summary=None): abort(401, _('Unauthorized to update dataset')) if not len(data_dict['resources']): # no data so keep on page - h.flash_error(_('You must add at least one data resource')) - redirect(h.url_for(controller='package', - action='new_resource', id=id)) + msg = _('You must add at least one data resource') + # On new templates do not use flash message + if g.legacy_templates: + h.flash_error(msg) + redirect(h.url_for(controller='package', + action='new_resource', id=id)) + else: + errors = {} + error_summary = {_('Error'): msg} + return self.new_resource(id, data, errors, error_summary) # we have a resource so let them add metadata redirect(h.url_for(controller='package', action='new_metadata', id=id)) diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index e98f975ca7d..ce32ca74505 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -57,6 +57,7 @@ 'openid_enabled': {'default': 'true', 'type' : 'bool'}, 'debug': {'default': 'false', 'type' : 'bool'}, 'ckan.debug_supress_header' : {'default': 'false', 'type' : 'bool'}, + 'ckan.legacy_templates' : {'default': 'false', 'type' : 'bool'}, # int 'ckan.datasets_per_page': {'default': '20', 'type': 'int'}, From e3699726a07b705fdcbd3bd351e21a8554942b66 Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 22 Mar 2013 19:22:29 +0000 Subject: [PATCH 051/149] [#691] Remove fq on legacy search API and force public datasets Even if it is a backwards breaking change we sholdn't be allowing the fq parameter to be set through the API as it can lead to privacy issues. We will also enforce that all datasets available through the API are public (as the v3 API does) --- ckan/controllers/api.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index 09b0c14368e..ee1f34f6ebd 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -532,6 +532,12 @@ def search(self, ver=None, register=None): params = search.\ convert_legacy_parameters_to_solr(params) query = search.query_for(model.Package) + + # Remove any existing fq param and set the capacity to + # public + if 'fq' in params: + del params['fq'] + params['fq'] = '+capacity:public' results = query.run(params) return self._finish_ok(results) except search.SearchError, e: From 995d1df4b06324ce115d2a9d0247fb2bb84f8801 Mon Sep 17 00:00:00 2001 From: Vitor Baptista Date: Fri, 22 Mar 2013 21:23:05 -0300 Subject: [PATCH 052/149] [#533] Change paster commands docs to follow paster's conventions In paster's documentation, a required field is shown in UPPER_CASE, and underscores are used as word separator. --- ckan/lib/cli.py | 101 ++++++++++++++++++++++++------------------------ doc/paster.rst | 6 +-- 2 files changed, 54 insertions(+), 53 deletions(-) diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index 1fc8de336df..99d8dffc90e 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -99,21 +99,21 @@ def _setup_app(self): class ManageDb(CkanCommand): '''Perform various tasks on the database. - db create # alias of db upgrade - db init # create and put in default data + db create - alias of db upgrade + db init - create and put in default data db clean - db upgrade [{version no.}] # Data migrate - db version # returns current version of data schema - db dump {file-path} # dump to a pg_dump file - db dump-rdf {dataset-name} {file-path} - db simple-dump-csv {file-path} # dump just datasets in CSV format - db simple-dump-json {file-path} # dump just datasets in JSON format - db user-dump-csv {file-path} # dump user information to a CSV file - db send-rdf {talis-store} {username} {password} - db load {file-path} # load a pg_dump from a file - db load-only {file-path} # load a pg_dump from a file but don\'t do - # the schema upgrade or search indexing - db create-from-model # create database from the model (indexes not made) + db upgrade [version no.] - Data migrate + db version - returns current version of data schema + db dump FILE_PATH - dump to a pg_dump file + db dump-rdf DATASET_NAME FILE_PATH + db simple-dump-csv FILE_PATH - dump just datasets in CSV format + db simple-dump-json FILE_PATH - dump just datasets in JSON format + db user-dump-csv FILE_PATH - dump user information to a CSV file + db send-rdf TALIS_STORE USERNAME PASSWORD + db load FILE_PATH - load a pg_dump from a file + db load-only FILE_PATH - load a pg_dump from a file but don\'t do + the schema upgrade or search indexing + db create-from-model - create database from the model (indexes not made) ''' summary = __doc__.split('\n')[0] usage = __doc__ @@ -312,10 +312,12 @@ class SearchIndexCommand(CkanCommand): '''Creates a search index for all datasets Usage: - search-index [-i] [-o] [-r] [-e] rebuild [dataset-name] - reindex dataset-name if given, if not then rebuild full search index (all datasets) - search-index check - checks for datasets not indexed - search-index show {dataset-name} - shows index of a dataset - search-index clear [dataset-name] - clears the search index for the provided dataset or for the whole ckan instance + search-index [-i] [-o] [-r] [-e] rebuild [dataset_name] - reindex dataset_name if given, if not then rebuild + full search index (all datasets) + search-index check - checks for datasets not indexed + search-index show DATASET_NAME - shows index of a dataset + search-index clear [dataset_name] - clears the search index for the provided dataset or + for the whole ckan instance ''' summary = __doc__.split('\n')[0] @@ -434,7 +436,7 @@ def command(self): class RDFExport(CkanCommand): - ''' + '''Export active datasets as RDF This command dumps out all currently active datasets as RDF into the specified folder. @@ -498,8 +500,8 @@ class Sysadmin(CkanCommand): Usage: sysadmin - lists sysadmins sysadmin list - lists sysadmins - sysadmin add - add a user as a sysadmin - sysadmin remove - removes user from sysadmins + sysadmin add USERNAME - add a user as a sysadmin + sysadmin remove USERNAME - removes user from sysadmins ''' summary = __doc__.split('\n')[0] @@ -579,16 +581,16 @@ class UserCmd(CkanCommand): Usage: user - lists users user list - lists users - user - shows user properties - user add [=] + user USERNAME - shows user properties + user add USERNAME [FIELD1=VALUE1 FIELD2=VALUE2 ...] - add a user (prompts for password if not supplied). Field can be: apikey password email - user setpass - set user password (prompts) - user remove - removes user from users - user search - searches for a user name + user setpass USERNAME - set user password (prompts) + user remove USERNAME - removes user from users + user search QUERY - searches for a user name ''' summary = __doc__.split('\n')[0] usage = __doc__ @@ -735,11 +737,11 @@ class DatasetCmd(CkanCommand): '''Manage datasets Usage: - dataset - shows dataset properties - dataset show - shows dataset properties + dataset DATASET_NAME|ID - shows dataset properties + dataset show DATASET_NAME|ID - shows dataset properties dataset list - lists datasets - dataset delete - changes dataset state to 'deleted' - dataset purge - removes dataset from db entirely + dataset delete [DATASET_NAME|ID] - changes dataset state to 'deleted' + dataset purge [DATASET_NAME|ID] - removes dataset from db entirely ''' summary = __doc__.split('\n')[0] usage = __doc__ @@ -816,12 +818,11 @@ class Celery(CkanCommand): '''Celery daemon Usage: - celeryd - run the celery daemon - celeryd run - run the celery daemon - celeryd run concurrency - run the celery daemon with - argument 'concurrency' - celeryd view - view all tasks in the queue - celeryd clean - delete all tasks in the queue + celeryd - run the celery daemon + celeryd run concurrency - run the celery daemon with + argument 'concurrency' + celeryd view - view all tasks in the queue + celeryd clean - delete all tasks in the queue ''' min_args = 0 max_args = 2 @@ -940,8 +941,8 @@ class Tracking(CkanCommand): '''Update tracking statistics Usage: - tracking update [start-date] - update tracking stats - tracking export [start-date] - export tracking stats to a csv file + tracking update [start_date] - update tracking stats + tracking export FILE [start_date] - export tracking stats to a csv file ''' summary = __doc__.split('\n')[0] @@ -1116,7 +1117,7 @@ def update_tracking(self, engine, summary_date): engine.execute(sql) class PluginInfo(CkanCommand): - ''' Provide info on installed plugins. + '''Provide info on installed plugins. ''' summary = __doc__.split('\n')[0] @@ -1220,8 +1221,8 @@ class CreateTestDataCommand(CkanCommand): create-test-data user - create a user 'tester' with api key 'tester' create-test-data translations - annakarenina, warandpeace, and some test translations of terms - create-test-data vocabs - annakerenina, warandpeace, and some test - vocabularies + create-test-data vocabs - annakerenina, warandpeace, and some test + vocabularies ''' summary = __doc__.split('\n')[0] @@ -1271,7 +1272,7 @@ class Profile(CkanCommand): by runsnakerun. Usage: - profile {url} + profile URL e.g. profile /data/search @@ -1332,11 +1333,11 @@ class CreateColorSchemeCommand(CkanCommand): less will need to generate the css files after this has been run - color - creates a random color scheme - color clear - clears any color scheme - color '' - uses as base color eg '#ff00ff' must be quoted. - color - a float between 0.0 and 1.0 used as base hue - color - html color name used for base color eg lightblue + color - creates a random color scheme + color clear - clears any color scheme + color <'HEX'> - uses as base color eg '#ff00ff' must be quoted. + color - a float between 0.0 and 1.0 used as base hue + color - html color name used for base color eg lightblue ''' summary = __doc__.split('\n')[0] @@ -1587,8 +1588,8 @@ def command(self): class TranslationsCommand(CkanCommand): '''Translation helper functions - trans js - generate the javascript translations - trans mangle - mangle the zh_TW translations for testing + trans js - generate the javascript translations + trans mangle - mangle the zh_TW translations for testing ''' summary = __doc__.split('\n')[0] @@ -1744,7 +1745,7 @@ class MinifyCommand(CkanCommand): Usage: - paster minify [--clean] + paster minify [--clean] PATH for example: diff --git a/doc/paster.rst b/doc/paster.rst index 43fd3c9e6c7..dbbcb2fe59b 100644 --- a/doc/paster.rst +++ b/doc/paster.rst @@ -234,7 +234,7 @@ Sets the authorization roles of a specific user on a given object within the sys For example, to give the user named 'bar' the 'admin' role on the dataset 'foo':: - paster --plugin=ckan rights make bar admin package:foo --config=/etc/ckan/std/std.ini + paster --plugin=ckan rights make bar admin package:foo --config=/etc/ckan/std/std.ini To list all the rights currently specified:: @@ -279,8 +279,8 @@ won't clear the index before starting rebuilding it:: There are other search related commands, mostly useful for debugging purposes:: search-index check - checks for datasets not indexed - search-index show {dataset-name} - shows index of a dataset - search-index clear [dataset-name] - clears the search index for the provided dataset or for the whole ckan instance + search-index show DATASET_NAME - shows index of a dataset + search-index clear [DATASET_NAME] - clears the search index for the provided dataset or for the whole ckan instance From ee1581a1020bf731249d78f7c36d3eb991b9353d Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Mon, 25 Mar 2013 15:31:32 +0100 Subject: [PATCH 053/149] [#517] Clarify custom db sqlalchemy.url setting --- doc/install-from-source.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install-from-source.rst b/doc/install-from-source.rst index b0b70d9cd68..37c1f5922af 100644 --- a/doc/install-from-source.rst +++ b/doc/install-from-source.rst @@ -121,7 +121,7 @@ config file:: ``sqlalchemy.url`` line, filling in the database name, user and password you used:: - sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckantest + sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckandatabase If you're using a remote host with password authentication rather than SSL authentication, use:: From 1e0a2605e2229240b1feacd9c645bad6595252a6 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Mon, 25 Mar 2013 15:32:30 +0100 Subject: [PATCH 054/149] [#517] Typo --- doc/test.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/test.rst b/doc/test.rst index 088f36d04b4..f724183b111 100644 --- a/doc/test.rst +++ b/doc/test.rst @@ -58,7 +58,7 @@ Or to run the CKAN tests and the core extensions tests together:: Testing with PostgreSQL ----------------------- -Starting in CKAN 2.1 tests are run in a separate postgres database by default. You should create the test database as follows.:: +Starting in CKAN 2.1 tests are run in a separate postgres database by default. You should create the test database as follows:: sudo -u postgres createdb -O ckanuser ckan_test -E utf-8 From 97138fe0d69719b40d3a55832dddc8cdb4b61b19 Mon Sep 17 00:00:00 2001 From: Vitor Baptista Date: Fri, 22 Mar 2013 22:12:52 -0300 Subject: [PATCH 055/149] [#533] Update paster's documentation and rename its title --- ckan/lib/cli.py | 6 +- ckanext/datastore/commands.py | 8 +- doc/database-dumps.rst | 6 +- doc/paster.rst | 179 ++++++++++++++++++++++++++++++++-- 4 files changed, 185 insertions(+), 14 deletions(-) diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index 99d8dffc90e..f93c39cbb68 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -1329,9 +1329,9 @@ def profile_url(url): class CreateColorSchemeCommand(CkanCommand): - ''' Create or remove a color scheme. + '''Create or remove a color scheme. - less will need to generate the css files after this has been run + After running this, you'll need to regenerate the css files. See paster's less command for details. color - creates a random color scheme color clear - clears any color scheme @@ -1920,7 +1920,7 @@ def compile_less(self, root, less_bin, color): class FrontEndBuildCommand(CkanCommand): - ''' Creates and minifies css and JavaScript files + '''Creates and minifies css and JavaScript files Usage: diff --git a/ckanext/datastore/commands.py b/ckanext/datastore/commands.py index 364e163eddb..d2cf9c55d54 100644 --- a/ckanext/datastore/commands.py +++ b/ckanext/datastore/commands.py @@ -8,7 +8,7 @@ class SetupDatastoreCommand(cli.CkanCommand): '''Perform commands to set up the datastore. - Make sure that the datastore urls are set properly before you run these commands. + Make sure that the datastore URLs are set properly before you run these commands. Usage:: @@ -16,9 +16,9 @@ class SetupDatastoreCommand(cli.CkanCommand): Where: SQL_SUPER_USER is the name of a postgres user with sufficient - permissions to create new tables, users, and grant - and revoke new permissions. Typically, this would - be the "postgres" user. + permissions to create new tables, users, and grant + and revoke new permissions. Typically, this would + be the "postgres" user. ''' summary = __doc__.split('\n')[0] diff --git a/doc/database-dumps.rst b/doc/database-dumps.rst index b8200d912d3..edeca20bb73 100644 --- a/doc/database-dumps.rst +++ b/doc/database-dumps.rst @@ -11,6 +11,8 @@ Creating a Dump We provide two ``paster`` methods to create dumpfiles. * ``db simple-dump-json`` - A simple dumpfile, useful to create a public listing of the datasets with no user information. All datasets are dumped, including deleted datasets and ones with strict authorization. These may be in JSON or CSV format. +* ``db user-dump-csv`` - It works the same way as simple-dump-json, but dumps the users, instead of datasets. +* ``db dump-rdf`` - A dump of a specific dataset, in RDF format. * ``db dump`` - A more complicated dumpfile, useful for backups. Replicates the database completely, including users, their personal info and API keys, and hence should be kept private. This is in the format of SQL commands. For more information on paster, see :doc:`paster`. @@ -27,7 +29,9 @@ Then create and zip the dumpfile:: paster --plugin=ckan db simple-dump-json /var/srvc/ckan/dumps/ckan.net-daily.json --config=/etc/ckan/std/std.ini gzip /var/srvc/ckan/dumps/ckan.net-daily.json -Change ``simple-dump-json`` to ``simple-dump-csv`` if you want CSV format instead of JSON. +Change ``simple-dump-json`` to ``simple-dump-csv`` if you want CSV format instead of JSON. + +Use ``user-dump-csv`` if you want a dump of the users instead. Backing up - db dump ++++++++++++++++++++ diff --git a/doc/paster.rst b/doc/paster.rst index dbbcb2fe59b..bf81169a5a8 100644 --- a/doc/paster.rst +++ b/doc/paster.rst @@ -1,8 +1,8 @@ .. _paster: -=============================== -Common CKAN Administrator Tasks -=============================== +==================== +CKAN Paster Commands +==================== The majority of common CKAN administration tasks are carried out using the **paster** script. @@ -137,14 +137,28 @@ Common Tasks Using Paster The following tasks are supported by paster. ================= ========================================================== + celeryd Control celery daemon. + check-po-files Check po files for common mistakes + color Create or remove a color scheme. create-test-data Create test data in the database. + dataset Manage datasets. + datastore Perform commands to set up the datastore. db Perform various tasks on the database. + front-end-build Creates and minifies css and JavaScript files + less Compile all root less documents into their CSS counterparts + minify Create minified versions of the given Javascript and CSS files. + notify Send out modification notifications. + plugin-info Provide info on installed plugins. + profile Code speed profiler ratings Manage the ratings stored in the db + rdf-export Export active datasets as RDF. rights Commands relating to per-object and system-wide access rights. roles Commands relating to roles and actions. search-index Creates a search index for all datasets - sysadmin Gives sysadmin rights to a named user - user Manage users + sysadmin Gives sysadmin rights to a named user. + tracking Update tracking statistics. + trans Translation helper functions + user Manage users. ================= ========================================================== @@ -153,12 +167,74 @@ For the full list of tasks supported by paster, you can run:: paster --plugin=ckan --help +celeryd: Control celery daemon +------------------------------- + +Usage:: + + celeryd - run the celery daemon + celeryd run concurrency - run the celery daemon with + argument 'concurrency' + celeryd view - view all tasks in the queue + celeryd clean - delete all tasks in the queue + + +check-po-files: Check po files for common mistakes +-------------------------------------------------- + +Usage:: + + check-po-files [options] [FILE] ... + + +color: Create or remove a color scheme +-------------------------------------- + +After running this command, you'll need to regenerate the css files. See :ref:`less` for details. + +Usage:: + + color - creates a random color scheme + color clear - clears any color scheme + color <'HEX'> - uses as base color eg '#ff00ff' must be quoted. + color - a float between 0.0 and 1.0 used as base hue + color - html color name used for base color eg lightblue + + create-test-data: Create test data ---------------------------------- As the name suggests, this command lets you load test data when first setting up CKAN. See :ref:`create-test-data` for details. +dataset: Manage datasets +------------------------ + +Usage:: + + dataset DATASET_NAME|ID - shows dataset properties + dataset show DATASET_NAME|ID - shows dataset properties + dataset list - lists datasets + dataset delete [DATASET_NAME|ID] - changes dataset state to 'deleted' + dataset purge [DATASET_NAME|ID] - removes dataset from db entirely + + +datastore: Perform commands to set up the datastore +--------------------------------------------------- + +Make sure that the datastore URLs are set properly before you run these commands. + +Usage:: + + datastore set-permissions SQL_SUPER_USER + + Where: + SQL_SUPER_USER is the name of a postgres user with sufficient + permissions to create new tables, users, and grant + and revoke new permissions. Typically, this would + be the "postgres" user. + + db: Manage databases -------------------- @@ -217,6 +293,72 @@ Creating dump files For information on using ``db`` to create dumpfiles, see :doc:`database-dumps`. +front-end-build: Creates and minifies css and JavaScript files +-------------------------------------------------------------- + +Usage:: + + front-end-build + + +.. _less: + +less: Compile all root less documents into their CSS counterparts +----------------------------------------------------------------- + +Usage:: + + less + + +minify: Create minified versions of the given Javascript and CSS files +---------------------------------------------------------------------- + +Usage:: + + paster minify [--clean] PATH + + For example: + + paster minify ckan/public/base + paster minify ckan/public/base/css/*.css + paster minify ckan/public/base/css/red.css + +If the --clean option is provided any minified files will be removed. + + +notify: Send out modification notifications +------------------------------------------- + +Usage:: + + notify replay - send out modification signals. In "replay" mode, + an update signal is sent for each dataset in the database. + + +plugin-info: Provide info on installed plugins +---------------------------------------------- + +As the name suggests, this commands shows you the installed plugins, their description, and which interfaces they implement + + +profile: Code speed profiler +---------------------------- + +Provide a ckan url and it will make the request and record how long each function call took in a file that can be read +by runsnakerun. + +Usage:: + + profile URL + +The result is saved in profile.data.search. To view the profile in runsnakerun:: + + runsnakerun ckan.data.search.profile + +You may need to install the cProfile python module. + + ratings: Manage dataset ratings ------------------------------- @@ -227,6 +369,14 @@ For example, to remove anonymous ratings from the database:: paster --plugin=ckan ratings clean-anonymous --config=/etc/ckan/std/std.ini +rdf-export: Export datasets as RDF +---------------------------------- + +This command dumps out all currently active datasets as RDF into the specified folder:: + + paster rdf-export /path/to/store/output + + rights: Set user permissions ---------------------------- @@ -283,7 +433,6 @@ There are other search related commands, mostly useful for debugging purposes:: search-index clear [DATASET_NAME] - clears the search index for the provided dataset or for the whole ckan instance - sysadmin: Give sysadmin rights ------------------------------ @@ -294,6 +443,24 @@ For example, to make a user called 'admin' into a sysadmin:: paster --plugin=ckan sysadmin add admin --config=/etc/ckan/std/std.ini +tracking: Update tracking statistics +------------------------------------ + +Usage:: + + tracking update [start_date] - update tracking stats + tracking export FILE [start_date] - export tracking stats to a csv file + + +trans: Translation helper functions +----------------------------------- + +Usage:: + + trans js - generate the javascript translations + trans mangle - mangle the zh_TW translations for testing + + .. _paster-user: user: Create and manage users From b8654228b72f163bab179cabb41fa1fec422c458 Mon Sep 17 00:00:00 2001 From: Vitor Baptista Date: Mon, 25 Mar 2013 15:11:29 -0300 Subject: [PATCH 056/149] [#533] Remove documentation of paster rights and roles. They don't exist anymore. --- doc/paster.rst | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/doc/paster.rst b/doc/paster.rst index bf81169a5a8..94e5524e03e 100644 --- a/doc/paster.rst +++ b/doc/paster.rst @@ -152,8 +152,6 @@ The following tasks are supported by paster. profile Code speed profiler ratings Manage the ratings stored in the db rdf-export Export active datasets as RDF. - rights Commands relating to per-object and system-wide access rights. - roles Commands relating to roles and actions. search-index Creates a search index for all datasets sysadmin Gives sysadmin rights to a named user. tracking Update tracking statistics. @@ -377,29 +375,6 @@ This command dumps out all currently active datasets as RDF into the specified f paster rdf-export /path/to/store/output -rights: Set user permissions ----------------------------- - -Sets the authorization roles of a specific user on a given object within the system. - -For example, to give the user named 'bar' the 'admin' role on the dataset 'foo':: - - paster --plugin=ckan rights make bar admin package:foo --config=/etc/ckan/std/std.ini - -To list all the rights currently specified:: - - paster --plugin=ckan rights list --config=/etc/ckan/std/std.ini - -For more information and examples, see :doc:`authorization`. - - -roles: Manage system-wide permissions --------------------------------------- - -This important command gives you fine-grained control over CKAN permissions, by listing and modifying the assignment of actions to roles. - -The ``roles`` command has its own section: see :doc:`authorization`. - .. _rebuild search index: search-index: Rebuild search index From adfc4bf2f9a862120a2ff05c9ff9bac9a6195102 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Mon, 25 Mar 2013 23:48:10 +0100 Subject: [PATCH 057/149] [#368] Move package_revision_info block and fix wrong tag (div->p) to make the sidebar float correctly on package read pages if a certain revision of a package is requested --- ckan/templates/package/read_base.html | 34 +++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/ckan/templates/package/read_base.html b/ckan/templates/package/read_base.html index d4ee8468941..30b5b3afda1 100644 --- a/ckan/templates/package/read_base.html +++ b/ckan/templates/package/read_base.html @@ -17,23 +17,6 @@ {% endblock %} {% block primary_content %} - {% block package_revision_info %} - {% if c.pkg_revision_id %} -
    -

    - {% set timestamp = h.render_datetime(c.pkg_revision_timestamp, with_hours=True) %} - {% set url = h.url(controller='package', action='read', id=pkg.name) %} - - {% if c.pkg_revision_not_latest %} - {% trans timestamp=timestamp, url=url %}This is an old revision of this dataset, as edited at {{ timestamp }}. It may differ significantly from the current revision.{% endtrans %} - {% else %} - {% trans timestamp=timestamp %}This is the current revision of this dataset, as edited at {{ timestamp }}.{% endtrans %} - {% endif %} -

    - - {% endif %} - {% endblock %} -
    {% block page_header %} @@ -44,6 +27,23 @@ ] %} {% endblock %} + {% block package_revision_info %} + {% if c.pkg_revision_id %} +
    +

    + {% set timestamp = h.render_datetime(c.pkg_revision_timestamp, with_hours=True) %} + {% set url = h.url(controller='package', action='read', id=pkg.name) %} + + {% if c.pkg_revision_not_latest %} + {% trans timestamp=timestamp, url=url %}This is an old revision of this dataset, as edited at {{ timestamp }}. It may differ significantly from the current revision.{% endtrans %} + {% else %} + {% trans timestamp=timestamp %}This is the current revision of this dataset, as edited at {{ timestamp }}.{% endtrans %} + {% endif %} +

    +
    + {% endif %} + {% endblock %} + {% block primary_content_inner %}{% endblock %}
    {% endblock %} From 46e3fde42fa36efdfc08dc2162c001cc5f4f67c5 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Mon, 25 Mar 2013 23:59:18 +0100 Subject: [PATCH 058/149] [#642] Log possible problems with the datastore permission checks instead of raising an exception. If postgres is set to a language other than english, some strings might not occur in the error message returned from the database. This change makes the checks less strict but in almost all cases, this should not be a problem because the only error raised during executing of the permission check statements are (expected) permission errors. --- ckanext/datastore/plugin.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index 6f67d4ec64b..d3d5f6e713c 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -61,17 +61,20 @@ def configure(self, config): if not ('debug' in config and config['debug']): self._check_separate_db() if self.legacy_mode: - log.warn('Legacy mode active. The sql search will not be available.') + log.warn("Legacy mode active." + "The sql search will not be available.") else: self._check_read_permissions() self._create_alias_table() else: - log.warn("We detected that CKAN is running on a read only database. " - "Permission checks and the creation of _table_metadata are skipped.") + log.warn("We detected that CKAN is running on a read" + "only database. Permission checks and the creation " + "of _table_metadata are skipped.") else: - log.warn("We detected that you do not use a PostgreSQL database. " - "The DataStore will NOT work and datastore tests will be skipped.") + log.warn("We detected that you do not use a PostgreSQL" + "database. The DataStore will NOT work and datastore" + "tests will be skipped.") ## Do light wrapping around action function to add datastore_active ## to resource dict. Not using IAction extension as this prevents @@ -118,7 +121,11 @@ def _is_read_only_database(self): if 'permission denied' in str(e) or 'read-only transaction' in str(e): pass else: - raise + log.critical("Possibly unsafe datastore. If '{0}' " + "does not mean 'permission denied', or " + "'read-only transaction' you have to double " + "check the permissions for the datastore " + "table.".format(e.message)) else: return False finally: @@ -166,7 +173,10 @@ def _check_read_permissions(self): read_connection.execute(sql) except ProgrammingError, e: if 'permission denied' not in str(e): - raise + log.critical("Possibly unsafe datastore. If '{0}'" + "does not mean 'permission denied', " + "you have to double check the permissions " + "for the datastore table.".format(e.message)) else: log.info("Connection url {0}".format(self.read_url)) if 'debug' in self.config and self.config['debug']: From 4872f516f824bcc6a07552a9d2abba13e27f6108 Mon Sep 17 00:00:00 2001 From: tobes Date: Tue, 26 Mar 2013 12:58:43 +0000 Subject: [PATCH 059/149] [#517] Update docs to create test datastore db --- doc/test.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/test.rst b/doc/test.rst index 088f36d04b4..589f1eee769 100644 --- a/doc/test.rst +++ b/doc/test.rst @@ -58,9 +58,11 @@ Or to run the CKAN tests and the core extensions tests together:: Testing with PostgreSQL ----------------------- -Starting in CKAN 2.1 tests are run in a separate postgres database by default. You should create the test database as follows.:: +Starting in CKAN 2.1 tests are run in a separate postgres database by +default. You should create the test databases as follows.:: sudo -u postgres createdb -O ckanuser ckan_test -E utf-8 + sudo -u postgres createdb -O ckanuser ckan_test_datastore -E utf-8 This database connection is specified in the ``test-core.ini`` file by the ``sqlalchemy.url`` parameter. From 57b6e3f5c29927f2390769be96114dccb4e37463 Mon Sep 17 00:00:00 2001 From: tobes Date: Tue, 26 Mar 2013 12:59:46 +0000 Subject: [PATCH 060/149] [#517] Update test-core.ini with correct datastore db and solr url --- test-core.ini | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test-core.ini b/test-core.ini index be9aa55c07a..d4f1c7e72c5 100644 --- a/test-core.ini +++ b/test-core.ini @@ -22,8 +22,11 @@ sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckan_test #sqlalchemy.url = sqlite:/// ## Datastore -ckan.datastore.write_url = postgresql://ckanuser:pass@localhost/ckan_test -ckan.datastore.read_url = postgresql://readonlyuser:pass@localhost/ckan_test +ckan.datastore.write_url = postgresql://ckanuser:pass@localhost/ckan_test_datastore +ckan.datastore.read_url = postgresql://readonlyuser:pass@localhost/ckan_test_datastore + +## Solr support +solr_url = http://127.0.0.1:8983/solr ckan.auth.user_create_organizations = true ckan.auth.user_create_groups = true From 7215631bea6d1e0f9b8db479612dada4dbf08202 Mon Sep 17 00:00:00 2001 From: tobes Date: Tue, 26 Mar 2013 13:18:05 +0000 Subject: [PATCH 061/149] [#517] Add readonly user added for testing --- doc/test.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/test.rst b/doc/test.rst index 589f1eee769..aa04f432e16 100644 --- a/doc/test.rst +++ b/doc/test.rst @@ -63,6 +63,11 @@ default. You should create the test databases as follows.:: sudo -u postgres createdb -O ckanuser ckan_test -E utf-8 sudo -u postgres createdb -O ckanuser ckan_test_datastore -E utf-8 + # create datastore user default password `pass` + sudo -u postgres createuser -S -D -R -P -l readonlyuser + # set the permissions for readonly user + paster datastore set-permissions postgres -c test-core.ini + This database connection is specified in the ``test-core.ini`` file by the ``sqlalchemy.url`` parameter. From e6eec2267afe62cd04ba199359a8fd1579c9ffb5 Mon Sep 17 00:00:00 2001 From: tobes Date: Tue, 26 Mar 2013 13:30:57 +0000 Subject: [PATCH 062/149] [#517] Doc fix --- doc/install-from-source.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install-from-source.rst b/doc/install-from-source.rst index 37c1f5922af..6860d767080 100644 --- a/doc/install-from-source.rst +++ b/doc/install-from-source.rst @@ -121,7 +121,7 @@ config file:: ``sqlalchemy.url`` line, filling in the database name, user and password you used:: - sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckandatabase + sqlalchemy.url = postgresql://ckanuser:pass@localhost/ckan_dev If you're using a remote host with password authentication rather than SSL authentication, use:: From 8d3917112382474d2d44900f979fee6ba99b7af7 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 26 Mar 2013 22:52:58 +0100 Subject: [PATCH 063/149] [#642] Add spaces to log messages where they are missing --- ckanext/datastore/plugin.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index d3d5f6e713c..e164deed4a7 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -43,8 +43,8 @@ def configure(self, config): # that we should ignore the following tests. import sys if sys.argv[0].split('/')[-1] == 'paster' and 'datastore' in sys.argv[1:]: - log.warn('Omitting permission checks because you are ' - 'running paster commands.') + log.warn("Omitting permission checks because you are " + "running paster commands.") return self.ckan_url = self.config['sqlalchemy.url'] @@ -68,12 +68,12 @@ def configure(self, config): self._create_alias_table() else: - log.warn("We detected that CKAN is running on a read" + log.warn("We detected that CKAN is running on a read " "only database. Permission checks and the creation " "of _table_metadata are skipped.") else: - log.warn("We detected that you do not use a PostgreSQL" - "database. The DataStore will NOT work and datastore" + log.warn("We detected that you do not use a PostgreSQL " + "database. The DataStore will NOT work and datastore " "tests will be skipped.") ## Do light wrapping around action function to add datastore_active @@ -173,7 +173,7 @@ def _check_read_permissions(self): read_connection.execute(sql) except ProgrammingError, e: if 'permission denied' not in str(e): - log.critical("Possibly unsafe datastore. If '{0}'" + log.critical("Possibly unsafe datastore. If '{0}' " "does not mean 'permission denied', " "you have to double check the permissions " "for the datastore table.".format(e.message)) From b68601d2bae05f289e969f189a0495ab9d53283e Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 27 Mar 2013 12:19:37 +0100 Subject: [PATCH 064/149] [#642] Use has_table_privilege and has_schema_privilege instead of experimental privilege checks. --- ckanext/datastore/plugin.py | 71 +++++++++++++------------------------ 1 file changed, 24 insertions(+), 47 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index e164deed4a7..9c4c3eb101e 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -63,9 +63,13 @@ def configure(self, config): if self.legacy_mode: log.warn("Legacy mode active." "The sql search will not be available.") - else: - self._check_read_permissions() - + elif not self._read_connection_has_correct_privileges(): + if 'debug' in self.config and self.config['debug']: + log.critical("We have write permissions " + "on the read-only database.") + else: + raise Exception("We have write permissions " + "on the read-only database.") self._create_alias_table() else: log.warn("We detected that CKAN is running on a read " @@ -112,24 +116,11 @@ def new_resource_show(context, data_dict): def _is_read_only_database(self): for url in [self.ckan_url, self.write_url, self.read_url]: connection = db._get_engine(None, - {'connection_url': url}).connect() - trans = connection.begin() - try: - sql = u"CREATE TABLE test_readonly(id INTEGER);" - connection.execute(sql) - except ProgrammingError, e: - if 'permission denied' in str(e) or 'read-only transaction' in str(e): - pass - else: - log.critical("Possibly unsafe datastore. If '{0}' " - "does not mean 'permission denied', or " - "'read-only transaction' you have to double " - "check the permissions for the datastore " - "table.".format(e.message)) - else: + {'connection_url': url}).connect() + sql = u"SELECT has_schema_privilege('public', 'CREATE')" + is_writable = connection.execute(sql).first()[0] + if is_writable: return False - finally: - trans.rollback() return True def _check_separate_db(self): @@ -140,7 +131,8 @@ def _check_separate_db(self): if not self.legacy_mode: if self.write_url == self.read_url: - raise Exception("The write and read-only database connection url are the same.") + raise Exception("The write and read-only database " + "connection url are the same.") if self._get_db_from_url(self.ckan_url) == self._get_db_from_url(self.read_url): raise Exception("The CKAN and datastore database are the same.") @@ -148,47 +140,32 @@ def _check_separate_db(self): def _get_db_from_url(self, url): return url[url.rindex("@"):] - def _check_read_permissions(self): + def _read_connection_has_correct_privileges(self): ''' Check whether the right permissions are set for the read only user. A table is created by the write user to test the read only user. ''' write_connection = db._get_engine(None, {'connection_url': self.write_url}).connect() - write_connection.execute(u"DROP TABLE IF EXISTS public._foo;" - u"CREATE TABLE public._foo (id INTEGER, name VARCHAR)") + write_connection.execute( + u"DROP TABLE IF EXISTS public._foo;", + u"CREATE TABLE public._foo ()") read_connection = db._get_engine(None, {'connection_url': self.read_url}).connect() - statements = [ - u"CREATE TABLE public._bar (id INTEGER, name VARCHAR)", - u"INSERT INTO public._foo VALUES (1, 'okfn')" - ] - try: - for sql in statements: - read_trans = read_connection.begin() - try: - read_connection.execute(sql) - except ProgrammingError, e: - if 'permission denied' not in str(e): - log.critical("Possibly unsafe datastore. If '{0}' " - "does not mean 'permission denied', " - "you have to double check the permissions " - "for the datastore table.".format(e.message)) - else: - log.info("Connection url {0}".format(self.read_url)) - if 'debug' in self.config and self.config['debug']: - log.critical("We have write permissions on the read-only database.") - else: - raise Exception("We have write permissions on the read-only database.") - finally: - read_trans.rollback() + write_connection.execute(u"CREATE TABLE public._foo ()") + for privilege in ['INSERT', 'UPDATE', 'DELETE']: + sql = u"SELECT has_table_privilege('_foo', '{privilege}')".format(privilege=privilege) + have_privilege = read_connection.execute(sql).first()[0] + if have_privilege: + return False except Exception: raise finally: write_connection.execute("DROP TABLE _foo") + return True def _create_alias_table(self): mapping_sql = ''' From cbc4fa95731b018d9d36a860662d5b6e9f8e43a1 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 27 Mar 2013 12:51:38 +0100 Subject: [PATCH 065/149] [#642] Make check functions consistent (return bool instead of raising exceptions) --- ckanext/datastore/plugin.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index 9c4c3eb101e..c74d72f4787 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -18,9 +18,6 @@ class DatastoreException(Exception): class DatastorePlugin(p.SingletonPlugin): - ''' - Datastore plugin. - ''' p.implements(p.IConfigurable, inherit=True) p.implements(p.IActions) p.implements(p.IAuthFunctions) @@ -59,9 +56,11 @@ def configure(self, config): # Make sure that the right permissions are set # so that no harmful queries can be made if not ('debug' in config and config['debug']): - self._check_separate_db() + if self._same_ckan_and_datastore_db(): + raise Exception("The write and read-only database " + "connection url are the same.") if self.legacy_mode: - log.warn("Legacy mode active." + log.warn("Legacy mode active. " "The sql search will not be available.") elif not self._read_connection_has_correct_privileges(): if 'debug' in self.config and self.config['debug']: @@ -114,6 +113,10 @@ def new_resource_show(context, data_dict): logic._actions['resource_show'] = new_resource_show def _is_read_only_database(self): + ''' + Returns True if no connection has CREATE privileges on the public + schema. This is the case if replication is enabled. + ''' for url in [self.ckan_url, self.write_url, self.read_url]: connection = db._get_engine(None, {'connection_url': url}).connect() @@ -123,26 +126,28 @@ def _is_read_only_database(self): return False return True - def _check_separate_db(self): + def _same_ckan_and_datastore_db(self): ''' Make sure the datastore is on a separate db. Otherwise one could access all internal tables via the api. + + Returns True if the CKAN and DataStore db are the same ''' if not self.legacy_mode: if self.write_url == self.read_url: - raise Exception("The write and read-only database " - "connection url are the same.") + return True if self._get_db_from_url(self.ckan_url) == self._get_db_from_url(self.read_url): - raise Exception("The CKAN and datastore database are the same.") + return True + return False def _get_db_from_url(self, url): return url[url.rindex("@"):] def _read_connection_has_correct_privileges(self): ''' - Check whether the right permissions are set for the read only user. + Returns True if the right permissions are set for the read only user. A table is created by the write user to test the read only user. ''' write_connection = db._get_engine(None, @@ -161,8 +166,6 @@ def _read_connection_has_correct_privileges(self): have_privilege = read_connection.execute(sql).first()[0] if have_privilege: return False - except Exception: - raise finally: write_connection.execute("DROP TABLE _foo") return True From e061b0c59af5a58032996b1e8bd80888ef941b97 Mon Sep 17 00:00:00 2001 From: tobes Date: Wed, 27 Mar 2013 12:23:23 +0000 Subject: [PATCH 066/149] [#509] Fix bug in add/remove groups --- ckan/lib/dictization/model_save.py | 35 ++++++++++++++++++------------ 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py index a87b53eb4dc..b43c49ffb78 100644 --- a/ckan/lib/dictization/model_save.py +++ b/ckan/lib/dictization/model_save.py @@ -233,29 +233,36 @@ def package_membership_list_save(group_dicts, package, context): ## need to flush so we can get out the package id model.Session.flush() - for group in groups - set(group_member.keys()): - if group: - member_obj = model.Member(table_id = package.id, - table_name = 'package', - group = group, - capacity = capacity, - group_id=group.id, - state = 'active') - session.add(member_obj) + + # Remove any groups we are no longer in for group in set(group_member.keys()) - groups: member_obj = group_member[group] + if member_obj and member_obj.state == 'deleted': + continue if new_authz.has_user_permission_for_group_or_org( member_obj.group_id, user, 'read'): member_obj.capacity = capacity member_obj.state = 'deleted' session.add(member_obj) - for group in set(group_member.keys()) & groups: - member_obj = group_member[group] + # Add any new groups + for group in groups: + member_obj = group_member.get(group) + if member_obj and member_obj.state == 'active': + continue if new_authz.has_user_permission_for_group_or_org( - member_obj.group_id, user, 'read'): - member_obj.capacity = capacity - member_obj.state = 'active' + group.id, user, 'read'): + member_obj = group_member.get(group) + if member_obj: + member_obj.capacity = capacity + member_obj.state = 'active' + else: + member_obj = model.Member(table_id=package.id, + table_name='package', + group=group, + capacity=capacity, + group_id=group.id, + state = 'active') session.add(member_obj) From 43ddc88c6f589b1972b946bf3e6c9681b92cad4f Mon Sep 17 00:00:00 2001 From: joetsoi Date: Wed, 27 Mar 2013 13:00:16 +0000 Subject: [PATCH 067/149] [#706] make substr index from correct character --- ckan/migration/versions/067_turn_extras_to_strings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ckan/migration/versions/067_turn_extras_to_strings.py b/ckan/migration/versions/067_turn_extras_to_strings.py index e6c0f1a26f0..4659cbc6cb4 100644 --- a/ckan/migration/versions/067_turn_extras_to_strings.py +++ b/ckan/migration/versions/067_turn_extras_to_strings.py @@ -7,7 +7,7 @@ def upgrade(migrate_engine): revision_tables = 'package_extra_revision group_extra_revision' for table in tables.split(): - sql = """select id, value from {table} where substr(value,0,1) = '"' """.format(table=table) + sql = """select id, value from {table} where substr(value,1,1) = '"' """.format(table=table) results = connection.execute(sql) for result in results: id, value = result @@ -16,7 +16,7 @@ def upgrade(migrate_engine): json.loads(value), id) for table in revision_tables.split(): - sql = """select id, revision_id, value from {table} where substr(value,0,1) = '"' """.format(table=table) + sql = """select id, revision_id, value from {table} where substr(value,1,1) = '"' """.format(table=table) results = connection.execute(sql) for result in results: From 302a9ff87780ce6653f16fd77bf25496b586a9e2 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 27 Mar 2013 15:50:59 +0100 Subject: [PATCH 068/149] [#642] Refactor datastore plugin configuration, improve (and fix ;-)) tests --- ckanext/datastore/plugin.py | 25 ++++++++-------- ckanext/datastore/tests/test_configure.py | 35 +++++++++++++++++------ 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index c74d72f4787..7ed6657fa93 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -56,9 +56,12 @@ def configure(self, config): # Make sure that the right permissions are set # so that no harmful queries can be made if not ('debug' in config and config['debug']): + if self._same_read_and_write_url(): + raise DatastoreException("The write and read-only database " + "connection url are the same.") if self._same_ckan_and_datastore_db(): - raise Exception("The write and read-only database " - "connection url are the same.") + raise DatastoreException("CKAN and DataStore database " + "cannot be the same.") if self.legacy_mode: log.warn("Legacy mode active. " "The sql search will not be available.") @@ -67,8 +70,8 @@ def configure(self, config): log.critical("We have write permissions " "on the read-only database.") else: - raise Exception("We have write permissions " - "on the read-only database.") + raise DatastoreException("We have write permissions " + "on the read-only database.") self._create_alias_table() else: log.warn("We detected that CKAN is running on a read " @@ -128,16 +131,8 @@ def _is_read_only_database(self): def _same_ckan_and_datastore_db(self): ''' - Make sure the datastore is on a separate db. Otherwise one could access - all internal tables via the api. - Returns True if the CKAN and DataStore db are the same ''' - - if not self.legacy_mode: - if self.write_url == self.read_url: - return True - if self._get_db_from_url(self.ckan_url) == self._get_db_from_url(self.read_url): return True return False @@ -145,6 +140,12 @@ def _same_ckan_and_datastore_db(self): def _get_db_from_url(self, url): return url[url.rindex("@"):] + def _same_read_and_write_url(self): + # in legacy mode, this test can be ignored + if self.legacy_mode: + return True + return self.write_url == self.read_url + def _read_connection_has_correct_privileges(self): ''' Returns True if the right permissions are set for the read only user. diff --git a/ckanext/datastore/tests/test_configure.py b/ckanext/datastore/tests/test_configure.py index eb96c9bdc49..a5f1dd24d21 100644 --- a/ckanext/datastore/tests/test_configure.py +++ b/ckanext/datastore/tests/test_configure.py @@ -24,16 +24,33 @@ def test_set_legacy_mode(self): assert self.p.write_url == 'foo' assert self.p.read_url == 'foo' - def test_check_separate_db(self): + def test_check_separate_write_and_read_if_not_legacy(self): + self.p.legacy_mode = True + self.p.write_url = 'postgresql://u:pass@localhost/ds' + self.p.read_url = 'postgresql://u:pass@localhost/ds' + assert self.p._same_read_and_write_url() + + self.p.legacy_mode = False + + assert not self.p.legacy_mode + + self.p.write_url = 'postgresql://u:pass@localhost/ds' + self.p.read_url = 'postgresql://u:pass@localhost/ds' + assert self.p._same_read_and_write_url() + + self.p.write_url = 'postgresql://u:pass@localhost/ds' + self.p.read_url = 'postgresql://u2:pass@localhost/ds' + assert not self.p._same_read_and_write_url() + + def test_same_ckan_and_datastore_db(self): + self.p.write_url = 'postgresql://u:pass@localhost/ckan' + self.p.read_url = 'postgresql://u:pass@localhost/ckan' + self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' + + assert self.p._same_ckan_and_datastore_db() + self.p.write_url = 'postgresql://u:pass@localhost/dt' self.p.read_url = 'postgresql://u:pass@localhost/dt' self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' - self.p.legacy_mode = True - try: - self.p._check_separate_db() - except Exception: - self.fail("_check_separate_db raise Exception unexpectedly!") - - self.p.legacy_mode = False - self.assertRaises(Exception, self.p._check_separate_db) + assert not self.p._same_ckan_and_datastore_db() From 42b65347117106d0e778ef7f18ceb2ae6870425b Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 27 Mar 2013 19:06:16 +0100 Subject: [PATCH 069/149] [#710] Use "Unnamed resource" instead of URL Use "Unnamed resource" instead of URL for resource display name when resource has no name. This looks better especially in the heading on the resource read page. Fixes #710 --- ckan/lib/helpers.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index ab521abeb3c..ff15eac6a53 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -925,7 +925,6 @@ def dataset_link(package_or_package_dict): def resource_display_name(resource_dict): name = resource_dict.get('name', None) description = resource_dict.get('description', None) - url = resource_dict.get('url') if name: return name elif description: @@ -934,11 +933,8 @@ def resource_display_name(resource_dict): if len(description) > max_len: description = description[:max_len] + '...' return description - elif url: - return url else: - noname_string = _('no name') - return '[%s] %s' % (noname_string, resource_dict['id']) + return _("Unnamed resource") def resource_link(resource_dict, package_id): From 67b01d5dae0d844cb0f7f5518e7e9efaaf9801c6 Mon Sep 17 00:00:00 2001 From: Nigel Babu Date: Thu, 28 Mar 2013 11:01:41 +0530 Subject: [PATCH 070/149] [#536] Remove user stories from doc for 2.0 --- doc/index.rst | 1 - doc/user-stories-list.rst | 548 -------------------------------------- doc/user-stories.rst | 9 - 3 files changed, 558 deletions(-) delete mode 100644 doc/user-stories-list.rst delete mode 100644 doc/user-stories.rst diff --git a/doc/index.rst b/doc/index.rst index aad7b737ca1..377cbd68638 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -108,7 +108,6 @@ Other material :maxdepth: 2 contrib.rst - user-stories CHANGELOG.rst Indices and tables diff --git a/doc/user-stories-list.rst b/doc/user-stories-list.rst deleted file mode 100644 index 870214ea445..00000000000 --- a/doc/user-stories-list.rst +++ /dev/null @@ -1,548 +0,0 @@ -Personas (New) -============== - -* Member of the Public -* Data Journalist (not technical) -* Data Analyst /Developer -* Publisher -* Curator -* Government -* Institution - -Publish and Find Datasets -========================= - -001 Publish a dataset - EXISTING --------------------------------- - -As a **Publisher** I want to **Publish a dataset** so that **I and others can find that dataset** - -* Value: -* Tags: publish-and-find - -001.a Add a licence to a dataset - EXISTING -------------------------------------------- - -As a **Publisher** I want to **Add a licence to a dataset** so that **I can choose from a list of licences when adding my data set to CKAN** - -* Value: -* Tags: publish-and-find - -001.c Add additional metadata fields to a dataset - EXISTING ------------------------------------------------------------- - - -As a **Publisher** I want to **Add additional metadata fields to a dataset** so that **These fields will be viewable with the dataset and searchable on. ** - -* Value: -* Tags: publish-and-find - -001.d Prioritise certain resources - EXISTING ---------------------------------------------- - -As a **Publisher** I want to **Prioritise certain resources** so that **I show see quickly which are the important ones** - -* Value: -* Tags: publish-and-find - -001.e Archive resources ------------------------ - -As a **Publisher** I want to **Archive resources** so that **old or out of date resources can be hidden** - -* Value: -* Tags: publish-and-find - -001.e Create relationships between datasets - EXISTING ------------------------------------------------------- - -As a **Publisher** I want to **create relationships between datasets (e.g. dataset X is derived from dataset Y, inherits from dataset Z)** so that **I can see how datasets relate to each other** - -* Value: -* Tags: publish-and-find - -002 Assign permissions to edit, add, moderate and delete datasets - EXISTING ----------------------------------------------------------------------------- - -As a **Publisher** I want to **Assign permissions to edit, add, moderate and delete datasets** so that **Work can be shared between multiple members (users)** - -* Value: -* Tags: publish-and-find, authz - -003 Delete a dataset - EXISTING -------------------------------- - -As a **Publisher** I want to **Delete a dataset** so that **I can remove datasets that are no longer wanted (or accidentally added)** - -* Value: -* Tags: publish-and-find, authz - -004 Hide a dataset - EXISTING ------------------------------ - -As a **Publisher** I want to **Hide a dataset** so that **So it can be worked on without others seeing it** - -* Value: -* Tags: publish-and-find, authz - -005 Upload data and retrieve it - EXISTING ------------------------------------------- - -As a **Publisher** I want to **Upload data and retrieve it** so that **I (and others) can easily find my material** - -* Value: -* Tags: publish-and-find - -010 View dataset information - EXISTING ---------------------------------------- - -As a **User** I want to **View dataset information (metadata) (e.g. title, description, contact details for publisher and maintainer, license, group theme....)** so that **I can see whether it is useful and relevant** - -* Value: 5 -* Tags: publish-and-find - -010a Access publisher dataset listing through API - EXISTING ------------------------------------------------------------- - -As a **User** I want to **Access publisher dataset listing through API** so that **** - -* Value: -* Tags: publish-and-find, api - -011 Browse datasets - EXISTING ------------------------------- - -As a **User** I want to **Browse datasets** so that **I can look through a list of datasets** - -* Value: -* Tags: publish-and-find - -012 See list of datasets someone has released - EXISTING --------------------------------------------------------- - -As a **Data publisher** I want to **point people at a list of datasets that I have released.** so that **I can show what I have created it and people can get that material** - -* Value: 4 -* Tags: publish-and-find - -013 See the format of availalble data - EXISTING ------------------------------------------------- - -As a **User** I want to **see the format of availalble data** so that **Format of available data be displayed on summary pages and search results** - -* Value: -* Tags: publish-and-find - -014 Download a dataset (have the dataset url) - EXISTING --------------------------------------------------------- - - -As a **User** I want to **Download a dataset (have the dataset url)** so that **I can use it** - -* Value: 5 -* Tags: publish-and-find - -015 See all the materials (files, data APIs) associated to a dataset - EXISTING -------------------------------------------------------------------------------- - -As a **User ** I want to **have a list of all the materials (files, data APIs) associated to a dataset.** so that **in additiona to basic information such as download link, additional information will be displayed such as file type, size, last updated etc.** - -* Value: -* Tags: publish-and-find - -016 Additional field available in the API - EXISTING ----------------------------------------------------- - -As a **Publisher** I want to **have additional field that I specify useable in the API.** so that **Additional fields are machine readable.** - -* Value: -* Tags: publish-and-find, api - - -Dataset Search -============== - -020 Search for datasets by text search and keywords on all attributes - EXISTING --------------------------------------------------------------------------------- - -As a **User** I want to **Search for datasets by text search and keywords and on all attributes** so that **I can find relevant datasets using all dataset metadata** - -* Value: 5 -* Tags: search - -020 Search for closely matching items - EXISTING ------------------------------------------------- - -As a **User** I want to **Search for closely matching items.** so that **I can search on closely matching items instead of exact matches.** - -* Value: -* Tags: search - -021 Be able to narrow the search by drilling down on facets on facets - EXISTING --------------------------------------------------------------------------------- - -As a **User ** I want to **Be able to consecutively narrow the search by further facetsby drilling down on search results** so that **users can limit their search by datasets with specific formats and tags.** - -* Value: -* Tags: search - -022 Easily browse datasets by topic, tag, publishers etc - EXISTING -------------------------------------------------------------------- - -As a **User** I want to **Easily browse through existing datasets by topic, tag, groups publishers and most popular (most downloaded/commented on)** so that **I still have a way to engage with the site even if I'm not looking for a specific dataset ** - -* Value: 2 -* Tags: search - -023 Have topics which datasets belong to ----------------------------------------- - -As a **User** I want to **Have topics (health, environment, education) which datasets belong to that I can browse and search within them** so that **I can intuitively limit my search to the relevant topic I'm interested in** - -* Value: 2 -* Tags: search - -024 See what format a dataset is available in in search results - EXISTING --------------------------------------------------------------------------- - -As a **User** I want to **See what format a dataset is available in from the search results (as tags next to title for example)** so that **I can quickly see which of the search results will be valuable to me** - -* Value: 4 -* Tags: search - -026 See related/similar datasets when viewing a particular dataset - EXISTING ------------------------------------------------------------------------------ - -As a **User** I want to **see related/similar packages when viewing a particular dataset** so that **I can find related useful data that may be relevant or even better suited to my needs (this should be prioritised by metadata & tags as well as popularity of dataset)** - -* Value: -* Tags: search - -Activity and Dashboards -======================= - -040 See my dashboard after logging in - EXISTING ------------------------------------------------- - -As a **User** I want to **See my dashboard after logging in** so that **See activity, stats (such as downloads) and have quick links to actions (such as create new dataset)** - -* Value: 4 -* Tags: activity - -041 View activity stream for datasets, users, groups that I "follow/watch" - PLANNED ------------------------------------------------------------------------------------- - -As a **User** I want to **See a dashboard where I can view activity stream for datasets, users, or groups that I "follow" or have subscribed to** so that **I know what is happening** - -* Value: -* Tags: activity - -043 Follow the activities of datasets and users - PLANNED ---------------------------------------------------------- - -As a **User** I want to **follow the activities of datasets and users** so that **I know what my others are doing and can decide who to pay attention to ** - -* Value: -* Tags: activity - - -Users -===== - -060 Browse and Search Users - EXISTING --------------------------------------- - -As a **User** I want to **Browse and search for other users** so that **So I know about others active on the site** - -* Value: -* Tags: users - -061 See the profile page for another user - EXISTING ----------------------------------------------------- - -As a **User** I want to **See the profile page for another user** so that **I know what they are up to, how recently they have been active, how many datasets they have etc** - -* Value: 4 -* Tags: users - -062 See Status of Other Users - EXISTING ----------------------------------------- - -As a **User** I want to **See what status people have by seeing small bits of info next to their name, e.g. a sign to indicate being a superuser/sysadmin and/or the number of datasets they have** so that **So I know the approximate activity and authority of users I come across** - -* Value: -* Tags: users - - -Visualization -============= - -080 Add a link to a visualization related to a dataset - EXISTING ------------------------------------------------------------------ - -As a **User** I want to **Add a link to a visualization related to a dataset** so that **So that others see that it exists (perhaps see preview in page)** - -* Value: 3.5 -* Tags: vis - -081 Have spreadsheet data be easily previewable - EXISTING ----------------------------------------------------------- - -As a **User** I want to **Have spreadsheet data be easily previewable (ideally as a separate tab) -** so that **I can get a good idea what the dataset contains and if it's what I'm looking for without downloading it first** - -* Value: 5 -* Tags: vis - -082 Be able to generate visualizations/graphs from the data that I can then save or download - EXISTING -------------------------------------------------------------------------------------------------------- - -As a **User** I want to **Be able to generate visualizations/graphs from the data that I can then save or download** so that **I can use a graph representing the data for my work quickly** - -* Value: -* Tags: vis - -083 Choose which parts of the data I want to use for my graph (i.e. date range, column) - EXISTING --------------------------------------------------------------------------------------------------- - -As a **User** I want to **Choose which parts of the data I want to use for my graph (i.e. date range, column)** so that **Customize the visualization to my interests to make it more relevant & hence valuable to me** - -* Value: -* Tags: vis - -084 Add a link to paper or website that is relevant to a dataset - EXISTING ---------------------------------------------------------------------------- - -As a **User** I want to **Add a link to paper or website that is relevant to a dataset** so that **Ditto** - -* Value: 3.5 -* Tags: related - - -Data Storage and Data APIs -========================== - -100 Get easy access to the data (e.g. via an API) - EXISTING ------------------------------------------------------------- - -As a **Developer** I want to **Get easy access to the data (e.g. via an API)** so that **I can use it for building apps around/creating visualizations (and I don't have to spend a long time first downloading the data and getting it into a usable format)** - -* Value: -* Tags: webstore - -101 Create related data ------------------------ - -As a **Data wrangler** I want to **Having created a data file derived from an existing file I want to upload it to a new, named location and document both the relation and the steps performed to create the change.** so that **I (and others) can easily find my material and see what steps when into creating it.** - -* Value: -* Tags: webstore - -103 Search via CKAN API on a data set - EXISTING ------------------------------------------------- - -As a **Developer** I want to **Search via CKAN API on a data set.** so that **dataset is machine reable by other software tools and also allowing the development of new tools for using data.** - -* Value: -* Tags: webstore - -104 Overview of contents of a dataset - EXISTING ------------------------------------------------- - -As a **Data wrangler** I want to **Get an overview of the contents of a dataset by seeing column names, example values, type guesses and the distinct & null values count for each column.** so that **I know whether I want to download and use this tabular dataset just by looking at the dataset page on the datahub.** - -* Value: -* Tags: webstore - -106 Save a graph and designate as the default graph - PLANNED -------------------------------------------------------------- - -As a **Data Wrangler** I want to **Save a graph and designate as the default graph** so that **so it is shown for others who come to my dataset (resource?)** - -* Value: 3 -* Tags: vis, webstore - -107 Convert my csv file to another structure online - PLANNED -------------------------------------------------------------- - -As a **Data Wrangler** I want to **Write some javascript to convert my csv file to another structure and preview a sample of running this in my browser and then save this to run on the whole file with the result saved in a new dataset (resource)** so that ... - -* Value: 1.5 -* Tags: webstore - -108 Write some sql to run on a resource - EXISTING --------------------------------------------------- - -As a **Data Wrangler** I want to **Write some sql to run on a resource ** so that **to produce a new resource as its output** - -* Value: 2 -* Tags: webstore - -109 See links between resources esp derivation - EXISTING ---------------------------------------------------------- - -As a **User** I want to **See that a resource was derived from another resource (or resources) and see reference to code/sql/etc that underlay this transformation** so that **I know that this resource was built from something else** - -* Value: 2 -* Tags: storage-and-processing - -110 Edit a cell in a tabular resource - EXISTING ------------------------------------------------- - -As a **User** I want to **Edit a cell in a tabular resource** so that **It is a correct** - -* Value: 1 -* Tags: webstore - -111 Undo an edit to my resource - FUTURE ----------------------------------------- - -As a **User (Owner)** I want to **Undo an edit to my resource** so that **** - -* Value: 1 -* Tags: webstore - -112 Restrict (or allow) who can edit my tabular resource - EXISTING -------------------------------------------------------------------- - -As a **User (Owner)** I want to **Restrict (or allow) who can edit my tabular resource** so that **** - -* Value: 1 -* Tags: webstore - -113 Comment on a cell in a tabular resource - FUTURE ----------------------------------------------------- - -As a **User (Owner)** I want to **Comment on a cell in a tabular resource** so that **Highlight that something is wrong or highlight something important** - -* Value: 1 -* Tags: webstore - -General User Experience -======================= - -120 As a visitor quickly grasp my options when landing on the site - EXISTING ------------------------------------------------------------------------------ - -As a **Anyone** I want to **quickly grasp my options when landing on the site (learn more, get data, add data, get involved in community)** so that **I know what I can do quickly and start doing it** - -* Value: 5 -* Tags: ux - -121 See the largest groups first on the groups page - EXISTING --------------------------------------------------------------- - -As a **User** I want to **See the largest groups first on the groups page** so that **I can see immediately the most active / largest groups** - -* Value: 3 -* Tags: ux - -122 Simple intro for new users - EXISTING ------------------------------------------ - -As a **New Registered User** I want to **Have some brief instructions as to what I can do next such as register/upload data, file issues, edit existing datasets information** so that **I know what I can do now -- and more importantly -- am encourage to do it** - -* Value: -* Tags: ux - - -Issues -====== - -140 See which of my datasets need updating - PLANNED ----------------------------------------------------- - -As a **Publisher** I want to **quickly see which of my datasets need updating (i.e. have broken links or are flagged as out of date) in a dashboard** so that **I can fix issues easily and from one place** - -* Value: -* Tags: issues - -141 Issue creation and notification for resource problems - PLANNED -------------------------------------------------------------------- - -As a **Publisher** I want to **Issue to be created and a notification to be sent when a resource I have published becomes unavailable. After resolving the issue, I want to report back and close the issue.** so that **I can correct erroneous / dead urls so that others can get my material** - -* Value: -* Tags: issues - -142 File issues against a dataset - PLANNED -------------------------------------------- - -As a **User** I want to **File issues against a dataset, specifying either availability, formatting or content issues. I want to group similar reports (e.g. 500 broken rows in a 20k rows table), set a priority and comment on an issue.** so that **The quality of the data can be determined and remedied.** - -* Value: -* Tags: issues - - -Customization -============= - -160 Theme CKAN - EXISTING -------------------------- - -As a **Administrator** I want to **Theme CKAN** so that **it looks as I want it to** - -* Value: -* Tags: customization - -160.a Add my logo to CKAN - EXISTING ------------------------------------- - -As a **Administrator** I want to **Add my logo to CKAN** so that **it is associated with my organization** - -* Value: -* Tags: customization - - -Geospatial -========== - -200 Associate a dataset with a place on a map - EXISTING --------------------------------------------------------- - -As a **Publisher** I want to **Associate a dataset with a place on a map** so that **A polygon or location point can be viewed on a map.** - -* Value: -* Tags: geospatial - -201 Do a geo based search - EXISTING ------------------------------------- - -As a **User** I want to **Do a geo based search** so that **I can find location - specific data** - -* Value: -* Tags: geospatial - - -Stats -===== - -220 See how many times a dataset has been downloaded / commented on - EXISTING ------------------------------------------------------------------------------- - -As a **User** I want to **See how many times a dataset has been downloaded / commented on** so that **I can gauge how popular and valuable a dataset is & be more likely to look at it** - -* Value: 3 -* Tags: analytics - -221 Share info about my dataset on social media ------------------------------------------------ - -As a **** I want to **Tweet/Fb/G+ about my graph/visualization with a link to the graph & dataset -** so that **I can tell other people about data / trends I find interesting with a clear visual representation of what i'm referring to** - -* Value: -* Tags: social - - -Performance -=========== - -240 Have a page load rapidly (< 750ms) - EXISTING -------------------------------------------------- - -As a **User** I want to **Have a page load rapidly (< 750ms)** so that **the site is responsive and enjoyable to use** - -* Value: 5 -* Tags: performance - diff --git a/doc/user-stories.rst b/doc/user-stories.rst deleted file mode 100644 index 349a774a1fe..00000000000 --- a/doc/user-stories.rst +++ /dev/null @@ -1,9 +0,0 @@ -===================== -User Stories Overview -===================== - -.. toctree:: - :maxdepth: 3 - - user-stories-list - From b2f477f9fe73e07820330a71460a65015332e2f0 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Thu, 28 Mar 2013 12:41:50 +0100 Subject: [PATCH 071/149] [#642] Fix how the check for separate urls is ignored in legacy mode. I put the check for the legacy mode in this function to make it testable. --- ckanext/datastore/plugin.py | 3 ++- ckanext/datastore/tests/test_configure.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index 7ed6657fa93..78cc73c0001 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -142,8 +142,9 @@ def _get_db_from_url(self, url): def _same_read_and_write_url(self): # in legacy mode, this test can be ignored + # because both URLs are set to the same url if self.legacy_mode: - return True + return False return self.write_url == self.read_url def _read_connection_has_correct_privileges(self): diff --git a/ckanext/datastore/tests/test_configure.py b/ckanext/datastore/tests/test_configure.py index a5f1dd24d21..09d7253fcec 100644 --- a/ckanext/datastore/tests/test_configure.py +++ b/ckanext/datastore/tests/test_configure.py @@ -28,7 +28,7 @@ def test_check_separate_write_and_read_if_not_legacy(self): self.p.legacy_mode = True self.p.write_url = 'postgresql://u:pass@localhost/ds' self.p.read_url = 'postgresql://u:pass@localhost/ds' - assert self.p._same_read_and_write_url() + assert not self.p._same_read_and_write_url() self.p.legacy_mode = False From 409eada907641fd63fd60071b5c090b223077748 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Thu, 28 Mar 2013 15:07:41 +0100 Subject: [PATCH 072/149] [#713] Blockify package_basic_fields.html Add a package_basic_fields_custom block to package_basic_fields.html, which IDatasetForm plugins can use to add custom fields to the first page of the new package form without duplicating any template code. Update example_idatasetform to use this new block for one of its custom fields. Fixes #713. --- ckan/templates/package/snippets/package_basic_fields.html | 3 +++ .../templates/package/snippets/package_basic_fields.html | 5 +++++ .../templates/package/snippets/package_metadata_fields.html | 1 - 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 ckanext/example_idatasetform/templates/package/snippets/package_basic_fields.html diff --git a/ckan/templates/package/snippets/package_basic_fields.html b/ckan/templates/package/snippets/package_basic_fields.html index 2b6933f62a3..0fb16754b45 100644 --- a/ckan/templates/package/snippets/package_basic_fields.html +++ b/ckan/templates/package/snippets/package_basic_fields.html @@ -13,6 +13,9 @@ {{ form.prepend('name', id='field-name', label=_('URL'), prepend=prefix, placeholder=_('eg. my-dataset'), value=data.name, error=errors.name, attrs=attrs) }} {% endblock %} +{% block package_basic_fields_custom %} +{% endblock %} + {% block package_basic_fields_description %} {{ form.markdown('notes', id='field-notes', label=_('Description'), placeholder=_('eg. Some useful notes about the data'), value=data.notes, error=errors.notes) }} {% endblock %} diff --git a/ckanext/example_idatasetform/templates/package/snippets/package_basic_fields.html b/ckanext/example_idatasetform/templates/package/snippets/package_basic_fields.html new file mode 100644 index 00000000000..d4fc3bf0f8c --- /dev/null +++ b/ckanext/example_idatasetform/templates/package/snippets/package_basic_fields.html @@ -0,0 +1,5 @@ +{% ckan_extends %} + +{% block package_basic_fields_custom %} + {{ form.input('custom_text', label=_('Custom Text'), id='field-custom_text', placeholder=_('custom text'), value=data.custom_text, error=errors.custom_text, classes=['control-medium']) }} +{% endblock %} diff --git a/ckanext/example_idatasetform/templates/package/snippets/package_metadata_fields.html b/ckanext/example_idatasetform/templates/package/snippets/package_metadata_fields.html index a107638f025..50271bd638d 100644 --- a/ckanext/example_idatasetform/templates/package/snippets/package_metadata_fields.html +++ b/ckanext/example_idatasetform/templates/package/snippets/package_metadata_fields.html @@ -19,7 +19,6 @@ - {{ form.input('custom_text', label=_('Custom Text'), id='field-custom_text', placeholder=_('custom text'), value=data.custom_text, error=errors.custom_text, classes=['control-medium']) }} {{ super() }} From ad07692ac6c1a8c23e4a5bba54bb9defe1a89621 Mon Sep 17 00:00:00 2001 From: amercader Date: Thu, 28 Mar 2013 15:37:21 +0000 Subject: [PATCH 073/149] [#716] Allow to pass the schema as part of the context In some cases extensions need to tweak the schema used on package creation or update. For instance harvesters may want to create a package with a certain id, or relax the default tags schema. There used to be an option via `form_to_db_schema_options` to provide a custom schema via the context, but this was removed on the last IDatasetForm refactor. This adds back the option to pass the schema as part of the context. --- ckan/logic/action/create.py | 5 ++++- ckan/logic/action/get.py | 5 ++++- ckan/logic/action/update.py | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index df259500790..2dfc02189a0 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -109,7 +109,10 @@ def package_create(context, data_dict): package_type = data_dict.get('type') package_plugin = lib_plugins.lookup_package_plugin(package_type) - schema = package_plugin.create_package_schema() + if 'schema' in context: + schema = context['schema'] + else: + schema = package_plugin.create_package_schema() _check_access('package_create', context, data_dict) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index cdeda998d1e..b9d9bda56c7 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -731,7 +731,10 @@ def package_show(context, data_dict): item.read(pkg) package_plugin = lib_plugins.lookup_package_plugin(package_dict['type']) - schema = package_plugin.show_package_schema() + if 'schema' in context: + schema = context['schema'] + else: + schema = package_plugin.show_package_schema() if schema and context.get('validate', True): package_dict, errors = _validate(package_dict, schema, context=context) diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index d6b969bc923..9f9e18dc834 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -235,7 +235,10 @@ def package_update(context, data_dict): # get the schema package_plugin = lib_plugins.lookup_package_plugin(pkg.type) - schema = package_plugin.update_package_schema() + if 'schema' in context: + schema = context['schema'] + else: + schema = package_plugin.update_package_schema() if 'api_version' not in context: # check_data_dict() is deprecated. If the package_plugin has a From 1a9566bc9ce4b4c5e1ac9dfe36e22f296bc7c5f3 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Thu, 28 Mar 2013 19:03:05 +0100 Subject: [PATCH 074/149] [#642] Simplify check for debug mode, only create _foo once --- ckanext/datastore/plugin.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index 78cc73c0001..d7f8fec5d9a 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -55,7 +55,7 @@ def configure(self, config): if not self._is_read_only_database(): # Make sure that the right permissions are set # so that no harmful queries can be made - if not ('debug' in config and config['debug']): + if self.config.get('debug'): if self._same_read_and_write_url(): raise DatastoreException("The write and read-only database " "connection url are the same.") @@ -66,7 +66,7 @@ def configure(self, config): log.warn("Legacy mode active. " "The sql search will not be available.") elif not self._read_connection_has_correct_privileges(): - if 'debug' in self.config and self.config['debug']: + if self.config.get('debug'): log.critical("We have write permissions " "on the read-only database.") else: @@ -154,22 +154,23 @@ def _read_connection_has_correct_privileges(self): ''' write_connection = db._get_engine(None, {'connection_url': self.write_url}).connect() - write_connection.execute( - u"DROP TABLE IF EXISTS public._foo;", - u"CREATE TABLE public._foo ()") - read_connection = db._get_engine(None, {'connection_url': self.read_url}).connect() + drop_foo_sql = u"DROP TABLE IF EXISTS _foo" + + write_connection.execute(drop_foo_sql) + try: - write_connection.execute(u"CREATE TABLE public._foo ()") + write_connection.execute(u"CREATE TABLE _foo ()") for privilege in ['INSERT', 'UPDATE', 'DELETE']: - sql = u"SELECT has_table_privilege('_foo', '{privilege}')".format(privilege=privilege) + test_privilege_sql = u"SELECT has_table_privilege('_foo', '{privilege}')" + sql = test_privilege_sql.format(privilege=privilege) have_privilege = read_connection.execute(sql).first()[0] if have_privilege: return False finally: - write_connection.execute("DROP TABLE _foo") + write_connection.execute(drop_foo_sql) return True def _create_alias_table(self): From 7af31361b8bb9ae013c31aff6a20bfaa4df74286 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Thu, 28 Mar 2013 18:51:51 +0100 Subject: [PATCH 075/149] [#718] Use error codes instead of relying on english error messages in datastore --- ckanext/datastore/db.py | 56 +++++++++++++++++-------------------- ckanext/datastore/plugin.py | 20 +++++++++++++ 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/ckanext/datastore/db.py b/ckanext/datastore/db.py index bc442d9d152..6be6c9fefd0 100644 --- a/ckanext/datastore/db.py +++ b/ckanext/datastore/db.py @@ -11,7 +11,7 @@ import logging import pprint import sqlalchemy -from sqlalchemy.exc import ProgrammingError, IntegrityError +from sqlalchemy.exc import ProgrammingError, IntegrityError, DBAPIError import psycopg2.extras log = logging.getLogger(__name__) @@ -156,15 +156,8 @@ def _is_valid_pg_type(context, type_name): return True else: connection = context['connection'] - try: - connection.execute('SELECT %s::regtype', type_name) - except ProgrammingError, e: - if 'invalid type name' in str(e) or 'does not exist' in str(e): - return False - else: - raise - else: - return True + return connection.execute('SELECT is_valid_type(%s)', + type_name).first()[0] def _get_type(context, oid): @@ -963,23 +956,24 @@ def create(context, data_dict): trans.commit() return _unrename_json_field(data_dict) except IntegrityError, e: - if ('duplicate key value violates unique constraint' in str(e) - or 'could not create unique index' in str(e)): + if int(e.orig.pgcode) == 23505: raise ValidationError({ - 'constraints': ['Cannot insert records or create index because of uniqueness constraint'], + 'constraints': ['Cannot insert records or create index because ' + 'of uniqueness constraint'], 'info': { 'details': str(e) } }) - else: - raise - except Exception, e: - trans.rollback() - if 'due to statement timeout' in str(e): + raise + except DBAPIError, e: + if int(e.orig.pgcode) == 57014: raise ValidationError({ 'query': ['Query took too long'] }) raise + except Exception, e: + trans.rollback() + raise finally: context['connection'].close() @@ -1005,22 +999,24 @@ def upsert(context, data_dict): trans.commit() return _unrename_json_field(data_dict) except IntegrityError, e: - if 'duplicate key value violates unique constraint' in str(e): + if int(e.orig.pgcode) == 23505: raise ValidationError({ - 'constraints': ['Cannot insert records because of uniqueness constraint'], + 'constraints': ['Cannot insert records or create index because ' + 'of uniqueness constraint'], 'info': { 'details': str(e) } }) - else: - raise - except Exception, e: - trans.rollback() - if 'due to statement timeout' in str(e): + raise + except DBAPIError, e: + if int(e.orig.pgcode) == 57014: raise ValidationError({ 'query': ['Query took too long'] }) raise + except Exception, e: + trans.rollback() + raise finally: context['connection'].close() @@ -1079,8 +1075,8 @@ def search(context, data_dict): data_dict['resource_id'])] }) return search_data(context, data_dict) - except Exception, e: - if 'due to statement timeout' in str(e): + except DBAPIError, e: + if int(e.orig.pgcode) == 57014: raise ValidationError({ 'query': ['Search took too long'] }) @@ -1112,10 +1108,10 @@ def search_sql(context, data_dict): 'orig': [str(e.orig)] } }) - except Exception, e: - if 'due to statement timeout' in str(e): + except DBAPIError, e: + if int(e.orig.pgcode) == 57014: raise ValidationError({ - 'query': ['Search took too long'] + 'query': ['Query took too long'] }) raise finally: diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index 6f67d4ec64b..ef01e3427b2 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -105,6 +105,7 @@ def new_resource_show(context, data_dict): if not hasattr(resource_show, '_datastore_wrapped'): new_resource_show._datastore_wrapped = True logic._actions['resource_show'] = new_resource_show + self._add_is_valid_type_function() def _is_read_only_database(self): for url in [self.ckan_url, self.write_url, self.read_url]: @@ -205,6 +206,25 @@ def _create_alias_table(self): {'connection_url': pylons.config['ckan.datastore.write_url']}).connect() connection.execute(create_alias_table_sql) + def _add_is_valid_type_function(self): + # syntax_error - may occur if someone provides a keyword as a type + # undefined_object - is raised if the type does not exist + create_func_sql = ''' + CREATE OR REPLACE FUNCTION is_valid_type(v_type text) + RETURNS boolean + AS $$ + BEGIN + PERFORM v_type::regtype; + RETURN true; + EXCEPTION WHEN undefined_object OR syntax_error THEN + RETURN false; + END; + $$ LANGUAGE plpgsql stable; + ''' + connection = db._get_engine(None, + {'connection_url': pylons.config['ckan.datastore.write_url']}).connect() + connection.execute(create_func_sql) + def get_actions(self): actions = {'datastore_create': action.datastore_create, 'datastore_upsert': action.datastore_upsert, From 26b45b6dbae8aac1859ebfcd9cfdf618c1404199 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Thu, 28 Mar 2013 19:50:25 +0100 Subject: [PATCH 076/149] [#718] Move pg error codes in a separate dictionary --- ckanext/datastore/db.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/ckanext/datastore/db.py b/ckanext/datastore/db.py index 6be6c9fefd0..63b026969f7 100644 --- a/ckanext/datastore/db.py +++ b/ckanext/datastore/db.py @@ -31,15 +31,21 @@ def __init__(self, error_dict): _type_names = set() _engines = {} +# See http://www.postgresql.org/docs/9.2/static/errcodes-appendix.html +_pg_err_code = { + 'unique_violation': 23505, + 'query_canceled': 57014 +} + _date_formats = ['%Y-%m-%d', - '%Y-%m-%d %H:%M:%S', - '%Y-%m-%dT%H:%M:%S', - '%Y-%m-%dT%H:%M:%SZ', - '%d/%m/%Y', - '%m/%d/%Y', - '%d-%m-%Y', - '%m-%d-%Y', - ] + '%Y-%m-%d %H:%M:%S', + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%dT%H:%M:%SZ', + '%d/%m/%Y', + '%m/%d/%Y', + '%d-%m-%Y', + '%m-%d-%Y', + ] INSERT = 'insert' UPSERT = 'upsert' UPDATE = 'update' @@ -956,7 +962,7 @@ def create(context, data_dict): trans.commit() return _unrename_json_field(data_dict) except IntegrityError, e: - if int(e.orig.pgcode) == 23505: + if int(e.orig.pgcode) == _pg_err_code['unique_violation']: raise ValidationError({ 'constraints': ['Cannot insert records or create index because ' 'of uniqueness constraint'], @@ -966,7 +972,7 @@ def create(context, data_dict): }) raise except DBAPIError, e: - if int(e.orig.pgcode) == 57014: + if int(e.orig.pgcode) == _pg_err_code['query_canceled']: raise ValidationError({ 'query': ['Query took too long'] }) @@ -999,7 +1005,7 @@ def upsert(context, data_dict): trans.commit() return _unrename_json_field(data_dict) except IntegrityError, e: - if int(e.orig.pgcode) == 23505: + if int(e.orig.pgcode) == _pg_err_code['unique_violation']: raise ValidationError({ 'constraints': ['Cannot insert records or create index because ' 'of uniqueness constraint'], @@ -1009,7 +1015,7 @@ def upsert(context, data_dict): }) raise except DBAPIError, e: - if int(e.orig.pgcode) == 57014: + if int(e.orig.pgcode) == _pg_err_code['query_canceled']: raise ValidationError({ 'query': ['Query took too long'] }) @@ -1076,7 +1082,7 @@ def search(context, data_dict): }) return search_data(context, data_dict) except DBAPIError, e: - if int(e.orig.pgcode) == 57014: + if int(e.orig.pgcode) == _pg_err_code['query_canceled']: raise ValidationError({ 'query': ['Search took too long'] }) @@ -1109,7 +1115,7 @@ def search_sql(context, data_dict): } }) except DBAPIError, e: - if int(e.orig.pgcode) == 57014: + if int(e.orig.pgcode) == _pg_err_code['query_canceled']: raise ValidationError({ 'query': ['Query took too long'] }) From c82ba857ba6f3073948a101a1e16a5bf59548abd Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Thu, 28 Mar 2013 23:08:47 +0100 Subject: [PATCH 077/149] [#642] Refactored datastore config to make it easier to understand and easier to test --- ckanext/datastore/plugin.py | 71 ++++++++++++----------- ckanext/datastore/tests/test_configure.py | 56 +++++++++++++----- 2 files changed, 79 insertions(+), 48 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index d7f8fec5d9a..f055878b9d9 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -48,39 +48,30 @@ def configure(self, config): self.write_url = self.config['ckan.datastore.write_url'] if self.legacy_mode: self.read_url = self.write_url + log.warn("Legacy mode active. " + "The sql search will not be available.") else: self.read_url = self.config['ckan.datastore.read_url'] - if model.engine_is_pg(): - if not self._is_read_only_database(): - # Make sure that the right permissions are set - # so that no harmful queries can be made - if self.config.get('debug'): - if self._same_read_and_write_url(): - raise DatastoreException("The write and read-only database " - "connection url are the same.") - if self._same_ckan_and_datastore_db(): - raise DatastoreException("CKAN and DataStore database " - "cannot be the same.") - if self.legacy_mode: - log.warn("Legacy mode active. " - "The sql search will not be available.") - elif not self._read_connection_has_correct_privileges(): - if self.config.get('debug'): - log.critical("We have write permissions " - "on the read-only database.") - else: - raise DatastoreException("We have write permissions " - "on the read-only database.") - self._create_alias_table() - else: - log.warn("We detected that CKAN is running on a read " - "only database. Permission checks and the creation " - "of _table_metadata are skipped.") - else: + if not model.engine_is_pg(): log.warn("We detected that you do not use a PostgreSQL " - "database. The DataStore will NOT work and datastore " + "database. The DataStore will NOT work and DataStore " "tests will be skipped.") + return + + if self._is_read_only_database(): + log.warn("We detected that CKAN is running on a read " + "only database. Permission checks and the creation " + "of _table_metadata are skipped.") + else: + if self.config.get('debug'): + handler = log.critical + else: + def handler(message): + raise DatastoreException(message) + self._check_urls_and_permissions(handler) + + self._create_alias_table() ## Do light wrapping around action function to add datastore_active ## to resource dict. Not using IAction extension as this prevents @@ -115,6 +106,22 @@ def new_resource_show(context, data_dict): new_resource_show._datastore_wrapped = True logic._actions['resource_show'] = new_resource_show + def _check_urls_and_permissions(self, handler): + # Make sure that the right permissions are set + # so that no harmful queries can be made + + if not self.legacy_mode and self._same_read_and_write_url(): + handler("The write and read-only database " + "connection urls are the same.") + + if self._same_ckan_and_datastore_db(): + handler("CKAN and DataStore database " + "cannot be the same.") + + if not self._read_connection_has_correct_privileges(): + handler("We have write permissions " + "on the read-only database.") + def _is_read_only_database(self): ''' Returns True if no connection has CREATE privileges on the public @@ -133,18 +140,12 @@ def _same_ckan_and_datastore_db(self): ''' Returns True if the CKAN and DataStore db are the same ''' - if self._get_db_from_url(self.ckan_url) == self._get_db_from_url(self.read_url): - return True - return False + return self._get_db_from_url(self.ckan_url) == self._get_db_from_url(self.read_url) def _get_db_from_url(self, url): return url[url.rindex("@"):] def _same_read_and_write_url(self): - # in legacy mode, this test can be ignored - # because both URLs are set to the same url - if self.legacy_mode: - return False return self.write_url == self.read_url def _read_connection_has_correct_privileges(self): diff --git a/ckanext/datastore/tests/test_configure.py b/ckanext/datastore/tests/test_configure.py index 09d7253fcec..50a83e71057 100644 --- a/ckanext/datastore/tests/test_configure.py +++ b/ckanext/datastore/tests/test_configure.py @@ -1,5 +1,7 @@ import unittest +import sqlalchemy + import ckanext.datastore.plugin as plugin @@ -24,16 +26,7 @@ def test_set_legacy_mode(self): assert self.p.write_url == 'foo' assert self.p.read_url == 'foo' - def test_check_separate_write_and_read_if_not_legacy(self): - self.p.legacy_mode = True - self.p.write_url = 'postgresql://u:pass@localhost/ds' - self.p.read_url = 'postgresql://u:pass@localhost/ds' - assert not self.p._same_read_and_write_url() - - self.p.legacy_mode = False - - assert not self.p.legacy_mode - + def test_check_separate_write_and_read_url(self): self.p.write_url = 'postgresql://u:pass@localhost/ds' self.p.read_url = 'postgresql://u:pass@localhost/ds' assert self.p._same_read_and_write_url() @@ -43,14 +36,51 @@ def test_check_separate_write_and_read_if_not_legacy(self): assert not self.p._same_read_and_write_url() def test_same_ckan_and_datastore_db(self): - self.p.write_url = 'postgresql://u:pass@localhost/ckan' - self.p.read_url = 'postgresql://u:pass@localhost/ckan' + self.p.read_url = 'postgresql://u2:pass@localhost/ckan' self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' assert self.p._same_ckan_and_datastore_db() - self.p.write_url = 'postgresql://u:pass@localhost/dt' self.p.read_url = 'postgresql://u:pass@localhost/dt' self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' assert not self.p._same_ckan_and_datastore_db() + + def test_check_urls_and_permissions(self): + self.p.legacy_mode = False + self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' + self.p.write_url = 'postgresql://u:pass@localhost/ds' + self.p.read_url = 'postgresql://u:pass@localhost/ds' + + def handler(message): + assert 'urls are the same' in message, message + try: + self.p._check_urls_and_permissions(handler) + except sqlalchemy.exc.OperationalError: + pass + else: + assert False + + self.p.ckan_url = 'postgresql://u:pass@localhost/ds' + self.p.legacy_mode = True + + def handler2(message): + assert 'cannot be the same' in message, message + try: + self.p._check_urls_and_permissions(handler2) + except sqlalchemy.exc.OperationalError: + pass + else: + assert False + + self.p.read_url = 'postgresql://u2:pass@localhost/ds' + self.p.legacy_mode = False + + def handler3(message): + assert 'cannot be the same' in message, message + try: + self.p._check_urls_and_permissions(handler3) + except sqlalchemy.exc.OperationalError: + pass + else: + assert False From c2e13e51f3e4aed97a7dc7fa944a9007b3637298 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Thu, 28 Mar 2013 23:23:09 +0100 Subject: [PATCH 078/149] [#718] Try to avoid PL/pgSQL since we cannot guarantee that is is activated --- ckanext/datastore/db.py | 51 ++++++++++++++++++++++--------------- ckanext/datastore/plugin.py | 20 --------------- 2 files changed, 30 insertions(+), 41 deletions(-) diff --git a/ckanext/datastore/db.py b/ckanext/datastore/db.py index 63b026969f7..0edca4c51b7 100644 --- a/ckanext/datastore/db.py +++ b/ckanext/datastore/db.py @@ -32,9 +32,11 @@ def __init__(self, error_dict): _engines = {} # See http://www.postgresql.org/docs/9.2/static/errcodes-appendix.html -_pg_err_code = { +_PG_ERR_CODE = { 'unique_violation': 23505, - 'query_canceled': 57014 + 'query_canceled': 57014, + 'undefined_object': 42704, + 'syntax_error': 42601 } _date_formats = ['%Y-%m-%d', @@ -46,10 +48,10 @@ def __init__(self, error_dict): '%d-%m-%Y', '%m-%d-%Y', ] -INSERT = 'insert' -UPSERT = 'upsert' -UPDATE = 'update' -_methods = [INSERT, UPSERT, UPDATE] + +_INSERT = 'insert' +_UPSERT = 'upsert' +_UPDATE = 'update' def _strip(input): @@ -162,8 +164,15 @@ def _is_valid_pg_type(context, type_name): return True else: connection = context['connection'] - return connection.execute('SELECT is_valid_type(%s)', - type_name).first()[0] + try: + connection.execute('SELECT %s::regtype', type_name) + except ProgrammingError, e: + if int(e.orig.pgcode) in [_PG_ERR_CODE['undefined_object'], + _PG_ERR_CODE['syntax_error']]: + return False + raise + else: + return True def _get_type(context, oid): @@ -520,7 +529,7 @@ def alter_table(context, data_dict): def insert_data(context, data_dict): - data_dict['method'] = INSERT + data_dict['method'] = _INSERT return upsert_data(context, data_dict) @@ -529,9 +538,9 @@ def upsert_data(context, data_dict): if not data_dict.get('records'): return - method = data_dict.get('method', UPSERT) + method = data_dict.get('method', _UPSERT) - if method not in _methods: + if method not in [_INSERT, _UPSERT, _UPDATE]: raise ValidationError({ 'method': [u'"{0}" is not defined'.format(method)] }) @@ -542,7 +551,7 @@ def upsert_data(context, data_dict): sql_columns = ", ".join(['"%s"' % name.replace('%', '%%') for name in field_names] + ['"_full_text"']) - if method == INSERT: + if method == _INSERT: rows = [] for num, record in enumerate(records): _validate_record(record, num, field_names) @@ -565,7 +574,7 @@ def upsert_data(context, data_dict): context['connection'].execute(sql_string, rows) - elif method in [UPDATE, UPSERT]: + elif method in [_UPDATE, _UPSERT]: unique_keys = _get_unique_key(context, data_dict) if len(unique_keys) < 1: raise ValidationError({ @@ -607,7 +616,7 @@ def upsert_data(context, data_dict): full_text = _to_full_text(fields, record) - if method == UPDATE: + if method == _UPDATE: sql_string = u''' UPDATE "{res_id}" SET ({columns}, "_full_text") = ({values}, to_tsvector(%s)) @@ -628,7 +637,7 @@ def upsert_data(context, data_dict): 'key': [u'key "{0}" not found'.format(unique_values)] }) - elif method == UPSERT: + elif method == _UPSERT: sql_string = u''' UPDATE "{res_id}" SET ({columns}, "_full_text") = ({values}, to_tsvector(%s)) @@ -962,7 +971,7 @@ def create(context, data_dict): trans.commit() return _unrename_json_field(data_dict) except IntegrityError, e: - if int(e.orig.pgcode) == _pg_err_code['unique_violation']: + if int(e.orig.pgcode) == _PG_ERR_CODE['unique_violation']: raise ValidationError({ 'constraints': ['Cannot insert records or create index because ' 'of uniqueness constraint'], @@ -972,7 +981,7 @@ def create(context, data_dict): }) raise except DBAPIError, e: - if int(e.orig.pgcode) == _pg_err_code['query_canceled']: + if int(e.orig.pgcode) == _PG_ERR_CODE['query_canceled']: raise ValidationError({ 'query': ['Query took too long'] }) @@ -1005,7 +1014,7 @@ def upsert(context, data_dict): trans.commit() return _unrename_json_field(data_dict) except IntegrityError, e: - if int(e.orig.pgcode) == _pg_err_code['unique_violation']: + if int(e.orig.pgcode) == _PG_ERR_CODE['unique_violation']: raise ValidationError({ 'constraints': ['Cannot insert records or create index because ' 'of uniqueness constraint'], @@ -1015,7 +1024,7 @@ def upsert(context, data_dict): }) raise except DBAPIError, e: - if int(e.orig.pgcode) == _pg_err_code['query_canceled']: + if int(e.orig.pgcode) == _PG_ERR_CODE['query_canceled']: raise ValidationError({ 'query': ['Query took too long'] }) @@ -1082,7 +1091,7 @@ def search(context, data_dict): }) return search_data(context, data_dict) except DBAPIError, e: - if int(e.orig.pgcode) == _pg_err_code['query_canceled']: + if int(e.orig.pgcode) == _PG_ERR_CODE['query_canceled']: raise ValidationError({ 'query': ['Search took too long'] }) @@ -1115,7 +1124,7 @@ def search_sql(context, data_dict): } }) except DBAPIError, e: - if int(e.orig.pgcode) == _pg_err_code['query_canceled']: + if int(e.orig.pgcode) == _PG_ERR_CODE['query_canceled']: raise ValidationError({ 'query': ['Query took too long'] }) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index ef01e3427b2..6f67d4ec64b 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -105,7 +105,6 @@ def new_resource_show(context, data_dict): if not hasattr(resource_show, '_datastore_wrapped'): new_resource_show._datastore_wrapped = True logic._actions['resource_show'] = new_resource_show - self._add_is_valid_type_function() def _is_read_only_database(self): for url in [self.ckan_url, self.write_url, self.read_url]: @@ -206,25 +205,6 @@ def _create_alias_table(self): {'connection_url': pylons.config['ckan.datastore.write_url']}).connect() connection.execute(create_alias_table_sql) - def _add_is_valid_type_function(self): - # syntax_error - may occur if someone provides a keyword as a type - # undefined_object - is raised if the type does not exist - create_func_sql = ''' - CREATE OR REPLACE FUNCTION is_valid_type(v_type text) - RETURNS boolean - AS $$ - BEGIN - PERFORM v_type::regtype; - RETURN true; - EXCEPTION WHEN undefined_object OR syntax_error THEN - RETURN false; - END; - $$ LANGUAGE plpgsql stable; - ''' - connection = db._get_engine(None, - {'connection_url': pylons.config['ckan.datastore.write_url']}).connect() - connection.execute(create_func_sql) - def get_actions(self): actions = {'datastore_create': action.datastore_create, 'datastore_upsert': action.datastore_upsert, From 0f8c1965f8507bb27826edee90e6ba72b2cb6191 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Fri, 29 Mar 2013 12:53:40 +0100 Subject: [PATCH 079/149] [#642] Ignore permission check in legacy mode and improve configuration tests --- ckanext/datastore/plugin.py | 37 +++++----- ckanext/datastore/tests/test_configure.py | 89 +++++++++++++++-------- 2 files changed, 79 insertions(+), 47 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index f055878b9d9..40c163b8c33 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -1,6 +1,5 @@ import logging import pylons -from sqlalchemy.exc import ProgrammingError import ckan.plugins as p import ckanext.datastore.logic.action as action @@ -64,12 +63,7 @@ def configure(self, config): "only database. Permission checks and the creation " "of _table_metadata are skipped.") else: - if self.config.get('debug'): - handler = log.critical - else: - def handler(message): - raise DatastoreException(message) - self._check_urls_and_permissions(handler) + self._check_urls_and_permissions(self._log_or_raise) self._create_alias_table() @@ -106,21 +100,30 @@ def new_resource_show(context, data_dict): new_resource_show._datastore_wrapped = True logic._actions['resource_show'] = new_resource_show - def _check_urls_and_permissions(self, handler): + def _log_or_raise(self, message): + if self.config.get('debug'): + log.critical(message) + else: + raise DatastoreException(message) + + def _check_urls_and_permissions(self, error_handler): # Make sure that the right permissions are set # so that no harmful queries can be made - if not self.legacy_mode and self._same_read_and_write_url(): - handler("The write and read-only database " - "connection urls are the same.") - if self._same_ckan_and_datastore_db(): - handler("CKAN and DataStore database " - "cannot be the same.") + error_handler("CKAN and DataStore database " + "cannot be the same.") + + # in legacy mode, the read and write url are ths same (both write url) + # consequently the same url check and and write privilege check + # don't make sense + if not self.legacy_mode: + if self._same_read_and_write_url(): + error_handler("The write and read-only database " + "connection urls are the same.") - if not self._read_connection_has_correct_privileges(): - handler("We have write permissions " - "on the read-only database.") + if not self._read_connection_has_correct_privileges(): + error_handler("The read-only user has write privileges.") def _is_read_only_database(self): ''' diff --git a/ckanext/datastore/tests/test_configure.py b/ckanext/datastore/tests/test_configure.py index 50a83e71057..2ab40412a8f 100644 --- a/ckanext/datastore/tests/test_configure.py +++ b/ckanext/datastore/tests/test_configure.py @@ -1,9 +1,12 @@ import unittest - -import sqlalchemy +from nose.tools import assert_equal import ckanext.datastore.plugin as plugin +# global variable used for callback tests +msg = '' +called = 0 + class TestTypeGetters(unittest.TestCase): def setUp(self): @@ -38,49 +41,75 @@ def test_check_separate_write_and_read_url(self): def test_same_ckan_and_datastore_db(self): self.p.read_url = 'postgresql://u2:pass@localhost/ckan' self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' - assert self.p._same_ckan_and_datastore_db() self.p.read_url = 'postgresql://u:pass@localhost/dt' self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' - assert not self.p._same_ckan_and_datastore_db() def test_check_urls_and_permissions(self): + global msg + + def handler(message): + global msg, called + msg = message + called += 1 + + def true_privileges_mock(): + return True + + def false_privileges_mock(): + return False + self.p.legacy_mode = False self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' self.p.write_url = 'postgresql://u:pass@localhost/ds' - self.p.read_url = 'postgresql://u:pass@localhost/ds' + self.p.read_url = 'postgresql://u2:pass@localhost/ds' + self.p._read_connection_has_correct_privileges = true_privileges_mock - def handler(message): - assert 'urls are the same' in message, message - try: - self.p._check_urls_and_permissions(handler) - except sqlalchemy.exc.OperationalError: - pass - else: - assert False + # all urls are correct + self.p._check_urls_and_permissions(handler) + assert_equal(msg, '') + assert_equal(called, 0) - self.p.ckan_url = 'postgresql://u:pass@localhost/ds' + # same url for read and write but in legacy mode self.p.legacy_mode = True + self.p.read_url = 'postgresql://u:pass@localhost/ds' + self.p._check_urls_and_permissions(handler) + assert_equal(msg, '') + assert_equal(called, 0) - def handler2(message): - assert 'cannot be the same' in message, message - try: - self.p._check_urls_and_permissions(handler2) - except sqlalchemy.exc.OperationalError: - pass - else: - assert False + # same url for read and write + self.p.legacy_mode = False + self.p._check_urls_and_permissions(handler) + assert 'urls are the same' in msg, msg + assert_equal(called, 1) + # same ckan and ds db + self.p.ckan_url = 'postgresql://u:pass@localhost/ds' self.p.read_url = 'postgresql://u2:pass@localhost/ds' + self.p._check_urls_and_permissions(handler) + assert 'cannot be the same' in msg, msg + assert_equal(called, 2) + + # have write permissions but in legacy mode + self.p.legacy_mode = True + msg = '' + self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' + self.p._read_connection_has_correct_privileges = false_privileges_mock + self.p._check_urls_and_permissions(handler) + assert_equal(msg, '') + assert_equal(called, 2) + + # have write permissions self.p.legacy_mode = False + self.p._check_urls_and_permissions(handler) + assert 'user has write privileges' in msg, msg + assert_equal(called, 3) - def handler3(message): - assert 'cannot be the same' in message, message - try: - self.p._check_urls_and_permissions(handler3) - except sqlalchemy.exc.OperationalError: - pass - else: - assert False + # everything is wrong + self.p.ckan_url = 'postgresql://u:pass@localhost/ds' + self.p.write_url = 'postgresql://u:pass@localhost/ds' + self.p.read_url = 'postgresql://u:pass@localhost/ds' + self.p._check_urls_and_permissions(handler) + assert_equal(called, 6) From bb2ca7f58005142b7008132692b9cdc954045b02 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Mon, 1 Apr 2013 19:08:45 +0200 Subject: [PATCH 080/149] [#642] Inject `error_handler` instead of explicitly passing it as an argument, split large test into smaller tests --- ckanext/datastore/plugin.py | 14 +-- ckanext/datastore/tests/test_configure.py | 101 ++++++++++------------ 2 files changed, 52 insertions(+), 63 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index 40c163b8c33..95ddad946bf 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -63,7 +63,7 @@ def configure(self, config): "only database. Permission checks and the creation " "of _table_metadata are skipped.") else: - self._check_urls_and_permissions(self._log_or_raise) + self._check_urls_and_permissions() self._create_alias_table() @@ -106,24 +106,24 @@ def _log_or_raise(self, message): else: raise DatastoreException(message) - def _check_urls_and_permissions(self, error_handler): + def _check_urls_and_permissions(self): # Make sure that the right permissions are set # so that no harmful queries can be made if self._same_ckan_and_datastore_db(): - error_handler("CKAN and DataStore database " - "cannot be the same.") + self._log_or_raise("CKAN and DataStore database " + "cannot be the same.") # in legacy mode, the read and write url are ths same (both write url) # consequently the same url check and and write privilege check # don't make sense if not self.legacy_mode: if self._same_read_and_write_url(): - error_handler("The write and read-only database " - "connection urls are the same.") + self._log_or_raise("The write and read-only database " + "connection urls are the same.") if not self._read_connection_has_correct_privileges(): - error_handler("The read-only user has write privileges.") + self._log_or_raise("The read-only user has write privileges.") def _is_read_only_database(self): ''' diff --git a/ckanext/datastore/tests/test_configure.py b/ckanext/datastore/tests/test_configure.py index 2ab40412a8f..8cf8e5e36a2 100644 --- a/ckanext/datastore/tests/test_configure.py +++ b/ckanext/datastore/tests/test_configure.py @@ -1,14 +1,10 @@ import unittest -from nose.tools import assert_equal +from nose.tools import raises import ckanext.datastore.plugin as plugin -# global variable used for callback tests -msg = '' -called = 0 - -class TestTypeGetters(unittest.TestCase): +class TestConfiguration(unittest.TestCase): def setUp(self): self.p = plugin.DatastorePlugin() @@ -47,69 +43,62 @@ def test_same_ckan_and_datastore_db(self): self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' assert not self.p._same_ckan_and_datastore_db() - def test_check_urls_and_permissions(self): - global msg - - def handler(message): - global msg, called - msg = message - called += 1 - - def true_privileges_mock(): - return True - def false_privileges_mock(): - return False +class TestCheckUrlsAndPermissions(unittest.TestCase): + def setUp(self): + self.p = plugin.DatastorePlugin() self.p.legacy_mode = False + + # initialize URLs self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' self.p.write_url = 'postgresql://u:pass@localhost/ds' self.p.read_url = 'postgresql://u2:pass@localhost/ds' + + # initialize mock for privileges check + def true_privileges_mock(): + return True self.p._read_connection_has_correct_privileges = true_privileges_mock - # all urls are correct - self.p._check_urls_and_permissions(handler) - assert_equal(msg, '') - assert_equal(called, 0) + def raise_datastore_exception(message): + raise plugin.DatastoreException(message) + self.p._log_or_raise = raise_datastore_exception - # same url for read and write but in legacy mode - self.p.legacy_mode = True - self.p.read_url = 'postgresql://u:pass@localhost/ds' - self.p._check_urls_and_permissions(handler) - assert_equal(msg, '') - assert_equal(called, 0) + def test_everything_correct_does_not_raise(self): + self.p._check_urls_and_permissions() - # same url for read and write - self.p.legacy_mode = False - self.p._check_urls_and_permissions(handler) - assert 'urls are the same' in msg, msg - assert_equal(called, 1) + @raises(plugin.DatastoreException) + def test_raises_when_ckan_and_datastore_db_are_the_same(self): + self.p.read_url = 'postgresql://u2:pass@localhost/ckan' + self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' - # same ckan and ds db - self.p.ckan_url = 'postgresql://u:pass@localhost/ds' - self.p.read_url = 'postgresql://u2:pass@localhost/ds' - self.p._check_urls_and_permissions(handler) - assert 'cannot be the same' in msg, msg - assert_equal(called, 2) + self.p._check_urls_and_permissions() - # have write permissions but in legacy mode + @raises(plugin.DatastoreException) + def test_raises_when_same_read_and_write_url(self): + self.p.read_url = 'postgresql://u:pass@localhost/ds' + self.p.write_url = 'postgresql://u:pass@localhost/ds' + + self.p._check_urls_and_permissions() + + def test_same_read_and_write_url_in_legacy_mode(self): + self.p.read_url = 'postgresql://u:pass@localhost/ds' + self.p.write_url = 'postgresql://u:pass@localhost/ds' self.p.legacy_mode = True - msg = '' - self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' + + self.p._check_urls_and_permissions() + + @raises(plugin.DatastoreException) + def test_raises_when_we_have_write_permissions(self): + def false_privileges_mock(): + return False self.p._read_connection_has_correct_privileges = false_privileges_mock - self.p._check_urls_and_permissions(handler) - assert_equal(msg, '') - assert_equal(called, 2) + self.p._check_urls_and_permissions() - # have write permissions - self.p.legacy_mode = False - self.p._check_urls_and_permissions(handler) - assert 'user has write privileges' in msg, msg - assert_equal(called, 3) + def test_have_write_permissions_in_legacy_mode(self): + def false_privileges_mock(): + return False + self.p._read_connection_has_correct_privileges = false_privileges_mock + self.p.legacy_mode = True - # everything is wrong - self.p.ckan_url = 'postgresql://u:pass@localhost/ds' - self.p.write_url = 'postgresql://u:pass@localhost/ds' - self.p.read_url = 'postgresql://u:pass@localhost/ds' - self.p._check_urls_and_permissions(handler) - assert_equal(called, 6) + self.p._check_urls_and_permissions() From ad4bb46df8a2913b0bb487dba8ff2468c977e1b6 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Mon, 1 Apr 2013 21:59:17 +0200 Subject: [PATCH 081/149] [#642] Add plugin loading and unloading. This does not fix the singleton issue but is better anyway. --- ckanext/datastore/tests/test_configure.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ckanext/datastore/tests/test_configure.py b/ckanext/datastore/tests/test_configure.py index 8cf8e5e36a2..2594e988700 100644 --- a/ckanext/datastore/tests/test_configure.py +++ b/ckanext/datastore/tests/test_configure.py @@ -1,4 +1,5 @@ import unittest +import ckan.plugins as p from nose.tools import raises import ckanext.datastore.plugin as plugin @@ -6,7 +7,10 @@ class TestConfiguration(unittest.TestCase): def setUp(self): - self.p = plugin.DatastorePlugin() + self.p = p.load('datastore') + + def tearDown(self): + p.unload('datastore') def test_legacy_mode_default(self): assert not self.p.legacy_mode @@ -46,7 +50,7 @@ def test_same_ckan_and_datastore_db(self): class TestCheckUrlsAndPermissions(unittest.TestCase): def setUp(self): - self.p = plugin.DatastorePlugin() + self.p = p.load('datastore') self.p.legacy_mode = False @@ -64,6 +68,9 @@ def raise_datastore_exception(message): raise plugin.DatastoreException(message) self.p._log_or_raise = raise_datastore_exception + def tearDown(self): + p.unload('datastore') + def test_everything_correct_does_not_raise(self): self.p._check_urls_and_permissions() From c76c351555f7daa44aa2be1f671363a76857d2b7 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Mon, 1 Apr 2013 22:01:53 +0200 Subject: [PATCH 082/149] [#621] Move extras_validation into __before This fixes an issue where creating a new resource would crash in validation if there was a custom field (using convert_to/from_extras) with a name that sorted after extras validation alphabetically. Fixes #621. --- ckan/logic/schema.py | 2 +- ckan/logic/validators.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index 07b22b6f9cb..515846284d5 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -116,6 +116,7 @@ def default_create_tag_schema(): def default_create_package_schema(): schema = { + '__before': [duplicate_extras_key, ignore], 'id': [empty], 'revision_id': [ignore], 'name': [not_empty, unicode, name_validator, package_name_validator], @@ -139,7 +140,6 @@ def default_create_package_schema(): 'tags': default_tags_schema(), 'tag_string': [ignore_missing, tag_string_convert], 'extras': default_extras_schema(), - 'extras_validation': [duplicate_extras_key, ignore], 'save': [ignore], 'return_to': [ignore], 'relationships_as_object': default_relationship_schema(), diff --git a/ckan/logic/validators.py b/ckan/logic/validators.py index b671231a41f..37af309ac31 100644 --- a/ckan/logic/validators.py +++ b/ckan/logic/validators.py @@ -313,7 +313,7 @@ def duplicate_extras_key(key, data, errors, context): for extra_key in set(extras_keys): extras_keys.remove(extra_key) if extras_keys: - errors[key].append(_('Duplicate key "%s"') % extras_keys[0]) + errors['extras_validation'].append(_('Duplicate key "%s"') % extras_keys[0]) def group_name_validator(key, data, errors, context): model = context['model'] From 494420ffaa7eeb7e4b676f6ffcc0a44836f4b407 Mon Sep 17 00:00:00 2001 From: tobes Date: Tue, 2 Apr 2013 18:29:20 +0100 Subject: [PATCH 083/149] [#727] Clean up imports in core --- ckan/config/environment.py | 27 +++++++++-------- ckan/controllers/api.py | 41 +++++++++++++------------- ckan/controllers/feed.py | 29 +++++++++--------- ckan/controllers/home.py | 5 ++-- ckan/controllers/related.py | 7 ++--- ckan/controllers/revision.py | 16 +++++----- ckan/controllers/tag.py | 5 ++-- ckan/controllers/user.py | 5 ++-- ckan/lib/activity_streams.py | 4 +-- ckan/lib/alphabet_paginate.py | 3 +- ckan/lib/base.py | 10 +++++-- ckan/lib/email_notifications.py | 5 ++-- ckan/lib/formatters.py | 3 +- ckan/lib/mailer.py | 13 ++++---- ckan/lib/navl/dictization_functions.py | 3 +- ckan/lib/navl/validators.py | 10 +++++-- ckan/logic/__init__.py | 22 +++++++------- ckan/logic/action/create.py | 5 ++-- ckan/logic/action/delete.py | 4 ++- ckan/logic/action/get.py | 4 +-- ckan/logic/action/update.py | 36 +++++++++++----------- ckan/logic/auth/create.py | 4 +-- ckan/logic/converters.py | 36 +++++++++++----------- ckan/logic/validators.py | 35 ++++++++++++---------- ckan/model/license.py | 4 +-- ckan/model/package_relationship.py | 2 +- ckan/plugins/toolkit.py | 8 ++--- ckan/tests/functional/test_home.py | 3 +- 28 files changed, 188 insertions(+), 161 deletions(-) diff --git a/ckan/config/environment.py b/ckan/config/environment.py index 1b0151e11b1..897c3321cb6 100644 --- a/ckan/config/environment.py +++ b/ckan/config/environment.py @@ -9,7 +9,6 @@ from paste.deploy.converters import asbool import sqlalchemy from pylons import config -from pylons.i18n import _, ungettext from genshi.template import TemplateLoader from genshi.filters.i18n import Translator @@ -18,10 +17,12 @@ import ckan.plugins as p import ckan.lib.helpers as h import ckan.lib.app_globals as app_globals +import ckan.lib.jinja_extensions as jinja_extensions + +from ckan.common import _, ungettext log = logging.getLogger(__name__) -import lib.jinja_extensions # Suppress benign warning 'Unbuilt egg for setuptools' warnings.simplefilter('ignore', UserWarning) @@ -297,22 +298,22 @@ def genshi_lookup_attr(cls, obj, key): # Create Jinja2 environment - env = lib.jinja_extensions.Environment( - loader=lib.jinja_extensions.CkanFileSystemLoader(template_paths), + env = jinja_extensions.Environment( + loader=jinja_extensions.CkanFileSystemLoader(template_paths), autoescape=True, extensions=['jinja2.ext.do', 'jinja2.ext.with_', - lib.jinja_extensions.SnippetExtension, - lib.jinja_extensions.CkanExtend, - lib.jinja_extensions.CkanInternationalizationExtension, - lib.jinja_extensions.LinkForExtension, - lib.jinja_extensions.ResourceExtension, - lib.jinja_extensions.UrlForStaticExtension, - lib.jinja_extensions.UrlForExtension] + jinja_extensions.SnippetExtension, + jinja_extensions.CkanExtend, + jinja_extensions.CkanInternationalizationExtension, + jinja_extensions.LinkForExtension, + jinja_extensions.ResourceExtension, + jinja_extensions.UrlForStaticExtension, + jinja_extensions.UrlForExtension] ) env.install_gettext_callables(_, ungettext, newstyle=True) # custom filters - env.filters['empty_and_escape'] = lib.jinja_extensions.empty_and_escape - env.filters['truncate'] = lib.jinja_extensions.truncate + env.filters['empty_and_escape'] = jinja_extensions.empty_and_escape + env.filters['truncate'] = jinja_extensions.truncate config['pylons.app_globals'].jinja_env = env # CONFIGURATION OPTIONS HERE (note: all config options will override diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index 09b0c14368e..fd2db898e67 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -5,12 +5,9 @@ import glob import urllib -from pylons import c, request, response -from pylons.i18n import _, gettext -from paste.util.multidict import MultiDict from webob.multidict import UnicodeMultiDict +from paste.util.multidict import MultiDict -import ckan.rating import ckan.model as model import ckan.logic as logic import ckan.lib.base as base @@ -20,6 +17,8 @@ import ckan.lib.jsonp as jsonp import ckan.lib.munge as munge +from ckan.common import _, c, request, response + log = logging.getLogger(__name__) @@ -159,7 +158,7 @@ def action(self, logic_function, ver=None): except KeyError: log.error('Can\'t find logic function: %s' % logic_function) return self._finish_bad_request( - gettext('Action name not known: %s') % str(logic_function)) + _('Action name not known: %s') % str(logic_function)) context = {'model': model, 'session': model.Session, 'user': c.user, 'api_version': ver} @@ -172,12 +171,12 @@ def action(self, logic_function, ver=None): except ValueError, inst: log.error('Bad request data: %s' % str(inst)) return self._finish_bad_request( - gettext('JSON Error: %s') % str(inst)) + _('JSON Error: %s') % str(inst)) if not isinstance(request_data, dict): # this occurs if request_data is blank log.error('Bad request data - not dict: %r' % request_data) return self._finish_bad_request( - gettext('Bad request data: %s') % + _('Bad request data: %s') % 'Request data JSON decoded to %r but ' 'it needs to be a dictionary.' % request_data) try: @@ -265,7 +264,7 @@ def list(self, ver=None, register=None, subregister=None, id=None): action = self._get_action_from_map(action_map, register, subregister) if not action: return self._finish_bad_request( - gettext('Cannot list entity of this type: %s') % register) + _('Cannot list entity of this type: %s') % register) try: return self._finish_ok(action(context, {'id': id})) except NotFound, e: @@ -296,7 +295,7 @@ def show(self, ver=None, register=None, subregister=None, action = self._get_action_from_map(action_map, register, subregister) if not action: return self._finish_bad_request( - gettext('Cannot read entity of this type: %s') % register) + _('Cannot read entity of this type: %s') % register) try: return self._finish_ok(action(context, data_dict)) except NotFound, e: @@ -331,12 +330,12 @@ def create(self, ver=None, register=None, subregister=None, data_dict.update(request_data) except ValueError, inst: return self._finish_bad_request( - gettext('JSON Error: %s') % str(inst)) + _('JSON Error: %s') % str(inst)) action = self._get_action_from_map(action_map, register, subregister) if not action: return self._finish_bad_request( - gettext('Cannot create new entity of this type: %s %s') % + _('Cannot create new entity of this type: %s %s') % (register, subregister)) try: @@ -390,12 +389,12 @@ def update(self, ver=None, register=None, subregister=None, data_dict.update(request_data) except ValueError, inst: return self._finish_bad_request( - gettext('JSON Error: %s') % str(inst)) + _('JSON Error: %s') % str(inst)) action = self._get_action_from_map(action_map, register, subregister) if not action: return self._finish_bad_request( - gettext('Cannot update entity of this type: %s') % + _('Cannot update entity of this type: %s') % register.encode('utf-8')) try: response_data = action(context, data_dict) @@ -439,7 +438,7 @@ def delete(self, ver=None, register=None, subregister=None, action = self._get_action_from_map(action_map, register, subregister) if not action: return self._finish_bad_request( - gettext('Cannot delete entity of this type: %s %s') % + _('Cannot delete entity of this type: %s %s') % (register, subregister or '')) try: response_data = action(context, data_dict) @@ -462,11 +461,11 @@ def search(self, ver=None, register=None): id = request.params['since_id'] if not id: return self._finish_bad_request( - gettext(u'No revision specified')) + _(u'No revision specified')) rev = model.Session.query(model.Revision).get(id) if rev is None: return self._finish_not_found( - gettext(u'There is no revision with id: %s') % id) + _(u'There is no revision with id: %s') % id) since_time = rev.timestamp elif 'since_time' in request.params: since_time_str = request.params['since_time'] @@ -476,7 +475,7 @@ def search(self, ver=None, register=None): return self._finish_bad_request('ValueError: %s' % inst) else: return self._finish_bad_request( - gettext("Missing search term ('since_id=UUID' or " + + _("Missing search term ('since_id=UUID' or " + " 'since_time=TIMESTAMP')")) revs = model.Session.query(model.Revision).\ filter(model.Revision.timestamp > since_time) @@ -486,7 +485,7 @@ def search(self, ver=None, register=None): params = MultiDict(self._get_search_params(request.params)) except ValueError, e: return self._finish_bad_request( - gettext('Could not read parameters: %r' % e)) + _('Could not read parameters: %r' % e)) # if using API v2, default to returning the package ID if # no field list is specified @@ -537,10 +536,10 @@ def search(self, ver=None, register=None): except search.SearchError, e: log.exception(e) return self._finish_bad_request( - gettext('Bad search option: %s') % e) + _('Bad search option: %s') % e) else: return self._finish_not_found( - gettext('Unknown register: %s') % register) + _('Unknown register: %s') % register) @classmethod def _get_search_params(cls, request_params): @@ -549,7 +548,7 @@ def _get_search_params(cls, request_params): qjson_param = request_params['qjson'].replace('\\\\u', '\\u') params = h.json.loads(qjson_param, encoding='utf8') except ValueError, e: - raise ValueError(gettext('Malformed qjson value') + ': %r' + raise ValueError(_('Malformed qjson value: %r') % e) elif len(request_params) == 1 and \ len(request_params.values()[0]) < 2 and \ diff --git a/ckan/controllers/feed.py b/ckan/controllers/feed.py index 2e9af01ed5c..257b6d648ec 100644 --- a/ckan/controllers/feed.py +++ b/ckan/controllers/feed.py @@ -21,16 +21,17 @@ # TODO fix imports import logging import urlparse +from urllib import urlencode import webhelpers.feedgenerator from pylons import config -from pylons.i18n import _ -from urllib import urlencode -from ckan import model -from ckan.lib.base import BaseController, c, request, response, json, abort, g +import ckan.model as model +import ckan.lib.base as base import ckan.lib.helpers as h -from ckan.logic import get_action, NotFound +import ckan.logic as logic + +from ckan.common import _, g, c, request, response, json # TODO make the item list configurable ITEMS_LIMIT = 20 @@ -55,7 +56,7 @@ def _package_search(data_dict): data_dict['rows'] = ITEMS_LIMIT # package_search action modifies the data_dict, so keep our copy intact. - query = get_action('package_search')(context, data_dict.copy()) + query = logic.get_action('package_search')(context, data_dict.copy()) return query['count'], query['results'] @@ -151,7 +152,7 @@ def _create_atom_id(resource_path, authority_name=None, date_string=None): return ':'.join(['tag', tagging_entity, resource_path]) -class FeedController(BaseController): +class FeedController(base.BaseController): base_url = config.get('ckan.site_url') @@ -171,9 +172,9 @@ def group(self, id): try: context = {'model': model, 'session': model.Session, 'user': c.user or c.author} - group_dict = get_action('group_show')(context, {'id': id}) - except NotFound: - abort(404, _('Group not found')) + group_dict = logic.get_action('group_show')(context, {'id': id}) + except logic.NotFound: + base.abort(404, _('Group not found')) data_dict, params = self._parse_url_params() data_dict['fq'] = 'groups:"%s"' % id @@ -283,9 +284,9 @@ def custom(self): try: page = int(request.params.get('page', 1)) except ValueError: - abort(400, _('"page" parameter must be a positive integer')) + base.abort(400, _('"page" parameter must be a positive integer')) if page < 0: - abort(400, _('"page" parameter must be a positive integer')) + base.abort(400, _('"page" parameter must be a positive integer')) limit = ITEMS_LIMIT data_dict = { @@ -437,9 +438,9 @@ def _parse_url_params(self): try: page = int(request.params.get('page', 1)) or 1 except ValueError: - abort(400, _('"page" parameter must be a positive integer')) + base.abort(400, _('"page" parameter must be a positive integer')) if page < 0: - abort(400, _('"page" parameter must be a positive integer')) + base.abort(400, _('"page" parameter must be a positive integer')) limit = ITEMS_LIMIT data_dict = { diff --git a/ckan/controllers/home.py b/ckan/controllers/home.py index 21843ca4fb3..2c11afa1081 100644 --- a/ckan/controllers/home.py +++ b/ckan/controllers/home.py @@ -1,5 +1,4 @@ -from pylons.i18n import _ -from pylons import g, c, config, cache +from pylons import config, cache import sqlalchemy.exc import ckan.logic as logic @@ -9,6 +8,8 @@ import ckan.model as model import ckan.lib.helpers as h +from ckan.common import _, g, c + CACHE_PARAMETERS = ['__cache', '__no_cache__'] # horrible hack diff --git a/ckan/controllers/related.py b/ckan/controllers/related.py index e4840663d0d..fd783a3e138 100644 --- a/ckan/controllers/related.py +++ b/ckan/controllers/related.py @@ -1,15 +1,14 @@ +import urllib + import ckan.model as model import ckan.logic as logic import ckan.lib.base as base import ckan.lib.helpers as h import ckan.lib.navl.dictization_functions as df -import pylons.i18n as i18n +from ckan.common import _, c -_ = i18n._ -import urllib -c = base.c abort = base.abort _get_action=logic.get_action diff --git a/ckan/controllers/revision.py b/ckan/controllers/revision.py index 32809602cc3..bbfb2c4b610 100644 --- a/ckan/controllers/revision.py +++ b/ckan/controllers/revision.py @@ -1,14 +1,14 @@ from datetime import datetime, timedelta -from pylons.i18n import get_lang, _ -from pylons import c, request - -from ckan.logic import NotAuthorized, check_access +from pylons.i18n import get_lang +import ckan.logic as logic import ckan.lib.base as base import ckan.model as model import ckan.lib.helpers as h +from ckan.common import _, c, request + class RevisionController(base.BaseController): @@ -18,15 +18,15 @@ def __before__(self, action, **env): context = {'model': model, 'user': c.user or c.author} if c.user: try: - check_access('revision_change_state', context) + logic.check_access('revision_change_state', context) c.revision_change_state_allowed = True - except NotAuthorized: + except logic.NotAuthorized: c.revision_change_state_allowed = False else: c.revision_change_state_allowed = False try: - check_access('site_read', context) - except NotAuthorized: + logic.check_access('site_read', context) + except logic.NotAuthorized: base.abort(401, _('Not authorized to see this page')) def index(self): diff --git a/ckan/controllers/tag.py b/ckan/controllers/tag.py index a736c17e8d0..c9efd44a2c8 100644 --- a/ckan/controllers/tag.py +++ b/ckan/controllers/tag.py @@ -1,11 +1,12 @@ -from pylons.i18n import _ -from pylons import request, c, config +from pylons import config import ckan.logic as logic import ckan.model as model import ckan.lib.base as base import ckan.lib.helpers as h +from ckan.common import _, request, c + LIMIT = 25 diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index 638bd7a7231..be35713c304 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -1,8 +1,7 @@ import logging from urllib import quote -from pylons import session, c, g, request, config -from pylons.i18n import _ +from pylons import config import genshi import ckan.lib.i18n as i18n @@ -17,6 +16,8 @@ import ckan.lib.mailer as mailer import ckan.lib.navl.dictization_functions as dictization_functions +from ckan.common import _, session, c, g, request + log = logging.getLogger(__name__) diff --git a/ckan/lib/activity_streams.py b/ckan/lib/activity_streams.py index fa1a7f04e87..6c4908a5ff2 100644 --- a/ckan/lib/activity_streams.py +++ b/ckan/lib/activity_streams.py @@ -1,13 +1,13 @@ import re -import datetime -from pylons.i18n import _ from webhelpers.html import literal import ckan.lib.helpers as h import ckan.lib.base as base import ckan.logic as logic +from ckan.common import _ + # get_snippet_*() functions replace placeholders like {user}, {dataset}, etc. # in activity strings with HTML representations of particular users, datasets, # etc. diff --git a/ckan/lib/alphabet_paginate.py b/ckan/lib/alphabet_paginate.py index b75bf27e83b..5bffd70594b 100644 --- a/ckan/lib/alphabet_paginate.py +++ b/ckan/lib/alphabet_paginate.py @@ -15,12 +15,13 @@ ''' from itertools import dropwhile import re + from sqlalchemy import __version__ as sqav from sqlalchemy.orm.query import Query -from pylons.i18n import _ from webhelpers.html.builder import HTML from routes import url_for + class AlphaPage(object): def __init__(self, collection, alpha_attribute, page, other_text, paging_threshold=50, controller_name='tag'): diff --git a/ckan/lib/base.py b/ckan/lib/base.py index 7f944a3393b..c72f09f09db 100644 --- a/ckan/lib/base.py +++ b/ckan/lib/base.py @@ -6,12 +6,12 @@ import time from paste.deploy.converters import asbool -from pylons import c, cache, config, g, request, response, session +from pylons import cache, config, session from pylons.controllers import WSGIController from pylons.controllers.util import abort as _abort from pylons.controllers.util import redirect_to, redirect from pylons.decorators import jsonify, validate -from pylons.i18n import _, ungettext, N_, gettext, ngettext +from pylons.i18n import N_, gettext, ngettext from pylons.templating import cached_template, pylons_globals from genshi.template import MarkupTemplate from genshi.template.text import NewTextTemplate @@ -25,7 +25,11 @@ import ckan.lib.app_globals as app_globals from ckan.plugins import PluginImplementations, IGenshiStreamFilter import ckan.model as model -from ckan.common import json + +# These imports are for legacy usages and will be removed soon these should +# be imported directly from ckan.common for internal ckan code and via the +# plugins.toolkit for extensions. +from ckan.common import json, _, ungettext, c, g, request, response log = logging.getLogger(__name__) diff --git a/ckan/lib/email_notifications.py b/ckan/lib/email_notifications.py index 20ae39b6ebf..9b12fcdd082 100644 --- a/ckan/lib/email_notifications.py +++ b/ckan/lib/email_notifications.py @@ -8,12 +8,13 @@ import re import pylons -import pylons.i18n import ckan.model as model import ckan.logic as logic import ckan.lib.base as base +from ckan.common import ungettext + def string_to_timedelta(s): '''Parse a string s and return a standard datetime.timedelta object. @@ -96,7 +97,7 @@ def _notifications_for_activities(activities, user_dict): # say something about the contents of the activities, or single out # certain types of activity to be sent in their own individual emails, # etc. - subject = pylons.i18n.ungettext( + subject = ungettext( "1 new activity from {site_title}", "{n} new activities from {site_title}", len(activities)).format( diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py index 9c8b656987a..5e3d51f7c6d 100644 --- a/ckan/lib/formatters.py +++ b/ckan/lib/formatters.py @@ -1,10 +1,11 @@ import datetime -from pylons.i18n import _, ungettext from babel import numbers import ckan.lib.i18n as i18n +from ckan.common import _, ungettext + ################################################## # # diff --git a/ckan/lib/mailer.py b/ckan/lib/mailer.py index c83b4d8fa45..f0e1978ac2c 100644 --- a/ckan/lib/mailer.py +++ b/ckan/lib/mailer.py @@ -7,12 +7,15 @@ from email import Utils from urlparse import urljoin -from pylons.i18n.translation import _ -from pylons import config, g -from ckan import model, __version__ -import ckan.lib.helpers as h +from pylons import config import paste.deploy.converters +import ckan +import ckan.model as model +import ckan.lib.helpers as h + +from ckan.common import _, g + log = logging.getLogger(__name__) class MailerException(Exception): @@ -36,7 +39,7 @@ def _mail_recipient(recipient_name, recipient_email, recipient = u"%s <%s>" % (recipient_name, recipient_email) msg['To'] = Header(recipient, 'utf-8') msg['Date'] = Utils.formatdate(time()) - msg['X-Mailer'] = "CKAN %s" % __version__ + msg['X-Mailer'] = "CKAN %s" % ckan.__version__ # Send the email using Python's smtplib. smtp_connection = smtplib.SMTP() diff --git a/ckan/lib/navl/dictization_functions.py b/ckan/lib/navl/dictization_functions.py index da0908e2dcc..aa4e69b59aa 100644 --- a/ckan/lib/navl/dictization_functions.py +++ b/ckan/lib/navl/dictization_functions.py @@ -1,9 +1,10 @@ import copy import formencode as fe import inspect -from pylons.i18n import _ from pylons import config +from ckan.common import _ + class Missing(object): def __unicode__(self): raise Invalid(_('Missing value')) diff --git a/ckan/lib/navl/validators.py b/ckan/lib/navl/validators.py index f72e2788617..01cbd87567f 100644 --- a/ckan/lib/navl/validators.py +++ b/ckan/lib/navl/validators.py @@ -1,5 +1,11 @@ -from dictization_functions import missing, StopOnError, Invalid -from pylons.i18n import _ +import ckan.lib.navl.dictization_functions as df + +from ckan.common import _ + +missing = df.missing +StopOnError = df.StopOnError +Invalid = df.Invalid + def identity_converter(key, data, errors, context): return diff --git a/ckan/logic/__init__.py b/ckan/logic/__init__.py index e21f222f50e..8f753c8ee5c 100644 --- a/ckan/logic/__init__.py +++ b/ckan/logic/__init__.py @@ -3,14 +3,12 @@ import types import re -from pylons.i18n import _ - -import ckan.lib.base as base import ckan.model as model -from ckan.new_authz import is_authorized -from ckan.lib.navl.dictization_functions import flatten_dict, DataError -from ckan.plugins import PluginImplementations -from ckan.plugins.interfaces import IActions +import ckan.new_authz as new_authz +import ckan.lib.navl.dictization_functions as df +import ckan.plugins as p + +from ckan.common import _, c log = logging.getLogger(__name__) @@ -174,7 +172,7 @@ def tuplize_dict(data_dict): try: key_list[num] = int(key) except ValueError: - raise DataError('Bad key') + raise df.DataError('Bad key') tuplized_dict[tuple(key_list)] = value return tuplized_dict @@ -190,7 +188,7 @@ def untuplize_dict(tuplized_dict): def flatten_to_string_key(dict): - flattented = flatten_dict(dict) + flattented = df.flatten_dict(dict) return untuplize_dict(flattented) @@ -205,7 +203,7 @@ def check_access(action, context, data_dict=None): # # TODO Check the API key is valid at some point too! # log.debug('Valid API key needed to make changes') # raise NotAuthorized - logic_authorization = is_authorized(action, context, data_dict) + logic_authorization = new_authz.is_authorized(action, context, data_dict) if not logic_authorization['success']: msg = logic_authorization.get('msg', '') raise NotAuthorized(msg) @@ -290,7 +288,7 @@ def get_action(action): # Then overwrite them with any specific ones in the plugins: resolved_action_plugins = {} fetched_actions = {} - for plugin in PluginImplementations(IActions): + for plugin in p.PluginImplementations(p.IActions): for name, auth_function in plugin.get_actions().items(): if name in resolved_action_plugins: raise Exception( @@ -317,7 +315,7 @@ def wrapped(context=None, data_dict=None, **kw): context.setdefault('model', model) context.setdefault('session', model.Session) try: - context.setdefault('user', base.c.user or base.c.author) + context.setdefault('user', c.user or c.author) except TypeError: # c not registered pass diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index df259500790..27197f2419d 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -1,9 +1,8 @@ import logging + from pylons import config -from pylons.i18n import _ from paste.deploy.converters import asbool -import ckan.new_authz as new_authz import ckan.lib.plugins as lib_plugins import ckan.logic as logic import ckan.rating as ratings @@ -15,6 +14,8 @@ import ckan.lib.dictization.model_save as model_save import ckan.lib.navl.dictization_functions +from ckan.common import _ + # FIXME this looks nasty and should be shared better from ckan.logic.action.update import _update_package_relationship diff --git a/ckan/logic/action/delete.py b/ckan/logic/action/delete.py index 8c4a7dd8cde..5dcaf88ab9b 100644 --- a/ckan/logic/action/delete.py +++ b/ckan/logic/action/delete.py @@ -1,9 +1,11 @@ -from pylons.i18n import _ import ckan.logic import ckan.logic.action import ckan.plugins as plugins import ckan.lib.dictization.model_dictize as model_dictize + +from ckan.common import _ + validate = ckan.lib.navl.dictization_functions.validate # Define some shortcuts diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index cdeda998d1e..4a1cd30ea70 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -4,8 +4,6 @@ import datetime from pylons import config -from pylons.i18n import _ -from pylons import c import sqlalchemy import ckan.lib.dictization @@ -21,6 +19,8 @@ import ckan.lib.activity_streams as activity_streams import ckan.new_authz as new_authz +from ckan.common import _ + log = logging.getLogger('ckan.logic') # Define some shortcuts diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index d6b969bc923..45775e933e7 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -2,24 +2,24 @@ import datetime import json -import pylons -from pylons.i18n import _ from pylons import config from vdm.sqlalchemy.base import SQLAlchemySession -import paste.deploy.converters +import paste.deploy.converters as converters import ckan.plugins as plugins import ckan.logic as logic -import ckan.logic.schema -import ckan.lib.dictization +import ckan.logic.schema as schema_ +import ckan.lib.dictization as dictization import ckan.lib.dictization.model_dictize as model_dictize import ckan.lib.dictization.model_save as model_save import ckan.lib.navl.dictization_functions import ckan.lib.navl.validators as validators import ckan.lib.plugins as lib_plugins -import ckan.lib.email_notifications +import ckan.lib.email_notifications as email_notifications import ckan.lib.search as search +from ckan.common import _, request + log = logging.getLogger(__name__) # Define some shortcuts @@ -130,7 +130,7 @@ def related_update(context, data_dict): user = context['user'] id = _get_or_bust(data_dict, "id") - schema = context.get('schema') or ckan.logic.schema.default_related_schema() + schema = context.get('schema') or schema_.default_related_schema() related = model.Related.get(id) context["related"] = related @@ -170,7 +170,7 @@ def resource_update(context, data_dict): model = context['model'] user = context['user'] id = _get_or_bust(data_dict, "id") - schema = context.get('schema') or ckan.logic.schema.default_update_resource_schema() + schema = context.get('schema') or schema_.default_update_resource_schema() resource = model.Resource.get(id) context["resource"] = resource @@ -337,7 +337,7 @@ def package_relationship_update(context, data_dict): ''' model = context['model'] - schema = context.get('schema') or ckan.logic.schema.default_update_relationship_schema() + schema = context.get('schema') or schema_.default_update_relationship_schema() id, id2, rel = _get_or_bust(data_dict, ['subject', 'object', 'type']) @@ -418,7 +418,7 @@ def _group_or_org_update(context, data_dict, is_org=False): # when editing an org we do not want to update the packages if using the # new templates. if ((not is_org) - and not paste.deploy.converters.asbool( + and not converters.asbool( config.get('ckan.legacy_templates', False)) and 'api_version' not in context): context['prevent_packages_update'] = True @@ -475,7 +475,7 @@ def _group_or_org_update(context, data_dict, is_org=False): activity_dict['activity_type'] = 'deleted group' if activity_dict is not None: activity_dict['data'] = { - 'group': ckan.lib.dictization.table_dictize(group, context) + 'group': dictization.table_dictize(group, context) } activity_create_context = { 'model': model, @@ -546,7 +546,7 @@ def user_update(context, data_dict): model = context['model'] user = context['user'] session = context['session'] - schema = context.get('schema') or ckan.logic.schema.default_update_user_schema() + schema = context.get('schema') or schema_.default_update_user_schema() id = _get_or_bust(data_dict, 'id') user_obj = model.User.get(id) @@ -614,7 +614,7 @@ def task_status_update(context, data_dict): user = context['user'] id = data_dict.get("id") - schema = context.get('schema') or ckan.logic.schema.default_task_status_schema() + schema = context.get('schema') or schema_.default_task_status_schema() if id: task_status = model.TaskStatus.get(id) @@ -824,7 +824,7 @@ def vocabulary_update(context, data_dict): _check_access('vocabulary_update', context, data_dict) - schema = context.get('schema') or ckan.logic.schema.default_update_vocabulary_schema() + schema = context.get('schema') or schema_.default_update_vocabulary_schema() data, errors = _validate(data_dict, schema, context) if errors: model.Session.rollback() @@ -983,15 +983,15 @@ def send_email_notifications(context, data_dict): # If paste.command_request is True then this function has been called # by a `paster post ...` command not a real HTTP request, so skip the # authorization. - if not pylons.request.environ.get('paste.command_request'): + if not request.environ.get('paste.command_request'): _check_access('send_email_notifications', context, data_dict) - if not paste.deploy.converters.asbool( - pylons.config.get('ckan.activity_streams_email_notifications')): + if not converters.asbool( + config.get('ckan.activity_streams_email_notifications')): raise logic.ParameterError('ckan.activity_streams_email_notifications' ' is not enabled in config') - ckan.lib.email_notifications.get_and_send_notifications_for_all_users() + email_notifications.get_and_send_notifications_for_all_users() def package_owner_org_update(context, data_dict): diff --git a/ckan/logic/auth/create.py b/ckan/logic/auth/create.py index 64d5cc3e150..1baf9b61077 100644 --- a/ckan/logic/auth/create.py +++ b/ckan/logic/auth/create.py @@ -1,8 +1,8 @@ -from pylons.i18n import _ - import ckan.logic as logic import ckan.new_authz as new_authz +from ckan.common import _ + def package_create(context, data_dict=None): user = context['user'] diff --git a/ckan/logic/converters.py b/ckan/logic/converters.py index a1270a59146..bc6e9fdf5e4 100644 --- a/ckan/logic/converters.py +++ b/ckan/logic/converters.py @@ -1,9 +1,9 @@ -from pylons.i18n import _ -from ckan import model -from ckan.lib.navl.dictization_functions import Invalid -from ckan.lib.field_types import DateType, DateConvertError -from ckan.logic.validators import tag_length_validator, tag_name_validator, \ - tag_in_vocabulary_validator +import ckan.model as model +import ckan.lib.navl.dictization_functions as df +import ckan.lib.field_types as field_types +import ckan.logic.validators as validators + +from ckan.common import _ def convert_to_extras(key, data, errors, context): extras = data.get(('extras',), []) @@ -20,16 +20,16 @@ def convert_from_extras(key, data, errors, context): def date_to_db(value, context): try: - value = DateType.form_to_db(value) - except DateConvertError, e: - raise Invalid(str(e)) + value = field_types.DateType.form_to_db(value) + except field_types.DateConvertError, e: + raise df.Invalid(str(e)) return value def date_to_form(value, context): try: - value = DateType.db_to_form(value) - except DateConvertError, e: - raise Invalid(str(e)) + value = field_types.DateType.db_to_form(value) + except field_types.DateConvertError, e: + raise df.Invalid(str(e)) return value def free_tags_only(key, data, errors, context): @@ -56,11 +56,11 @@ def callable(key, data, errors, context): v = model.Vocabulary.get(vocab) if not v: - raise Invalid(_('Tag vocabulary "%s" does not exist') % vocab) + raise df.Invalid(_('Tag vocabulary "%s" does not exist') % vocab) context['vocabulary'] = v for tag in new_tags: - tag_in_vocabulary_validator(tag, context) + validators.tag_in_vocabulary_validator(tag, context) for num, tag in enumerate(new_tags): data[('tags', num + n, 'name')] = tag @@ -71,7 +71,7 @@ def convert_from_tags(vocab): def callable(key, data, errors, context): v = model.Vocabulary.get(vocab) if not v: - raise Invalid(_('Tag vocabulary "%s" does not exist') % vocab) + raise df.Invalid(_('Tag vocabulary "%s" does not exist') % vocab) tags = [] for k in data.keys(): @@ -103,7 +103,7 @@ def convert_user_name_or_id_to_id(user_name_or_id, context): result = session.query(model.User).filter_by( name=user_name_or_id).first() if not result: - raise Invalid('%s: %s' % (_('Not found'), _('User'))) + raise df.Invalid('%s: %s' % (_('Not found'), _('User'))) return result.id def convert_package_name_or_id_to_id(package_name_or_id, context): @@ -128,7 +128,7 @@ def convert_package_name_or_id_to_id(package_name_or_id, context): result = session.query(model.Package).filter_by( name=package_name_or_id).first() if not result: - raise Invalid('%s: %s' % (_('Not found'), _('Dataset'))) + raise df.Invalid('%s: %s' % (_('Not found'), _('Dataset'))) return result.id def convert_group_name_or_id_to_id(group_name_or_id, context): @@ -153,5 +153,5 @@ def convert_group_name_or_id_to_id(group_name_or_id, context): result = session.query(model.Group).filter_by( name=group_name_or_id).first() if not result: - raise Invalid('%s: %s' % (_('Not found'), _('Group'))) + raise df.Invalid('%s: %s' % (_('Not found'), _('Group'))) return result.id diff --git a/ckan/logic/validators.py b/ckan/logic/validators.py index b671231a41f..9ba2ee0c141 100644 --- a/ckan/logic/validators.py +++ b/ckan/logic/validators.py @@ -2,27 +2,32 @@ from itertools import count import re -from pylons.i18n import _ - -from ckan.lib.navl.dictization_functions import Invalid, StopOnError, Missing, missing, unflatten -from ckan.logic import check_access, NotAuthorized, NotFound +import ckan.lib.navl.dictization_functions as df +import ckan.logic as logic import ckan.lib.helpers as h from ckan.model import (MAX_TAG_LENGTH, MIN_TAG_LENGTH, PACKAGE_NAME_MIN_LENGTH, PACKAGE_NAME_MAX_LENGTH, PACKAGE_VERSION_MAX_LENGTH, VOCABULARY_NAME_MAX_LENGTH, VOCABULARY_NAME_MIN_LENGTH) -import ckan.new_authz +import ckan.new_authz as new_authz + +from ckan.common import _ + +Invalid = df.Invalid +StopOnError = df.StopOnError +Missing = df.Missing +missing = df.missing def owner_org_validator(key, data, errors, context): value = data.get(key) if value is missing or not value: - if not ckan.new_authz.check_config_permission('create_unowned_dataset'): + if not new_authz.check_config_permission('create_unowned_dataset'): raise Invalid(_('A organization must be supplied')) data.pop(key, None) - raise StopOnError + raise df.StopOnError model = context['model'] group = model.Group.get(value) @@ -303,7 +308,7 @@ def package_version_validator(value, context): def duplicate_extras_key(key, data, errors, context): - unflattened = unflatten(data) + unflattened = df.unflatten(data) extras = unflattened.get('extras', []) extras_keys = [] for extra in extras: @@ -392,16 +397,16 @@ def ignore_not_package_admin(key, data, errors, context): if 'ignore_auth' in context: return - if user and ckan.new_authz.is_sysadmin(user): + if user and new_authz.is_sysadmin(user): return authorized = False pkg = context.get('package') if pkg: try: - check_access('package_change_state',context) + logic.check_access('package_change_state',context) authorized = True - except NotAuthorized: + except logic.NotAuthorized: authorized = False if (user and pkg and authorized): @@ -419,16 +424,16 @@ def ignore_not_group_admin(key, data, errors, context): model = context['model'] user = context.get('user') - if user and ckan.new_authz.is_sysadmin(user): + if user and new_authz.is_sysadmin(user): return authorized = False group = context.get('group') if group: try: - check_access('group_change_state',context) + logic.check_access('group_change_state',context) authorized = True - except NotAuthorized: + except logic.NotAuthorized: authorized = False if (user and group and authorized): @@ -590,6 +595,6 @@ def user_name_exists(user_name, context): def role_exists(role, context): - if role not in ckan.new_authz.ROLE_PERMISSIONS: + if role not in new_authz.ROLE_PERMISSIONS: raise Invalid(_('role does not exist.')) return role diff --git a/ckan/model/license.py b/ckan/model/license.py index 2a0b8e732e8..1b65d6040ec 100644 --- a/ckan/model/license.py +++ b/ckan/model/license.py @@ -1,10 +1,10 @@ import datetime import urllib2 import re -import simplejson as json from pylons import config -from pylons.i18n import _ + +from ckan.common import _, json class License(object): diff --git a/ckan/model/package_relationship.py b/ckan/model/package_relationship.py index 503fc45b654..3ee5cd49a7f 100644 --- a/ckan/model/package_relationship.py +++ b/ckan/model/package_relationship.py @@ -10,7 +10,7 @@ # i18n only works when this is run as part of pylons, # which isn't the case for paster commands. try: - from pylons.i18n import _ + from ckan.common import _ _('') except: def _(txt): diff --git a/ckan/plugins/toolkit.py b/ckan/plugins/toolkit.py index e011184fe56..25882e9395a 100644 --- a/ckan/plugins/toolkit.py +++ b/ckan/plugins/toolkit.py @@ -2,7 +2,6 @@ import os import re -import pylons import paste.deploy.converters as converters import webhelpers.html.tags @@ -74,6 +73,7 @@ def _initialize(self): import ckan.logic as logic import ckan.lib.cli as cli import ckan.lib.plugins as lib_plugins + import ckan.common as common # Allow class access to these modules self.__class__.ckan = ckan @@ -82,9 +82,9 @@ def _initialize(self): t = self._toolkit # imported functions - t['_'] = pylons.i18n._ - t['c'] = pylons.c - t['request'] = pylons.request + t['_'] = common._ + t['c'] = common.c + t['request'] = common.request t['render'] = base.render t['render_text'] = base.render_text t['asbool'] = converters.asbool diff --git a/ckan/tests/functional/test_home.py b/ckan/tests/functional/test_home.py index f69ca90d5da..248810b56ab 100644 --- a/ckan/tests/functional/test_home.py +++ b/ckan/tests/functional/test_home.py @@ -1,4 +1,3 @@ -from pylons import c, session from pylons.i18n import set_lang from ckan.lib.create_test_data import CreateTestData @@ -10,6 +9,8 @@ from ckan.tests.pylons_controller import PylonsTestCase from ckan.tests import search_related, setup_test_search_index +from ckan.common import c, session + class TestHomeController(TestController, PylonsTestCase, HtmlCheckMethods): @classmethod def setup_class(cls): From d242bb98a9af9b94c7ee760049aff91ddfde8c17 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Tue, 2 Apr 2013 20:11:08 +0200 Subject: [PATCH 084/149] [#728] Add ckan.tracking_enabled to app_globals The ckan.tracking_enabled ini file setting was being ignored, so page view tracking wasn't working. Adding ckan.tracking_enabled to app_globals makes the setting work again. Fixes #728 --- ckan/lib/app_globals.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index e98f975ca7d..62dcbd246d3 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -57,6 +57,7 @@ 'openid_enabled': {'default': 'true', 'type' : 'bool'}, 'debug': {'default': 'false', 'type' : 'bool'}, 'ckan.debug_supress_header' : {'default': 'false', 'type' : 'bool'}, + 'ckan.tracking_enabled' : {'default': 'false', 'type' : 'bool'}, # int 'ckan.datasets_per_page': {'default': '20', 'type': 'int'}, From 55129dbf959b060b27c68afce87486e5925e0b74 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Tue, 2 Apr 2013 20:29:23 +0200 Subject: [PATCH 085/149] [#729] Handle languages in URLs when updating page view tracking summary Page view tracking was failing when a language was selected: 1. Put `ckan.tracking_enabled = true` in your ini file 2. Run CKAN, visit a page with a language e.g. `/en/dataset/annakarenina` 3. Run `paster tracking update`. If you look in your db, in the `tracking_summary` table there'll be a row with `package_id` `~~not~found~~`. 4. Run `paster tracking export tracking.csv`, the exported CSV file will say 0 views. If you visit the page without the language in the URL e.g. `/dataset/annakarenina` then run the export command again, the view does get counted. This commit fixes the SQL used by the `paster tracking update/export` command to handle URLs with or without languages at the start. Fixes #729 --- ckan/lib/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index 1fc8de336df..c895cba2932 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -1047,7 +1047,7 @@ def export_tracking(self, engine, output_filename): for r in total_views]) def update_tracking(self, engine, summary_date): - PACKAGE_URL = '/dataset/' + PACKAGE_URL = '%/dataset/' # clear out existing data before adding new sql = '''DELETE FROM tracking_summary WHERE tracking_date='%s'; ''' % summary_date @@ -1073,7 +1073,7 @@ def update_tracking(self, engine, summary_date): sql = '''UPDATE tracking_summary t SET package_id = COALESCE( (SELECT id FROM package p - WHERE t.url = %s || p.name) + WHERE t.url LIKE %s || p.name) ,'~~not~found~~') WHERE t.package_id IS NULL AND tracking_type = 'page';''' From 66c450a6d655c52e679e3de9a25d36a810bc73da Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Tue, 2 Apr 2013 23:55:43 +0200 Subject: [PATCH 086/149] [#642] Use single quotes where possible --- ckanext/datastore/db.py | 30 +++++++++++------------ ckanext/datastore/plugin.py | 47 +++++++++++++++++-------------------- 2 files changed, 35 insertions(+), 42 deletions(-) diff --git a/ckanext/datastore/db.py b/ckanext/datastore/db.py index bc442d9d152..248d1c87869 100644 --- a/ckanext/datastore/db.py +++ b/ckanext/datastore/db.py @@ -57,7 +57,7 @@ def _pluck(field, arr): def _get_list(input, strip=True): - """Transforms a string or list to a list""" + '''Transforms a string or list to a list''' if input is None: return if input == '': @@ -106,7 +106,7 @@ def _validate_int(i, field_name, non_negative=False): def _get_engine(context, data_dict): - 'Get either read or write engine.' + '''Get either read or write engine.''' connection_url = data_dict['connection_url'] engine = _engines.get(connection_url) @@ -173,10 +173,8 @@ def _get_type(context, oid): def _rename_json_field(data_dict): - ''' - rename json type to a corresponding type for the datastore since - pre 9.2 postgres versions do not support native json - ''' + '''Rename json type to a corresponding type for the datastore since + pre 9.2 postgres versions do not support native json''' return _rename_field(data_dict, 'json', 'nested') @@ -193,7 +191,8 @@ def _rename_field(data_dict, term, replace): def _guess_type(field): - 'Simple guess type of field, only allowed are integer, numeric and text' + '''Simple guess type of field, only allowed are + integer, numeric and text''' data_types = set([int, float]) if isinstance(field, (dict, list)): return 'nested' @@ -252,7 +251,7 @@ def json_get_values(obj, current_list=None): def check_fields(context, fields): - 'Check if field types are valid.' + '''Check if field types are valid.''' for field in fields: if field.get('type') and not _is_valid_pg_type(context, field['type']): raise ValidationError({ @@ -281,7 +280,7 @@ def convert(data, type_name): def create_table(context, data_dict): - 'Create table from combination of fields and first row of data.' + '''Create table from combination of fields and first row of data.''' datastore_fields = [ {'id': '_id', 'type': 'serial primary key'}, @@ -331,7 +330,7 @@ def create_table(context, data_dict): def _get_aliases(context, data_dict): - ''' Get a list of aliases for a resource. ''' + '''Get a list of aliases for a resource.''' res_id = data_dict['resource_id'] alias_sql = sqlalchemy.text( u'SELECT name FROM "_table_metadata" WHERE alias_of = :id') @@ -340,8 +339,8 @@ def _get_aliases(context, data_dict): def _get_resources(context, alias): - ''' Get a list of resources for an alias. There could be more than one alias - in a resource_dict. ''' + '''Get a list of resources for an alias. There could be more than one alias + in a resource_dict.''' alias_sql = sqlalchemy.text( u'''SELECT alias_of FROM "_table_metadata" WHERE name = :alias AND alias_of IS NOT NULL''') @@ -700,7 +699,7 @@ def _to_full_text(fields, record): def _where(field_ids, data_dict): - 'Return a SQL WHERE clause from data_dict filters and q' + '''Return a SQL WHERE clause from data_dict filters and q''' filters = data_dict.get('filters', {}) if not isinstance(filters, dict): @@ -786,9 +785,8 @@ def _sort(context, data_dict, field_ids): def _insert_links(data_dict, limit, offset): - ''' Adds link to the next/prev part (same limit, offset=offset+limit) - and the resource page. - ''' + '''Adds link to the next/prev part (same limit, offset=offset+limit) + and the resource page.''' data_dict['_links'] = {} # get the url from the request diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index 95ddad946bf..50b792f122e 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -39,29 +39,29 @@ def configure(self, config): # that we should ignore the following tests. import sys if sys.argv[0].split('/')[-1] == 'paster' and 'datastore' in sys.argv[1:]: - log.warn("Omitting permission checks because you are " - "running paster commands.") + log.warn('Omitting permission checks because you are ' + 'running paster commands.') return self.ckan_url = self.config['sqlalchemy.url'] self.write_url = self.config['ckan.datastore.write_url'] if self.legacy_mode: self.read_url = self.write_url - log.warn("Legacy mode active. " - "The sql search will not be available.") + log.warn('Legacy mode active. ' + 'The sql search will not be available.') else: self.read_url = self.config['ckan.datastore.read_url'] if not model.engine_is_pg(): - log.warn("We detected that you do not use a PostgreSQL " - "database. The DataStore will NOT work and DataStore " - "tests will be skipped.") + log.warn('We detected that you do not use a PostgreSQL ' + 'database. The DataStore will NOT work and DataStore ' + 'tests will be skipped.') return if self._is_read_only_database(): - log.warn("We detected that CKAN is running on a read " - "only database. Permission checks and the creation " - "of _table_metadata are skipped.") + log.warn('We detected that CKAN is running on a read ' + 'only database. Permission checks and the creation ' + 'of _table_metadata are skipped.') else: self._check_urls_and_permissions() @@ -111,25 +111,23 @@ def _check_urls_and_permissions(self): # so that no harmful queries can be made if self._same_ckan_and_datastore_db(): - self._log_or_raise("CKAN and DataStore database " - "cannot be the same.") + self._log_or_raise('CKAN and DataStore database ' + 'cannot be the same.') # in legacy mode, the read and write url are ths same (both write url) # consequently the same url check and and write privilege check # don't make sense if not self.legacy_mode: if self._same_read_and_write_url(): - self._log_or_raise("The write and read-only database " - "connection urls are the same.") + self._log_or_raise('The write and read-only database ' + 'connection urls are the same.') if not self._read_connection_has_correct_privileges(): - self._log_or_raise("The read-only user has write privileges.") + self._log_or_raise('The read-only user has write privileges.') def _is_read_only_database(self): - ''' - Returns True if no connection has CREATE privileges on the public - schema. This is the case if replication is enabled. - ''' + ''' Returns True if no connection has CREATE privileges on the public + schema. This is the case if replication is enabled.''' for url in [self.ckan_url, self.write_url, self.read_url]: connection = db._get_engine(None, {'connection_url': url}).connect() @@ -140,9 +138,7 @@ def _is_read_only_database(self): return True def _same_ckan_and_datastore_db(self): - ''' - Returns True if the CKAN and DataStore db are the same - ''' + '''Returns True if the CKAN and DataStore db are the same''' return self._get_db_from_url(self.ckan_url) == self._get_db_from_url(self.read_url) def _get_db_from_url(self, url): @@ -152,8 +148,7 @@ def _same_read_and_write_url(self): return self.write_url == self.read_url def _read_connection_has_correct_privileges(self): - ''' - Returns True if the right permissions are set for the read only user. + ''' Returns True if the right permissions are set for the read only user. A table is created by the write user to test the read only user. ''' write_connection = db._get_engine(None, @@ -161,12 +156,12 @@ def _read_connection_has_correct_privileges(self): read_connection = db._get_engine(None, {'connection_url': self.read_url}).connect() - drop_foo_sql = u"DROP TABLE IF EXISTS _foo" + drop_foo_sql = u'DROP TABLE IF EXISTS _foo' write_connection.execute(drop_foo_sql) try: - write_connection.execute(u"CREATE TABLE _foo ()") + write_connection.execute(u'CREATE TABLE _foo ()') for privilege in ['INSERT', 'UPDATE', 'DELETE']: test_privilege_sql = u"SELECT has_table_privilege('_foo', '{privilege}')" sql = test_privilege_sql.format(privilege=privilege) From a64916ce12ceb88fa5da1e8fe15b02f2848519dd Mon Sep 17 00:00:00 2001 From: Vitor Baptista Date: Mon, 1 Apr 2013 20:59:19 -0300 Subject: [PATCH 087/149] [#642] Refactor and fix Datastore tests Datastore is a SingletonPlugin, so it doesn't matter if we call plugin.DatastorePlugin() many times: we always end up with the same instance. I've added a workaround that, first, saves and unloads the current datastore instance, then sets: pyutilib.component.core.PluginGlobals.singleton_services()[plugin.DatastorePlugin] = True This will make plugin.DatastorePlugin not be a Singleton anymore, so any subsequent calls to ckan.plugins.load('datastore') will create a new instance. Then, in the next line, we create a new DatastorePlugin instance by loading it, and save it into self.p and pyutilib.component.core.PluginGlobals.singleton_services()[plugin.DatastorePlugin]. This turns DatastorePlugin into a Singleton again, and subsequent calls to ckan.plugins.load('datastore') will return this new instance instead. Then, in the teardown, we unload the current the datastore, which gets rid of our test instance, and put the original datastore back in its place, so the environment before setUp() is the same as after tearDown(). For InvalidUrlsOrPermissionsException, what I wanted was a way to check if _check_urls_and_permissions() failed. I did this by overloading _log_or_raise() with an unique Exception, and checking if it's raised. If so, I guarantee that _log_or_raise() was called. This feels like too much boilerplate, but we don't have a stub/mock library, so we have to write it. Conflicts: ckanext/datastore/tests/test_configure.py --- ckanext/datastore/tests/test_configure.py | 104 +++++++++++----------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/ckanext/datastore/tests/test_configure.py b/ckanext/datastore/tests/test_configure.py index 2594e988700..019bb317f48 100644 --- a/ckanext/datastore/tests/test_configure.py +++ b/ckanext/datastore/tests/test_configure.py @@ -1,22 +1,24 @@ import unittest -import ckan.plugins as p -from nose.tools import raises +import nose.tools +import pyutilib.component.core +import ckan.plugins import ckanext.datastore.plugin as plugin - class TestConfiguration(unittest.TestCase): def setUp(self): - self.p = p.load('datastore') + self._original_plugin = ckan.plugins.unload('datastore') + pyutilib.component.core.PluginGlobals.singleton_services()[plugin.DatastorePlugin] = True + self.p = pyutilib.component.core.PluginGlobals.singleton_services()[plugin.DatastorePlugin] = ckan.plugins.load('datastore') def tearDown(self): - p.unload('datastore') + ckan.plugins.unload('datastore') + pyutilib.component.core.PluginGlobals.singleton_services()[plugin.DatastorePlugin] = self._original_plugin def test_legacy_mode_default(self): assert not self.p.legacy_mode def test_set_legacy_mode(self): - assert not self.p.legacy_mode c = { 'sqlalchemy.url': 'bar', 'ckan.datastore.write_url': 'foo' @@ -47,65 +49,67 @@ def test_same_ckan_and_datastore_db(self): self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' assert not self.p._same_ckan_and_datastore_db() + def test_setup_plugin_for_check_urls_and_permissions_tests_should_leave_the_plugin_in_a_valid_state(self): + self.setUp_plugin_for_check_urls_and_permissions_tests() + self.p._check_urls_and_permissions() # Should be OK -class TestCheckUrlsAndPermissions(unittest.TestCase): - def setUp(self): - self.p = p.load('datastore') + def test_check_urls_and_permissions_requires_different_ckan_and_datastore_dbs(self): + self.setUp_plugin_for_check_urls_and_permissions_tests() - self.p.legacy_mode = False + self.p._same_ckan_and_datastore_db = lambda: False + self.p._check_urls_and_permissions() # Should be OK - # initialize URLs - self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' - self.p.write_url = 'postgresql://u:pass@localhost/ds' - self.p.read_url = 'postgresql://u2:pass@localhost/ds' + self.p._same_ckan_and_datastore_db = lambda: True + nose.tools.assert_raises(InvalidUrlsOrPermissionsException, self.p._check_urls_and_permissions) - # initialize mock for privileges check - def true_privileges_mock(): - return True - self.p._read_connection_has_correct_privileges = true_privileges_mock + def test_check_urls_and_permissions_requires_different_read_and_write_urls_when_not_in_legacy_mode(self): + self.setUp_plugin_for_check_urls_and_permissions_tests() + self.p.legacy_mode = False - def raise_datastore_exception(message): - raise plugin.DatastoreException(message) - self.p._log_or_raise = raise_datastore_exception + self.p._same_read_and_write_url = lambda: False + self.p._check_urls_and_permissions() # Should be OK - def tearDown(self): - p.unload('datastore') + self.p._same_read_and_write_url = lambda: True + nose.tools.assert_raises(InvalidUrlsOrPermissionsException, self.p._check_urls_and_permissions) - def test_everything_correct_does_not_raise(self): - self.p._check_urls_and_permissions() + def test_check_urls_and_permissions_doesnt_require_different_read_and_write_urls_when_in_legacy_mode(self): + self.setUp_plugin_for_check_urls_and_permissions_tests() + self.p.legacy_mode = True - @raises(plugin.DatastoreException) - def test_raises_when_ckan_and_datastore_db_are_the_same(self): - self.p.read_url = 'postgresql://u2:pass@localhost/ckan' - self.p.ckan_url = 'postgresql://u:pass@localhost/ckan' + self.p._same_read_and_write_url = lambda: False + self.p._check_urls_and_permissions() # Should be OK - self.p._check_urls_and_permissions() + self.p._same_read_and_write_url = lambda: True + self.p._check_urls_and_permissions() # Should be OK - @raises(plugin.DatastoreException) - def test_raises_when_same_read_and_write_url(self): - self.p.read_url = 'postgresql://u:pass@localhost/ds' - self.p.write_url = 'postgresql://u:pass@localhost/ds' + def test_check_urls_and_permissions_requires_read_connection_with_correct_privileges_when_not_in_legacy_mode(self): + self.setUp_plugin_for_check_urls_and_permissions_tests() + self.p.legacy_mode = False - self.p._check_urls_and_permissions() + self.p._read_connection_has_correct_privileges = lambda: True + self.p._check_urls_and_permissions() # Should be OK - def test_same_read_and_write_url_in_legacy_mode(self): - self.p.read_url = 'postgresql://u:pass@localhost/ds' - self.p.write_url = 'postgresql://u:pass@localhost/ds' + self.p._read_connection_has_correct_privileges = lambda: False + nose.tools.assert_raises(InvalidUrlsOrPermissionsException, self.p._check_urls_and_permissions) + + def test_check_urls_and_permissions_doesnt_care_about_read_connection_privileges_when_in_legacy_mode(self): + self.setUp_plugin_for_check_urls_and_permissions_tests() self.p.legacy_mode = True - self.p._check_urls_and_permissions() + self.p._read_connection_has_correct_privileges = lambda: True + self.p._check_urls_and_permissions() # Should be OK + + self.p._read_connection_has_correct_privileges = lambda: False + self.p._check_urls_and_permissions() # Should be OK - @raises(plugin.DatastoreException) - def test_raises_when_we_have_write_permissions(self): - def false_privileges_mock(): - return False - self.p._read_connection_has_correct_privileges = false_privileges_mock - self.p._check_urls_and_permissions() + def setUp_plugin_for_check_urls_and_permissions_tests(self): + def _raise_invalid_urls_or_permissions_exception(message): + raise InvalidUrlsOrPermissionsException(message) - def test_have_write_permissions_in_legacy_mode(self): - def false_privileges_mock(): - return False - self.p._read_connection_has_correct_privileges = false_privileges_mock + self.p._same_ckan_and_datastore_db = lambda: False self.p.legacy_mode = True + self.p._same_read_and_write_url = lambda: False + self.p._read_connection_has_correct_privileges = lambda: True + self.p._log_or_raise = _raise_invalid_urls_or_permissions_exception - self.p._check_urls_and_permissions() +class InvalidUrlsOrPermissionsException(Exception): pass From 511f6f4ff504f67758f67da4340f6544a93dd128 Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Wed, 3 Apr 2013 00:02:39 +0200 Subject: [PATCH 088/149] [#642] PEP8 --- ckanext/datastore/tests/test_configure.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/ckanext/datastore/tests/test_configure.py b/ckanext/datastore/tests/test_configure.py index 019bb317f48..079df3910b1 100644 --- a/ckanext/datastore/tests/test_configure.py +++ b/ckanext/datastore/tests/test_configure.py @@ -5,6 +5,7 @@ import ckan.plugins import ckanext.datastore.plugin as plugin + class TestConfiguration(unittest.TestCase): def setUp(self): self._original_plugin = ckan.plugins.unload('datastore') @@ -51,13 +52,13 @@ def test_same_ckan_and_datastore_db(self): def test_setup_plugin_for_check_urls_and_permissions_tests_should_leave_the_plugin_in_a_valid_state(self): self.setUp_plugin_for_check_urls_and_permissions_tests() - self.p._check_urls_and_permissions() # Should be OK + self.p._check_urls_and_permissions() # Should be OK def test_check_urls_and_permissions_requires_different_ckan_and_datastore_dbs(self): self.setUp_plugin_for_check_urls_and_permissions_tests() self.p._same_ckan_and_datastore_db = lambda: False - self.p._check_urls_and_permissions() # Should be OK + self.p._check_urls_and_permissions() # Should be OK self.p._same_ckan_and_datastore_db = lambda: True nose.tools.assert_raises(InvalidUrlsOrPermissionsException, self.p._check_urls_and_permissions) @@ -67,7 +68,7 @@ def test_check_urls_and_permissions_requires_different_read_and_write_urls_when_ self.p.legacy_mode = False self.p._same_read_and_write_url = lambda: False - self.p._check_urls_and_permissions() # Should be OK + self.p._check_urls_and_permissions() # Should be OK self.p._same_read_and_write_url = lambda: True nose.tools.assert_raises(InvalidUrlsOrPermissionsException, self.p._check_urls_and_permissions) @@ -77,17 +78,17 @@ def test_check_urls_and_permissions_doesnt_require_different_read_and_write_urls self.p.legacy_mode = True self.p._same_read_and_write_url = lambda: False - self.p._check_urls_and_permissions() # Should be OK + self.p._check_urls_and_permissions() # Should be OK self.p._same_read_and_write_url = lambda: True - self.p._check_urls_and_permissions() # Should be OK + self.p._check_urls_and_permissions() # Should be OK def test_check_urls_and_permissions_requires_read_connection_with_correct_privileges_when_not_in_legacy_mode(self): self.setUp_plugin_for_check_urls_and_permissions_tests() self.p.legacy_mode = False self.p._read_connection_has_correct_privileges = lambda: True - self.p._check_urls_and_permissions() # Should be OK + self.p._check_urls_and_permissions() # Should be OK self.p._read_connection_has_correct_privileges = lambda: False nose.tools.assert_raises(InvalidUrlsOrPermissionsException, self.p._check_urls_and_permissions) @@ -97,10 +98,10 @@ def test_check_urls_and_permissions_doesnt_care_about_read_connection_privileges self.p.legacy_mode = True self.p._read_connection_has_correct_privileges = lambda: True - self.p._check_urls_and_permissions() # Should be OK + self.p._check_urls_and_permissions() # Should be OK self.p._read_connection_has_correct_privileges = lambda: False - self.p._check_urls_and_permissions() # Should be OK + self.p._check_urls_and_permissions() # Should be OK def setUp_plugin_for_check_urls_and_permissions_tests(self): def _raise_invalid_urls_or_permissions_exception(message): @@ -112,4 +113,6 @@ def _raise_invalid_urls_or_permissions_exception(message): self.p._read_connection_has_correct_privileges = lambda: True self.p._log_or_raise = _raise_invalid_urls_or_permissions_exception -class InvalidUrlsOrPermissionsException(Exception): pass + +class InvalidUrlsOrPermissionsException(Exception): + pass From 7551cbbc241c0304cb767f572965aac1e8142034 Mon Sep 17 00:00:00 2001 From: tobes Date: Wed, 3 Apr 2013 10:08:43 +0100 Subject: [PATCH 089/149] [#509] Test fixups --- ckan/lib/dictization/model_save.py | 3 ++- ckan/tests/lib/test_dictization.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py index b43c49ffb78..27a7822ab45 100644 --- a/ckan/lib/dictization/model_save.py +++ b/ckan/lib/dictization/model_save.py @@ -229,7 +229,8 @@ def package_membership_list_save(group_dicts, package, context): group = session.query(model.Group).get(id) else: group = session.query(model.Group).filter_by(name=name).first() - groups.add(group) + if group: + groups.add(group) ## need to flush so we can get out the package id model.Session.flush() diff --git a/ckan/tests/lib/test_dictization.py b/ckan/tests/lib/test_dictization.py index 19f89aeba2f..abfbab4ac9b 100644 --- a/ckan/tests/lib/test_dictization.py +++ b/ckan/tests/lib/test_dictization.py @@ -359,6 +359,7 @@ def test_07_table_simple_save(self): def test_08_package_save(self): context = {"model": model, + "user": 'testsysadmin', "session": model.Session} anna1 = model.Session.query(model.Package).filter_by(name='annakarenina').one() From 8eca49719cb2be394e103890e81cd8c315e74b2a Mon Sep 17 00:00:00 2001 From: amercader Date: Wed, 3 Apr 2013 10:26:13 +0100 Subject: [PATCH 090/149] [#691] Add note to legacy search docs about private datasets --- doc/legacy-api.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/legacy-api.rst b/doc/legacy-api.rst index 261eb84d977..b10841fc976 100644 --- a/doc/legacy-api.rst +++ b/doc/legacy-api.rst @@ -366,6 +366,12 @@ The ``Dataset`` and ``Revision`` data formats are as defined in `Model Formats`_ filter_by_openness and filter_by_downloadable were dropped from CKAN version 1.5 onwards. +.. Note:: + + Only public datasets can be accessed via the legacy search API, regardless of + the provided authorization. If you need to access private datasets via the + API you will need to use the `package_search` method of the :doc:`api`. + **Resource Parameters** From 3eeeef257a9c9a7c574f9a9662289ebc7cd40d24 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 3 Apr 2013 13:27:10 +0200 Subject: [PATCH 091/149] [#541] Add docs for page view tracking feature Add documentation for the page view tracking feature. There are still some details to document, including how to show number of page views on the pages themselves (eg. dataset views next to datasets) and how to show "popular" labels next to popular datasets. It's also possible to get a list of the N most popular datasets, for example for the front page, perhaps this should be documented too. --- doc/index.rst | 1 + doc/tracking.rst | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 doc/tracking.rst diff --git a/doc/index.rst b/doc/index.rst index 377cbd68638..c9ed6549c68 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -49,6 +49,7 @@ Customizing and Extending geospatial multilingual email-notifications + tracking Publishing Datasets =================== diff --git a/doc/tracking.rst b/doc/tracking.rst new file mode 100644 index 00000000000..7243063330c --- /dev/null +++ b/doc/tracking.rst @@ -0,0 +1,88 @@ +================== +Page View Tracking +================== + +CKAN can track visits to pages of your site and use this tracking data to sort +your datasets by popularity. You can also export the tracking data to a CSV +file. + + +Enabling Page View Tracking +=========================== + +To enable page view tracking: + +1. Put ``ckan.tracking_enabled = true`` in the ``[app:main]`` section of your + CKAN configuration file (e.g. ``development.ini`` or ``production.ini``):: + + [app:main] + ckan.tracking_enabled = true + + Save the file and restart your web server. CKAN will now record raw page + view tracking data in your CKAN database as pages are viewed. + +2. Setup a cron job to update the tracking summary data. + + When sorting datasets by popularity or exporting tracking data to file, CKAN + uses a summarised version of the tracking data, not the raw tracking data + that is recorded "live" as page views happen. The ``paster tracking update`` + and ``paster search-index rebuild`` commands need to be run periodicially to + update this tracking summary data. + + You can setup a cron job to run these commands. On most UNIX systems you can + setup a cron job by running ``crontab -e`` in a shell to edit your crontab + file, and adding a line to the file to specify the new job. For more + information run ``man crontab`` in a shell. For example, here is a crontab + line to update the tracking data and rebuild the search index hourly:: + + @hourly /usr/lib/ckan/bin/paster --plugin=ckan tracking update -c /etc/ckan/production.ini && /usr/lib/ckan/bin/paster --plugin=ckan search-index rebuild -r -c /etc/ckan/production.ini + + Replace ``/usr/lib/ckan/bin/`` with the path to the ``bin`` directory of the + virtualenv that you've installed CKAN into, and replace ``/etc/ckan/production.ini`` + with the path to your CKAN configuration file. + + The ``@hourly`` can be replaced with ``@daily``, ``@weekly`` or + ``@monthly``. + + +Sorting Datasets by Popularity +============================== + +Once you've enabled page view tracking on your CKAN site, you can view datasets +most-popular-first by selecting ``Popular`` from the ``Order by:`` dropdown on +the dataset search page: + +.. image:: images/sort-datasets-by-popularity.png + +You can retrieve datasets most-popular-first from the +:doc:`CKAN API ` by passing ``'sort': 'views_recent desc'`` to the +``package_search()`` action. + +.. tip:: + + You can also sort datasets by total views rather than recent views. Pass + ``'sort': 'views_total desc'`` to the ``package_search()`` API, or use the + URL ``/dataset?q=&sort=views_total+desc`` in the web interface. + +.. tip:: + + Tracking summary data for datasets is available in the dataset dictionaries + returned by, for example, the ``package_show()`` API:: + + "tracking_summary": { + "recent": 5, + "total": 15 + }, + + +.. note:: + + Repeatedly visiting the same page will not increase the page's view count! + Page view counting is limited to one view per user per page per day. + + +Exporting Tracking Data +======================= + +You can export CKAN's page view tracking data to a CSV file using the +``paster tracking export`` command. For details, run ``paster tracking -h``. From 8db766429d7f2afd593396b7138cb04e15e6d187 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 3 Apr 2013 13:33:50 +0200 Subject: [PATCH 092/149] [#541] Add popularity-sorting screenshot Should have been added in commit 3eeeef2 but I forgot. --- doc/images/sort-datasets-by-popularity.png | Bin 0 -> 89653 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 doc/images/sort-datasets-by-popularity.png diff --git a/doc/images/sort-datasets-by-popularity.png b/doc/images/sort-datasets-by-popularity.png new file mode 100644 index 0000000000000000000000000000000000000000..87343b96df2bb447143634a751feda649655cb9d GIT binary patch literal 89653 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∋k zdV5$cL;HK9qfFWM1&IQ|v@7yo4}GbrFbg2vjHpriAO%PtpPzv)`PDq|@~5iNXq>oz ziBNCK$t$T50f9asLme<=K{BTSy3@DKS#B@4I)Ts}MMV6K1A~kM>tlfrK&=={dQ8tU zGk7^O-fGQ*^*IxjiY-TXl7KNGQAtAmht|5PX&|6n2?x}v^WdGkm9Z+faA3**lAv!;T;Rj|)&{q|xqL zsYj{}(f|D8sE8Vo!|IRm)!y{ZytdK(gyFlM!LG_f-oFb&{fjJU7WBkf+9e3^49jER z+#HKC=zaJ{OKOW|g|---Dfwu$* zpG>s-Y-{{i84sX2?VzA49*@h%N2@zkH-Py5S0#Wf@w40lpT}v(c%wgbwn~Sfct#57 z!~aEIKFO=K+u|07Q9B1{kPsLgyy4gi6up_Jxy~^4*)FBkjr-@9LALAGT}jZBnLK73Now2-<5n^0Yw%&vn&7=N+ua)f3YPM7au?I zUF=6XpmD|^CYH|?394DJ2BoU2sMPzw+seu!0}@r!OuFv?n+gTQbO>SZ_te}!?C;A@ zKN}X$q#j=){HKK%Bs<)>_B?mzr`PRJ7sPas@!%(pi;Ls-zGLRFoMmpOBY0C%b-1u9 zzPNd=jopVYo->0p&e}x;&JDxnZ*ER29h%voL&nEzWd*}`_`3Mi$!6EZT6 zxa8-XJw03#vmh;gecEzdtHqvtA58H9`;{Yk zENJPN6M8*;1U@(tT&r2R(>;Uw$YE)yK}z9LamZ`iOi(^YYxK7X1DXoxd)3HE_n>!4 zo#4!B*J3SOgxt@CMb1ZmvKG;r#`SiW(I0LfGWVv0cBlPWHzS6*KXhTKkuD-q$&zJ({^lr-CYJ32$PF+;7l-*bx)leAfG8&RW4-G}m4#B9t#c=@6(5O+(PB23whiMS6)VJryA}wP`bU zhlco&VE$Y0frM%8_eLF)$yG+$PKm*R%V|(FNiy3MucS~$TgOm4p z!%}eXnvdg=lk^6hTCwPYXjKdAeDe7A7Dsx-tX-jEz1R+9eBM2DzC$CLIi#CoHIw&_ z8hk_CPt>+sBtQ10+8p$7ceY%Nu!K$R6(a^5*J zXQTO(orYD;xQ~(!Z)jd>xjA&@ihh+IC$IuR>2!j?VzLU!^u1a(z4QpPh~SSi8;ghZD zc13AivbnP6=|}#|jPE#RkT>ACKffMt5g$oMjDtx6{^$H)y5?Q<)o{fsSewo^?Rh<_ zcz1YIGZSDOfvqQff-l{OBe2*kS#>fIH`&}?hg7pf^~%nwp=kWHuReWffDj)4@;cOO zL-D*wi${MI1hi{KzX>lTa(J$ZTpccV2m7*R&z)IRaP$7?d#}<`lWTxdl?$_LD}Tfv zte#EQ7oe=KiBz!3`7HU7OP=l5mT=&LiAIx*zX8ChAM@Huo99M;PXdrv_tIg?0F^_` zfU$@Y1oNNeKX!4iE}IO@(&@57;lYBk%2ZVtBiVHczFbnCZ}hJ-qI^9KPcVtUmr`CD zY_AV~Bm3aUGO!qLdWw#~y~Zd7186e`8llfIYFCV0W%>~k3c_M}w*iDdwwAP+&_DMt zd|oN_Gos9!)JtiQS;lkHdFY3Q2cfZ)x zndgb?OCmnI%5HS4urV%47z?0T_&ECpn`GnXS=T8GX^V*vRTvAEe$M#3?tIMyd2hx@ zGV;7_<2?&6M^8VWArQeBC-o|Mw>QH|O3Y|js9yj5zQmU~H(r&feQb4#rbdI6Zrplm zYbAOfbc5I}_GCuY5^-DOOP;H}exqCTJ;9m99cWp1D*0s9kxORuVpOkv>w9%9C=|YL1HN%J`|R=Q^#!>{5%ykSL6OskcNYHsaJbdE%7@7N3NP@A zCvoxeLzNR&^Qn7ba)N1)7tBF$IQ6IM2OsE zl9l$qtI%8)B#pJNGf-*2Q#8{=y0S_h?cB{}=6gX>Tp+G$bIaW0soJkk=^9afxqsjh z`}LKxu{1lFL@>%&+GtDh*;3dY(lH@R&Kam*7F0}p{x(1#+3aCwim1`R-lBqb;J^H|q;y9C)j;09gr_l3+3pP<7Q;*TFc9CH4I&eO&(slh!P zuj%MQMn+^eH#cFvtaY^Oke*g5;|9s4@nB(Niv#W$etv#8x3{{J+V={5{r%YF^fpLlw_hJ#FP2-Z1HvzH5|Ik`u7;K`KK5S&Gf^>xg}6mX|L9v8552K|Pb0U-a>c5gt2yRe! zLeKo;sH~BS-4T)TMVWWm167FfkpRx1k2+2Ij(SYDw1MfOi=D#E>p|6L_`KN!J4~Q3 z1XN;6NBy>qc?T_pVu>XlAcurd;H3cF7}7smV7a<1-g+u)H-+M@>6LBCb*V%r4aKuF z$2FVz;xwGKlz>bFv21b6(eASoUCAqk@27%x=;;ar}4CGlm~Z^xaGjJ5;q zG}}ettWxlWp7!IoqE24nu^3ZC03NMuOX#t=K}tFH=9#;W6hHhlhR6l9?$ysE<&gv$ zzXY$1-O7G$5159GBC5a$hvYF8vKx+hTov-2L=?R{5P9i#aqOQAV}5347WJGs(<;Bo zsu~uIAtJSw`14ey!k(E3bPnl+n4v49edn5IrTp2XEL8eZz8<^Pn;Z2HC$ z!imVb7K&b;PS}WB7mtTZRu^B86`e2FIcsG*{4}Rs;C(54aBYsM{F5f8ZfrdbigL18 z*iKKkomMe;S! zL1Pm=??xkE=DDDZgg-0`O7^j_W_For{+h#a8rS8+<@z4E8j_TZ$>3Zqp&*FI*dw6_ z_Pb*!#m@WCB=iNnu3k;FRdPL7A zdNqfHIKK0i-Z>hrIMMow1Tlj3aw=*9275$IJ^RQ}y$XqEdt?YjH{@soeY>*vcPf0C zVWNAOZv--VVn9QZ8-+K{htJ7E&FB+U#5hi7G+B|!L)EDEWDGXr{rsr>Tkf`p zzK)>=agI$w(M=f_$xE{nLA;6Ur;#~u!`BKiPxl`Xpf*Y#HB;#1P6KFI^VSVI8^9qp zZd?L#e*n=^z;qGFSbclA1ZvT&HgZr4x=A7x_0eJV|8u^cXc)WdTc%ne2DGx;?`mDi zPhNGRI0=6%8dI2fQvjC`pq9-geK_tFpYDeu^iK1%(5#ZYR~7Y@hE-~k=Fntn%v8M( zzcp^5T)UR_6B8amXATWBzgj?Hh_GzC0bFRl?5>4NjHZORB~EIHZh;z~qiE^VFWTJ8 z72NvdjfyXCd^CH9GK#q@v|;9Mjq|kun(0UHT;w8Erq6+IoX4;Y{p#kkG@;g=!!H+A z=oI~=V>nM#;X$;8vCiej)i9~@Kja^GH49%e{@nYW13L^MryD(@wF19!fvejrf0t1V z!T69sx3y&(T;8lK-8$LeR5uT2t)6<`&v{gIjG@ZtSL2J27rTY$(Jo6H*C<`LVtWnI z&ZC(}?3X~~A@&{p!^zREK>jSD@{huDQ+~7?&f>w1H?iYJScR8+J!q4FfWhad#pF=T zqu^W(Jww=|CwAykLbTo*uV0|zn?rG#8l!$o4wY<^WIF0Rqbp8uSR*l!WE@|T^Y`)0 z*z1tbsTd~&}f!G*|{o_q1FXGF!aN}NW9GC?f z*by|mRtiSJ@Tzr_~BqGh? z>S@{K_NU4utx_NF$c<~ytk+|Mmxp7n0mZ6H7lIo&Lq7hZTsc4a1f^qHY@Ye5GLkOM z8ozxt?qf%UOXVB(axAp>dAr<}$k&I_rZU!~)obm;uAI3H`cHus9TRQq7X2OK2&t+`|CMd^<$Coh4R{z zyD3Wl5Z~?tX*PGx#dn>|oU(<7C@WNYtJ!@qt{1Rs9X4#JWIT z6xBk%u}gFxMRz7N!Z=4UsQW}iNYXLVBz<%u5a$oaA72em#K`VcA#csNV>l3v${&W;Uc)c+Gpw>kEk#*{Iy0ZbEuSp2bRL%>$;rN`9 zpvv&er3va0sm&7adH$h&Xvg{i&C)_vU4)zrK28#`_(JZBz`0{FVH*~GpZd^wZ>hr@ z8%CU2GnHx|2Jtg@W_Ke3EVM;sVNbZMsElz)?@BK#@%TJm@povjPQuBq%d|_yX2fYk z=u%@$(?^_?#P4w7b;$Z`jY~%?(WYBkAC=nt<{J$Xv68vv-gEogtwN0c2Df^j5g6h2 zg(YH2XrWY3GahFn-Y?21I0OCIe0v|JCQyC$$ipcz`;&jtecg5?n00XS^yI_9TOB@( z3Ff$%>dWVLOWY7<0{co;ZZM;lh0~ROLeWmB-qtf=k{LF`;&UzEtdv$b5l@;VO&5;M zsRWHSnKDctKf?<%-R1;JaIUxeO$&np*?aVM0-!O9KgX>u%83GFD~+3FaapI+lU9#q zqT+u>dLQW0mZ@1>dl5)KM-@j;S4jw6tBdB!v#!T&P`owPAZigM4ePV+dBeE~i2%Y; zuzu}Ada2*suRjiDX0=2>AyJ$K;L&R!U(ofzAL%nN9Ot5}jQKW}?e;FI1D%vF`6oUQD*=@f1kNO=Bg+623 z-X9cFPa{{sqn92fF87%P*>;O{QfojD#piII{Sg2`JzQKGj$K;qc71>sEuf>J*+W1l zJuzFrp_A7+OGr;I`}593z3=L-*|!fDYFmJ0n~Dl-bdC6N(X3*VqKXi3i z^DF_uP))55IDlTjf;JvsNL3h*kaK;ri`I2d7-Ab)&%=lN&U*QGf*<-hEhrSeooF?? zQ70TUNdIJA$K_cnu3xaduBNASBAHyu0WXe;byndY?Du%PJFd^3L`nR83Am+0^VVA}fmy_-QKv;2UWz!mP z8#!|E)DDcoH=ue&^0Uwm@w2H1X!?h+-Ba&Gso9}&o$nrd$&Iclo}z5#0rusXh_v0520e%Y!5`W-a6u(Nq% zJ3{RfCMqYEaKGyfmFgg^#d93SND<&e7S%~#;7LQ8!pCvtLMvp6JacUXS3hMG-Yp2a|>HyN~(Riy2C*HbwMbGAeeWR;ZMwJiUj7cmQrs*{=)I+?ll*eIMRt za2Iw}Y7^WotH73?BA@d|OAa3yKtRs2{}f+rd8-MdX6M0cZ7H*RPPrwFEft<5zG^3R ze^BvdTi1#pUM|$Ry!a-IlL7mH?wNn9 z7Uj*aA*~Uu))4R>Pk21*yU=rLsU`gL9y=+M=V9*Ux{H8Ap`%+@dF3~Q=R0=; z;ngnl_SVVEgj>?#6|rFOx>KgR6TQ{g8f=i`N}<-3rACX(<*i4ZC|k!Poahl>$URTn z#g(xB1PE$=_zV7M#3A^?EvRqM)?MytDk&U@sQ#vrFB&wNHJKA-U>z%(gKT(6S}Njx z{MfKjAN&0Au0LpF;C7t_VH{K+Ukih~_0(uD{IK>dCUixmTU*Xj2ezIr)a`y0T z@8NdGXN(U@^ZL{ipA-(+)o6OYL$yBL;Pk|tD9ep~LqNXQ@e_h{L(X$Vj3U)8wM@T) zv6VV;F*-eKR5$O_M=x8t16R{;zG{n?Uq0T#;H4MdKDI*VYwVb$qJ~vyu9vvYUCY6Z zqn`W;Ihfi7_@mm~+1B5YwP{0qpA%+~B&8hb=aCcXT4vgp)uzMGE4RIbT`}G{r>Q`u zDbm9aI|J&)&)eX+C()@L@R|CCHTiwEUbn`P#WPzZ(Ea7k(g>n*Os;7_>e2qV+}*wa zc)k0?Y6vFu`=^yng-B`MQmu8grg2hH6H!`)CWRkw=t$Cczqf5WIuF4v~N|r z04P9LhkgS$_#X#Nz@6iCwoe%_eF9+IIry`ytd1PRCTZPpPhVE~6-G+lCaxy&uNedG zd#9cZ=W`fs&qksx7|CN6_*)ndF2S{PGr?j+OK(SKYf6= z%Ak*R46jJOr*$}&bfLdHtI=@`Ejq7MtL2D3x?5?w7*EPT8kh%RXtZYE4M1F1Hsn0mXDou=lg{uZBR7?VTt@s1KBiyh?|d?wL<6!qk_W0$hPjjI z&=^WQC?6P;M<6=y=2eS`Diw-lA4;~%^gv;wNK0DwPJ&E6t0Tku9TKul%k9k*<7rgG zIGiuL9~@UwxE5fMIut>pg`=4`OV{6}C{x##0#mzer6CV}>13_*t{wQ!uuK#ArZmV2 z;bQ!r-{MygJ`GA!Nd~+IU+UfYcr;diST|h-ob;}11>s<=RDXVb$E4QnYd)3pL4H9- zX^3|GFKbd;b9L#4J%x)AjARZ^-kEzfDzWAf^Zm!+6dvanpJT^j%i&c@Yz6N`SHc+I zA)7pmrM=w2d6a$W@?7=3DUOlmKzM{x*I$!=gG3#w(4}1f7X!MZmZK8NI;@NIX3l)z z23ZS!3dffS0-T3)$GtzmL#R+wk(Dcrsl+A9sIHt4q;WZQy<0udB{k%YyVEZZ73|ju zlmceG;ZJr~GFw>3wnj&YpIJ&>GkSH4u+b>IVmnx5z=b+sH%_0OQ9qO--X=In2#d+o zDrfRA*Uz1GIt#V#{bnC2nG_-W7oXA1F?Bky$h7&{W0MkV{Br^lCoiUc)FI7#jM^+b zYY*2|b^s2=sNyAcTR&!ryw1H}=-t;5Y%@QD^ha$QmjunL*zLZq-Uf`C)w&AHO}$MU zE$V-emJshaS-0_GI3(457Gw}>7U?0HtVEHEF#m4PevG5nK~K9rT@Xb&I?DN5nIu#bwE#jU|j^iUc&5VoNoTkoBx% z2c3cr?<>-#UwZN(d|@~((`%k_9GLJ4?KN>f{sYR{BY2&iRenl(lCyv03CoJnDHu=l zX5*BUGnDLo6cXMPUL~E*H1Y%)A3pTB-A6-6HwX9&E#9tsOSJ!fwNp^(?!EiwK>&3F zjFFXdb*na#?AoakGTx(NWUMNLi z$9vf|lM10ptf_;5oyzsmN%|~Q?C{f`r7tgb1?vK73N24AszQnh%3yxPBMFZlV-0`$ zWFvlFbzq05&ivx06rJOc_{z$<@#+5;F#*^Ae<3E_Qo8n^1+))gd6-7s!6`1P*9eRW zVhf-EC8;w;__meosPG9OOmn3Cb|wqy*+A=-m<7Chn{-%5BzCzW=vQQ#UMjDqjXFul z_+3WbgPwgbSr=?EW5@_c4@U?1JvjXFA1QWv>ng((WDJJ&T}nRAny&xasg8OKp1F^< z@6bWHbCoyj&p%pK#8-y$ullmaSMF5MoW`(__Dg7*a0*-=DB$ejdpjOO0%x-AXUHDf z1pRKOo^n-B4Rp-W9p8|_mT>1QS0S&ywaU{h55FN>eJ?JKExlnK+if&b;vO58G(ih$NyrGw8*kQ{wslO zw8Cfo1RiLuzUYIdv=qN!yYcE&dXS#VPoTJWtuR$m#=gI%#%yFFYwv)DG-65z>e5q} zBv+@7;L;O8kOqsAA0u-@{#O*}@zK-S9DgA-;jL&zvo1*b$U^r*SVENhTOZoK%5_Dn zg(6#cE)Sj5eM}zSfwWeheiQa5Rp?xpIQY{EzVDs63!#-{fu=Jm6>1mO=S05}dEP;IRFDF1rC0x5CivT4&t}$lM6e+Q6P~Um33M{=ZbgIo2I(4>x*GT^qZ#9#_x?we6b&fcIp3z6D z8T#uE%|f}6*(V%IXn4Z35{b@E%+%0dBO*o9WMXq{w-CCi&Ux%1SM1=y;(2u>&P4I? z(%)Q)FKr|Yl^07RrYrw&L53Lr4HMqg7+~WAvpsx+(|Z0kjE)rdI<8RWPZOh2)Un!g zZ_S6lQX#D`dIz>W#%iaL!=m>KkCqkoRKCclM_s`}C6t|q3A)2uarup|ZC{|kALFE7 zFoS+i@IKtAn~l#mK!=PDo4=T#z=x9QZm`r&gZ<-#)imz$0@-llJf9KBGYM+;f%EHh zZTwfO>}1x@P1mUdX=n!hX3Y<`dKEi44l~4a8mo!Nv%k-uDj3lmJ0g$AzS?_zU1jhC zyzSJ#H!N5uN70&q??F!YVc!K5-kP2HI^|408}t`J`6UdAoeyL)zEM309Ka~X7mP|% zl4U)=FA=5LUsafE(Qu2PpG>EN*KK=Ls=Io;DDvL(D1e>$vXVz!-X6o%>^4PR#;t&1 z>D|Yh(=B($Q6#lGz=CVFkoArwD$6^D>YDv8&%|XB>-dBDti;ka)5!;Eg8t|Ie5lT^ z{X@dlhh2t}n-?Z=C$#~fxf~Umq7N3Z%5i*Bh}_+>-{h|{wY*w7v&h**J}n%Oxk5yG zAaEp~m=;o6-!RY3+e-R_AvGtOxERjBXrUs2YT_aqm+2X?-CE<$Uh~%a)A|<|70Xxj zV)#h<=!W5@Fi@`M{!_in*X18nBRemrc%n__E}f$K$Et7M&tDGRwz=RVtI?xYpOTJR ze2N#%Hh1LOcy$~R08fKxMNAuq1XTSQq5vSSj~yJfIu0b}wNN%sTh^i^SrMcpcV zK`K?+aUJnx44nC*8H-ou)9FEhACq!T{!%~$LOl`cd$Cv`(zFW5VrMlE`WEkumw)7l%@)g{&S#)I$K4nF85-oNo->L(+Qfi0N6; zFt~NJfeH}QfL-LSF2zRuuH;q%73E(G zviZ!P01G(eNY3!lLa&52^~>qTmw8hMje}1j>ffpvlbfB$bVWXU5gR9}gCEszmxJp6 za|5}fo0%L&V4*tBjdzxB1XEhILhjm7*n$FYcaPa?=7>?Tkb=$WHoUl2l7mZTK)s4X z?hf>BT-t|Tn(O5%$*;)MS|Uzs__6ObT>Iz5-d6BA^zVGZZr%=RIgqvs7xXwfR)fRO zj)=^2-&x09H|D~8Akr~CT3=U$0&jrKnUC3T<%XF{H)c^^sUu6m)-#OtHW2wdE}@4t|nd*90!zO zU<&JeodpghB_%+b!zLsQ&B~$xH1@%>dErq}G+bO<8Zvg8n#qk<30R>~vqk|6))H#C zaKPnzgl?l6^KYm3lp;iW4#_9&f+#K#O38mo>f1kNynp7z$XzM^y$;d1TJtL(DPYRa z477?dQFXBWqq1$UptV@L0HDRd z_-rEdGGb6RJu=X)P0RhSzWL`A0AYPv+Fb#BvVdk8fcDv-@f2vBRiI=R80*eeFC8_< z)?jYRCyn*brDBnh$vHW3c@v~>rar!_X*^e#8NZvz02wGmk8=$8^gEw0Tiu$GEipeYZ zCLqSs<>x`3U9E`P_&q}EMLE^pL_O2WY81@@G;=kxa(C^T)5Rpf#f<;GM_c!%32(N& z?ZGR({h0*1{^K`Hb2YvV6sK!vN9X{j@Y)>MM=YtPb+|xJ-mcMNhVawj5;n71UeL|+ zJ0L~t>X(I%3*uXEIr(s`XK~)_?tqSyzi;!o2C@L&MBvKj?$f2G{6o?^_cv38zN3-c zg9~4q2ch1&#Zf`G>k{xf4&g}E!4&%LmCT!{lCpWOiqbR-;9lvrf z9kjgTfL@hHWq+(Z7rx+d&v4fUq%Q_g6$C9Xg=D;Sf@!P%A z^Z6~$VdotM>t7n!v!8Apm=DHR3Y_6eI{MpYtN`LHOKIZxb_AW35u5{0)Xx3m6{u0p zQL1J9#M<#_v7Y%k7mX4Iw{UJ6Z*{OY=>7z1*~aR* zFIZPcFKour^1bin|G?uFWtgsqtrEo5=RR~KibH*hPrtNd>oOkdC{yGy@Y|o<_c+u~ z2B&qWJ*(kgXe?{sBP2zeUyLMBlk!Qa8?ZA@e|ZP(QgGD2g~1ihh^nkN{ThJx~{L)7DAxg!v|>=C@hUOPTJ6v*jzCg#_g zly=3JEJZ&lxaLZKhy72u#g`0HACGMsNcyXOp?4rMD97>M&^+rxN08Aa6mru&b2uDc zV4JOJ=?)Ac)4z-I^f;UDac20^@wM##1~b}O zMzx2KgVD96r(*Q`FBTkSMUlNOaofScoE>%?P&DB83X+orRY?B}Ib(?Xn(FCu|66@y zYrsNu)pimHl!DH&6Ux?HFlpXQ5+pOZQN21HI_}Li;>(?_YO5p)EiQv#zzg}-HDkIQto@y4nyXugq+No6S9f zluLqd3BNH3iU&Ql`1kNOV|@EDs4SJiUB_IH9{2~!VhoOKE3MNx-}XGN2z(sJ0a8+L z`DjN{N?t(~RSAN^b&>QZ?Kd(VnSJxO^+Z#pq@;ht9FQM=ZCW!~I0prsx91G1MAyg} zlHQT$+r0A&molrC*dBd}x>jMq)0ZTpi!T?Z%kg05`D+XjroF;Bw1xU%421=MSoDaP zc~6W9@iV8w?lheA1S+A9yT>Tpt9Lti{AX6iNc(#gN^OuR{D2i(ZS^SOs*Mun<%4uq zo%dfTP51Zc3yQ@oe=Sc<0UJiU7&McM?*)OG?PHIj&w&fp{d-Ox11|w$sagV?auvyM^wwYk8=v_h;U`5SCF-ZtE78QPA#;MG6)N{C zcT_<5!3ApMeA#a^U|2qi61Q65-h>QT5FC86!Taq}?WF-PK)i#aZjYHg@{)_q_I7E! z`%*7WMkJ3^uiwtsU3~8w#axxqHuOQDfy_{BDAq1a4kKrb>TyqTw$|s%k)&J8{YqeF zOXlOs@?U|#YU&auS3tB6<#BQ9Z3(s#8njTZ+@L0w^XrR>^MOzGiFx)BcOTS>k@x<8$kV*A4GL$}}hC=*J3?F6Dqs!LhX}{5glut>XgPx#)q=nY&R zjjmtQ)eg-h*v;7(E+w8WtBBo7-nY8%04d(4df`n%fmA6mz2{ubZU{r9k0&D4T!E)3 z!Ht@w!a=Se;o~$+us2TvT-vxYJ!5!ogI8p|nsvN|GK%wn0Q%0i^BZXIwkl;o8%TkT zo?r)qVn8h)AFs_gOI4gnweb5Rq2y!@zxO(7qQgY|DgH{8(piPW{2BM51{L@V_D;>_ z&EcNj#oUV=}sWt}zn}#n++x zWF%O1Ui^EXH5}HRywwk17Jaxc`1sb4?$IQ5XKws?>;dXKo@T)B%9G|*k0cpous4m4 zs|*mOw*s3Z-CR(Y5+e;htSrjzs^Z2G%eSM~D}o#1I-F^wig{+Kw_s>2R!XRSMR6eD z#VsOrYsS@-A6aH6w**s~_FqL0cA(~u4`E__u9rR$KgN09=sY6aGkvWc`x9MRo{2Ud z)M=v#M`^9(j8dlywPI$u?k|P=^_u3&UP=5%JR^b&rTyt~eyCsZ{d2mrBnRT1F-l5; zhZ4Kk#<+~gm#xR!ZaxJx+{;PolJCz>B?tclIk}Q#-ypDtbfPWBG`Rznm1|j5*Dkq@ zQvy$c+*vfk)yg-hNiQ(L?Mi{voM)8kN!&C@F< zXrz0Hr<^_WT0$Zyv-@c~G|ZveTaKB;M}ewFiw4%wRE8672!Ftf(J!CN?v_dUn(k}A zcKT$_(;r~O`3s3;o8#>wKfW)bX9xO+|Hfnt4GjZ>@H(N>M*UIGQ7X0vf7X9NRju7* z)qo02tU^n)lBcGiaT`&|m&aBQlsjWx=JQ<7F;;cy$B@Fyxdmb3PVFDZl`c)cb$K<6 zsG77<7+-FcZA1_)NzI4}f}$+4D$iey4zYwHH=l^$5`G-$@9MONMCwZsD;9gBew2gZ z8RDu4#`}7t`Rgu@Rg8|)9{G{{i_~z#`1w?r*OeK2xT&e>kA%$bAES;ploPe|ZBEIJ zuj93=M98d)JM~A>__9R(Hm{f8r})Mrox9t#rIV&iWWY~MO#6Nz4e7Q#?=-GF8{A=2 z!TE+dW&-Izw+%rN&bn{q|2sAzwJp$LRM|oGt~@|j_SAi0+;x~fMECR9zM)fvG1#E5 zr1~~P)c4C$V+&m+q^6gy`X`sMM~4cFeFZcTVKQhr)=Iv(aEe?U@}xV~{q-e`2$&0H zjQpjAwQrl5M@!-LC;N#N>&fI^;v{pq@Q}DtytaRIjlA{X%^6)%;_v+MA)rBZT(8UA zc&C;h5KTw)bGm$FylrPX{@;05%)GXun-HK22B7J7R#l^rVl`Wi6yWD(2tLoOJs)Xi z$wWUeu15MZa9p?$=qk;9{CDKC9~>Q1E;Umk@V;e)+}urs5V;awG3$&Mvl0}HHD@Xk zJaPq)up0LkESLW%*tBua86eGkxI&bg%FLfZB`GljVfGGBo^M}?+#^z#yvGqwWTj+L zdC54zTCf{b631v5mAO%kOp@YcfY-CQGEC5iE1!Adl*D@eML`>v{hhoBYya^Np?R+@ zQ~$@63vc=}qZpI=*O!8`A6Cc}`X`*S>C2BM^ z5$Drf@&5~La(Q&i8P<@^0Qg#30uBBN7L(6t9_nW=k(FxS!6l9pjiv2D&n?!%lTt(m z>2}qvCGea%?YGlZShhG;VqWI%-mebd#5^YsLPux>v-xJ0P&ZaWn7{~%Mf_~-89iHJ zD1`U3q_D%>`NQYBH_n?`x2=SK^U7wCrjj2g?DA8QmFl-^QK3pizT5D}lFPUfJrzo? z1c0n>!TJiKYup0NOJfHr+P?$??mO5#K3a(Oh4zjPE3huG^xW=S9m>sxUDffBrwTQq z>j~TWbDRTt=bX={2h5p;_>fG3J9fvZq{H#cW84wl@$s#YA@GeggwchR@1web661{J zN1#^J<quNH@r;g&$&S&_nsVAL3Etps{nGsEJ!dH=EAD&4JW+uxmdNwRi%ZD#+%DndLwv{jMUOE89 zJTzM4xf^X-XEqHnUf#9Iw2O4aaDi*@6g+%OR*szeom!Xe?o@6kTvIM~@pumPBQ(rL znr_VB(x+0cja&)ym7qVQYIXRYzuBL#aufE@eZpXP@dM-Sm+6J+vI5uvU#58*HAJ1{zw5e=s#afJB$O zbmYRp!O>;`Oj%V{Q7QJ+(EU@Yy`JNY`D$X4uT%6nA#ucC#Kw&c3t5yS;Fe9Jbzj$( z-y8tZp6`|#3O2s-{Tdvk=18y@qYlKEAzU0W4_&KM?{F(u12f-yD6TU86|Ap*+%0-L zVD*y8si^^IA0Tr<5#2%S=vc`G(3C>W#6gpm_Hm(XYyf`wDg9e6bzXbiU@5SeuhmQH zBgwiDVC$x5ByILt&->Ywu@#(hyAdLkhSwiMQAgcohH*^t%rSMf+;={ckM}Wj=>aX+ zBX71!?oTQNa z;C+Bg_hmKj>e##>xfIY%o6c9S)obI=pnA)6F0_`f!9vIfXN@+Cg+p- zi<^ZlmW_GnmcIdr*)4Rk(Z)Cz8ckaOhUu@6>hGILvKU0!UD;CV5IlzGnsgb1lmi}B z#RPIrJ)XOF`>LB$tcRR7aF1!1Mzg{EZI2%iQdf0j5?3VOK7W6dW>4O7cH~p+AglKm zW*O6tjtkw7vQX@9+C^>%jSc)RryUIslzCP%8}kd+G6~dwgSoa?(tNXi!-!-l_e>_g z3U4HnB$b|mj1fDMvae~4Bi;_5Q$Dc+$ETzO5G4RkmJ%`Maf5+>6rsbq$5Ylko+T21 zib@85wJA^9sJ~S-x85!F#Wik^L;1`#AQi=!!&=>D=~o~gUWXs_;xwWrnYt)t9i|cV zV`uZ}O*C2|WIqNZuS;#rG$(NVQPYMLH6O$KL0gt0kqnxkQfrJnnoQ*4q8O zLp|?6@k=I5w~eSSbsHF*f;RCZ7%Ha1D(5jaQZ~gjZGxxwrIZQ9ByCwUQVVDMf6Ig$ zG0D8wc1(P3g^2XgX^qc3=P`l|ym!4HhTw*_u^P$broIA7Exob+X{o=5N9EE3@)dx5 zO82Ui(F_n^4{x6pn1%HGD0RQ3us6jy+ofxptLLNb^nTbc6&xq0?o$uC7if6Xk}bU= zO~bHogWJcfp)V0V?rS9++TtBNkt2s-iKpm?Ty)T}(Fe9@f?@hrpmJ6{=T@eKrTS)X zg(;k01>JEKZo_@5<(%(SQa1ib?b~ov{_Ny+)jF8ctE#upo8qdl+9A(CmcxpslbTGqSpp>FPmf1KQKJQ&9-d+;%~y)o1(Xs913DZ>zINC3M4?8 z4lsnM9h4*IEsn95L@sW}-90 zc4fA%jaJL}AcE@pll+Ur<%UqLu&~3(wUN8|^IaD27$*Py1Z20npgg0~#%!3RM+&I) z&WGTrH-7Hp-F*uzDsJFQsb0njA@(cXJKo3>ehr*=a5g5H(miO{f9r00%Vmb&xxzM} zpcxsWoqASL%>Ig{Uy!u7Ns+y>OPg5Ej`vMiXC*SRJL#FNBPv+8UJk1r%oL??Z9w11 zK=L>xC(RNH0k@gYXKOz@`tP$*fg^&p$iEk7$6Lnehg4`vOJsj3Zn_?!GC#&>s&PWJ ze)qdHBvr{Wq?Mb((;52}sfuesQO@r_9o)Zj;2mP?eiqZrG3N#|H_`}v{jE9p2_c*g%h zFvp(MH~W+9F#Vmpc#V+j8v@{91J{&A+&3K-5Im5|q_NTR)K(n)<;%&$niSF2b<^+o z1AMVeBtE`n@DPS#?7Bi-nKE*b-*N$+3SOPC*?m;i5IKCHc5KMVu|0t`({owiKQ7%7 z79O+9=weH;0jSx(QH3hUg7^6B- z>n<2Z+D{^x3VjxQX1@Ny3@Gz{f_aFLLIGnW{b*r*iZ)LBa^fiCdyeH4;;)1yE|jmC z34>6`c;tL`57sEHrlj2WduhDhSB{#!2N)9@WjfWz-Npb_ABY1BjopB|1K?5NnHF#7 zrj5ZUr9>0fT&r&Hdi`mVSI%(cigY>`hLL9*M%1_d`}vdI$tM;Py2giJE%9$t`GkQ-dY9bcMBf3~_4#4Y-BsT2WF6W8e-! z7#x8=JrV0lvC7=S`Igz8ig;q_?YnvgGnG#J@LrWC)i6ftmA|`Sy(}Lj*RJ8TK!ET| zZpF{%m{7am<=Z@bCcoGkT-VvY6ummp#yqW5BV-Ow|Ehb;3LGthMmHlIRt+ZE4Sc@1 znD_Ri-OV&4DnXuA31xxB)?_2w?+pv>CZ+nFsXu1&*lS5t!71>5SxW?3n~J!7pJj(# zbvfNiEQwfp+*K~o@RYL%)^>j#WTF2Ycu-{Qz1U39;3O27!ZCVJ0_y+e13ek+CD={N z+0f6Yy>918?Bafj_`9Mla*Dte>+76>gp+s1j~nkaUtQ1Uo1SsY)1ngvyVXcYX32!Ty< zO}}HFgH|6{WnYNq{yg{=uHAxOa-gtG#ESSrcKa;>N@vQJ1)h5N?0^Y>6gVAAV1%9) z@3$CIr)tA5Q=CTZ+qddVb|%q8QNu4Vzcm-DWy0I9aLJuRw2V`!8<#Rl4HCNm(ZQTr z)T};`widV_*DWuzcQ;bhE2`Wrdk4CcjO(pw8edDPKRRV5w&x=KApIwipt{SUu!k%M zJyW$;$CpMnyXZd%PK!lkL@Wu~SkTYfvKg1ElR2ttJa{^Z3Ae!PXc3rDNX`u#*(@Te zbWJ9cpm$cLuWetwA}XACSz=V7^MbpK6q1~DT1ga$txVciG89Ky!GuUcsi}m!Y}?rQ zl+b0(v%?q$4A)Au z$1?0k)w82V%!-`D&kg2`8Fb&4OW}9?!Wv1M+a5?P)H( zTfP)_8IgiS&jLbQJ8*_Z;?(XEbWiPJVkU_c9Jbci+U}Eol}_Q-yydH6KD8qi6)1=5 zbEKionk=&;p8D)+!*$!6)OOZ9G!P;Af9QJ4usE7-Yd8rO2$G<|A-KDH2r{@2?(VJw zM34jz4uiY9ySuv++}-`{+|T`-@BBF5ueq+ds=BMXcUSG&Ywfj_I~h_3XT+Yo=I$R_ zkM)RdDymmyz_l>z7tt-3TMMGCJYXAgQOMm=EYYH;erva#sON>Mx+|CTSx<(CGu^qq z3vm1};)H@gAW{~V-+=DWt1;`FL&&gJ{tz`%QwGJ}AfmeLTUd?Ll%~*TuNy;lytDK3 zj?PY)AMIb|a-C?4@7mWo1JBOR`pak!fkY(jCl~7G3i`}x%VaYo7;1ouP@k`^IxVq7 zElu*CpAygG6ICm5f3Fr~_j22Oh4I9nV;^(T=kA*#F*&Q7tKPs$`bJKjW`sHUvQyqRDN!VG8>8zX`Gh|Y zWXg*;=)Lt@v*csV$RbQ|$|IuBFZ1H5#?Nt2gBS`mxck?lo2s^pePlM$;aA*bYb$>U zTiojjaD%j2-~VCqwERXQ|D@IsvwDeCrdv9yPjpn~8B0!cC1Vg+Yu>DTqAdle0Bz<8 zpH@XeVym_6lq^nVR%|y?Js;J(2UH4odt$&i71!Es;;I;?PvNt*!S6YV<#JSJb$4gz z%?^GqR#|RIxK4^I-@3Z4lyr<~JejXi!BF5jI#XqG60f-~*6V!9qjjBx#G{Ns_1r9`t7TC6q*|^ z{A9V9o!XymQHZ{I$+90rIoK)V@WhG{}G$jR`aBOX6#2mI#Dp!E? z#II@Ewi7PqT`BQ&5@wL^3_miwQEtTbg1ohAO&LyXHu$m#$)9TC5?$Gs6dRRE&bUI7 zMnKt&J80`Fapt?n3SKP^V$8jnHtygWHQHwKb);PI)6!?)ZF&+cot8H+;WRLbch1Vr zK9eY}uXCnolw18mGx_}+F-ootqW6f0h~U6N-;1DGSRr|U^_sn-gJ(5TkG(VQMGeuK z++ZwbbcI*Ls~9L}cEz#!PKwi9Zt@6Mx?c=Urw}Yh-&AbR|bd92uCB!T~1#qoSe@hc;60eV8lWuUfuX< z&jdeg%ARNI-8r7Ky817R=r!=b^n|79Xx49O@2H@U*cz@RZ9U?929t;4WaBv-%y|iT zx+(^$$bf6u+!o{?9Vk&x%offy1G%?fHV1F->=fi~ z{HhhHus^*dc6W3y@_6?O=fmrKNKicS8vqIt|)6Lmw1EgBsu8;C#9*vW#-5u6ZFjPq^sJVXw=wGnMAB4o5H~L zY|ws5Jm(33m|to6XU(|nY8gWXw^y8s?I57h8@I@4QgS_B?+Z3j+P%~a&j>!p0)+XS zEt7~1QFSgW&cYi%$3bi3&7ZHsO(`85kQ`Ejabrl#oOua@GK>`hh~W}O5nOjGP`kMs zcV5?dKh)q65*7#e`{(bBrh+>=I;P6Xn6JjtMGGv!-4u)tTazZW^T~13mM*&3!D7|w z^!s-gJJT<)UQM3PYQ!AW)RNv7ea0^1c3#t!il739C$e9?)1{uU`GgYm3NQSw;sV#b z5Eu6clIokJNz2HB0?Vb<{hxnGsyDtS>|e9RO}~2?QPe|0Qu?vbTI|zEC({gZ4dzYP zidb;zq2Uk%i2V?d&b29i^#y`ZKA*f_LGDe7B2^1|k?Y4w(PooD@vi81`yDMd(*}Ho zD#6Rg=ibV5H~WL_I@9v8$133|{Athk(^i&)sKkX{>xUYh12TW_uf{N4;@w*yeWRyP zs(TB5auqIH@Z3z@Cu}@u>XX-~q|y~Q6_en|JNd-1WUWYs-I@bm4FNBIFmltGigoAM zKQwiL+{{?Mq;{%S78G1J`sQ_N)`B0d3A4hq>O`J)J~vs`pEdGU*hDPY94cL%7*S0L z6!sQ+fB!L+7N(acfnJHoWm?Fab!4?aF^G+`SDjy5gRHFOmXJXtFXeS}%y`v#%~Y^~ zMo~9Zh!$vFeA-C(9xom*rOeZzj+dTQg8JqpoB6DiUve8r%gw71o}#tYN1T|(g@XRe4&pb%S|VF z_b1!_`(IzfsGy;t#Y`!G>!v7qx#R^>hCTNtD6VFTwJaf!CQH@?73gqm>EwcFAZk8< zUs-=W(`K++y_1An$g4A_fljP z{SY2MaI4N>f{$t*3Q4{4PUve`)pJ-Y1Whcx(Ewi2z1b=P&!EZrvp3K65U%M8Mr(ls zGOLS~%@%e}DPgz)&(%NGAMkk`b9orp7;K~}k}O%Nt;QtLDREA?>IvHkbv$&X&=B482R>a zEdG-w93DIQX)cE~k7wXE1LUL?hhYnI@*zauia?4CJ@?T3yJcWnNGFZc5P9jP)9Gvuo9*iv1$*0(1Gmo*}Acs5HHrm3l1MSh#;^8#Oh zRQT13mX;EF`RnZnmCBFwMue24DVt|w5|4#r&?U{n&mDH77R8m%(ytK6_SA+6^Tgp& zsqaJQy21*RcOA*h?ktxd80E8qn?~xxI5MS(25C#5qzAvhu5#aT;cUIb(qp0?JS*c3 ziPvMga_?HYWRf?YH%$c>Bq}$)KJr#;H$p4^Bk#l}=-rBcB|wqK>UTr7ad*^1SY$HM zo$J270lgmi=0CJ@EIyiGr_@VrfaDRhFQVDfW#b)=Q)~KYl`j$;KU@8lhRSgqPy^V~ zN787!?^|f%D`#>+!5-RH3c318zON1DmGey1%mFk}H-z#o*1V>gPEk@zZ+431?-f>F z<$;oPZ+TYrY`Y$aT{7agKI(z>ew-P@$Bn6j(L1}FkqRN04CqLdQ6rStKHrl< zyZ61PRb5glPVL})96J8abS{-FxgBzc=;_-Nai4F{eGF~K@l`RJ3S>Ym)7?2;d5bh- zBWxj$%k1EDwll=lI;U%0?4D>jwFkYU42Z0vY)ZEBLBAKh1~?Hx~V?OxpB zN2BPq)wv*bM~)b}Z?Wmp`Gk3-GWI?ckM3%mGPCuZWUTDoc#O~m4ew1*Q)I{PJPnXA z6W3<*T}lD|qjT7nI+l-$T5lMZS?_)+{UA_eY-gZ!BMA^_8{cyt*}?4n*130s$ah!( z_oiJr=nyn-+Wc{*2&eN9w8Z^{234asn2t7BntBm#r305^+lo@;dK8DqXKTX7yE>ee z#4NzQ>JDCqy9%3s=P4F!2U@NA72#*7FcHnp=dhOEMIOM?0!CmDJ8L)XFlHFK;-j^30%Fzp_DjcNBq=Oo5@*)yqeSg&ozXQQ~~4NOad{DW2Q?b9L; zguR7aNXFb>=qtMLFl~!PSmn@{qF`t4L;W-i?5!(qixv9&~>k;(=N|e{1yqt?%@{R>f3#1!CuYn@uf2T9dKu2*Z`hi$Jq}EO znw1zbUDKLOMkABUTcRck%eE1D(-zLJ4-?8jFLA!XY@a()xMk&*6B!@SOLx5A4TqmK zl4l4X^7gRAv!(K9Iorbdtq4$d@@6oVPN`;W)4t~7Yl;OnjuSd<>O`a42xqK0`3Oz3xq<!oTzt-I9CM`-{?rC~ zt$LCx%~;dpL;>w1U4I)Y6m}vo5f{nIbfZIwxE6&d@A;>=r)#1XJeCxldtO)$JE_yJ zsIV{BucOe3ndOhQ0##JBIAbJReAVR*@m*x5OhDojk{*^^{}_Ebnxd~$vg`C`ZD#`g zPv--K^q5~>{@Oe9yz{v2@k7cmYGiOo zz*HRH1havsTaeY^UjauoI<>-}_3g0j8hl={K(E*G{LV;0D=?Yk@i}i7%iVX!EK%I? zLO;q9?N-~mKe!vQ=g?x|p$P=d02WQwBNJ5CDEA}drfbk#Mu%c_tWRLy`~>OcNmXnfYxf)l)I+h20jB#NiWi^^w`;^-s9$l>c%8jz$R9 z#>O)V<}b1EXQ#J{5j)NI<0{oG*y=Nto$EaCSny2Rq$=o150+>Y+J3TJ9HdN}YYueS zeE^vLT*?hj9F{9I1M96NA2u+)6(0ukW0-fj_X=EzN_>0sF%HHpN>_5F#Lo$=+$uL+FX@Cg6jEyrRMA%MYX=9x zrKM;A2UZEJ8(g1k&>mVswY;!d%)~yzSyrX>rm+lZb4Oh#dRy~j19hU>z^B8?x|@|n zzs%+xnGr`#GjF#WPi`5H?zexKVwR=o5B{o?Ihm`wIB{NiGR9il_6mw;;K3~{5`!Mi zWs;RSlEjt?Xgho6fk@Z?cqnO`rcmOT6t+-4eK@f>||*vYsRo`iZ;8)^Jb#E zSyL=Vea98DA`tbg$@0m5#ooVN^$3aQ?@w%fjRm8~M7*w4ktrGZR>%?MAZkT?7-yc? zXS7(+@mBYUt%)MGem#QaHquav;s--PmZ`Yn(6`0 zCmwYceggEE!bw=F9x2 zm+VS;5RDMTxL+NS4F1r>_?A1j6UqMGjuGAdhysdzP~DjRmd{c zdCa&ezmX&mgHC7IzYa`VP~leASvYNKAAdz{Pu+~F zgWac(U0hHw=>X6gI;=T!X%A7`oq2Vz<+WbnbTS3$ozeMR>~TFRaliWT;RCJ@lU~bX zDouyf8Rw-<^ia>us=@ih@FB0gpJ-$4=|vXW-Ls=dgInQOz3P&xQ|_T6Q1G0`IX%_$ zzp|zG-g-T0Uay{IXe)AM$rx;@hM(ET;M~zzVGFpw3ygex zdpr{&Wip^Zc~me}g+KgGle_$i+`+P^U%9PPB)XwKc5l^Y`xHo@dv5%0!FH2ob&ozB zs#Jqf(q^iYm(Y}O78QFo`YY;Ci|+>$*D3|qAXhz&R;Vw`_aGgEJGTdBejFLxCzzRF zNTCxtZ|W=LGtxsr_msRmd(@Jofy-75$)I{UnV?4nk4O*4{nWQ>t??VO_cgrG=z>%VI@&34hV5Xm`U`fkn1ei_XESzakw~svu6d>HROL%I*Q(8yu{$?a?U32{ zFHB>x@D=^GwAt+At0{cuk4j9fB<>YQ$~#_V(JNa~vsMzTJIy)C=?#dusZnjNp=*D7J=GoCL~^054(yu;o( z5323r&^WgpZS;B1a9XGHF_(|-BZ8vib`$HLHLdTT7YeOkj;Lpkmo}He{OJX)L^91Q z`JtX3N<}{2O(nhGz&8{~>4B7C{qwPcYbK zpXyG0IB{?#7q$B`U{y75Jz@_FWZ3}Q>ke&|D0W~)(X{2?q`npJo8?g;TM!To#(g6L z0;Nl?eY(BPTEP0x2evlLcQrdm=W>x*3vhE@fR}T7e=q1MtL~bs(n%Rzp|{;9v^Zbo zN~U!aV83`v?=8_Hy%Utu*YJ7DLM^t-`#}AC5XnafTjDJ2!G;XihD`Y1y>-Yr(H~UGq(u0;?wUaA@5F8Cxn$FFhc5Oj#AL? z@8!N_24?K4yiLN0Pt7&NZEcq4cDYJ%%kIz}v$~&9pZ?=-+&2t?L%Fe#ECRJiF>L zKVa)labo%;LP!{Jm0d!{QnD|0@6KwM$SP6hGOUP{D;>Bup*E93E%kspeLAs4P&3nt z-i>ix4r%`!`{)w^{dR;qGYO559h@)p4dXc6CN%FzkC!XEG;L9phH7R$n@lD&G(AKn$X@JJ9)<^h#6$jb zp=d*05*MM}A6H^sh(nX=^yD11fWIurt@@PO8w7W#D+22?0f5D?! zMKKz4{TV7)w1D1dCTn;W%A0?84}0BesnpP*(-uKms%yRIr^HhQjWx-fk5wxfHD_9R z*INpUf`>)9KZ^DEctKl}`tAOymwD4aV>goU3@-i2oAaQ<*6DcErAV_rGR9)IyyM7R zIe%_a8EuNZ!$)tBWp$=$ikW0F^Fq5O-TMCg(tCd(dr-ag0cnLHfYnvl`_Tn&GdkFM z{=L+^9HBd989mDWt>aes)?>vw+Q>oTM5_zZ17mc~WH85BwVp}uf)S?W%_kQm%W-q| zxsqf3qwHNPQH{Z5xYXDFw&8aVOfNIwJ|W}PJ)LmL=wQn}5f#%| z^hJYh{qr&117Qw)O#2&r>+`Gzw3e$0__dO*k7$ilSOrSQ(=Mi*)@qNmi>Cr#7fY|_ zxOIA(Gc+;`e;g~N@t+uQJLqwWKAQSguyTw$sZQtL>PQVkY%{}6eCZf3!WX?NQ4A{~ zE^f&DF)0Y(d^jZv^IgL%0^2uzP=-Dy6mU#wczHE}3NPeTRD58+YE6c5fT^CZBQf_I z3HHt}(bg$Wr`qh!>&q8dlfhUlT#EPnChE3_1_vx?cyP0AU>JSM!^YW<4z{nPbDATz z`@~4zzx~om{(dv2GY~D8S9S6LI2Qwvk)@@jq?^gqkgUl)fK3c2b^tQvuGFn+g;>pk z)52l|ndI-^!oCrCCebK4e<*T*H1A<@9rxH*r?D1C$M8jgdbbnn_gj|6EKEb*!83VsDpECiUe*=@6)6x%JIx zi=cnI$aCK(FW5KVcH>~b1C0D?({TeVv3B5BA%t*y9@R>Q28hi1Ps#S6qG`G|CeO{O z8{jd#-z4i`*94{qbQC7(6G9~xlo1ww1&Hnz&;mSGd3kwqkYmYP=}3S*=v4BS@0m$- z-?4n74WN=F=~n;K>EFKY+AIk&l97G@9+Kj-$<0dh#3B`@*UKVZAu2s%S-tDbn~TE$ z+~eQo@870in#`r2@?OJ=s=a_IqxMoh0o7C#RIw`OLo%oh!-3H8|1>4?BA6L+Z4~%n zRCH(`FMir5ikK>@F1(|x(BZzI3~oJp z>b9H7G`PRB{GR~>CR{OZE$2OBTFC_a1D&1Rb4)PE^7v$@@-duz^&HBdwIL{q&Ly8pxlj=4d`G@xRQXV6xpRG-fPDB~ zCJxjq>4Vu{SO2Sa0mX0`Wf5a_L`hCIQ)|b8K-Tl?c`$&$A3c z?}87K5G18Grw=?~%i97cq0t`{GlbxAhmTd={kqx~8p5BB3mcuzwvFPvd@@8KJ-e=| zZ&!hrnG-EiPs4fiZFh~^IEbsD;P-!!^`h_wXlZY5H%L_n%VAO} z6B{~GmTv-Ew0>p}5)2%?mWGvH^$Lgkb@Y+LfE>%F_ts>BJb?VJ%+t^bQh1X-T}yVi zXKfRp%JNf_@%^ih0}}?q3m${h2ucFEW>In#&ci7CO)nTvsTU#{*c9TrTfq!Fbtd^z zh{3@Rn!lIPzVC^@1=}?^Q1y&t??S&&EYO^!IFzEgzUwzdX^Ej#9LhVT_O$D-q}90x zW5GqP9_=fVfG`U*BQREdM_U4nEDjldvHo3WL`url>36R@0i|uH(lYT(kysO;BEqC{ zRVNRiyDhKt0nifK40(Z zJUW+=GjY)VL{=}cHhQZ6gdESGcr06>yipIny^M;Td(rGFG3PT!I{Qpz_pA>2$?cF4 zLy}sytlpdEYFECO(>c{PQmnU{^905JRq@+We16YH?MSnS>WOc%zy)XJW9v7kHDA_) zWZkNFs5uvDdygMtlCOSBbGhYI^(z-hfnqF43_S>G@9tTqhb2@X2~?^F5W^~Q^p;>sqK8t3m60hGQDvU zEWV=*MKeR^#M@&b$5q642y%sT53)Xl3$cQ${stpULS2Dno*j#qmc%mlP$J~yhHqnj z4;+w~&C}Hc-YTvmt(xrK#VGYgs}C@Zxry%El&SQJUbkzVvwIV~@tisjY}>uwOBHWs z4hKo)&+|drE0w8|mHv%pH;((ZISq!vh8urchTd^~;BBTNY~}u}B**&Ov|-VLx;5oC z^cPIS>3uj3OJl?!0L!-@5$253=Flz1^A!EP@6MLn&Uvq%-o(Z936EV1_+*w~u5W6M zX_B;#A{jw;`(|)HK2WQ)tjU*7_wMWy_A954t5=AWx6@*NTCZf1ptI0e7_igwJ;|OD z=&ep~SFP7ri3y)4NTjhpn9Xc@hl8W1%+zY*OAl92t3Dj~)r`NLlqo$o<@3AND zF*deFVvL8 zUhHvo5o2N6;hIq!o^|)`ycqHQgPAP6h5?2iyRPe^y`m+`}=(u5m_cSF9T zrl)*X%+C0|wUrV|CnMM`I*GD6@;t*gMy+d-!c;3z*5LfiMy1Qka2ZWc!Qk~cxNPp` zZEuOxPrL;_b>Fwh3izw)izv3y%RR};7jv1b+z>R~c8AAk@RKo&z&Dkh5CfJ}NHtDs z*a@4<_80i+VAHF(ReR=ob;>&{WsW?efU%<_sfS3DR+p~~I1mrgJn{zQ@8V^SQBidU{sIj(abfK8ru}%%=6{=RvQ|-*wIa|&lr+{cG#?W z(P2nrv1PW_3`>rY{Rww*>Kxt}*OaB__ZP3tFG%9>8dLW=a~<1GD0)VchW?n@oo`eL z{RF+d?sopR<%FtKbb|wVSkA4gd1>cbJ?w7)t0AVzHGls?jI9_Q@71Y(bu%xdjH zk;3RDx{LENY<1saVk?$))x{jRZ6V zDj8l6ORz#j(M)}wm{5d@yHYGx9gDkRO=oDnVkTQeete+9z~(gTd(dN>bN!yJY<972 zFHnecnXaz8K17lkGJ3J0{tUVAN~rT@d+p|{H+3O&aSg@KA5T;7K`gnLcON+nX)9Ia z;h(_nyi#4+ZiL%|YaMP^ZFO93MC5pknNknM%2(FsgoHP=Ob2_@!s7@m44OT_7y!4Dy6DiKyr+~@+eN(a>mFHgc zwMVrIfZcOG0kAuo6-S1loJ@P7d&miAt?^jI*=232NoSJXM~-2re=55_!3kF<7d{!r z!O`2}-GPhGg*j7~G5Lx$%wqcz7=^+F_opkh?M;+jj>3WBV2ajyV@J39pZe7sFedLSvR1(ZdRql9XGo-agkEi z4&PZm3^~RAjpO7dH6KM4{pGp zgi-I$mV7t=7sl^iJ@quiU>xG|z?L_1Fcg2q*UrYP9I;dzwUgo4uo^tyKE>n8Z7Np(Tp_#^2INB;FN~b2|1@x{keV1FdENLQdZ;7N=*Vw z0Tzp4R;p1>l1_V&)zzK*2!7Kair1=nfAIUIVZfC_IUIzK@5Zlxv|GD-pzDn=jQ*}1 zJs+>NM(FqGr?f_M0A zfYU8LVrGvLet6TMQQ}1ZSu`KqRH+JocxI*{bOl3cTQgxfA)MkM-I)F^OG+P|Pt>q% z#zEqPi_WQDn!93?{sL`ERvNhKHujRD+AKl=dH!TCdP(a4f@oi$#^#tP3ehX6$hl55 z3<}AP_ZLl%d4HI=!)GqMEli3es7v!qr!MVAu}{*5_0*1hoa9U28COUPL>Z<>Q~(Xd~? zX8eJJ8g=|L9NX{QPl^LKnMI8o1~A!p(W0$nT=j-q*Zzg-?`qQ{o%nwJZ&dvm_u;Im z|AwS4Ha`|24OXEt$WD_u@m)RA(qJV%>1Yx?gzEUJe8SvPEOD+ zvebTSrN(<_y2)rM$nf(|_s1~Z4h65b#kK^-?Z2(M8jq;7^QCqskZQR$Idn`jMpnLn zeO{R)tqP(<54qf}l*&FR*N2Sr9MgKj-6>upHn6f&AEhw1;wg!=hL%CGsbfUFe?NH` zoE8=l-(onqau`{kE!ALD20wr>oqw0w@>JXWJ(BU)WP!g^fClWfBSlHoFyS;}f0H8u zCqcDBvEL@RVv0Y5gQW=@SuG~e@>9(==%ss_C0`{XJH6v`*b%LZu@4xxfiB^<9a74~ zW@v|2e{V(2z72+4-g`ET3Vz;Tni(JBdN9zQP)!5Xd%=`>-RDS$m!&M-x51v>9eXP3 zBUT`a-zR!^*tzm7^$Li>K*3%^3i5*8$y(uujrr_SQH6^fShQW?Q#)d28kTSep`t^J zbIWFk!)(2ByWYVgG~J%(F^laB#hlIxlfsF2%gekUVT3UkWJx&ElG%L4av^!H_xR~y zzSbU7$Y=GZqLUEi991Rqu?lhtV${o@!>t1AdQ8bvgl+}aJ+*@W4+wUKMS;)c9LyDL zR2|;7%1aVOG#Q}cfUZZAXR&KkpfY>>J`s^9*X|>?a+n;bk2^SSZd29b{LrV=(<+Gs zZ*x2)+F$ueR+_TnUi~A5Jh?)JJX}E+=GyVLvR{2FWxb5ufR}IwOqMi&{90CC+M{+G zQ-~70sHR59y)p(6+d3a5nwf8FI$SL7$KeFHN52ow<+zhCo)mEIXg#*aYGBw6^8oMf zd+*2I4Xf{r_#dNb)=#|p$8mia$8kMo7k2D zX|{yHpUKt1uPrkflyB0cl_gbW=voPaQ>w#rz;`0rj4(!#$`N4(J8ZMtu~9{2`37+1 z$o-j4ivIH;hikpGbj{KX{~~;Oky>uJp}k_cn+lqbiO4#n%6-%RgN{&5W~R!bB=JjE zJ~dIxR$&*RR{wRDe$OagZ$t`1z8=jh*$SSG?+cwh$$>X06#ELFn}_-jD04%8#f{oa zU*~4kuAjFUpHh_B8zA8@%A^b1T}J-@!k5tDj&whXEj5)&LgVgGu9(>;L%#fMSLA5BQd{xP$Cqfbjkom zrZ|6_F2@9MJx&t%99_bl-5f)YB(t*V_5PezS$AvVg96L@t8-FD9?tCokRNgV^`Ec^ zW4i(lny%qwtvfc>uMH;rXZcF_t5$q-4RnbevW0&r1SA`rVbxH(BJ&|+tZ^qBD^&4J z6+(|IJOTY{UWgo68J6kV5Pfm^dZJE?;`yFgX;F}x)1;jCGy?c8akhrxpLYxw3ZMl5 z4Zjg}SxpfW;13k3s$!|CCJzAIbqP6N?MOg)>9oT)Fr?gBgWoRDUiG|vbQDdf^?RL; z&Vbp*tiyCHuZS#f^H&Eb#N+7)D)|#@ifSDFjiSQn*~y(3jQzx&j$eF`Lgr7#aEERF zy>Ow*C@Y_8nw-&L{!vMTQA|Vh$*r5@)&%x7yP%flpQ%EY@)|dXEi+o6pPXwS)L)$m zXhtw4`$>a?-6Cjk4)s&)B{y-n}H_ ziPJ1q#2f`Du=`22_2@zbacnObEz#B z-y|oqpIwhnXDPy{<8>QePN-IQ%;#yEqQL@oY>yW1gkrA$askLx*SXkyCRm?&93s7~ z%$N^CqYTp)*^CU#6x1 zmSbN^U=W*fRDJL{;{A2$U64m0hKT3bBs}ID%h}Af>{jkVuYdtTJb<~W{M+df>sYok z(Z`&KS_kRdaDxIHkI|h^{T<_tb;3~>kyd}x38zDU(i0ePD8_xqsU(+74mS6SR=gsk z8o|sSOXBF3ZX0)>>!Es*DIB|8bu2VgpC!w-N}7N(BCVcIm5mpDuiD5)qwHEfYP
    Q5{&k=)7j*<4E}Ac9wT)ha~a)@;ed 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 XEJ5AvFqG>u4+0w63}ae**^TfwkE`ZH literal 0 HcmV?d00001 From 52005cb098848ffe55117fb46c06be1a96e59c5d Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 3 Apr 2013 14:10:19 +0200 Subject: [PATCH 093/149] [#730] Only show 'Popular' sort option if tracking enabled Sorting datasets by popularity only works if page view tracking is enabled, so don't show the 'Popular' option in the 'Order by:' dropdown if tracking isn't enabled. --- ckan/templates/snippets/sort_by.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ckan/templates/snippets/sort_by.html b/ckan/templates/snippets/sort_by.html index a2a6076c3bc..fcc58fc74aa 100644 --- a/ckan/templates/snippets/sort_by.html +++ b/ckan/templates/snippets/sort_by.html @@ -15,7 +15,9 @@ - + {% if g.tracking_enabled %} + + {% endif %} From 8f12aea64093320837bb928862904c958a3d6ee2 Mon Sep 17 00:00:00 2001 From: John Glover Date: Wed, 3 Apr 2013 14:59:47 +0200 Subject: [PATCH 094/149] [#702] Update Recline to latest master version. Adds filter support for datastore backend and a couple of other bug fixes. --- .../theme/public/preview_recline.js | 6 +- .../theme/public/vendor/recline/recline.css | 12 + .../public/vendor/recline/recline.dataset.js | 9 +- .../vendor/recline/recline.dataset.min.js | 5 +- .../theme/public/vendor/recline/recline.js | 216 ++++++++++++++---- .../public/vendor/recline/recline.min.css | 2 +- .../public/vendor/recline/recline.min.js | 59 +++-- 7 files changed, 241 insertions(+), 68 deletions(-) diff --git a/ckanext/reclinepreview/theme/public/preview_recline.js b/ckanext/reclinepreview/theme/public/preview_recline.js index c8bf386c5ea..7b700d03e1b 100644 --- a/ckanext/reclinepreview/theme/public/preview_recline.js +++ b/ckanext/reclinepreview/theme/public/preview_recline.js @@ -15,7 +15,7 @@ this.ckan.module('reclinepreview', function (jQuery, _) { jQuery.proxyAll(this, /_on/); this.el.ready(this._onReady); // hack to make leaflet use a particular location to look for images - L.Icon.Default.imagePath = this.options.site_url + 'vendor/leaflet/0.4.4/images' + L.Icon.Default.imagePath = this.options.site_url + 'vendor/leaflet/0.4.4/images'; }, _onReady: function() { @@ -125,9 +125,9 @@ this.ckan.module('reclinepreview', function (jQuery, _) { var sidebarViews = [ { - id: 'filterEditor', + id: 'valueFilter', label: 'Filters', - view: new recline.View.FilterEditor({ + view: new recline.View.ValueFilter({ model: dataset }) } diff --git a/ckanext/reclinepreview/theme/public/vendor/recline/recline.css b/ckanext/reclinepreview/theme/public/vendor/recline/recline.css index e6437a11baf..c030e764692 100644 --- a/ckanext/reclinepreview/theme/public/vendor/recline/recline.css +++ b/ckanext/reclinepreview/theme/public/vendor/recline/recline.css @@ -395,6 +395,18 @@ div.data-table-cell-content-numeric > a.data-table-cell-edit { width: 175px; } +.recline-filter-editor input { + margin-top: 0.5em; +} + +.recline-filter-editor .add-filter { + margin-top: 1em; + margin-bottom: 2em; +} + +.recline-filter-editor .update-filter { + margin-top: 1em; +} /********************************************************** * Fields Widget diff --git a/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.js b/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.js index 92183702532..7579c2ecaad 100644 --- a/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.js +++ b/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.js @@ -309,9 +309,11 @@ my.Record = Backbone.Model.extend({ // // For the provided Field get the corresponding rendered computed data value // for this record. + // + // NB: if field is undefined a default '' value will be returned getFieldValue: function(field) { val = this.getFieldValueUnrendered(field); - if (field.renderer) { + if (field && !_.isUndefined(field.renderer)) { val = field.renderer(val, field, this.toJSON()); } return val; @@ -321,7 +323,12 @@ my.Record = Backbone.Model.extend({ // // For the provided Field get the corresponding computed data value // for this record. + // + // NB: if field is undefined a default '' value will be returned getFieldValueUnrendered: function(field) { + if (!field) { + return ''; + } var val = this.get(field.id); if (field.deriver) { val = field.deriver(val, field, this); diff --git a/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.min.js b/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.min.js index 441e3937d81..f3298056220 100644 --- a/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.min.js +++ b/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.min.js @@ -13,8 +13,9 @@ return{fields:fields,records:records};},save:function(){var self=this;return thi this.trigger('recline:flash',{message:"Updating all visible docs. This could take a while...",persist:true,loader:true});this._store.transform(editFunc).done(function(){self.query();self.trigger('recline:flash',{message:"Records updated successfully"});});},query:function(queryObj){var self=this;var dfd=new Deferred();this.trigger('query:start');if(queryObj){this.queryState.set(queryObj,{silent:true});} var actualQuery=this.queryState.toJSON();this._store.query(actualQuery,this.toJSON()).done(function(queryResult){self._handleQueryResult(queryResult);self.trigger('query:done');dfd.resolve(self.records);}).fail(function(arguments){self.trigger('query:fail',arguments);dfd.reject(arguments);});return dfd.promise();},_handleQueryResult:function(queryResult){var self=this;self.recordCount=queryResult.total;var docs=_.map(queryResult.hits,function(hit){var _doc=new my.Record(hit);_doc.fields=self.fields;_doc.bind('change',function(doc){self._changes.updates.push(doc.toJSON());});_doc.bind('destroy',function(doc){self._changes.deletes.push(doc.toJSON());});return _doc;});self.records.reset(docs);if(queryResult.facets){var facets=_.map(queryResult.facets,function(facetResult,facetId){facetResult.id=facetId;return new my.Facet(facetResult);});self.facets.reset(facets);}},toTemplateJSON:function(){var data=this.toJSON();data.recordCount=this.recordCount;data.fields=this.fields.toJSON();return data;},getFieldsSummary:function(){var self=this;var query=new my.Query();query.set({size:0});this.fields.each(function(field){query.addFacet(field.id);});var dfd=new Deferred();this._store.query(query.toJSON(),this.toJSON()).done(function(queryResult){if(queryResult.facets){_.each(queryResult.facets,function(facetResult,facetId){facetResult.id=facetId;var facet=new my.Facet(facetResult);self.fields.get(facetId).facets.reset(facet);});} dfd.resolve(queryResult);});return dfd.promise();},recordSummary:function(record){return record.summary();},_backendFromString:function(backendString){var backend=null;if(recline&&recline.Backend){_.each(_.keys(recline.Backend),function(name){if(name.toLowerCase()===backendString.toLowerCase()){backend=recline.Backend[name];}});} -return backend;}});my.Record=Backbone.Model.extend({constructor:function Record(){Backbone.Model.prototype.constructor.apply(this,arguments);},initialize:function(){_.bindAll(this,'getFieldValue');},getFieldValue:function(field){val=this.getFieldValueUnrendered(field);if(field.renderer){val=field.renderer(val,field,this.toJSON());} -return val;},getFieldValueUnrendered:function(field){var val=this.get(field.id);if(field.deriver){val=field.deriver(val,field,this);} +return backend;}});my.Record=Backbone.Model.extend({constructor:function Record(){Backbone.Model.prototype.constructor.apply(this,arguments);},initialize:function(){_.bindAll(this,'getFieldValue');},getFieldValue:function(field){val=this.getFieldValueUnrendered(field);if(field&&!_.isUndefined(field.renderer)){val=field.renderer(val,field,this.toJSON());} +return val;},getFieldValueUnrendered:function(field){if(!field){return'';} +var val=this.get(field.id);if(field.deriver){val=field.deriver(val,field,this);} return val;},summary:function(record){var self=this;var html='
    ';this.fields.each(function(field){if(field.id!='id'){html+='
    '+field.get('label')+': '+self.getFieldValue(field)+'
    ';}});html+='
    ';return html;},fetch:function(){},save:function(){},destroy:function(){this.trigger('destroy',this);}});my.RecordList=Backbone.Collection.extend({constructor:function RecordList(){Backbone.Collection.prototype.constructor.apply(this,arguments);},model:my.Record});my.Field=Backbone.Model.extend({constructor:function Field(){Backbone.Model.prototype.constructor.apply(this,arguments);},defaults:{label:null,type:'string',format:null,is_derived:false},initialize:function(data,options){if('0'in data){throw new Error('Looks like you did not pass a proper hash with id to Field constructor');} if(this.attributes.label===null){this.set({label:this.id});} if(this.attributes.type.toLowerCase()in this._typeMap){this.attributes.type=this._typeMap[this.attributes.type.toLowerCase()];} diff --git a/ckanext/reclinepreview/theme/public/vendor/recline/recline.js b/ckanext/reclinepreview/theme/public/vendor/recline/recline.js index adb0ab154fe..f6bc1f46b79 100644 --- a/ckanext/reclinepreview/theme/public/vendor/recline/recline.js +++ b/ckanext/reclinepreview/theme/public/vendor/recline/recline.js @@ -66,15 +66,25 @@ this.recline.Backend.Ckan = this.recline.Backend.Ckan || {}; var actualQuery = { resource_id: dataset.id, q: queryObj.q, + filters: {}, limit: queryObj.size || 10, offset: queryObj.from || 0 }; + if (queryObj.sort && queryObj.sort.length > 0) { var _tmp = _.map(queryObj.sort, function(sortObj) { return sortObj.field + ' ' + (sortObj.order || ''); }); actualQuery.sort = _tmp.join(','); } + + if (queryObj.filters && queryObj.filters.length > 0) { + _.each(queryObj.filters, function(filter) { + if (filter.type === "term") { + actualQuery.filters[filter.field] = filter.term; + } + }); + } return actualQuery; }; @@ -105,15 +115,14 @@ this.recline.Backend.Ckan = this.recline.Backend.Ckan || {}; // // @param endpoint: CKAN api endpoint (e.g. http://datahub.io/api) my.DataStore = function(endpoint) { - var that = { - endpoint: endpoint || my.API_ENDPOINT - }; + var that = {endpoint: endpoint || my.API_ENDPOINT}; + that.search = function(data) { var searchUrl = that.endpoint + '/3/action/datastore_search'; var jqxhr = jQuery.ajax({ url: searchUrl, - data: data, - dataType: 'json' + type: 'POST', + data: JSON.stringify(data) }); return jqxhr; }; @@ -1650,9 +1659,11 @@ my.Record = Backbone.Model.extend({ // // For the provided Field get the corresponding rendered computed data value // for this record. + // + // NB: if field is undefined a default '' value will be returned getFieldValue: function(field) { val = this.getFieldValueUnrendered(field); - if (field.renderer) { + if (field && !_.isUndefined(field.renderer)) { val = field.renderer(val, field, this.toJSON()); } return val; @@ -1662,7 +1673,12 @@ my.Record = Backbone.Model.extend({ // // For the provided Field get the corresponding computed data value // for this record. + // + // NB: if field is undefined a default '' value will be returned getFieldValueUnrendered: function(field) { + if (!field) { + return ''; + } var val = this.get(field.id); if (field.deriver) { val = field.deriver(val, field, this); @@ -2092,14 +2108,19 @@ my.Flot = Backbone.View.extend({ var xtype = xfield.get('type'); var isDateTime = (xtype === 'date' || xtype === 'date-time' || xtype === 'time'); - if (this.model.records.models[parseInt(x, 10)]) { - x = this.model.records.models[parseInt(x, 10)].get(this.state.attributes.group); - if (isDateTime) { - x = new Date(x).toLocaleDateString(); - } - } else if (isDateTime) { - x = new Date(parseInt(x, 10)).toLocaleDateString(); + if (this.xvaluesAreIndex) { + x = parseInt(x, 10); + // HACK: deal with bar graph style cases where x-axis items were strings + // In this case x at this point is the index of the item in the list of + // records not its actual x-axis value + x = this.model.records.models[x].get(this.state.attributes.group); } + if (isDateTime) { + x = new Date(x).toLocaleDateString(); + } + // } else if (isDateTime) { + // x = new Date(parseInt(x, 10)).toLocaleDateString(); + // } return x; }, @@ -2119,13 +2140,13 @@ my.Flot = Backbone.View.extend({ // convert x to a string and make sure that it is not too long or the // tick labels will overlap // TODO: find a more accurate way of calculating the size of tick labels - var label = self._xaxisLabel(x); + var label = self._xaxisLabel(x) || ""; if (typeof label !== 'string') { label = label.toString(); } - if (self.state.attributes.graphType !== 'bars' && label.length > 8) { - label = label.slice(0, 5) + "..."; + if (self.state.attributes.graphType !== 'bars' && label.length > 10) { + label = label.slice(0, 10) + "..."; } return label; @@ -2134,31 +2155,16 @@ my.Flot = Backbone.View.extend({ var xaxis = {}; xaxis.tickFormatter = tickFormatter; - // calculate the x-axis ticks - // - // the number of ticks should be a multiple of the number of points so that - // each tick lines up with a point - if (numPoints) { - var ticks = [], - maxTicks = 10, - x = 1, - i = 0; - - // show all ticks in bar graphs - // for other graphs only show up to maxTicks ticks - if (self.state.attributes.graphType !== 'bars') { - while (x <= maxTicks) { - if ((numPoints / x) <= maxTicks) { - break; - } - x = x + 1; - } - } - - for (i = 0; i < numPoints; i = i + x) { - ticks.push(i); + // for labels case we only want ticks at the label intervals + // HACK: however we also get this case with Date fields. In that case we + // could have a lot of values and so we limit to max 30 (we assume) + if (this.xvaluesAreIndex) { + var numTicks = Math.min(this.model.records.length, 15); + var increment = this.model.records.length / numTicks; + var ticks = []; + for (i=0; i \ +

    Filters

    \ + \ + \ +
    \ + {{#filters}} \ + {{{filterRender}}} \ + {{/filters}} \ + {{#filters.length}} \ + \ + {{/filters.length}} \ +
    \ + \ + ', + filterTemplates: { + term: ' \ +
    \ +
    \ + {{field}} \ + × \ + \ +
    \ +
    \ + ' + }, + events: { + 'click .js-remove-filter': 'onRemoveFilter', + 'click .js-add-filter': 'onAddFilterShow', + 'submit form.js-edit': 'onTermFiltersUpdate', + 'submit form.js-add': 'onAddFilter' + }, + initialize: function() { + this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.fields.bind('all', this.render); + this.model.queryState.bind('change', this.render); + this.model.queryState.bind('change:filters:new-blank', this.render); + this.render(); + }, + render: function() { + var self = this; + var tmplData = $.extend(true, {}, this.model.queryState.toJSON()); + // we will use idx in list as the id ... + tmplData.filters = _.map(tmplData.filters, function(filter, idx) { + filter.id = idx; + return filter; + }); + tmplData.fields = this.model.fields.toJSON(); + tmplData.filterRender = function() { + return Mustache.render(self.filterTemplates.term, this); + }; + var out = Mustache.render(this.template, tmplData); + this.el.html(out); + }, + updateFilter: function(input) { + var self = this; + var filters = self.model.queryState.get('filters'); + var $input = $(input); + var filterIndex = parseInt($input.attr('data-filter-id'), 10); + var value = $input.val(); + filters[filterIndex].term = value; + }, + onAddFilterShow: function(e) { + e.preventDefault(); + var $target = $(e.target); + $target.hide(); + this.el.find('form.js-add').show(); + }, + onAddFilter: function(e) { + e.preventDefault(); + var $target = $(e.target); + $target.hide(); + var field = $target.find('select.fields').val(); + this.model.queryState.addFilter({type: 'term', field: field}); + }, + onRemoveFilter: function(e) { + e.preventDefault(); + var $target = $(e.target); + var filterId = $target.attr('data-filter-id'); + this.model.queryState.removeFilter(filterId); + }, + onTermFiltersUpdate: function(e) { + var self = this; + e.preventDefault(); + var filters = self.model.queryState.get('filters'); + var $form = $(e.target); + _.each($form.find('input'), function(input) { + self.updateFilter(input); + }); + self.model.queryState.set({filters: filters, from: 0}); + self.model.queryState.trigger('change'); + } +}); + +})(jQuery, recline.View); diff --git a/ckanext/reclinepreview/theme/public/vendor/recline/recline.min.css b/ckanext/reclinepreview/theme/public/vendor/recline/recline.min.css index db61ee86dd7..ff445f823a8 100644 --- a/ckanext/reclinepreview/theme/public/vendor/recline/recline.min.css +++ b/ckanext/reclinepreview/theme/public/vendor/recline/recline.min.css @@ -1 +1 @@ -.recline-flot .graph{height:500px;overflow:hidden}.recline-flot .legend table{width:auto;margin-bottom:0}.recline-flot .legend td{padding:5px;line-height:13px}.recline-flot .graph .alert{width:450px}#recline-flot-tooltip{position:absolute;background-color:#FEE!important;color:#000000!important;opacity:0.8!important;border:1px solid #fdd!important}.recline-graph .graph{height:500px}.recline-graph .legend table{width:auto;margin-bottom:0}.recline-graph .legend td{padding:5px;line-height:13px}.recline-graph .graph .alert{width:450px}.flotr-mouse-value{background-color:#FEE!important;color:#000000!important;opacity:0.8!important;border:1px solid #fdd!important}.flotr-legend{border:none!important}.flotr-legend-bg{display:none}.flotr-legend-color-box{padding:5px}table.recline-grid{table-layout:fixed;width:100%}.recline-grid .btn-group .dropdown-toggle{padding:1px 3px;line-height:auto}.recline-grid td,.recline-grid th{border-left:1px solid #ccc;padding:3px 4px;text-align:left;word-wrap:break-word;white-space:normal}.recline-grid tbody tr{vertical-align:top;border-bottom:solid 1px #ccc}.recline-grid tbody tr:last-child{border-bottom:1px solid #ccc}.recline-grid tbody td:last-child{border-right:1px solid #ccc}.recline-grid th{background-color:#e6e6e6;background-repeat:no-repeat;background-image:-webkit-gradient(linear,0 0,0 100%,from(#ffffff),color-stop(25%,#ffffff),to(#e6e6e6));background-image:-webkit-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:-moz-linear-gradient(top,#ffffff,#ffffff 25%,#e6e6e6);background-image:-ms-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:-o-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff',endColorstr='#e6e6e6',GradientType=0);text-shadow:0 1px 1px rgba(255,255,255,0.75);color:#333;border:1px solid #ccc;border-bottom-color:#bbb;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);-moz-box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);-webkit-transition:0.1s linear all;-moz-transition:0.1s linear all;-ms-transition:0.1s linear all;-o-transition:0.1s linear all;transition:0.1s linear all}div.table-container{overflow:auto}html>body div.table-container{overflow:hidden}thead.fixed-header tr{overflow-x:hidden}thead.fixed-header tr{position:relative}html>body thead.fixed-header tr{display:block}tbody.scroll-content{display:block;max-height:500px;overflow:auto}.column-header-menu,a.root-header-menu{float:right}div.data-table-cell-content{line-height:1.2;color:#222;position:relative}div.data-table-cell-content-numeric{text-align:right}a.data-table-cell-edit{position:absolute;top:0;right:0;display:block;width:25px;height:16px;text-decoration:none;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAQCAYAAABUWyyMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OTNEODNFMjVEMDA5MTFERjk5NzhEQzZDRDUwRkEzMUEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OTNEODNFMjZEMDA5MTFERjk5NzhEQzZDRDUwRkEzMUEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo5M0Q4M0UyM0QwMDkxMURGOTk3OERDNkNENTBGQTMxQSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo5M0Q4M0UyNEQwMDkxMURGOTk3OERDNkNENTBGQTMxQSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpApVmcAAAKVSURBVHjaYvz//z/DcAAsIGLaxjf2QKoLiM2oaPYpIC7L8hc5COI4F9+hmR17e1UOskAFVgCxBJUDyQxqriQ97GCCCpBkATCUGQxVOTHY7KyMDFoKHMhKJXCwCQJgKDNEOAlisHk5mRh8LPgw7GChNEg2HfvI8OnbPzDbwYAH6BkmhmsPflA12EtnPmN4/vY3mF0YKgb2zJYTn5CV/EfxiKgAC4O7CS8DHzczw5PXvxl2nv7E8PP3fxTx87e/o1hipMrFcOfZT6BmVgYZUTawmKUWN8Pxa1+xOkpNhp2hLk6CQVKYleHcrW8MTYteMHz+/g9FfMW+9yh6Ih0FGA5e/MLAAIx5IzUusFiqtzDD7K1vYUpcmZA1+FnxM7z++AccyqBkAgphmMPY2ZiAHvsMFkcGMkAPgMQ+ff3L8PPXPzD++O0vztDtypBmuPXkJziUebiYwSEMcxiID/IYiEYJLKDjQWLP3v1h+AI0G4RhMQQF5UzoDvr5CxIDoJhQlmKHy527/Y3hLjDkD4BCBgsAxSAoiYEwrqQFchAoWcBiAOQge30euBwoJkAh37/6FVb9oBgEeQCE0ZKWC8488vj1LzCmJTgLTKZn0ZIquYAJOURBsQDKB68//GHgYEWkOlBMgPICKIYcoCGIC4BiFRSDuEIUFBtSQizg5AWKHRgAxQSoZALFECy54QKgZAbLKxgegZVAfFxM4LyiLMUGTPeQ0ujAhS9gz7mb8sI9iisWQclSG7UIRgFlM56CM3R3uhTY0bC0DkpOtx//AGd4mEdxxSIoWaIVwQyMoCYKsGanWTsFWM8wQmt2mtkBqmdgMfKCRna8wMGmth2MMI/EAfEdKltwB2ouAz3sYBwurV8mhmECAAIMAEe3EkMWh/DvAAAAAElFTkSuQmCC);background-repeat:no-repeat;visibility:hidden}a.data-table-cell-edit:hover{background-position:-25px 0px}.recline-grid td:hover .data-table-cell-edit{visibility:visible}div.data-table-cell-content-numeric>a.data-table-cell-edit{left:0px;right:auto}.data-table-value-nonstring{color:#282}.data-table-error{color:red}.data-table-cell-editor-editor{overflow:hidden;display:block;width:98%;height:3em;font-family:monospace;margin:3px 0}.data-table-cell-copypaste-editor{overflow:hidden;display:block;width:98%;height:10em;font-family:monospace;margin:3px 0}.data-table-cell-editor-action{float:left;vertical-align:bottom;text-align:center}.data-table-cell-editor-key{font-size:0.8em;color:#999}.recline-read-only .recline-grid .write-op,.recline-read-only .recline-grid a.data-table-cell-edit{display:none}.recline-read-only a.row-header-menu{display:none}.recline-map .map{height:500px}.recline-map .editor{float:right;width:200px;padding-left:0px;margin-left:10px}.recline-map .editor form{padding-left:4px}.recline-map .editor select{width:100%}.recline-map .editor .editor-options{margin-top:10px;border-top:1px solid gray;padding:5px 0}.recline-data-explorer .data-view-container{display:block}.recline-data-explorer .data-view-sidebar{float:right;margin-left:8px;width:220px}.recline-data-explorer .header .navigation{margin-bottom:8px}.recline-data-explorer .header .navigation,.recline-data-explorer .header .pagination,.recline-data-explorer .header .pagination form{display:inline}.recline-data-explorer .header .navigation{float:left}.recline-data-explorer .header .menu-right{float:right;margin-left:5px;padding-left:5px;border-left:solid 2px #ddd}.header .recline-results-info{line-height:28px;margin-left:20px;float:left}.header .recline-query-editor{float:right;height:30px}.header .input-prepend{margin-bottom:auto}.header .add-on{float:left}.header .add-on{margin-left:-27px}.header .input-prepend{vertical-align:top}.header .recline-query-editor form button{vertical-align:top}.header .recline-pager{float:left;margin:auto;display:block;margin-left:20px}.header .recline-pager .pagination input{width:30px;height:18px;padding:2px 4px;margin:0;margin-top:-4px}.header .recline-pager .pagination a{line-height:26px;padding:0 6px}.recline-filter-editor{padding:8px;display:none}.recline-filter-editor .filter-term a{font-size:18px}.recline-filter-editor input,.recline-filter-editor select{width:175px}.recline-fields-view{display:none}.recline-fields-view .fields-list{padding:0}.recline-fields-view .fields-list .accordion-heading,.recline-fields-view .fields-list h3{margin:3px 0 3px 5px}.recline-fields-view .fields-list .accordion-heading a,.recline-fields-view .fields-list .accordion-heading h4{display:inline}.recline-fields-view .fields-list .accordion-heading a{padding:0}.recline-fields-view .fields-list .accordion-heading h4{word-wrap:break-word}.recline-fields-view .clear{clear:both}.recline-fields-view .facet-items{list-style-type:none;margin-left:0}.recline-fields-view .facet-item .term{font-weight:bold}.recline-fields-view .facet-item .count{}.recline-data-explorer .notification-loader{width:18px;margin-left:5px;background-image:url(data:image/gif;base64,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%3D%3D);display:inline-block}.recline-data-explorer .alert-loader{position:absolute;width:200px;left:50%;margin-left:-100px;z-index:10000;padding:40px 0px 40px 0px;margin-top:-10px;text-align:center;font-size:16px;font-weight:bold;-webkit-border-radius:0px;-moz-border-radius:0px;border-radius:0px;border-top:none}.recline-slickgrid .slick-header-columns .slick-header-column{background-color:#e6e6e6;background-repeat:no-repeat;background-image:-webkit-gradient(linear,0 0,0 100%,from(#ffffff),color-stop(25%,#ffffff),to(#e6e6e6));background-image:-webkit-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:-moz-linear-gradient(top,#ffffff,#ffffff 25%,#e6e6e6);background-image:-ms-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:-o-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff',endColorstr='#e6e6e6',GradientType=0);text-shadow:0 1px 1px rgba(255,255,255,0.75);color:#333;font-weight:bold;border-right:1px solid #ccc;border-top:1px solid #ccc;border-bottom:1px solid #bbb;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);-moz-box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05)}.recline-slickgrid .slick-header-column:hover,.slick-header-column-active{}.recline-slickgrid .slick-headerrow{background:#fafafa}.recline-slickgrid .slick-headerrow-column{background:#fafafa;border-bottom:0;height:100%}.recline-slickgrid .slick-row.ui-state-active{background:#F5F7D7}.recline-slickgrid .slick-row{position:absolute;background:white;border:0px;line-height:20px}.recline-slickgrid .slick-row.selected{z-index:10;background:#DFE8F6}.recline-slickgrid .slick-cell{padding-left:4px;padding-right:4px}.recline-slickgrid .slick-group{border-bottom:2px solid silver}.recline-slickgrid .slick-group-toggle{width:9px;height:9px;margin-right:5px}.recline-slickgrid .slick-group-toggle.expanded{background:url(../images/collapse.gif) no-repeat center center}.recline-slickgrid .slick-group-toggle.collapsed{background:url(../images/expand.gif) no-repeat center center}.recline-slickgrid .slick-group-totals{color:gray;background:white}.recline-slickgrid .slick-cell.selected{background-color:beige}.recline-slickgrid .slick-cell.active{border-color:gray;border-style:solid}.recline-slickgrid .slick-sortable-placeholder{background:silver!important}.recline-slickgrid .slick-row[row$="1"],.slick-row[row$="3"],.slick-row[row$="5"],.slick-row[row$="7"],.slick-row[row$="9"]{background:#fafafa}.recline-slickgrid .slick-row.ui-state-active{background:#F5F7D7}.recline-slickgrid .slick-row.loading{opacity:0.5;filter:alpha(opacity=50)}.recline-slickgrid .slick-cell.invalid{border-color:red}.recline-slickgrid .slick-row .slick-cell:first-child,.recline-slickgrid .slick-header{border-left:1px solid #ccc}.recline-slickgrid .slick-row .slick-cell{margin-right:-1px}.slick-contextmenu{border-radius:5px}.slick-contextmenu li{clear:both;height:24px;cursor:pointer}.slick-contextmenu .divider{cursor:default}.slick-contextmenu>li:hover{background-color:#0088cc}.slick-contextmenu .divider:hover{background-color:#E5E5E5}.slick-contextmenu li:hover>label{color:white}.slick-contextmenu input{float:left;margin-left:15px;margin-top:5px}.slick-contextmenu label{float:left;margin-right:15px;margin-left:5px;margin-top:3px;color:#555;cursor:pointer}.recline-transform{overflow:hidden}.recline-transform .script textarea{width:100%;height:100px;font-family:monospace;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.recline-transform h2{margin-bottom:10px}.recline-transform h2 .okButton{margin-left:10px;margin-top:-2px}.expression-preview-parsing-status{color:#999}.expression-preview-parsing-status.error{color:red}.recline-transform .before-after .after{font-style:italic}.recline-transform .before-after .after.different{font-weight:bold} \ No newline at end of file +.recline-flot .graph{height:500px;overflow:hidden}.recline-flot .legend table{width:auto;margin-bottom:0}.recline-flot .legend td{padding:5px;line-height:13px}.recline-flot .graph .alert{width:450px}#recline-flot-tooltip{position:absolute;background-color:#FEE!important;color:#000000!important;opacity:0.8!important;border:1px solid #fdd!important}.recline-graph .graph{height:500px}.recline-graph .legend table{width:auto;margin-bottom:0}.recline-graph .legend td{padding:5px;line-height:13px}.recline-graph .graph .alert{width:450px}.flotr-mouse-value{background-color:#FEE!important;color:#000000!important;opacity:0.8!important;border:1px solid #fdd!important}.flotr-legend{border:none!important}.flotr-legend-bg{display:none}.flotr-legend-color-box{padding:5px}table.recline-grid{table-layout:fixed;width:100%}.recline-grid .btn-group .dropdown-toggle{padding:1px 3px;line-height:auto}.recline-grid td,.recline-grid th{border-left:1px solid #ccc;padding:3px 4px;text-align:left;word-wrap:break-word;white-space:normal}.recline-grid tbody tr{vertical-align:top;border-bottom:solid 1px #ccc}.recline-grid tbody tr:last-child{border-bottom:1px solid #ccc}.recline-grid tbody td:last-child{border-right:1px solid #ccc}.recline-grid th{background-color:#e6e6e6;background-repeat:no-repeat;background-image:-webkit-gradient(linear,0 0,0 100%,from(#ffffff),color-stop(25%,#ffffff),to(#e6e6e6));background-image:-webkit-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:-moz-linear-gradient(top,#ffffff,#ffffff 25%,#e6e6e6);background-image:-ms-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:-o-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff',endColorstr='#e6e6e6',GradientType=0);text-shadow:0 1px 1px rgba(255,255,255,0.75);color:#333;border:1px solid #ccc;border-bottom-color:#bbb;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);-moz-box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);-webkit-transition:0.1s linear all;-moz-transition:0.1s linear all;-ms-transition:0.1s linear all;-o-transition:0.1s linear all;transition:0.1s linear all}div.table-container{overflow:auto}html>body div.table-container{overflow:hidden}thead.fixed-header tr{overflow-x:hidden}thead.fixed-header tr{position:relative}html>body thead.fixed-header tr{display:block}tbody.scroll-content{display:block;max-height:500px;overflow:auto}.column-header-menu,a.root-header-menu{float:right}div.data-table-cell-content{line-height:1.2;color:#222;position:relative}div.data-table-cell-content-numeric{text-align:right}a.data-table-cell-edit{position:absolute;top:0;right:0;display:block;width:25px;height:16px;text-decoration:none;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAQCAYAAABUWyyMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OTNEODNFMjVEMDA5MTFERjk5NzhEQzZDRDUwRkEzMUEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OTNEODNFMjZEMDA5MTFERjk5NzhEQzZDRDUwRkEzMUEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo5M0Q4M0UyM0QwMDkxMURGOTk3OERDNkNENTBGQTMxQSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo5M0Q4M0UyNEQwMDkxMURGOTk3OERDNkNENTBGQTMxQSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpApVmcAAAKVSURBVHjaYvz//z/DcAAsIGLaxjf2QKoLiM2oaPYpIC7L8hc5COI4F9+hmR17e1UOskAFVgCxBJUDyQxqriQ97GCCCpBkATCUGQxVOTHY7KyMDFoKHMhKJXCwCQJgKDNEOAlisHk5mRh8LPgw7GChNEg2HfvI8OnbPzDbwYAH6BkmhmsPflA12EtnPmN4/vY3mF0YKgb2zJYTn5CV/EfxiKgAC4O7CS8DHzczw5PXvxl2nv7E8PP3fxTx87e/o1hipMrFcOfZT6BmVgYZUTawmKUWN8Pxa1+xOkpNhp2hLk6CQVKYleHcrW8MTYteMHz+/g9FfMW+9yh6Ih0FGA5e/MLAAIx5IzUusFiqtzDD7K1vYUpcmZA1+FnxM7z++AccyqBkAgphmMPY2ZiAHvsMFkcGMkAPgMQ+ff3L8PPXPzD++O0vztDtypBmuPXkJziUebiYwSEMcxiID/IYiEYJLKDjQWLP3v1h+AI0G4RhMQQF5UzoDvr5CxIDoJhQlmKHy527/Y3hLjDkD4BCBgsAxSAoiYEwrqQFchAoWcBiAOQge30euBwoJkAh37/6FVb9oBgEeQCE0ZKWC8488vj1LzCmJTgLTKZn0ZIquYAJOURBsQDKB68//GHgYEWkOlBMgPICKIYcoCGIC4BiFRSDuEIUFBtSQizg5AWKHRgAxQSoZALFECy54QKgZAbLKxgegZVAfFxM4LyiLMUGTPeQ0ujAhS9gz7mb8sI9iisWQclSG7UIRgFlM56CM3R3uhTY0bC0DkpOtx//AGd4mEdxxSIoWaIVwQyMoCYKsGanWTsFWM8wQmt2mtkBqmdgMfKCRna8wMGmth2MMI/EAfEdKltwB2ouAz3sYBwurV8mhmECAAIMAEe3EkMWh/DvAAAAAElFTkSuQmCC);background-repeat:no-repeat;visibility:hidden}a.data-table-cell-edit:hover{background-position:-25px 0px}.recline-grid td:hover .data-table-cell-edit{visibility:visible}div.data-table-cell-content-numeric>a.data-table-cell-edit{left:0px;right:auto}.data-table-value-nonstring{color:#282}.data-table-error{color:red}.data-table-cell-editor-editor{overflow:hidden;display:block;width:98%;height:3em;font-family:monospace;margin:3px 0}.data-table-cell-copypaste-editor{overflow:hidden;display:block;width:98%;height:10em;font-family:monospace;margin:3px 0}.data-table-cell-editor-action{float:left;vertical-align:bottom;text-align:center}.data-table-cell-editor-key{font-size:0.8em;color:#999}.recline-read-only .recline-grid .write-op,.recline-read-only .recline-grid a.data-table-cell-edit{display:none}.recline-read-only a.row-header-menu{display:none}.recline-map .map{height:500px}.recline-map .editor{float:right;width:200px;padding-left:0px;margin-left:10px}.recline-map .editor form{padding-left:4px}.recline-map .editor select{width:100%}.recline-map .editor .editor-options{margin-top:10px;border-top:1px solid gray;padding:5px 0}.recline-data-explorer .data-view-container{display:block}.recline-data-explorer .data-view-sidebar{float:right;margin-left:8px;width:220px}.recline-data-explorer .header .navigation{margin-bottom:8px}.recline-data-explorer .header .navigation,.recline-data-explorer .header .pagination,.recline-data-explorer .header .pagination form{display:inline}.recline-data-explorer .header .navigation{float:left}.recline-data-explorer .header .menu-right{float:right;margin-left:5px;padding-left:5px;border-left:solid 2px #ddd}.header .recline-results-info{line-height:28px;margin-left:20px;float:left}.header .recline-query-editor{float:right;height:30px}.header .input-prepend{margin-bottom:auto}.header .add-on{float:left}.header .add-on{margin-left:-27px}.header .input-prepend{vertical-align:top}.header .recline-query-editor form button{vertical-align:top}.header .recline-pager{float:left;margin:auto;display:block;margin-left:20px}.header .recline-pager .pagination input{width:30px;height:18px;padding:2px 4px;margin:0;margin-top:-4px}.header .recline-pager .pagination a{line-height:26px;padding:0 6px}.recline-filter-editor{padding:8px;display:none}.recline-filter-editor .filter-term a{font-size:18px}.recline-filter-editor input,.recline-filter-editor select{width:175px}.recline-filter-editor input{margin-top:0.5em}.recline-filter-editor .add-filter{margin-top:1em;margin-bottom:2em}.recline-filter-editor .update-filter{margin-top:1em}.recline-fields-view{display:none}.recline-fields-view .fields-list{padding:0}.recline-fields-view .fields-list .accordion-heading,.recline-fields-view .fields-list h3{margin:3px 0 3px 5px}.recline-fields-view .fields-list .accordion-heading a,.recline-fields-view .fields-list .accordion-heading h4{display:inline}.recline-fields-view .fields-list .accordion-heading a{padding:0}.recline-fields-view .fields-list .accordion-heading h4{word-wrap:break-word}.recline-fields-view .clear{clear:both}.recline-fields-view .facet-items{list-style-type:none;margin-left:0}.recline-fields-view .facet-item .term{font-weight:bold}.recline-fields-view .facet-item .count{}.recline-data-explorer .notification-loader{width:18px;margin-left:5px;background-image:url(data:image/gif;base64,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%3D%3D);display:inline-block}.recline-data-explorer .alert-loader{position:absolute;width:200px;left:50%;margin-left:-100px;z-index:10000;padding:40px 0px 40px 0px;margin-top:-10px;text-align:center;font-size:16px;font-weight:bold;-webkit-border-radius:0px;-moz-border-radius:0px;border-radius:0px;border-top:none}.recline-slickgrid .slick-header-columns .slick-header-column{background-color:#e6e6e6;background-repeat:no-repeat;background-image:-webkit-gradient(linear,0 0,0 100%,from(#ffffff),color-stop(25%,#ffffff),to(#e6e6e6));background-image:-webkit-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:-moz-linear-gradient(top,#ffffff,#ffffff 25%,#e6e6e6);background-image:-ms-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:-o-linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);background-image:linear-gradient(#ffffff,#ffffff 25%,#e6e6e6);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff',endColorstr='#e6e6e6',GradientType=0);text-shadow:0 1px 1px rgba(255,255,255,0.75);color:#333;font-weight:bold;border-right:1px solid #ccc;border-top:1px solid #ccc;border-bottom:1px solid #bbb;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);-moz-box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);box-shadow:inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05)}.recline-slickgrid .slick-header-column:hover,.slick-header-column-active{}.recline-slickgrid .slick-headerrow{background:#fafafa}.recline-slickgrid .slick-headerrow-column{background:#fafafa;border-bottom:0;height:100%}.recline-slickgrid .slick-row.ui-state-active{background:#F5F7D7}.recline-slickgrid .slick-row{position:absolute;background:white;border:0px;line-height:20px}.recline-slickgrid .slick-row.selected{z-index:10;background:#DFE8F6}.recline-slickgrid .slick-cell{padding-left:4px;padding-right:4px}.recline-slickgrid .slick-group{border-bottom:2px solid silver}.recline-slickgrid .slick-group-toggle{width:9px;height:9px;margin-right:5px}.recline-slickgrid .slick-group-toggle.expanded{background:url(../images/collapse.gif) no-repeat center center}.recline-slickgrid .slick-group-toggle.collapsed{background:url(../images/expand.gif) no-repeat center center}.recline-slickgrid .slick-group-totals{color:gray;background:white}.recline-slickgrid .slick-cell.selected{background-color:beige}.recline-slickgrid .slick-cell.active{border-color:gray;border-style:solid}.recline-slickgrid .slick-sortable-placeholder{background:silver!important}.recline-slickgrid .slick-row[row$="1"],.slick-row[row$="3"],.slick-row[row$="5"],.slick-row[row$="7"],.slick-row[row$="9"]{background:#fafafa}.recline-slickgrid .slick-row.ui-state-active{background:#F5F7D7}.recline-slickgrid .slick-row.loading{opacity:0.5;filter:alpha(opacity=50)}.recline-slickgrid .slick-cell.invalid{border-color:red}.recline-slickgrid .slick-row .slick-cell:first-child,.recline-slickgrid .slick-header{border-left:1px solid #ccc}.recline-slickgrid .slick-row .slick-cell{margin-right:-1px}.slick-contextmenu{border-radius:5px}.slick-contextmenu li{clear:both;height:24px;cursor:pointer}.slick-contextmenu .divider{cursor:default}.slick-contextmenu>li:hover{background-color:#0088cc}.slick-contextmenu .divider:hover{background-color:#E5E5E5}.slick-contextmenu li:hover>label{color:white}.slick-contextmenu input{float:left;margin-left:15px;margin-top:5px}.slick-contextmenu label{float:left;margin-right:15px;margin-left:5px;margin-top:3px;color:#555;cursor:pointer}.recline-transform{overflow:hidden}.recline-transform .script textarea{width:100%;height:100px;font-family:monospace;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.recline-transform h2{margin-bottom:10px}.recline-transform h2 .okButton{margin-left:10px;margin-top:-2px}.expression-preview-parsing-status{color:#999}.expression-preview-parsing-status.error{color:red}.recline-transform .before-after .after{font-style:italic}.recline-transform .before-after .after.different{font-weight:bold} \ No newline at end of file diff --git a/ckanext/reclinepreview/theme/public/vendor/recline/recline.min.js b/ckanext/reclinepreview/theme/public/vendor/recline/recline.min.js index 3ba7d8d831a..6ed057ef33a 100644 --- a/ckanext/reclinepreview/theme/public/vendor/recline/recline.min.js +++ b/ckanext/reclinepreview/theme/public/vendor/recline/recline.min.js @@ -1,7 +1,8 @@ this.recline=this.recline||{};this.recline.Backend=this.recline.Backend||{};this.recline.Backend.Ckan=this.recline.Backend.Ckan||{};(function(my){my.__type__='ckan';var Deferred=_.isUndefined(this.jQuery)?_.Deferred:jQuery.Deferred;my.API_ENDPOINT='http://datahub.io/api';my.fetch=function(dataset){if(dataset.endpoint){var wrapper=my.DataStore(dataset.endpoint);}else{var out=my._parseCkanResourceUrl(dataset.url);dataset.id=out.resource_id;var wrapper=my.DataStore(out.endpoint);} -var dfd=new Deferred();var jqxhr=wrapper.search({resource_id:dataset.id,limit:0});jqxhr.done(function(results){var fields=_.map(results.result.fields,function(field){field.type=field.type in CKAN_TYPES_MAP?CKAN_TYPES_MAP[field.type]:field.type;return field;});var out={fields:fields,useMemoryStore:false};dfd.resolve(out);});return dfd.promise();};my._normalizeQuery=function(queryObj,dataset){var actualQuery={resource_id:dataset.id,q:queryObj.q,limit:queryObj.size||10,offset:queryObj.from||0};if(queryObj.sort&&queryObj.sort.length>0){var _tmp=_.map(queryObj.sort,function(sortObj){return sortObj.field+' '+(sortObj.order||'');});actualQuery.sort=_tmp.join(',');} +var dfd=new Deferred();var jqxhr=wrapper.search({resource_id:dataset.id,limit:0});jqxhr.done(function(results){var fields=_.map(results.result.fields,function(field){field.type=field.type in CKAN_TYPES_MAP?CKAN_TYPES_MAP[field.type]:field.type;return field;});var out={fields:fields,useMemoryStore:false};dfd.resolve(out);});return dfd.promise();};my._normalizeQuery=function(queryObj,dataset){var actualQuery={resource_id:dataset.id,q:queryObj.q,filters:{},limit:queryObj.size||10,offset:queryObj.from||0};if(queryObj.sort&&queryObj.sort.length>0){var _tmp=_.map(queryObj.sort,function(sortObj){return sortObj.field+' '+(sortObj.order||'');});actualQuery.sort=_tmp.join(',');} +if(queryObj.filters&&queryObj.filters.length>0){_.each(queryObj.filters,function(filter){if(filter.type==="term"){actualQuery.filters[filter.field]=filter.term;}});} return actualQuery;};my.query=function(queryObj,dataset){if(dataset.endpoint){var wrapper=my.DataStore(dataset.endpoint);}else{var out=my._parseCkanResourceUrl(dataset.url);dataset.id=out.resource_id;var wrapper=my.DataStore(out.endpoint);} -var actualQuery=my._normalizeQuery(queryObj,dataset);var dfd=new Deferred();var jqxhr=wrapper.search(actualQuery);jqxhr.done(function(results){var out={total:results.result.total,hits:results.result.records};dfd.resolve(out);});return dfd.promise();};my.DataStore=function(endpoint){var that={endpoint:endpoint||my.API_ENDPOINT};that.search=function(data){var searchUrl=that.endpoint+'/3/action/datastore_search';var jqxhr=jQuery.ajax({url:searchUrl,data:data,dataType:'json'});return jqxhr;};return that;};my._parseCkanResourceUrl=function(url){parts=url.split('/');var len=parts.length;return{resource_id:parts[len-1],endpoint:parts.slice(0,[len-4]).join('/')+'/api'};};var CKAN_TYPES_MAP={'int4':'integer','int8':'integer','float8':'float'};}(this.recline.Backend.Ckan));this.recline=this.recline||{};this.recline.Backend=this.recline.Backend||{};this.recline.Backend.CSV=this.recline.Backend.CSV||{};(function(my){my.__type__='csv';var Deferred=_.isUndefined(this.jQuery)?_.Deferred:jQuery.Deferred;my.fetch=function(dataset){var dfd=new Deferred();if(dataset.file){var reader=new FileReader();var encoding=dataset.encoding||'UTF-8';reader.onload=function(e){var rows=my.parseCSV(e.target.result,dataset);dfd.resolve({records:rows,metadata:{filename:dataset.file.name},useMemoryStore:true});};reader.onerror=function(e){alert('Failed to load file. Code: '+e.target.error.code);};reader.readAsText(dataset.file,encoding);}else if(dataset.data){var rows=my.parseCSV(dataset.data,dataset);dfd.resolve({records:rows,useMemoryStore:true});}else if(dataset.url){jQuery.get(dataset.url).done(function(data){var rows=my.parseCSV(data,dataset);dfd.resolve({records:rows,useMemoryStore:true});});} +var actualQuery=my._normalizeQuery(queryObj,dataset);var dfd=new Deferred();var jqxhr=wrapper.search(actualQuery);jqxhr.done(function(results){var out={total:results.result.total,hits:results.result.records};dfd.resolve(out);});return dfd.promise();};my.DataStore=function(endpoint){var that={endpoint:endpoint||my.API_ENDPOINT};that.search=function(data){var searchUrl=that.endpoint+'/3/action/datastore_search';var jqxhr=jQuery.ajax({url:searchUrl,type:'POST',data:JSON.stringify(data)});return jqxhr;};return that;};my._parseCkanResourceUrl=function(url){parts=url.split('/');var len=parts.length;return{resource_id:parts[len-1],endpoint:parts.slice(0,[len-4]).join('/')+'/api'};};var CKAN_TYPES_MAP={'int4':'integer','int8':'integer','float8':'float'};}(this.recline.Backend.Ckan));this.recline=this.recline||{};this.recline.Backend=this.recline.Backend||{};this.recline.Backend.CSV=this.recline.Backend.CSV||{};(function(my){my.__type__='csv';var Deferred=_.isUndefined(this.jQuery)?_.Deferred:jQuery.Deferred;my.fetch=function(dataset){var dfd=new Deferred();if(dataset.file){var reader=new FileReader();var encoding=dataset.encoding||'UTF-8';reader.onload=function(e){var rows=my.parseCSV(e.target.result,dataset);dfd.resolve({records:rows,metadata:{filename:dataset.file.name},useMemoryStore:true});};reader.onerror=function(e){alert('Failed to load file. Code: '+e.target.error.code);};reader.readAsText(dataset.file,encoding);}else if(dataset.data){var rows=my.parseCSV(dataset.data,dataset);dfd.resolve({records:rows,useMemoryStore:true});}else if(dataset.url){jQuery.get(dataset.url).done(function(data){var rows=my.parseCSV(data,dataset);dfd.resolve({records:rows,useMemoryStore:true});});} return dfd.promise();};my.parseCSV=function(s,options){s=chomp(s);var options=options||{};var trm=(options.trim===false)?false:true;var delimiter=options.delimiter||',';var quotechar=options.quotechar||'"';var cur='',inQuote=false,fieldQuoted=false,field='',row=[],out=[],i,processField;processField=function(field){if(fieldQuoted!==true){if(field===''){field=null;}else if(trm===true){field=trim(field);} if(rxIsInt.test(field)){field=parseInt(field,10);}else if(rxIsFloat.test(field)){field=parseFloat(field,10);}} return field;};for(i=0;i'+field.get('label')+': '+self.getFieldValue(field)+'';}});html+='';return html;},fetch:function(){},save:function(){},destroy:function(){this.trigger('destroy',this);}});my.RecordList=Backbone.Collection.extend({constructor:function RecordList(){Backbone.Collection.prototype.constructor.apply(this,arguments);},model:my.Record});my.Field=Backbone.Model.extend({constructor:function Field(){Backbone.Model.prototype.constructor.apply(this,arguments);},defaults:{label:null,type:'string',format:null,is_derived:false},initialize:function(data,options){if('0'in data){throw new Error('Looks like you did not pass a proper hash with id to Field constructor');} if(this.attributes.label===null){this.set({label:this.id});} if(this.attributes.type.toLowerCase()in this._typeMap){this.attributes.type=this._typeMap[this.attributes.type.toLowerCase()];} @@ -102,15 +104,14 @@ facets[fieldId]={terms:{field:fieldId}};this.set({facets:facets},{silent:true}); ',initialize:function(options){var self=this;this.graphColors=["#edc240","#afd8f8","#cb4b4b","#4da74d","#9440ed"];this.el=$(this.el);_.bindAll(this,'render','redraw','_toolTip','_xaxisLabel');this.needToRedraw=false;this.model.bind('change',this.render);this.model.fields.bind('reset',this.render);this.model.fields.bind('add',this.render);this.model.records.bind('add',this.redraw);this.model.records.bind('reset',this.redraw);var stateData=_.extend({group:null,series:[],graphType:'lines-and-points'},options.state);this.state=new recline.Model.ObjectState(stateData);this.previousTooltipPoint={x:null,y:null};this.editor=new my.FlotControls({model:this.model,state:this.state.toJSON()});this.editor.state.bind('change',function(){self.state.set(self.editor.state.toJSON());self.redraw();});this.elSidebar=this.editor.el;},render:function(){var self=this;var tmplData=this.model.toTemplateJSON();var htmls=Mustache.render(this.template,tmplData);$(this.el).html(htmls);this.$graph=this.el.find('.panel.graph');this.$graph.on("plothover",this._toolTip);return this;},redraw:function(){var areWeVisible=!jQuery.expr.filters.hidden(this.el[0]);if((!areWeVisible||this.model.records.length===0)){this.needToRedraw=true;return;} if(this.state.get('group')&&this.state.get('series')){var series=this.createSeries();var options=this.getGraphOptions(this.state.attributes.graphType,series[0].data.length);this.plot=$.plot(this.$graph,series,options);}},show:function(){if(this.needToRedraw){this.redraw();}},_toolTip:function(event,pos,item){if(item){if(this.previousTooltipPoint.x!==item.dataIndex||this.previousTooltipPoint.y!==item.seriesIndex){this.previousTooltipPoint.x=item.dataIndex;this.previousTooltipPoint.y=item.seriesIndex;$("#recline-flot-tooltip").remove();var x=item.datapoint[0].toFixed(2),y=item.datapoint[1].toFixed(2);if(this.state.attributes.graphType==='bars'){x=item.datapoint[1].toFixed(2),y=item.datapoint[0].toFixed(2);} var content=_.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>',{group:this.state.attributes.group,x:this._xaxisLabel(x),series:item.series.label,y:y});var xLocation,yLocation;if(this.state.attributes.graphType==='bars'){xLocation=item.pageX+15;yLocation=item.pageY-10;}else if(this.state.attributes.graphType==='columns'){xLocation=item.pageX+15;yLocation=item.pageY;}else{xLocation=item.pageX+10;yLocation=item.pageY-20;} -$('
    '+content+'
    ').css({top:yLocation,left:xLocation}).appendTo("body").fadeIn(200);}}else{$("#recline-flot-tooltip").remove();this.previousTooltipPoint.x=null;this.previousTooltipPoint.y=null;}},_xaxisLabel:function(x){var xfield=this.model.fields.get(this.state.attributes.group);var xtype=xfield.get('type');var isDateTime=(xtype==='date'||xtype==='date-time'||xtype==='time');if(this.model.records.models[parseInt(x,10)]){x=this.model.records.models[parseInt(x,10)].get(this.state.attributes.group);if(isDateTime){x=new Date(x).toLocaleDateString();}}else if(isDateTime){x=new Date(parseInt(x,10)).toLocaleDateString();} -return x;},getGraphOptions:function(typeId,numPoints){var self=this;var tickFormatter=function(x){var label=self._xaxisLabel(x);if(typeof label!=='string'){label=label.toString();} -if(self.state.attributes.graphType!=='bars'&&label.length>8){label=label.slice(0,5)+"...";} -return label;};var xaxis={};xaxis.tickFormatter=tickFormatter;if(numPoints){var ticks=[],maxTicks=10,x=1,i=0;if(self.state.attributes.graphType!=='bars'){while(x<=maxTicks){if((numPoints/x)<=maxTicks){break;} -x=x+1;}} -for(i=0;i'+content+'').css({top:yLocation,left:xLocation}).appendTo("body").fadeIn(200);}}else{$("#recline-flot-tooltip").remove();this.previousTooltipPoint.x=null;this.previousTooltipPoint.y=null;}},_xaxisLabel:function(x){var xfield=this.model.fields.get(this.state.attributes.group);var xtype=xfield.get('type');var isDateTime=(xtype==='date'||xtype==='date-time'||xtype==='time');if(this.xvaluesAreIndex){x=parseInt(x,10);x=this.model.records.models[x].get(this.state.attributes.group);} +if(isDateTime){x=new Date(x).toLocaleDateString();} +return x;},getGraphOptions:function(typeId,numPoints){var self=this;var tickFormatter=function(x){var label=self._xaxisLabel(x)||"";if(typeof label!=='string'){label=label.toString();} +if(self.state.attributes.graphType!=='bars'&&label.length>10){label=label.slice(0,10)+"...";} +return label;};var xaxis={};xaxis.tickFormatter=tickFormatter;if(this.xvaluesAreIndex){var numTicks=Math.min(this.model.records.length,15);var increment=this.model.records.length/numTicks;var ticks=[];for(i=0;i \
    \
    \ @@ -612,4 +613,36 @@ newFrom=Math.max(newFrom,0);this.model.set({from:newFrom});},render:function(){v
    \ \
    \ - ',events:{'submit form':'onFormSubmit'},initialize:function(){_.bindAll(this,'render');this.el=$(this.el);this.model.bind('change',this.render);this.render();},onFormSubmit:function(e){e.preventDefault();var query=this.el.find('.text-query input').val();this.model.set({q:query});},render:function(){var tmplData=this.model.toJSON();var templated=Mustache.render(this.template,tmplData);this.el.html(templated);}});})(jQuery,recline.View); \ No newline at end of file + ',events:{'submit form':'onFormSubmit'},initialize:function(){_.bindAll(this,'render');this.el=$(this.el);this.model.bind('change',this.render);this.render();},onFormSubmit:function(e){e.preventDefault();var query=this.el.find('.text-query input').val();this.model.set({q:query});},render:function(){var tmplData=this.model.toJSON();var templated=Mustache.render(this.template,tmplData);this.el.html(templated);}});})(jQuery,recline.View);this.recline=this.recline||{};this.recline.View=this.recline.View||{};(function($,my){my.ValueFilter=Backbone.View.extend({className:'recline-filter-editor well',template:' \ +
    \ +

    Filters

    \ + \ + \ +
    \ + {{#filters}} \ + {{{filterRender}}} \ + {{/filters}} \ + {{#filters.length}} \ + \ + {{/filters.length}} \ +
    \ +
    \ + ',filterTemplates:{term:' \ +
    \ +
    \ + {{field}} \ + × \ + \ +
    \ +
    \ + '},events:{'click .js-remove-filter':'onRemoveFilter','click .js-add-filter':'onAddFilterShow','submit form.js-edit':'onTermFiltersUpdate','submit form.js-add':'onAddFilter'},initialize:function(){this.el=$(this.el);_.bindAll(this,'render');this.model.fields.bind('all',this.render);this.model.queryState.bind('change',this.render);this.model.queryState.bind('change:filters:new-blank',this.render);this.render();},render:function(){var self=this;var tmplData=$.extend(true,{},this.model.queryState.toJSON());tmplData.filters=_.map(tmplData.filters,function(filter,idx){filter.id=idx;return filter;});tmplData.fields=this.model.fields.toJSON();tmplData.filterRender=function(){return Mustache.render(self.filterTemplates.term,this);};var out=Mustache.render(this.template,tmplData);this.el.html(out);},updateFilter:function(input){var self=this;var filters=self.model.queryState.get('filters');var $input=$(input);var filterIndex=parseInt($input.attr('data-filter-id'),10);var value=$input.val();filters[filterIndex].term=value;},onAddFilterShow:function(e){e.preventDefault();var $target=$(e.target);$target.hide();this.el.find('form.js-add').show();},onAddFilter:function(e){e.preventDefault();var $target=$(e.target);$target.hide();var field=$target.find('select.fields').val();this.model.queryState.addFilter({type:'term',field:field});},onRemoveFilter:function(e){e.preventDefault();var $target=$(e.target);var filterId=$target.attr('data-filter-id');this.model.queryState.removeFilter(filterId);},onTermFiltersUpdate:function(e){var self=this;e.preventDefault();var filters=self.model.queryState.get('filters');var $form=$(e.target);_.each($form.find('input'),function(input){self.updateFilter(input);});self.model.queryState.set({filters:filters,from:0});self.model.queryState.trigger('change');}});})(jQuery,recline.View); \ No newline at end of file From ca04063ce061fb80375e73d5aad865ebd6e0ae6d Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 3 Apr 2013 15:48:16 +0200 Subject: [PATCH 095/149] [#714] Fix default sort ordering Change the default sort order of package_search() to 'relevance asc, metadata_modified desc'. We want to sort by relevance by default, but when there's no search query relevance is meaningless, in that case fall back on showing the most recently modified datasets first. Also changes the sort ordering of the "Relevance" option in the "Order by:" dropdown to 'relevance asc, metadata_modified desc' instead of just 'relevance asc'. The previous default ordering was 'score desc, name asc'. I'm not even sure if that works, it seems to disagree with the sort strings that the dropdown gives you, when you chose relevance from the dropdown you got 'relevance asc' not 'score desc' (and the datasets appeared in a different order then the default), and when you chose name you get 'title_string' not name. Previously we've fallen back on showing datasets alphabetically but that's boring as it simply means that all the datasets beginning with 'a' are always shown. Last modified seems more interesting and changes over time. Popularity is not an option because that only works if the page view tracking feature is enabled. Move the logic that selects the default sort order for package_search() out of lib and into package_search(). The package_search() action function now returns the sort order it used in the 'sort' key of the returned dict, and the package controller sends this to the templates to decide which sort ordering to show selected in the "Order by:" dropdown. Previously the package controller and action function each had their own logic and the dropdown was out of sync with the actual sort order. Fixes #714. --- ckan/controllers/package.py | 2 +- ckan/lib/search/query.py | 5 ----- ckan/logic/action/get.py | 11 ++++++++--- ckan/templates/snippets/sort_by.html | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index 00cc0b09a37..1126fc16240 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -174,7 +174,6 @@ def _sort_by(fields): else: c.sort_by_fields = [field.split()[0] for field in sort_by.split(',')] - c.sort_by_selected = sort_by def pager_url(q=None, page=None): params = list(params_nopage) @@ -245,6 +244,7 @@ def pager_url(q=None, page=None): } query = get_action('package_search')(context, data_dict) + c.sort_by_selected = query['sort'] c.page = h.Page( collection=query['results'], diff --git a/ckan/lib/search/query.py b/ckan/lib/search/query.py index e43825f8d1e..e56847b7437 100644 --- a/ckan/lib/search/query.py +++ b/ckan/lib/search/query.py @@ -319,11 +319,6 @@ def run(self, query): rows_to_query = rows_to_return query['rows'] = rows_to_query - # order by score if no 'sort' term given - order_by = query.get('sort') - if order_by == 'rank' or order_by is None: - query['sort'] = 'score desc, name asc' - # show only results from this CKAN instance fq = query.get('fq', '') if not '+site_id:' in fq: diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index cdeda998d1e..557c65c4b86 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -1161,8 +1161,9 @@ def package_search(context, data_dict): :param rows: the number of matching rows to return. :type rows: int :param sort: sorting of the search results. Optional. Default: - "score desc, name asc". As per the solr documentation, this is a - comma-separated string of field names and sort-orderings. + 'relevance asc, metadata_modified desc'. As per the solr + documentation, this is a comma-separated string of field names and + sort-orderings. :type sort: string :param start: the offset in the complete result for where the set of returned datasets should begin. @@ -1258,6 +1259,9 @@ def package_search(context, data_dict): if not 'capacity:' in p) data_dict['fq'] = fq + ' capacity:"public"' + if data_dict.get('sort') in (None, 'rank'): + data_dict['sort'] = 'relevance asc, metadata_modified desc' + query = search.query_for(model.Package) query.run(data_dict) @@ -1298,7 +1302,8 @@ def package_search(context, data_dict): search_results = { 'count': count, 'facets': facets, - 'results': results + 'results': results, + 'sort': data_dict['sort'] } # Transform facets into a more useful data structure. diff --git a/ckan/templates/snippets/sort_by.html b/ckan/templates/snippets/sort_by.html index fcc58fc74aa..ebbd67461cc 100644 --- a/ckan/templates/snippets/sort_by.html +++ b/ckan/templates/snippets/sort_by.html @@ -11,7 +11,7 @@ - + From 85b30cacf3d0f249544ace9fd73705f03933d1f9 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Mon, 8 Apr 2013 13:39:40 +0200 Subject: [PATCH 109/149] [#714] Fix pagination tests Change create_arbitrary() to always create datasets in the same order, instead of a different order each time. Update pagination tests to expect datasets in the new default sort order. --- ckan/lib/create_test_data.py | 8 ++++---- ckan/tests/functional/test_pagination.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py index e2aba3b242f..6720bbb9c79 100644 --- a/ckan/lib/create_test_data.py +++ b/ckan/lib/create_test_data.py @@ -148,15 +148,15 @@ def create_arbitrary(cls, package_dicts, relationships=[], new_group_names = set() new_groups = {} - rev = model.repo.new_revision() - rev.author = cls.author - rev.message = u'Creating test packages.' admins_list = defaultdict(list) # package_name: admin_names if package_dicts: if isinstance(package_dicts, dict): package_dicts = [package_dicts] for item in package_dicts: + rev = model.repo.new_revision() + rev.author = cls.author + rev.message = u'Creating test packages.' pkg_dict = {} for field in cls.pkg_core_fields: if item.has_key(field): @@ -245,7 +245,7 @@ def create_arbitrary(cls, package_dicts, relationships=[], model.setup_default_user_roles(pkg, admins=[]) for admin in admins: admins_list[item['name']].append(admin) - model.repo.commit_and_remove() + model.repo.commit_and_remove() needs_commit = False diff --git a/ckan/tests/functional/test_pagination.py b/ckan/tests/functional/test_pagination.py index e5ed5445910..94f0b9ee8d3 100644 --- a/ckan/tests/functional/test_pagination.py +++ b/ckan/tests/functional/test_pagination.py @@ -59,25 +59,25 @@ def test_package_search_p1(self): res = self.app.get(url_for(controller='package', action='search', q='groups:group_00')) assert 'href="/dataset?q=groups%3Agroup_00&page=2"' in res pkg_numbers = scrape_search_results(res, 'dataset') - assert_equal(['00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19'], pkg_numbers) + assert_equal(['50', '49', '48', '47', '46', '45', '44', '43', '42', '41', '40', '39', '38', '37', '36', '35', '34', '33', '32', '31'], pkg_numbers) def test_package_search_p2(self): res = self.app.get(url_for(controller='package', action='search', q='groups:group_00', page=2)) assert 'href="/dataset?q=groups%3Agroup_00&page=1"' in res pkg_numbers = scrape_search_results(res, 'dataset') - assert_equal(['20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '37', '38', '39'], pkg_numbers) + assert_equal(['30', '29', '28', '27', '26', '25', '24', '23', '22', '21', '20', '19', '18', '17', '16', '15', '14', '13', '12', '11'], pkg_numbers) def test_group_datasets_read_p1(self): res = self.app.get(url_for(controller='group', action='read', id='group_00')) assert 'href="/group/group_00?page=2' in res, res pkg_numbers = scrape_search_results(res, 'group_dataset') - assert_equal(['00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19'], pkg_numbers) + assert_equal(['50', '49', '48', '47', '46', '45', '44', '43', '42', '41', '40', '39', '38', '37', '36', '35', '34', '33', '32', '31'], pkg_numbers) def test_group_datasets_read_p2(self): res = self.app.get(url_for(controller='group', action='read', id='group_00', page=2)) assert 'href="/group/group_00?page=1' in res, res pkg_numbers = scrape_search_results(res, 'group_dataset') - assert_equal(['20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '37', '38', '39'], pkg_numbers) + assert_equal(['30', '29', '28', '27', '26', '25', '24', '23', '22', '21', '20', '19', '18', '17', '16', '15', '14', '13', '12', '11'], pkg_numbers) class TestPaginationGroup(TestController): @classmethod From 495d1521ebacad5281d06c02ab21963bd6f4e13d Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Mon, 8 Apr 2013 17:14:01 +0200 Subject: [PATCH 110/149] [#714] Fix an intermittently failing test This test depends on the order of the two datasets. The default sort order has changed now to most-recently-modified-first when there's no search query, and since the test creates the two datasets in a random order the search returns the datasets in a different order each time the test is run, giving a 50/50 chance that the test will fail. Change the test to not depend on the fixed order of the datasets. --- ckan/tests/logic/test_action.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ckan/tests/logic/test_action.py b/ckan/tests/logic/test_action.py index b63e0ca6067..91141d6c776 100644 --- a/ckan/tests/logic/test_action.py +++ b/ckan/tests/logic/test_action.py @@ -1272,7 +1272,7 @@ def test_1_basic_no_params(self): result = res['result'] assert_equal(res['success'], True) assert_equal(result['count'], 2) - assert_equal(result['results'][0]['name'], 'annakarenina') + assert result['results'][0]['name'] in ('annakarenina', 'warandpeace') # Test GET request res = self.app.get('/api/action/package_search') @@ -1280,7 +1280,7 @@ def test_1_basic_no_params(self): result = res['result'] assert_equal(res['success'], True) assert_equal(result['count'], 2) - assert_equal(result['results'][0]['name'], 'annakarenina') + assert result['results'][0]['name'] in ('annakarenina', 'warandpeace') def test_2_bad_param(self): postparams = '%s=1' % json.dumps({ From 4e149cbd888e93465c77731a7f18f603f6299ec8 Mon Sep 17 00:00:00 2001 From: kindly Date: Mon, 8 Apr 2013 16:29:38 +0100 Subject: [PATCH 111/149] #739 remove whitespace between : and term for comaptibility with edismax search --- ckan/controllers/group.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index f3f8508c6f4..1ba3509faa9 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -197,9 +197,9 @@ def _read(self, id, limit): q = c.q = request.params.get('q', '') # Search within group if c.group_dict.get('is_organization'): - q += ' owner_org: "%s"' % c.group_dict.get('id') + q += ' owner_org:"%s"' % c.group_dict.get('id') else: - q += ' groups: "%s"' % c.group_dict.get('name') + q += ' groups:"%s"' % c.group_dict.get('name') try: description_formatted = ckan.misc.MarkdownFormat().to_html( From 0ee390495785d6d4b37885ee488d324690702db7 Mon Sep 17 00:00:00 2001 From: amercader Date: Mon, 8 Apr 2013 18:53:55 +0100 Subject: [PATCH 112/149] [#368] Fix links in revision list --- ckan/templates/revision/snippets/revisions_list.html | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ckan/templates/revision/snippets/revisions_list.html b/ckan/templates/revision/snippets/revisions_list.html index dd815ea4ce6..2726df8dab7 100644 --- a/ckan/templates/revision/snippets/revisions_list.html +++ b/ckan/templates/revision/snippets/revisions_list.html @@ -20,15 +20,14 @@ {{ h.linked_user(rev.author) }} {% for pkg in rev.packages %} - {% set dataset = pkg.title or pkg.name %} - {{ dataset }} + {{ pkg.title }} {% endfor %} {% for group in rev.groups %} - {{ group.display_name }} + {{ group.display_name }} {% endfor %} {{ rev.message }} {% endfor %} - \ No newline at end of file + From 945b0f4b3054550fd7d181ffbe0444a1037ead66 Mon Sep 17 00:00:00 2001 From: amercader Date: Mon, 8 Apr 2013 18:54:36 +0100 Subject: [PATCH 113/149] [#386] Display old revision message as a notice --- ckan/templates/package/read_base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/templates/package/read_base.html b/ckan/templates/package/read_base.html index b43615d42d0..e3845a35202 100644 --- a/ckan/templates/package/read_base.html +++ b/ckan/templates/package/read_base.html @@ -32,7 +32,7 @@ {% block package_revision_info %} {% if c.pkg_revision_id %} -
    +

    {% set timestamp = h.render_datetime(c.pkg_revision_timestamp, with_hours=True) %} {% set url = h.url(controller='package', action='read', id=pkg.name) %} From 28fe3090165e435facdb8ff29e0fe8bc21321f4d Mon Sep 17 00:00:00 2001 From: Vitor Baptista Date: Mon, 8 Apr 2013 17:18:06 -0300 Subject: [PATCH 114/149] [#517] Small refactoring in travis-build script --- bin/travis-build | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bin/travis-build b/bin/travis-build index 82a72bc62be..04efefcbb7a 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -48,10 +48,7 @@ then psql -c 'CREATE USER readonlyuser;' -U postgres sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/readonlyuser@\/ckan_test_datastore/' test-core.ini paster datastore set-permissions postgres -c test-core.ini -fi - -if [ $PGVERSION = '8.4' ] -then +else sed -i -e 's/.*datastore.read_url.*//' test-core.ini fi From 05bc0be1a14b7cae8a631b507e47faefbf7a2674 Mon Sep 17 00:00:00 2001 From: amercader Date: Tue, 9 Apr 2013 12:25:22 +0100 Subject: [PATCH 115/149] [#716] Fix typo in check_data_dict check --- ckan/logic/action/create.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index 2dfc02189a0..dfb79962859 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -120,7 +120,7 @@ def package_create(context, data_dict): # check_data_dict() is deprecated. If the package_plugin has a # check_data_dict() we'll call it, if it doesn't have the method we'll # do nothing. - check_data_dict = getattr(package_plugin, 'check_datadict', None) + check_data_dict = getattr(package_plugin, 'check_data_dict', None) if check_data_dict: try: check_data_dict(data_dict, schema) From 22c1845b277fae269dbb4d602937b660fc4d2658 Mon Sep 17 00:00:00 2001 From: amercader Date: Tue, 9 Apr 2013 12:31:38 +0100 Subject: [PATCH 116/149] [#716] Do not use provided schema on later package_show calls Make sure that if users provide a schema in the context, it is only used for creating or updating the packages, but not on the package_show call at the end of the functions. --- ckan/logic/action/create.py | 3 +++ ckan/logic/action/update.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index dfb79962859..f7d0913587f 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -176,6 +176,9 @@ def package_create(context, data_dict): context["id"] = pkg.id log.debug('Created object %s' % str(pkg.name)) + # Make sure that a user provided schema is not used on package_show + context.pop('schema', None) + return_id_only = context.get('return_id_only', False) output = context['id'] if return_id_only \ diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index 9f9e18dc834..d53a503ad41 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -291,6 +291,9 @@ def package_update(context, data_dict): return_id_only = context.get('return_id_only', False) + # Make sure that a user provided schema is not used on package_show + context.pop('schema', None) + # we could update the dataset so we should still be able to read it. context['ignore_auth'] = True output = data_dict['id'] if return_id_only \ From 5bd3efac1e0b19056b5305fd626eedafc40f510b Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Tue, 9 Apr 2013 14:24:43 +0200 Subject: [PATCH 117/149] [#541] Tweak page view tracking docs Just clarify the docs a bit, and also mention that "recent" means last 14 days and that paster export is for datasets only. --- doc/tracking.rst | 64 ++++++++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/doc/tracking.rst b/doc/tracking.rst index 8c2f759d0bd..396dec88ee6 100644 --- a/doc/tracking.rst +++ b/doc/tracking.rst @@ -2,10 +2,13 @@ Page View Tracking ================== -CKAN can track visits to pages of your site and use this tracking data to sort -datasets by popularity, highlight popular datasets and resources, show view -counts next to datasets and resources, return a list of the most popular -datasets, etc. You can also export the tracking data to a CSV file. +CKAN can track visits to pages of your site and use this tracking data to: + +* Sort datasets by popularity +* Highlight popular datasets and resources +* Show view counts next to datasets and resources +* Show a list of the most popular datasets +* Export page-view data to a CSV file .. seealso:: @@ -51,6 +54,32 @@ To enable page view tracking: ``@monthly``. +Retrieving Tracking Data +======================== + +Tracking summary data for datasets and resources is available in the dataset +and resource dictionaries returned by, for example, the ``package_show()`` +API:: + + "tracking_summary": { + "recent": 5, + "total": 15 + }, + +This can be used, for example, by custom templates to show the number of views +next to datasets and resources. A dataset or resource's ``recent`` count is +its number of views in the last 14 days, the ``total`` count is all of its +tracked views (including recent ones). + +You can also export tracking data for all datasets to a CSV file using the +``paster tracking export`` command. For details, run ``paster tracking -h``. + +.. note:: + + Repeatedly visiting the same page will not increase the page's view count! + Page view counting is limited to one view per user per page per day. + + Sorting Datasets by Popularity ============================== @@ -60,6 +89,8 @@ the dataset search page: .. image:: images/sort-datasets-by-popularity.png +The datasets are sorted by their number of recent views. + You can retrieve datasets most-popular-first from the :doc:`CKAN API ` by passing ``'sort': 'views_recent desc'`` to the ``package_search()`` action. This could be used, for example, by a custom @@ -71,26 +102,6 @@ template to show a list of the most popular datasets on the site's front page. ``'sort': 'views_total desc'`` to the ``package_search()`` API, or use the URL ``/dataset?q=&sort=views_total+desc`` in the web interface. -.. tip:: - - Tracking summary data for datasets and resources is available in the dataset - and resource dictionaries returned by, for example, the ``package_show()`` - API:: - - "tracking_summary": { - "recent": 5, - "total": 15 - }, - - This can be used, for example, by custom templates to show the number of views - next to datasets and resources. - - -.. note:: - - Repeatedly visiting the same page will not increase the page's view count! - Page view counting is limited to one view per user per page per day. - Highlighting Popular Datasets and Resources =========================================== @@ -104,8 +115,3 @@ badge and a tooltip showing the number of views: .. image:: images/popular-resource.png -Exporting Tracking Data -======================= - -You can export CKAN's page view tracking data to a CSV file using the -``paster tracking export`` command. For details, run ``paster tracking -h``. From 48da70a71de2a0c2d3ed7da152dd289f2f166a51 Mon Sep 17 00:00:00 2001 From: John Martin Date: Tue, 9 Apr 2013 13:50:09 +0100 Subject: [PATCH 118/149] [#740] Adds ellipsis wrapper to resource_read --- ckan/public/base/less/prose.less | 6 ++++++ ckan/templates/package/resource_read.html | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ckan/public/base/less/prose.less b/ckan/public/base/less/prose.less index c3846d7db27..315668a0227 100644 --- a/ckan/public/base/less/prose.less +++ b/ckan/public/base/less/prose.less @@ -68,3 +68,9 @@ border-bottom-right-radius: @radius; } } + +.ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/ckan/templates/package/resource_read.html b/ckan/templates/package/resource_read.html index 80156ff93dd..6e607ea3407 100644 --- a/ckan/templates/package/resource_read.html +++ b/ckan/templates/package/resource_read.html @@ -46,7 +46,7 @@ {% block resource_read_title %}

    {{ h.resource_display_name(res) | truncate(50) }}

    {% endblock %} {% block resource_read_url %} {% if res.url %} -

    {{ _('URL:') }} {{ res.url }}

    +

    {{ _('URL:') }} {{ res.url }}

    {% endif %} {% endblock %}
    From 7c7f3320de025aad5a1abbe426925b0184b2c309 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Tue, 9 Apr 2013 16:52:32 +0200 Subject: [PATCH 119/149] [#541] Small tweak to page view tracking docs --- doc/tracking.rst | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/doc/tracking.rst b/doc/tracking.rst index 396dec88ee6..384028e8a76 100644 --- a/doc/tracking.rst +++ b/doc/tracking.rst @@ -32,11 +32,10 @@ To enable page view tracking: 2. Setup a cron job to update the tracking summary data. - When sorting datasets by popularity or exporting tracking data to file, CKAN - uses a summarised version of the tracking data, not the raw tracking data - that is recorded "live" as page views happen. The ``paster tracking update`` - and ``paster search-index rebuild`` commands need to be run periodicially to - update this tracking summary data. + For operations based on the tracking data CKAN uses a summarised version of + the data, not the raw tracking data that is recorded "live" as page views + happen. The ``paster tracking update`` and ``paster search-index rebuild`` + commands need to be run periodicially to update this tracking summary data. You can setup a cron job to run these commands. On most UNIX systems you can setup a cron job by running ``crontab -e`` in a shell to edit your crontab From 938a346af96fbe1f91c0f19c7acd16fd89b87ab2 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 10 Apr 2013 16:12:27 +0200 Subject: [PATCH 120/149] [#621] Fix crash on duplicate extras key error Fix a crash when generating a 'duplicate extras key' error. To trigger the crash, post a dataset dict to package_create() containing two extras dicts with the same key. There is no test for this yet. --- ckan/logic/validators.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ckan/logic/validators.py b/ckan/logic/validators.py index 37af309ac31..a05e9480477 100644 --- a/ckan/logic/validators.py +++ b/ckan/logic/validators.py @@ -313,7 +313,9 @@ def duplicate_extras_key(key, data, errors, context): for extra_key in set(extras_keys): extras_keys.remove(extra_key) if extras_keys: - errors['extras_validation'].append(_('Duplicate key "%s"') % extras_keys[0]) + key_ = ('extras_validation',) + assert key_ not in errors + errors[key_] = _('Duplicate key "%s"') % extras_keys[0] def group_name_validator(key, data, errors, context): model = context['model'] From f0aeecefe2a742b5923a04cced5e16d1524d70fc Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 10 Apr 2013 16:52:00 +0200 Subject: [PATCH 121/149] [#621] Add tests for duplicate extras key error This code path was not covered by the tests. --- ckan/tests/logic/test_action.py | 36 +++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/ckan/tests/logic/test_action.py b/ckan/tests/logic/test_action.py index b63e0ca6067..54f7a3f52fa 100644 --- a/ckan/tests/logic/test_action.py +++ b/ckan/tests/logic/test_action.py @@ -1132,6 +1132,40 @@ def test_42_resource_search_accessible_via_get_request(self): assert "index" in resource['description'].lower() assert "json" in resource['format'].lower() + def test_package_create_duplicate_extras_error(self): + import ckan.tests + import paste.fixture + import pylons.test + + # Posting a dataset dict to package_create containing two extras dicts + # with the same key, should return a Validation Error. + app = paste.fixture.TestApp(pylons.test.pylonsapp) + error = ckan.tests.call_action_api(app, 'package_create', + apikey=self.sysadmin_user.apikey, status=409, + name='foobar', extras=[{'key': 'foo', 'value': 'bar'}, + {'key': 'foo', 'value': 'gar'}]) + assert error['__type'] == 'Validation Error' + assert error['extras_validation'] == 'Duplicate key "foo"' + + def test_package_update_duplicate_extras_error(self): + import ckan.tests + import paste.fixture + import pylons.test + + # We need to create a package first, so that we can update it. + app = paste.fixture.TestApp(pylons.test.pylonsapp) + package = ckan.tests.call_action_api(app, 'package_create', + apikey=self.sysadmin_user.apikey, name='foobar') + + # Posting a dataset dict to package_update containing two extras dicts + # with the same key, should return a Validation Error. + package['extras'] = [{'key': 'foo', 'value': 'bar'}, + {'key': 'foo', 'value': 'gar'}] + error = ckan.tests.call_action_api(app, 'package_update', + apikey=self.sysadmin_user.apikey, status=409, **package) + assert error['__type'] == 'Validation Error' + assert error['extras_validation'] == 'Duplicate key "foo"' + class TestActionTermTranslation(WsgiAppCase): @classmethod @@ -1553,5 +1587,3 @@ def test_02_bulk_delete(self): res = self.app.get('/api/action/package_search?q=*:*') assert json.loads(res.body)['result']['count'] == 0 - - From 04d8df80da19579bd551e469a879f12c8dedb15c Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 10 Apr 2013 17:25:43 +0200 Subject: [PATCH 122/149] [#618] Rename contributing -> CONTRIBUTING in docs The source file in the root of this git repo is still called CONTRIBUTING.rst, but the symlink to it in docs/ is now lower-case contributing.rst. This means the docs.ckan.org URL is /contributing.html not /CONTRIBUTING.html. --- doc/{CONTRIBUTING.rst => contributing.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/{CONTRIBUTING.rst => contributing.rst} (100%) diff --git a/doc/CONTRIBUTING.rst b/doc/contributing.rst similarity index 100% rename from doc/CONTRIBUTING.rst rename to doc/contributing.rst From 25852a2032f11d3c3117a3cfa29c81643c545c2f Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 10 Apr 2013 17:27:43 +0200 Subject: [PATCH 123/149] [#618] Add contributing docs to docs index --- doc/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/index.rst b/doc/index.rst index aad7b737ca1..8737221c45c 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -92,6 +92,7 @@ For CKAN Developers .. toctree:: :maxdepth: 1 + contributing architecture python-coding-standards javascript-coding-standards From 3428f8a5c1f873bfa36dfb08fa62eb226bf9092c Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 10 Apr 2013 17:37:24 +0200 Subject: [PATCH 124/149] [#618] Fix a broken link in CONTRIBUTING.rst --- CONTRIBUTING.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 6cecad332a1..c1a46c16a34 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -5,7 +5,6 @@ Contributing to CKAN .. _CKAN repo on GitHub: https://github.com/okfn/ckan .. _CKAN issue tracker: https://github.com/okfn/ckan/issues .. _docs.ckan.org: http://docs.ckan.org -.. _Contributing to CKAN's Documentation: https://github.com/okfn/ckan/blob/master/CONTRIBUTING.rst#contributing-to-ckans-documentation (This section is about contributing code, if you want to contribute documentation see `Contributing to CKAN's Documentation`_.) From 2ab6084a5e7e8ecd109a65d6b5dd2dc53f5f368c Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 10 Apr 2013 17:38:40 +0200 Subject: [PATCH 125/149] [#618] Move Coding Standards link to top of CONTRIBUTING.rst Make it more prominent. --- CONTRIBUTING.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c1a46c16a34..e3d8c461661 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -15,6 +15,13 @@ your code to a feature branch on your fork, then make a pull request for your branch on the central CKAN repo. We'll go through each step in detail below... +Coding Standards +---------------- + +When writing code for CKAN, try to follow our +`coding standards `_. + + Fork CKAN on GitHub ------------------- @@ -132,13 +139,6 @@ When merging a feature or bug branch into master: - Use the ``--no-ff`` option in the ``git merge`` command, -Coding Standards ----------------- - -When writing code for CKAN, try to follow our -`coding standards `_. - - ==================================== Contributing to CKAN's Documentation ==================================== From 09220c47854666bd126a8bc8125621abbc6b399f Mon Sep 17 00:00:00 2001 From: John Martin Date: Wed, 10 Apr 2013 16:44:35 +0100 Subject: [PATCH 126/149] [#748] Changes page title on /organization index --- ckan/templates/organization/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/templates/organization/index.html b/ckan/templates/organization/index.html index 1ac66970154..74ef2cd9050 100644 --- a/ckan/templates/organization/index.html +++ b/ckan/templates/organization/index.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ _('Organizations of Datasets') }}{% endblock %} +{% block subtitle %}{{ _('Organizations') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Organizations'), controller='organization', action='index' %}
  • From 6760976d0e1868a03196d90949dd619cff7f89d9 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 10 Apr 2013 17:51:03 +0200 Subject: [PATCH 127/149] Fix broken link in CONTRIBUTING.rst There seems to be a bug in GitHub's restructured text rendering, an internal link to a section with a ' in its title will be broken (but it works fine in Sphinx). Rename the section to workaround this. --- CONTRIBUTING.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e3d8c461661..5cbf062dd5a 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -7,7 +7,7 @@ Contributing to CKAN .. _docs.ckan.org: http://docs.ckan.org (This section is about contributing code, if you want to contribute -documentation see `Contributing to CKAN's Documentation`_.) +documentation see `Contributing to the CKAN Documentation`_.) CKAN is a free software project and code contributions are welcome. To contribute code to CKAN you should fork CKAN to your own GitHub account, push @@ -124,7 +124,7 @@ When submitting a pull request: `CHANGELOG file `_ briefly summarising your code changes. - Your branch should contain new or updated documentation for any new or - updated code, see `Contributing to CKAN's Documentation`_. + updated code, see `Contributing to the CKAN Documentation`_. - Your branch should be up to date with the master branch of the central CKAN repo, see `Keeping Up with master`_. - All the CKAN tests should pass on your branch, see @@ -139,9 +139,9 @@ When merging a feature or bug branch into master: - Use the ``--no-ff`` option in the ``git merge`` command, -==================================== -Contributing to CKAN's Documentation -==================================== +====================================== +Contributing to the CKAN Documentation +====================================== Note: getting started with contributing to `docs.ckan.org`_ is a little complicated. An easier way to contribute documentation to CKAN is to From f3bb02d9fe3f749055ef838379ed4cf11ca3db00 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 10 Apr 2013 18:04:24 +0200 Subject: [PATCH 128/149] Remove changelog update from contributing guidelines We agreed not to update the changelog as features are added, someone will update it all at once before a release instead. --- CONTRIBUTING.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 5cbf062dd5a..298a27765c4 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -120,9 +120,6 @@ When submitting a pull request: see `Feature Branches`_. - Your branch should contain new or changed tests for any new or changed code. -- Your branch should contain updates to the - `CHANGELOG file `_ - briefly summarising your code changes. - Your branch should contain new or updated documentation for any new or updated code, see `Contributing to the CKAN Documentation`_. - Your branch should be up to date with the master branch of the central From bcff15ac0c6262b81d3ff2ed5454f7899e3f8a3c Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 10 Apr 2013 18:27:38 +0200 Subject: [PATCH 129/149] [#621] Return duplicate extras key error in a list This makes it consistent with other errors that CKAN returns. --- ckan/logic/validators.py | 2 +- ckan/tests/logic/test_action.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ckan/logic/validators.py b/ckan/logic/validators.py index a05e9480477..f521981fd0d 100644 --- a/ckan/logic/validators.py +++ b/ckan/logic/validators.py @@ -315,7 +315,7 @@ def duplicate_extras_key(key, data, errors, context): if extras_keys: key_ = ('extras_validation',) assert key_ not in errors - errors[key_] = _('Duplicate key "%s"') % extras_keys[0] + errors[key_] = [_('Duplicate key "%s"') % extras_keys[0]] def group_name_validator(key, data, errors, context): model = context['model'] diff --git a/ckan/tests/logic/test_action.py b/ckan/tests/logic/test_action.py index 54f7a3f52fa..880896b216b 100644 --- a/ckan/tests/logic/test_action.py +++ b/ckan/tests/logic/test_action.py @@ -1145,7 +1145,7 @@ def test_package_create_duplicate_extras_error(self): name='foobar', extras=[{'key': 'foo', 'value': 'bar'}, {'key': 'foo', 'value': 'gar'}]) assert error['__type'] == 'Validation Error' - assert error['extras_validation'] == 'Duplicate key "foo"' + assert error['extras_validation'] == ['Duplicate key "foo"'] def test_package_update_duplicate_extras_error(self): import ckan.tests @@ -1164,7 +1164,7 @@ def test_package_update_duplicate_extras_error(self): error = ckan.tests.call_action_api(app, 'package_update', apikey=self.sysadmin_user.apikey, status=409, **package) assert error['__type'] == 'Validation Error' - assert error['extras_validation'] == 'Duplicate key "foo"' + assert error['extras_validation'] == ['Duplicate key "foo"'] class TestActionTermTranslation(WsgiAppCase): From 5fff2bc88d6f84566178d76660fc37f6876131d5 Mon Sep 17 00:00:00 2001 From: joetsoi Date: Thu, 11 Apr 2013 09:42:26 +0100 Subject: [PATCH 130/149] [#509] minor indentation cleanup --- ckan/lib/dictization/model_save.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py index 27a7822ab45..8afed9bdbc5 100644 --- a/ckan/lib/dictization/model_save.py +++ b/ckan/lib/dictization/model_save.py @@ -239,7 +239,7 @@ def package_membership_list_save(group_dicts, package, context): for group in set(group_member.keys()) - groups: member_obj = group_member[group] if member_obj and member_obj.state == 'deleted': - continue + continue if new_authz.has_user_permission_for_group_or_org( member_obj.group_id, user, 'read'): member_obj.capacity = capacity @@ -250,7 +250,7 @@ def package_membership_list_save(group_dicts, package, context): for group in groups: member_obj = group_member.get(group) if member_obj and member_obj.state == 'active': - continue + continue if new_authz.has_user_permission_for_group_or_org( group.id, user, 'read'): member_obj = group_member.get(group) From cd8f25f6556ef5ed9d381d71e343d62e71cdaf32 Mon Sep 17 00:00:00 2001 From: Vitor Baptista Date: Thu, 11 Apr 2013 12:22:42 -0300 Subject: [PATCH 131/149] [#726] Recompile CSS files. --- ckan/public/base/css/main.css | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ckan/public/base/css/main.css b/ckan/public/base/css/main.css index a3f654b889c..b4b5fc9cb69 100644 --- a/ckan/public/base/css/main.css +++ b/ckan/public/base/css/main.css @@ -4860,8 +4860,6 @@ a.tag:hover { } .box { background-color: #FFF; - margin-left: -1px; - margin-right: -1px; border: 1px solid #cccccc; -webkit-border-radius: 4px; -moz-border-radius: 4px; @@ -7659,8 +7657,6 @@ textarea { .wrapper { *zoom: 1; background-color: #FFF; - margin-left: -1px; - margin-right: -1px; border: 1px solid #cccccc; -webkit-border-radius: 4px; -moz-border-radius: 4px; @@ -7700,8 +7696,7 @@ textarea { border-top-width: 1px; } [role=main] .primary { - width: 719px; - margin-left: 1px; + width: 717px; float: right; } [role=main] .secondary { From 6f637791340bf04b61e6f6169fddabbb06b77251 Mon Sep 17 00:00:00 2001 From: jbspeakr Date: Fri, 5 Apr 2013 17:06:59 +0200 Subject: [PATCH 132/149] reformat /ckan/ckan/config/routing.py --- ckan/config/routing.py | 163 +++++++++++++++++++++-------------------- 1 file changed, 83 insertions(+), 80 deletions(-) diff --git a/ckan/config/routing.py b/ckan/config/routing.py index 0400512bd60..0d3c00c5838 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -69,14 +69,13 @@ def make_map(): # import controllers here rather than at root level because # pylons config is initialised by this point. - # Helpers to reduce code clutter GET = dict(method=['GET']) PUT = dict(method=['PUT']) POST = dict(method=['POST']) DELETE = dict(method=['DELETE']) GET_POST = dict(method=['GET', 'POST']) - PUT_POST = dict(method=['PUT','POST']) + PUT_POST = dict(method=['PUT', 'POST']) PUT_POST_DELETE = dict(method=['PUT', 'POST', 'DELETE']) OPTIONS = dict(method=['OPTIONS']) @@ -93,7 +92,8 @@ def make_map(): map.connect('/error/{action}', controller='error') map.connect('/error/{action}/{id}', controller='error') - map.connect('*url', controller='home', action='cors_options', conditions=OPTIONS) + map.connect('*url', controller='home', action='cors_options', + conditions=OPTIONS) # CUSTOM ROUTES HERE for plugin in routing_plugins: @@ -104,39 +104,43 @@ def make_map(): # CKAN API versioned. register_list = [ - 'package', - 'dataset', - 'resource', - 'tag', - 'group', - 'related', - 'revision', - 'licenses', - 'rating', - 'user', - 'activity' - ] + 'package', + 'dataset', + 'resource', + 'tag', + 'group', + 'related', + 'revision', + 'licenses', + 'rating', + 'user', + 'activity' + ] register_list_str = '|'.join(register_list) # /api ver 3 or none - with SubMapper(map, controller='api', path_prefix='/api{ver:/3|}', ver='/3') as m: + with SubMapper(map, controller='api', path_prefix='/api{ver:/3|}', + ver='/3') as m: m.connect('/action/{logic_function}', action='action', conditions=GET_POST) # /api ver 1, 2, 3 or none - with SubMapper(map, controller='api', path_prefix='/api{ver:/1|/2|/3|}', ver='/1') as m: + with SubMapper(map, controller='api', path_prefix='/api{ver:/1|/2|/3|}', + ver='/1') as m: m.connect('', action='get_api') m.connect('/search/{register}', action='search') # /api ver 1, 2 or none - with SubMapper(map, controller='api', path_prefix='/api{ver:/1|/2|}', ver='/1') as m: + with SubMapper(map, controller='api', path_prefix='/api{ver:/1|/2|}', + ver='/1') as m: m.connect('/tag_counts', action='tag_counts') m.connect('/rest', action='index') m.connect('/qos/throughput/', action='throughput', conditions=GET) # /api/rest ver 1, 2 or none - with SubMapper(map, controller='api', path_prefix='/api{ver:/1|/2|}', ver='/1', - requirements=dict(register=register_list_str)) as m: + with SubMapper(map, controller='api', path_prefix='/api{ver:/1|/2|}', + ver='/1', requirements=dict(register=register_list_str) + ) as m: m.connect('/rest/{register}', action='list', conditions=GET) m.connect('/rest/{register}', action='create', conditions=POST) @@ -145,20 +149,21 @@ def make_map(): m.connect('/rest/{register}/{id}', action='update', conditions=POST) m.connect('/rest/{register}/{id}', action='delete', conditions=DELETE) m.connect('/rest/{register}/{id}/:subregister', action='list', - conditions=GET) + conditions=GET) m.connect('/rest/{register}/{id}/:subregister', action='create', - conditions=POST) + conditions=POST) m.connect('/rest/{register}/{id}/:subregister/{id2}', action='create', - conditions=POST) + conditions=POST) m.connect('/rest/{register}/{id}/:subregister/{id2}', action='show', - conditions=GET) + conditions=GET) m.connect('/rest/{register}/{id}/:subregister/{id2}', action='update', - conditions=PUT) + conditions=PUT) m.connect('/rest/{register}/{id}/:subregister/{id2}', action='delete', - conditions=DELETE) + conditions=DELETE) # /api/util ver 1, 2 or none - with SubMapper(map, controller='api', path_prefix='/api{ver:/1|/2|}', ver='/1') as m: + with SubMapper(map, controller='api', path_prefix='/api{ver:/1|/2|}', + ver='/1') as m: m.connect('/util/user/autocomplete', action='user_autocomplete') m.connect('/util/is_slug_valid', action='is_slug_valid', conditions=GET) @@ -190,7 +195,7 @@ def make_map(): map.redirect('/package/{url:.*}', '/dataset/{url}') with SubMapper(map, controller='related') as m: - m.connect('related_new', '/dataset/{id}/related/new', action='new') + m.connect('related_new', '/dataset/{id}/related/new', action='new') m.connect('related_edit', '/dataset/{id}/related/edit/{related_id}', action='edit') m.connect('related_delete', '/dataset/{id}/related/delete/{related_id}', @@ -205,35 +210,32 @@ def make_map(): highlight_actions='index search') m.connect('add dataset', '/dataset/new', action='new') m.connect('/dataset/{action}', - requirements=dict(action='|'.join([ - 'list', - 'autocomplete', - 'search' - ])) - ) + requirements=dict(action='|'.join([ + 'list', + 'autocomplete', + 'search' + ]))) m.connect('/dataset/{action}/{id}/{revision}', action='read_ajax', - requirements=dict(action='|'.join([ - 'read', - 'edit', - 'history', - ])) - ) + requirements=dict(action='|'.join([ + 'read', + 'edit', + 'history', + ]))) m.connect('/dataset/{action}/{id}', - requirements=dict(action='|'.join([ - 'edit', - 'new_metadata', - 'new_resource', - 'history', - 'read_ajax', - 'history_ajax', - 'follow', - 'activity', - 'unfollow', - 'delete', - 'api_data', - ])) - ) + requirements=dict(action='|'.join([ + 'edit', + 'new_metadata', + 'new_resource', + 'history', + 'read_ajax', + 'history_ajax', + 'follow', + 'activity', + 'unfollow', + 'delete', + 'api_data', + ]))) m.connect('dataset_followers', '/dataset/followers/{id}', action='followers', ckan_icon='group') m.connect('dataset_activity', '/dataset/activity/{id}', @@ -253,7 +255,8 @@ def make_map(): m.connect('/dataset/{id}/resource/{resource_id}/embed', action='resource_embedded_dataviewer') m.connect('/dataset/{id}/resource/{resource_id}/viewer', - action='resource_embedded_dataviewer', width="960", height="800") + action='resource_embedded_dataviewer', width="960", + height="800") m.connect('/dataset/{id}/resource/{resource_id}/preview', action='resource_datapreview') @@ -271,22 +274,21 @@ def make_map(): m.connect('group_index', '/group', action='index', highlight_actions='index search') m.connect('group_list', '/group/list', action='list') - m.connect('group_new', '/group/new', action='new') + m.connect('group_new', '/group/new', action='new') m.connect('group_action', '/group/{action}/{id}', - requirements=dict(action='|'.join([ - 'edit', - 'delete', - 'members', - 'member_new', - 'member_delete', - 'history', - 'followers', - 'follow', - 'unfollow', - 'admins', - 'activity', - ])) - ) + requirements=dict(action='|'.join([ + 'edit', + 'delete', + 'members', + 'member_new', + 'member_delete', + 'history', + 'followers', + 'follow', + 'unfollow', + 'admins', + 'activity', + ]))) m.connect('group_about', '/group/about/{id}', action='about', ckan_icon='info-sign'), m.connect('group_activity', '/group/activity/{id}/{offset}', @@ -300,14 +302,13 @@ def make_map(): m.connect('/organization/list', action='list') m.connect('/organization/new', action='new') m.connect('/organization/{action}/{id}', - requirements=dict(action='|'.join([ - 'delete', - 'admins', - 'member_new', - 'member_delete', - 'history' - ])) - ) + requirements=dict(action='|'.join([ + 'delete', + 'admins', + 'member_new', + 'member_delete', + 'history' + ]))) m.connect('organization_activity', '/organization/activity/{id}', action='activity', ckan_icon='time') m.connect('organization_read', '/organization/{id}', action='read') @@ -319,7 +320,8 @@ def make_map(): action='edit', ckan_icon='edit') m.connect('organization_members', '/organization/members/{id}', action='members', ckan_icon='group') - m.connect('organization_bulk_process', '/organization/bulk_process/{id}', + m.connect('organization_bulk_process', + '/organization/bulk_process/{id}', action='bulk_process', ckan_icon='sitemap') register_package_plugins(map) register_group_plugins(map) @@ -327,7 +329,8 @@ def make_map(): # tags map.redirect('/tags', '/tag') map.redirect('/tags/{url:.*}', '/tag/{url}') - map.redirect('/tag/read/{url:.*}', '/tag/{url}', _redirect_code='301 Moved Permanently') + map.redirect('/tag/read/{url:.*}', '/tag/{url}', + _redirect_code='301 Moved Permanently') map.connect('/tag', controller='tag', action='index') map.connect('/tag/{id}', controller='tag', action='read') # users From 87e0df4ea027f883c8f208f5b95ffee544f481a6 Mon Sep 17 00:00:00 2001 From: jbspeakr Date: Fri, 5 Apr 2013 17:43:07 +0200 Subject: [PATCH 133/149] reformat /ckan/ckan/controllers/error.py --- ckan/controllers/error.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ckan/controllers/error.py b/ckan/controllers/error.py index c5ba044d246..57180dc7289 100644 --- a/ckan/controllers/error.py +++ b/ckan/controllers/error.py @@ -10,7 +10,6 @@ class ErrorController(BaseController): - """Generates error documents as and when they are required. The ErrorDocuments middleware forwards to ErrorController when error From a591c7d33a88b65de9e0a3903acd82028f4225ad Mon Sep 17 00:00:00 2001 From: jbspeakr Date: Fri, 5 Apr 2013 17:48:10 +0200 Subject: [PATCH 134/149] reformat /ckan/ckan/controllers/feed.py --- ckan/controllers/feed.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/ckan/controllers/feed.py b/ckan/controllers/feed.py index 2e9af01ed5c..e1ea58fa511 100644 --- a/ckan/controllers/feed.py +++ b/ckan/controllers/feed.py @@ -152,7 +152,6 @@ def _create_atom_id(resource_path, authority_name=None, date_string=None): class FeedController(BaseController): - base_url = config.get('ckan.site_url') def _alternate_url(self, params, **kwargs): @@ -207,7 +206,6 @@ def group(self, id): navigation_urls=navigation_urls) def tag(self, id): - data_dict, params = self._parse_url_params() data_dict['fq'] = 'tags:"%s"' % id @@ -323,7 +321,6 @@ def custom(self): def output_feed(self, results, feed_title, feed_description, feed_link, feed_url, navigation_urls, feed_guid): - author_name = config.get('ckan.feeds.author_name', '').strip() or \ config.get('ckan.site_id', '').strip() author_link = config.get('ckan.feeds.author_link', '').strip() or \ @@ -349,8 +346,8 @@ def output_feed(self, results, feed_title, feed_description, feed.add_item( title=pkg.get('title', ''), link=self.base_url + h.url_for(controller='package', - action='read', - id=pkg['id']), + action='read', + id=pkg['id']), description=pkg.get('notes', ''), updated=h.date_str_to_datetime(pkg.get('metadata_modified')), published=h.date_str_to_datetime(pkg.get('metadata_created')), @@ -360,10 +357,10 @@ def output_feed(self, results, feed_title, feed_description, categories=[t['name'] for t in pkg.get('tags', [])], enclosure=webhelpers.feedgenerator.Enclosure( self.base_url + h.url_for(controller='api', - register='package', - action='show', - id=pkg['name'], - ver='2'), + register='package', + action='show', + id=pkg['name'], + ver='2'), unicode(len(json.dumps(pkg))), # TODO fix this u'application/json') ) @@ -433,7 +430,6 @@ def _parse_url_params(self): Returns the constructed search-query dict, and the valid URL query parameters. """ - try: page = int(request.params.get('page', 1)) or 1 except ValueError: From 1f2cbbaa15de2ce305cfadaed580e140b19fb47a Mon Sep 17 00:00:00 2001 From: jbspeakr Date: Fri, 5 Apr 2013 17:59:31 +0200 Subject: [PATCH 135/149] reformat /ckan/ckan/controllers/home.py --- ckan/controllers/home.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/ckan/controllers/home.py b/ckan/controllers/home.py index 21843ca4fb3..7a9fe062a0d 100644 --- a/ckan/controllers/home.py +++ b/ckan/controllers/home.py @@ -14,6 +14,7 @@ # horrible hack dirty_cached_group_stuff = None + class HomeController(base.BaseController): repo = model.repo @@ -32,7 +33,7 @@ def __before__(self, action, **env): ('no such table' in msg): # table missing, major database problem base.abort(503, _('This site is currently off-line. Database ' - 'is not initialised.')) + 'is not initialised.')) # TODO: send an email to the admin person (#1285) else: raise @@ -58,24 +59,27 @@ def index(self): c.facets = query['facets'] maintain.deprecate_context_item( - 'facets', - 'Use `c.search_facets` instead.') + 'facets', + 'Use `c.search_facets` instead.') c.search_facets = query['search_facets'] - c.facet_titles = {'groups': _('Groups'), - 'tags': _('Tags'), - 'res_format': _('Formats'), - 'license': _('Licence'), } + c.facet_titles = { + 'groups': _('Groups'), + 'tags': _('Tags'), + 'res_format': _('Formats'), + 'license': _('Licence'), + } data_dict = {'sort': 'packages', 'all_fields': 1} # only give the terms to group dictize that are returned in the # facets as full results take a lot longer if 'groups' in c.search_facets: - data_dict['groups'] = [ item['name'] for item in - c.search_facets['groups']['items'] ] + data_dict['groups'] = [ + item['name'] for item in c.search_facets['groups']['items'] + ] c.groups = logic.get_action('group_list')(context, data_dict) - except search.SearchError, se: + except search.SearchError: c.package_count = 0 c.groups = [] @@ -90,8 +94,8 @@ def index(self): msg = _(u'Please update your profile' u' and add your email address and your full name. ' u'{site} uses your email address' - u' if you need to reset your password.'.format(link=url, - site=g.site_title)) + u' if you need to reset your password.'.format( + link=url, site=g.site_title)) elif not c.userobj.email: msg = _('Please update your profile' ' and add your email address. ') % url + \ @@ -134,7 +138,7 @@ def db_to_form_schema(group_type=None): except logic.NotFound: return None - return {'group_dict' :group_dict} + return {'group_dict': group_dict} global dirty_cached_group_stuff if not dirty_cached_group_stuff: @@ -160,14 +164,14 @@ def db_to_form_schema(group_type=None): # We get all the packages or at least too many so # limit it to just 2 for group in groups_data: - group['group_dict']['packages'] = group['group_dict']['packages'][:2] + group['group_dict']['packages'] = \ + group['group_dict']['packages'][:2] #now add blanks so we have two while len(groups_data) < 2: - groups_data.append({'group_dict' :{}}) + groups_data.append({'group_dict': {}}) # cache for later use dirty_cached_group_stuff = groups_data - c.group_package_stuff = dirty_cached_group_stuff # END OF DIRTYNESS From 6ebc7d9010a29fb7d19a03513a5f02cf43aea812 Mon Sep 17 00:00:00 2001 From: jbspeakr Date: Fri, 5 Apr 2013 18:01:12 +0200 Subject: [PATCH 136/149] reformat /ckan/ckan/controllers/organization.py --- ckan/controllers/organization.py | 1 + ckan/controllers/package.py | 16 ++++++---------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/ckan/controllers/organization.py b/ckan/controllers/organization.py index 53996819a7b..b411f8e2b31 100644 --- a/ckan/controllers/organization.py +++ b/ckan/controllers/organization.py @@ -1,5 +1,6 @@ import ckan.controllers.group as group + class OrganizationController(group.GroupController): ''' The organization controller is pretty much just the group controller. It has a few templates defined that are different and sets diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index 00cc0b09a37..2be958f72ed 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -41,6 +41,7 @@ lookup_package_plugin = ckan.lib.plugins.lookup_package_plugin + def _encode_params(params): return [(k, v.encode('utf-8') if isinstance(v, basestring) else str(v)) for k, v in params] @@ -110,7 +111,6 @@ def _guess_package_type(self, expecting_name=False): return pt - def search(self): from ckan.lib.search import SearchError @@ -267,7 +267,6 @@ def pager_url(q=None, page=None): limit = int(request.params.get('_%s_limit' % facet, 10)) c.search_facets_limits[facet] = limit - maintain.deprecate_context_item( 'facets', 'Use `c.search_facets` instead.') @@ -331,7 +330,7 @@ def read(self, id, format='html'): abort(400, _('Invalid revision format: %r') % 'Too many "@" symbols') - #check if package exists + # check if package exists try: c.pkg_dict = get_action('package_show')(context, data_dict) c.pkg = context['package'] @@ -359,7 +358,7 @@ def comments(self, id): context = {'model': model, 'session': model.Session, 'user': c.user or c.author} - #check if package exists + # check if package exists try: c.pkg_dict = get_action('package_show')(context, {'id': id}) c.pkg = context['package'] @@ -371,7 +370,7 @@ def comments(self, id): # used by disqus plugin c.current_package_id = c.pkg.id - #render the package + # render the package package_saver.PackageSaver().render_package(c.pkg_dict) return render(self._comments_template(package_type)) @@ -400,7 +399,7 @@ def history(self, id): c.pkg_dict = get_action('package_show')(context, data_dict) c.pkg_revisions = get_action('package_revision_list')(context, data_dict) - #TODO: remove + # TODO: remove # Still necessary for the authz check in group/layout.html c.pkg = context['package'] @@ -543,7 +542,6 @@ def resource_edit(self, id, resource_id, data=None, errors=None, redirect(h.url_for(controller='package', action='resource_read', id=id, resource_id=resource_id)) - context = {'model': model, 'session': model.Session, 'api_version': 3, 'user': c.user or c.author,} @@ -575,8 +573,6 @@ def resource_edit(self, id, resource_id, data=None, errors=None, 'error_summary': error_summary, 'action': 'new'} return render('package/resource_edit.html', extra_vars=vars) - - def new_resource(self, id, data=None, errors=None, error_summary=None): ''' FIXME: This is a temporary action to allow styling of the forms. ''' @@ -1172,7 +1168,7 @@ def resource_read(self, id, resource_id): c.package['isopen'] = False # TODO: find a nicer way of doing this - c.datastore_api = '%s/api/action' % config.get('ckan.site_url','').rstrip('/') + c.datastore_api = '%s/api/action' % config.get('ckan.site_url', '').rstrip('/') c.related_count = c.pkg.related_count return render('package/resource_read.html') From 468210b5303371bec33c8d9e60b1f992c228bb04 Mon Sep 17 00:00:00 2001 From: jbspeakr Date: Fri, 5 Apr 2013 18:15:14 +0200 Subject: [PATCH 137/149] reformat /ckan/ckan/controllers/related.py --- ckan/controllers/related.py | 44 ++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/ckan/controllers/related.py b/ckan/controllers/related.py index e4840663d0d..68a68c8c1d1 100644 --- a/ckan/controllers/related.py +++ b/ckan/controllers/related.py @@ -11,7 +11,7 @@ c = base.c abort = base.abort -_get_action=logic.get_action +_get_action = logic.get_action class RelatedController(base.BaseController): @@ -32,15 +32,15 @@ def dashboard(self): 'featured': base.request.params.get('featured', '') } - params_nopage = [(k, v) for k,v in base.request.params.items() + params_nopage = [(k, v) for k, v in base.request.params.items() if k != 'page'] try: page = int(base.request.params.get('page', 1)) - except ValueError, e: + except ValueError: base.abort(400, ('"page" parameter must be an integer')) # Update ordering in the context - query = logic.get_action('related_list')(context,data_dict) + query = logic.get_action('related_list')(context, data_dict) def search_url(params): url = h.url_for(controller='related', action='dashboard') @@ -54,7 +54,6 @@ def pager_url(q=None, page=None): params.append(('page', page)) return search_url(params) - c.page = h.Page( collection=query.all(), page=page, @@ -66,13 +65,15 @@ def pager_url(q=None, page=None): c.filters = dict(params_nopage) c.type_options = self._type_options() - c.sort_options = ({'value': '', 'text': _('Most viewed')}, - {'value': 'view_count_desc', 'text': _('Most Viewed')}, - {'value': 'view_count_asc', 'text': _('Least Viewed')}, - {'value': 'created_desc', 'text': _('Newest')}, - {'value': 'created_asc', 'text': _('Oldest')}) + c.sort_options = ( + {'value': '', 'text': _('Most viewed')}, + {'value': 'view_count_desc', 'text': _('Most Viewed')}, + {'value': 'view_count_asc', 'text': _('Least Viewed')}, + {'value': 'created_desc', 'text': _('Newest')}, + {'value': 'created_asc', 'text': _('Oldest')} + ) - return base.render( "related/dashboard.html") + return base.render("related/dashboard.html") def read(self, id): context = {'model': model, 'session': model.Session, @@ -85,8 +86,8 @@ def read(self, id): except logic.NotAuthorized: base.abort(401, _('Not authorized to see this page')) - related = model.Session.query(model.Related).\ - filter(model.Related.id == id).first() + related = model.Session.query(model.Related) \ + .filter(model.Related.id == id).first() if not related: base.abort(404, _('The requested related item was not found')) @@ -97,7 +98,6 @@ def read(self, id): base.redirect(related.url) - def list(self, id): """ List all related items for a specific dataset """ context = {'model': model, 'session': model.Session, @@ -164,10 +164,9 @@ def _edit_or_new(self, id, related_id, is_edit): if base.request.method == "POST": try: data = logic.clean_dict( - df.unflatten( - logic.tuplize_dict( - logic.parse_params(base.request.params) - ))) + df.unflatten( + logic.tuplize_dict( + logic.parse_params(base.request.params)))) if is_edit: data['id'] = related_id @@ -182,9 +181,8 @@ def _edit_or_new(self, id, related_id, is_edit): else: h.flash_success(_("Related item was successfully updated")) - h.redirect_to(controller='related', - action='list', - id=c.pkg_dict['name']) + h.redirect_to( + controller='related', action='list', id=c.pkg_dict['name']) except df.DataError: base.abort(400, _(u'Integrity Error')) except logic.ValidationError, e: @@ -202,7 +200,6 @@ def _edit_or_new(self, id, related_id, is_edit): return base.render(tpl) def delete(self, id, related_id): - if 'cancel' in base.request.params: h.redirect_to(controller='related', action='edit', id=id, related_id=related_id) @@ -215,7 +212,8 @@ def delete(self, id, related_id): logic.get_action('related_delete')(context, {'id': related_id}) h.flash_notice(_('Related item has been deleted.')) h.redirect_to(controller='package', action='read', id=id) - c.related_dict = logic.get_action('related_show')(context, {'id': related_id}) + c.related_dict = logic.get_action('related_show')( + context, {'id': related_id}) c.pkg_id = id except logic.NotAuthorized: base.abort(401, _('Unauthorized to delete related item %s') % '') From f0088871d8f09c6243901c98ab542e53cf7aa011 Mon Sep 17 00:00:00 2001 From: jbspeakr Date: Fri, 5 Apr 2013 18:18:00 +0200 Subject: [PATCH 138/149] reformat /ckan/ckan/controllers/storage.py --- ckan/controllers/storage.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ckan/controllers/storage.py b/ckan/controllers/storage.py index c3a55248364..2ac00eb3697 100644 --- a/ckan/controllers/storage.py +++ b/ckan/controllers/storage.py @@ -97,7 +97,8 @@ def authorize(method, bucket, key, user, ofs): # now check user stuff context = {'user': c.user, 'model': model} - is_authorized = new_authz.is_authorized_boolean('file_upload', context, {}) + is_authorized = new_authz.is_authorized_boolean( + 'file_upload', context, {}) if not is_authorized: h.flash_error('Not authorized to upload files.') abort(401) @@ -143,9 +144,9 @@ def upload_handle(self): params['uploaded-by'] = c.userobj.name if c.userobj else "" self.ofs.put_stream(bucket_id, label, stream.file, params) - success_action_redirect = h.url_for('storage_upload_success', - qualified=True, - bucket=BUCKET, label=label) + success_action_redirect = h.url_for( + 'storage_upload_success', qualified=True, + bucket=BUCKET, label=label) # Do not redirect here as it breaks js file uploads (get infinite loop # in FF and crash in Chrome) return self.success(label) @@ -186,11 +187,10 @@ def file(self, label): fapp = FileApp(filepath, headers=None, **headers) return fapp(request.environ, self.start_response) else: - h.redirect_to(file_url.encode('ascii','ignore')) + h.redirect_to(file_url.encode('ascii', 'ignore')) class StorageAPIController(BaseController): - _ofs_impl = None @property @@ -270,7 +270,7 @@ def get_metadata(self, label): qualified=False ) if url.startswith('/'): - url = config.get('ckan.site_url','').rstrip('/') + url + url = config.get('ckan.site_url', '').rstrip('/') + url if not self.ofs.exists(bucket, label): abort(404) @@ -306,7 +306,7 @@ def auth_request(self, label): try: data = fix_stupid_pylons_encoding(request.body) headers = json.loads(data) - except Exception, e: + except Exception: from traceback import print_exc msg = StringIO() print_exc(msg) @@ -397,7 +397,7 @@ def auth_form(self, label): try: data = fix_stupid_pylons_encoding(request.body) headers = json.loads(data) - except Exception, e: + except Exception: from traceback import print_exc msg = StringIO() print_exc(msg) From 39b49dfd22e6b678fbdaa6ec2ad612e9afef9a91 Mon Sep 17 00:00:00 2001 From: jbspeakr Date: Fri, 5 Apr 2013 18:18:25 +0200 Subject: [PATCH 139/149] reformat /ckan/ckan/controllers/tag.py --- ckan/controllers/tag.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ckan/controllers/tag.py b/ckan/controllers/tag.py index a736c17e8d0..699d31fceb3 100644 --- a/ckan/controllers/tag.py +++ b/ckan/controllers/tag.py @@ -68,4 +68,5 @@ def read(self, id): if h.asbool(config.get('ckan.legacy_templates', False)): return base.render('tag/read.html') else: - h.redirect_to(controller='package', action='search', tags=c.tag.get('name')) + h.redirect_to(controller='package', action='search', + tags=c.tag.get('name')) From bbb15f9ca6dd256ce178c950f33f6264c1dcd5e6 Mon Sep 17 00:00:00 2001 From: jbspeakr Date: Fri, 5 Apr 2013 18:19:56 +0200 Subject: [PATCH 140/149] reformat /ckan/ckan/controllers/user.py --- ckan/controllers/user.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index 638bd7a7231..d79d68a64e8 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -35,7 +35,6 @@ class UserController(base.BaseController): - def __before__(self, action, **env): base.BaseController.__before__(self, action, **env) try: @@ -238,7 +237,7 @@ def edit(self, id=None, data=None, errors=None, error_summary=None): except NotAuthorized: abort(401, _('Unauthorized to edit user %s') % '') - except NotFound, e: + except NotFound: abort(404, _('User not found')) user_obj = context.get('user_obj') @@ -507,8 +506,7 @@ def activity(self, id, offset=0): return render('user/activity_stream.html') - def _get_dashboard_context(self, filter_type=None, filter_id=None, - q=None): + def _get_dashboard_context(self, filter_type=None, filter_id=None, q=None): '''Return a dict needed by the dashboard view to determine context.''' def display_name(followee): @@ -520,8 +518,10 @@ def display_name(followee): return display_name or fullname or title or name if (filter_type and filter_id): - context = {'model': model, 'session': model.Session, - 'user': c.user or c.author, 'for_view': True} + context = { + 'model': model, 'session': model.Session, + 'user': c.user or c.author, 'for_view': True + } data_dict = {'id': filter_id} followee = None @@ -529,8 +529,9 @@ def display_name(followee): 'dataset': 'package_show', 'user': 'user_show', 'group': 'group_show' - } - action_function = logic.get_action(action_functions.get(filter_type)) + } + action_function = logic.get_action( + action_functions.get(filter_type)) # Is this a valid type? if action_function is None: raise abort(404, _('Follow item not found')) From 4361de6044a366da3c72a7c32542f2a62835f35c Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 12 Apr 2013 11:43:48 +0100 Subject: [PATCH 141/149] [#740] Rebuild css --- ckan/public/base/css/main.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ckan/public/base/css/main.css b/ckan/public/base/css/main.css index b4b5fc9cb69..0ea0e13e359 100644 --- a/ckan/public/base/css/main.css +++ b/ckan/public/base/css/main.css @@ -6513,6 +6513,11 @@ textarea { -moz-border-radius-bottomright: 2px; border-bottom-right-radius: 2px; } +.ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .ckan-icon { *margin-right: .3em; display: inline-block; From 954fa1381e796891e1a098982675c9b7a04dad05 Mon Sep 17 00:00:00 2001 From: John Martin Date: Tue, 16 Apr 2013 11:17:39 +0100 Subject: [PATCH 142/149] [#767] Changes doc title --- doc/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index dc2a7a8bcc5..638e26ae04c 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -44,7 +44,7 @@ master_doc = 'index' # General information about the project. -project = u'CKAN Data Management System Documentation' +project = u'CKAN Documentation' project_short_name = u'CKAN' copyright = u'''© 2009-2012, Open Knowledge Foundation. Licensed under Date: Tue, 16 Apr 2013 16:05:16 +0300 Subject: [PATCH 143/149] Add email to paster user add command in docs Also replace config ini with standard development.ini --- doc/post-installation.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/post-installation.rst b/doc/post-installation.rst index 1c3caf26517..3298560ff34 100644 --- a/doc/post-installation.rst +++ b/doc/post-installation.rst @@ -24,7 +24,7 @@ First create an admin account from the command line (you must be root, ``sudo -s :: - paster --plugin=ckan user add admin --config=/etc/ckan/std/std.ini + paster --plugin=ckan user add admin email=admin@example.com --config=development.ini When prompted, enter a password - this is the password you will use to log in to CKAN. In the resulting output, note that you will also get assigned a CKAN API key. @@ -37,7 +37,7 @@ this: :: - paster --plugin=ckan sysadmin add admin --config=/etc/ckan/std/std.ini + paster --plugin=ckan sysadmin add admin --config=development.ini You can now login to the CKAN frontend with the username ``admin`` and the password you set up. From cafdb7988cf48a9a4f44ef9d5903f7ae07a0e19b Mon Sep 17 00:00:00 2001 From: John Glover Date: Tue, 16 Apr 2013 16:31:18 +0200 Subject: [PATCH 144/149] [#702] Lint and whitespace cleanup. --- .../public/vendor/recline/recline.dataset.js | 16 +-- .../vendor/recline/recline.dataset.min.js | 8 +- .../theme/public/vendor/recline/recline.js | 119 +++++++++--------- .../public/vendor/recline/recline.min.js | 58 ++++----- 4 files changed, 98 insertions(+), 103 deletions(-) diff --git a/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.js b/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.js index 7579c2ecaad..cdd33a0eb92 100644 --- a/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.js +++ b/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.js @@ -55,8 +55,8 @@ my.Dataset = Backbone.Model.extend({ if (this.backend !== recline.Backend.Memory) { this.backend.fetch(this.toJSON()) .done(handleResults) - .fail(function(arguments) { - dfd.reject(arguments); + .fail(function(args) { + dfd.reject(args); }); } else { // special case where we have been given data directly @@ -79,8 +79,8 @@ my.Dataset = Backbone.Model.extend({ .done(function() { dfd.resolve(self); }) - .fail(function(arguments) { - dfd.reject(arguments); + .fail(function(args) { + dfd.reject(args); }); } @@ -198,9 +198,9 @@ my.Dataset = Backbone.Model.extend({ self.trigger('query:done'); dfd.resolve(self.records); }) - .fail(function(arguments) { - self.trigger('query:fail', arguments); - dfd.reject(arguments); + .fail(function(args) { + self.trigger('query:fail', args); + dfd.reject(args); }); return dfd.promise(); }, @@ -450,7 +450,7 @@ my.Field = Backbone.Model.extend({ if (val && typeof val === 'string') { val = val.replace(/(https?:\/\/[^ ]+)/g, '$1'); } - return val + return val; } } } diff --git a/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.min.js b/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.min.js index f3298056220..c09914ac855 100644 --- a/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.min.js +++ b/ckanext/reclinepreview/theme/public/vendor/recline/recline.dataset.min.js @@ -1,7 +1,7 @@ this.recline=this.recline||{};this.recline.Model=this.recline.Model||{};(function(my){var Deferred=_.isUndefined(this.jQuery)?_.Deferred:jQuery.Deferred;my.Dataset=Backbone.Model.extend({constructor:function Dataset(){Backbone.Model.prototype.constructor.apply(this,arguments);},initialize:function(){_.bindAll(this,'query');this.backend=null;if(this.get('backend')){this.backend=this._backendFromString(this.get('backend'));}else{if(this.get('records')){this.backend=recline.Backend.Memory;}} -this.fields=new my.FieldList();this.records=new my.RecordList();this._changes={deletes:[],updates:[],creates:[]};this.facets=new my.FacetList();this.recordCount=null;this.queryState=new my.Query();this.queryState.bind('change',this.query);this.queryState.bind('facet:add',this.query);this._store=this.backend;if(this.backend==recline.Backend.Memory){this.fetch();}},fetch:function(){var self=this;var dfd=new Deferred();if(this.backend!==recline.Backend.Memory){this.backend.fetch(this.toJSON()).done(handleResults).fail(function(arguments){dfd.reject(arguments);});}else{handleResults({records:this.get('records'),fields:this.get('fields'),useMemoryStore:true});} +this.fields=new my.FieldList();this.records=new my.RecordList();this._changes={deletes:[],updates:[],creates:[]};this.facets=new my.FacetList();this.recordCount=null;this.queryState=new my.Query();this.queryState.bind('change',this.query);this.queryState.bind('facet:add',this.query);this._store=this.backend;if(this.backend==recline.Backend.Memory){this.fetch();}},fetch:function(){var self=this;var dfd=new Deferred();if(this.backend!==recline.Backend.Memory){this.backend.fetch(this.toJSON()).done(handleResults).fail(function(args){dfd.reject(args);});}else{handleResults({records:this.get('records'),fields:this.get('fields'),useMemoryStore:true});} function handleResults(results){var out=self._normalizeRecordsAndFields(results.records,results.fields);if(results.useMemoryStore){self._store=new recline.Backend.Memory.Store(out.records,out.fields);} -self.set(results.metadata);self.fields.reset(out.fields);self.query().done(function(){dfd.resolve(self);}).fail(function(arguments){dfd.reject(arguments);});} +self.set(results.metadata);self.fields.reset(out.fields);self.query().done(function(){dfd.resolve(self);}).fail(function(args){dfd.reject(args);});} return dfd.promise();},_normalizeRecordsAndFields:function(records,fields){if(!fields&&records&&records.length>0){if(records[0]instanceof Array){fields=records[0];records=records.slice(1);}else{fields=_.map(_.keys(records[0]),function(key){return{id:key};});}} if(fields&&fields.length>0&&(fields[0]===null||typeof(fields[0])!='object')){var seen={};fields=_.map(fields,function(field,index){if(field===null){field='';}else{field=field.toString();} var fieldId=field.replace(/^\s+|\s+$/g,'');if(fieldId===''){fieldId='_noname_';field=fieldId;} @@ -11,7 +11,7 @@ return{id:fieldId};});} if(records&&records.length>0&&records[0]instanceof Array){records=_.map(records,function(doc){var tmp={};_.each(fields,function(field,idx){tmp[field.id]=doc[idx];});return tmp;});} return{fields:fields,records:records};},save:function(){var self=this;return this._store.save(this._changes,this.toJSON());},transform:function(editFunc){var self=this;if(!this._store.transform){alert('Transform is not supported with this backend: '+this.get('backend'));return;} this.trigger('recline:flash',{message:"Updating all visible docs. This could take a while...",persist:true,loader:true});this._store.transform(editFunc).done(function(){self.query();self.trigger('recline:flash',{message:"Records updated successfully"});});},query:function(queryObj){var self=this;var dfd=new Deferred();this.trigger('query:start');if(queryObj){this.queryState.set(queryObj,{silent:true});} -var actualQuery=this.queryState.toJSON();this._store.query(actualQuery,this.toJSON()).done(function(queryResult){self._handleQueryResult(queryResult);self.trigger('query:done');dfd.resolve(self.records);}).fail(function(arguments){self.trigger('query:fail',arguments);dfd.reject(arguments);});return dfd.promise();},_handleQueryResult:function(queryResult){var self=this;self.recordCount=queryResult.total;var docs=_.map(queryResult.hits,function(hit){var _doc=new my.Record(hit);_doc.fields=self.fields;_doc.bind('change',function(doc){self._changes.updates.push(doc.toJSON());});_doc.bind('destroy',function(doc){self._changes.deletes.push(doc.toJSON());});return _doc;});self.records.reset(docs);if(queryResult.facets){var facets=_.map(queryResult.facets,function(facetResult,facetId){facetResult.id=facetId;return new my.Facet(facetResult);});self.facets.reset(facets);}},toTemplateJSON:function(){var data=this.toJSON();data.recordCount=this.recordCount;data.fields=this.fields.toJSON();return data;},getFieldsSummary:function(){var self=this;var query=new my.Query();query.set({size:0});this.fields.each(function(field){query.addFacet(field.id);});var dfd=new Deferred();this._store.query(query.toJSON(),this.toJSON()).done(function(queryResult){if(queryResult.facets){_.each(queryResult.facets,function(facetResult,facetId){facetResult.id=facetId;var facet=new my.Facet(facetResult);self.fields.get(facetId).facets.reset(facet);});} +var actualQuery=this.queryState.toJSON();this._store.query(actualQuery,this.toJSON()).done(function(queryResult){self._handleQueryResult(queryResult);self.trigger('query:done');dfd.resolve(self.records);}).fail(function(args){self.trigger('query:fail',args);dfd.reject(args);});return dfd.promise();},_handleQueryResult:function(queryResult){var self=this;self.recordCount=queryResult.total;var docs=_.map(queryResult.hits,function(hit){var _doc=new my.Record(hit);_doc.fields=self.fields;_doc.bind('change',function(doc){self._changes.updates.push(doc.toJSON());});_doc.bind('destroy',function(doc){self._changes.deletes.push(doc.toJSON());});return _doc;});self.records.reset(docs);if(queryResult.facets){var facets=_.map(queryResult.facets,function(facetResult,facetId){facetResult.id=facetId;return new my.Facet(facetResult);});self.facets.reset(facets);}},toTemplateJSON:function(){var data=this.toJSON();data.recordCount=this.recordCount;data.fields=this.fields.toJSON();return data;},getFieldsSummary:function(){var self=this;var query=new my.Query();query.set({size:0});this.fields.each(function(field){query.addFacet(field.id);});var dfd=new Deferred();this._store.query(query.toJSON(),this.toJSON()).done(function(queryResult){if(queryResult.facets){_.each(queryResult.facets,function(facetResult,facetId){facetResult.id=facetId;var facet=new my.Facet(facetResult);self.fields.get(facetId).facets.reset(facet);});} dfd.resolve(queryResult);});return dfd.promise();},recordSummary:function(record){return record.summary();},_backendFromString:function(backendString){var backend=null;if(recline&&recline.Backend){_.each(_.keys(recline.Backend),function(name){if(name.toLowerCase()===backendString.toLowerCase()){backend=recline.Backend[name];}});} return backend;}});my.Record=Backbone.Model.extend({constructor:function Record(){Backbone.Model.prototype.constructor.apply(this,arguments);},initialize:function(){_.bindAll(this,'getFieldValue');},getFieldValue:function(field){val=this.getFieldValueUnrendered(field);if(field&&!_.isUndefined(field.renderer)){val=field.renderer(val,field,this.toJSON());} return val;},getFieldValueUnrendered:function(field){if(!field){return'';} @@ -23,7 +23,7 @@ if(options){this.renderer=options.renderer;this.deriver=options.deriver;} if(!this.renderer){this.renderer=this.defaultRenderers[this.get('type')];} this.facets=new my.FacetList();},_typeMap:{'text':'string','double':'number','float':'number','numeric':'number','int':'integer','datetime':'date-time','bool':'boolean','timestamp':'date-time','json':'object'},defaultRenderers:{object:function(val,field,doc){return JSON.stringify(val);},geo_point:function(val,field,doc){return JSON.stringify(val);},'number':function(val,field,doc){var format=field.get('format');if(format==='percentage'){return val+'%';} return val;},'string':function(val,field,doc){var format=field.get('format');if(format==='markdown'){if(typeof Showdown!=='undefined'){var showdown=new Showdown.converter();out=showdown.makeHtml(val);return out;}else{return val;}}else if(format=='plain'){return val;}else{if(val&&typeof val==='string'){val=val.replace(/(https?:\/\/[^ ]+)/g,'$1');} -return val}}}});my.FieldList=Backbone.Collection.extend({constructor:function FieldList(){Backbone.Collection.prototype.constructor.apply(this,arguments);},model:my.Field});my.Query=Backbone.Model.extend({constructor:function Query(){Backbone.Model.prototype.constructor.apply(this,arguments);},defaults:function(){return{size:100,from:0,q:'',facets:{},filters:[]};},_filterTemplates:{term:{type:'term',field:'',term:''},range:{type:'range',start:'',stop:''},geo_distance:{type:'geo_distance',distance:10,unit:'km',point:{lon:0,lat:0}}},addFilter:function(filter){var ourfilter=JSON.parse(JSON.stringify(filter));if(_.keys(filter).length<=3){ourfilter=_.defaults(ourfilter,this._filterTemplates[filter.type]);} +return val;}}}});my.FieldList=Backbone.Collection.extend({constructor:function FieldList(){Backbone.Collection.prototype.constructor.apply(this,arguments);},model:my.Field});my.Query=Backbone.Model.extend({constructor:function Query(){Backbone.Model.prototype.constructor.apply(this,arguments);},defaults:function(){return{size:100,from:0,q:'',facets:{},filters:[]};},_filterTemplates:{term:{type:'term',field:'',term:''},range:{type:'range',start:'',stop:''},geo_distance:{type:'geo_distance',distance:10,unit:'km',point:{lon:0,lat:0}}},addFilter:function(filter){var ourfilter=JSON.parse(JSON.stringify(filter));if(_.keys(filter).length<=3){ourfilter=_.defaults(ourfilter,this._filterTemplates[filter.type]);} var filters=this.get('filters');filters.push(ourfilter);this.trigger('change:filters:new-blank');},updateFilter:function(index,value){},removeFilter:function(filterIndex){var filters=this.get('filters');filters.splice(filterIndex,1);this.set({filters:filters});this.trigger('change');},addFacet:function(fieldId){var facets=this.get('facets');if(_.contains(_.keys(facets),fieldId)){return;} facets[fieldId]={terms:{field:fieldId}};this.set({facets:facets},{silent:true});this.trigger('facet:add',this);},addHistogramFacet:function(fieldId){var facets=this.get('facets');facets[fieldId]={date_histogram:{field:fieldId,interval:'day'}};this.set({facets:facets},{silent:true});this.trigger('facet:add',this);}});my.Facet=Backbone.Model.extend({constructor:function Facet(){Backbone.Model.prototype.constructor.apply(this,arguments);},defaults:function(){return{_type:'terms',total:0,other:0,missing:0,terms:[]};}});my.FacetList=Backbone.Collection.extend({constructor:function FacetList(){Backbone.Collection.prototype.constructor.apply(this,arguments);},model:my.Facet});my.ObjectState=Backbone.Model.extend({});Backbone.sync=function(method,model,options){return model.backend.sync(method,model,options);};}(this.recline.Model));this.recline=this.recline||{};this.recline.Backend=this.recline.Backend||{};this.recline.Backend.Memory=this.recline.Backend.Memory||{};(function(my){my.__type__='memory';var Deferred=_.isUndefined(this.jQuery)?_.Deferred:jQuery.Deferred;my.Store=function(records,fields){var self=this;this.records=records;this.data=this.records;if(fields){this.fields=fields;}else{if(records){this.fields=_.map(records[0],function(value,key){return{id:key,type:'string'};});}} this.update=function(doc){_.each(self.records,function(internalDoc,idx){if(doc.id===internalDoc.id){self.records[idx]=doc;}});};this.remove=function(doc){var newdocs=_.reject(self.records,function(internalDoc){return(doc.id===internalDoc.id);});this.records=newdocs;};this.save=function(changes,dataset){var self=this;var dfd=new Deferred();_.each(changes.updates,function(record){self.update(record);});_.each(changes.deletes,function(record){self.remove(record);});dfd.resolve();return dfd.promise();},this.query=function(queryObj){var dfd=new Deferred();var numRows=queryObj.size||this.records.length;var start=queryObj.from||0;var results=this.records;results=this._applyFilters(results,queryObj);results=this._applyFreeTextQuery(results,queryObj);_.each(queryObj.sort,function(sortObj){var fieldName=sortObj.field;results=_.sortBy(results,function(doc){var _out=doc[fieldName];return _out;});if(sortObj.order=='desc'){results.reverse();}});var facets=this.computeFacets(results,queryObj);var out={total:results.length,hits:results.slice(start,start+numRows),facets:facets};dfd.resolve(out);return dfd.promise();};this._applyFilters=function(results,queryObj){var filters=queryObj.filters;var filterFunctions={term:term,range:range,geo_distance:geo_distance};var dataParsers={integer:function(e){return parseFloat(e,10);},'float':function(e){return parseFloat(e,10);},number:function(e){return parseFloat(e,10);},string:function(e){return e.toString()},date:function(e){return new Date(e).valueOf()},datetime:function(e){return new Date(e).valueOf()}};var keyedFields={};_.each(self.fields,function(field){keyedFields[field.id]=field;});function getDataParser(filter){var fieldType=keyedFields[filter.field].type||'string';return dataParsers[fieldType];} diff --git a/ckanext/reclinepreview/theme/public/vendor/recline/recline.js b/ckanext/reclinepreview/theme/public/vendor/recline/recline.js index f6bc1f46b79..89258b2353e 100644 --- a/ckanext/reclinepreview/theme/public/vendor/recline/recline.js +++ b/ckanext/reclinepreview/theme/public/vendor/recline/recline.js @@ -37,12 +37,13 @@ this.recline.Backend.Ckan = this.recline.Backend.Ckan || {}; // ### fetch my.fetch = function(dataset) { + var wrapper; if (dataset.endpoint) { - var wrapper = my.DataStore(dataset.endpoint); + wrapper = my.DataStore(dataset.endpoint); } else { var out = my._parseCkanResourceUrl(dataset.url); dataset.id = out.resource_id; - var wrapper = my.DataStore(out.endpoint); + wrapper = my.DataStore(out.endpoint); } var dfd = new Deferred(); var jqxhr = wrapper.search({resource_id: dataset.id, limit: 0}); @@ -89,12 +90,13 @@ this.recline.Backend.Ckan = this.recline.Backend.Ckan || {}; }; my.query = function(queryObj, dataset) { + var wrapper; if (dataset.endpoint) { - var wrapper = my.DataStore(dataset.endpoint); + wrapper = my.DataStore(dataset.endpoint); } else { var out = my._parseCkanResourceUrl(dataset.url); dataset.id = out.resource_id; - var wrapper = my.DataStore(out.endpoint); + wrapper = my.DataStore(out.endpoint); } var actualQuery = my._normalizeQuery(queryObj, dataset); var dfd = new Deferred(); @@ -485,8 +487,8 @@ this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {}; useMemoryStore: true }); }) - .fail(function(arguments) { - dfd.reject(arguments); + .fail(function(args) { + dfd.reject(args); }); return dfd.promise(); }; @@ -503,17 +505,17 @@ this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {}; message: 'Request Error: Backend did not respond after ' + (my.timeout / 1000) + ' seconds' }); }, my.timeout); - ourFunction.done(function(arguments) { + ourFunction.done(function(args) { clearTimeout(timer); - dfd.resolve(arguments); + dfd.resolve(args); }) - .fail(function(arguments) { + .fail(function(args) { clearTimeout(timer); - dfd.reject(arguments); + dfd.reject(args); }) ; return dfd.promise(); - } + }; }(this.recline.Backend.DataProxy)); this.recline = this.recline || {}; @@ -1405,8 +1407,8 @@ my.Dataset = Backbone.Model.extend({ if (this.backend !== recline.Backend.Memory) { this.backend.fetch(this.toJSON()) .done(handleResults) - .fail(function(arguments) { - dfd.reject(arguments); + .fail(function(args) { + dfd.reject(args); }); } else { // special case where we have been given data directly @@ -1429,8 +1431,8 @@ my.Dataset = Backbone.Model.extend({ .done(function() { dfd.resolve(self); }) - .fail(function(arguments) { - dfd.reject(arguments); + .fail(function(args) { + dfd.reject(args); }); } @@ -1548,9 +1550,9 @@ my.Dataset = Backbone.Model.extend({ self.trigger('query:done'); dfd.resolve(self.records); }) - .fail(function(arguments) { - self.trigger('query:fail', arguments); - dfd.reject(arguments); + .fail(function(args) { + self.trigger('query:fail', args); + dfd.reject(args); }); return dfd.promise(); }, @@ -1800,7 +1802,7 @@ my.Field = Backbone.Model.extend({ if (val && typeof val === 'string') { val = val.replace(/(https?:\/\/[^ ]+)/g, '$1'); } - return val + return val; } } } @@ -2157,13 +2159,13 @@ my.Flot = Backbone.View.extend({ // for labels case we only want ticks at the label intervals // HACK: however we also get this case with Date fields. In that case we - // could have a lot of values and so we limit to max 30 (we assume) + // could have a lot of values and so we limit to max 15 (we assume) if (this.xvaluesAreIndex) { var numTicks = Math.min(this.model.records.length, 15); var increment = this.model.records.length / numTicks; var ticks = []; for (i=0; i= 0; i--){ - if (_.indexOf(_.pluck(visibleColumns,'id'),columns[i].id) == -1){ + if (_.indexOf(_.pluck(visibleColumns,'id'),columns[i].id) === -1){ tempHiddenColumns.push(columns.splice(i,1)[0]); } } @@ -4556,18 +4558,18 @@ my.SlickGrid = Backbone.View.extend({ this.push = function(model, row) { models.push(model); rows.push(row); - } + }; - this.getLength = function() { return rows.length; } - this.getItem = function(index) { return rows[index];} - this.getItemMetadata= function(index) { return {};} - this.getModel= function(index) { return models[index]; } - this.getModelRow = function(m) { return models.indexOf(m);} - this.updateItem = function(m,i) { + this.getLength = function() {return rows.length; }; + this.getItem = function(index) {return rows[index];}; + this.getItemMetadata = function(index) {return {};}; + this.getModel = function(index) {return models[index];}; + this.getModelRow = function(m) {return models.indexOf(m);}; + this.updateItem = function(m,i) { rows[i] = toRow(m); - models[i] = m + models[i] = m; }; - }; + } var data = new RowSet(); @@ -4581,7 +4583,7 @@ my.SlickGrid = Backbone.View.extend({ var sortInfo = this.model.queryState.get('sort'); if (sortInfo){ var column = sortInfo[0].field; - var sortAsc = !(sortInfo[0].order == 'desc'); + var sortAsc = sortInfo[0].order !== 'desc'; this.grid.setSortColumn(column, sortAsc); } @@ -4615,7 +4617,7 @@ my.SlickGrid = Backbone.View.extend({ // var grid = args.grid; var model = data.getModel(args.row); - var field = grid.getColumns()[args.cell]['id']; + var field = grid.getColumns()[args.cell].id; var v = {}; v[field] = args.item[field]; model.set(v); @@ -4676,7 +4678,7 @@ my.SlickGrid = Backbone.View.extend({ $menu = $('