[ckan-changes] [okfn/ckan] 9d460b: Merge branch 'master' of https://github.com/okfn/c...

GitHub noreply at github.com
Thu May 3 15:27:57 UTC 2012


  Branch: refs/heads/master
  Home:   https://github.com/okfn/ckan
  Commit: 9d460be4e398ee3b9ea087688d53e942a3c410b9
      https://github.com/okfn/ckan/commit/9d460be4e398ee3b9ea087688d53e942a3c410b9
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-05-03 (Thu, 03 May 2012)

  Changed paths:
    M ckan/config/environment.py
    M ckan/controllers/home.py
    M ckan/controllers/user.py
    M ckan/lib/base.py
    M ckan/lib/cli.py
    M ckan/lib/helpers.py
    M ckan/logic/__init__.py
    M ckan/model/__init__.py
    M ckan/public/css/style.css
    M ckan/public/scripts/application.js
    M ckan/templates/_util.html
    M ckan/templates/layout_base.html
    M ckan/templates/package/related_list.html
    M ckan/templates/user/login.html
    M ckan/tests/lib/test_helpers.py
    M doc/configuration.rst

  Log Message:
  -----------
  Merge branch 'master' of https://github.com/okfn/ckan


diff --git a/ckan/config/environment.py b/ckan/config/environment.py
index 8d42f4b..d5cf049 100644
--- a/ckan/config/environment.py
+++ b/ckan/config/environment.py
@@ -30,12 +30,12 @@ class _Helpers(object):
     def __init__(self, helpers, restrict=True):
         functions = {}
         allowed = helpers.__allowed_functions__
-        # list of functions due to be depreciated
-        self.depreciated = []
+        # list of functions due to be deprecated
+        self.deprecated = []
 
         for helper in dir(helpers):
             if helper not in allowed:
-                self.depreciated.append(helper)
+                self.deprecated.append(helper)
                 if restrict:
                     continue
             functions[helper] = getattr(helpers, helper)
@@ -63,14 +63,14 @@ def null_function(cls, *args, **kw):
     def __getattr__(self, name):
         ''' return the function/object requested '''
         if name in self.functions:
-            if name in self.depreciated:
-                msg = 'Template helper function `%s` is depriciated' % name
+            if name in self.deprecated:
+                msg = 'Template helper function `%s` is deprecated' % name
                 self.log.warn(msg)
             return self.functions[name]
         else:
-            if name in self.depreciated:
+            if name in self.deprecated:
                 msg = 'Template helper function `%s` is not available ' \
-                      'as it has been depriciated.\nYou can enable it ' \
+                      'as it has been deprecated.\nYou can enable it ' \
                       'by setting ckan.restrict_template_vars = true ' \
                       'in your .ini file.' % name
                 self.log.critical(msg)
diff --git a/ckan/controllers/home.py b/ckan/controllers/home.py
index cfa139c..3f272b1 100644
--- a/ckan/controllers/home.py
+++ b/ckan/controllers/home.py
@@ -14,8 +14,8 @@ class HomeController(BaseController):
     repo = model.repo
 
     def __before__(self, action, **env):
-        BaseController.__before__(self, action, **env)
         try:
+            BaseController.__before__(self, action, **env)
             context = {'model':model,'user': c.user or c.author}
             ckan.logic.check_access('site_read',context)
         except ckan.logic.NotAuthorized:
diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py
index d17df6f..81ec29f 100644
--- a/ckan/controllers/user.py
+++ b/ckan/controllers/user.py
@@ -52,6 +52,11 @@ def _setup_template_variables(self, context):
 
     ## end hooks
 
+    def _get_repoze_handler(self, handler_name):
+        '''Returns the URL that repoze.who will respond to and perform a
+        login or logout.'''
+        return getattr(request.environ['repoze.who.plugins']['friendlyform'], handler_name)
+        
     def index(self):
         LIMIT = 20
 
@@ -165,7 +170,9 @@ def _save_new(self, context):
             return self.new(data_dict, errors, error_summary)
         if not c.user:
             # Redirect to a URL picked up by repoze.who which performs the login
