[ckan-changes] commit/ckan: 5 new changesets

Bitbucket commits-noreply at bitbucket.org
Thu Jul 28 12:16:28 UTC 2011


5 new changesets in ckan:

http://bitbucket.org/okfn/ckan/changeset/c7588fc1b8f8/
changeset:   c7588fc1b8f8
branch:      feature-1229-db-out-of-controllers
user:        amercader
date:        2011-07-27 17:36:03
summary:     Put datetime conversion on a helper function
affected #:  3 files (199 bytes)

--- a/ckan/controllers/group.py	Wed Jul 27 13:14:38 2011 +0100
+++ b/ckan/controllers/group.py	Wed Jul 27 16:36:03 2011 +0100
@@ -441,7 +441,7 @@
                 language=unicode(get_lang()),
             )
             for revision_dict in c.group_revisions:
-                revision_date = datetime.datetime.strptime(revision_dict['timestamp'], '%Y-%m-%dT%H:%M:%S.%f')
+                revision_date = h.date_str_to_datetime(revision_dict['timestamp'])
                 try:
                     dayHorizon = int(request.params.get('days'))
                 except:


--- a/ckan/controllers/package.py	Wed Jul 27 13:14:38 2011 +0100
+++ b/ckan/controllers/package.py	Wed Jul 27 16:36:03 2011 +0100
@@ -288,7 +288,7 @@
                 language=unicode(get_lang()),
             )
             for revision_dict in c.pkg_revisions:
-                revision_date = datetime.datetime.strptime(revision_dict['timestamp'], '%Y-%m-%dT%H:%M:%S.%f')
+                revision_date = h.date_str_to_datetime(revision_dict['timestamp'])
                 try:
                     dayHorizon = int(request.params.get('days'))
                 except:


--- a/ckan/lib/helpers.py	Wed Jul 27 13:14:38 2011 +0100
+++ b/ckan/lib/helpers.py	Wed Jul 27 16:36:03 2011 +0100
@@ -29,6 +29,7 @@
 except ImportError:
     import simplejson as json
 
+ISO_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%f'
 
 class Message(object):
     """A message returned by ``Flash.pop_messages()``.
@@ -210,5 +211,8 @@
     else:
         return ''
 
-def time_ago_in_words_from_str(date_str, format='%Y-%m-%dT%H:%M:%S.%f',granularity='month'):
+def date_str_to_datetime(date_str, format=ISO_DATE_FORMAT):
+    return datetime.strptime(date_str, format)
+
+def time_ago_in_words_from_str(date_str, format=ISO_DATE_FORMAT, granularity='month'):
     return date.time_ago_in_words(datetime.strptime(date_str, format), granularity=granularity)


http://bitbucket.org/okfn/ckan/changeset/b42c18503c84/
changeset:   b42c18503c84
branch:      feature-1229-db-out-of-controllers
user:        amercader
date:        2011-07-27 17:37:00
summary:     Fix typo
affected #:  3 files (0 bytes)

--- a/ckan/controllers/package.py	Wed Jul 27 16:36:03 2011 +0100
+++ b/ckan/controllers/package.py	Wed Jul 27 16:37:00 2011 +0100
@@ -77,7 +77,7 @@
             raise DataError(data_dict)
 
     def _setup_template_variables(self, context, data_dict):
-        c.groups = get.group_list_availible(context, data_dict)
+        c.groups = get.group_list_available(context, data_dict)
         c.groups_authz = get.group_list_authz(context, data_dict)
         c.licences = [('', '')] + model.Package.get_license_options()
         c.is_sysadmin = Authorizer().is_sysadmin(c.user)


--- a/ckan/lib/navl/dictization_functions.py	Wed Jul 27 16:36:03 2011 +0100
+++ b/ckan/lib/navl/dictization_functions.py	Wed Jul 27 16:37:00 2011 +0100
@@ -81,7 +81,7 @@
 
 def make_full_schema(data, schema):
     '''make schema by getting all valid combinations and making sure that all keys
-    are availible'''
+    are available'''
 
     flattented_schema = flatten_schema(schema)
 


--- a/ckan/logic/action/get.py	Wed Jul 27 16:36:03 2011 +0100
+++ b/ckan/logic/action/get.py	Wed Jul 27 16:37:00 2011 +0100
@@ -119,7 +119,7 @@
     groups = set(query.all())
     return dict((group.id, group.name) for group in groups)
 
-def group_list_availible(context, data_dict):
+def group_list_available(context, data_dict):
     model = context['model']
     user = context['user']
     pkg = context.get('package')


http://bitbucket.org/okfn/ckan/changeset/1f9081f6dd6c/
changeset:   1f9081f6dd6c
branch:      feature-1229-db-out-of-controllers
user:        amercader
date:        2011-07-28 13:21:47
summary:     [merge] from default
affected #:  46 files (33.7 KB)

--- a/CHANGELOG.txt	Wed Jul 27 16:37:00 2011 +0100
+++ b/CHANGELOG.txt	Thu Jul 28 12:21:47 2011 +0100
@@ -5,7 +5,19 @@
 =================
 Major:
   * Packages revisions can be marked as 'moderated' (#1141)
+  * Password reset facility (#1186/#1198)
+
+Minor:
   * Viewing of a package at any revision (#1141)
+  * API POSTs can be of Content-Type "application/json" as alternative to existing "application/x-www-form-urlencoded" (#1206)
+  * Caching of static files (#1223)
+
+Bug fixes:
+  * When you removed last row of resource table, you could't add it again - since 1.0 (#1215)
+  * Exception if you had any Groups and migrated between CKAN v1.0.2 to v1.2 (migration 29) - since v1.0.2 (#1205)
+  * API Package edit requests returned the Package in a different format to usual - since 1.4 (#1214)
+  * API error responses were not all JSON format and didn't have correct Content-Type (#1214)
+  * API package delete doesn't require a Content-Length header (#1214)
 
 
 v1.4.1 2011-06-27


--- a/README.txt	Wed Jul 27 16:37:00 2011 +0100
+++ b/README.txt	Thu Jul 28 12:21:47 2011 +0100
@@ -371,6 +371,11 @@
 
         python -c "import pylons"
 
+* `OperationalError: (OperationalError) no such function: plainto_tsquery ...`
+
+   This error usually results from running a test which involves search functionality, which requires using a PostgreSQL database, but another (such as SQLite) is configured. The particular test is either missing a `@search_related` decorator or there is a mixup with the test configuration files leading to the wrong database being used.
+
+
 Testing extensions
 ------------------
 


--- a/ckan/ckan_nose_plugin.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/ckan_nose_plugin.py	Thu Jul 28 12:21:47 2011 +0100
@@ -4,8 +4,7 @@
 import sys
 import pkg_resources
 from paste.deploy import loadapp
-
-pylonsapp = None
+from pylons import config
 
 class CkanNose(Plugin):
     settings = None
@@ -29,6 +28,15 @@
             # the db is destroyed after every test when you Session.Remove().
             model.repo.init_db()
 
+            ## This is to make sure the configuration is run again.
+            ## Plugins use configure to make their own tables and they
+            ## may need to be recreated to make tests work.
+            from ckan.plugins import PluginImplementations
+            from ckan.plugins.interfaces import IConfigurable
+            for plugin in PluginImplementations(IConfigurable):
+                plugin.configure(config)
+            
+
     def options(self, parser, env):
         parser.add_option(
             '--ckan',


--- a/ckan/controllers/api.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/controllers/api.py	Thu Jul 28 12:21:47 2011 +0100
@@ -11,6 +11,7 @@
 from ckan.plugins import PluginImplementations, IGroupController
 from ckan.lib.munge import munge_title_to_name
 from ckan.lib.navl.dictization_functions import DataError
+from ckan.logic import get_action
 import ckan.logic.action.get as get 
 import ckan.logic.action.create as create
 import ckan.logic.action.update as update
@@ -129,32 +130,9 @@
         response_data = {}
         response_data['version'] = ver or '1'
         return self._finish_ok(response_data) 
-
-    @classmethod
-    def create_actions(cls):
-        if cls._actions:
-            return 
-        for name, action in get.__dict__.iteritems():
-            if not name.startswith('_') and callable(action):
-                cls._actions[name] = action
-        for name, action in update.__dict__.iteritems():
-            if not name.startswith('_') and callable(action):
-                cls._actions[name] = action
-        for name, action in create.__dict__.iteritems():
-            if not name.startswith('_') and callable(action):
-                cls._actions[name] = action
-
-    def get_action(self, action):
-        self.create_actions()
-        return self._actions[action]
-
-    @classmethod
-    def register_action(cls, name, function):
-        cls.create_actions()
-        cls._actions[name] = function
     
     def action(self, logic_function):
-        function = self.get_action(logic_function)
+        function = get_action(logic_function)
         
         context = {'model': model, 'session': model.Session, 'user': c.user}
         model.Session()._context = context
@@ -259,10 +237,10 @@
     def create(self, ver=None, register=None, subregister=None, id=None, id2=None):
 
         action_map = {
-            ('package', 'relationships'): create.package_relationship_create,
-             'group': create.group_create_rest,
-             'package': create.package_create_rest,
-             'rating': create.rating_create,
+            ('package', 'relationships'): get_action('package_relationship_create'),
+             'group': get_action('group_create_rest'),
+             'package': get_action('package_create_rest'),
+             'rating': get_action('rating_create'),
         }
 
         for type in model.PackageRelationship.get_all_types():
@@ -309,9 +287,9 @@
     def update(self, ver=None, register=None, subregister=None, id=None, id2=None):
 
         action_map = {
-            ('package', 'relationships'): update.package_relationship_update,
-             'package': update.package_update_rest,
-             'group': update.group_update_rest,
+            ('package', 'relationships'): get_action('package_relationship_update'),
+             'package': get_action('package_update_rest'),
+             'group': get_action('group_update_rest'),
         }
         for type in model.PackageRelationship.get_all_types():
             action_map[('package', type)] = update.package_relationship_update


--- a/ckan/controllers/home.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/controllers/home.py	Thu Jul 28 12:21:47 2011 +0100
@@ -43,7 +43,8 @@
 
         query = query_for(model.Package)
         query.run(query='*:*', facet_by=g.facets,
-                  limit=0, offset=0, username=c.user)
+                  limit=0, offset=0, username=c.user,
+                  order_by=None)
         c.facets = query.facets
         c.fields = []
         c.package_count = query.count
@@ -74,7 +75,7 @@
                 abort(400, _('Invalid language specified'))
             h.flash_notice(_("Language has been set to: English"))
         else:
-            h.flash_notice(_("No language given!"))
+            abort(400, _("No language given!"))
         return_to = get_redirect()
         if not return_to:
             # no need for error, just don't redirect


--- a/ckan/controllers/package.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/controllers/package.py	Thu Jul 28 12:21:47 2011 +0100
@@ -15,6 +15,7 @@
 import ckan.logic.action.create as create
 import ckan.logic.action.update as update
 import ckan.logic.action.get as get
+from ckan.logic import get_action
 from ckan.logic.schema import package_form_schema
 from ckan.lib.base import request, c, BaseController, model, abort, h, g, render
 from ckan.lib.base import etag_cache, response, redirect, gettext
@@ -173,24 +174,30 @@
         # revision may have more than one package in it.
         return str(hash((pkg.id, pkg.latest_related_revision.id, c.user, pkg.get_average_rating())))
 
-    def _clear_pkg_cache(self, pkg):
-        read_cache = cache.get_cache('package/read.html', type='dbm')
-        read_cache.remove_value(self._pkg_cache_key(pkg))
-
     @proxy_cache()
     def read(self, id):
         context = {'model': model, 'session': model.Session,
                    'user': c.user or c.author, 'extras_as_string': True,
                    'schema': self._form_to_db_schema()}
         data_dict = {'id': id}
+
+        # interpret @<revision_id> or @<date> suffix
         split = id.split('@')
         if len(split) == 2:
-            data_dict['id'], revision = split
-            try:
-                date = datetime.datetime(*map(int, re.split('[^\d]', revision)))
-                context['revision_date'] = date
-            except ValueError:
-                context['revision_id'] = revision
+            data_dict['id'], revision_ref = split
+            if model.is_id(revision_ref):
+                context['revision_id'] = revision_ref
+            else:
+                try:
+                    date = model.strptimestamp(revision_ref)
+                    context['revision_date'] = date
+                except TypeError, e:
+                    abort(400, _('Invalid revision format: %r') % e.args)
+                except ValueError, e:
+                    abort(400, _('Invalid revision format: %r') % e.args)
+        elif len(split) > 2:
+            abort(400, _('Invalid revision format: %r') % 'Too many "@" symbols')
+            
         #check if package exists
         try:
             c.pkg_dict = get.package_show(context, data_dict)
@@ -437,7 +444,7 @@
                 tuplize_dict(parse_params(request.POST))))
             self._check_data_dict(data_dict)
             context['message'] = data_dict.get('log_message', '')
-            pkg = create.package_create(context, data_dict)
+            pkg = get_action('package_create')(context, data_dict)
 
             if context['preview']:
                 PackageSaver().render_package(pkg, context)
@@ -468,7 +475,7 @@
             if not context['moderated']:
                 context['pending'] = False
             data_dict['id'] = id
-            pkg = update.package_update(context, data_dict)
+            pkg = get_action('package_update')(context, data_dict)
             if request.params.get('save', '') == 'Approve':
                 update.make_latest_pending_package_active(context, data_dict)
             c.pkg = context['package']
@@ -522,6 +529,7 @@
         pkg = model.Package.get(id)
         if pkg is None:
             abort(404, gettext('Package not found'))
+        c.pkg = pkg # needed to add in the tab bar to the top of the auth page
         c.pkgname = pkg.name
         c.pkgtitle = pkg.title
 


--- a/ckan/controllers/user.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/controllers/user.py	Thu Jul 28 12:21:47 2011 +0100
@@ -216,6 +216,8 @@
 
 
     def login(self):
+        if 'error' in request.params:
+            h.flash_error(request.params['error'])
         return render('user/login.html')
     
     def logged_in(self):


--- a/ckan/lib/dictization/model_dictize.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/lib/dictization/model_dictize.py	Thu Jul 28 12:21:47 2011 +0100
@@ -78,7 +78,11 @@
     return resource
 
 def _execute_with_revision(q, rev_table, context):
+    '''
+    Raises NotFound if the context['revision_id'] does not exist.
+    Returns [] if there are no results.
 
+    '''
     model = context['model']
     meta = model.meta
     session = model.Session
@@ -87,8 +91,11 @@
     pending = context.get('pending')
 
     if revision_id:
-        revision_date = session.query(context['model'].Revision).filter_by(
-            id=revision_id).one().timestamp
+        revision = session.query(context['model'].Revision).filter_by(
+            id=revision_id).first()
+        if not revision:
+            raise NotFound
+        revision_date = revision.timestamp
     
     if revision_date:
         q = q.where(rev_table.c.revision_timestamp <= revision_date)


--- a/ckan/lib/dictization/model_save.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/lib/dictization/model_save.py	Thu Jul 28 12:21:47 2011 +0100
@@ -5,17 +5,15 @@
 ##package saving
 
 def resource_dict_save(res_dict, context):
-
     model = context["model"]
     session = context["session"]
 
-    obj = None
-
+    # try to get resource object directly from context, then by ID
+    # if not found, create a new resource object
     id = res_dict.get("id")
-    
-    if id:
+    obj = context.get("resource")
+    if (not obj) and id:
         obj = session.query(model.Resource).get(id)
-
     if not obj:
         obj = model.Resource()
 
@@ -30,14 +28,17 @@
         if key in fields:
             setattr(obj, key, value)
         else:
+            # resources save extras directly onto the object, instead
+            # of in a separate extras field like packages and groups
             obj.extras[key] = value
 
     if context.get('pending'):
         if session.is_modified(obj, include_collections=False):
-            obj.state = 'pending'
+            obj.state = u'pending'
+    else:
+        obj.state = u'active'
 
     session.add(obj)
-
     return obj
 
 def package_resource_list_save(res_dicts, package, context):
@@ -126,7 +127,6 @@
     return result_dict
 
 def package_tag_list_save(tag_dicts, package, context):
-
     
     allow_partial_update = context.get("allow_partial_update", False)
     if not tag_dicts and allow_partial_update:


--- a/ckan/lib/helpers.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/lib/helpers.py	Thu Jul 28 12:21:47 2011 +0100
@@ -204,10 +204,22 @@
 
 
 def render_datetime(datetime_):
-    '''Render a datetime object as a string in a reasonable way (Y-m-d H:m).
+    '''Render a datetime object or timestamp string as a pretty string
+    (Y-m-d H:m).
+    If timestamp is badly formatted, then a blank string is returned.
     '''
-    if datetime_:
-        return datetime_.strftime('%Y-%m-%d %H:%M')
+    from ckan import model
+    date_format = '%Y-%m-%d %H:%M'
+    if isinstance(datetime_, datetime):
+        return datetime_.strftime(date_format)
+    elif isinstance(datetime_, basestring):
+        try:
+            datetime_ = model.strptimestamp(datetime_)
+        except TypeError:
+            return ''
+        except ValueError:
+            return ''
+        return datetime_.strftime(date_format)
     else:
         return ''
 


--- a/ckan/lib/mailer.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/lib/mailer.py	Thu Jul 28 12:21:47 2011 +0100
@@ -17,13 +17,16 @@
 class MailerException(Exception):
     pass
 
+def add_msg_niceties(recipient_name, body, sender_name, sender_url):
+    return _(u"Dear %s,") % recipient_name \
+           + u"\r\n\r\n%s\r\n\r\n" % body \
+           + u"--\r\n%s (%s)" % (sender_name, sender_url)
+
 def _mail_recipient(recipient_name, recipient_email,
         sender_name, sender_url, subject,
         body, headers={}):
     mail_from = config.get('ckan.mail_from')
-    body = _(u"Dear %s,") % recipient_name \
-         + u"\r\n\r\n%s\r\n\r\n" % body \
-         + u"--\r\n%s (%s)" % (sender_name, sender_url)
+    body = add_msg_niceties(recipient_name, body, sender_name, sender_url)
     msg = MIMEText(body.encode('utf-8'), 'plain', 'utf-8')
     for k, v in headers.items(): msg[k] = v
     subject = Header(subject.encode('utf-8'), 'utf-8')
@@ -34,7 +37,9 @@
     msg['Date'] = Utils.formatdate(time())
     msg['X-Mailer'] = "CKAN %s" % __version__
     try:
-        server = smtplib.SMTP(config.get('smtp_server', 'localhost'))
+        server = smtplib.SMTP(
+            config.get('test_smtp_server',
+                       config.get('smtp_server', 'localhost')))
         #server.set_debuglevel(1)
         server.sendmail(mail_from, [recipient_email], msg.as_string())
         server.quit()
@@ -48,15 +53,12 @@
             g.site_title, g.site_url, subject, body, headers=headers)
 
 def mail_user(recipient, subject, body, headers={}):
-    if (recipient.email is None) and len(recipient.email):
+    if (recipient.email is None) or not len(recipient.email):
         raise MailerException(_("No recipient email address available!"))
     mail_recipient(recipient.display_name, recipient.email, subject, 
             body, headers=headers)
 
 
-def make_key():
-    return uuid.uuid4().hex[:10]
-
 RESET_LINK_MESSAGE = _(
 '''You have requested your password on %(site_title)s to be reset.
 
@@ -65,16 +67,30 @@
    %(reset_link)s
 ''')
 
-def send_reset_link(user):
-    user.reset_key = make_key()
-    model.Session.add(user)
-    model.Session.commit()
+def make_key():
+    return uuid.uuid4().hex[:10]
+
+def create_reset_key(user):
+    user.reset_key = unicode(make_key())
+    model.repo.commit_and_remove()
+
+def get_reset_link(user):
+    return urljoin(g.site_url,
+                   url_for(controller='user',
+                           action='perform_reset',
+                           id=user.id,
+                           key=user.reset_key))
+
+def get_reset_link_body(user):
     d = {
-        'reset_link': urljoin(g.site_url, url_for(controller='user',
-            action='perform_reset', id=user.id, key=user.reset_key)),
+        'reset_link': get_reset_link(user),
         'site_title': g.site_title
         }
-    body = RESET_LINK_MESSAGE % d
+    return RESET_LINK_MESSAGE % d
+
+def send_reset_link(user):
+    create_reset_key(user)
+    body = get_reset_link_body(user)
     mail_user(user, _('Reset your password'), body)
 
 def verify_reset_link(user, key):


--- a/ckan/lib/package_saver.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/lib/package_saver.py	Thu Jul 28 12:21:47 2011 +0100
@@ -62,6 +62,11 @@
             if isinstance(v, (list, tuple)):
                 v = ", ".join(map(unicode, v))
             c.pkg_extras.append((k, v))
+        if context.get('revision_id') or context.get('revision_date'):
+            # request was for a specific revision id or date
+            c.pkg_revision_id = c.pkg_dict[u'revision_id']
+            c.pkg_revision_timestamp = c.pkg_dict[u'revision_timestamp']
+            c.pkg_revision_not_latest = c.pkg_dict[u'revision_id'] != c.pkg.revision.id
 
     @classmethod
     def _preview_pkg(cls, fs, log_message=None, author=None, client=None):


--- a/ckan/logic/__init__.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/logic/__init__.py	Thu Jul 28 12:21:47 2011 +0100
@@ -1,6 +1,8 @@
 import logging
 import ckan.authz
 from ckan.lib.navl.dictization_functions import flatten_dict
+from ckan.plugins import PluginImplementations
+from ckan.plugins.interfaces import IActions
 
 class ActionError(Exception):
     def __init__(self, extra_msg=None):
@@ -90,4 +92,42 @@
         log.debug("No valid API key provided.")
         raise NotAuthorized
     log.debug("Access OK.")
-    return True                
+    return True             
+
+_actions = {}
+
+def get_action(action):
+    if _actions:
+        return _actions.get(action)
+    # Otherwise look in all the plugins to resolve all possible
+    # First get the default ones in the ckan/logic/action directory
+    # Rather than writing them out in full will use __import__
+    # to load anything from ckan.logic.action that looks like it might
+    # be an action 
+    for action_module_name in ['get', 'create', 'update']:
+        module_path = 'ckan.logic.action.'+action_module_name
+        module = __import__(module_path)
+        for part in module_path.split('.')[1:]:
+            module = getattr(module, part)
+        for k, v in module.__dict__.items():
+            if not k.startswith('_'):
+                _actions[k] = v
+    # Then overwrite them with any specific ones in the plugins:
+    resolved_action_plugins = {}
+    fetched_actions = {}
+    for plugin in PluginImplementations(IActions):
+        for name, auth_function in plugin.get_actions().items():
+            if name in resolved_action_plugins:
+                raise Exception(
+                    'The action %r is already implemented in %r' % (
+                        name,
+                        resolved_action_plugins[name]
+                    )
+                )
+            log.debug('Auth function %r was inserted', plugin.name)
+            resolved_action_plugins[name] = plugin.name
+            fetched_actions[name] = auth_function
+    # Use the updated ones in preference to the originals.
+    _actions.update(fetched_actions)
+    return _actions.get(action)
+


--- a/ckan/logic/action/create.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/logic/action/create.py	Thu Jul 28 12:21:47 2011 +0100
@@ -79,6 +79,25 @@
     else:
         return data
 
+def package_create_validate(context, data_dict):
+    model = context['model']
+    user = context['user']
+    preview = context.get('preview', False)
+    schema = context.get('schema') or default_create_package_schema()
+    model.Session.remove()
+    model.Session()._context = context
+
+    check_access(model.System(), model.Action.PACKAGE_CREATE, context)
+    check_group_auth(context, data_dict)
+
+    data, errors = validate(data_dict, schema, context)
+
+    if errors:
+        model.Session.rollback()
+        raise ValidationError(errors, package_error_summary(errors))
+    else:
+        return data
+
 def resource_create(context, data_dict):
     model = context['model']
     user = context['user']


--- a/ckan/logic/action/get.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/logic/action/get.py	Thu Jul 28 12:21:47 2011 +0100
@@ -2,6 +2,7 @@
 from sqlalchemy import or_, func, desc
 
 from ckan.logic import NotFound, check_access
+from ckan.model import Session
 from ckan.plugins import (PluginImplementations,
                           IGroupController,
                           IPackageController)
@@ -30,7 +31,9 @@
     api = context.get("api_version", '1')
     ref_package_by = 'id' if api == '2' else 'name'
 
-    query = ckan.authz.Authorizer().authorized_query(user, model.Package)
+    query = Session.query(model.PackageRevision)
+    query = query.filter(model.PackageRevision.state=='active')
+    query = query.filter(model.PackageRevision.current==True)
     packages = query.all()
     return [getattr(p, ref_package_by) for p in packages]
 
@@ -39,7 +42,7 @@
     user = context["user"]
     limit = data_dict.get("limit")
 
-    q = ckan.authz.Authorizer().authorized_query(user, model.PackageRevision)
+    q = Session.query(model.PackageRevision)
     q = q.filter(model.PackageRevision.state=='active')
     q = q.filter(model.PackageRevision.current==True)
 
@@ -59,8 +62,12 @@
         result_dict["resources"] = resource_list_dictize(result, context)
         license_id = result_dict['license_id']
         if license_id:
-            isopen = model.Package.get_license_register()[license_id].isopen()
-            result_dict['isopen'] = isopen
+            try:
+                isopen = model.Package.get_license_register()[license_id].isopen()
+                result_dict['isopen'] = isopen
+            except KeyError:
+                # TODO: create a log message this error?
+                result_dict['isopen'] = False 
         else:
             result_dict['isopen'] = False
         package_list.append(result_dict)


--- a/ckan/logic/action/update.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/logic/action/update.py	Thu Jul 28 12:21:47 2011 +0100
@@ -9,6 +9,7 @@
 from ckan.lib.dictization.model_dictize import (package_dictize,
                                                 package_to_api1,
                                                 package_to_api2,
+                                                resource_dictize,
                                                 group_dictize,
                                                 group_to_api1,
                                                 group_to_api2,
@@ -16,11 +17,13 @@
 from ckan.lib.dictization.model_save import (group_api_to_dict,
                                              package_api_to_dict,
                                              group_dict_save,
+                                             user_dict_save,
                                              package_dict_save,
-                                             user_dict_save)
+                                             resource_dict_save)
 from ckan.logic.schema import (default_update_group_schema,
                                default_update_package_schema,
-                               default_update_user_schema)
+                               default_update_user_schema,
+                               default_update_resource_schema)
 from ckan.lib.navl.dictization_functions import validate
 log = logging.getLogger(__name__)
 
@@ -42,6 +45,18 @@
             error_summary[_(prettify(key))] = error[0]
     return error_summary
 
+def resource_error_summary(error_dict):
+
+    error_summary = {}
+    for key, error in error_dict.iteritems():
+        if key == 'extras':
+            error_summary[_('Extras')] = _('Missing Value')
+        elif key == 'extras_validation':
+            error_summary[_('Extras')] = error[0]
+        else:
+            error_summary[_(prettify(key))] = error[0]
+    return error_summary
+
 def group_error_summary(error_dict):
 
     error_summary = {}
@@ -149,6 +164,50 @@
     session.remove()        
 
 
+def resource_update(context, data_dict):
+    model = context['model']
+    session = context['session']
+    user = context['user']
+    id = context["id"]
+    schema = context.get('schema') or default_update_resource_schema()
+    model.Session.remove()
+
+    resource = model.Resource.get(id)
+    context["resource"] = resource
+
+    if not resource:
+        raise NotFound(_('Resource was not found.'))
+    context["id"] = resource.id
+
+    # TODO: can check_access be used against a resource?
+    query = session.query(model.Package
+    ).join(model.ResourceGroup
+    ).join(model.Resource
+    ).filter(model.ResourceGroup.id == resource.resource_group_id)
+    pkg = query.first()
+    if not pkg:
+        raise NotFound(_('No package found for this resource, cannot check auth.'))
+
+    check_access(pkg, model.Action.EDIT, context)
+
+    data, errors = validate(data_dict, schema, context)
+
+    if errors:
+        model.Session.rollback()
+        raise ValidationError(errors, resource_error_summary(errors))
+
+    rev = model.repo.new_revision()
+    rev.author = user
+    if 'message' in context:
+        rev.message = context['message']
+    else:
+        rev.message = _(u'REST API: Update object %s') % data.get("name")
+
+    resource = resource_dict_save(data, context)
+    model.repo.commit()        
+    return resource_dictize(resource, context)
+
+
 def package_update(context, data_dict):
     model = context['model']
     user = context['user']
@@ -193,6 +252,31 @@
         return package_dictize(pkg, context)
     return data
 
+def package_update_validate(context, data_dict):
+    model = context['model']
+    user = context['user']
+    
+    id = data_dict["id"]
+    preview = context.get('preview', False)
+    schema = context.get('schema') or default_update_package_schema()
+    model.Session.remove()
+    model.Session()._context = context
+
+    pkg = model.Package.get(id)
+    context["package"] = pkg
+
+    if pkg is None:
+        raise NotFound(_('Package was not found.'))
+    data_dict["id"] = pkg.id
+
+    check_access(pkg, model.Action.EDIT, context)
+    data, errors = validate(data_dict, schema, context)
+
+    if errors:
+        model.Session.rollback()
+        raise ValidationError(errors, package_error_summary(errors))
+    return data
+
 
 def _update_package_relationship(relationship, comment, context):
     model = context['model']
@@ -294,7 +378,7 @@
         raise NotFound('User was not found.')
 
     if not (ckan.authz.Authorizer().is_sysadmin(unicode(user)) or user == user_obj.name) and \
-       not ('reset_key' in data_dict and data_dict['reset_key'] == user_obj['reset_key']):
+       not ('reset_key' in data_dict and data_dict['reset_key'] == user_obj.reset_key):
         raise NotAuthorized( _('User %s not authorized to edit %s') % (str(user), id))
 
     data, errors = validate(data_dict, schema, context)


--- a/ckan/logic/schema.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/logic/schema.py	Thu Jul 28 12:21:47 2011 +0100
@@ -50,6 +50,10 @@
 
     return schema
 
+def default_update_resource_schema():
+    schema = default_resource_schema()
+    return schema
+
 def default_tags_schema():
 
     schema = {


--- a/ckan/migration/versions/039_add_expired_id_and_dates.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/migration/versions/039_add_expired_id_and_dates.py	Thu Jul 28 12:21:47 2011 +0100
@@ -28,7 +28,7 @@
 
 insert into package_revision (id,name,title,url,notes,license_id,revision_id,version,author,author_email,maintainer,maintainer_email,state,continuity_id) select id,name,title,url,notes,license_id, '%(id)s',version,author,author_email,maintainer,maintainer_email,state, id from package where package.id not in (select id from package_revision);
 
-''' % dict(id=id, timestamp=datetime.datetime.now().isoformat())
+''' % dict(id=id, timestamp=datetime.datetime.utcnow().isoformat())
 
 
     update_schema = '''


--- a/ckan/model/__init__.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/model/__init__.py	Thu Jul 28 12:21:47 2011 +0100
@@ -220,10 +220,20 @@
 Revision.user = property(_get_revision_user)
 
 def strptimestamp(s):
+    '''Convert a string of an ISO date into a datetime.datetime object.
+    
+    raises TypeError if the number of numbers in the string is not between 3
+                     and 7 (see datetime constructor).
+    raises ValueError if any of the numbers are out of range.
+    '''
+    
     import datetime, re
     return datetime.datetime(*map(int, re.split('[^\d]', s)))
 
 def strftimestamp(t):
+    '''Takes a datetime.datetime and returns it as an ISO string. For
+    a pretty printed string, use ckan.lib.helpers.render_datetime.
+    '''
     return t.isoformat()
 
 def revision_as_dict(revision, include_packages=True, include_groups=True,ref_package_by='name'):
@@ -242,3 +252,8 @@
                                      for grp in revision.groups]
        
     return revision_dict
+
+def is_id(id_string):
+    '''Tells the client if the string looks like a revision id or not'''
+    import re
+    return bool(re.match('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', id_string))


--- a/ckan/model/resource.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/model/resource.py	Thu Jul 28 12:21:47 2011 +0100
@@ -81,6 +81,15 @@
         if self.resource_group and not core_columns_only:
             _dict["package_id"] = self.resource_group.package_id
         return _dict
+
+    @classmethod
+    def get(cls, reference):
+        '''Returns a resource object referenced by its id.'''
+        query = Session.query(ResourceRevision).filter(ResourceRevision.id==reference)
+        query = query.filter(and_(
+            ResourceRevision.state == u'active', ResourceRevision.current == True
+        ))
+        return query.first()
         
     @classmethod
     def get_columns(cls, extra_columns=True):


--- a/ckan/model/tag.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/model/tag.py	Thu Jul 28 12:21:47 2011 +0100
@@ -1,4 +1,5 @@
 from sqlalchemy.orm import eagerload_all
+from sqlalchemy import and_
 import vdm.sqlalchemy
 
 from types import make_uuid
@@ -60,15 +61,21 @@
     @classmethod
     def all(cls):
         q = Session.query(cls)
-        q = q.distinct().join(cls.package_tags)
-        q = q.filter(PackageTag.state == 'active')
+        q = q.distinct().join(PackageTagRevision)
+        q = q.filter(and_(
+            PackageTagRevision.state == 'active', PackageTagRevision.current == True
+        ))
         return q
 
     @property
     def packages_ordered(self):
-        ## make sure packages are active
-        packages = [package for package in self.packages 
-                    if package.state == State.ACTIVE]
+        q = Session.query(Package)
+        q = q.join(PackageTagRevision)
+        q = q.filter(PackageTagRevision.tag_id == self.id)
+        q = q.filter(and_(
+            PackageTagRevision.state == 'active', PackageTagRevision.current == True
+        ))
+        packages = [p for p in q]
         ourcmp = lambda pkg1, pkg2: cmp(pkg1.name, pkg2.name)
         return sorted(packages, cmp=ourcmp)
 


--- a/ckan/plugins/interfaces.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/plugins/interfaces.py	Thu Jul 28 12:21:47 2011 +0100
@@ -10,7 +10,8 @@
     'IMiddleware',
     'IDomainObjectModification', 'IGroupController', 
     'IPackageController', 'IPluginObserver',
-    'IConfigurable', 'IConfigurer', 'IAuthorizer'
+    'IConfigurable', 'IConfigurer', 'IAuthorizer',
+    'IActions'
 ]
 
 from inspect import isclass
@@ -300,4 +301,12 @@
         other Authorizers to run; True will shortcircuit and return.
         """
         
