diff --git a/ckan/__init__.py b/ckan/__init__.py index b4333195546..b1b15441a63 100644 --- a/ckan/__init__.py +++ b/ckan/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.6.1b' +__version__ = '1.8a' __description__ = 'Comprehensive Knowledge Archive Network (CKAN) Software' __long_description__ = \ '''CKAN software provides a hub for datasets. The flagship site running CKAN diff --git a/ckan/config/solr/CHANGELOG.txt b/ckan/config/solr/CHANGELOG.txt index 5fe664fc8f8..1e4e67f6ff6 100644 --- a/ckan/config/solr/CHANGELOG.txt +++ b/ckan/config/solr/CHANGELOG.txt @@ -1,6 +1,14 @@ CKAN SOLR schemas changelog =========================== +v1.4 - (ckan>=1.7) +-------------------- +* Add Ascii folding filter to text fields. +* Add capacity field for public, private access. +* Add title_string so you can sort alphabetically on title. +* Fields related to analytics, access and view counts. +* Add data_dict field for the whole package_dict. + v1.3 - (ckan>=1.5.1) -------------------- * Use the index_id (hash of dataset id + site_id) as uniqueKey (#1430) diff --git a/ckan/config/solr/schema-1.4.xml b/ckan/config/solr/schema-1.4.xml index 29cb4737287..0409e71b14b 100644 --- a/ckan/config/solr/schema-1.4.xml +++ b/ckan/config/solr/schema-1.4.xml @@ -51,6 +51,7 @@ + @@ -63,6 +64,7 @@ + @@ -115,6 +117,8 @@ + + @@ -134,8 +138,8 @@ - - + + @@ -144,8 +148,9 @@ - - + + + diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index ba12b869761..9e99e0fae3c 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -480,6 +480,7 @@ def edit(self, id, data=None, errors=None, error_summary=None): c.errors_json = json.dumps(errors) self._setup_template_variables(context, {'id': id}, package_type=package_type) + c.related_count = len(c.pkg.related) # TODO: This check is to maintain backwards compatibility with the old way of creating # custom forms. This behaviour is now deprecated. @@ -749,6 +750,8 @@ def resource_read(self, id, resource_id): c.package['isopen'] = False c.datastore_api = h.url_for('datastore_read', id=c.resource.get('id'), qualified=True) + + c.related_count = len(c.pkg.related) return render('package/resource_read.html') def resource_embedded_dataviewer(self, id, resource_id): diff --git a/ckan/lib/alphabet_paginate.py b/ckan/lib/alphabet_paginate.py index 0afa88f3ff1..b75bf27e83b 100644 --- a/ckan/lib/alphabet_paginate.py +++ b/ckan/lib/alphabet_paginate.py @@ -1,6 +1,7 @@ ''' -Based on webhelpers.paginator, but each page is for items beginning - with a particular letter. +Based on webhelpers.paginator, but: + * each page is for items beginning with a particular letter + * output is suitable for Bootstrap Example: c.page = h.Page( @@ -43,7 +44,12 @@ def __init__(self, collection, alpha_attribute, page, other_text, paging_thresho self.other_text = other_text self.paging_threshold = paging_threshold self.controller_name = controller_name - self.available = dict( (c,0,) for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ) + + self.letters = [char for char in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'] + [self.other_text] + + # Work out which alphabet letters are 'available' i.e. have some results + # because we grey-out those which aren't. + self.available = dict( (c,0,) for c in self.letters ) for c in self.collection: if isinstance(c, unicode): x = c[0] @@ -51,35 +57,42 @@ def __init__(self, collection, alpha_attribute, page, other_text, paging_thresho x = c[self.alpha_attribute][0] else: x = getattr(c, self.alpha_attribute)[0] + x = x.upper() + if x not in self.letters: + x = self.other_text self.available[x] = self.available.get(x, 0) + 1 def pager(self, q=None): '''Returns pager html - for navigating between the pages. e.g. Something like this: -
- A - B - C + ''' if self.item_count < self.paging_threshold: return '' pages = [] page = q or self.page - letters = [char for char in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'] + [self.other_text] - for letter in letters: + for letter in self.letters: + href = url_for(controller=self.controller_name, action='index', page=letter) + link = HTML.a(href=href, c=letter) if letter != page: if self.available.get(letter, 0): - page_element = HTML.a(class_='pager_link', href=url_for(controller=self.controller_name, action='index', page=letter),c=letter) + li_class = '' else: - page_element = HTML.span(class_="pager_empty", c=letter) + li_class = 'disabled' else: - page_element = HTML.span(class_='pager_curpage', c=letter) + li_class = 'active' + attributes = {'class_': li_class} if li_class else {} + page_element = HTML.li(link, **attributes) pages.append(page_element) - div = HTML.tag('div', class_='pager', *pages) + ul = HTML.tag('ul', *pages) + div = HTML.div(ul, class_='pagination pagination-alphabet') return div diff --git a/ckan/lib/search/__init__.py b/ckan/lib/search/__init__.py index 1ed19a2e739..0731d0e54b9 100644 --- a/ckan/lib/search/__init__.py +++ b/ckan/lib/search/__init__.py @@ -26,7 +26,7 @@ def text_traceback(): SIMPLE_SEARCH = config.get('ckan.simple_search', False) -SUPPORTED_SCHEMA_VERSIONS = ['1.3'] +SUPPORTED_SCHEMA_VERSIONS = ['1.4'] DEFAULT_OPTIONS = { 'limit': 20, diff --git a/ckan/lib/search/index.py b/ckan/lib/search/index.py index 086a39ed79d..992721fb1d3 100644 --- a/ckan/lib/search/index.py +++ b/ckan/lib/search/index.py @@ -99,6 +99,11 @@ def index_package(self, pkg_dict): if pkg_dict is None: return + # add to string field for sorting + title = pkg_dict.get('title') + if title: + pkg_dict['title_string'] = title + if (not pkg_dict.get('state')) or ('active' not in pkg_dict.get('state')): return self.delete_package(pkg_dict) @@ -163,7 +168,7 @@ def index_package(self, pkg_dict): pkg_dict = dict([(k.encode('ascii', 'ignore'), v) for (k, v) in pkg_dict.items()]) - for k in ('title','notes'): + for k in ('title', 'notes', 'title_string'): if k in pkg_dict and pkg_dict[k]: pkg_dict[k] = escape_xml_illegal_chars(pkg_dict[k]) diff --git a/ckan/logic/auth/delete.py b/ckan/logic/auth/delete.py index c99bec37bc5..c32404942aa 100644 --- a/ckan/logic/auth/delete.py +++ b/ckan/logic/auth/delete.py @@ -27,6 +27,15 @@ def related_delete(context, data_dict): related = get_related_object(context, data_dict) userobj = model.User.get( user ) + + if related.datasets: + package = related.datasets[0] + + pkg_dict = { 'id': package.id } + authorized = package_delete(context, pkg_dict).get('success') + if authorized: + return {'success': True} + if not userobj or userobj.id != related.owner_id: return {'success': False, 'msg': _('Only the owner can delete a related item')} diff --git a/ckan/logic/auth/publisher/delete.py b/ckan/logic/auth/publisher/delete.py index 9d3388f2340..0aaa0d9b6b5 100644 --- a/ckan/logic/auth/publisher/delete.py +++ b/ckan/logic/auth/publisher/delete.py @@ -40,6 +40,12 @@ def related_delete(context, data_dict): related = get_related_object(context, data_dict) userobj = model.User.get( user ) + + if related.datasets: + package = related.datasets[0] + if _groups_intersect( userobj.get_groups('organization'), package.get_groups('organization') ): + return {'success': True} + if not userobj or userobj.id != related.owner_id: return {'success': False, 'msg': _('Only the owner can delete a related item')} diff --git a/ckan/plugins/core.py b/ckan/plugins/core.py index 5260164f2a0..1fee98a10f3 100644 --- a/ckan/plugins/core.py +++ b/ckan/plugins/core.py @@ -6,11 +6,10 @@ from inspect import isclass from itertools import chain from pkg_resources import iter_entry_points -from pyutilib.component.core import PluginGlobals, ExtensionPoint as PluginImplementations, implements +from pyutilib.component.core import PluginGlobals, implements +from pyutilib.component.core import ExtensionPoint as PluginImplementations from pyutilib.component.core import SingletonPlugin as _pca_SingletonPlugin from pyutilib.component.core import Plugin as _pca_Plugin -from pyutilib.component.core import PluginEnvironment -from sqlalchemy.orm.interfaces import MapperExtension from ckan.plugins.interfaces import IPluginObserver @@ -23,18 +22,20 @@ log = logging.getLogger(__name__) -# Entry point group. +# Entry point group. PLUGINS_ENTRY_POINT_GROUP = "ckan.plugins" # Entry point group for system plugins (those that are part of core ckan and do # not need to be explicitly enabled by the user) SYSTEM_PLUGINS_ENTRY_POINT_GROUP = "ckan.system_plugins" + class PluginNotFoundException(Exception): """ Raised when a requested plugin cannot be found. """ + class Plugin(_pca_Plugin): """ Base class for plugins which require multiple instances. @@ -43,6 +44,7 @@ class Plugin(_pca_Plugin): probably use SingletonPlugin. """ + class SingletonPlugin(_pca_SingletonPlugin): """ Base class for plugins which are singletons (ie most of them) @@ -52,6 +54,7 @@ class SingletonPlugin(_pca_SingletonPlugin): same singleton instance. """ + def _get_service(plugin): """ Return a service (ie an instance of a plugin class). @@ -100,6 +103,7 @@ def load_all(config): for plugin in plugins: load(plugin) + def reset(): """ Clear and reload all configured plugins @@ -107,6 +111,7 @@ def reset(): from pylons import config load_all(config) + def load(plugin): """ Load a single plugin, given a plugin name, class or instance @@ -120,6 +125,7 @@ def load(plugin): observer_plugin.after_load(service) return service + def unload_all(): """ Unload (deactivate) all loaded plugins @@ -128,6 +134,7 @@ def unload_all(): for service in env.services.copy(): unload(service) + def unload(plugin): """ Unload a single plugin, given a plugin name, class or instance @@ -144,6 +151,7 @@ def unload(plugin): return service + def find_user_plugins(config): """ Return all plugins specified by the user in the 'ckan.plugins' config @@ -159,10 +167,11 @@ def find_user_plugins(config): plugins.extend(ep.load() for ep in entry_points) return plugins + def find_system_plugins(): """ Return all plugins in the ckan.system_plugins entry point group. - + These are essential for operation and therefore cannot be enabled/disabled through the configuration file. """ @@ -170,4 +179,3 @@ def find_system_plugins(): ep.load() for ep in iter_entry_points(group=SYSTEM_PLUGINS_ENTRY_POINT_GROUP) ) - diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index bc2eb889f60..4ca74f27894 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -21,6 +21,7 @@ from inspect import isclass from pyutilib.component.core import Interface as _pca_Interface + class Interface(_pca_Interface): @classmethod @@ -80,13 +81,15 @@ def before_map(self, map): def after_map(self, map): """ - Called after routes map is set up. ``after_map`` can be used to add fall-back handlers. + Called after routes map is set up. ``after_map`` can be used to + add fall-back handlers. :param map: Routes map object :returns: Modified version of the map object """ return map + class IMapper(Interface): """ A subset of the SQLAlchemy mapper extension hooks. @@ -104,7 +107,8 @@ class IMapper(Interface): def before_insert(self, mapper, connection, instance): """ - Receive an object instance before that instance is INSERTed into its table. + Receive an object instance before that instance is INSERTed into + its table. """ def before_update(self, mapper, connection, instance): @@ -132,6 +136,7 @@ def after_delete(self, mapper, connection, instance): Receive an object instance after that instance is DELETEed. """ + class ISession(Interface): """ A subset of the SQLAlchemy session extension hooks. @@ -167,6 +172,7 @@ def after_rollback(self, session): Execute after a rollback has occured. """ + class IDomainObjectModification(Interface): """ Receives notification of new, changed and deleted datesets. @@ -175,6 +181,7 @@ class IDomainObjectModification(Interface): def notify(self, entity, operation): pass + class IResourceUrlChange(Interface): """ Receives notification of changed urls. @@ -183,6 +190,7 @@ class IResourceUrlChange(Interface): def notify(self, resource): pass + class ITagController(Interface): ''' Hook into the Tag controller. These will usually be called just before @@ -198,6 +206,7 @@ def before_view(self, tag_dict): ''' return tag_dict + class IGroupController(Interface): """ Hook into the Group controller. These will @@ -226,11 +235,13 @@ def delete(self, entity): def before_view(self, pkg_dict): ''' - Extensions will recieve this before the group gets displayed. The dictionary - passed will be the one that gets sent to the template. + Extensions will recieve this before the group gets + displayed. The dictionary passed will be the one that gets + sent to the template. ''' return pkg_dict + class IPackageController(Interface): """ Hook into the package controller. @@ -288,17 +299,19 @@ def after_search(self, search_results, search_params): def before_index(self, pkg_dict): ''' - Extensions will receive what will be given to the solr for indexing. - This is essentially a flattened dict (except for multlivlaued fields such as tags - of all the terms sent to the indexer. The extension can modify this by returning - an altered version. + Extensions will receive what will be given to the solr for + indexing. This is essentially a flattened dict (except for + multli-valued fields such as tags) of all the terms sent to + the indexer. The extension can modify this by returning an + altered version. ''' return pkg_dict def before_view(self, pkg_dict): ''' - Extensions will recieve this before the dataset gets displayed. The dictionary - passed will be the one that gets sent to the template. + Extensions will recieve this before the dataset gets + displayed. The dictionary passed will be the one that gets + sent to the template. ''' return pkg_dict @@ -332,6 +345,7 @@ def after_unload(self, service): This method is passed the instantiated service object. """ + class IConfigurable(Interface): """ Pass configuration to plugins and extensions @@ -342,6 +356,7 @@ def configure(self, config): Called by load_environment """ + class IConfigurer(Interface): """ Configure CKAN (pylons) environment via the ``pylons.config`` object @@ -382,6 +397,7 @@ def is_authorized(self, username, action, domain_obj): other Authorizers to run; True will shortcircuit and return. """ + class IActions(Interface): """ Allow adding of actions to the logic layer. @@ -392,6 +408,7 @@ def get_actions(self): function and the values being the functions themselves. """ + class IAuthFunctions(Interface): """ Allow customisation of default Authorization implementation @@ -402,6 +419,7 @@ def get_auth_functions(self): implementation overrides """ + class ITemplateHelpers(Interface): """ Allow adding extra template functions available via h variable @@ -412,6 +430,7 @@ def get_helpers(self): function and the values being the functions themselves. """ + class IDatasetForm(Interface): """ Allows customisation of the package controller as a plugin. @@ -499,7 +518,6 @@ def history_template(self): rendered for the history page """ - def package_form(self): """ Returns a string representing the location of the template to be @@ -616,8 +634,6 @@ def history_template(self): rendered for the history page """ - - def package_form(self): """ Returns a string representing the location of the template to be @@ -649,4 +665,3 @@ def setup_template_variables(self, context, data_dict): """ ##### End of hooks ##### - diff --git a/ckan/plugins/toolkit.py b/ckan/plugins/toolkit.py index fa1f2c38cb0..120c0ac372d 100644 --- a/ckan/plugins/toolkit.py +++ b/ckan/plugins/toolkit.py @@ -8,10 +8,12 @@ __all__ = ['toolkit'] + class CkanVersionException(Exception): ''' Exception raised if required ckan version is not available. ''' pass + class _Toolkit(object): '''This class is intended to make functions/objects consistently available to plugins, whilst giving developers the ability move @@ -40,7 +42,8 @@ class _Toolkit(object): 'literal', # stop tags in a string being escaped 'get_action', # get logic action function 'check_access', # check logic function authorisation - 'ObjectNotFound', # action not found exception (ckan.logic.NotFound) + 'ObjectNotFound', # action not found exception + # (ckan.logic.NotFound) 'NotAuthorized', # action not authorized exception 'ValidationError', # model update validation error 'CkanCommand', # class for providing cli interfaces @@ -53,7 +56,6 @@ class _Toolkit(object): 'CkanVersionException', ] - def __init__(self): self._toolkit = {} @@ -85,7 +87,7 @@ def _initialize(self): t['get_action'] = logic.get_action t['check_access'] = logic.check_access - t['ObjectNotFound'] = logic.NotFound ## Name change intentional + t['ObjectNotFound'] = logic.NotFound # Name change intentional t['NotAuthorized'] = logic.NotAuthorized t['ValidationError'] = logic.ValidationError @@ -117,7 +119,8 @@ def _render_snippet(cls, template, data=None): def _add_template_directory(cls, config, relative_path): ''' Function to aid adding extra template paths to the config. The path is relative to the file calling this function. ''' - cls._add_served_directory(config, relative_path, 'extra_template_paths') + cls._add_served_directory(config, relative_path, + 'extra_template_paths') @classmethod def _add_public_directory(cls, config, relative_path): diff --git a/ckan/public/css/style.css b/ckan/public/css/style.css index 7e9e6093173..283f5d376a7 100644 --- a/ckan/public/css/style.css +++ b/ckan/public/css/style.css @@ -379,28 +379,8 @@ ul.no-break li { /* ============== */ /* = Pagination = */ /* ============== */ -.pager { - width: 100%; - text-align: center; - margin: 0 0 1.2em 0; - clear: both; -} -.pager span, .pager a { - text-decoration: none; - margin: 0em; - border: none; - padding: 0.3em 0.1em; -} -.pager a:hover, .pager a:active { - color: #fff; - background-color: #c22; -} -.pager span.pager_dotdot { - color: #aaa; -} -.pager span.pager_curpage { - font-weight: bold; - border: 1px solid #ddd; +.pagination-alphabet a { + padding: 0 6px; } /* ====== */ diff --git a/ckan/public/scripts/vendor/recline/css/graph.css b/ckan/public/scripts/vendor/recline/css/graph.css index 88acf5f89b2..413ac14e7af 100644 --- a/ckan/public/scripts/vendor/recline/css/graph.css +++ b/ckan/public/scripts/vendor/recline/css/graph.css @@ -13,6 +13,11 @@ line-height: 13px; } +.recline-graph .graph .alert { + width: 450px; + margin: auto; +} + /********************************************************** * Editor *********************************************************/ diff --git a/ckan/public/scripts/vendor/recline/recline.js b/ckan/public/scripts/vendor/recline/recline.js index a4c01caab6c..271e9c54fb9 100644 --- a/ckan/public/scripts/vendor/recline/recline.js +++ b/ckan/public/scripts/vendor/recline/recline.js @@ -757,22 +757,13 @@ my.Graph = Backbone.View.extend({ \
\ \
\
\ -
\ - \ -
\ - \ -
\ -
\
\
\
\ @@ -784,13 +775,34 @@ my.Graph = Backbone.View.extend({
\ \ \ -
\ +
\ +
\ +

Hey there!

\ +

There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.

\ +

Please tell us by using the menu on the right and a graph will automatically appear.

\ +
\ +
\ \ ', + templateSeriesEditor: ' \ +
\ + \ +
\ + \ +
\ +
\ + ', events: { 'change form select': 'onEditorSubmit', - 'click .editor-add': 'addSeries', + 'click .editor-add': '_onAddSeries', 'click .action-remove-series': 'removeSeries', 'click .action-toggle-help': 'toggleHelp' }, @@ -807,7 +819,8 @@ my.Graph = Backbone.View.extend({ this.model.currentDocuments.bind('reset', this.redraw); var stateData = _.extend({ group: null, - series: [], + // so that at least one series chooser box shows up + series: [""], graphType: 'lines-and-points' }, options.state @@ -817,21 +830,45 @@ my.Graph = Backbone.View.extend({ }, render: function() { - htmls = $.mustache(this.template, this.model.toTemplateJSON()); + var self = this; + var tmplData = this.model.toTemplateJSON(); + var htmls = $.mustache(this.template, tmplData); $(this.el).html(htmls); - // now set a load of stuff up this.$graph = this.el.find('.panel.graph'); - // for use later when adding additional series - // could be simpler just to have a common template! - this.$seriesClone = this.el.find('.editor-series').clone(); - this._updateSeries(); + + // set up editor from state + if (this.state.get('graphType')) { + this._selectOption('.editor-type', this.state.get('graphType')); + } + if (this.state.get('group')) { + this._selectOption('.editor-group', this.state.get('group')); + } + _.each(this.state.get('series'), function(series, idx) { + self.addSeries(idx); + self._selectOption('.editor-series.js-series-' + idx, series); + }); return this; }, + // Private: Helper function to select an option from a select list + // + _selectOption: function(id,value){ + var options = this.el.find(id + ' select > option'); + if (options) { + options.each(function(opt){ + if (this.value == value) { + $(this).attr('selected','selected'); + return false; + } + }); + } + }, + onEditorSubmit: function(e) { var select = this.el.find('.editor-group select'); - $editor = this; - var series = this.$series.map(function () { + var $editor = this; + var $series = this.el.find('.editor-series select'); + var series = $series.map(function () { return $(this).val(); }); var updatedState = { @@ -870,10 +907,20 @@ my.Graph = Backbone.View.extend({ // } }, + // ### getGraphOptions + // + // Get options for Flot Graph + // // needs to be function as can depend on state + // + // @param typeId graphType id (lines, lines-and-points etc) getGraphOptions: function(typeId) { var self = this; // special tickformatter to show labels rather than numbers + // TODO: we should really use tickFormatter and 1 interval ticks if (and + // only if) x-axis values are non-numeric + // However, that is non-trivial to work out from a dataset (datasets may + // have no field type info). Thus at present we only do this for bars. var tickFormatter = function (val) { if (self.model.currentDocuments.models[val]) { var out = self.model.currentDocuments.models[val].get(self.state.attributes.group); @@ -886,20 +933,25 @@ my.Graph = Backbone.View.extend({ } return val; }; - // TODO: we should really use tickFormatter and 1 interval ticks if (and - // only if) x-axis values are non-numeric - // However, that is non-trivial to work out from a dataset (datasets may - // have no field type info). Thus at present we only do this for bars. - var options = { + + var xaxis = {}; + // check for time series on x-axis + if (this.model.fields.get(this.state.get('group')).get('type') === 'date') { + xaxis.mode = 'time'; + xaxis.timeformat = '%y-%b'; + } + var optionsPerGraphType = { lines: { - series: { - lines: { show: true } - } + series: { + lines: { show: true } + }, + xaxis: xaxis }, points: { series: { points: { show: true } }, + xaxis: xaxis, grid: { hoverable: true, clickable: true } }, 'lines-and-points': { @@ -907,6 +959,7 @@ my.Graph = Backbone.View.extend({ points: { show: true }, lines: { show: true } }, + xaxis: xaxis, grid: { hoverable: true, clickable: true } }, bars: { @@ -930,7 +983,7 @@ my.Graph = Backbone.View.extend({ } } }; - return options[typeId]; + return optionsPerGraphType[typeId]; }, setupTooltips: function() { @@ -987,8 +1040,15 @@ my.Graph = Backbone.View.extend({ _.each(this.state.attributes.series, function(field) { var points = []; _.each(self.model.currentDocuments.models, function(doc, index) { - var x = doc.get(self.state.attributes.group); - var y = doc.get(field); + var xfield = self.model.fields.get(self.state.attributes.group); + var x = doc.getFieldValue(xfield); + // time series + var isDateTime = xfield.get('type') === 'date'; + if (isDateTime) { + x = new Date(x); + } + var yfield = self.model.fields.get(field); + var y = doc.getFieldValue(yfield); if (typeof x === 'string') { x = index; } @@ -1006,23 +1066,25 @@ my.Graph = Backbone.View.extend({ // Public: Adds a new empty series select box to the editor. // - // All but the first select box will have a remove button that allows them - // to be removed. + // @param [int] idx index of this series in the list of series // // Returns itself. - addSeries: function (e) { - e.preventDefault(); - var element = this.$seriesClone.clone(), - label = element.find('label'), - index = this.$series.length; - - this.el.find('.editor-series-group').append(element); - this._updateSeries(); - label.append(' [Remove]'); - label.find('span').text(String.fromCharCode(this.$series.length + 64)); + addSeries: function (idx) { + var data = _.extend({ + seriesIndex: idx, + seriesName: String.fromCharCode(idx + 64 + 1), + }, this.model.toTemplateJSON()); + + var htmls = $.mustache(this.templateSeriesEditor, data); + this.el.find('.editor-series-group').append(htmls); return this; }, + _onAddSeries: function(e) { + e.preventDefault(); + this.addSeries(this.state.get('series').length); + }, + // Public: Removes a series list item from the editor. // // Also updates the labels of the remaining series elements. @@ -1030,26 +1092,12 @@ my.Graph = Backbone.View.extend({ e.preventDefault(); var $el = $(e.target); $el.parent().parent().remove(); - this._updateSeries(); - this.$series.each(function (index) { - if (index > 0) { - var labelSpan = $(this).prev().find('span'); - labelSpan.text(String.fromCharCode(index + 65)); - } - }); this.onEditorSubmit(); }, toggleHelp: function() { this.el.find('.editor-info').toggleClass('editor-hide-info'); }, - - // Private: Resets the series property to reference the select elements. - // - // Returns itself. - _updateSeries: function () { - this.$series = this.el.find('.editor-series select'); - } }); })(jQuery, recline.View); diff --git a/ckan/templates/_util.html b/ckan/templates/_util.html index 8e320d8519d..5aa7c077c7e 100644 --- a/ckan/templates/_util.html +++ b/ckan/templates/_util.html @@ -128,6 +128,7 @@
  • + diff --git a/ckan/templates/package/related_list.html b/ckan/templates/package/related_list.html index 49a75ea5a33..baced803519 100644 --- a/ckan/templates/package/related_list.html +++ b/ckan/templates/package/related_list.html @@ -40,6 +40,25 @@

    Related items + diff --git a/ckan/templates/tag/index.html b/ckan/templates/tag/index.html index 17292e98c33..8c383c8c87f 100644 --- a/ckan/templates/tag/index.html +++ b/ckan/templates/tag/index.html @@ -8,9 +8,9 @@

    Tags

    -
    diff --git a/ckan/templates/user/list.html b/ckan/templates/user/list.html index 3c736b4b66b..1ef2655f697 100644 --- a/ckan/templates/user/list.html +++ b/ckan/templates/user/list.html @@ -8,9 +8,10 @@
  • -

    ${c.page.item_count} users found. diff --git a/ckan/templates/user/login.html b/ckan/templates/user/login.html index 6cabb81b0fe..9fdf3df1f39 100644 --- a/ckan/templates/user/login.html +++ b/ckan/templates/user/login.html @@ -18,25 +18,39 @@ Login - User Login to ${g.site_title} + no-sidebar

    - -
    + +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + + +
    + +
    +
    - - -
    - - - - -
    +
    - - — - Forgot your password?

    diff --git a/ckan/templates/user/logout.html b/ckan/templates/user/logout.html index e40ec5c5af1..0f372721954 100644 --- a/ckan/templates/user/logout.html +++ b/ckan/templates/user/logout.html @@ -6,6 +6,7 @@ Logout Logout from ${g.site_title} + no-sidebar

    You have logged out successfully.

    diff --git a/ckan/tests/functional/test_search.py b/ckan/tests/functional/test_search.py index fe1802c76bf..a9a93398472 100644 --- a/ckan/tests/functional/test_search.py +++ b/ckan/tests/functional/test_search.py @@ -108,7 +108,7 @@ def test_search_foreign_chars(self): res = self.app.get(offset) assert 'Search - ' in res self._check_search_results(res, u'th\xfcmb', ['1']) - self._check_search_results(res, 'thumb', ['0']) + self._check_search_results(res, 'thumb', ['1']) @search_related def test_search_escape_chars(self): diff --git a/ckan/tests/functional/test_user.py b/ckan/tests/functional/test_user.py index 5ab1efcb599..eb44bd92376 100644 --- a/ckan/tests/functional/test_user.py +++ b/ckan/tests/functional/test_user.py @@ -168,11 +168,14 @@ def test_login(self): fv = res.forms['login'] fv['login'] = str(username) fv['password'] = str(password) + fv['remember'] = False res = fv.submit() # check cookies set cookies = self._get_cookie_headers(res) assert cookies + for cookie in cookies: + assert not 'max-age' in cookie.lower(), cookie # first get redirected to user/logged_in assert_equal(res.status, 302) @@ -206,6 +209,32 @@ def test_login(self): print res assert 'testlogin' in res.body, res.body + def test_login_remembered(self): + # create test user + username = u'testlogin2' + password = u'letmein' + CreateTestData.create_user(name=username, + password=password) + user = model.User.by_name(username) + + # do the login + offset = url_for(controller='user', action='login') + res = self.app.get(offset) + fv = res.forms['login'] + fv['login'] = str(username) + fv['password'] = str(password) + fv['remember'] = True + res = fv.submit() + + # check cookies set + cookies = self._get_cookie_headers(res) + assert cookies + # check cookie is remembered via Max-Age and Expires + # (both needed for cross-browser compatibility) + for cookie in cookies: + assert 'Max-Age=63072000;' in cookie, cookie + assert 'Expires=' in cookie, cookie + def test_login_wrong_password(self): # create test user username = u'testloginwrong' diff --git a/ckan/tests/lib/test_alphabet_pagination.py b/ckan/tests/lib/test_alphabet_pagination.py index a1bf6ae4749..8afc27642b4 100644 --- a/ckan/tests/lib/test_alphabet_pagination.py +++ b/ckan/tests/lib/test_alphabet_pagination.py @@ -1,5 +1,7 @@ import re +from nose.tools import assert_equal + from ckan.tests import * from ckan.tests import regex_related from ckan.lib.create_test_data import CreateTestData @@ -28,6 +30,16 @@ def setup_class(cls): def teardown_class(cls): model.repo.rebuild_db() + def test_00_model(self): + query = model.Session.query(model.Package) + page = h.AlphaPage( + collection=query, + alpha_attribute='title', + page='A', + other_text=other, + ) + assert_equal(page.available, {'Other': 20, 'A': 10, 'C': 10, 'B': 10, 'E': 0, 'D': 10, 'G': 0, 'F': 0, 'I': 0, 'H': 0, 'K': 0, 'J': 0, 'M': 0, 'L': 0, 'O': 0, 'N': 0, 'Q': 0, 'P': 0, 'S': 0, 'R': 0, 'U': 0, 'T': 0, 'W': 0, 'V': 0, 'Y': 0, 'X': 0, 'Z': 0}) + def test_01_package_page(self): query = model.Session.query(model.Package) page = h.AlphaPage( @@ -37,11 +49,12 @@ def test_01_package_page(self): other_text=other, ) pager = page.pager() - assert pager.startswith('
    '), pager - assert 'A' in pager, pager + assert pager.startswith('
  • A
  • ' in pager, pager url_base = '/packages' - assert re.search('\B\<\/span\>', pager), pager - assert re.search('\Other\<\/span\>', pager), pager + assert re.search(r'\\B\<\/a\>\<\/li\>', pager), pager + assert re.search(r'\
  • \E\<\/a\>\<\/li\>', pager), pager + assert re.search(r'\\Other\<\/a\>\<\/li\>', pager), pager def test_02_package_items(self): diff --git a/ckan/tests/lib/test_solr_package_search.py b/ckan/tests/lib/test_solr_package_search.py index 6ec2b2fa2c4..75d54c0dafc 100644 --- a/ckan/tests/lib/test_solr_package_search.py +++ b/ckan/tests/lib/test_solr_package_search.py @@ -292,7 +292,7 @@ def test_search_foreign_chars(self): result = search.query_for(model.Package).run({'q': 'umlaut'}) assert result['results'] == ['gils'], result['results'] result = search.query_for(model.Package).run({'q': u'thumb'}) - assert result['count'] == 0, result['results'] + assert result['results'] == ['gils'], result['results'] result = search.query_for(model.Package).run({'q': u'th\xfcmb'}) assert result['results'] == ['gils'], result['results'] diff --git a/ckanext/multilingual/solr/schema.xml b/ckanext/multilingual/solr/schema.xml index 84751876ddb..fb957d36fae 100644 --- a/ckanext/multilingual/solr/schema.xml +++ b/ckanext/multilingual/solr/schema.xml @@ -16,7 +16,7 @@ limitations under the License. --> - + @@ -373,6 +373,8 @@ + + @@ -390,11 +392,19 @@ + + + + + + + @@ -424,6 +434,8 @@ + + diff --git a/doc/index.rst b/doc/index.rst index b1c71f00c18..601d4c7a5ff 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -2,6 +2,10 @@ Welcome to CKAN's Administration Guide ======================================= +.. note :: + + This is the documentation for CKAN version '|version|'. If you are using a different version, use the links on the bottom right corner of the page to select the appropriate documentation. + This Administration Guide covers how to set up and manage `CKAN `_ software. * The first two sections cover your two options for installing CKAN: package or source install. diff --git a/doc/solr-setup.rst b/doc/solr-setup.rst index 4529797aa50..3c158b27ce3 100644 --- a/doc/solr-setup.rst +++ b/doc/solr-setup.rst @@ -71,7 +71,7 @@ so, create a symbolic link to the schema file in the config folder. Use the late supported by the CKAN version you are installing (it will generally be the highest one):: sudo mv /etc/solr/conf/schema.xml /etc/solr/conf/schema.xml.bak - sudo ln -s ~/ckan/ckan/config/solr/schema-1.3.xml /etc/solr/conf/schema.xml + sudo ln -s ~/ckan/ckan/config/solr/schema-1.4.xml /etc/solr/conf/schema.xml Now restart jetty:: @@ -93,6 +93,7 @@ will have different paths in the Solr server URL:: http://localhost:8983/solr/ckan-schema-1.2 # Used by CKAN up to 1.5 http://localhost:8983/solr/ckan-schema-1.3 # Used by CKAN 1.5.1 + http://localhost:8983/solr/ckan-schema-1.4 # Used by CKAN 1.7 http://localhost:8983/solr/some-other-site # Used by another site To set up a multicore Solr instance, repeat the steps on the previous section diff --git a/doc/using-data-api.rst b/doc/using-data-api.rst index 09caf21d6e0..50c7db2da0b 100644 --- a/doc/using-data-api.rst +++ b/doc/using-data-api.rst @@ -2,22 +2,44 @@ Using the Data API ================== +The following provides an introduction to using the CKAN :doc:`DataStore +` Data API. + Introduction ============ -The Data API builds directly on ElasticSearch, with a resource API endpoint -being equivalent to a single index 'type' in ElasticSearch (we tend to refer to -it as a 'table'). This means you can often directly re-use `ElasticSearch -client libraries`_ when connecting to the API endpoint. +Each 'table' in the DataStore is an ElasticSearch_ index type ('table'). As +such the Data API for each CKAN resource is directly equivalent to a single +index 'type' in ElasticSearch (we tend to refer to it as a 'table'). + +This means you can (usually) directly re-use `ElasticSearch client libraries`_ +when connecting to a Data API endpoint. It also means that what follows is, in +essence, a tutorial in using the ElasticSearch_ API. + +The following short set of slides provide a brief overview and introduction to +the DataStore and the Data API. -Furthermore, it means that what is presented below is essentially a tutorial in the ElasticSearch API. +.. raw:: html + + +.. _ElasticSearch: http://elasticsearch.org/ .. _ElasticSearch client libraries: http://www.elasticsearch.org/guide/appendix/clients.html Quickstart ========== -``{{endpoint}}`` refers to the data API endpoint (or ElasticSearch index / table). +``{{endpoint}}`` refers to the data API endpoint (or ElasticSearch index / +table). For example, on the DataHub_ this gold prices data resource +http://datahub.io/dataset/gold-prices/resource/b9aae52b-b082-4159-b46f-7bb9c158d013 +would have its Data API endpoint at: +http://datahub.io/api/data/b9aae52b-b082-4159-b46f-7bb9c158d013. If you were +just using ElasticSearch standalone an example of an endpoint would be: +http://localhost:9200/gold-prices/monthly-price-table. + +.. note:: every resource on a CKAN instance for which a DataStore table is + enabled provides links to its Data API endpoint via the Data API + button at the top right of the resource page. Key urls: @@ -28,6 +50,8 @@ Key urls: * Schema (Mapping): ``{{endpoint}}/_mapping`` +.. _DataHub: http://datahub.io/ + Examples -------- diff --git a/pip-requirements.txt b/pip-requirements.txt index 4460f149f3e..9ff9d104906 100644 --- a/pip-requirements.txt +++ b/pip-requirements.txt @@ -5,11 +5,11 @@ # # pip install --ignore-installed -r pip-requirements.txt --e git+https://github.com/okfn/ckan@release-v1.6.1#egg=ckan +-e git+https://github.com/okfn/ckan@master#egg=ckan # CKAN dependencies --r https://github.com/okfn/ckan/raw/release-v1.6.1/requires/lucid_conflict.txt --r https://github.com/okfn/ckan/raw/release-v1.6.1/requires/lucid_present.txt --r https://github.com/okfn/ckan/raw/release-v1.6.1/requires/lucid_missing.txt +-r https://github.com/okfn/ckan/raw/master/requires/lucid_conflict.txt +-r https://github.com/okfn/ckan/raw/master/requires/lucid_present.txt +-r https://github.com/okfn/ckan/raw/master/requires/lucid_missing.txt # NOTE: Developers, please do not edit this file. Changes should go in the # appropriate files in the `requires' directory.