-            h.redirect_to('/login_generic?login=%s&password=%s' % (
+            login_url = self._get_repoze_handler('login_handler_path')
+            h.redirect_to('%s?login=%s&password=%s' % (
+                login_url,
                 str(data_dict['name']),
                 quote(data_dict['password1'].encode('utf-8'))))
         else:
@@ -257,6 +264,7 @@ def login(self):
             g.openid_enabled = False
 
         if not c.user:
+            c.login_handler = h.url_for(self._get_repoze_handler('login_handler_path'))
             return render('user/login.html')
         else:
             return render('user/logout_first.html')
@@ -283,10 +291,10 @@ def logged_in(self):
             h.redirect_to(locale=lang, controller='user', action='login')
 
     def logout(self):
-        # save our language in the session so we don't loose it
+        # save our language in the session so we don't lose it
         session['lang'] = request.environ.get('CKAN_LANG')
         session.save()
-        h.redirect_to('/user/logout')
+        h.redirect_to(self._get_repoze_handler('logout_handler_path'))
 
     def set_lang(self, lang):
         # this allows us to set the lang in session.  Used for logging
diff --git a/ckan/lib/base.py b/ckan/lib/base.py
index ffb79a7..4f77150 100644
--- a/ckan/lib/base.py
+++ b/ckan/lib/base.py
@@ -169,9 +169,11 @@ def _identify_user(self):
         b) For API calls he may set a header with his API key.
         If the user is identified then:
           c.user = user name (unicode)
+          c.userobj = user object
           c.author = user name
         otherwise:
           c.user = None
+          c.userobj = None
           c.author = user\'s IP address (unicode)
         '''
         # see if it was proxied first
@@ -180,8 +182,10 @@ def _identify_user(self):
             c.remote_addr = request.environ.get('REMOTE_ADDR', 'Unknown IP Address')
 
         # environ['REMOTE_USER'] is set by repoze.who if it authenticates a user's
-        # cookie or OpenID. (But it doesn't check the user (still) exists in our
-        # database - we need to do that here.
+        # cookie or OpenID. But repoze.who doesn't check the user (still)
+        # exists in our database - we need to do that here. (Another way would
+        # be with an userid_checker, but that would mean another db access.
+        # See: http://docs.repoze.org/who/1.0/narr.html#module-repoze.who.plugins.sql )
         c.user = request.environ.get('REMOTE_USER', '')
         if c.user:
             c.user = c.user.decode('utf8')
@@ -210,38 +214,41 @@ def __call__(self, environ, start_response):
         # the request is routed to. This routing information is
         # available in environ['pylons.routes_dict']
 
+        try:
+            res = WSGIController.__call__(self, environ, start_response)
+        finally:
+            model.Session.remove()
+
         # Clean out any old cookies as they may contain api keys etc
         # This also improves the cachability of our pages as cookies
         # prevent proxy servers from caching content unless they have
         # been configured to ignore them.
-        # we do not want to clear cookies when setting the user lang
-        if not environ.get('PATH_INFO').startswith('/user/set_lang'):
-            for cookie in request.cookies:
-                if cookie.startswith('ckan') and cookie not in ['ckan']:
-                    response.delete_cookie(cookie)
-                # Remove the ckan session cookie if not used e.g. logged out
-                elif cookie == 'ckan' and not c.user:
-                    # Check session for valid data (including flash messages)
-                    # (DGU also uses session for a shopping basket-type behaviour)
-                    is_valid_cookie_data = False
-                    for key, value in session.items():
-                        if not key.startswith('_') and value:
-                            is_valid_cookie_data = True
-                            break
-                    if not is_valid_cookie_data:
-                        if session.id:
-                            if not session.get('lang'):
-                                session.delete()
-                        else:
-                            response.delete_cookie(cookie)
-                # Remove auth_tkt repoze.who cookie if user not logged in.
-                elif cookie == 'auth_tkt' and not session.id:
-                    response.delete_cookie(cookie)
-
-        try:
-            return WSGIController.__call__(self, environ, start_response)
-        finally:
-            model.Session.remove()
+        for cookie in request.cookies:
+            if cookie.startswith('ckan') and cookie not in ['ckan']:
+                response.delete_cookie(cookie)
+            # Remove the ckan session cookie if not used e.g. logged out
+            elif cookie == 'ckan' and not c.user:
+                # Check session for valid data (including flash messages)
+                # (DGU also uses session for a shopping basket-type behaviour)
+                is_valid_cookie_data = False
+                for key, value in session.items():
+                    if not key.startswith('_') and value:
+                        is_valid_cookie_data = True
+                        break
+                if not is_valid_cookie_data:
+                    if session.id:
+                        if not session.get('lang'):
+                            self.log.debug('No session data any more - deleting session')
+                            self.log.debug('Session: %r', session.items())
+                            session.delete()
+                    else:
+                        response.delete_cookie(cookie)
+                        self.log.debug('No session data any more - deleting session cookie')
+            # Remove auth_tkt repoze.who cookie if user not logged in.
+            elif cookie == 'auth_tkt' and not session.id:
+                response.delete_cookie(cookie)
+
+        return res
 
     def __after__(self, action, **params):
         self._set_cors()
diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py
index d11a1f4..389ad17 100644
--- a/ckan/lib/cli.py
+++ b/ckan/lib/cli.py
@@ -3,11 +3,15 @@
 import sys
 import logging
 from pprint import pprint
+import re
 
 import paste.script
 from paste.registry import Registry
 from paste.script.util.logging_config import fileConfig
-import re
+
+#NB No CKAN imports are allowed until after the config file is loaded.
+#   i.e. do the imports in methods, after _load_config is called.
+#   Otherwise loggers get disabled.
 
 class MockTranslator(object):
     def gettext(self, value):
@@ -33,11 +37,7 @@ class CkanCommand(paste.script.command.Command):
     group_name = 'ckan'
 
     def _load_config(self):
-        # Avoids vdm logging warning
-        logging.basicConfig(level=logging.ERROR)
-
         from paste.deploy import appconfig
-        from ckan.config.environment import load_environment
         if not self.options.config:
             msg = 'No config file supplied'
             raise self.BadCommand(msg)
@@ -46,6 +46,10 @@ def _load_config(self):
             raise AssertionError('Config filename %r does not exist.' % self.filename)
         fileConfig(self.filename)
         conf = appconfig('config:' + self.filename)
+        assert 'ckan' not in dir() # otherwise loggers would be disabled
+        # We have now loaded the config. Now we can import ckan for the
+        # first time.
+        from ckan.config.environment import load_environment
         load_environment(conf.global_conf, conf.local_conf)
 
         self.registry=Registry()
diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py
index b84636c..b21d5a5 100644
--- a/ckan/lib/helpers.py
+++ b/ckan/lib/helpers.py
@@ -263,7 +263,7 @@ def are_there_flash_messages():
 
 def nav_link(*args, **kwargs):
     # nav_link() used to need c passing as the first arg
-    # this is depriciated as pointless
+    # this is deprecated as pointless
     # throws error if ckan.restrict_template_vars is True
     # When we move to strict helpers then this should be removed as a wrapper
     if len(args) > 2 or (len(args) > 1 and 'controller' in kwargs):
@@ -286,7 +286,7 @@ def _nav_link(text, controller, **kwargs):
 
 def nav_named_link(*args, **kwargs):
     # subnav_link() used to need c passing as the first arg
-    # this is depriciated as pointless
+    # this is deprecated as pointless
     # throws error if ckan.restrict_template_vars is True
     # When we move to strict helpers then this should be removed as a wrapper
     if len(args) > 3 or (len(args) > 0 and 'text' in kwargs) or \
@@ -307,7 +307,7 @@ def _nav_named_link(text, name, **kwargs):
 
 def subnav_link(*args, **kwargs):
     # subnav_link() used to need c passing as the first arg
-    # this is depriciated as pointless
+    # this is deprecated as pointless
     # throws error if ckan.restrict_template_vars is True
     # When we move to strict helpers then this should be removed as a wrapper
     if len(args) > 2 or (len(args) > 1 and 'action' in kwargs):
@@ -325,7 +325,7 @@ def _subnav_link(text, action, **kwargs):
 
 def subnav_named_route(*args, **kwargs):
     # subnav_link() used to need c passing as the first arg
-    # this is depriciated as pointless
+    # this is deprecated as pointless
     # throws error if ckan.restrict_template_vars is True
     # When we move to strict helpers then this should be removed as a wrapper
     if len(args) > 2 or (len(args) > 0 and 'text' in kwargs) or \
@@ -382,7 +382,7 @@ def facet_items(*args, **kwargs):
     """
     _log.warning('Deprecated function: ckan.lib.helpers:facet_items().  Will be removed in v1.8')
     # facet_items() used to need c passing as the first arg
-    # this is depriciated as pointless
+    # this is deprecated as pointless
     # throws error if ckan.restrict_template_vars is True
     # When we move to strict helpers then this should be removed as a wrapper
     if len(args) > 2 or (len(args) > 0 and 'name' in kwargs) or (len(args) > 1 and 'limit' in kwargs):
@@ -605,26 +605,69 @@ def date_str_to_datetime(date_str):
     # a strptime. Also avoids problem with Python 2.5 not having %f.
     return datetime.datetime(*map(int, re.split('[^\d]', date_str)))
 
-def parse_rfc_2822_date(date_str, tz_aware=True):
+def parse_rfc_2822_date(date_str, assume_utc=True):
     """
     Parse a date string of the form specified in RFC 2822, and return a datetime.
 
-    RFC 2822 is the date format used in HTTP headers.
-
-    If the date string contains a timezone indication, and tz_aware is True,
-    then the associated tzinfo is attached to the returned datetime object.
-
-    Returns None if the string cannot be parse as a valid datetime.
+    RFC 2822 is the date format used in HTTP headers.  It should contain timezone
+    information, but that cannot be relied upon.
+    
+    If date_str doesn't contain timezone information, then the 'assume_utc' flag
+    determines whether we assume this string is local (with respect to the
+    server running this code), or UTC.  In practice, what this means is that if
+    assume_utc is True, then the returned datetime is 'aware', with an associated
+    tzinfo of offset zero.  Otherwise, the returned datetime is 'naive'.
+
+    If timezone information is available in date_str, then the returned datetime
+    is 'aware', ie - it has an associated tz_info object.
+    
+    Returns None if the string cannot be parsed as a valid datetime.
     """
     time_tuple = email.utils.parsedate_tz(date_str)
 
+    # Not parsable
     if not time_tuple:
         return None
 
-    if not tz_aware:
-        time_tuple = time_tuple[:-1] + (None,)
+    # No timezone information available in the string
+    if time_tuple[-1] is None and not assume_utc:
+        return datetime.datetime.fromtimestamp(email.utils.mktime_tz(time_tuple))
+    else:
+        offset = 0 if time_tuple[-1] is None else time_tuple[-1]
+        tz_info = _RFC2282TzInfo(offset)
+    return datetime.datetime(*time_tuple[:6], microsecond=0, tzinfo=tz_info)
+
+class _RFC2282TzInfo(datetime.tzinfo):
+    """
+    A datetime.tzinfo implementation used by parse_rfc_2822_date() function.
+
+    In order to return timezone information, a concrete implementation of
+    datetime.tzinfo is required.  This class represents tzinfo that knows
+    about it's offset from UTC, has no knowledge of daylight savings time, and
+    no knowledge of the timezone name.
+
+    """
+
+    def __init__(self, offset):
+        """
+        offset from UTC in seconds.
+        """
+        self.offset = datetime.timedelta(seconds=offset)
+
+    def utcoffset(self, dt):
+        return self.offset
+
+    def dst(self, dt):
+        """
+        Dates parsed from an RFC 2822 string conflate timezone and dst, and so
+        it's not possible to determine whether we're in DST or not, hence
+        returning None.
+        """
+        return None
+
+    def tzname(self, dt):
+        return None
 
-    return datetime.datetime.fromtimestamp(email.utils.mktime_tz(time_tuple))
 
 def time_ago_in_words_from_str(date_str, granularity='month'):
     if date_str:
@@ -690,7 +733,7 @@ def dump_json(obj, **kw):
 
 def auto_log_message(*args):
     # auto_log_message() used to need c passing as the first arg
-    # this is depriciated as pointless
+    # this is deprecated as pointless
     # throws error if ckan.restrict_template_vars is True
     # When we move to strict helpers then this should be removed as a wrapper
     if len(args) and asbool(config.get('ckan.restrict_template_vars', 'false')):
@@ -777,7 +820,7 @@ def process_names(items):
            'default_group_type',
            'facet_items',
            'facet_title',
-         #  am_authorized, # depreciated
+         #  am_authorized, # deprecated
            'check_access',
            'linked_user',
            'linked_authorization_group',
@@ -809,6 +852,7 @@ def process_names(items):
            'convert_to_dict',
            'activity_div',
            'lang_native_name',
+           'unselected_facet_items',
     # imported into ckan.lib.helpers
            'literal',
            'link_to',
diff --git a/ckan/logic/__init__.py b/ckan/logic/__init__.py
index 30cd4ae..fa37d8a 100644
--- a/ckan/logic/__init__.py
+++ b/ckan/logic/__init__.py
@@ -227,7 +227,7 @@ def get_action(action):
 
 def get_or_bust(data_dict, keys):
     '''Try and get values from dictionary and if they are not there
-    raise a validataion error.
+    raise a validation error.
 
     data_dict: a dictionary
     keys: either a single string key in which case will return a single value,
diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py
index c49cb1b..08574c5 100644
--- a/ckan/model/__init__.py
+++ b/ckan/model/__init__.py
@@ -94,6 +94,7 @@ def init_db(self):
 
                 self.init_configuration_data()
                 self.tables_created_and_initialised = True
+        log.info('Database initialised')
 
     def clean_db(self):
         metadata = MetaData(self.metadata.bind)
@@ -103,6 +104,7 @@ def clean_db(self):
 
         metadata.drop_all()
         self.tables_created_and_initialised = False
+        log.info('Database tables dropped')
 
     def init_const_data(self):
         '''Creates 'constant' objects that should always be there in
@@ -135,6 +137,7 @@ def create_db(self):
         self.metadata.create_all(bind=self.metadata.bind)
         self.init_const_data()
         self.init_configuration_data()
+        log.info('Database tables created')
 
     def latest_migration_version(self):
         import migrate.versioning.api as mig
@@ -156,6 +159,7 @@ def rebuild_db(self):
         self.session.remove()
         self.init_db()
         self.session.flush()
+        log.info('Database rebuilt')
 
     def delete_all(self):
         '''Delete all data from all tables.'''
@@ -169,7 +173,7 @@ def delete_all(self):
         for table in tables:
             connection.execute('delete from "%s"' % table.name)
         self.session.commit()
-
+        log.info('Database table data deleted')
 
     def setup_migration_version_control(self, version=None):
         import migrate.exceptions
@@ -191,9 +195,16 @@ def upgrade_db(self, version=None):
             meta.engine.name
         import migrate.versioning.api as mig
         self.setup_migration_version_control()
+        version_before = mig.db_version(self.metadata.bind, self.migrate_repository)
         mig.upgrade(self.metadata.bind, self.migrate_repository, version=version)
-        self.init_const_data()
+        version_after = mig.db_version(self.metadata.bind, self.migrate_repository)
+        if version_after != version_before:
+            log.info('CKAN database version upgraded: %s -> %s', version_before, version_after)
+        else:
+            log.info('CKAN database version remains as: %s', version_after)
 
+        self.init_const_data()
+        
         ##this prints the diffs in a readable format
         ##import pprint
         ##from migrate.versioning.schemadiff import getDiffOfModelAgainstDatabase
diff --git a/ckan/public/css/style.css b/ckan/public/css/style.css
index 2e9a938..6c828f7 100644
--- a/ckan/public/css/style.css
+++ b/ckan/public/css/style.css
@@ -1405,6 +1405,18 @@ body.editresources .error-explanation {
   margin-bottom: 0;
 }
 
+.popover-title {
+  margin-bottom: 0;
+}
+
+.popover-inner {
+  background: #aaa;
+}
+
+.popover.right .arrow {
+  border-right-color: #aaa;
+}
+
 /* Chosen Form Styles */
 
 .chzn-container-single {
@@ -1420,10 +1432,27 @@ body.editresources .error-explanation {
   color: #808080;
 }
 
+.related-help {
+  opacity: 0.3;
+  position: relative;
+  top: 2px;
+  cursor: pointer;
+}
+
+.thumbnails li {
+  z-index: 0;
+  position: relative;
+  background-color: #fff;
+}
+
 .thumbnails li:nth-of-type(5n) {
   clear: left;
 }
 
+.thumbnails li.expanded-description {
+  z-index: 1;
+}
+
 .thumbnail .heading {
   font-weight: bold;
 }
@@ -1444,6 +1473,19 @@ body.editresources .error-explanation {
   height: auto;
 }
 
+.thumbnail .close {
+  padding-bottom: 7px;
+  padding-top: 2px;
+  position: relative;
+  top: -1px;
+  right: -1px;
+  display: none;
+}
+
+.thumbnail:hover .close {
+  display: block;
+}
+
 .thumbnail .empty {
   color: #ccc;
 }
diff --git a/ckan/public/scripts/application.js b/ckan/public/scripts/application.js
index 0d3628e..6e00004 100644
--- a/ckan/public/scripts/application.js
+++ b/ckan/public/scripts/application.js
@@ -133,10 +133,23 @@ CKAN.Utils = CKAN.Utils || {};
 jQuery.fn.truncate = function (max, suffix) {
   return this.each(function () {
     var element = jQuery(this),
-        cached  = element.text(),
-        length  = max || element.data('truncate') || 30,
-        text    = cached.slice(0, length),
-        expand  = jQuery('<a href="#" />').text(suffix || '»');
+        isTruncated = element.hasClass('truncated'),
+        cached, length, text, expand;
+
+    if (isTruncated) {
+      element.html(element.data('truncate:' + (max === 'expand' ? 'original' : 'truncated')));
+      return;
+    }
+
+    cached  = element.text();
+    length  = max || element.data('truncate') || 30;
+    text    = cached.slice(0, length);
+    expand  = jQuery('<a href="#" />').text(suffix || '»');
+
+    // Bail early if there is nothing to truncate.
+    if (cached.length < length) {
+      return;
+    }
 
     // Try to truncate to nearest full word.
     while ((/\S/).test(text[text.length - 1])) {
@@ -144,12 +157,15 @@ jQuery.fn.truncate = function (max, suffix) {
     }
 
     element.html(jQuery.trim(text));
-
     expand.appendTo(element.append(' '));
     expand.click(function (event) {
       event.preventDefault();
-      element.text(cached);
+      element.html(cached);
     });
+
+    element.addClass('truncated');
+    element.data('truncate:original',  cached);
+    element.data('truncate:truncated', element.html());
   });
 };
 
@@ -1141,13 +1157,30 @@ CKAN.Utils = function($, my) {
 
 
   my.relatedSetup = function(form) {
+    $('[rel=popover]').popover();
+
     function addAlert(msg) {
       $('<div class="alert alert-error" />').html(msg).hide().prependTo(form).fadeIn();
     }
 
+    function relatedRequest(action, method, data) {
+      return $.ajax({
+        type: method,
+        dataType: 'json',
+        contentType: 'application/json',
+        url: CKAN.SITE_URL + '/api/3/action/related_' + action,
+        data: data ? JSON.stringify(data) : undefined,
+        error: function(err, txt, w) {
+          // This needs to be far more informative.
+          addAlert('<strong>Error:</strong> Unable to ' + action + ' related item');
+        }
+      });
+    }
+
     // Center thumbnails vertically.
-    $('.related-items').each(function () {
-      var item = $(this);
+    var relatedItems = $('.related-items');
+    relatedItems.find('li').each(function () {
+      var item = $(this), description = item.find('.description');
 
       function vertiallyAlign() {
         var img = $(this),
@@ -1161,7 +1194,47 @@ CKAN.Utils = function($, my) {
       }
 
       item.find('img').load(vertiallyAlign);
-      item.find('.description').truncate();
+      description.data('height', description.height()).truncate();
+    });
+
+    relatedItems.on('mouseenter mouseleave', '.description.truncated', function (event) {
+      var isEnter = event.type === 'mouseenter'
+          description = $(this)
+          timer = description.data('hover-intent');
+
+      function update() {
+        var parent = description.parents('li:first'),
+            difference = description.data('height') - description.height();
+
+        description.truncate(isEnter ? 'expand' : 'collapse');
+        parent.toggleClass('expanded-description', isEnter);
+
+        // Adjust the bottom margin of the item relative to it's current value
+        // to allow the description to expand without breaking the grid.
+        parent.css('margin-bottom', isEnter ? '-=' + difference + 'px' : '');
+        description.removeData('hover-intent');
+      }
+
+      if (!isEnter && timer) {
+        // User has moused out in the time set so cancel the action.
+        description.removeData('hover-intent');
+        return clearTimeout(timer);
+      } else if (!isEnter && !timer) {
+        update();
+      } else {
+        // Delay the hover action slightly to wait to see if the user mouses
+        // out again. This prevents unwanted actions.
+        description.data('hover-intent', setTimeout(update, 200));
+      }
+    });
+
+    // Add a handler for the delete buttons.
+    relatedItems.on('click', '[data-action=delete]', function (event) {
+      var id = $(this).data('relatedId');
+      relatedRequest('delete', 'POST', {id: id}).done(function () {
+        $('#related-item-' + id).remove();
+      });
+      event.preventDefault();
     });
 
     $(form).submit(function (event) {
@@ -1186,18 +1259,10 @@ CKAN.Utils = function($, my) {
         return;
       }
 
-      $.ajax({
-        type: this.method,
-        url: CKAN.SITE_URL + '/api/3/action/related_create',
-        data: JSON.stringify(data),
-        success: function (data) {
-          window.location.reload();
-        },
-        error: function(err, txt, w) {
-          // This needs to be far more informative.
-          addAlert('<strong>Error:</strong> Unable to add related item');
-        }
-      }); 
+      relatedRequest('create', this.method, data).done(function () {
+        // TODO: Insert item dynamically.
+        window.location.reload();
+      });
     });
   };
 
diff --git a/ckan/templates/_util.html b/ckan/templates/_util.html
index 5aa7c07..8c29918 100644
--- a/ckan/templates/_util.html
+++ b/ckan/templates/_util.html
@@ -126,16 +126,16 @@
 
 
   <py:def function="related_summary(related)">
-    <li class="span3">
+  <li id="related-item-${related.id}" class="span3">
       <div class="thumbnail">
-        <button py:if="c.user and (c.userobj.id == related.owner_id or h.check_access('package_update',{'id':c.pkg.id}))" class="close" onclick="related_delete('${related.id}');">×</button>
+        <button py:if="c.user and (c.userobj.id == related.owner_id or h.check_access('package_update',{'id':c.pkg.id}))" class="close" data-action="delete" data-related-id="${related.id}">×</button>
         <a href="${related.url}" class="image">
           <img src="${related.image_url}" width="210" py:if="related.image_url" />
           <img src="/images/photo-placeholder.png" width="210" py:if="not related.image_url" />
         </a>
         <div class="caption">
           <h5 class="heading" title="${related.title}">${h.markdown_extract(related.title, extract_length=30)}</h5>
-          <div class="description" data-truncate="60" py:if="related.description">${h.markdown_extract(related.description, extract_length=1000)}</div>
+          <div class="description" data-truncate="55" py:if="related.description">${h.markdown_extract(related.description, extract_length=1000)}</div>
           <i class="empty" py:if="not related.description">No description for this item</i>
           <p class="read-more"><a href="${related.url}">View this related item</a></p>
         </div>
diff --git a/ckan/templates/layout_base.html b/ckan/templates/layout_base.html
index ca36c86..9d49ba1 100644
--- a/ckan/templates/layout_base.html
+++ b/ckan/templates/layout_base.html
@@ -27,7 +27,7 @@
   <link rel="alternate" type="application/atom+xml" title="${g.site_title} - Recent Revision History" href="${h.url_for(controller='revision', action='list', format='atom', days=1)}" />
   </py:otherwise>
   </py:choose>
-  <link href='http://fonts.googleapis.com/css?family=Ubuntu' rel='stylesheet' type='text/css' />
+  <link href='http://fonts.googleapis.com/css?family=Ubuntu:400,700' rel='stylesheet' type='text/css' />
 
   <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/jqueryui/1.8.14/css/jquery-ui.custom.css')}" type="text/css" media="screen, print" />
   <link rel="stylesheet" href="${h.url_for_static('/css/bootstrap.min.css')}" type="text/css" media="screen, projection" />
@@ -274,7 +274,9 @@ <h3 class="widget-title">Meta</h3>
                  // Tracking
 				 var url = location.pathname;
 				 // remove any site root from url
-				 url = url.substring(CKAN.SITE_URL.length, url.length - 1);
+				 url = url.substring(CKAN.SITE_URL.length, url.length);
+				 // trim any trailing /
+				 url = url.replace(/\/*$/, '');
 				 $.ajax({url : CKAN.SITE_URL_NO_LOCALE + '/_tracking',
 						 type : 'POST',
 						 data : {url:url, type:'page'},
diff --git a/ckan/templates/package/related_list.html b/ckan/templates/package/related_list.html
index baced80..173715f 100644
--- a/ckan/templates/package/related_list.html
+++ b/ckan/templates/package/related_list.html
@@ -18,12 +18,12 @@
   - Related</py:def>
 
   <py:def function="page_heading" property="dc:title">
-    ${c.pkg_dict['title']} - Related
+  ${c.pkg_dict['title']} - Related
   </py:def>
 
   <div py:match="content">
     ${add_related(c.pkg)}
-    <h3>Related items <a class="btn btn-small btn-primary pull-right" data-toggle="modal" href=".modal-add-related" py:if="c.user"><i class="icon-plus-sign icon-white"></i> Add related item</a></h3>
+    <h3>Related items <i class="related-help icon-info-sign" rel="popover" data-content="These are applications, ideas and visualisations that are using this dataset." data-original-title="What are related items?"></i> <a class="btn btn-small btn-primary pull-right" data-toggle="modal" href=".modal-add-related" py:if="c.user"><i class="icon-plus-sign icon-white"></i> Add related item</a></h3>
     <div>
       <div py:if="not c.pkg.related" class="span8 no-related-items">
           There are no related items here yet<span py:if="c.user">, why not <a data-toggle="modal" href=".modal-add-related">add one</a>?</span>
@@ -40,25 +40,6 @@
   </div>
 
   <py:def function="optional_head">
-    <script type="text/javascript" py:if="c.user">
-        function related_delete(related_id) {
-          var data = { 'id' : related_id }
-          $.ajax({
-            type: "post",
-            url: CKAN.SITE_URL + '/api/3/action/related_delete',
-            data: JSON.stringify(data),
-            success: function (data) {
-              window.location.reload();
-            },
-            error: function(err, txt, w) {
-              // This needs to be far more informative.
-              var msg = '<strong>Error:</strong> Unable to delete related item';
-              $('<div class="alert alert-error" />').html(msg).hide().prependTo($('div#main')).fadeIn();
-            }
-          });
-
-        }
-    </script>
     <py:if test="config.get('rdf_packages')">
       <link rel="alternate" type="application/rdf+xml" title="RDF/XML" href="${config['rdf_packages'] + '/' + c.pkg.id + '.rdf' }" />
       <link rel="alternate" type="application/turtle" title="RDF/Turtle" href="${config['rdf_packages'] + '/' + c.pkg.id + '.ttl' }" />
diff --git a/ckan/templates/user/login.html b/ckan/templates/user/login.html
index 9fdf3df..8575e62 100644
--- a/ckan/templates/user/login.html
+++ b/ckan/templates/user/login.html
@@ -22,7 +22,7 @@
 
   <div py:match="content">
 
-    <form action="${h.url_for('/login_generic')}" method="post" class="form-horizontal" id="login">  
+    <form action="${c.login_handler}" method="post" class="form-horizontal" id="login">  
       <fieldset>
         <!--legend i18n:msg="site_title">Login</legend-->
         <div class="control-group">
diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py
index b7eeacb..5233d85 100644
--- a/ckan/tests/lib/test_helpers.py
+++ b/ckan/tests/lib/test_helpers.py
@@ -92,15 +92,25 @@ def test_gravatar_encodes_url_correctly(self):
         for e in expected:
             assert e in res, (e,res)
 
-    def test_parse_rfc_2822_simple_case(self):
+    def test_parse_rfc_2822_no_timezone_specified(self):
         """
         Parse "Tue, 15 Nov 1994 12:45:26" successfully.
 
-        No zone info.
+        Assuming it's UTC.
         """
         dt = h.parse_rfc_2822_date('Tue, 15 Nov 1994 12:45:26')
-        assert_equal(dt.isoformat(), '1994-11-15T12:45:26')
+        assert_equal(dt.isoformat(), '1994-11-15T12:45:26+00:00')
     
+    def test_parse_rfc_2822_no_timezone_specified_assuming_local(self):
+        """
+        Parse "Tue, 15 Nov 1994 12:45:26" successfully.
+
+        Assuming it's local.
+        """
+        dt = h.parse_rfc_2822_date('Tue, 15 Nov 1994 12:45:26', assume_utc=False)
+        assert_equal(dt.isoformat(), '1994-11-15T12:45:26')
+        assert_equal(dt.tzinfo, None)
+
     def test_parse_rfc_2822_gmt_case(self):
         """
         Parse "Tue, 15 Nov 1994 12:45:26 GMT" successfully.
@@ -108,20 +118,12 @@ def test_parse_rfc_2822_gmt_case(self):
         GMT obs-zone specified
         """
         dt = h.parse_rfc_2822_date('Tue, 15 Nov 1994 12:45:26 GMT')
-        assert_equal(dt.isoformat(), '1994-11-15T12:45:26')
+        assert_equal(dt.isoformat(), '1994-11-15T12:45:26+00:00')
 
     def test_parse_rfc_2822_with_offset(self):
         """
         Parse "Tue, 15 Nov 1994 12:45:26 +0700" successfully.
         """
         dt = h.parse_rfc_2822_date('Tue, 15 Nov 1994 12:45:26 +0700')
-        assert_equal(dt.isoformat(), '1994-11-15T05:45:26')
-
-    def test_parse_rfc_2822_ignoring_offset(self):
-        """
-        Parse "Tue, 15 Nov 1994 12:45:26 +0700" successfully.
-        """
-        dt = h.parse_rfc_2822_date('Tue, 15 Nov 1994 12:45:26 +0700', tz_aware=False)
-        assert_equal(dt.isoformat(), '1994-11-15T12:45:26')
-
+        assert_equal(dt.isoformat(), '1994-11-15T12:45:26+07:00')
 
diff --git a/doc/configuration.rst b/doc/configuration.rst
index 0ac49fb..7bcc747 100644
--- a/doc/configuration.rst
+++ b/doc/configuration.rst
@@ -673,7 +673,7 @@ This is a directory where SQL database backups are to be written, assuming a scr
 
 
 
-Compatability
+Compatibility
 -------------
 
 .. index::


================================================================
  Commit: 2f4d2f30e27f59c1d247fbac59d5f5ce54bf4503
      https://github.com/okfn/ckan/commit/2f4d2f30e27f59c1d247fbac59d5f5ce54bf4503
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-05-03 (Thu, 03 May 2012)

  Changed paths:
    M ckan/public/css/style.css
    M ckan/templates/layout_base.html

  Log Message:
  -----------
  Merge branch 'master' of https://github.com/okfn/ckan


diff --git a/ckan/public/css/style.css b/ckan/public/css/style.css
index 6c828f7..5db8483 100644
--- a/ckan/public/css/style.css
+++ b/ckan/public/css/style.css
@@ -126,10 +126,6 @@ footer a {
   text-decoration: none;
 }
 
-footer #footer-okf-logo {
-  margin-bottom: -4px;
-}
-
 footer h3 {
   font-size: 1.2em;
 }
diff --git a/ckan/templates/layout_base.html b/ckan/templates/layout_base.html
index 9d49ba1..5949424 100644
--- a/ckan/templates/layout_base.html
+++ b/ckan/templates/layout_base.html
@@ -211,7 +211,7 @@ <h3 class="widget-title">Meta</h3>
             <img src="http://assets.okfn.org/images/logo/okf_logo_white_and_green_tiny.png" id="footer-okf-logo" />
             <a href="http://okfn.org/">Open Knowledge Foundation</a>
             Licensed under the <a href="http://opendatacommons.org/licenses/odbl/1.0/">Open Database License</a>
-            <a href="http://opendefinition.org/"><img alt="This Content and Data is Open" src="http://assets.okfn.org/images/ok_buttons/od_80x15_blue.png" style="border: none ; margin-bottom: -4px;"/></a>
+            <a href="http://opendefinition.org/"><img alt="This Content and Data is Open" src="http://assets.okfn.org/images/ok_buttons/od_80x15_blue.png" style="border: none;"/></a>
 
             <br/><br/>
             Powered by <a href="http://ckan.org">CKAN</a> v${c.__version__}.<br/>


================================================================
  Commit: a2fd245719a1224ce58d3af072f7cb146bea2899
      https://github.com/okfn/ckan/commit/a2fd245719a1224ce58d3af072f7cb146bea2899
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-05-03 (Thu, 03 May 2012)

  Changed paths:
    M ckan/public/css/style.css
    M ckan/templates/home/about.html
    M ckan/templates/home/index.html
    M ckan/templates/package/search.html
    M ckan/templates/package/search_form.html

  Log Message:
  -----------
  Merge branch 'master' of https://github.com/okfn/ckan


diff --git a/ckan/public/css/style.css b/ckan/public/css/style.css
index 5db8483..f844078 100644
--- a/ckan/public/css/style.css
+++ b/ckan/public/css/style.css
@@ -717,25 +717,28 @@ body.package.search #menusearch {
 }
 .dataset-search {
   margin-bottom: 35px;
+  padding-right: 100px;
 }
 input.search {
   width: 100%;
+  float: left;
   font-size: 1.2em;
   margin: 0px;
   border: 1px solid #ccc;
-  padding: 0.5em;
+  padding: 0.6em 0.5em 0.6em 5px;
   font-weight: bold;
-     -moz-border-radius: 5px;
+  -moz-border-radius: 5px;
   -webkit-border-radius: 5px;
-          border-radius: 5px;
- -moz-background-clip: padding; -webkit-background-clip: padding-box; background-clip: padding-box;
+  border-radius: 5px;
+  -moz-background-clip: padding;
+  -webkit-background-clip: padding-box;
+  background-clip: padding-box;
 }
 .dataset-search input.button {
     display: inline-block;
-    float: right;
-    margin-top: 5px;
-    margin-right: 10px !important;
-    margin-bottom: 1px !important;
+    float: left;
+    margin-left: 9px;
+    margin-right: -100px;
 }
 
 
diff --git a/ckan/templates/home/about.html b/ckan/templates/home/about.html
index 1df2995..05b5bc9 100644
--- a/ckan/templates/home/about.html
+++ b/ckan/templates/home/about.html
@@ -29,7 +29,7 @@
 
     <h2>Open data and the Open Knowledge Foundation</h2>
 
-    <p i18n:msg="site_title">Most of the data indexed at ${g.site_title} is openly licensed, meaning anyone is free to use or re-use it however they like. Perhaps someone will take that nice dataset of a city's public art that you found, and add it to a tourist map - or even make a neat app for your phone that'll help you find artworks when you visit the city. Open data means more enterprise, collaborative science and transparent government. You can read more about open data in the <a href="http://opendatamanual.org/introduction/index.html" >Open Data Manual</a>.</p>
+    <p i18n:msg="site_title">Most of the data indexed at ${g.site_title} is openly licensed, meaning anyone is free to use or re-use it however they like. Perhaps someone will take that nice dataset of a city's public art that you found, and add it to a tourist map - or even make a neat app for your phone that'll help you find artworks when you visit the city. Open data means more enterprise, collaborative science and transparent government. You can read more about open data in the <a href="http://opendatahandbook.org" >Open Data Handbook</a>.</p>
 
     <p i18n:msg="">The <a href="http://okfn.org">Open Knowledge Foundation</a> is a non-profit organisation <a href="http://okfn.org/about/vision/">promoting</a> open knowledge: writing and improving CKAN is one of the ways we do that. If you want to get involved with its design or code, join the discussion or development <a href="http://ckan.org/contact/">mailing lists</a>, or take a look at the <a href="http://okfn.org">OKFN</a> site to find out about our other projects.</p>
 
diff --git a/ckan/templates/home/index.html b/ckan/templates/home/index.html
index 7e4ca38..46386b7 100644
--- a/ckan/templates/home/index.html
+++ b/ckan/templates/home/index.html
@@ -53,7 +53,7 @@ <h1 class="page_heading">Welcome to ${g.site_title}!</h1>
           <ul>
             <li><a href="http://getthedata.org">GetTheData.org</a></li>
             <li><a href="http://datapatterns.org">DataPatterns.org</a></li>
-            <li><a href="http://opendatamanual.org">Open Data Manual</a></li>
+            <li><a href="http://opendatahandbook.org">Open Data Handbook</a></li>
           </ul>
           </div>
         </div>
diff --git a/ckan/templates/package/search.html b/ckan/templates/package/search.html
index cee817e..1414640 100644
--- a/ckan/templates/package/search.html
+++ b/ckan/templates/package/search.html
@@ -26,7 +26,7 @@
     ${facet_div('groups', 'Groups')}
 
     <li class="widget-container widget_text">
-        <h4>Other access</h4>
+        <h2>Other access</h2>
 <?python
   from pylons import config
   dumps_url = config.get('ckan.dumps_url')
diff --git a/ckan/templates/package/search_form.html b/ckan/templates/package/search_form.html
index ba15882..e781234 100644
--- a/ckan/templates/package/search_form.html
+++ b/ckan/templates/package/search_form.html
@@ -5,15 +5,15 @@
   py:strip=""
   >
 
-<form id="dataset-search" class="dataset-search" method="GET">
+<form id="dataset-search" class="dataset-search clearfix" method="GET">
   <input type="search" class="search" name="q" value="${c.q}" autocomplete="off" results="0" placeholder="${_('Search...')}" />
+  <input type="submit" value="${_('Search')}" class="btn btn-large button" />
   <span py:if="c.fields">
   <py:for each="(k, v) in c.fields"> 
     <input type="hidden" name="${k}" value="${v}" />  
   </py:for>
   </span>
   <div id="dataset-search-ext"></div>
-  <input type="submit" value="${_('Search')}" class="btn primary button" />
 </form>
 
 </html>


================================================================
  Commit: e2af388a0a91e3258f537cb6f5bce79fa4c526d5
      https://github.com/okfn/ckan/commit/e2af388a0a91e3258f537cb6f5bce79fa4c526d5
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-05-03 (Thu, 03 May 2012)

  Changed paths:
    M ckan/logic/action/create.py
    M ckan/logic/action/delete.py
    M ckan/logic/action/get.py
    M ckan/tests/logic/test_member.py

  Log Message:
  -----------
  [bug] Fix to extraneous fields in context for member api


diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py
index aee73a3..e6be5a3 100644
--- a/ckan/logic/action/create.py
+++ b/ckan/logic/action/create.py
@@ -11,6 +11,7 @@
 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.logic.auth as auth
 
 # FIXME this looks nasty and should be shared better
 from ckan.logic.action.update import _update_package_relationship
@@ -196,7 +197,7 @@ def member_create(context, data_dict=None):
         user  - The name of the current user
 
     data_dict:
-        group - The ID of the group to which we want to add a new object
+        id - The ID of the group to which we want to add a new object
         object - The ID of the object being added as a member
         object_type - The name of the type being added, all lowercase,
                       e.g. package, or user
@@ -204,7 +205,6 @@ def member_create(context, data_dict=None):
     """
     model = context['model']
     user = context['user']
-    group = context['group']
 
     rev = model.repo.new_revision()
     rev.author = user
@@ -213,6 +213,7 @@ def member_create(context, data_dict=None):
     else:
         rev.message = _(u'REST API: Create member object %s') % data_dict.get("name", "")
 
+    group = model.Group.get(data_dict.get('id', ''))
     obj_id   = data_dict['object']
     obj_type = data_dict['object_type']
     capacity = data_dict['capacity']
diff --git a/ckan/logic/action/delete.py b/ckan/logic/action/delete.py
index c806a51..4dd5c70 100644
--- a/ckan/logic/action/delete.py
+++ b/ckan/logic/action/delete.py
@@ -90,16 +90,15 @@ def member_delete(context, data_dict=None):
         user  - The name of the current user
 
     data_dict:
-        group - The ID of the group to which we want to remove object
+        id - The ID of the group from which we want to remove object
         object - The ID of the object being removed as a member
         object_type - The name of the type being removed, all lowercase,
                       e.g. package, or user
     """
     model = context['model']
     user = context['user']
-    group = context['group']
 
-    group_id = data_dict['group']
+    group = model.Group.get(data_dict.get('id'))
     obj_id   = data_dict['object']
     obj_type = data_dict['object_type']
 
diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py
index b2afe94..b0cb640 100644
--- a/ckan/logic/action/get.py
+++ b/ckan/logic/action/get.py
@@ -179,16 +179,15 @@ def member_list(context, data_dict=None):
         user  - The name of the current user
 
     data_dict:
-        group - The ID of the group to which we want to list members
+        id - The ID of the group to which we want to list members
         object_type - The optional name of the type being added, all lowercase,
                       e.g. package, or user
         capacity - The optional capacity of objects that we want to retrieve
     """
     model = context['model']
     user = context['user']
-    group = context['group']
 
-    group_id = data_dict['group']
+    group = model.Group.get(data_dict.get('id',''))
     obj_type = data_dict.get('object_type', None)
     capacity = data_dict.get('capacity', None)
 
diff --git a/ckan/tests/logic/test_member.py b/ckan/tests/logic/test_member.py
index 04af59d..3d6294e 100644
--- a/ckan/tests/logic/test_member.py
+++ b/ckan/tests/logic/test_member.py
@@ -21,14 +21,12 @@ def teardown_class(cls):
         model.repo.rebuild_db()
 
     def _build_context( self, obj, obj_type, capacity='public'):
-        grp = model.Group.by_name(self.groupname)
         ctx = { 'model': model,
                 'session': model.Session,
-                'user':self.username,
-                'group': grp,
+                'user':self.username
         }
         dd = {
-            'group': grp,
+            'id': self.groupname,
             'object': obj,
             'object_type': obj_type,
             'capacity': capacity }


================================================================
Compare: https://github.com/okfn/ckan/compare/668d4d7...e2af388


More information about the ckan-changes mailing list