-
+class IActions(Interface):
+    """
+    Allow adding of actions to the logic layer.
+    """
+    def get_actions(self):
+        """
+        Should return a dict, the keys being the name of the logic 
+        function and the values being the functions themselves.
+        """


--- a/ckan/public/css/ckan.css	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/public/css/ckan.css	Thu Jul 28 12:21:47 2011 +0100
@@ -357,6 +357,13 @@
   display: inline-block;
   margin-top: 0;
   margin-right: 10px;
+  /* 
+   * IE 6 & 7 don't support inline-block, but we can use the hasLayout 
+   * magical property. 
+   * http://blog.mozilla.com/webdev/2009/02/20/cross-browser-inline-block/ 
+   */ 
+  zoom: 1; 
+  *display: inline; 
 }
 
 #footer-widget-area .widget-container .textwidget {
@@ -377,6 +384,13 @@
   margin: 0 1em 0 0;
   padding: 0;
   display: inline-block;
+  /* 
+   * IE 6 & 7 don't support inline-block, but we can use the hasLayout 
+   * magical property. 
+   * http://blog.mozilla.com/webdev/2009/02/20/cross-browser-inline-block/ 
+   */ 
+  zoom: 1; 
+  *display: inline; 
 }
 
 #footer-widget-area #fourth {
@@ -943,6 +957,20 @@
   float: none;
 }
 
+#revision.widget-container
+{
+  background: #f9f2ce;
+  color: #333;
+  margin: 0 0 1em 0;
+  padding: 10px;
+	border: 1px solid #ebd897;
+	border-left: none;
+	border-top: none;
+	border-radius: 0.5em;
+	-moz-border-radius: 0.5em;
+	-webkit-border-radius: 0.5em;
+}
+
 /* ===================== */
 /* = User Listing      = */
 /* ===================== */


Binary file ckan/public/images/icons/page_stack.png has changed


--- a/ckan/templates/_util.html	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/templates/_util.html	Thu Jul 28 12:21:47 2011 +0100
@@ -269,8 +269,8 @@
     </py:for></table>
 
-<!-- Copy and paste of above table. Only difference when created was the h.linked_user for the  -->
-<!-- table rows. How to combine the two? -->
+<!--! Copy and paste of above table. Only difference when created was the h.linked_user for the  -->
+<!--! table rows. How to combine the two? --><table py:def="authz_form_group_table(id, roles, users, user_role_dict)"><tr><th>User Group</th>
@@ -326,7 +326,7 @@
     </tr></table>
 
-  <!-- again, copy-and-paste of above, this time to attach different autocompletion -->
+  <!--! again, copy-and-paste of above, this time to attach different autocompletion --><table py:def="authz_add_group_table(roles)"><tr><th>User Group</th>


--- a/ckan/templates/layout_base.html	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/templates/layout_base.html	Thu Jul 28 12:21:47 2011 +0100
@@ -30,7 +30,7 @@
   <![endif]--><script type="text/javascript" src="${g.site_url}/language.js"></script>
-  <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4/jquery.min.js"></script>
+  <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.5.2/jquery.min.js"></script><script type="text/javascript" src="http://assets.okfn.org/ext/jquery.cookie/jquery.cookie.min.js"></script><script type="text/javascript" src="http://assets.okfn.org/ext/jquery.placeholder/jquery.placeholder.js"></script><script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.8.11/jquery-ui.min.js"></script>


--- a/ckan/templates/package/history.html	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/templates/package/history.html	Thu Jul 28 12:21:47 2011 +0100
@@ -39,7 +39,7 @@
 
       <table><tr>
-          <th></th><th>Revision</th><th>Timestamp</th><th>Author</th><th>Log Message</th>
+          <th></th><th>Revision ID</th><th>Package with timestamp</th><th>Author</th><th>Log Message</th></tr><py:for each="index, rev in enumerate(c.pkg_revisions)"><tr>
@@ -48,9 +48,10 @@
               ${h.radio("selected2", rev.id, checked=(index == len(c.pkg_revisions)-1))}
             </td><td>
-              <a href="${h.url_for(controller='revision',action='read',id=rev['id'])}">${rev['id']}</a>
+              <a href="${h.url_for(controller='revision',action='read',id=rev['id'])}">${rev['id'][:4]}…</a></td>
-            <td>${rev['timestamp']}</td>
+            <td>
+              <a href="${h.url_for(controller='package',action='read',id='%s@%s' % (c.pkg_dict['name'], rev['timestamp']))}" title="${'Read package as of %s' % rev['timestamp']}">${h.render_datetime(rev['timestamp'])}</a></td><td>${h.linked_user(rev['author'])}</td><td>${rev['message']}</td></tr> 


--- a/ckan/templates/package/layout.html	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/templates/package/layout.html	Thu Jul 28 12:21:47 2011 +0100
@@ -12,7 +12,7 @@
       <li py:if="h.am_authorized(c, actions.EDIT, c.pkg)">
           ${h.subnav_link(c, h.icon('package_edit') + _('Edit'), controller='package', action='edit', id=c.pkg.name)}
       </li>
-      <li>${h.subnav_link(c, h.icon('page_white_stack') + _('History'), controller='package', action='history', id=c.pkg.name)}</li>
+      <li>${h.subnav_link(c, h.icon('page_stack') + _('History'), controller='package', action='history', id=c.pkg.name)}</li><li py:if="h.am_authorized(c, actions.EDIT_PERMISSIONS, c.pkg)">
         ${h.subnav_link(c, h.icon('lock') + _('Authorization'), controller='package', action='authz', id=c.pkg.name)}
       </li>


--- a/ckan/templates/package/read.html	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/templates/package/read.html	Thu Jul 28 12:21:47 2011 +0100
@@ -93,6 +93,13 @@
   </py:match><div py:match="content">
+    <py:if test="c.pkg_revision_id">
+      <div id="revision" class="widget-container">
+            <p py:if="c.pkg_revision_not_latest">This is an old revision of this package, as edited <!--!by ${h.linked_user(rev.author)}-->at ${h.render_datetime(c.pkg_revision_timestamp)}. It may differ significantly from the <a href="${url(controller='package', action='read', id=c.pkg.name)}">current revision</a>.</p>
+            <p py:if="not c.pkg_revision_not_latest">This is the current revision of this package, as edited <!--!by ${h.linked_user(rev.author)}-->at ${h.render_datetime(c.pkg_revision_timestamp)}.</p>
+      </div>
+    </py:if>
+
     <xi:include href="read_core.html" /></div>
 


--- a/ckan/templates/package/search.html	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/templates/package/search.html	Thu Jul 28 12:21:47 2011 +0100
@@ -53,10 +53,10 @@
         <p i18n:msg="item_count"><strong>There was an error while searching.</strong> 
             Please try another search term.</p></py:if>
-      <py:if test="c.q">      
+      <py:if test="request.params"><h4 i18n:msg="item_count"><strong>${c.page.item_count}</strong> packages found</h4></py:if>
-      <py:if test="c.page.item_count == 0 and c.q">
+      <py:if test="c.page.item_count == 0 and request.params"><p i18n:msg="">Would you like to <a href="${h.url_for(action='new', id=None)}">create a new package?</a></p></py:if>
       ${package_list_from_dict(c.page.items)}


--- a/ckan/templates/user/request_reset.html	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/templates/user/request_reset.html	Thu Jul 28 12:21:47 2011 +0100
@@ -14,7 +14,7 @@
       Request a password reset
     </h2>
 
-    <form id="user-edit" action="" method="post" class="simple-form" 
+    <form id="user-password-reset" action="" method="post" class="simple-form" 
       xmlns:py="http://genshi.edgewall.org/"
       xmlns:xi="http://www.w3.org/2001/XInclude"
       >
@@ -25,7 +25,7 @@
       </fieldset><div>
-        ${h.submit('save', _('Reset password'))}
+        ${h.submit('reset', _('Reset password'))}
       </div></form></div>


--- a/ckan/tests/functional/test_home.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/tests/functional/test_home.py	Thu Jul 28 12:21:47 2011 +0100
@@ -5,10 +5,11 @@
 import ckan.model as model
 
 from ckan.tests import *
+from ckan.tests.html_check import HtmlCheckMethods
 from ckan.tests.pylons_controller import PylonsTestCase
 from ckan.tests import search_related
 
-class TestHomeController(TestController, PylonsTestCase):
+class TestHomeController(TestController, PylonsTestCase, HtmlCheckMethods):
     @classmethod
     def setup_class(cls):
         PylonsTestCase.setup_class()
@@ -19,7 +20,6 @@
     def teardown_class(self):
         model.repo.rebuild_db()
 
-    @search_related
     def test_home_page(self):
         offset = url_for('home')
         res = self.app.get(offset)
@@ -48,7 +48,6 @@
         res = self.app.get(offset)
         res.click('Search', index=0)
         
-    @search_related
     def test_tags_link(self):
         offset = url_for('home')
         res = self.app.get(offset)
@@ -77,14 +76,12 @@
         assert 'Search - ' in results_page, results_page
         assert '>0<' in results_page, results_page
     
-    @search_related
     def test_template_footer_end(self):
         offset = url_for('home')
         res = self.app.get(offset)
         assert '<strong>TEST TEMPLATE_FOOTER_END TEST</strong>'
 
     # DISABLED because this is not on home page anymore
-    @search_related
     def _test_register_new_package(self):
         offset = url_for('home')
         res = self.app.get(offset)
@@ -94,7 +91,6 @@
         assert 'Register a New Package' in results_page, results_page
         assert '<input id="Package--title" name="Package--title" size="40" type="text" value="test title">' in results_page, results_page
         
-    @search_related
     def test_locale_change(self):
         offset = url_for('home')
         res = self.app.get(offset)
@@ -105,7 +101,18 @@
         finally:
             res = res.click('English')
 
-    @search_related
+    def test_locale_change_invalid(self):
+        offset = url_for(controller='home', action='locale', locale='')
+        res = self.app.get(offset, status=400)
+        main_res = self.main_div(res)
+        assert 'Invalid language specified' in main_res, main_res
+
+    def test_locale_change_blank(self):
+        offset = url_for(controller='home', action='locale')
+        res = self.app.get(offset, status=400)
+        main_res = self.main_div(res)
+        assert 'No language given!' in main_res, main_res
+
     def test_locale_change_with_false_hash(self):
         offset = url_for('home')
         res = self.app.get(offset)


--- a/ckan/tests/functional/test_package.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/tests/functional/test_package.py	Thu Jul 28 12:21:47 2011 +0100
@@ -1,4 +1,5 @@
 import cgi
+import datetime
 
 from paste.fixture import AppError
 from pylons import config
@@ -427,6 +428,178 @@
         assert plugin.calls['read'] == 1, plugin.calls
         plugins.unload(plugin)
 
+
+class TestReadAtRevision(FunctionalTestCase, HtmlCheckMethods):
+
+    @classmethod
+    def setup_class(cls):
+        cls.before = datetime.datetime(2010, 1, 1)
+        cls.date1 = datetime.datetime(2011, 1, 1)
+        cls.date2 = datetime.datetime(2011, 1, 2)
+        cls.date3 = datetime.datetime(2011, 1, 3)
+        cls.today = datetime.datetime.now()
+        cls.pkg_name = u'testpkg'
+        
+        # create package
+        rev = model.repo.new_revision()
+        rev.timestamp = cls.date1
+        pkg = model.Package(name=cls.pkg_name, title=u'title1')
+        model.Session.add(pkg)
+        model.setup_default_user_roles(pkg)
+        model.repo.commit_and_remove()
+
+        # edit package
+        rev = model.repo.new_revision()
+        rev.timestamp = cls.date2
+        pkg = model.Package.by_name(cls.pkg_name)
+        pkg.title = u'title2'
+        pkg.add_tag_by_name(u'tag2')
+        pkg.extras = {'key2': u'value2'}
+        model.repo.commit_and_remove()
+
+        # edit package again
+        rev = model.repo.new_revision()
+        rev.timestamp = cls.date3
+        pkg = model.Package.by_name(cls.pkg_name)
+        pkg.title = u'title3'
+        pkg.add_tag_by_name(u'tag3')
+        pkg.extras['key2'] = u'value3'
+        model.repo.commit_and_remove()
+
+        cls.offset = url_for(controller='package',
+                             action='read',
+                             id=cls.pkg_name)
+        pkg = model.Package.by_name(cls.pkg_name)
+        cls.revision_ids = [rev[0].id for rev in pkg.all_related_revisions[::-1]]
+                        # revision order is reversed to be chronological
+
+    @classmethod
+    def teardown_class(cls):
+        model.repo.rebuild_db()
+
+    def test_read_normally(self):
+        res = self.app.get(self.offset, status=200)
+        pkg_html = self.named_div('package', res)
+        side_html = self.named_div('primary', res)
+        print 'PKG', pkg_html
+        assert 'title3' in pkg_html
+        assert 'key2' in pkg_html
+        assert 'value3' in pkg_html
+        print 'SIDE', side_html
+        assert 'tag3' in side_html
+        assert 'tag2' in side_html
+
+    def test_read_date1(self):
+        offset = self.offset + self.date1.strftime('@%Y-%m-%d')
+        res = self.app.get(offset, status=200)
+        pkg_html = self.named_div('package', res)
+        side_html = self.named_div('primary', res)
+        print 'PKG', pkg_html
+        assert 'title1' in pkg_html
+        assert 'key2' not in pkg_html
+        assert 'value3' not in pkg_html
+        print 'SIDE', side_html
+        assert 'tag3' not in side_html
+        assert 'tag2' not in side_html
+
+    def test_read_date2(self):
+        date2_plus_3h = self.date2 + datetime.timedelta(hours=3)
+        offset = self.offset + date2_plus_3h.strftime('@%Y-%m-%d')
+        res = self.app.get(offset, status=200)
+        pkg_html = self.named_div('package', res)
+        side_html = self.named_div('primary', res)
+        print 'PKG', pkg_html
+        assert 'title2' in pkg_html
+        assert 'key2' in pkg_html
+        assert 'value2' in pkg_html
+        print 'SIDE', side_html
+        assert 'tag3' not in side_html
+        assert 'tag2' in side_html
+
+    def test_read_date3(self):
+        offset = self.offset + self.date3.strftime('@%Y-%m-%d-%H-%M')
+        res = self.app.get(offset, status=200)
+        pkg_html = self.named_div('package', res)
+        side_html = self.named_div('primary', res)
+        print 'PKG', pkg_html
+        assert 'title3' in pkg_html
+        assert 'key2' in pkg_html
+        assert 'value3' in pkg_html
+        print 'SIDE', side_html
+        assert 'tag3' in side_html
+        assert 'tag2' in side_html
+
+    def test_read_date_before_created(self):
+        offset = self.offset + self.before.strftime('@%Y-%m-%d')
+        res = self.app.get(offset, status=404)
+
+    def test_read_date_invalid(self):
+        res = self.app.get(self.offset + self.date3.strftime('@%Y-%m'),
+                           status=400)
+        res = self.app.get(self.offset + self.date3.strftime('@%Y'),
+                           status=400)
+        res = self.app.get(self.offset + self.date3.strftime('@%Y@%m'),
+                           status=400)
+
+    def test_read_revision1(self):
+        offset = self.offset + '@%s' % self.revision_ids[0]
+        res = self.app.get(offset, status=200)
+        main_html = self.main_div(res)
+        pkg_html = self.named_div('package', res)
+        side_html = self.named_div('primary', res)
+        print 'MAIN', main_html
+        assert 'This is an old revision of this package' in main_html
+        assert 'at 2011-01-01 00:00' in main_html
+        self.check_named_element(main_html, 'a', 'href="/package/%s"' % self.pkg_name)
+        print 'PKG', pkg_html
+        assert 'title1' in pkg_html
+        assert 'key2' not in pkg_html
+        assert 'value3' not in pkg_html
+        print 'SIDE', side_html
+        assert 'tag3' not in side_html
+        assert 'tag2' not in side_html
+
+    def test_read_revision2(self):
+        offset = self.offset + '@%s' % self.revision_ids[1]
+        res = self.app.get(offset, status=200)
+        main_html = self.main_div(res)
+        pkg_html = self.named_div('package', res)
+        side_html = self.named_div('primary', res)
+        print 'MAIN', main_html
+        assert 'This is an old revision of this package' in main_html
+        assert 'at 2011-01-02 00:00' in main_html
+        self.check_named_element(main_html, 'a', 'href="/package/%s"' % self.pkg_name)
+        print 'PKG', pkg_html
+        assert 'title2' in pkg_html
+        assert 'key2' in pkg_html
+        assert 'value2' in pkg_html
+        print 'SIDE', side_html
+        assert 'tag3' not in side_html
+        assert 'tag2' in side_html
+
+    def test_read_revision3(self):
+        offset = self.offset + '@%s' % self.revision_ids[2]
+        res = self.app.get(offset, status=200)
+        main_html = self.main_div(res)
+        pkg_html = self.named_div('package', res)
+        side_html = self.named_div('primary', res)
+        print 'MAIN', main_html
+        assert 'This is an old revision of this package' not in main_html
+        assert 'This is the current revision of this package' in main_html
+        assert 'at 2011-01-03 00:00' in main_html
+        self.check_named_element(main_html, 'a', 'href="/package/%s"' % self.pkg_name)
+        print 'PKG', pkg_html
+        assert 'title3' in pkg_html
+        assert 'key2' in pkg_html
+        assert 'value3' in pkg_html
+        print 'SIDE', side_html
+        assert 'tag3' in side_html
+        assert 'tag2' in side_html
+
+    def test_read_bad_revision(self):
+        # this revision doesn't exist in the db
+        offset = self.offset + '@ccab6798-1f4b-4a22-bcf5-462703aa4594'
+        res = self.app.get(offset, status=404)
         
 class TestEdit(TestPackageForm):
     editpkg_name = u'editpkgtest'
@@ -1277,36 +1450,39 @@
 
 class TestRevisions(TestPackageBase):
     @classmethod
-    def setup_class(self):
+    def setup_class(cls):
         model.Session.remove()
         model.repo.init_db()
-        self.name = u'revisiontest1'
+        cls.name = u'revisiontest1'
 
         # create pkg
-        self.notes = [u'Written by Puccini', u'Written by Rossini', u'Not written at all', u'Written again', u'Written off']
+        cls.notes = [u'Written by Puccini', u'Written by Rossini', u'Not written at all', u'Written again', u'Written off']
         rev = model.repo.new_revision()
-        self.pkg1 = model.Package(name=self.name)
-        self.pkg1.notes = self.notes[0]
-        model.Session.add(self.pkg1)
-        model.setup_default_user_roles(self.pkg1)
+        cls.pkg1 = model.Package(name=cls.name)
+        cls.pkg1.notes = cls.notes[0]
+        model.Session.add(cls.pkg1)
+        model.setup_default_user_roles(cls.pkg1)
         model.repo.commit_and_remove()
 
         # edit pkg
         for i in range(5)[1:]:
             rev = model.repo.new_revision()
-            pkg1 = model.Package.by_name(self.name)
-            pkg1.notes = self.notes[i]
+            pkg1 = model.Package.by_name(cls.name)
+            pkg1.notes = cls.notes[i]
             model.repo.commit_and_remove()
 
-        self.pkg1 = model.Package.by_name(self.name)        
+        cls.pkg1 = model.Package.by_name(cls.name)        
+        cls.revision_ids = [rev[0].id for rev in cls.pkg1.all_related_revisions]
+                           # revision ids are newest first
+        cls.revision_timestamps = [rev[0].timestamp for rev in cls.pkg1.all_related_revisions]
+        cls.offset = url_for(controller='package', action='history', id=cls.pkg1.name)
 
     @classmethod
-    def teardown_class(self):
+    def teardown_class(cls):
         model.repo.rebuild_db()
     
     def test_0_read_history(self):
-        offset = url_for(controller='package', action='history', id=self.pkg1.name)
-        res = self.app.get(offset)
+        res = self.app.get(self.offset)
         main_res = self.main_div(res)
         assert self.pkg1.name in main_res, main_res
         assert 'radio' in main_res, main_res
@@ -1318,8 +1494,7 @@
         assert last_radio_checked_html in main_res, '%s %s' % (last_radio_checked_html, main_res)
 
     def test_1_do_diff(self):
-        offset = url_for(controller='package', action='history', id=self.pkg1.name)
-        res = self.app.get(offset)
+        res = self.app.get(self.offset)
         form = res.forms['package-revisions']
         res = form.submit()
         res = res.follow()
@@ -1330,13 +1505,26 @@
         assert '<tr><td>notes</td><td><pre>- Written by Puccini\n+ Written off</pre></td></tr>' in main_res, main_res
 
     def test_2_atom_feed(self):
-        offset = url_for(controller='package', action='history', id=self.pkg1.name)
-        offset = "%s?format=atom" % offset
+        offset = "%s?format=atom" % self.offset
         res = self.app.get(offset)
         assert '<feed' in res, res
         assert 'xmlns="http://www.w3.org/2005/Atom"' in res, res
         assert '</feed>' in res, res
 
+    def test_3_history_revision_link(self):
+        res = self.app.get(self.offset)
+        res = res.click('%s' % self.revision_ids[2][:4])
+        main_res = self.main_div(res)
+        assert 'Revision: %s' % self.revision_ids[2] in main_res
+
+    def test_4_history_revision_package_link(self):
+        res = self.app.get(self.offset)
+        url = str(self.revision_timestamps[1])[-6:]
+        res = res.click(href=url)
+        main_html = self.main_div(res)
+        assert 'This is an old revision of this package' in main_html
+        assert 'at %s' % str(self.revision_timestamps[1])[:6] in main_html
+
    
 class TestMarkdownHtmlWhitelist(TestPackageForm):
 


--- a/ckan/tests/functional/test_user.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/tests/functional/test_user.py	Thu Jul 28 12:21:47 2011 +0100
@@ -4,12 +4,17 @@
 from pprint import pprint
 from ckan.tests import search_related, CreateTestData
 from ckan.tests.html_check import HtmlCheckMethods
+from ckan.tests.pylons_controller import PylonsTestCase
+from ckan.tests.mock_mail_server import SmtpServerHarness
 import ckan.model as model
 from base import FunctionalTestCase
+from ckan.lib.mailer import get_reset_link, create_reset_key
 
-class TestUserController(FunctionalTestCase, HtmlCheckMethods):
+class TestUserController(FunctionalTestCase, HtmlCheckMethods, PylonsTestCase, SmtpServerHarness):
     @classmethod
     def setup_class(self):
+        PylonsTestCase.setup_class()
+        SmtpServerHarness.setup_class()
         CreateTestData.create()
 
         # make 3 changes, authored by annafan
@@ -27,6 +32,7 @@
         
     @classmethod
     def teardown_class(self):
+        SmtpServerHarness.teardown_class()
         model.repo.rebuild_db()
 
     def teardown(self):
@@ -432,6 +438,12 @@
         assert 'looks like spam' in main_res, main_res
         assert 'Edit User: ' in main_res, main_res
 
+    def test_login_openid_error(self):
+        # comes back as a params like this:
+        # e.g. /user/login?error=Error%20in%20discovery:%20Error%20fetching%20XRDS%20document:%20(6,%20%22Couldn't%20resolve%20host%20'mysite.myopenid.com'%22)
+        res = self.app.get("/user/login?error=Error%20in%20discovery:%20Error%20fetching%20XRDS%20document:%20(6,%20%22Couldn't%20resolve%20host%20'mysite.myopenid.com'%22")
+        main_res = self.main_div(res)
+        assert "Couldn't resolve host" in main_res, main_res
 
     ############
     # Disabled
@@ -481,3 +493,93 @@
         # but for some reason this does not work ...
         return res
 
+    def test_request_reset_user_password_link_user_incorrect(self):
+        offset = url_for(controller='user',
+                         action='request_reset')
+        res = self.app.get(offset)
+        fv = res.forms['user-password-reset']
+        fv['user'] = 'unknown'
+        res = fv.submit()
+        main_res = self.main_div(res)
+        assert 'No such user: unknown' in main_res, main_res # error
+
+    def test_request_reset_user_password_using_search(self):
+        CreateTestData.create_user(name='larry1', email='kittens at john.com')
+        offset = url_for(controller='user',
+                         action='request_reset')
+        res = self.app.get(offset)
+        fv = res.forms['user-password-reset']
+        fv['user'] = 'kittens'
+        res = fv.submit()
+        assert_equal(res.status, 302)
+        assert_equal(res.header_dict['Location'], 'http://localhost/')
+
+        CreateTestData.create_user(name='larry2', fullname='kittens')
+        res = self.app.get(offset)
+        fv = res.forms['user-password-reset']
+        fv['user'] = 'kittens'
+        res = fv.submit()
+        main_res = self.main_div(res)
+        assert '"kittens" matched several users' in main_res, main_res
+        assert 'larry1' not in main_res, main_res
+        assert 'larry2' not in main_res, main_res
+
+        res = self.app.get(offset)
+        fv = res.forms['user-password-reset']
+        fv['user'] = ''
+        res = fv.submit()
+        main_res = self.main_div(res)
+        assert 'No such user:' in main_res, main_res
+
+        res = self.app.get(offset)
+        fv = res.forms['user-password-reset']
+        fv['user'] = 'l'
+        res = fv.submit()
+        main_res = self.main_div(res)
+        assert 'No such user:' in main_res, main_res
+
+    def test_reset_user_password_link(self):
+        # Set password
+        CreateTestData.create_user(name='bob', email='bob at bob.net', password='test1')
+        
+        # Set password to something new
+        model.User.by_name(u'bob').password = 'test2'
+        model.repo.commit_and_remove()
+        test2_encoded = model.User.by_name(u'bob').password
+        assert test2_encoded != 'test2'
+        assert model.User.by_name(u'bob').password == test2_encoded
+
+        # Click link from reset password email
+        create_reset_key(model.User.by_name(u'bob'))
+        reset_password_link = get_reset_link(model.User.by_name(u'bob'))
+        offset = reset_password_link.replace('http://test.ckan.net', '')
+        print offset
+        res = self.app.get(offset)
+
+        # Reset password form
+        fv = res.forms['user-reset']
+        fv['password1'] = 'test1'
+        fv['password2'] = 'test1'
+        res = fv.submit('save', status=302)
+
+        # Check a new password is stored
+        assert model.User.by_name(u'bob').password != test2_encoded
+
+    def test_perform_reset_user_password_link_key_incorrect(self):
+        CreateTestData.create_user(name='jack', password='test1')
+        # Make up a key - i.e. trying to hack this
+        user = model.User.by_name(u'jack')
+        offset = url_for(controller='user',
+                         action='perform_reset',
+                         id=user.id,
+                         key='randomness') # i.e. incorrect
+        res = self.app.get(offset, status=403) # error
+
+    def test_perform_reset_user_password_link_user_incorrect(self):
+        # Make up a key - i.e. trying to hack this
+        user = model.User.by_name(u'jack')
+        offset = url_for(controller='user',
+                         action='perform_reset',
+                         id='randomness',  # i.e. incorrect
+                         key='randomness')
+        res = self.app.get(offset, status=404)


--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ckan/tests/lib/test_mailer.py	Thu Jul 28 12:21:47 2011 +0100
@@ -0,0 +1,112 @@
+import time
+from nose.tools import assert_equal, assert_raises
+from pylons import config
+from email.mime.text import MIMEText
+
+from ckan import model
+from ckan.tests.pylons_controller import PylonsTestCase
+from ckan.tests.mock_mail_server import SmtpServerHarness
+from ckan.lib.mailer import mail_recipient, mail_user, send_reset_link, add_msg_niceties, MailerException, get_reset_link_body, get_reset_link
+from ckan.lib.create_test_data import CreateTestData
+from ckan.lib.base import g
+
+class TestMailer(SmtpServerHarness, PylonsTestCase):
+    @classmethod
+    def setup_class(cls):
+        CreateTestData.create_user(name='bob', email='bob at bob.net')
+        CreateTestData.create_user(name='mary') #NB No email addr provided
+        SmtpServerHarness.setup_class()
+        PylonsTestCase.setup_class()
+
+    @classmethod
+    def teardown_class(cls):
+        SmtpServerHarness.teardown_class()
+        model.repo.rebuild_db()
+
+    def setup(self):
+        self.clear_smtp_messages()
+
+    def mime_encode(self, msg, recipient_name):
+        sender_name = g.site_title
+        sender_url = g.site_url
+        body = add_msg_niceties(recipient_name, msg, sender_name, sender_url)
+        encoded_body = MIMEText(body.encode('utf-8'), 'plain', 'utf-8').get_payload().strip()
+        return encoded_body
+
+    def test_mail_recipient(self):
+        msgs = self.get_smtp_messages()
+        assert_equal(msgs, [])
+
+        # send email
+        test_email = {'recipient_name': 'Bob',
+                      'recipient_email':'bob at bob.net',
+                      'subject': 'Meeting', 
+                      'body': 'The meeting is cancelled.',
+                      'headers': {'header1': 'value1'}}
+        mail_recipient(**test_email)
+        time.sleep(0.1)
+
+        # check it went to the mock smtp server
+        msgs = self.get_smtp_messages()
+        assert_equal(len(msgs), 1)
+        msg = msgs[0]
+        assert_equal(msg[1], config['ckan.mail_from'])
+        assert_equal(msg[2], [test_email['recipient_email']])
+        assert test_email['headers'].keys()[0] in msg[3], msg[3]
+        assert test_email['headers'].values()[0] in msg[3], msg[3]
+        assert test_email['subject'] in msg[3], msg[3]
+        expected_body = self.mime_encode(test_email['body'],
+                                         test_email['recipient_name'])
+        assert expected_body in msg[3], '%r not in %r' % (expected_body, msg[3])
+
+    def test_mail_user(self):
+        msgs = self.get_smtp_messages()
+        assert_equal(msgs, [])
+
+        # send email
+        test_email = {'recipient': model.User.by_name(u'bob'),
+                      'subject': 'Meeting', 
+                      'body': 'The meeting is cancelled.',
+                      'headers': {'header1': 'value1'}}
+        mail_user(**test_email)
+        time.sleep(0.1)
+
+        # check it went to the mock smtp server
+        msgs = self.get_smtp_messages()
+        assert_equal(len(msgs), 1)
+        msg = msgs[0]
+        assert_equal(msg[1], config['ckan.mail_from'])
+        assert_equal(msg[2], [model.User.by_name(u'bob').email])
+        assert test_email['headers'].keys()[0] in msg[3], msg[3]
+        assert test_email['headers'].values()[0] in msg[3], msg[3]
+        assert test_email['subject'] in msg[3], msg[3]
+        expected_body = self.mime_encode(test_email['body'],
+                                         'bob')
+        assert expected_body in msg[3], '%r not in %r' % (expected_body, msg[3])
+
+    def test_mail_user_without_email(self):
+        # send email
+        test_email = {'recipient': model.User.by_name(u'mary'),
+                      'subject': 'Meeting', 
+                      'body': 'The meeting is cancelled.',
+                      'headers': {'header1': 'value1'}}
+        assert_raises(MailerException, mail_user, **test_email)
+
+    def test_send_reset_email(self):
+        # send email
+        send_reset_link(model.User.by_name(u'bob'))
+        time.sleep(0.1)
+
+        # check it went to the mock smtp server
+        msgs = self.get_smtp_messages()
+        assert_equal(len(msgs), 1)
+        msg = msgs[0]
+        assert_equal(msg[1], config['ckan.mail_from'])
+        assert_equal(msg[2], [model.User.by_name(u'bob').email])
+        assert 'Reset' in msg[3], msg[3]
+        test_msg = get_reset_link_body(model.User.by_name(u'bob'))
+        expected_body = self.mime_encode(test_msg,
+                                         u'bob') 
+        assert expected_body in msg[3], '%r not in %r' % (expected_body, msg[3])
+        
+        # reset link tested in user functional test


--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ckan/tests/misc/test_mock_mail_server.py	Thu Jul 28 12:21:47 2011 +0100
@@ -0,0 +1,33 @@
+import time
+from nose.tools import assert_equal
+from pylons import config
+from email.mime.text import MIMEText
+
+from ckan.tests.pylons_controller import PylonsTestCase
+from ckan.tests.mock_mail_server import SmtpServerHarness
+from ckan.lib.mailer import mail_recipient
+
+class TestMockMailServer(SmtpServerHarness, PylonsTestCase):
+    @classmethod
+    def setup_class(cls):
+        SmtpServerHarness.setup_class()
+        PylonsTestCase.setup_class()
+
+    @classmethod
+    def teardown_class(cls):
+        SmtpServerHarness.teardown_class()
+
+    def test_basic(self):
+        msgs = self.get_smtp_messages()
+        assert_equal(msgs, [])
+
+        test_email = {'recipient_name': 'Bob',
+                      'recipient_email':'bob at bob.net',
+                      'subject': 'Meeting', 
+                      'body': 'The meeting is cancelled.',
+                      'headers': {'header1': 'value1'}}
+        mail_recipient(**test_email)
+        time.sleep(0.1)
+
+        msgs = self.get_smtp_messages()
+        assert_equal(len(msgs), 1)


--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ckan/tests/mock_mail_server.py	Thu Jul 28 12:21:47 2011 +0100
@@ -0,0 +1,82 @@
+import threading
+import asyncore
+import socket
+from smtpd import SMTPServer
+
+from pylons import config
+
+from ckan.lib.mailer import _mail_recipient
+
+class MockSmtpServer(SMTPServer):
+    '''A mock SMTP server that operates in an asyncore loop'''
+    def __init__(self, host, port):
+        self.msgs = []
+        SMTPServer.__init__(self, (host, port), None)
+        
+    def process_message(self, peer, mailfrom, rcpttos, data):
+        self.msgs.append((peer, mailfrom, rcpttos, data))
+
+    def get_smtp_messages(self):
+        return self.msgs
+
+    def clear_smtp_messages(self):
+        self.msgs = []
+
+class MockSmtpServerThread(threading.Thread):
+    '''Runs the mock SMTP server in a thread'''
+    def __init__(self, host, port):   
+        self.assert_port_free(host, port)
+        # init thread
+        self._stop_event = threading.Event()
+        self.thread_name = self.__class__
+        threading.Thread.__init__(self, name=self.thread_name)
+        # init smtp server
+        self.server = MockSmtpServer(host, port)
+
+    def assert_port_free(self, host, port):
+        test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR,
+                               test_socket.getsockopt(socket.SOL_SOCKET,
+                                                      socket.SO_REUSEADDR) | 1 )
+        test_socket.bind((host, port))
+        test_socket.close()
+        
+    def run(self):
+        while not self._stop_event.isSet():
+            asyncore.loop(timeout=0.01, count=1)
+
+    def stop(self, timeout=None):
+        self._stop_event.set()
+        threading.Thread.join(self, timeout)
+        self.server.close()
+
+    def get_smtp_messages(self):
+        return self.server.get_smtp_messages()
+
+    def clear_smtp_messages(self):
+        return self.server.clear_smtp_messages()
+        
+class SmtpServerHarness(object):
+    '''Derive from this class to run MockSMTP - a test harness that
+    records what email messages are requested to be sent by it.'''
+
+    @classmethod
+    def setup_class(cls):
+        smtp_server  = config.get('test_smtp_server') or config['smtp_server']
+        if ':' in smtp_server:
+            host, port = smtp_server.split(':')
+        else:
+            host, port = smtp_server, 25
+        cls.smtp_thread = MockSmtpServerThread(host, int(port))
+        cls.smtp_thread.start()
+
+    @classmethod
+    def teardown_class(cls):
+        cls.smtp_thread.stop()
+
+    def get_smtp_messages(self):
+        return self.smtp_thread.get_smtp_messages()
+
+    def clear_smtp_messages(self):
+        return self.smtp_thread.clear_smtp_messages()
+    


--- a/ckan/tests/test_mailer.py	Wed Jul 27 16:37:00 2011 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,29 +0,0 @@
-"""
-"""
-from pylons import config
-from ckan.lib.mailer import _mail_recipient
-from ckan.tests import *
-
-from smtpd import SMTPServer
-
-class TestMailer(TestController):
-
-    def setup(self):
-        config['smtp_server'] = 'localhost:667511'
-        config['ckan.mail_from'] = 'info at ckan.net'
-        class TestSMTPServer(SMTPServer):
-            def process_message(zelf, peer, mailfrom, rcpttos, data):
-                print "FOO"
-                return self.process_message(peer, mailfrom, rcpttos, data)
-        self.server = TestSMTPServer(('localhost', 6675), None)
-
-    def test_mail_recipient(self):
-    #    def tests(s, peer, mailfrom, rcpttos, data):
-    #        assert 'info at ckan.net' in mailfrom
-    #        assert 'foo at bar.com' in recpttos
-    #        assert 'i am a banana' in data
-    #    #self.process_message = tests
-    #    _mail_recipient('fooman', 'foo at localhost', 
-    #            'banaman', 'http://banana.com',
-    #            'i am a banana', 'this is a test')
-        pass


--- a/ckan/tests/wsgi_ckanclient.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/ckan/tests/wsgi_ckanclient.py	Thu Jul 28 12:21:47 2011 +0100
@@ -2,7 +2,12 @@
 
 import paste.fixture
 
-from ckanclient import CkanClient, Request, CkanApiError
+from ckanclient import CkanClient, CkanApiError
+try:
+    from ckanclient import ApiRequest
+except ImportError:
+    # older versions of ckanclient
+    from ckanclient import Request as ApiRequest
 
 __all__ = ['WsgiCkanClient', 'ClientError']
 
@@ -27,7 +32,7 @@
         if data != None:
             data = urllib.urlencode({data: 1})
         # Don't use request beyond getting the method
-        req = Request(location, data, headers, method=method)
+        req = ApiRequest(location, data, headers, method=method)
 
         # Make header values ascii strings
         for key, value in headers.items():


--- a/fabfile.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/fabfile.py	Thu Jul 28 12:21:47 2011 +0100
@@ -167,7 +167,7 @@
              db_user=None,
              db_pass='',
              db_host='localhost',
-             user=None
+             user='okfn'
         ):
     '''Configurable configuration: fab -d gives full info.
     
@@ -401,7 +401,7 @@
     with cd(env.instance_path):
         assert exists(env.config_ini_filename), "Can't find config file: %s/%s" % (env.instance_path, env.config_ini_filename)
     db_details = _get_db_config()
-    assert db_details['db_type'] == 'postgres'
+    assert db_details['db_type'] in ('postgres', 'postgresql')
     port_option = '-p %s' % db_details['db_port'] if db_details['db_port'] else ''
     run('export PGPASSWORD=%s&&pg_dump -U %s -h %s %s %s > %s' % (db_details['db_pass'], db_details['db_user'], db_details['db_host'], port_option, db_details['db_name'], pg_dump_filepath), shell=False)
     assert exists(pg_dump_filepath)


--- a/requires/lucid_present.txt	Wed Jul 27 16:37:00 2011 +0100
+++ b/requires/lucid_present.txt	Thu Jul 28 12:21:47 2011 +0100
@@ -10,9 +10,9 @@
 psycopg2==2.0.13
 lxml==2.2.4
 sphinx==0.6.4
-# Specifying not to use later webob because of incompatibility
+# Specifying particular version of WebOb because later version has incompatibility
 # with pylons 0.9.7 (change to imports of Multidict)
-webob<=1.0.8
+webob==1.0.8
 Pylons==0.9.7
 repoze.who==1.0.18
 tempita==0.4


--- a/setup.py	Wed Jul 27 16:37:00 2011 +0100
+++ b/setup.py	Thu Jul 28 12:21:47 2011 +0100
@@ -65,6 +65,9 @@
     rights = ckan.lib.authztool:RightsCommand
     roles = ckan.lib.authztool:RolesCommand
     
+    [console_scripts]
+    ckan-admin = bin.ckan_admin:Command
+
     [paste.paster_create_template]
     ckanext=ckan.pastertemplates:CkanextTemplate
 


--- a/test-core.ini	Wed Jul 27 16:37:00 2011 +0100
+++ b/test-core.ini	Thu Jul 28 12:21:47 2011 +0100
@@ -47,3 +47,7 @@
 
 # use <strong> so we can check that html is *not* escaped
 ckan.template_footer_end = <strong>TEST TEMPLATE_FOOTER_END TEST</strong>
+
+# mailer
+test_smtp_server = localhost:6675
+ckan.mail_from = info at test.ckan.net


http://bitbucket.org/okfn/ckan/changeset/1317fd88220d/
changeset:   1317fd88220d
branch:      feature-1229-db-out-of-controllers
user:        amercader
date:        2011-07-28 13:50:46
summary:     Fix issues in the user controller
affected #:  2 files (757 bytes)

--- a/ckan/controllers/user.py	Thu Jul 28 12:21:47 2011 +0100
+++ b/ckan/controllers/user.py	Thu Jul 28 12:50:46 2011 +0100
@@ -84,9 +84,6 @@
         try:
             user_dict = get.user_show(context,data_dict)
         except NotFound:
-             abort(404, _('User not found'))
- 
-        if not user_dict:
             h.redirect_to(controller='user', action='login', id=None)
 
         c.user_dict = user_dict
@@ -253,22 +250,38 @@
                        'user': c.user}
 
             data_dict = {'id':id}
-
+            user_obj = None
             try:
                 user_dict = get.user_show(context,data_dict)
                 user_obj = context['user_obj']
+            except NotFound:
+                # Try searching the user
+                del data_dict['id']
+                data_dict['q'] = id
 
-                if user_dict is None:
+                if id and len(id) > 2:
+                    user_list = get.user_list(context,data_dict)
+                    if len(user_list) == 1:
+                        # This is ugly, but we need the user object for the mailer,
+                        # and user_list does not return them
+                        del data_dict['q']
+                        data_dict['id'] = user_list[0]['id']
+                        user_dict = get.user_show(context,data_dict)
+                        user_obj = context['user_obj']
+                    elif len(user_list) > 1:
+                        h.flash_error(_('"%s" matched several users') % (id))
+                    else:
+                        h.flash_error(_('No such user: %s') % id)
+                else:
                     h.flash_error(_('No such user: %s') % id)
+
+            if user_obj:
                 try:
                     mailer.send_reset_link(user_obj)
                     h.flash_success(_('Please check your inbox for a reset code.'))
                     redirect('/')
                 except mailer.MailerException, e:
                     h.flash_error(_('Could not send reset link: %s') % unicode(e))
-
-            except NotFound:
-                h.flash_error(_('No such user: %s') % id)
         return render('user/request_reset.html')
 
     def perform_reset(self, id):


--- a/ckan/logic/action/get.py	Thu Jul 28 12:21:47 2011 +0100
+++ b/ckan/logic/action/get.py	Thu Jul 28 12:50:46 2011 +0100
@@ -358,7 +358,7 @@
     elif provided_user:
         context['user_obj'] = user = provided_user
     else:
-        return None
+        raise NotFound
 
     user_dict = user_dictize(user,context)
 


http://bitbucket.org/okfn/ckan/changeset/b8c4dbf865d4/
changeset:   b8c4dbf865d4
user:        amercader
date:        2011-07-28 13:55:51
summary:     [merge] from feature-1220-db-out-of-controllers
affected #:  35 files (42.5 KB)

--- a/ckan/controllers/api.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/controllers/api.py	Thu Jul 28 12:55:51 2011 +0100
@@ -157,9 +157,14 @@
                                     'message': _('Access denied')}
             return_dict['success'] = False
             return self._finish(403, return_dict, content_type='json')
+        except NotFound:
+            return_dict['error'] = {'__type': 'Not Found Error',
+                                    'message': _('Not found')}
+            return_dict['success'] = False
+            return self._finish(404, return_dict, content_type='json')
         except ValidationError, e:
             error_dict = e.error_dict 
-            error_dict['__type'] = 'Validtion Error'
+            error_dict['__type'] = 'Validation Error'
             return_dict['error'] = error_dict
             return_dict['success'] = False
             log.error('Validation error: %r' % str(e.error_dict))
@@ -198,7 +203,7 @@
         action_map = {
             'revision': get.revision_show,
             'group': get.group_show_rest,
-            'tag': get.tag_show,
+            'tag': get.tag_show_rest,
             'package': get.package_show_rest,
             ('package', 'relationships'): get.package_relationships_list,
         }
@@ -447,12 +452,18 @@
         return params        
 
     def tag_counts(self, ver=None):
-        log.debug('tag counts')
-        tags = model.Session.query(model.Tag).all()
+        c.q = request.params.get('q', '')
+
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author}
+
+        data_dict = {'all_fields': True}
+
+        tag_list = get.tag_list(context, data_dict)
         results = []
-        for tag in tags:
-            tag_count = len(tag.package_tags)
-            results.append((tag.name, tag_count))
+        for tag in tag_list:
+            tag_count = len(tag['packages'])
+            results.append((tag['name'], tag_count))
         return self._finish_ok(results)
 
     def throughput(self, ver=None):
@@ -478,21 +489,15 @@
     def user_autocomplete(self):
         q = request.params.get('q', '')
         limit = request.params.get('limit', 20)
-        try:
-            limit = int(limit)
-        except:
-            limit = 20
-        limit = min(50, limit)
-    
-        query = model.User.search(q)
-        def convert_to_dict(user):
-            out = {}
-            for k in ['id', 'name', 'fullname']:
-                out[k] = getattr(user, k)
-            return out
-        query = query.limit(limit)
-        out = map(convert_to_dict, query.all())
-        return out
+        user_list = []
+        if q:
+            context = {'model': model, 'session': model.Session,
+                       'user': c.user or c.author}
+
+            data_dict = {'q':q,'limit':limit}
+
+            user_list = get.user_autocomplete(context,data_dict)
+        return user_list
 
 
     @jsonpify
@@ -528,26 +533,22 @@
         return self._finish_ok(response_data)
 
     def tag_autocomplete(self):
-        incomplete = request.params.get('incomplete', '')
-        if incomplete:
-            query = query_for('tag', backend='sql')
-            query.run(query=incomplete,
-                      return_objects=True,
-                      limit=10,
-                      username=c.user)
-            tagNames = [t.name for t in query.results]
-        else:
-            tagNames = []
+        q = request.params.get('incomplete', '')
+        limit = request.params.get('limit', 10)
+        tag_names = []
+        if q:
+            context = {'model': model, 'session': model.Session,
+                       'user': c.user or c.author}
+
+            data_dict = {'q':q,'limit':limit}
+
+            tag_names = get.tag_autocomplete(context,data_dict)
+
         resultSet = {
-            "ResultSet": {
-                "Result": []
+            'ResultSet': {
+                'Result': [{'Name': tag} for tag in tag_names]
             }
         }
-        for tagName in tagNames[:10]:
-            result = {
-                "Name": tagName
-            }
-            resultSet["ResultSet"]["Result"].append(result)
         return self._finish_ok(resultSet)
 
 


--- a/ckan/controllers/group.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/controllers/group.py	Thu Jul 28 12:55:51 2011 +0100
@@ -1,4 +1,5 @@
 import genshi
+import datetime
 
 from sqlalchemy.orm import eagerload_all
 from ckan.lib.base import BaseController, c, model, request, render, h
@@ -39,21 +40,21 @@
                 c, model.Action.CHANGE_STATE, group)
 
     ## end hooks
-
-    def __init__(self):
-        BaseController.__init__(self)
-        self.extensions = PluginImplementations(IGroupController)
     
     def index(self):
-        
+
         if not self.authorizer.am_authorized(c, model.Action.SITE_READ, model.System):
             abort(401, _('Not authorized to see this page'))
-        
-        query = authz.Authorizer().authorized_query(c.user, model.Group)
-        query = query.order_by(model.Group.name.asc())
-        query = query.order_by(model.Group.title.asc())
+
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author}
+
+        data_dict = {'all_fields': True}
+               
+        results = get.group_list(context,data_dict)
+
         c.page = Page(
-            collection=query,
+            collection=results,
             page=request.params.get('page', 1),
             items_per_page=20
         )
@@ -61,17 +62,21 @@
 
 
     def read(self, id):
-        c.group = model.Group.get(id)
-        if c.group is None:
-            abort(404)
-        auth_for_read = self.authorizer.am_authorized(c, model.Action.READ, c.group)
-        if not auth_for_read:
-            abort(401, _('Not authorized to read %s') % id.encode('utf8'))
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author,
+                   'schema': self._form_to_db_schema()}
+        data_dict = {'id': id}
+        try:
+            c.group_dict = get.group_show(context, data_dict)
+            c.group = context['group']
+        except NotFound:
+            abort(404, _('Group not found'))
+        except NotAuthorized:
+            abort(401, _('Unauthorized to read group %s') % id)
         
-        import ckan.misc
-        format = ckan.misc.MarkdownFormat()
-        desc_formatted = format.to_html(c.group.description)
-        try: 
+        try:
+ 
+            desc_formatted = ckan.misc.MarkdownFormat().to_html(c.group.description)
             desc_formatted = genshi.HTML(desc_formatted)
         except genshi.ParseError, e:
             log.error('Could not print group description: %r Error: %r', c.group.description, e)
@@ -84,8 +89,7 @@
             page=request.params.get('page', 1),
             items_per_page=50
         )
-        for extension in self.extensions:
-            extension.read(c.group)
+
         return render('group/read.html')
 
     def new(self, data=None, errors=None, error_summary=None):
@@ -394,12 +398,6 @@
         c.authz_groups_role_dict = authz_groups_role_dict
 
         return render('group/authz.html')
-
-
-
-
-
-
        
     def history(self, id):
         if 'diff' in request.params or 'selected1' in request.params:
@@ -416,10 +414,19 @@
                 params['diff_entity'] = 'group'
                 h.redirect_to(controller='revision', action='diff', **params)
 
-        c.group = model.Group.get(id)
-        if not c.group:
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author,
+                   'schema': self._form_to_db_schema()}
+        data_dict = {'id': id}
+        try:
+            c.group_dict = get.group_show(context, data_dict)
+            c.group_revisions = get.group_revision_list(context, data_dict)
+            #TODO: remove
+            # Still necessary for the authz check in group/layout.html
+            c.group = context['group']
+        except NotFound:
             abort(404, _('Group not found'))
-        if not self.authorizer.am_authorized(c, model.Action.READ, c.group):
+        except NotAuthorized:
             abort(401, _('User %r not authorized to edit %r') % (c.user, id))
 
         format = request.params.get('format', '')
@@ -428,31 +435,29 @@
             from webhelpers.feedgenerator import Atom1Feed
             feed = Atom1Feed(
                 title=_(u'CKAN Group Revision History'),
-                link=h.url_for(controller='group', action='read', id=c.group.name),
+                link=h.url_for(controller='group', action='read', id=c.group_dict['name']),
                 description=_(u'Recent changes to CKAN Group: ') +
-                    c.group.display_name,
+                    c.group_dict['display_name'],
                 language=unicode(get_lang()),
             )
-            for revision, obj_rev in c.group.all_related_revisions:
+            for revision_dict in c.group_revisions:
+                revision_date = h.date_str_to_datetime(revision_dict['timestamp'])
                 try:
                     dayHorizon = int(request.params.get('days'))
                 except:
                     dayHorizon = 30
-                try:
-                    dayAge = (datetime.now() - revision.timestamp).days
-                except:
-                    dayAge = 0
+                dayAge = (datetime.datetime.now() - revision_date).days
                 if dayAge >= dayHorizon:
                     break
-                if revision.message:
-                    item_title = u'%s' % revision.message.split('\n')[0]
+                if revision_dict['message']:
+                    item_title = u'%s' % revision_dict['message'].split('\n')[0]
                 else:
-                    item_title = u'%s' % revision.id
-                item_link = h.url_for(controller='revision', action='read', id=revision.id)
+                    item_title = u'%s' % revision_dict['id']
+                item_link = h.url_for(controller='revision', action='read', id=revision_dict['id'])
                 item_description = _('Log message: ')
-                item_description += '%s' % (revision.message or '')
-                item_author_name = revision.author
-                item_pubdate = revision.timestamp
+                item_description += '%s' % (revision_dict['message'] or '')
+                item_author_name = revision_dict['author']
+                item_pubdate = revision_date
                 feed.add_item(
                     title=item_title,
                     link=item_link,
@@ -462,7 +467,6 @@
                 )
             feed.content_type = 'application/atom+xml'
             return feed.writeString('utf-8')
-        c.group_revisions = c.group.all_related_revisions
         return render('group/history.html')
 
     def _render_edit_form(self, fs):


--- a/ckan/controllers/package.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/controllers/package.py	Thu Jul 28 12:55:51 2011 +0100
@@ -6,7 +6,6 @@
 import re
 
 from sqlalchemy.orm import eagerload_all
-from sqlalchemy import or_
 import genshi
 from pylons import config, cache
 from pylons.i18n import get_lang, _
@@ -21,7 +20,7 @@
 from ckan.lib.base import request, c, BaseController, model, abort, h, g, render
 from ckan.lib.base import etag_cache, response, redirect, gettext
 from ckan.authz import Authorizer
-from ckan.lib.search import query_for, SearchError
+from ckan.lib.search import SearchError
 from ckan.lib.cache import proxy_cache
 from ckan.lib.package_saver import PackageSaver, ValidationException
 from ckan.lib.navl.dictization_functions import DataError, unflatten, validate
@@ -79,7 +78,7 @@
             raise DataError(data_dict)
 
     def _setup_template_variables(self, context, data_dict):
-        c.groups = get.group_list_availible(context, data_dict)
+        c.groups = get.group_list_available(context, data_dict)
         c.groups_authz = get.group_list_authz(context, data_dict)
         c.licences = [('', '')] + model.Package.get_license_options()
         c.is_sysadmin = Authorizer().is_sysadmin(c.user)
@@ -108,7 +107,6 @@
         except ValueError, e:
             abort(400, ('"page" parameter must be an integer'))
         limit = 20
-        query = query_for(model.Package)
 
         # most search operations should reset the page counter:
         params_nopage = [(k, v) for k,v in request.params.items() if k != 'page']
@@ -139,25 +137,30 @@
                         and len(value) and not param.startswith('_'):
                     c.fields.append((param, value))
 
-            query.run(query=q,
-                      fields=c.fields,
-                      facet_by=g.facets,
-                      limit=limit,
-                      offset=(page-1)*limit,
-                      return_objects=True,
-                      filter_by_openness=c.open_only,
-                      filter_by_downloadable=c.downloadable_only,
-                      username=c.user)
-                       
+            context = {'model': model, 'session': model.Session,
+                       'user': c.user or c.author}
+
+            data_dict = {'q':q,
+                         'fields':c.fields,
+                         'facet_by':g.facets,
+                         'limit':limit,
+                         'offset':(page-1)*limit,
+                         'return_objects':True,
+                         'filter_by_openness':c.open_only,
+                         'filter_by_downloadable':c.downloadable_only,
+                        }
+
+            query = get.package_search(context,data_dict)
+
             c.page = h.Page(
-                collection=query.results,
+                collection=query['results'],
                 page=page,
                 url=pager_url,
-                item_count=query.count,
+                item_count=query['count'],
                 items_per_page=limit
             )
-            c.facets = query.facets
-            c.page.items = query.results
+            c.facets = query['facets']
+            c.page.items = query['results']
         except SearchError, se:
             c.query_error = True
             c.facets = {}
@@ -265,39 +268,50 @@
                 params['diff_entity'] = 'package'
                 h.redirect_to(controller='revision', action='diff', **params)
 
-        c.pkg = model.Package.get(id)
-        if not c.pkg:
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author,
+                   'extras_as_string': True,}
+        data_dict = {'id':id}
+        try:
+            c.pkg_dict = get.package_show(context, data_dict)
+            c.pkg_revisions = get.package_revision_list(context, data_dict)
+            #TODO: remove
+            # Still necessary for the authz check in group/layout.html
+            c.pkg = context['package']
+
+        except NotAuthorized:
+            abort(401, _('Unauthorized to read package %s') % '')
+        except NotFound:
             abort(404, _('Package not found'))
+
         format = request.params.get('format', '')
         if format == 'atom':
             # Generate and return Atom 1.0 document.
             from webhelpers.feedgenerator import Atom1Feed
             feed = Atom1Feed(
                 title=_(u'CKAN Package Revision History'),
-                link=h.url_for(controller='revision', action='read', id=c.pkg.name),
-                description=_(u'Recent changes to CKAN Package: ') + (c.pkg.title or ''),
+                link=h.url_for(controller='revision', action='read', id=c.pkg_dict['name']),
+                description=_(u'Recent changes to CKAN Package: ') + (c.pkg_dict['title'] or ''),
                 language=unicode(get_lang()),
             )
-            for revision, obj_rev in c.pkg.all_related_revisions:
+            for revision_dict in c.pkg_revisions:
+                revision_date = h.date_str_to_datetime(revision_dict['timestamp'])
                 try:
                     dayHorizon = int(request.params.get('days'))
                 except:
                     dayHorizon = 30
-                try:
-                    dayAge = (datetime.now() - revision.timestamp).days
-                except:
-                    dayAge = 0
+                dayAge = (datetime.datetime.now() - revision_date).days
                 if dayAge >= dayHorizon:
                     break
-                if revision.message:
-                    item_title = u'%s' % revision.message.split('\n')[0]
+                if revision_dict['message']:
+                    item_title = u'%s' % revision_dict['message'].split('\n')[0]
                 else:
-                    item_title = u'%s' % revision.id
-                item_link = h.url_for(controller='revision', action='read', id=revision.id)
+                    item_title = u'%s' % revision_dict['id']
+                item_link = h.url_for(controller='revision', action='read', id=revision_dict['id'])
                 item_description = _('Log message: ')
-                item_description += '%s' % (revision.message or '')
-                item_author_name = revision.author
-                item_pubdate = revision.timestamp
+                item_description += '%s' % (revision_dict['message'] or '')
+                item_author_name = revision_dict['author']
+                item_pubdate = revision_date
                 feed.add_item(
                     title=item_title,
                     link=item_link,
@@ -307,7 +321,6 @@
                 )
             feed.content_type = 'application/atom+xml'
             return feed.writeString('utf-8')
-        c.pkg_revisions = c.pkg.all_related_revisions
         return render('package/history.html')
 
     def new(self, data=None, errors=None, error_summary=None):
@@ -395,21 +408,31 @@
 
     def history_ajax(self, id):
 
-        pkg = model.Package.get(id)
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author,
+                   'extras_as_string': True,}
+        data_dict = {'id':id}
+        try:
+            pkg_revisions = get.package_revision_list(context, data_dict)
+        except NotAuthorized:
+            abort(401, _('Unauthorized to read package %s') % '')
+        except NotFound:
+            abort(404, _('Package not found'))
+
+
         data = []
         approved = False
-        for num, (revision, revision_obj) in enumerate(pkg.all_related_revisions):
-            if not approved and revision.approved_timestamp:
+        for num, revision in enumerate(pkg_revisions):
+            if not approved and revision['approved_timestamp']:
                 current_approved, approved = True, True
             else:
                 current_approved = False
             
-            data.append({'revision_id': revision.id,
-                         'message': revision.message,
-                         'timestamp': format_datetime(revision.timestamp, 
-                                                      locale=(get_lang() or ['en'])[0]),
-                         'author': revision.author,
-                         'approved': bool(revision.approved_timestamp),
+            data.append({'revision_id': revision['id'],
+                         'message': revision['message'],
+                         'timestamp': revision['timestamp'],
+                         'author': revision['author'],
+                         'approved': bool(revision['approved_timestamp']),
                          'current_approved': current_approved})
                 
         response.headers['Content-Type'] = 'application/json;charset=utf-8'
@@ -726,38 +749,24 @@
 
         return render('package/authz.html')
 
-
-
-
-    def rate(self, id):
-        package_name = id
-        package = model.Package.get(package_name)
-        if package is None:
-            abort(404, gettext('Package Not Found'))
-        #self._clear_pkg_cache(package)
-        rating = request.params.get('rating', '')
-        if rating:
-            try:
-                ckan.rating.set_my_rating(c, package, rating)
-            except ckan.rating.RatingValueException, e:
-                abort(400, gettext('Rating value invalid'))
-        h.redirect_to(controller='package', action='read', id=package_name, rating=str(rating))
-
     def autocomplete(self):
         q = unicode(request.params.get('q', ''))
         if not len(q): 
             return ''
+
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author}
+
+        data_dict = {'q':q}
+
+        packages = get.package_autocomplete(context,data_dict)
+
         pkg_list = []
-        like_q = u"%s%%" % q
-        pkg_query = ckan.authz.Authorizer().authorized_query(c.user, model.Package)
-        pkg_query = pkg_query.filter(or_(model.Package.name.ilike(like_q),
-                                         model.Package.title.ilike(like_q)))
-        pkg_query = pkg_query.limit(10)
-        for pkg in pkg_query:
-            if pkg.name.lower().startswith(q.lower()):
-                pkg_list.append('%s|%s' % (pkg.name, pkg.name))
+        for pkg in packages:
+            if pkg['name'].lower().startswith(q.lower()):
+                pkg_list.append('%s|%s' % (pkg['name'], pkg['name']))
             else:
-                pkg_list.append('%s (%s)|%s' % (pkg.title.replace('|', ' '), pkg.name, pkg.name))
+                pkg_list.append('%s (%s)|%s' % (pkg['title'].replace('|', ' '), pkg['name'], pkg['name']))
         return '\n'.join(pkg_list)
 
     def _render_edit_form(self, fs, params={}, clear_session=False):


--- a/ckan/controllers/tag.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/controllers/tag.py	Thu Jul 28 12:55:51 2011 +0100
@@ -7,6 +7,9 @@
 from ckan.lib.cache import proxy_cache
 from ckan.lib.helpers import AlphaPage, Page
 
+from ckan.logic import NotFound, NotAuthorized
+import ckan.logic.action.get as get
+
 LIMIT = 25
 
 class TagController(BaseController):
@@ -18,26 +21,32 @@
 
     def index(self):
         c.q = request.params.get('q', '')
-        
+
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author}
+
+        data_dict = {}
+
         if c.q:
             page = int(request.params.get('page', 1))
-            query = query_for('tag', backend='sql')
-            query.run(query=c.q,
-                      limit=LIMIT,
-                      offset=(page-1)*LIMIT,
-                      return_objects=True,
-                      username=c.user)
+            data_dict['q'] = c.q
+            data_dict['limit'] = LIMIT
+            data_dict['offset'] = (page-1)*LIMIT
+            data_dict['return_objects'] = True
+               
+        results = get.tag_list(context,data_dict)
+         
+        if c.q:
             c.page = h.Page(
-                            collection=query.results,
+                            collection=results,
                             page=page,
-                            item_count=query.count,
+                            item_count=len(results),
                             items_per_page=LIMIT
                             )
-            c.page.items = query.results
+            c.page.items = results
         else:
-            query = model.Tag.all()
             c.page = AlphaPage(
-                collection=query,
+                collection=results,
                 page=request.params.get('page', 'A'),
                 alpha_attribute='name',
                 other_text=_('Other'),
@@ -47,10 +56,14 @@
 
     @proxy_cache()
     def read(self, id):
-        query = model.Session.query(model.Tag)
-        query = query.filter(model.Tag.name==id)
-        c.tag = query.first()
-        if c.tag is None:
-            abort(404)
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author}
+        
+        data_dict = {'id':id}
+        try:
+            c.tag = get.tag_show(context,data_dict)
+        except NotFound:
+            abort(404, _('Tag not found'))
+
         return render('tag/read.html')
 


--- a/ckan/controllers/user.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/controllers/user.py	Thu Jul 28 12:55:51 2011 +0100
@@ -1,12 +1,20 @@
 import logging
 
 import genshi
-from sqlalchemy import or_, func, desc
 from urllib import quote
 
 import ckan.misc
 from ckan.lib.base import *
 from ckan.lib import mailer
+from ckan.authz import Authorizer
+from ckan.lib.navl.dictization_functions import DataError, unflatten
+from ckan.logic import NotFound, NotAuthorized, ValidationError
+from ckan.logic import tuplize_dict, clean_dict, parse_params
+from ckan.logic.schema import user_new_form_schema, user_edit_form_schema 
+
+import ckan.logic.action.get as get
+import ckan.logic.action.create as create
+import ckan.logic.action.update as update
 
 log = logging.getLogger(__name__)
 
@@ -15,7 +23,30 @@
 
 class UserController(BaseController):
 
-    def index(self, id=None):
+    ## hooks for subclasses 
+    new_user_form = 'user/new_user_form.html'
+    edit_user_form = 'user/edit_user_form.html'
+
+    def _new_form_to_db_schema(self):
+        return user_new_form_schema()
+
+    def _db_to_new_form_schema(self):
+        '''This is an interface to manipulate data from the database
+        into a format suitable for the form (optional)'''
+
+    def _edit_form_to_db_schema(self):
+        return user_edit_form_schema()
+
+    def _db_to_edit_form_schema(self):
+        '''This is an interface to manipulate data from the database
+        into a format suitable for the form (optional)'''
+
+    def _setup_template_variables(self, context):
+        c.is_sysadmin = Authorizer().is_sysadmin(c.user)
+
+    ## end hooks
+
+    def index(self):
         LIMIT = 20
 
         if not self.authorizer.am_authorized(c, model.Action.USER_READ, model.System):
@@ -25,25 +56,18 @@
         c.q  = request.params.get('q', '')
         c.order_by = request.params.get('order_by', 'name')
 
-        query = model.Session.query(model.User, func.count(model.User.id))
-        if c.q:
-            query = model.User.search(c.q, query)
+        context = {'model': model,
+                   'user': c.user or c.author}
 
-        if c.order_by == 'edits':
-            query = query.join((model.Revision, or_(
-                    model.Revision.author==model.User.name,
-                    model.Revision.author==model.User.openid
-                    )))
-            query = query.group_by(model.User)
-            query = query.order_by(desc(func.count(model.User.id)))
-        else:
-            query = query.group_by(model.User)
-            query = query.order_by(model.User.name)
+        data_dict = {'q':c.q,
+                     'order_by':c.order_by}
+
+        users_list = get.user_list(context,data_dict)
 
         c.page = h.Page(
-            collection=query,
+            collection=users_list,
             page=page,
-            item_count=query.count(),
+            item_count=len(users_list),
             items_per_page=LIMIT
             )
         return render('user/list.html')
@@ -51,21 +75,21 @@
     def read(self, id=None):
         if not self.authorizer.am_authorized(c, model.Action.USER_READ, model.System):
             abort(401, _('Not authorized to see this page'))
-        if id:
-            user = model.User.get(id)
-        else:
-            user = c.userobj
-        if not user:
+
+        context = {'model': model,
+                   'user': c.user or c.author}
+
+        data_dict = {'id':id,
+                     'user_obj':c.userobj}
+        try:
+            user_dict = get.user_show(context,data_dict)
+        except NotFound:
             h.redirect_to(controller='user', action='login', id=None)
-        c.read_user = user.display_name
-        c.is_myself = user.name == c.user
-        c.api_key = user.apikey
-        c.about_formatted = self._format_about(user.about)
-        revisions_q = model.Session.query(model.Revision
-                ).filter_by(author=user.name)
-        c.num_edits = user.number_of_edits()
-        c.num_pkg_admin = user.number_administered_packages()
-        c.activity = revisions_q.limit(20).all()
+
+        c.user_dict = user_dict
+        c.is_myself = user_dict['name'] == c.user
+        c.about_formatted = self._format_about(user_dict['about'])
+
         return render('user/read.html')
     
     def me(self):
@@ -74,41 +98,119 @@
         user_ref = c.userobj.get_reference_preferred_for_uri()
         h.redirect_to(controller='user', action='read', id=user_ref)
 
-    def register(self):
-        if not self.authorizer.am_authorized(c, model.Action.USER_CREATE, model.System):
-            abort(401, _('Not authorized to see this page'))
-        if request.method == 'POST':
-            try:
-                c.login = request.params.getone('login')
-                c.fullname = request.params.getone('fullname')
+    def register(self, data=None, errors=None, error_summary=None):
+        return self.new(data, errors, error_summary)
+
+    def new(self, data=None, errors=None, error_summary=None):
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author,
+                   'schema': self._new_form_to_db_schema(),
+                   'save': 'save' in request.params}
+
+        auth_for_create = Authorizer().am_authorized(c, model.Action.USER_CREATE, model.System())
+        if not auth_for_create:
+            abort(401, _('Unauthorized to create a user'))
+
+        if context['save'] and not data:
+            return self._save_new(context)
+        
+        data = data or {}
+        errors = errors or {}
+        error_summary = error_summary or {}
+        vars = {'data': data, 'errors': errors, 'error_summary': error_summary}
+
+        self._setup_template_variables(context)
+        c.form = render(self.new_user_form, extra_vars=vars)
+        return render('user/new.html')
+
+    def _save_new(self, context):
+        try:
+            data_dict = clean_dict(unflatten(
+                tuplize_dict(parse_params(request.params))))
+            context['message'] = data_dict.get('log_message', '')
+            user = create.user_create(context, data_dict)
+            h.redirect_to(controller='user', action='read', id=user['name'])
+        except NotAuthorized:
+            abort(401, _('Unauthorized to create user %s') % '')
+        except NotFound, e:
+            abort(404, _('User not found'))
+        except DataError:
+            abort(400, _(u'Integrity Error'))
+        except ValidationError, e:
+            errors = e.error_dict
+            error_summary = e.error_summary
+            return self.new(data_dict, errors, error_summary)
+
+    def edit(self, id, data=None, errors=None, error_summary=None):
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author,
+                   'preview': 'preview' in request.params,
+                   'save': 'save' in request.params,
+                   'schema': self._edit_form_to_db_schema(),
+                   }
+        data_dict = {'id': id}
+
+        if (context['save'] or context['preview']) and not data:
+            return self._save_edit(id, context)
+
+        try:
+            old_data = get.user_show(context, data_dict)
+
+            schema = self._db_to_edit_form_schema()
+            if schema:
+                old_data, errors = validate(old_data, schema)
+
+            c.display_name = old_data.get('display_name')
+            c.user_name = old_data.get('name')
+
+            data = data or old_data
+
+        except NotAuthorized:
+            abort(401, _('Unauthorized to edit user %s') % '')
+
+        user_obj = context.get('user_obj')
+        
+        if not (ckan.authz.Authorizer().is_sysadmin(unicode(c.user)) or c.user == user_obj.name):
+            abort(401, _('User %s not authorized to edit %s') % (str(c.user), id))
+        
+        errors = errors or {}
+        vars = {'data': data, 'errors': errors, 'error_summary': error_summary}
+
+        self._setup_template_variables(context)
+
+        c.form = render(self.edit_user_form, extra_vars=vars)
+
+        return render('user/edit.html')
+
+    def _save_edit(self, id, context):
+        try:
+            data_dict = clean_dict(unflatten(
+                tuplize_dict(parse_params(request.params))))
+            context['message'] = data_dict.get('log_message', '')
+            data_dict['id'] = id
+            user = update.user_update(context, data_dict)
+
+            if context['preview']:
+                about = request.params.getone('about')
+                c.preview = self._format_about(about)
+                c.user_about = about
+                c.full_name = request.params.get('fullname','')
                 c.email = request.params.getone('email')
-            except KeyError, e:
-                abort(401, _('Missing parameter: %r') % e)
-            if not c.login:
-                h.flash_error(_("Please enter a login name."))
-                return render("user/register.html")
-            if not model.User.check_name_valid(c.login):
-                h.flash_error(_('That login name is not valid. It must be at least 3 characters, restricted to alphanumerics and these symbols: %s') % '_\-')
-                return render("user/register.html")
-            if not model.User.check_name_available(c.login):
-                h.flash_error(_("That login name is not available."))
-                return render("user/register.html")
-            if not request.params.getone('password1'):
-                h.flash_error(_("Please enter a password."))
-                return render("user/register.html")                
-            try:
-                password = self._get_form_password()
-            except ValueError, ve:
-                h.flash_error(ve)
-                return render('user/register.html')
-            user = model.User(name=c.login, fullname=c.fullname,
-                              email=c.email, password=password)
-            model.Session.add(user)
-            model.Session.commit() 
-            model.Session.remove()
-            h.redirect_to('/login_generic?login=%s&password=%s' % (str(c.login), quote(password.encode('utf-8'))))
 
-        return render('user/register.html')
+                return self.edit(id, data_dict)
+
+            h.redirect_to(controller='user', action='read', id=user['id'])
+        except NotAuthorized:
+            abort(401, _('Unauthorized to edit user %s') % id)
+        except NotFound, e:
+            abort(404, _('User not found'))
+        except DataError:
+            abort(400, _(u'Integrity Error'))
+        except ValidationError, e:
+            errors = e.error_dict
+            error_summary = e.error_summary
+            return self.edit(id, data_dict, errors, error_summary)
+
 
     def login(self):
         if 'error' in request.params:
@@ -116,11 +218,18 @@
         return render('user/login.html')
     
     def logged_in(self):
-        if c.userobj:
-            response.set_cookie("ckan_user", c.userobj.name)
-            response.set_cookie("ckan_display_name", c.userobj.display_name)
-            response.set_cookie("ckan_apikey", c.userobj.apikey)
-            h.flash_success(_("Welcome back, %s") % c.userobj.display_name)
+        if c.user:
+            context = {'model': model,
+                       'user': c.user}
+
+            data_dict = {'id':c.user}
+
+            user_dict = get.user_show(context,data_dict)
+
+            response.set_cookie("ckan_user", user_dict['name'])
+            response.set_cookie("ckan_display_name", user_dict['display_name'])
+            response.set_cookie("ckan_apikey", user_dict['apikey'])
+            h.flash_success(_("Welcome back, %s") % user_dict['display_name'])
             h.redirect_to(controller='user', action='me', id=None)
         else:
             h.flash_error('Login failed. Bad username or password.')
@@ -132,98 +241,84 @@
         response.delete_cookie("ckan_display_name")
         response.delete_cookie("ckan_apikey")
         return render('user/logout.html')
-
-    def edit(self, id=None):
-        if id is not None:
-            user = model.User.get(id)
-        else:
-            user = c.userobj
-        if user is None:
-            abort(404)
-        currentuser = c.userobj
-        if not (ckan.authz.Authorizer().is_sysadmin(unicode(c.user)) or user == currentuser):
-            abort(401)
-        c.userobj = user
-        if not 'save' in request.params and not 'preview' in request.params:
-            c.user_about = user.about
-            c.user_fullname = user.fullname
-            c.user_email = user.email
-        elif 'preview' in request.params:
-            about = request.params.getone('about')
-            c.preview = self._format_about(about)
-            c.user_about = about
-            c.user_fullname = request.params.getone('fullname')
-            c.user_email = request.params.getone('email')
-        elif 'save' in request.params:
-            try:
-                about = request.params.getone('about')
-                if 'http://' in about or 'https://' in about:
-                    msg = _('Edit not allowed as it looks like spam. Please avoid links in your description.')
-                    h.flash_error(msg)
-                    c.user_about = about
-                    c.user_fullname = request.params.getone('fullname')
-                    c.user_email = request.params.getone('email')
-                    return render('user/edit.html')
-                user.about = about
-                user.fullname = request.params.getone('fullname')
-                user.email = request.params.getone('email')
-                try:
-                    password = self._get_form_password()
-                    if password: 
-                        user.password = password
-                except ValueError, ve:
-                    h.flash_error(ve)
-                    return render('user/edit.html')
-            except Exception, inst:
-                model.Session.rollback()
-                raise
-            else:
-                model.Session.commit()
-                h.flash_notice(_("Your account has been updated."))
-            response.set_cookie("ckan_display_name", user.display_name)
-            h.redirect_to(controller='user', action='read', id=user.id)
-            
-        return render('user/edit.html')
     
     def request_reset(self):
         if request.method == 'POST':
             id = request.params.get('user')
-            user = model.User.get(id)
-            if user is None and id and len(id)>2:
-                q = model.User.search(id)
-                if q.count() == 1:
-                    user = q.one()
-                elif q.count() > 1:
-                    users = ' '.join([user.name for user in q])
-                    h.flash_error(_('"%s" matched several users') % (id))
-                    return render("user/request_reset.html")
-            if user is None:
-                h.flash_error(_('No such user: %s') % id)
-                return render("user/request_reset.html")
+
+            context = {'model': model,
+                       'user': c.user}
+
+            data_dict = {'id':id}
+            user_obj = None
             try:
-                mailer.send_reset_link(user)
-                h.flash_success(_('Please check your inbox for a reset code.'))
-                redirect('/')
-            except mailer.MailerException, e:
-                h.flash_error(_('Could not send reset link: %s') % unicode(e))
+                user_dict = get.user_show(context,data_dict)
+                user_obj = context['user_obj']
+            except NotFound:
+                # Try searching the user
+                del data_dict['id']
+                data_dict['q'] = id
+
+                if id and len(id) > 2:
+                    user_list = get.user_list(context,data_dict)
+                    if len(user_list) == 1:
+                        # This is ugly, but we need the user object for the mailer,
+                        # and user_list does not return them
+                        del data_dict['q']
+                        data_dict['id'] = user_list[0]['id']
+                        user_dict = get.user_show(context,data_dict)
+                        user_obj = context['user_obj']
+                    elif len(user_list) > 1:
+                        h.flash_error(_('"%s" matched several users') % (id))
+                    else:
+                        h.flash_error(_('No such user: %s') % id)
+                else:
+                    h.flash_error(_('No such user: %s') % id)
+
+            if user_obj:
+                try:
+                    mailer.send_reset_link(user_obj)
+                    h.flash_success(_('Please check your inbox for a reset code.'))
+                    redirect('/')
+                except mailer.MailerException, e:
+                    h.flash_error(_('Could not send reset link: %s') % unicode(e))
         return render('user/request_reset.html')
 
     def perform_reset(self, id):
-        user = model.User.get(id)
-        if user is None:
-            abort(404)
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user}
+
+        data_dict = {'id':id}
+
+        try:
+            user_dict = get.user_show(context,data_dict)
+            user_obj = context['user_obj']
+        except NotFound, e:
+            abort(404, _('User not found'))
+
         c.reset_key = request.params.get('key')
-        if not mailer.verify_reset_link(user, c.reset_key):
-            msg = _('Invalid reset key. Please try again.')
-            h.flash_error(msg)
-            abort(403, msg.encode('utf8'))
+        if not mailer.verify_reset_link(user_obj, c.reset_key):
+            h.flash_error(_('Invalid reset key. Please try again.'))
+            abort(403)
+
         if request.method == 'POST':
             try:
-                user.password = self._get_form_password()
-                model.Session.add(user)
-                model.Session.commit()
+                context['reset_password'] = True 
+                new_password = self._get_form_password()
+                user_dict['password'] = new_password
+                user_dict['reset_key'] = c.reset_key
+                user = update.user_update(context, user_dict)
+
                 h.flash_success(_("Your password has been reset."))
                 redirect('/')
+            except NotAuthorized:
+                h.flash_error(_('Unauthorized to edit user %s') % id)
+            except NotFound, e:
+                h.flash_error(_('User not found'))
+            except DataError:
+                h.flash_error(_(u'Integrity Error'))
+            except ValidationError, e:
+                h.flash_error(u'%r'% e.error_dict)
             except ValueError, ve:
                 h.flash_error(unicode(ve))
         return render('user/perform_reset.html')


--- a/ckan/lib/alphabet_paginate.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/lib/alphabet_paginate.py	Thu Jul 28 12:55:51 2011 +0100
@@ -13,7 +13,7 @@
         ${c.page.pager()}
 '''
 from itertools import dropwhile
-
+import re
 from sqlalchemy import  __version__ as sqav
 from sqlalchemy.orm.query import Query
 from pylons.i18n import _
@@ -92,7 +92,18 @@
                     # regexp search
                     query = query.filter(attribute.op('~')(u'^[^a-zA-Z].*'))
             query.order_by(attribute)
-            return query.all()                                   
+            return query.all()
+        elif isinstance(self.collection,list):
+            if self.item_count >= self.paging_threshold:
+                if self.page != self.other_text:
+                    items = [x for x in self.collection if x[0:1].lower() == self.page.lower()]
+                else:
+                    # regexp search
+                    items = [x for x in self.collection if re.match('^[^a-zA-Z].*',x)]
+                items.sort()
+            else:
+                items = self.collection
+            return items
         else:
             raise NotImplementedError
 
@@ -100,5 +111,7 @@
     def item_count(self):
         if isinstance(self.collection, Query):
             return self.collection.count()
+        elif isinstance(self.collection,list):
+            return len(self.collection)
         else:
             raise NotImplementedError


--- a/ckan/lib/dictization/model_dictize.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/lib/dictization/model_dictize.py	Thu Jul 28 12:55:51 2011 +0100
@@ -11,7 +11,7 @@
 
 ## package save
 
-def group_list_dictize(obj_list, context, sort_key=lambda x:x):
+def group_list_dictize(obj_list, context, sort_key=lambda x:x['display_name']):
 
     active = context.get('active', True)
 
@@ -23,6 +23,10 @@
         if active and obj.state not in ('active', 'pending'):
             continue
 
+        group_dict['display_name'] = obj.display_name
+
+        group_dict['packages'] = len(obj.packages)
+
         result_list.append(group_dict)
     return sorted(result_list, key=sort_key)
 
@@ -155,15 +159,37 @@
 def group_dictize(group, context):
 
     result_dict = table_dictize(group, context)
+    
+    result_dict['display_name'] = group.display_name
 
-    result_dict["extras"] = extras_dict_dictize(
+    result_dict['extras'] = extras_dict_dictize(
         group._extras, context)
 
-    result_dict["packages"] = obj_list_dictize(
+    result_dict['packages'] = obj_list_dictize(
         group.packages, context)
 
     return result_dict
 
+def tag_dictize(tag, context):
+
+    result_dict = table_dictize(tag, context)
+
+    result_dict["packages"] = obj_list_dictize(
+        tag.packages_ordered, context)
+    
+    return result_dict 
+
+def user_dictize(user, context):
+
+    result_dict = table_dictize(user, context)
+
+    del result_dict['password']
+    
+    result_dict['display_name'] = user.display_name
+    result_dict['number_of_edits'] = user.number_of_edits()
+    result_dict['number_administered_packages'] = user.number_administered_packages()
+
+    return result_dict 
 
 ## conversion to api
 
@@ -183,6 +209,15 @@
     dictized["packages"] = sorted([package["id"] for package in dictized["packages"]])
     return dictized
 
+def tag_to_api1(tag, context):
+    
+    dictized = tag_dictize(tag, context)
+    return sorted([package["name"] for package in dictized["packages"]])
+
+def tag_to_api2(tag, context):
+
+    dictized = tag_dictize(tag, context)
+    return sorted([package["id"] for package in dictized["packages"]])
 
 def resource_dict_to_api(res_dict, package_id, context):
     res_dict.pop("revision_id")


--- a/ckan/lib/dictization/model_save.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/lib/dictization/model_save.py	Thu Jul 28 12:55:51 2011 +0100
@@ -297,6 +297,19 @@
 
     return group
 
+def user_dict_save(user_dict, context):
+
+    model = context['model']
+    session = context['session']
+    user = context.get('user_obj')
+    
+    User = model.User
+    if user:
+        user_dict['id'] = user.id
+
+    user = table_dict_save(user_dict, User, context)
+
+    return user
 
 def package_api_to_dict(api1_dict, context):
 


--- a/ckan/lib/helpers.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/lib/helpers.py	Thu Jul 28 12:55:51 2011 +0100
@@ -5,6 +5,7 @@
 Consists of functions to typically be used within templates, but also
 available to Controllers. This module is available to templates as 'h'.
 """
+from datetime import datetime
 from webhelpers.html import escape, HTML, literal, url_escape
 from webhelpers.html.tools import mail_to
 from webhelpers.html.tags import *
@@ -16,7 +17,6 @@
 from routes import url_for, redirect_to
 from alphabet_paginate import AlphaPage
 from lxml.html import fromstring
-import datetime
 from ckan.i18n import get_available_locales
 
 try:
@@ -29,6 +29,7 @@
 except ImportError:
     import simplejson as json
 
+ISO_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%f'
 
 class Message(object):
     """A message returned by ``Flash.pop_messages()``.
@@ -209,7 +210,7 @@
     '''
     from ckan import model
     date_format = '%Y-%m-%d %H:%M'
-    if isinstance(datetime_, datetime.datetime):
+    if isinstance(datetime_, datetime):
         return datetime_.strftime(date_format)
     elif isinstance(datetime_, basestring):
         try:
@@ -222,3 +223,8 @@
     else:
         return ''
 
+def date_str_to_datetime(date_str, format=ISO_DATE_FORMAT):
+    return datetime.strptime(date_str, format)
+
+def time_ago_in_words_from_str(date_str, format=ISO_DATE_FORMAT, granularity='month'):
+    return date.time_ago_in_words(datetime.strptime(date_str, format), granularity=granularity)


--- a/ckan/lib/navl/dictization_functions.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/lib/navl/dictization_functions.py	Thu Jul 28 12:55:51 2011 +0100
@@ -81,7 +81,7 @@
 
 def make_full_schema(data, schema):
     '''make schema by getting all valid combinations and making sure that all keys
-    are availible'''
+    are available'''
 
     flattented_schema = flatten_schema(schema)
 


--- a/ckan/logic/action/create.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/logic/action/create.py	Thu Jul 28 12:55:51 2011 +0100
@@ -14,15 +14,17 @@
 from ckan.lib.dictization.model_save import (group_api_to_dict,
                                              group_dict_save,
                                              package_api_to_dict,
-                                             package_dict_save)
+                                             package_dict_save,
+                                             user_dict_save)
 
 from ckan.lib.dictization.model_dictize import (group_dictize,
-                                                package_dictize)
+                                                package_dictize,
+                                                user_dictize)
 
 
 from ckan.logic.schema import default_create_package_schema, default_resource_schema
 
-from ckan.logic.schema import default_group_schema
+from ckan.logic.schema import default_group_schema, default_user_schema
 from ckan.lib.navl.dictization_functions import validate 
 from ckan.logic.action.update import (_update_package_relationship,
                                       package_error_summary,
@@ -213,6 +215,36 @@
                 'rating count': len(package.ratings)}
     return ret_dict
 
+def user_create(context, data_dict):
+    '''Creates a new user'''
+
+    model = context['model']
+    user = context['user']
+    schema = context.get('schema') or default_user_schema()
+
+    check_access(model.System(), model.Action.USER_CREATE, context)
+
+    data, errors = validate(data_dict, schema, context)
+
+    if errors:
+        model.Session.rollback()
+        raise ValidationError(errors, group_error_summary(errors))
+
+    rev = model.repo.new_revision()
+    rev.author = user
+
+    if 'message' in context:
+        rev.message = context['message']
+    else:
+        rev.message = _(u'REST API: Create user %s') % data.get('name')
+
+    user = user_dict_save(data, context)
+
+    model.repo.commit()        
+    context['user'] = user
+    context['id'] = user.id
+    log.debug('Created user %s' % str(user.name))
+    return user_dictize(user, context)
 
 ## Modifications for rest api
 


--- a/ckan/logic/action/get.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/logic/action/get.py	Thu Jul 28 12:55:51 2011 +0100
@@ -1,4 +1,6 @@
 from sqlalchemy.sql import select
+from sqlalchemy import or_, func, desc
+
 from ckan.logic import NotFound, check_access
 from ckan.model import Session
 from ckan.plugins import (PluginImplementations,
@@ -7,13 +9,20 @@
 import ckan.authz
 
 from ckan.lib.dictization import table_dictize
-from ckan.lib.dictization.model_dictize import group_to_api1, group_to_api2
+from ckan.lib.dictization.model_dictize import (package_dictize,
+                                                resource_list_dictize,
+                                                group_dictize,
+                                                group_list_dictize,
+                                                tag_dictize,
+                                                user_dictize)
+
 from ckan.lib.dictization.model_dictize import (package_to_api1,
                                                 package_to_api2,
-                                                package_dictize,
-                                                resource_list_dictize,
-                                                group_dictize)
-
+                                                group_to_api1,
+                                                group_to_api2,
+                                                tag_to_api1,
+                                                tag_to_api2)
+from ckan.lib.search import query_for
 
 def package_list(context, data_dict):
     '''Lists the package by name'''
@@ -81,18 +90,32 @@
     revision_dicts = []
     for revision, object_revisions in pkg.all_related_revisions:
         revision_dicts.append(model.revision_as_dict(revision,
-                                                     include_packages=False))
+                                                     include_packages=False,
+                                                     include_groups=False))
     return revision_dicts
 
 def group_list(context, data_dict):
-    model = context["model"]
-    user = context["user"]
+    '''Returns a list of groups'''
+
+    model = context['model']
+    user = context['user']
     api = context.get('api_version') or '1'
     ref_group_by = 'id' if api == '2' else 'name';
 
+    all_fields = data_dict.get('all_fields',None)
+
     query = ckan.authz.Authorizer().authorized_query(user, model.Group)
-    groups = query.all() 
-    return [getattr(p, ref_group_by) for p in groups]
+    query = query.order_by(model.Group.name.asc())
+    query = query.order_by(model.Group.title.asc())
+
+    groups = query.all()
+
+    if not all_fields:
+        group_list = [getattr(p, ref_group_by) for p in groups]
+    else:
+        group_list = group_list_dictize(groups,context)
+    
+    return group_list
 
 def group_list_authz(context, data_dict):
     model = context['model']
@@ -103,7 +126,7 @@
     groups = set(query.all())
     return dict((group.id, group.name) for group in groups)
 
-def group_list_availible(context, data_dict):
+def group_list_available(context, data_dict):
     model = context['model']
     user = context['user']
     pkg = context.get('package')
@@ -116,6 +139,21 @@
 
     return [(group.id, group.name) for group in groups]
 
+def group_revision_list(context, data_dict):
+    model = context['model']
+    id = data_dict['id']
+    group = model.Group.get(id)
+    if group is None:
+        raise NotFound
+    check_access(group, model.Action.READ, context)
+
+    revision_dicts = []
+    for revision, object_revisions in group.all_related_revisions:
+        revision_dicts.append(model.revision_as_dict(revision,
+                                                     include_packages=False,
+                                                     include_groups=False))
+    return revision_dicts
+
 def licence_list(context, data_dict):
     model = context["model"]
     license_register = model.Package.get_license_register()
@@ -124,11 +162,71 @@
     return licences
 
 def tag_list(context, data_dict):
-    model = context["model"]
-    tags = model.Session.query(model.Tag).all() #TODO
-    tag_list = [tag.name for tag in tags]
+    '''Returns a list of tags'''
+
+    model = context['model']
+    user = context['user']
+
+    all_fields = data_dict.get('all_fields',None)
+
+    q = data_dict.get('q','')
+    if q:
+        limit = data_dict.get('limit',25)
+        offset = data_dict.get('offset',0)
+        return_objects = data_dict.get('return_objects',True)
+
+        query = query_for(model.Tag, backend='sql')
+        query.run(query=q,
+                  limit=limit,
+                  offset=offset,
+                  return_objects=return_objects,
+                  username=user)
+        tags = query.results
+    else:
+        tags = model.Session.query(model.Tag).all() 
+    
+    tag_list = []
+    if all_fields:
+        for tag in tags:
+            result_dict = tag_dictize(tag, context)
+            tag_list.append(result_dict)
+    else:
+        tag_list = [tag.name for tag in tags]
+
     return tag_list
 
+def user_list(context, data_dict):
+    '''Lists the current users'''
+    model = context['model']
+    user = context['user']
+
+    q = data_dict.get('q','')
+    order_by = data_dict.get('order_by','name')
+
+    query = model.Session.query(model.User, func.count(model.User.id))
+    if q:
+        query = model.User.search(q, query)
+
+    if order_by == 'edits':
+        query = query.join((model.Revision, or_(
+                model.Revision.author==model.User.name,
+                model.Revision.author==model.User.openid
+                )))
+        query = query.group_by(model.User)
+        query = query.order_by(desc(func.count(model.User.id)))
+    else:
+        query = query.group_by(model.User)
+        query = query.order_by(model.User.name)
+
+    users_list = []
+
+    for user in query.all():
+        result_dict = user_dictize(user[0], context)
+        del result_dict['apikey']
+        users_list.append(result_dict)
+
+    return users_list
+
 def package_relationships_list(context, data_dict):
 
     ##TODO needs to work with dictization layer
@@ -201,6 +299,8 @@
     return rev_dict
 
 def group_show(context, data_dict):
+    '''Shows group details'''
+
     model = context['model']
     id = data_dict['id']
     api = context.get('api_version') or '1'
@@ -211,6 +311,7 @@
 
     if group is None:
         raise NotFound
+
     check_access(group, model.Action.READ, context)
 
     group_dict = group_dictize(group, context)
@@ -222,17 +323,57 @@
 
 
 def tag_show(context, data_dict):
+    '''Shows tag details'''
+
     model = context['model']
     api = context.get('api_version') or '1'
     id = data_dict['id']
-    ref_package_by = 'id' if api == '2' else 'name'
-    obj = model.Tag.get(id) #TODO tags
-    if obj is None:
+
+    tag = model.Tag.get(id)
+    context['tag'] = tag
+
+    if tag is None:
         raise NotFound
-    package_list = [getattr(pkgtag.package, ref_package_by)
-                    for pkgtag in obj.package_tags]
-    return package_list 
 
+    tag_dict = tag_dictize(tag,context)
+    extended_packages = []
+    for package in tag_dict['packages']:
+        extended_packages.append(_extend_package_dict(package,context))
+
+    tag_dict['packages'] = extended_packages
+
+    return tag_dict
+
+def user_show(context, data_dict):
+    '''Shows user details'''
+    model = context['model']
+
+    id = data_dict.get('id',None)
+    provided_user = data_dict.get('user_obj',None)
+    if id:
+        user = model.User.get(id)
+        context['user_obj'] = user
+        if user is None:
+            raise NotFound
+    elif provided_user:
+        context['user_obj'] = user = provided_user
+    else:
+        raise NotFound
+
+    user_dict = user_dictize(user,context)
+
+    revisions_q = model.Session.query(model.Revision
+            ).filter_by(author=user.name)
+    
+    revisions_list = []
+    for revision in revisions_q.limit(20).all():
+        revision_dict = revision_show(context,{'id':revision.id})
+        revision_dict['state'] = revision.state
+        revisions_list.append(revision_dict)
+
+    user_dict['activity'] = revisions_list
+
+    return user_dict
 
 def package_show_rest(context, data_dict):
 
@@ -261,3 +402,141 @@
 
     return group_dict
 
+def tag_show_rest(context, data_dict):
+
+    tag_show(context, data_dict)
+    api = context.get('api_version') or '1'
+    tag = context['tag']
+
+    if api == '2':
+        tag_dict = tag_to_api2(tag, context)
+    else:
+        tag_dict = tag_to_api1(tag, context)
+
+    return tag_dict
+
+def package_autocomplete(context, data_dict):
+    '''Returns packages containing the provided string'''
+    model = context['model']
+    session = context['session']
+    user = context['user']
+    q = data_dict['q']
+
+    like_q = u"%s%%" % q
+
+    #TODO: Auth
+    pkg_query = ckan.authz.Authorizer().authorized_query(user, model.Package)
+    pkg_query = session.query(model.Package) \
+                    .filter(or_(model.Package.name.ilike(like_q),
+                                model.Package.title.ilike(like_q)))
+    pkg_query = pkg_query.limit(10)
+
+    pkg_list = []
+    for package in pkg_query:
+        result_dict = table_dictize(package, context)
+        pkg_list.append(result_dict)
+
+    return pkg_list
+
+def tag_autocomplete(context, data_dict):
+    '''Returns tags containing the provided string'''
+    model = context['model']
+    session = context['session']
+    user = context['user']
+
+    q = data_dict.get('q',None)
+    if not q:
+        return []
+
+    limit = data_dict.get('limit',10)
+
+    like_q = u"%s%%" % q
+
+    query = query_for('tag', backend='sql')
+    query.run(query=like_q,
+              return_objects=True,
+              limit=10,
+              username=user)
+
+    return [tag.name for tag in query.results]
+
+def user_autocomplete(context, data_dict):
+    '''Returns users containing the provided string'''
+    model = context['model']
+    session = context['session']
+    user = context['user']
+    q = data_dict.get('q',None)
+    if not q:
+        return []
+
+    limit = data_dict.get('limit',20)
+
+    query = model.User.search(q).limit(limit)
+
+    user_list = []
+    for user in query.all():
+        result_dict = {}
+        for k in ['id', 'name', 'fullname']:
+                result_dict[k] = getattr(user,k)
+
+        user_list.append(result_dict)
+
+    return user_list
+
+def package_search(context, data_dict):
+    model = context['model']
+    session = context['session']
+    user = context['user']
+
+    q=data_dict.get('q','')
+    fields=data_dict.get('fields',[])
+    facet_by=data_dict.get('facet_by',[])
+    limit=data_dict.get('limit',20)
+    offset=data_dict.get('offset',0)
+    return_objects=data_dict.get('return_objects',False)
+    filter_by_openness=data_dict.get('filter_by_openness',False)
+    filter_by_downloadable=data_dict.get('filter_by_downloadable',False)
+
+    query = query_for(model.Package)
+    query.run(query=q,
+              fields=fields,
+              facet_by=facet_by,
+              limit=limit,
+              offset=offset,
+              return_objects=return_objects,
+              filter_by_openness=filter_by_openness,
+              filter_by_downloadable=filter_by_downloadable,
+              username=user)
+    
+    results = []
+    for package in query.results:
+        result_dict = table_dictize(package, context)
+        result_dict = _extend_package_dict(result_dict,context)
+
+        results.append(result_dict)
+
+    return {
+        'count': query.count,
+        'facets': query.facets,
+        'results': results
+    }
+
+def _extend_package_dict(package_dict,context):
+    model = context['model']
+
+    resources = model.Session.query(model.Resource)\
+                .join(model.ResourceGroup)\
+                .filter(model.ResourceGroup.package_id == package_dict['id'])\
+                .all()
+    if resources:
+        package_dict['resources'] = resource_list_dictize(resources, context)
+    else:
+        package_dict['resources'] = []
+    license_id = package_dict['license_id']
+    if license_id:
+        isopen = model.Package.get_license_register()[license_id].isopen()
+        package_dict['isopen'] = isopen
+    else:
+        package_dict['isopen'] = False
+
+    return package_dict


--- a/ckan/logic/action/update.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/logic/action/update.py	Thu Jul 28 12:55:51 2011 +0100
@@ -12,14 +12,17 @@
                                                 resource_dictize,
                                                 group_dictize,
                                                 group_to_api1,
-                                                group_to_api2)
+                                                group_to_api2,
+                                                user_dictize)
 from ckan.lib.dictization.model_save import (group_api_to_dict,
                                              package_api_to_dict,
                                              group_dict_save,
+                                             user_dict_save,
                                              package_dict_save,
                                              resource_dict_save)
 from ckan.logic.schema import (default_update_group_schema,
                                default_update_package_schema,
+                               default_update_user_schema,
                                default_update_resource_schema)
 from ckan.lib.navl.dictization_functions import validate
 log = logging.getLogger(__name__)
@@ -360,6 +363,45 @@
 
     return group_dictize(group, context)
 
+def user_update(context, data_dict):
+    '''Updates the user's details'''
+
+    model = context['model']
+    user = context['user']
+    preview = context.get('preview', False)
+    schema = context.get('schema') or default_update_user_schema() 
+    id = data_dict['id']
+
+    user_obj = model.User.get(id)
+    context['user_obj'] = user_obj
+    if user_obj is None:
+        raise NotFound('User was not found.')
+
+    if not (ckan.authz.Authorizer().is_sysadmin(unicode(user)) or user == user_obj.name) and \
+       not ('reset_key' in data_dict and data_dict['reset_key'] == user_obj.reset_key):
+        raise NotAuthorized( _('User %s not authorized to edit %s') % (str(user), id))
+
+    data, errors = validate(data_dict, schema, context)
+    if errors:
+        model.Session.rollback()
+        raise ValidationError(errors, group_error_summary(errors))
+
+    if not preview:
+        rev = model.repo.new_revision()
+        rev.author = user
+        if 'message' in context:
+            rev.message = context['message']
+        else:
+            rev.message = _(u'REST API: Update user %s') % data.get('name')
+
+    user = user_dict_save(data, context)
+    
+    if not preview:
+        model.repo.commit()        
+        return user_dictize(user, context)
+
+    return data
+
 ## Modifications for rest api
 
 def package_update_rest(context, data_dict):


--- a/ckan/logic/schema.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/logic/schema.py	Thu Jul 28 12:55:51 2011 +0100
@@ -20,7 +20,13 @@
                                    duplicate_extras_key,
                                    ignore_not_admin,
                                    no_http,
-                                   tag_not_uppercase)
+                                   tag_not_uppercase,
+                                   user_name_validator,
+                                   user_password_validator,
+                                   user_both_passwords_entered,
+                                   user_passwords_match,
+                                   user_password_not_empty,
+                                   user_about_validator)
 from formencode.validators import OneOf
 import ckan.model
 
@@ -189,4 +195,44 @@
          'state': [ignore],
      }
 
+def default_user_schema():
 
+    schema = {
+        'id': [ignore_missing, unicode],
+        'name': [not_empty, unicode, user_name_validator],
+        'fullname': [ignore_missing, unicode],
+        'password': [user_password_validator, user_password_not_empty, ignore_missing, unicode],
+        'email': [ignore_missing, unicode],
+        'about': [ignore_missing, user_about_validator, unicode],
+        'created': [ignore],
+        'openid': [ignore],
+        'apikey': [ignore],
+        'reset_key': [ignore],
+    }
+    return schema
+
+def user_new_form_schema():
+    schema = default_user_schema()
+    
+    schema['password1'] = [unicode,user_both_passwords_entered,user_password_validator,user_passwords_match]
+    schema['password2'] = [unicode]
+
+    return schema
+
+def user_edit_form_schema():
+    schema = default_user_schema()
+
+    schema['name'] = [ignore_missing]
+    schema['password'] = [ignore_missing]
+    schema['password1'] = [ignore_missing,unicode,user_password_validator,user_passwords_match]
+    schema['password2'] = [ignore_missing,unicode]
+
+    return schema
+
+def default_update_user_schema():
+    schema = default_user_schema()
+    
+    schema['name'] = [ignore_missing]
+    schema['password'] = [user_password_validator,ignore_missing, unicode]
+    return schema
+


--- a/ckan/logic/validators.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/logic/validators.py	Thu Jul 28 12:55:51 2011 +0100
@@ -1,6 +1,6 @@
 import re
 from pylons.i18n import _, ungettext, N_, gettext
-from ckan.lib.navl.dictization_functions import Invalid, missing, unflatten
+from ckan.lib.navl.dictization_functions import Invalid, Missing, missing, unflatten
 from ckan.authz import Authorizer
 
 def package_id_not_changed(value, context):
@@ -168,3 +168,58 @@
 
     data.pop(key)
 
+def user_name_validator(value,context):
+    model = context['model']
+
+    if not model.User.check_name_valid(value):
+        raise Invalid(
+            _('That login name is not valid. It must be at least 3 characters, restricted to alphanumerics and these symbols: %s') % '_\-'
+        )
+
+    if not model.User.check_name_available(value):
+        raise Invalid(
+            _("That login name is not available.")
+        )
+
+    return value
+
+def user_both_passwords_entered(key, data, errors, context):
+    
+    password1 = data.get(('password1',),None)
+    password2 = data.get(('password2',),None)
+
+    if password1 is None or password1 == '' or \
+       password2 is None or password2 == '':
+        errors[('password',)].append(_('Please enter both passwords'))
+
+def user_password_validator(key, data, errors, context):
+    value = data[key]
+
+    if not value == '' and not isinstance(value, Missing) and not len(value) >= 4:
+        errors[('password',)].append(_('Your password must be 4 characters or longer'))
+
+def user_passwords_match(key, data, errors, context):
+    
+    password1 = data.get(('password1',),None)
+    password2 = data.get(('password2',),None)
+
+    if not password1 == password2:
+        errors[key].append(_('The passwords you entered do not match'))
+    else:
+        #Set correct password
+        data[('password',)] = password1
+
+def user_password_not_empty(key, data, errors, context):
+    '''Only check if password is present if the user is created via action API.
+       If not, user_both_passwords_entered will handle the validation'''
+     
+    if not ('password1',) in data and not ('password2',) in data:
+        password = data.get(('password',),None)
+        if not password:
+            errors[key].append(_('Missing value'))
+
+def user_about_validator(value,context):
+    if 'http://' in value or 'https://' in value:
+        raise Invalid(_('Edit not allowed as it looks like spam. Please avoid links in your description.'))
+
+    return value


--- a/ckan/model/__init__.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/model/__init__.py	Thu Jul 28 12:55:51 2011 +0100
@@ -236,16 +236,21 @@
     '''
     return t.isoformat()
 
-def revision_as_dict(revision, include_packages=True, ref_package_by='name'):
+def revision_as_dict(revision, include_packages=True, include_groups=True,ref_package_by='name'):
     revision_dict = OrderedDict((
         ('id', revision.id),
         ('timestamp', strftimestamp(revision.timestamp)),
         ('message', revision.message),
         ('author', revision.author),
+        ('approved_timestamp',revision.approved_timestamp)
         ))
     if include_packages:
         revision_dict['packages'] = [getattr(pkg, ref_package_by) \
                                      for pkg in revision.packages]
+    if include_groups:
+        revision_dict['groups'] = [getattr(grp, ref_package_by) \
+                                     for grp in revision.groups]
+       
     return revision_dict
 
 def is_id(id_string):


--- a/ckan/templates/_util.html	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/templates/_util.html	Thu Jul 28 12:55:51 2011 +0100
@@ -30,7 +30,15 @@
       ${h.link_to(tag['name'], h.url_for(controller='tag', action='read', id=tag['name']))}
     </li></ul>
-  
+
+  <!--! List of tags: pass in a list of tag name and this renders the standard
+        tag listing -->
+  <ul py:def="tag_list_from_name(tags)" class="tags clearfix">
+    <li py:for="tag in tags">
+      ${h.link_to(tag, h.url_for(controller='tag', action='read', id=tag))}
+    </li>
+  </ul>
+ 
   <!--! List of users: pass in a collection of users and this renders the standard
           user listing --><ul py:def="user_list(users)" class="users">
@@ -153,7 +161,20 @@
     </tr></py:for></table>
-  
+
+  <!--! List of data package groups: pass in a collection of data package groups 
+        and this renders the standard group listing. Same as the above, but using dictionaries -->
+  <table py:def="group_list_from_dict(groups)" class="groups">
+    <tr><th>Title</th><th>Number of packages</th><th>Description</th></tr>
+    <py:for each="group in groups">
+    <tr>
+      <td><a href="${h.url_for(controller='group', action='read', id=group['name'])}">${group['display_name']}</a></td>
+      <td>${group['packages']}</td>
+      <td>${h.truncate(group['description'], length=80, whole_word=True)}</td>
+    </tr>
+    </py:for>
+  </table>
+ 
   <!--! List of authorization groups: pass in a collection of authorization groups and 
         this renders the standard group listing --><table py:def="authorization_group_list(authorization_groups)" class="authorization_groups">
@@ -376,4 +397,54 @@
     </tr></table>
 
+  
+  <table py:def="revision_list_from_dict(revisions, allow_compare=False)">
+    <tr>
+      <th>Revision</th><th>Timestamp</th><th>Author</th><th>Entity</th><th>Log Message</th>
+    </tr>
+    <tr
+      class="state-${revision['state']}"
+      py:for="revision in revisions"
+      >
+      <td>
+        ${
+          h.link_to(revision['id'],
+            h.url_for(
+              controller='revision',
+              action='read',
+              id=revision['id'])
+            )
+        }
+        <py:if test="c.revision_change_state_allowed">
+        <div class="actions">
+          <form
+            method="POST"
+            action="${h.url_for(controller='revision',
+                action='edit',
+                id=revision['id'])}"
+            >
+            <py:if test="revision['state']!='deleted'">
+            <button type="submit" name="action" value="delete">Delete</button>
+            </py:if>
+            <py:if test="revision['state']=='deleted'">
+            <button type="submit" name="action" value="undelete">Undelete</button>
+            </py:if>
+          </form>
+        </div>
+        </py:if>
+      </td>
+      <td>${revision['timestamp']}</td>
+      <td>${h.linked_user(revision['author'])}</td>
+      <td>
+        <py:for each="pkg in revision['packages']">
+          <a href="${h.url_for(controller='package', action='read', id=pkg)}">${pkg}</a>
+        </py:for>
+        <py:for each="grp in revision['groups']">
+          <a href="${h.url_for(controller='group', action='read', id=grp)}">${grp}</a>
+        </py:for>
+      </td>
+      <td>${revision['message']}</td>
+    </tr>
+  </table>
+
 </html>


--- a/ckan/templates/group/history.html	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/templates/group/history.html	Thu Jul 28 12:55:51 2011 +0100
@@ -2,19 +2,19 @@
   xmlns:xi="http://www.w3.org/2001/XInclude"
   py:strip="">
   
-  <py:def function="page_title">${c.group.display_name} - Groups - History</py:def>
+  <py:def function="page_title">${c.group_dict['display_name']} - Groups - History</py:def><div py:match="content" class="group"><h2 class="head">
-      ${c.group.display_name}
+      ${c.group_dict['display_name']}
     </h2><h3>
       Revisions
       <p class="atom-feed-link group-history-link"><a
-          href="${url(controller='group', action='history', id=c.group.name, format='atom', days=7)}"
-          title="${g.site_title} - Group History - ${c.group.name}"
+          href="${url(controller='group', action='history', id=c.group_dict['name'], format='atom', days=7)}"
+          title="${g.site_title} - Group History - ${c.group_dict['name']}"
           >
           Subscribe »</a></p>
@@ -28,24 +28,24 @@
         Error: ${c.error}
       </h3>
 
-      <input type="hidden" name="group_name" value="${c.group.name}"/>
+      <input type="hidden" name="group_name" value="${c.group_dict['name']}"/><table><tr><th></th><th>Revision</th><th>Timestamp</th><th>Author</th><th>Log Message</th></tr>
-        <py:for each="index, rev in enumerate([rev for rev, obj_revs in c.group_revisions])">
+        <py:for each="index, revision_dict in enumerate(c.group_revisions)"><tr><td nowrap="nowrap">
-              ${h.radio("selected1", rev.id, checked=(index == 0))}
-              ${h.radio("selected2", rev.id, checked=(index == len(c.group_revisions)-1))}
+              ${h.radio("selected1", revision_dict['id'], checked=(index == 0))}
+              ${h.radio("selected2", revision_dict['id'], checked=(index == len(c.group_revisions)-1))}
             </td><td>
-              <a href="${h.url_for(controller='revision',action='read',id=rev.id)}">${rev.id}</a>
+              <a href="${h.url_for(controller='revision',action='read',id=revision_dict['id'])}">${revision_dict['id']}</a></td>
-            <td>${rev.timestamp}</td>
-            <td>${h.linked_user(rev.author)}</td>
-            <td>${rev.message}</td>
+            <td>${revision_dict['timestamp']}</td>
+            <td>${h.linked_user(revision_dict['author'])}</td>
+            <td>${revision_dict['message']}</td></tr></py:for></table>
@@ -55,7 +55,7 @@
 
   <py:def function="optional_feed"><link rel="alternate" type="application/atom+xml" title="Group History"
-      href="${url(controller='group', action='history', id=c.group.name, format='atom', days=7)}" />
+      href="${url(controller='group', action='history', id=c.group_dict['name'], format='atom', days=7)}" /></py:def><xi:include href="layout.html" />


--- a/ckan/templates/group/index.html	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/templates/group/index.html	Thu Jul 28 12:55:51 2011 +0100
@@ -11,7 +11,7 @@
     <p i18n:msg="item_count">There are <strong>${c.page.item_count}</strong> groups.</p>
 
     ${c.page.pager()}
-    ${group_list(c.page.items)}
+    ${group_list_from_dict(c.page.items)}
     ${c.page.pager()}
     
     <py:choose test="">


--- a/ckan/templates/package/history.html	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/templates/package/history.html	Thu Jul 28 12:55:51 2011 +0100
@@ -1,8 +1,9 @@
 <html xmlns:py="http://genshi.edgewall.org/"
+  xmlns:i18n="http://genshi.edgewall.org/i18n"
   xmlns:xi="http://www.w3.org/2001/XInclude"
   py:strip="">
   
-  <py:def function="page_title">${c.pkg.title or c.pkg.name} - Data Packages - History</py:def>
+  <py:def function="page_title">${c.pkg_dict.get('title', c.pkg_dict['name'])} - Data Packages - History</py:def><!-- Sidebar --><py:match path="primarysidebar">
@@ -10,8 +11,8 @@
         <h4>Updates</h4><p class="atom-feed-link package-history-link"><a
-            href="${url(controller='package', action='history', id=c.pkg.name, format='atom', days=7)}"
-            title="${g.site_title} - Package History - ${c.pkg.name}">
+            href="${url(controller='package', action='history', id=c.pkg_dict['name'], format='atom', days=7)}"
+            title="${g.site_title} - Package History - ${c.pkg_dict['name']}">
             Subscribe »</a></p></li>
@@ -21,7 +22,7 @@
 
     <!-- Title --><h2 class="head">
-      ${c.pkg.title} - History
+        ${c.pkg_dict.get('title', c.pkg_dict['name'])} - History
     </h2><h3>Revisions</h3>
@@ -34,25 +35,25 @@
         Error: ${c.error}
       </h3>
       
-      <input type="hidden" name="pkg_name" value="${c.pkg.name}"/>
+      <input type="hidden" name="pkg_name" value="${c.pkg_dict['name']}"/><table><tr><th></th><th>Revision ID</th><th>Package with timestamp</th><th>Author</th><th>Log Message</th></tr>
-        <py:for each="index, rev in enumerate([rev for rev, obj_revs in c.pkg_revisions])">
+        <py:for each="index, rev in enumerate(c.pkg_revisions)"><tr><td nowrap="nowrap">
               ${h.radio("selected1", rev.id, checked=(index == 0))}
               ${h.radio("selected2", rev.id, checked=(index == len(c.pkg_revisions)-1))}
             </td><td>
-              <a href="${h.url_for(controller='revision',action='read',id=rev.id)}" title="${rev.id}">${rev.id[:4]}…</a>
+              <a href="${h.url_for(controller='revision',action='read',id=rev['id'])}">${rev['id'][:4]}…</a></td><td>
-              <a href="${h.url_for(controller='package',action='read',id='%s@%s' % (c.pkg.name, rev.timestamp))}" title="${'Read package as of %s' % rev.timestamp}">${h.render_datetime(rev.timestamp)}</a></td>
-            <td>${h.linked_user(rev.author)}</td>
-            <td>${rev.message}</td>
+              <a href="${h.url_for(controller='package',action='read',id='%s@%s' % (c.pkg_dict['name'], rev['timestamp']))}" title="${'Read package as of %s' % rev['timestamp']}">${h.render_datetime(rev['timestamp'])}</a></td>
+            <td>${h.linked_user(rev['author'])}</td>
+            <td>${rev['message']}</td></tr></py:for></table>
@@ -62,7 +63,7 @@
 
   <py:def function="optional_feed"><link rel="alternate" type="application/atom+xml" title="Package History"
-    href="${url(controller='package', action='history', id=c.pkg.name, format='atom', days=7)}" />
+    href="${url(controller='package', action='history', id=c.pkg_dict['name'], format='atom', days=7)}" /></py:def><xi:include href="layout.html" />


--- a/ckan/templates/package/search.html	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/templates/package/search.html	Thu Jul 28 12:55:51 2011 +0100
@@ -59,7 +59,7 @@
       <py:if test="c.page.item_count == 0 and request.params"><p i18n:msg="">Would you like to <a href="${h.url_for(action='new', id=None)}">create a new package?</a></p></py:if>
-      ${package_list(c.page.items)}
+      ${package_list_from_dict(c.page.items)}
       ${c.page.pager(q=c.q)}
 
   </div>


--- a/ckan/templates/tag/index.html	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/templates/tag/index.html	Thu Jul 28 12:55:51 2011 +0100
@@ -27,7 +27,7 @@
     </p>
     
     ${c.page.pager(q=c.q)}
-    ${tag_list(c.page.items)}
+    ${tag_list_from_name(c.page.items)}
     ${c.page.pager(q=c.q)}
     
     <p py:if="c.q">


--- a/ckan/templates/tag/read.html	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/templates/tag/read.html	Thu Jul 28 12:55:51 2011 +0100
@@ -3,12 +3,12 @@
   xmlns:xi="http://www.w3.org/2001/XInclude"
   py:strip="">
   
-  <py:def function="page_title">${c.tag.name} - Tags</py:def>
+  <py:def function="page_title">${c.tag['name']} - Tags</py:def><div py:match="content">
-    <h2>Tag: ${c.tag.name}</h2>
-    <p i18n:msg="package_count, tagname">There are ${len(c.tag.packages_ordered)} packages tagged with <strong>${c.tag.name}</strong>:</p>
-    ${package_list(c.tag.packages_ordered)}
+    <h2>Tag: ${c.tag['name']}</h2>
+    <p i18n:msg="package_count, tagname">There are ${len(c.tag['packages'])} packages tagged with <strong>${c.tag['name']}</strong>:</p>
+    ${package_list_from_dict(c.tag['packages'])}
   </div><xi:include href="layout.html" />


--- a/ckan/templates/user/edit.html	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/templates/user/edit.html	Thu Jul 28 12:55:51 2011 +0100
@@ -11,51 +11,27 @@
 
   <div py:match="content"><h2>
-      Edit User: ${c.userobj.display_name} (${c.userobj.name})
+      Edit User: ${c.display_name} (${c.user_name})
       <a href="#preview" py:if="c.preview">(skip to preview)</a></h2>
 
-    <form id="user-edit" action="" method="post" class="simple-form" 
-      xmlns:py="http://genshi.edgewall.org/"
-      xmlns:xi="http://www.w3.org/2001/XInclude"
-      >
-      <fieldset>
-        <legend>Base details</legend>
-        <label for="fullname">Full name:</label>
-        <input name="fullname" value="${c.user_fullname}" /><br/>
-         
-        <label for="email">E-Mail:</label>
-        <input name="email" value="${c.user_email}" /><br/>
-      </fieldset>
-      <fieldset>
-        <legend>Change your password</legend>
-        <label for="password1">Password:</label>
-        <input type="password" name="password1" value="" />
-        <br/>
-        <label for="password2">Password (repeat):</label>
-        <input type="password" name="password2" value="" />
-        <br/>
-      </fieldset>
-      <label for="about">About user:</label>
-      <textarea id="about" rows="5" name="about" cols="60">${c.user_about}</textarea>
-      <p class="small" i18n:msg="">You can use <a href="http://daringfireball.net/projects/markdown/syntax">Markdown formatting</a> here.</p>
-
-      <div>
-        <input name="preview" type="submit" value="Preview" />
-        ${h.submit('save', _('Save'))}
-      </div>
-    </form><div id="preview" style="margin-left: 20px;" py:if="c.preview"><hr /><h2>Preview</h2>
-      <h4>Full name: ${c.user_fullname}</h4>
+      <h4>Full name: ${c.full_name}</h4>
+      <h4>Email: ${c.email}</h4><div style="border: 2px dashed red; padding: 5px;"> 
         ${c.preview}
       </div></div>
+
+    ${Markup(c.form)}
+
+
   </div>
 
+
   <xi:include href="layout.html" /></html>


--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ckan/templates/user/edit_user_form.html	Thu Jul 28 12:55:51 2011 +0100
@@ -0,0 +1,42 @@
+<form id="user-edit" action="" method="post"
+    py:attrs="{'class':'has-errors'} if errors else {}"
+    xmlns:i18n="http://genshi.edgewall.org/i18n"
+    xmlns:py="http://genshi.edgewall.org/"
+    xmlns:xi="http://www.w3.org/2001/XInclude">
+
+<div class="error-explanation" py:if="error_summary">
+<h2>Errors in form</h2>
+<p>The form contains invalid entries:</p>
+<ul>
+  <li py:for="key, error in error_summary.items()">${"%s: %s" % (key, error)}</li>
+</ul>
+</div>
+  <fieldset>
+    <legend>Base details</legend>
+    <dl>
+    <dt><label for="fullname">Full name:</label></dt>
+    <dd><input type="text" name="fullname" value="${data.get('fullname','')}" /></dd>
+     
+    <dt><label for="email">E-Mail:</label></dt>
+    <dd><input type="text" name="email" value="${data.get('email','')}" /></dd>
+    </dl>
+  </fieldset>
+  <fieldset>
+    <legend>Change your password</legend>
+    <dl>
+    <dt><label for="password1">Password:</label></dt>
+    <dd><input type="password" name="password1" value="" /></dd>
+    <dt><label for="password2">Password (repeat):</label></dt>
+    <dd><input type="password" name="password2" value="" /></dd>
+    </dl>
+  </fieldset>
+  <label for="about">About user:</label>
+  <textarea id="about" rows="5" name="about" cols="60">${data.get('about','')}</textarea>
+  <p class="small" i18n:msg="">You can use <a href="http://daringfireball.net/projects/markdown/syntax">Markdown formatting</a> here.</p>
+
+  <div>
+    <input name="preview" type="submit" value="Preview" />
+    ${h.submit('save', _('Save'))}
+  </div>
+</form>
+


--- a/ckan/templates/user/list.html	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/templates/user/list.html	Thu Jul 28 12:55:51 2011 +0100
@@ -25,25 +25,24 @@
     <hr /><ul class="userlist">
-      <li py:for="(user,count) in c.page.items" class="user">
+      <li py:for="user in c.page.items" class="user"><ul><li class="username">
-          ${h.linked_user(user, maxlength=20)}
+          ${h.linked_user(user['name'], maxlength=20)}
           </li><li class="created">
-            Member for ${h.date.time_ago_in_words(user.created,
-              granularity='month')}
+            Member for ${h.time_ago_in_words_from_str(user['created'],granularity='month')}
           </li><li>
-            <span class="edits" title="${user.number_of_edits()} Edits">
-              ${user.number_of_edits()}
+            <span class="edits" title="${user['number_of_edits']} Edits">
+              ${user['number_of_edits']}
             </span><span class="administered"
-              title="${user.number_administered_packages()} Administered">
+              title="${user['number_administered_packages']} Administered"><span class="badge">
                 ●
               </span>
-              ${user.number_administered_packages()}
+              ${user['number_administered_packages']}
             </span></li></ul>


--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ckan/templates/user/new.html	Thu Jul 28 12:55:51 2011 +0100
@@ -0,0 +1,35 @@
+<html xmlns:py="http://genshi.edgewall.org/"
+
+  xmlns:xi="http://www.w3.org/2001/XInclude"
+  py:strip="">
+  
+  <py:def function="page_title">Register - User</py:def>
+
+  <py:def function="optional_head">
+    <link rel="stylesheet" href="${g.site_url}/css/forms.css" type="text/css" media="screen, print" />
+  </py:def>
+
+  <py:match path="primarysidebar">
+    <li class="widget-container widget_text">
+      <h2>Have an OpenID?</h2>
+      <p>
+        If you have an account with Google, Yahoo or one of many other 
+        OpenID providers, you can log in without signing up. 
+      </p>
+      <ul>
+        <li>${h.link_to(_('Log in now'), h.url_for(action='login'))}</li>
+      </ul>
+    </li>
+  </py:match>
+
+  <div py:match="content">
+    <h2>
+      Join the community
+    </h2>
+
+    ${Markup(c.form)}
+  </div>
+
+  <xi:include href="layout.html" />
+</html>
+


--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ckan/templates/user/new_user_form.html	Thu Jul 28 12:55:51 2011 +0100
@@ -0,0 +1,42 @@
+<form id="user-edit" action="" method="post"
+    py:attrs="{'class':'has-errors'} if errors else {}"
+    xmlns:i18n="http://genshi.edgewall.org/i18n"
+    xmlns:py="http://genshi.edgewall.org/"
+    xmlns:xi="http://www.w3.org/2001/XInclude">
+
+<div class="error-explanation" py:if="error_summary">
+<h2>Errors in form</h2>
+<p>The form contains invalid entries:</p>
+<ul>
+  <li py:for="key, error in error_summary.items()">${"%s: %s" % (key, error)}</li>
+</ul>
+</div>
+
+<fieldset>
+    <legend i18n:msg="site_title">Register with CKAN</legend>
+    <dl>
+        <dt><label class="field_opt" for="name">Login:</label></dt>
+        <dd><input type="text" name="name" value="${data.get('name','')}" /></dd>
+        <dd class="instructions basic">3+ chars, using only 'a-z0-9' and '-_'</dd>
+        <dd class="field_error" py:if="errors.get('name', '')">${errors.get('name', '')}</dd>
+
+        <dt><label class="field_opt" for="fullname">Full name (optional):</label></dt>
+        <dd><input type="text" name="fullname" value="${data.get('fullname','')}" /></dd>
+        <dd class="field_error" py:if="errors.get('fullname', '')">${errors.get('fullname', '')}</dd>
+
+        <dt><label class="field_opt" for="email">E-Mail (optional):</label></dt>
+        <dd><input type="text" name="email" value="${data.get('email','')}" /></dd>
+
+        <dt><label class="field_opt" for="password1">Password:</label></dt>
+        <dd><input type="password" name="password1" value="" /></dd>
+        <dd class="field_error" py:if="errors.get('password1', '')">${errors.get('password1', '')}</dd>
+
+        <dt><label class="field_opt" for="password2">Password (repeat):</label></dt>
+        <dd><input type="password" name="password2" value="" /></dd>
+
+    </dl>
+</fieldset>
+
+  <br />
+  <input id="save" name="save" type="submit" value="Save" />
+</form>


--- a/ckan/templates/user/read.html	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/templates/user/read.html	Thu Jul 28 12:55:51 2011 +0100
@@ -2,18 +2,18 @@
   xmlns:xi="http://www.w3.org/2001/XInclude"
   py:strip="">
   
-  <py:def function="page_title">${c.read_user} - User</py:def>
+  <py:def function="page_title">${c.user_dict['display_name']} - User</py:def><py:def function="body_class">user-view</py:def><div py:match="content">
 
-    <h2>${c.read_user}</h2>
+    <h2>${c.user_dict['display_name']}</h2><py:if test="c.is_myself"><h3>My Account</h3><p>You are logged in.</p><ul>
-        <li>Your API key is: ${c.api_key}</li>
+        <li>Your API key is: ${c.user_dict['apikey']}</li><li><a href="${h.url_for(controller='user', action='edit')}">Edit your profile</a></li><li><a href="${h.url_for('/user/logout')}">Log out</a></li></ul>
@@ -26,14 +26,14 @@
     <div class="activity"><h3>Activity</h3><ul>
-        <li><strong>Number of edits:</strong> ${c.num_edits}</li>
-        <li><strong>Number of packages administered:</strong> ${c.num_pkg_admin}</li>
+        <li><strong>Number of edits:</strong> ${c.user_dict['number_of_edits']}</li>
+        <li><strong>Number of packages administered:</strong> ${c.user_dict['number_administered_packages']}</li></ul></div><div class="changes"><h3>Recent changes</h3>
-      ${revision_list(c.activity)}
+      ${revision_list_from_dict(c.user_dict['activity'])}
     </div></div>
 


--- a/ckan/templates/user/register.html	Wed Jul 27 17:15:34 2011 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,48 +0,0 @@
-<html xmlns:py="http://genshi.edgewall.org/"
-  xmlns:i18n="http://genshi.edgewall.org/i18n"
-  xmlns:xi="http://www.w3.org/2001/XInclude"
-  py:strip="">
-  
-  <py:match path="primarysidebar">
-    <li class="widget-container widget_text">
-      <h2>Have an OpenID?</h2>
-      <p>
-        If you have an account with Google, Yahoo or one of many other 
-        OpenID providers, you can log in without signing up. 
-      </p>
-      <ul>
-        <li>${h.link_to(_('Log in now'), h.url_for(action='login'))}</li>
-      </ul>
-    </li>
-  </py:match>
-  
-  <py:def function="page_title">Register - User</py:def>
-
-  <div py:match="content">
-    <h2>Join the community</h2>
-    
-    <form action="/user/register" method="post" class="simple-form" id="register_form">  
-      <fieldset>
-        <legend i18n:msg="site_title">Register with CKAN</legend>
-
-        <label for="login">Login:</label>
-        <input name="login" value="${c.login}" />
-        <br/>
-        <label for="fullname">Full name (optional):</label>
-        <input name="fullname" value="${c.fullname}" />
-        <br/>
-        <label for="email">E-Mail (optional):</label>
-        <input name="email" value="${c.email}" />
-        <br/>
-        <label for="password1">Password:</label>
-        <input type="password" name="password1" value="" />
-        <br/>
-        <label for="password2">Password (repeat):</label>
-        <input type="password" name="password2" value="" />
-        <br/>
-      </fieldset>
-      ${h.submit('signup', _('Sign up'))}
-    </form>
-  </div>
-  <xi:include href="layout.html" />
-</html>


--- a/ckan/tests/functional/api/model/test_package.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/tests/functional/api/model/test_package.py	Thu Jul 28 12:55:51 2011 +0100
@@ -476,7 +476,7 @@
         res = self.app.get(self.offset('/rest/package/%s/revisions' % 'annakarenina'))
         revisions = res.json
         assert len(revisions) == 1, len(revisions)
-        expected_keys = set(('id', 'message', 'author', 'timestamp'))
+        expected_keys = set(('id', 'message', 'author', 'timestamp', 'approved_timestamp'))
         keys = set(revisions[0].keys())
         assert_equal(keys, expected_keys)
 


--- a/ckan/tests/functional/api/test_action.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/tests/functional/api/test_action.py	Thu Jul 28 12:55:51 2011 +0100
@@ -6,10 +6,25 @@
 
 class TestAction(WsgiAppCase):
 
+    STATUS_200_OK = 200
+    STATUS_201_CREATED = 201
+    STATUS_400_BAD_REQUEST = 400
+    STATUS_403_ACCESS_DENIED = 403
+    STATUS_404_NOT_FOUND = 404
+    STATUS_409_CONFLICT = 409
+
+    sysadmin_user = None
+    
+    normal_user = None
+
     @classmethod
     def setup_class(self):
         CreateTestData.create()
 
+        self.sysadmin_user = model.User.get('testsysadmin')
+
+        self.normal_user = model.User.get('annafan')
+
     @classmethod
     def teardown_class(self):
         model.repo.rebuild_db()
@@ -21,7 +36,14 @@
                                         "success": True,
                                         "result": ["annakarenina", "warandpeace"]}
 
-    def test_02_create_update_package(self):
+    def test_02_package_autocomplete(self):
+        postparams = '%s=1' % json.dumps({'q':'a'})
+        res = self.app.post('/api/action/package_autocomplete', params=postparams)
+        res_obj = json.loads(res.body)
+        assert res_obj['success'] == True
+        assert res_obj['result'][0]['name'] == 'annakarenina'
+
+    def test_03_create_update_package(self):
 
         package = {
             'author': None,
@@ -70,3 +92,298 @@
         package_created.pop('revision_timestamp')
         assert package_updated == package_created#, (pformat(json.loads(res.body)), pformat(package_created['result']))
 
+    def test_04_user_list(self):
+        postparams = '%s=1' % json.dumps({})
+        res = self.app.post('/api/action/user_list', params=postparams)
+        res_obj = json.loads(res.body)
+        assert res_obj['help'] == 'Lists the current users'
+        assert res_obj['success'] == True
+        assert len(res_obj['result']) == 7
+        assert res_obj['result'][0]['name'] == 'annafan'
+        assert res_obj['result'][0]['about'] == 'I love reading Annakarenina. My site: <a href="http://anna.com">anna.com</a>'
+        assert not 'apikey' in res_obj['result'][0]
+
+    def test_05_user_show(self):
+        postparams = '%s=1' % json.dumps({'id':'annafan'})
+        res = self.app.post('/api/action/user_show', params=postparams)
+        res_obj = json.loads(res.body)
+        assert res_obj['help'] == 'Shows user details'
+        assert res_obj['success'] == True
+        result = res_obj['result']
+        assert result['name'] == 'annafan'
+        assert result['about'] == 'I love reading Annakarenina. My site: <a href="http://anna.com">anna.com</a>'
+        assert 'apikey' in result
+        assert 'activity' in result
+        assert 'created' in result
+        assert 'display_name' in result
+        assert 'number_administered_packages' in result
+        assert 'number_of_edits' in result
+
+    def test_06_tag_list(self):
+        postparams = '%s=1' % json.dumps({})
+        res = self.app.post('/api/action/tag_list', params=postparams)
+        assert json.loads(res.body) == {'help': 'Returns a list of tags',
+                                        'success': True,
+                                        'result': ['russian', 'tolstoy']}
+        #Get all fields
+        postparams = '%s=1' % json.dumps({'all_fields':True})
+        res = self.app.post('/api/action/tag_list', params=postparams)
+        res_obj = json.loads(res.body)
+        pprint(res_obj)
+        assert res_obj['success'] == True
+        assert res_obj['result'][0]['name'] == 'russian'
+        assert len(res_obj['result'][0]['packages']) == 3
+        assert res_obj['result'][1]['name'] == 'tolstoy'
+        assert len(res_obj['result'][1]['packages']) == 2
+        assert 'id' in res_obj['result'][0]
+        assert 'id' in res_obj['result'][1]
+
+    def test_07_tag_show(self):
+        postparams = '%s=1' % json.dumps({'id':'russian'})
+        res = self.app.post('/api/action/tag_show', params=postparams)
+        res_obj = json.loads(res.body)
+        assert res_obj['help'] == 'Shows tag details'
+        assert res_obj['success'] == True
+        result = res_obj['result']
+        assert result['name'] == 'russian'
+        assert 'id' in result
+        assert 'packages' in result and len(result['packages']) == 3
+        assert [package['name'] for package in result['packages']].sort() == ['annakarenina', 'warandpeace', 'moo'].sort()
+
+    def test_08_user_create_not_authorized(self):
+        postparams = '%s=1' % json.dumps({'name':'test_create_from_action_api', 'password':'testpass'})
+        res = self.app.post('/api/action/user_create', params=postparams,
+                            status=self.STATUS_403_ACCESS_DENIED)
+        res_obj = json.loads(res.body)
+        assert res_obj == {'help': 'Creates a new user',
+                           'success': False,
+                           'error': {'message': 'Access denied', '__type': 'Authorization Error'}}
+
+    def test_09_user_create(self):
+        user_dict = {'name':'test_create_from_action_api',
+                      'about': 'Just a test user',
+                      'password':'testpass'}
+
+        postparams = '%s=1' % json.dumps(user_dict)
+        res = self.app.post('/api/action/user_create', params=postparams,
+                            extra_environ={'Authorization': str(self.sysadmin_user.apikey)})
+        res_obj = json.loads(res.body)
+        assert res_obj['help'] == 'Creates a new user'
+        assert res_obj['success'] == True
+        result = res_obj['result']
+        assert result['name'] == user_dict['name']
+        assert result['about'] == user_dict['about']
+        assert 'apikey' in result
+        assert 'created' in result
+        assert 'display_name' in result
+        assert 'number_administered_packages' in result
+        assert 'number_of_edits' in result
+        assert not 'password' in result
+
+    def test_10_user_create_parameters_missing(self):
+        user_dict = {}
+
+        postparams = '%s=1' % json.dumps(user_dict)
+        res = self.app.post('/api/action/user_create', params=postparams,
+                            extra_environ={'Authorization': str(self.sysadmin_user.apikey)},
+                            status=self.STATUS_409_CONFLICT)
+        res_obj = json.loads(res.body)
+        assert res_obj == {
+            'error': {
+                '__type': 'Validation Error',
+                'name': ['Missing value'],
+                'password': ['Missing value']
+            },
+            'help': 'Creates a new user',
+            'success': False
+        }
+
+    def test_11_user_create_wrong_password(self):
+        user_dict = {'name':'test_create_from_action_api_2',
+                      'password':'tes'} #Too short
+
+        postparams = '%s=1' % json.dumps(user_dict)
+        res = self.app.post('/api/action/user_create', params=postparams,
+                            extra_environ={'Authorization': str(self.sysadmin_user.apikey)},
+                            status=self.STATUS_409_CONFLICT)
+
+        res_obj = json.loads(res.body)
+        assert res_obj == {
+            'error': {
+                '__type': 'Validation Error',
+                'password': ['Your password must be 4 characters or longer']
+            },
+            'help': 'Creates a new user',
+            'success': False
+        }
+
+    def test_12_user_update(self):
+        normal_user_dict = {'id': self.normal_user.id,
+                            'fullname': 'Updated normal user full name',
+                            'about':'Updated normal user about'}
+
+        sysadmin_user_dict = {'id': self.sysadmin_user.id,
+                            'fullname': 'Updated sysadmin user full name',
+                            'about':'Updated sysadmin user about'} 
+
+        #Normal users can update themselves
+        postparams = '%s=1' % json.dumps(normal_user_dict)
+        res = self.app.post('/api/action/user_update', params=postparams,
+                            extra_environ={'Authorization': str(self.normal_user.apikey)})
+
+        res_obj = json.loads(res.body)
+        assert res_obj['help'] == 'Updates the user\'s details'
+        assert res_obj['success'] == True
+        result = res_obj['result']
+        assert result['id'] == self.normal_user.id
+        assert result['name'] == self.normal_user.name
+        assert result['fullname'] == normal_user_dict['fullname']
+        assert result['about'] == normal_user_dict['about']
+        assert 'apikey' in result
+        assert 'created' in result
+        assert 'display_name' in result
+        assert 'number_administered_packages' in result
+        assert 'number_of_edits' in result
+        assert not 'password' in result
+
+        #Sysadmin users can update themselves
+        postparams = '%s=1' % json.dumps(sysadmin_user_dict)
+        res = self.app.post('/api/action/user_update', params=postparams,
+                            extra_environ={'Authorization': str(self.sysadmin_user.apikey)})
+
+        res_obj = json.loads(res.body)
+        assert res_obj['help'] == 'Updates the user\'s details'
+        assert res_obj['success'] == True
+        result = res_obj['result']
+        assert result['id'] == self.sysadmin_user.id
+        assert result['name'] == self.sysadmin_user.name
+        assert result['fullname'] == sysadmin_user_dict['fullname']
+        assert result['about'] == sysadmin_user_dict['about']
+
+        #Sysadmin users can update all users
+        postparams = '%s=1' % json.dumps(normal_user_dict)
+        res = self.app.post('/api/action/user_update', params=postparams,
+                            extra_environ={'Authorization': str(self.sysadmin_user.apikey)})
+
+        res_obj = json.loads(res.body)
+        assert res_obj['help'] == 'Updates the user\'s details'
+        assert res_obj['success'] == True
+        result = res_obj['result']
+        assert result['id'] == self.normal_user.id
+        assert result['name'] == self.normal_user.name
+        assert result['fullname'] == normal_user_dict['fullname']
+        assert result['about'] == normal_user_dict['about']
+
+        #Normal users can not update other users
+        postparams = '%s=1' % json.dumps(sysadmin_user_dict)
+        res = self.app.post('/api/action/user_update', params=postparams,
+                            extra_environ={'Authorization': str(self.normal_user.apikey)},
+                            status=self.STATUS_403_ACCESS_DENIED)
+
+        res_obj = json.loads(res.body)
+        assert res_obj == {
+            'error': {
+                '__type': 'Authorization Error',
+                'message': 'Access denied'
+            },
+            'help': 'Updates the user\'s details',
+            'success': False
+        }
+
+    def test_13_group_list(self):
+        postparams = '%s=1' % json.dumps({})
+        res = self.app.post('/api/action/group_list', params=postparams)
+        res_obj = json.loads(res.body)
+        assert res_obj == {
+            'result': [
+                'david',
+                'roger'
+            ],
+            'help': 'Returns a list of groups',
+            'success': True
+        }
+        
+        #Get all fields
+        postparams = '%s=1' % json.dumps({'all_fields':True})
+        res = self.app.post('/api/action/group_list', params=postparams)
+        res_obj = json.loads(res.body)
+
+        assert res_obj['success'] == True
+        assert res_obj['result'][0]['name'] == 'david'
+        assert res_obj['result'][0]['display_name'] == 'Dave\'s books'
+        assert res_obj['result'][0]['packages'] == 2
+        assert res_obj['result'][1]['name'] == 'roger'
+        assert res_obj['result'][1]['packages'] == 1
+        assert 'id' in res_obj['result'][0]
+        assert 'revision_id' in res_obj['result'][0]
+        assert 'state' in res_obj['result'][0]
+
+    def test_14_group_show(self):
+        postparams = '%s=1' % json.dumps({'id':'david'})
+        res = self.app.post('/api/action/group_show', params=postparams)
+        res_obj = json.loads(res.body)
+        assert res_obj['help'] == 'Shows group details'
+        assert res_obj['success'] == True
+        result = res_obj['result']
+        assert result['name'] == 'david'
+        assert result['title'] == result['display_name'] == 'Dave\'s books'
+        assert result['state'] == 'active'
+        assert 'id' in result
+        assert 'revision_id' in result
+        assert len(result['packages']) == 2
+
+        #Group not found
+        postparams = '%s=1' % json.dumps({'id':'not_present_in_the_db'})
+        res = self.app.post('/api/action/group_show', params=postparams,
+                            status=self.STATUS_404_NOT_FOUND)
+
+        res_obj = json.loads(res.body)
+        pprint(res_obj)
+        assert res_obj == {
+            'error': {
+                '__type': 'Not Found Error',
+                'message': 'Not found'
+            },
+            'help': 'Shows group details',
+            'success': False
+        }
+
+    def test_15_tag_autocomplete(self):
+        #Empty query
+        postparams = '%s=1' % json.dumps({})
+        res = self.app.post('/api/action/tag_autocomplete', params=postparams)
+        res_obj = json.loads(res.body)
+        assert res_obj == {
+            'help': 'Returns tags containing the provided string', 
+            'result': [], 
+            'success': True
+        }
+
+        #Normal query
+        postparams = '%s=1' % json.dumps({'q':'r'})
+        res = self.app.post('/api/action/tag_autocomplete', params=postparams)
+        res_obj = json.loads(res.body)
+        assert res_obj == {
+            'help': 'Returns tags containing the provided string', 
+            'result': ['russian'], 
+            'success': True
+        }
+
+    def test_16_user_autocomplete(self):
+        #Empty query
+        postparams = '%s=1' % json.dumps({})
+        res = self.app.post('/api/action/user_autocomplete', params=postparams)
+        res_obj = json.loads(res.body)
+        assert res_obj == {
+            'help': 'Returns users containing the provided string', 
+            'result': [], 
+            'success': True
+        }
+
+        #Normal query
+        postparams = '%s=1' % json.dumps({'q':'joe'})
+        res = self.app.post('/api/action/user_autocomplete', params=postparams)
+        res_obj = json.loads(res.body)
+        assert res_obj['result'][0]['name'] == 'joeadmin'
+        assert 'id','fullname' in res_obj['result'][0]
+


--- a/ckan/tests/functional/test_package.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/tests/functional/test_package.py	Thu Jul 28 12:55:51 2011 +0100
@@ -1651,4 +1651,8 @@
                            extra_environ={'REMOTE_USER':c.user})
         anna_hash = str(PackageController._pkg_cache_key(self.anna))
         self.assert_equal(res.header_dict['ETag'], anna_hash)
-    
+
+    def test_package_autocomplete(self):
+        query = 'a'
+        res = self.app.get('/package/autocomplete?q=%s' % query)
+        assert res.body == "annakarenina|annakarenina\nA Wonderful Story (warandpeace)|warandpeace"


--- a/ckan/tests/functional/test_user.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/tests/functional/test_user.py	Thu Jul 28 12:55:51 2011 +0100
@@ -1,6 +1,7 @@
 from routes import url_for
 from nose.tools import assert_equal
 
+from pprint import pprint
 from ckan.tests import search_related, CreateTestData
 from ckan.tests.html_check import HtmlCheckMethods
 from ckan.tests.pylons_controller import PylonsTestCase
@@ -191,12 +192,12 @@
         res = self.app.get(offset, status=200)
         main_res = self.main_div(res)
         assert 'Register' in main_res, main_res
-        fv = res.forms['register_form']
-        fv['login'] = username
+        fv = res.forms['user-edit']
+        fv['name'] = username
         fv['fullname'] = fullname
         fv['password1'] = password
         fv['password2'] = password
-        res = fv.submit('signup')
+        res = fv.submit('save')
         
         # view user
         assert res.status == 302, self.main_div(res).encode('utf8')
@@ -209,7 +210,6 @@
             res = res.follow()
         assert res.status == 200, res
         main_res = self.main_div(res)
-        assert username in main_res, main_res
         assert fullname in main_res, main_res
 
         user = model.User.by_name(unicode(username))
@@ -229,12 +229,12 @@
         res = self.app.get(offset, status=200)
         main_res = self.main_div(res)
         assert 'Register' in main_res, main_res
-        fv = res.forms['register_form']
-        fv['login'] = username
+        fv = res.forms['user-edit']
+        fv['name'] = username
         fv['fullname'] = fullname.encode('utf8')
         fv['password1'] = password.encode('utf8')
         fv['password2'] = password.encode('utf8')
-        res = fv.submit('signup')
+        res = fv.submit('save')
         
         # view user
         assert res.status == 302, self.main_div(res).encode('utf8')
@@ -247,7 +247,6 @@
             res = res.follow()
         assert res.status == 200, res
         main_res = self.main_div(res)
-        assert username in main_res, main_res
         assert fullname in main_res, main_res
 
         user = model.User.by_name(unicode(username))
@@ -264,13 +263,13 @@
         res = self.app.get(offset, status=200)
         main_res = self.main_div(res)
         assert 'Register' in main_res, main_res
-        fv = res.forms['register_form']
+        fv = res.forms['user-edit']
         fv['password1'] = password
         fv['password2'] = password
-        res = fv.submit('signup')
+        res = fv.submit('save')
         assert res.status == 200, res
         main_res = self.main_div(res)
-        assert 'Please enter a login name' in main_res, main_res
+        assert 'Name: Missing value' in main_res, main_res
 
     def test_user_create_bad_name(self):
         # create/register user
@@ -281,15 +280,15 @@
         res = self.app.get(offset, status=200)
         main_res = self.main_div(res)
         assert 'Register' in main_res, main_res
-        fv = res.forms['register_form']
-        fv['login'] = username
+        fv = res.forms['user-edit']
+        fv['name'] = username
         fv['password1'] = password
         fv['password2'] = password
-        res = fv.submit('signup')
+        res = fv.submit('save')
         assert res.status == 200, res
         main_res = self.main_div(res)
         assert 'login name is not valid' in main_res, main_res
-        self.check_named_element(main_res, 'input', 'name="login"', 'value="%s"' % username)
+        self.check_named_element(main_res, 'input', 'name="name"', 'value="%s"' % username)
 
     def test_user_create_bad_password(self):
         # create/register user
@@ -300,15 +299,15 @@
         res = self.app.get(offset, status=200)
         main_res = self.main_div(res)
         assert 'Register' in main_res, main_res
-        fv = res.forms['register_form']
-        fv['login'] = username
+        fv = res.forms['user-edit']
+        fv['name'] = username
         fv['password1'] = password
         fv['password2'] = password
-        res = fv.submit('signup')
+        res = fv.submit('save')
         assert res.status == 200, res
         main_res = self.main_div(res)
         assert 'password must be 4 characters or longer' in main_res, main_res
-        self.check_named_element(main_res, 'input', 'name="login"', 'value="%s"' % username)
+        self.check_named_element(main_res, 'input', 'name="name"', 'value="%s"' % username)
 
     def test_user_create_without_password(self):
         # create/register user
@@ -319,14 +318,54 @@
         res = self.app.get(offset, status=200)
         main_res = self.main_div(res)
         assert 'Register' in main_res, main_res
-        fv = res.forms['register_form']
-        fv['login'] = username
+        fv = res.forms['user-edit']
+        fv['name'] = username
         # no password
-        res = fv.submit('signup')
+        res = fv.submit('save')
         assert res.status == 200, res
         main_res = self.main_div(res)
-        assert 'Please enter a password' in main_res, main_res
-        self.check_named_element(main_res, 'input', 'name="login"', 'value="%s"' % username)
+        assert 'Password: Please enter both passwords' in main_res, main_res
+        self.check_named_element(main_res, 'input', 'name="name"', 'value="%s"' % username)
+
+    def test_user_create_only_one_password(self):
+        # create/register user
+        username = 'testcreate4'
+        password = u'testpassword'
+        user = model.User.by_name(unicode(username))
+
+        offset = url_for(controller='user', action='register')
+        res = self.app.get(offset, status=200)
+        main_res = self.main_div(res)
+        assert 'Register' in main_res, main_res
+        fv = res.forms['user-edit']
+        fv['name'] = username
+        fv['password1'] = password
+        # Only password1
+        res = fv.submit('save')
+        assert res.status == 200, res
+        main_res = self.main_div(res)
+        assert 'Password: Please enter both passwords' in main_res, main_res
+        self.check_named_element(main_res, 'input', 'name="name"', 'value="%s"' % username)
+
+    def test_user_invalid_password(self):
+        # create/register user
+        username = 'testcreate4'
+        password = u'tes' # Too short
+        user = model.User.by_name(unicode(username))
+
+        offset = url_for(controller='user', action='register')
+        res = self.app.get(offset, status=200)
+        main_res = self.main_div(res)
+        assert 'Register' in main_res, main_res
+        fv = res.forms['user-edit']
+        fv['name'] = username
+        fv['password1'] = password
+        fv['password2'] = password
+        res = fv.submit('save')
+        assert res.status == 200, res
+        main_res = self.main_div(res)
+        assert 'Password: Your password must be 4 characters or longer' in main_res, main_res
+        self.check_named_element(main_res, 'input', 'name="name"', 'value="%s"' % username)
 
     def test_user_edit(self):
         # create user
@@ -356,8 +395,6 @@
         # preview
         main_res = self.main_div(res)
         assert 'Edit User: testedit' in main_res, main_res
-        before_preview = main_res[:main_res.find('Preview')]
-        assert new_about in before_preview, before_preview
         in_preview = main_res[main_res.find('Preview'):]
         assert new_about in in_preview, in_preview
 


--- a/ckan/tests/lib/test_dictization.py	Wed Jul 27 17:15:34 2011 +0100
+++ b/ckan/tests/lib/test_dictization.py	Thu Jul 28 12:55:51 2011 +0100
@@ -771,6 +771,7 @@
                     'extras': [{'key': u'genre', 'state': u'active', 'value': u'"horror"'},
                                {'key': u'media', 'state': u'active', 'value': u'"dvd"'}],
                     'name': u'help',
+                    'display_name': u'help',
                     'packages': [{'author': None,
                                   'author_email': None,
                                   'license_id': u'other-open',

Repository URL: https://bitbucket.org/okfn/ckan/

--

This is a commit notification from bitbucket.org. You are receiving
this because you have the service enabled, addressing the recipient of
this email.




More information about the ckan-changes mailing list