diff --git a/ckan/ckan_nose_plugin.py b/ckan/ckan_nose_plugin.py
index 7018e31c23c..bc995dbbdb3 100644
--- a/ckan/ckan_nose_plugin.py
+++ b/ckan/ckan_nose_plugin.py
@@ -30,7 +30,6 @@ def startContext(self, ctx):
# init_db is run at the start of every class because
# when you use an in-memory sqlite db, it appears that
# 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
@@ -40,6 +39,7 @@ def startContext(self, ctx):
for plugin in PluginImplementations(IConfigurable):
plugin.configure(config)
+ model.repo.init_db()
def options(self, parser, env):
parser.add_option(
diff --git a/ckan/config/environment.py b/ckan/config/environment.py
index ff3bc45fbb5..8d42f4bedef 100644
--- a/ckan/config/environment.py
+++ b/ckan/config/environment.py
@@ -30,10 +30,14 @@ class _Helpers(object):
def __init__(self, helpers, restrict=True):
functions = {}
allowed = helpers.__allowed_functions__
+ # list of functions due to be depreciated
+ self.depreciated = []
for helper in dir(helpers):
- if restrict and (helper not in allowed):
- continue
+ if helper not in allowed:
+ self.depreciated.append(helper)
+ if restrict:
+ continue
functions[helper] = getattr(helpers, helper)
self.functions = functions
@@ -46,6 +50,8 @@ def __init__(self, helpers, restrict=True):
raise Exception('overwritting extra helper %s' % helper)
extra_helpers.append(helper)
functions[helper] = helpers[helper]
+ # logging
+ self.log = logging.getLogger('ckan.helpers')
@classmethod
def null_function(cls, *args, **kw):
@@ -57,10 +63,21 @@ def null_function(cls, *args, **kw):
def __getattr__(self, name):
''' return the function/object requested '''
if name in self.functions:
+ if name in self.depreciated:
+ msg = 'Template helper function `%s` is depriciated' % name
+ self.log.warn(msg)
return self.functions[name]
else:
- log = logging.getLogger('ckan.helpers')
- log.critical('Helper function `%s` could not be found (missing extension?)' % name)
+ if name in self.depreciated:
+ msg = 'Template helper function `%s` is not available ' \
+ 'as it has been depriciated.\nYou can enable it ' \
+ 'by setting ckan.restrict_template_vars = true ' \
+ 'in your .ini file.' % name
+ self.log.critical(msg)
+ else:
+ msg = 'Helper function `%s` could not be found\n ' \
+ '(are you missing an extension?)' % name
+ self.log.critical(msg)
return self.null_function
@@ -171,9 +188,8 @@ def template_loaded(template):
ckan_db = os.environ.get('CKAN_DB')
if ckan_db:
- engine = sqlalchemy.create_engine(ckan_db)
- else:
- engine = sqlalchemy.engine_from_config(config, 'sqlalchemy.')
+ config['sqlalchemy.url'] = ckan_db
+ engine = sqlalchemy.engine_from_config(config, 'sqlalchemy.')
if not model.meta.engine:
model.init_model(engine)
diff --git a/ckan/config/middleware.py b/ckan/config/middleware.py
index c07948a51a8..5d2be57b77e 100644
--- a/ckan/config/middleware.py
+++ b/ckan/config/middleware.py
@@ -1,8 +1,11 @@
"""Pylons middleware initialization"""
import urllib
-import logging
+import urllib2
+import logging
import json
+import hashlib
+import sqlalchemy as sa
from beaker.middleware import CacheMiddleware, SessionMiddleware
from paste.cascade import Cascade
from paste.registry import RegistryManager
@@ -14,6 +17,7 @@
from routes.middleware import RoutesMiddleware
from repoze.who.config import WhoConfig
from repoze.who.middleware import PluggableAuthenticationMiddleware
+
from ckan.plugins import PluginImplementations
from ckan.plugins.interfaces import IMiddleware
from ckan.lib.i18n import get_locales
@@ -130,6 +134,8 @@ def make_app(global_conf, full_stack=True, static_files=True, **app_conf):
if asbool(config.get('ckan.page_cache_enabled')):
app = PageCacheMiddleware(app, config)
+ # Tracking add config option
+ app = TrackingMiddleware(app, config)
return app
class I18nMiddleware(object):
@@ -277,3 +283,40 @@ def _start_response(status, response_headers, exc_info=None):
pipe.rpush(key, page_string)
pipe.execute()
return page
+
+
+class TrackingMiddleware(object):
+
+ def __init__(self, app, config):
+ self.app = app
+ self.engine = sa.create_engine(config.get('sqlalchemy.url'))
+
+
+ def __call__(self, environ, start_response):
+ path = environ['PATH_INFO']
+ if path == '/_tracking':
+ # do the tracking
+ # get the post data
+ payload = environ['wsgi.input'].read()
+ parts = payload.split('&')
+ data = {}
+ for part in parts:
+ k, v = part.split('=')
+ data[k] = urllib2.unquote(v).decode("utf8")
+ start_response('200 OK', [('Content-Type', 'text/html')])
+ # we want a unique anonomized key for each user so that we do
+ # not count multiple clicks from the same user.
+ key = ''.join([
+ environ['HTTP_USER_AGENT'],
+ environ['REMOTE_ADDR'],
+ environ['HTTP_ACCEPT_LANGUAGE'],
+ environ['HTTP_ACCEPT_ENCODING'],
+ ])
+ key = hashlib.md5(key).hexdigest()
+ # store key/data here
+ sql = '''INSERT INTO tracking_raw
+ (user_key, url, tracking_type)
+ VALUES (%s, %s, %s)'''
+ self.engine.execute(sql, key, data.get('url'), data.get('type'))
+ return []
+ return self.app(environ, start_response)
diff --git a/ckan/config/routing.py b/ckan/config/routing.py
index f3ae5b8b34c..6c03cb096aa 100644
--- a/ckan/config/routing.py
+++ b/ckan/config/routing.py
@@ -57,6 +57,7 @@ def make_map():
'resource',
'tag',
'group',
+ 'related',
'authorizationgroup',
'revision',
'licenses',
@@ -151,6 +152,11 @@ def make_map():
##map.connect('/package/new', controller='package_formalchemy', action='new')
##map.connect('/package/edit/{id}', controller='package_formalchemy', action='edit')
+ with SubMapper(map, controller='related') as m:
+ m.connect('related_edit', '/related/{id}/edit', action='edit')
+ m.connect('related_list', '/dataset/{id}/related', action='list')
+ m.connect('related_read', '/dataset/{id}/related/{related_id}', action='read')
+
with SubMapper(map, controller='package') as m:
m.connect('/dataset', action='search')
m.connect('/dataset/{action}',
@@ -183,6 +189,7 @@ def make_map():
m.connect('/dataset/{id}.{format}', action='read')
m.connect('/dataset/{id}', action='read')
m.connect('/dataset/{id}/resource/{resource_id}', action='resource_read')
+ m.connect('/dataset/{id}/resource/{resource_id}/embed', action='resource_embedded_dataviewer')
# group
map.redirect('/groups', '/group')
@@ -210,7 +217,6 @@ def make_map():
register_package_plugins(map)
register_group_plugins(map)
-
# authz group
map.redirect('/authorizationgroups', '/authorizationgroup')
map.redirect('/authorizationgroups/{url:.*}', '/authorizationgroup/{url}')
diff --git a/ckan/config/solr/schema-1.4.xml b/ckan/config/solr/schema-1.4.xml
new file mode 100644
index 00000000000..29cb4737287
--- /dev/null
+++ b/ckan/config/solr/schema-1.4.xml
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+index_id
+text
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py
index 33bb54a51ae..a587f3d824d 100644
--- a/ckan/controllers/api.py
+++ b/ckan/controllers/api.py
@@ -235,6 +235,7 @@ def list(self, ver=None, register=None, subregister=None, id=None):
'group': 'group_list',
'dataset': 'package_list',
'tag': 'tag_list',
+ 'related': 'related_list',
'licenses': 'licence_list',
('dataset', 'relationships'): 'package_relationships_list',
('dataset', 'revisions'): 'package_revision_list',
@@ -261,6 +262,7 @@ def show(self, ver=None, register=None, subregister=None, id=None, id2=None):
'revision': 'revision_show',
'group': 'group_show_rest',
'tag': 'tag_show_rest',
+ 'related': 'related_show',
'dataset': 'package_show_rest',
('dataset', 'relationships'): 'package_relationships_list',
}
@@ -294,6 +296,7 @@ def create(self, ver=None, register=None, subregister=None, id=None, id2=None):
'group': 'group_create_rest',
'dataset': 'package_create_rest',
'rating': 'rating_create',
+ 'related': 'related_create',
('dataset', 'relationships'): 'package_relationship_create_rest',
}
for type in model.PackageRelationship.get_all_types():
@@ -393,6 +396,7 @@ def delete(self, ver=None, register=None, subregister=None, id=None, id2=None):
action_map = {
'group': 'group_delete',
'dataset': 'package_delete',
+ 'related': 'related_delete',
('dataset', 'relationships'): 'package_relationship_delete_rest',
}
for type in model.PackageRelationship.get_all_types():
@@ -621,6 +625,7 @@ def convert_to_dict(user):
def is_slug_valid(self):
slug = request.params.get('slug') or ''
slugtype = request.params.get('type') or ''
+ # TODO: We need plugins to be able to register new disallowed names
disallowed = ['new', 'edit', 'search']
if slugtype==u'package':
response_data = dict(valid=not bool(common.package_exists(slug)
diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py
index 4e7cda3ef10..95a4597ad70 100644
--- a/ckan/controllers/group.py
+++ b/ckan/controllers/group.py
@@ -72,7 +72,7 @@ def index(self):
group_type = self._guess_group_type()
context = {'model': model, 'session': model.Session,
- 'user': c.user or c.author}
+ 'user': c.user or c.author, 'for_view': True}
data_dict = {'all_fields': True}
@@ -194,6 +194,7 @@ def pager_url(q=None, page=None):
items_per_page=limit
)
c.facets = query['facets']
+ c.search_facets = query['search_facets']
c.page.items = query['results']
except SearchError, se:
log.error('Group search error: %r', se.args)
diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py
index e90d65889e1..ba12b869761 100644
--- a/ckan/controllers/package.py
+++ b/ckan/controllers/package.py
@@ -210,6 +210,7 @@ def pager_url(q=None, page=None):
items_per_page=limit
)
c.facets = query['facets']
+ c.search_facets = query['search_facets']
c.page.items = query['results']
except SearchError, se:
log.error('Dataset search error: %r', se.args)
@@ -295,6 +296,7 @@ def read(self, id, format='html'):
# used by disqus plugin
c.current_package_id = c.pkg.id
+ c.related_count = len(c.pkg.related)
# Add the package's activity stream (already rendered to HTML) to the
# template context for the package/read.html template to retrieve
@@ -403,6 +405,8 @@ def history(self, id):
)
feed.content_type = 'application/atom+xml'
return feed.writeString('utf-8')
+
+ c.related_count = len(c.pkg.related)
return render( self._history_template(c.pkg_dict.get('type',package_type)))
def new(self, data=None, errors=None, error_summary=None):
@@ -658,6 +662,9 @@ def authz(self, id):
roles = self._handle_update_of_authz(pkg)
self._prepare_authz_info_for_render(roles)
+
+ # c.related_count = len(pkg.related)
+
return render('package/authz.html')
def autocomplete(self):
@@ -744,3 +751,63 @@ def resource_read(self, id, resource_id):
qualified=True)
return render('package/resource_read.html')
+ def resource_embedded_dataviewer(self, id, resource_id):
+ """
+ Embeded page for a read-only resource dataview.
+ """
+ context = {'model': model, 'session': model.Session,
+ 'user': c.user or c.author}
+
+ try:
+ c.resource = get_action('resource_show')(context, {'id': resource_id})
+ c.package = get_action('package_show')(context, {'id': id})
+ c.resource_json = json.dumps(c.resource)
+
+ # double check that the resource belongs to the specified package
+ if not c.resource['id'] in [ r['id'] for r in c.package['resources'] ]:
+ raise NotFound
+
+ except NotFound:
+ abort(404, _('Resource not found'))
+ except NotAuthorized:
+ abort(401, _('Unauthorized to read resource %s') % id)
+
+ # Construct the recline state
+ state_version = int(request.params.get('state_version', '1'))
+ recline_state = self._parse_recline_state(request.params)
+ if recline_state is None:
+ abort(400, ('"state" parameter must be a valid recline state (version %d)' % state_version))
+
+ c.recline_state = json.dumps(recline_state)
+
+ c.width = max(int(request.params.get('width', 500)), 100)
+ c.height = max(int(request.params.get('height', 500)), 100)
+ c.embedded = True
+
+ return render('package/resource_embedded_dataviewer.html')
+
+ def _parse_recline_state(self, params):
+ state_version = int(request.params.get('state_version', '1'))
+ if state_version != 1:
+ return None
+
+ recline_state = {}
+ for k,v in request.params.items():
+ try:
+ v = json.loads(v)
+ except ValueError:
+ pass
+ recline_state[k] = v
+
+ recline_state.pop('width', None)
+ recline_state.pop('height', None)
+ recline_state['readOnly'] = True
+
+ # Ensure only the currentView is available
+ if not recline_state.get('currentView', None):
+ recline_state['currentView'] = 'grid' # default to grid view if none specified
+ for k in recline_state.keys():
+ if k.startswith('view-') and not k.endswith(recline_state['currentView']):
+ recline_state.pop(k)
+ return recline_state
+
diff --git a/ckan/controllers/related.py b/ckan/controllers/related.py
new file mode 100644
index 00000000000..85042aaa8eb
--- /dev/null
+++ b/ckan/controllers/related.py
@@ -0,0 +1,36 @@
+
+
+import ckan.model as model
+import ckan.logic as logic
+import ckan.lib.base as base
+import ckan.lib.helpers as h
+
+c = base.c
+
+class RelatedController(base.BaseController):
+
+ def list(self, id):
+
+ context = {'model': model, 'session': model.Session,
+ 'user': c.user or c.author, 'extras_as_string': True,
+ 'for_view': True}
+ data_dict = {'id': id}
+
+ try:
+ logic.check_access('package_show', context, data_dict)
+ except logic.NotAuthorized:
+ abort(401, _('Not authorized to see this page'))
+
+ try:
+ c.pkg_dict = logic.get_action('package_show')(context, data_dict)
+ c.pkg = context['package']
+ c.resources_json = h.json.dumps(c.pkg_dict.get('resources',[]))
+ except logic.NotFound:
+ abort(404, _('Dataset not found'))
+ except logic.NotAuthorized:
+ abort(401, _('Unauthorized to read package %s') % id)
+
+ c.related_count = len(c.pkg.related)
+
+ return base.render( "package/related_list.html")
+
diff --git a/ckan/controllers/tag.py b/ckan/controllers/tag.py
index f623f05c643..06bccd768ca 100644
--- a/ckan/controllers/tag.py
+++ b/ckan/controllers/tag.py
@@ -25,9 +25,9 @@ def index(self):
c.q = request.params.get('q', '')
context = {'model': model, 'session': model.Session,
- 'user': c.user or c.author}
+ 'user': c.user or c.author, 'for_view': True}
- data_dict = {}
+ data_dict = {'all_fields': True}
if c.q:
page = int(request.params.get('page', 1))
@@ -58,7 +58,7 @@ def index(self):
def read(self, id):
context = {'model': model, 'session': model.Session,
- 'user': c.user or c.author}
+ 'user': c.user or c.author, 'for_view': True}
data_dict = {'id':id}
try:
diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py
index 0a26d59c0f3..d17df6fc410 100644
--- a/ckan/controllers/user.py
+++ b/ckan/controllers/user.py
@@ -83,7 +83,7 @@ def index(self):
def read(self, id=None):
context = {'model': model,
- 'user': c.user or c.author}
+ 'user': c.user or c.author, 'for_view': True}
data_dict = {'id':id,
'user_obj':c.userobj}
try:
@@ -383,6 +383,8 @@ def perform_reset(self, id):
h.flash_error(u'%r'% e.error_dict)
except ValueError, ve:
h.flash_error(unicode(ve))
+
+ c.user_dict = user_dict
return render('user/perform_reset.html')
def _format_about(self, about):
diff --git a/ckan/lib/base.py b/ckan/lib/base.py
index 075029c8947..ffb79a7c4fb 100644
--- a/ckan/lib/base.py
+++ b/ckan/lib/base.py
@@ -53,16 +53,19 @@ def render_snippet(template_name, **kw):
comment tags added to show the template used. NOTE: unlike other
render functions this takes a list of keywords instead of a dict for
the extra template variables. '''
- output = render(template_name, extra_vars=kw)
+ # allow cache_force to be set in render function
+ cache_force = kw.pop('cache_force', None)
+ output = render(template_name, extra_vars=kw, cache_force=cache_force)
output = '\n\n%s\n\n' % (
template_name, output, template_name)
return literal(output)
-def render_text(template_name, extra_vars=None):
+def render_text(template_name, extra_vars=None, cache_force=None):
''' Helper function to render a genshi NewTextTemplate without
having to pass the loader_class or method. '''
return render(template_name,
extra_vars=extra_vars,
+ cache_force=cache_force,
method='text',
loader_class=NewTextTemplate)
@@ -245,8 +248,8 @@ def __after__(self, action, **params):
def _set_cors(self):
response.headers['Access-Control-Allow-Origin'] = "*"
- response.headers['Access-Control-Allow-Methods'] = "POST, PUT, GET, DELETE"
- response.headers['Access-Control-Allow-Headers'] = "X-CKAN-API-KEY, Content-Type"
+ response.headers['Access-Control-Allow-Methods'] = "POST, PUT, GET, DELETE, OPTIONS"
+ response.headers['Access-Control-Allow-Headers'] = "X-CKAN-API-KEY, Authorization, Content-Type"
def _get_user(self, reference):
return model.User.by_name(reference)
diff --git a/ckan/lib/celery_app.py b/ckan/lib/celery_app.py
index cd87aa7a0eb..27d2951d10b 100644
--- a/ckan/lib/celery_app.py
+++ b/ckan/lib/celery_app.py
@@ -1,5 +1,6 @@
import ConfigParser
import os
+from pylons import config as pylons_config
from pkg_resources import iter_entry_points
#from celery.loaders.base import BaseLoader
@@ -12,16 +13,22 @@
config = ConfigParser.ConfigParser()
config_file = os.environ.get('CKAN_CONFIG')
+
if not config_file:
config_file = os.path.join(
os.path.dirname(os.path.abspath(__file__)), '../../development.ini')
config.read(config_file)
+sqlalchemy_url = pylons_config.get('sqlalchemy.url')
+if not sqlalchemy_url:
+ sqlalchemy_url = config.get('app:main', 'sqlalchemy.url')
+
+
default_config = dict(
BROKER_BACKEND = 'sqlalchemy',
- BROKER_HOST = config.get('app:main', 'sqlalchemy.url'),
- CELERY_RESULT_DBURI = config.get('app:main', 'sqlalchemy.url'),
+ BROKER_HOST = sqlalchemy_url,
+ CELERY_RESULT_DBURI = sqlalchemy_url,
CELERY_RESULT_BACKEND = 'database',
CELERY_RESULT_SERIALIZER = 'json',
CELERY_TASK_SERIALIZER = 'json',
diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py
index 6798cc48359..d11a1f450e1 100644
--- a/ckan/lib/cli.py
+++ b/ckan/lib/cli.py
@@ -1,4 +1,5 @@
import os
+import datetime
import sys
import logging
from pprint import pprint
@@ -879,3 +880,207 @@ def clean(self, user_ratings=True):
rating.purge()
model.repo.commit_and_remove()
+class Tracking(CkanCommand):
+ '''Update tracking statistics
+
+ Usage:
+ tracking - update tracking stats
+ '''
+
+ summary = __doc__.split('\n')[0]
+ usage = __doc__
+ max_args = 1
+ min_args = 0
+
+ def command(self):
+ self._load_config()
+ import ckan.model as model
+ engine = model.meta.engine
+
+ if len(self.args) == 1:
+ # Get summeries from specified date
+ start_date = datetime.datetime.strptime(self.args[0], '%Y-%m-%d')
+ else:
+ # No date given. See when we last have data for and get data
+ # from 2 days before then in case new data is available.
+ # If no date here then use 2010-01-01 as the start date
+ sql = '''SELECT tracking_date from tracking_summary
+ ORDER BY tracking_date DESC LIMIT 1;'''
+ result = engine.execute(sql).fetchall()
+ if result:
+ start_date = result[0]['tracking_date']
+ start_date += datetime.timedelta(-2)
+ # convert date to datetime
+ combine = datetime.datetime.combine
+ start_date = combine(start_date, datetime.time(0))
+ else:
+ start_date = datetime.datetime(2011, 1, 1)
+ end_date = datetime.datetime.now()
+
+ while start_date < end_date:
+ stop_date = start_date + datetime.timedelta(1)
+ self.update_tracking(engine, start_date)
+ print 'tracking updated for %s' % start_date
+ start_date = stop_date
+
+ def update_tracking(self, engine, summary_date):
+ PACKAGE_URL = '/dataset/'
+ # clear out existing data before adding new
+ sql = '''DELETE FROM tracking_summary
+ WHERE tracking_date='%s'; ''' % summary_date
+ engine.execute(sql)
+
+ sql = '''SELECT DISTINCT url, user_key,
+ CAST(access_timestamp AS Date) AS tracking_date,
+ tracking_type INTO tracking_tmp
+ FROM tracking_raw
+ WHERE CAST(access_timestamp as Date)='%s';
+
+ INSERT INTO tracking_summary
+ (url, count, tracking_date, tracking_type)
+ SELECT url, count(user_key), tracking_date, tracking_type
+ FROM tracking_tmp
+ GROUP BY url, tracking_date, tracking_type;
+
+ DROP TABLE tracking_tmp;
+ COMMIT;''' % summary_date
+ engine.execute(sql)
+
+ # get ids for dataset urls
+ sql = '''UPDATE tracking_summary t
+ SET package_id = COALESCE(
+ (SELECT id FROM package p
+ WHERE t.url = %s || p.name)
+ ,'~~not~found~~')
+ WHERE t.package_id IS NULL
+ AND tracking_type = 'page';'''
+ engine.execute(sql, PACKAGE_URL)
+
+ # update summary totals for resources
+ sql = '''UPDATE tracking_summary t1
+ SET running_total = (
+ SELECT sum(count)
+ FROM tracking_summary t2
+ WHERE t1.url = t2.url
+ AND t2.tracking_date <= t1.tracking_date
+ ) + t1.count
+ ,recent_views = (
+ SELECT sum(count)
+ FROM tracking_summary t2
+ WHERE t1.url = t2.url
+ AND t2.tracking_date <= t1.tracking_date AND t2.tracking_date >= t1.tracking_date - 14
+ ) + t1.count
+ WHERE t1.running_total = 0 AND tracking_type = 'resource';'''
+ engine.execute(sql)
+
+ # update summary totals for pages
+ sql = '''UPDATE tracking_summary t1
+ SET running_total = (
+ SELECT sum(count)
+ FROM tracking_summary t2
+ WHERE t1.package_id = t2.package_id
+ AND t2.tracking_date <= t1.tracking_date
+ ) + t1.count
+ ,recent_views = (
+ SELECT sum(count)
+ FROM tracking_summary t2
+ WHERE t1.package_id = t2.package_id
+ AND t2.tracking_date <= t1.tracking_date AND t2.tracking_date >= t1.tracking_date - 14
+ ) + t1.count
+ WHERE t1.running_total = 0 AND tracking_type = 'page'
+ AND t1.package_id IS NOT NULL
+ AND t1.package_id != '~~not~found~~';'''
+ engine.execute(sql)
+
+class PluginInfo(CkanCommand):
+ ''' Provide info on installed plugins.
+ '''
+
+ summary = __doc__.split('\n')[0]
+ usage = __doc__
+ max_args = 0
+ min_args = 0
+
+ def command(self):
+ self.get_info()
+
+ def get_info(self):
+ ''' print info about current plugins from the .ini file'''
+ import ckan.plugins as p
+ self._load_config()
+ interfaces = {}
+ plugins = {}
+ for name in dir(p):
+ item = getattr(p, name)
+ try:
+ if issubclass(item, p.Interface):
+ interfaces[item] = {'class' : item}
+ except TypeError:
+ pass
+
+ for interface in interfaces:
+ for plugin in p.PluginImplementations(interface):
+ name = plugin.name
+ if name not in plugins:
+ plugins[name] = {'doc' : plugin.__doc__,
+ 'class' : plugin,
+ 'implements' : []}
+ plugins[name]['implements'].append(interface.__name__)
+
+ for plugin in plugins:
+ p = plugins[plugin]
+ print plugin + ':'
+ print '-' * (len(plugin) + 1)
+ if p['doc']:
+ print p['doc']
+ print 'Implements:'
+ for i in p['implements']:
+ extra = None
+ if i == 'ITemplateHelpers':
+ extra = self.template_helpers(p['class'])
+ if i == 'IActions':
+ extra = self.actions(p['class'])
+ print ' %s' % i
+ if extra:
+ print extra
+ print
+
+
+ def actions(self, cls):
+ ''' Return readable action function info. '''
+ actions = cls.get_actions()
+ return self.function_info(actions)
+
+ def template_helpers(self, cls):
+ ''' Return readable helper function info. '''
+ helpers = cls.get_helpers()
+ return self.function_info(helpers)
+
+ def function_info(self, functions):
+ ''' Take a dict of functions and output readable info '''
+ import inspect
+ output = []
+ for function_name in functions:
+ fn = functions[function_name]
+ args_info = inspect.getargspec(fn)
+ params = args_info.args
+ num_params = len(params)
+ if args_info.varargs:
+ params.append('*' + args_info.varargs)
+ if args_info.keywords:
+ params.append('**' + args_info.keywords)
+ if args_info.defaults:
+ offset = num_params - len(args_info.defaults)
+ for i, v in enumerate(args_info.defaults):
+ params[i + offset] = params[i + offset] + '=' + repr(v)
+ # is this a classmethod if so remove the first parameter
+ if inspect.ismethod(fn) and inspect.isclass(fn.__self__):
+ params = params[1:]
+ params = ', '.join(params)
+ output.append(' %s(%s)' % (function_name, params))
+ # doc string
+ if fn.__doc__:
+ bits = fn.__doc__.split('\n')
+ for bit in bits:
+ output.append(' %s' % bit)
+ return ('\n').join(output)
diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py
index 18e81beee4f..44d5c646f21 100644
--- a/ckan/lib/create_test_data.py
+++ b/ckan/lib/create_test_data.py
@@ -6,13 +6,16 @@ class CreateTestData(cli.CkanCommand):
'''Create test data in the database.
Tests can also delete the created objects easily with the delete() method.
- create-test-data - annakarenina and warandpeace
- create-test-data search - realistic data to test search
- create-test-data gov - government style data
- create-test-data family - package relationships data
- create-test-data user - create a user 'tester' with api key 'tester'
+ create-test-data - annakarenina and warandpeace
+ create-test-data search - realistic data to test search
+ create-test-data gov - government style data
+ create-test-data family - package relationships data
+ create-test-data user - create a user 'tester' with api key 'tester'
+ create-test-data translations - annakarenina, warandpeace, and some test
+ translations of terms
create-test-data vocabs - annakerenina, warandpeace, and some test
vocabularies
+
'''
summary = __doc__.split('\n')[0]
usage = __doc__
@@ -53,6 +56,8 @@ def command(self):
self.create_gov_test_data()
elif cmd == 'family':
self.create_family_test_data()
+ elif cmd == 'translations':
+ self.create_translations_test_data()
elif cmd == 'vocabs':
self.create_vocabs_test_data()
else:
@@ -92,6 +97,44 @@ def create_test_user(cls):
cls.user_refs.append(u'tester')
@classmethod
+
+ def create_translations_test_data(cls):
+ import ckan.model
+ CreateTestData.create()
+ rev = ckan.model.repo.new_revision()
+ rev.author = CreateTestData.author
+ rev.message = u'Creating test translations.'
+
+ sysadmin_user = ckan.model.User.get('testsysadmin')
+ package = ckan.model.Package.get('annakarenina')
+
+ # Add some new tags to the package.
+ # These tags are codes that are meant to be always translated before
+ # display, if not into the user's current language then into the
+ # fallback language.
+ package.add_tags([ckan.model.Tag('123'), ckan.model.Tag('456'),
+ ckan.model.Tag('789')])
+
+ # Add the above translations to CKAN.
+ for (lang_code, translations) in (('de', german_translations),
+ ('fr', french_translations), ('en', english_translations)):
+ for term in terms:
+ if term in translations:
+ data_dict = {
+ 'term': term,
+ 'term_translation': translations[term],
+ 'lang_code': lang_code,
+ }
+ context = {
+ 'model': ckan.model,
+ 'session': ckan.model.Session,
+ 'user': sysadmin_user.name,
+ }
+ ckan.logic.action.update.term_translation_update(context,
+ data_dict)
+
+ ckan.model.Session.commit()
+
def create_vocabs_test_data(cls):
import ckan.model
CreateTestData.create()
@@ -770,3 +813,49 @@ def get_all_data(cls):
}
}
]
+
+# Some test terms and translations.
+terms = ('A Novel By Tolstoy',
+ 'Index of the novel',
+ 'russian',
+ 'tolstoy',
+ "Dave's books",
+ "Roger's books",
+ 'Other (Open)',
+ 'romantic novel',
+ 'book',
+ '123',
+ '456',
+ '789',
+ 'plain text',
+ 'Roger likes these books.',
+)
+english_translations = {
+ '123': 'jealousy',
+ '456': 'realism',
+ '789': 'hypocrisy',
+}
+german_translations = {
+ 'A Novel By Tolstoy': 'Roman von Tolstoi',
+ 'Index of the novel': 'Index des Romans',
+ 'russian': 'Russisch',
+ 'tolstoy': 'Tolstoi',
+ "Dave's books": 'Daves Bucher',
+ "Roger's books": 'Rogers Bucher',
+ 'Other (Open)': 'Andere (Open)',
+ 'romantic novel': 'Liebesroman',
+ 'book': 'Buch',
+ '456': 'Realismus',
+ '789': 'Heuchelei',
+ 'plain text': 'Klartext',
+ 'Roger likes these books.': 'Roger mag diese Bucher.'
+}
+french_translations = {
+ 'A Novel By Tolstoy': 'A Novel par Tolstoi',
+ 'Index of the novel': 'Indice du roman',
+ 'russian': 'russe',
+ 'romantic novel': 'roman romantique',
+ 'book': 'livre',
+ '123': 'jalousie',
+ '789': 'hypocrisie',
+}
diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py
index d0e7d6c11e5..75fe85ec14d 100644
--- a/ckan/lib/dictization/model_dictize.py
+++ b/ckan/lib/dictization/model_dictize.py
@@ -1,7 +1,7 @@
import datetime
from pylons import config
from sqlalchemy.sql import select
-
+import datetime
import ckan.model
import ckan.misc
import ckan.logic as logic
@@ -32,6 +32,11 @@ def group_list_dictize(obj_list, context,
group_dict['packages'] = len(obj.active_packages().all())
+ if context.get('for_view'):
+ for item in plugins.PluginImplementations(
+ plugins.IGroupController):
+ group_dict = item.before_view(group_dict)
+
result_list.append(group_dict)
return sorted(result_list, key=sort_key, reverse=reverse)
@@ -43,10 +48,20 @@ def resource_list_dictize(res_list, context):
resource_dict = resource_dictize(res, context)
if active and res.state not in ('active', 'pending'):
continue
+
result_list.append(resource_dict)
return sorted(result_list, key=lambda x: x["position"])
+def related_list_dictize(related_list, context):
+ result_list = []
+ for res in related_list:
+ related_dict = related_dictize(res, context)
+ result_list.append(related_dict)
+
+ return sorted(result_list, key=lambda x: x["created"], reverse=True)
+
+
def extras_dict_dictize(extras_dict, context):
result_list = []
for name, extra in extras_dict.iteritems():
@@ -81,8 +96,15 @@ def resource_dictize(res, context):
extras = resource.pop("extras", None)
if extras:
resource.update(extras)
+ #tracking
+ model = context['model']
+ tracking = model.TrackingSummary.get_for_resource(res.url)
+ resource['tracking_summary'] = tracking
return resource
+def related_dictize(rel, context):
+ return d.table_dictize(rel, context)
+
def _execute_with_revision(q, rev_table, context):
'''
Takes an SqlAlchemy query (q) that is (at its base) a Select on an
@@ -153,6 +175,7 @@ def package_dictize(pkg, context):
q = q.where(resource_group.c.package_id == pkg.id)
result = _execute_with_revision(q, res_rev, context)
result_dict["resources"] = resource_list_dictize(result, context)
+
#tags
tag_rev = model.package_tag_revision_table
tag = model.tag_table
@@ -161,11 +184,22 @@ def package_dictize(pkg, context):
).where(tag_rev.c.package_id == pkg.id)
result = _execute_with_revision(q, tag_rev, context)
result_dict["tags"] = d.obj_list_dictize(result, context, lambda x: x["name"])
+
+ # Add display_names to tags. At first a tag's display_name is just the
+ # same as its name, but the display_name might get changed later (e.g.
+ # translated into another language by the multilingual extension).
+ for tag in result_dict['tags']:
+ assert not tag.has_key('display_name')
+ tag['display_name'] = tag['name']
+
#extras
extra_rev = model.extra_revision_table
q = select([extra_rev]).where(extra_rev.c.package_id == pkg.id)
result = _execute_with_revision(q, extra_rev, context)
result_dict["extras"] = extras_list_dictize(result, context)
+ #tracking
+ tracking = model.TrackingSummary.get_for_package(pkg.id)
+ result_dict['tracking_summary'] = tracking
#groups
member_rev = model.member_revision_table
group = model.group_table
@@ -210,10 +244,9 @@ def package_dictize(pkg, context):
if pkg.metadata_created else None
if context.get('for_view'):
- for item in plugins.PluginImplementations(plugins.IPackageController):
+ for item in plugins.PluginImplementations( plugins.IPackageController):
result_dict = item.before_view(result_dict)
-
return result_dict
def _get_members(context, group, member_type):
@@ -226,7 +259,6 @@ def _get_members(context, group, member_type):
filter(model.Member.state == 'active').\
filter(model.Member.table_name == member_type[:-1]).all()
-
def group_dictize(group, context):
model = context['model']
result_dict = d.table_dictize(group, context)
@@ -279,6 +311,18 @@ def tag_dictize(tag, context):
result_dict = d.table_dictize(tag, context)
result_dict["packages"] = d.obj_list_dictize(tag.packages, context)
+
+ # Add display_names to tags. At first a tag's display_name is just the
+ # same as its name, but the display_name might get changed later (e.g.
+ # translated into another language by the multilingual extension).
+ assert not result_dict.has_key('display_name')
+ result_dict['display_name'] = result_dict['name']
+
+ if context.get('for_view'):
+ for item in plugins.PluginImplementations(
+ plugins.ITagController):
+ result_dict = item.before_view(result_dict)
+
return result_dict
def user_list_dictize(obj_list, context,
diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py
index aa9f1a5f45e..8001dd4c49b 100644
--- a/ckan/lib/dictization/model_save.py
+++ b/ckan/lib/dictization/model_save.py
@@ -1,3 +1,4 @@
+import datetime
import uuid
from sqlalchemy.orm import class_mapper
import ckan.lib.dictization as d
@@ -8,7 +9,6 @@
def resource_dict_save(res_dict, context):
model = context["model"]
session = context["session"]
- trigger_url_change = False
id = res_dict.get("id")
obj = None
@@ -26,9 +26,12 @@ def resource_dict_save(res_dict, context):
for key, value in res_dict.iteritems():
if isinstance(value, list):
continue
- if key in ('extras', 'revision_timestamp'):
+ if key in ('extras', 'revision_timestamp', 'tracking_summary'):
continue
if key in fields:
+ if isinstance(getattr(obj, key), datetime.datetime):
+ if getattr(obj, key).isoformat() == value:
+ continue
if key == 'url' and not new and obj.url <> value:
obj.url_changed = True
setattr(obj, key, value)
@@ -417,6 +420,14 @@ def user_dict_save(user_dict, context):
return user
+
+def related_dict_save(related_dict, context):
+ model = context['model']
+ session = context['session']
+
+ return d.table_dict_save(related_dict,model.Related, context)
+
+
def package_api_to_dict(api1_dict, context):
package = context.get("package")
diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py
index ac1d053273a..78924b4ff24 100644
--- a/ckan/lib/helpers.py
+++ b/ckan/lib/helpers.py
@@ -7,6 +7,7 @@
"""
import email.utils
import datetime
+import logging
import re
import urllib
@@ -45,6 +46,8 @@
except ImportError:
import simplejson as json
+_log = logging.getLogger(__name__)
+
def redirect_to(*args, **kw):
'''A routes.redirect_to wrapper to retain the i18n settings'''
kw['__ckan_no_root'] = True
@@ -109,6 +112,9 @@ def _add_i18n_to_url(url_to_amend, **kw):
root = request.environ.get('SCRIPT_NAME', '')
except TypeError:
root = ''
+ if kw.get('qualified', False):
+ # if qualified is given we want the full url ie http://...
+ root = _routes_default_url_for('/', qualified=True)[:-1] + root
# ckan.root_path is defined when we have none standard language
# position in the url
root_path = config.get('ckan.root_path', None)
@@ -331,8 +337,41 @@ def _subnav_named_route(text, routename, **kwargs):
def default_group_type():
return str( config.get('ckan.default.group_type', 'group') )
+def unselected_facet_items(facet, limit=10):
+ '''Return the list of unselected facet items for the given facet, sorted
+ by count.
+
+ Returns the list of unselected facet contraints or facet items (e.g. tag
+ names like "russian" or "tolstoy") for the given search facet (e.g.
+ "tags"), sorted by facet item count (i.e. the number of search results that
+ match each facet item).
+
+ Reads the complete list of facet items for the given facet from
+ c.search_facets, and filters out the facet items that the user has already
+ selected.
+
+ Arguments:
+ facet -- the name of the facet to filter.
+ limit -- the max. number of facet items to return.
+
+ '''
+ if not c.search_facets or \
+ not c.search_facets.get(facet) or \
+ not c.search_facets.get(facet).get('items'):
+ return []
+ facets = []
+ for facet_item in c.search_facets.get(facet)['items']:
+ if not len(facet_item['name'].strip()):
+ continue
+ if not (facet, facet_item['name']) in request.params.items():
+ facets.append(facet_item)
+ return sorted(facets, key=lambda item: item['count'], reverse=True)[:limit]
def facet_items(*args, **kwargs):
+ """
+ DEPRECATED: Use the new facet data structure, and `unselected_facet_items()`
+ """
+ _log.warning('Deprecated function: ckan.lib.helpers:facet_items(). Will be removed in v1.8')
# facet_items() used to need c passing as the first arg
# this is depriciated as pointless
# throws error if ckan.restrict_template_vars is True
diff --git a/ckan/lib/plugins.py b/ckan/lib/plugins.py
index c7fe22875d9..258e1449681 100644
--- a/ckan/lib/plugins.py
+++ b/ckan/lib/plugins.py
@@ -274,6 +274,9 @@ def setup_template_variables(self, context, data_dict):
c.licences = [('', '')] + base.model.Package.get_license_options()
c.is_sysadmin = authz.Authorizer().is_sysadmin(c.user)
+ if c.pkg:
+ c.related_count = len(c.pkg.related)
+
## This is messy as auths take domain object not data_dict
context_pkg = context.get('package', None)
pkg = context_pkg or c.pkg
@@ -287,6 +290,7 @@ def setup_template_variables(self, context, data_dict):
c.auth_for_change_state = False
+
class DefaultGroupForm(object):
"""
Provides a default implementation of the pluggable Group controller
diff --git a/ckan/lib/search/index.py b/ckan/lib/search/index.py
index 2599115a0ff..086a39ed79d 100644
--- a/ckan/lib/search/index.py
+++ b/ckan/lib/search/index.py
@@ -132,6 +132,12 @@ def index_package(self, pkg_dict):
pkg_dict['groups'] = [group['name'] for group in groups]
+ # tracking
+ tracking_summary = pkg_dict.pop('tracking_summary', None)
+ if tracking_summary:
+ pkg_dict['views_total'] = tracking_summary['total']
+ pkg_dict['views_recent'] = tracking_summary['recent']
+
# flatten the structure for indexing:
for resource in pkg_dict.get('resources', []):
for (okey, nkey) in [('description', 'res_description'),
diff --git a/ckan/logic/__init__.py b/ckan/logic/__init__.py
index 513f2918ab3..30cd4ae7983 100644
--- a/ckan/logic/__init__.py
+++ b/ckan/logic/__init__.py
@@ -39,6 +39,7 @@ class NotAuthorized(ActionError):
class ParameterError(ActionError):
pass
+
class ValidationError(ParameterError):
def __init__(self, error_dict, error_summary=None, extra_msg=None):
self.error_dict = error_dict
@@ -224,3 +225,29 @@ def get_action(action):
_actions.update(fetched_actions)
return _actions.get(action)
+def get_or_bust(data_dict, keys):
+ '''Try and get values from dictionary and if they are not there
+ raise a validataion error.
+
+ data_dict: a dictionary
+ keys: either a single string key in which case will return a single value,
+ or a iterable which will return a tuple for unpacking purposes.
+
+ e.g single_value = get_or_bust(data_dict, 'a_key')
+ value_1, value_2 = get_or_bust(data_dict, ['key1', 'key2'])
+ '''
+ values = []
+ errors = {}
+
+ if isinstance(keys, basestring):
+ keys = [keys]
+ for key in keys:
+ value = data_dict.get(key)
+ if not value:
+ errors[key] = _('Missing value')
+ values.append(value)
+ if errors:
+ raise ValidationError(errors)
+ if len(values) == 1:
+ return values[0]
+ return tuple(values)
diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py
index d142dcf3744..aee73a32aa4 100644
--- a/ckan/logic/action/create.py
+++ b/ckan/logic/action/create.py
@@ -112,6 +112,35 @@ def resource_create(context, data_dict):
ckan.logic.schema.default_resource_schema(),
context)
+
+def related_create(context, data_dict):
+ model = context['model']
+ user = context['user']
+ userobj = model.User.get(user)
+
+ data_dict["owner_id"] = userobj.id
+ data, errors = validate(data_dict,
+ ckan.logic.schema.default_related_schema(),
+ context)
+ if errors:
+ model.Session.rollback()
+ raise ValidationError(errors, error_summary(errors))
+
+ related = model_save.related_dict_save(data, context)
+ if not context.get('defer_commit'):
+ model.repo.commit_and_remove()
+
+ if 'dataset_id' in data_dict:
+ dataset = model.Package.get(data_dict['dataset_id'])
+ dataset.related.append( related )
+ model.repo.commit_and_remove()
+
+ context["related"] = related
+ context["id"] = related.id
+ log.debug('Created object %s' % str(related.title))
+ return model_dictize.related_dictize(related, context)
+
+
def package_relationship_create(context, data_dict):
model = context['model']
diff --git a/ckan/logic/action/delete.py b/ckan/logic/action/delete.py
index a8368ec6195..c806a5113cd 100644
--- a/ckan/logic/action/delete.py
+++ b/ckan/logic/action/delete.py
@@ -64,6 +64,21 @@ def package_relationship_delete(context, data_dict):
relationship.delete()
model.repo.commit()
+def related_delete(context, data_dict):
+ model = context['model']
+ user = context['user']
+ id = data_dict['id']
+
+ entity = model.Related.get(id)
+
+ if entity is None:
+ raise NotFound
+
+ check_access('related_delete',context, data_dict)
+
+ entity.delete()
+ model.repo.commit()
+
def member_delete(context, data_dict=None):
"""
diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py
index 21429d357d0..fd0ce25ad57 100644
--- a/ckan/logic/action/get.py
+++ b/ckan/logic/action/get.py
@@ -6,7 +6,7 @@
import webhelpers.html
from sqlalchemy.sql import select
from sqlalchemy.orm import aliased
-from sqlalchemy import or_, and_, func, desc, case
+from sqlalchemy import or_, and_, func, desc, case, text
import ckan
import ckan.authz
@@ -31,6 +31,7 @@
check_access = logic.check_access
NotFound = logic.NotFound
ValidationError = logic.ValidationError
+get_or_bust = logic.get_or_bust
def _package_list_with_resources(context, package_revision_list):
package_list = []
@@ -105,6 +106,69 @@ def package_revision_list(context, data_dict):
include_groups=False))
return revision_dicts
+
+def related_show(context, data_dict=None):
+ """
+ Shows a single related item
+
+ context:
+ model - The CKAN model module
+ user - The name of the current user
+
+ data_dict:
+ id - The ID of the related item we want to show
+ """
+ model = context['model']
+ id = data_dict['id']
+
+ related = model.Related.get(id)
+ context['related'] = related
+
+ if related is None:
+ raise NotFound
+
+ check_access('related_show',context, data_dict)
+
+ schema = context.get('schema') or ckan.logic.schema.default_related_schema()
+ related_dict = model_dictize.related_dictize(related, context)
+ related_dict, errors = validate(related_dict, schema, context=context)
+
+ return related_dict
+
+
+def related_list(context, data_dict=None):
+ """
+ List the related items for a specific package which should be
+ mentioned in the data_dict
+
+ context:
+ model - The CKAN model module
+ user - The name of the current user
+ session - The current DB session
+
+ data_dict:
+ id - The ID of the dataset to which we want to list related items
+ or
+ dataset - The dataset (package) model
+ """
+ model = context['model']
+ session = context['session']
+ dataset = data_dict.get('dataset', None)
+
+ if not dataset:
+ dataset = model.Package.get(data_dict.get('id'))
+
+ if not dataset:
+ raise NotFound
+
+ check_access('related_show',context, data_dict)
+
+ relateds = model.Related.get_for_dataset(dataset, status='active')
+ related_items = (r.related for r in relateds)
+ related_list = model_dictize.related_list_dictize( related_items, context)
+ return related_list
+
+
def member_list(context, data_dict=None):
"""
Returns a list of (id,type,capacity) tuples that are members of the
@@ -417,9 +481,27 @@ def resource_show(context, data_dict):
raise NotFound
check_access('resource_show', context, data_dict)
-
return model_dictize.resource_dictize(resource, context)
+def resource_status_show(context, data_dict):
+
+ model = context['model']
+ id = get_or_bust(data_dict, 'id')
+
+ check_access('resource_status_show', context, data_dict)
+
+ # needs to be text query as celery tables are not in our model
+ q = text("""select status, date_done, traceback, task_status.*
+ from task_status left join celery_taskmeta
+ on task_status.value = celery_taskmeta.task_id and key = 'celery_task_id'
+ where entity_id = :entity_id """)
+
+ result = model.Session.connection().execute(q, entity_id=id)
+ result_list = [table_dictize(row, context) for row in result]
+
+ return result_list
+
+
def revision_show(context, data_dict):
model = context['model']
api = context.get('api_version')
@@ -753,12 +835,40 @@ def package_search(context, data_dict):
'results': results
}
+ # Transform facets into a more useful data structure.
+ restructured_facets = {}
+ for key, value in search_results['facets'].items():
+ restructured_facets[key] = {
+ 'title': key,
+ 'items': []
+ }
+ for key_, value_ in value.items():
+ new_facet_dict = {}
+ new_facet_dict['name'] = key_
+ if key == 'groups':
+ group = model.Group.get(key_)
+ if group:
+ new_facet_dict['display_name'] = group.display_name
+ else:
+ new_facet_dict['display_name'] = key_
+ else:
+ new_facet_dict['display_name'] = key_
+ new_facet_dict['count'] = value_
+ restructured_facets[key]['items'].append(new_facet_dict)
+ search_results['search_facets'] = restructured_facets
+
# check if some extension needs to modify the search results
for item in plugins.PluginImplementations(plugins.IPackageController):
search_results = item.after_search(search_results,data_dict)
- return search_results
+ # After extensions have had a chance to modify the facets, sort them by
+ # display name.
+ for facet in search_results['search_facets']:
+ search_results['search_facets'][facet]['items'] = sorted(
+ search_results['search_facets'][facet]['items'],
+ key=lambda facet: facet['display_name'], reverse=True)
+ return search_results
def resource_search(context, data_dict):
model = context['model']
@@ -933,13 +1043,13 @@ def term_translation_show(context, data_dict):
q = select([trans_table])
- if 'term' not in data_dict:
- raise ValidationError({'term': 'term not in data'})
+ if 'terms' not in data_dict:
+ raise ValidationError({'terms': 'terms not in data'})
- q = q.where(trans_table.c.term == data_dict['term'])
+ q = q.where(trans_table.c.term.in_(data_dict['terms']))
- if 'lang_code' in data_dict:
- q = q.where(trans_table.c.lang_code == data_dict['lang_code'])
+ if 'lang_codes' in data_dict:
+ q = q.where(trans_table.c.lang_code.in_(data_dict['lang_codes']))
conn = model.Session.connection()
cursor = conn.execute(q)
diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py
index 2509c924af6..cd5aa2fe8da 100644
--- a/ckan/logic/action/update.py
+++ b/ckan/logic/action/update.py
@@ -98,6 +98,35 @@ def make_latest_pending_package_active(context, data_dict):
session.remove()
+def related_update(context, data_dict):
+ model = context['model']
+ user = context['user']
+ id = data_dict["id"]
+
+ schema = context.get('schema') or ckan.logic.schema.default_related_schema()
+ model.Session.remove()
+
+ related = model.Related.get(id)
+ context["related"] = related
+
+ if not related:
+ logging.error('Could not find related ' + id)
+ raise NotFound(_('Related was not found.'))
+
+ check_access('related_update', context, data_dict)
+ data, errors = validate(data_dict, schema, context)
+
+ if errors:
+ model.Session.rollback()
+ raise ValidationError(errors, error_summary(errors))
+
+ related = model_save.related_dict_save(data, context)
+ if not context.get('defer_commit'):
+ model.repo.commit()
+ return model_dictize.related_dictize(related, context)
+
+
+
def resource_update(context, data_dict):
model = context['model']
user = context['user']
diff --git a/ckan/logic/auth/__init__.py b/ckan/logic/auth/__init__.py
index ab3ee7aa222..7b00f9c8786 100644
--- a/ckan/logic/auth/__init__.py
+++ b/ckan/logic/auth/__init__.py
@@ -17,6 +17,9 @@ def _get_object(context, data_dict, name, class_name):
obj = context[name]
return obj
+def get_related_object(context, data_dict = {}):
+ return _get_object(context, data_dict, 'related', 'Related')
+
def get_package_object(context, data_dict = {}):
return _get_object(context, data_dict, 'package', 'Package')
diff --git a/ckan/logic/auth/create.py b/ckan/logic/auth/create.py
index 3ec56088ddb..546a1b872ac 100644
--- a/ckan/logic/auth/create.py
+++ b/ckan/logic/auth/create.py
@@ -1,12 +1,11 @@
-from ckan.logic import check_access_old, NotFound
+import ckan.logic as logic
from ckan.authz import Authorizer
from ckan.lib.base import _
-
def package_create(context, data_dict=None):
model = context['model']
user = context['user']
- check1 = check_access_old(model.System(), model.Action.PACKAGE_CREATE, context)
+ check1 = logic.check_access_old(model.System(), model.Action.PACKAGE_CREATE, context)
if not check1:
return {'success': False, 'msg': _('User %s not authorized to create packages') % str(user)}
@@ -18,6 +17,17 @@ def package_create(context, data_dict=None):
return {'success': True}
+def related_create(context, data_dict=None):
+ model = context['model']
+ user = context['user']
+ userobj = model.User.get( user )
+
+ if userobj:
+ return {'success': True}
+
+ return {'success': False, 'msg': _('You must be logged in to add a related item')}
+
+
def resource_create(context, data_dict):
return {'success': False, 'msg': 'Not implemented yet in the auth refactor'}
@@ -43,7 +53,7 @@ def group_create(context, data_dict=None):
model = context['model']
user = context['user']
- authorized = check_access_old(model.System(), model.Action.GROUP_CREATE, context)
+ authorized = logic.check_access_old(model.System(), model.Action.GROUP_CREATE, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to create groups') % str(user)}
else:
@@ -53,7 +63,7 @@ def authorization_group_create(context, data_dict=None):
model = context['model']
user = context['user']
- authorized = check_access_old(model.System(), model.Action.AUTHZ_GROUP_CREATE, context)
+ authorized = logic.check_access_old(model.System(), model.Action.AUTHZ_GROUP_CREATE, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to create authorization groups') % str(user)}
else:
@@ -67,7 +77,7 @@ def user_create(context, data_dict=None):
model = context['model']
user = context['user']
- authorized = check_access_old(model.System(), model.Action.USER_CREATE, context)
+ authorized = logic.check_access_old(model.System(), model.Action.USER_CREATE, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to create users') % str(user)}
else:
@@ -98,7 +108,7 @@ def _check_group_auth(context, data_dict):
id = group_blob
grp = model.Group.get(id)
if grp is None:
- raise NotFound(_('Group was not found.'))
+ raise logic.NotFound(_('Group was not found.'))
groups.add(grp)
if pkg:
@@ -107,7 +117,7 @@ def _check_group_auth(context, data_dict):
groups = groups - set(pkg_groups)
for group in groups:
- if not check_access_old(group, model.Action.EDIT, context):
+ if not logic.check_access_old(group, model.Action.EDIT, context):
return False
return True
diff --git a/ckan/logic/auth/delete.py b/ckan/logic/auth/delete.py
index 12937c284d9..c99bec37bc5 100644
--- a/ckan/logic/auth/delete.py
+++ b/ckan/logic/auth/delete.py
@@ -1,5 +1,5 @@
-from ckan.logic import check_access_old
-from ckan.logic.auth import get_package_object, get_group_object
+import ckan.logic as logic
+from ckan.logic.auth import get_package_object, get_group_object, get_related_object
from ckan.logic.auth.create import package_relationship_create
from ckan.authz import Authorizer
from ckan.lib.base import _
@@ -9,22 +9,40 @@ def package_delete(context, data_dict):
user = context['user']
package = get_package_object(context, data_dict)
- authorized = check_access_old(package, model.Action.PURGE, context)
+ authorized = logic.check_access_old(package, model.Action.PURGE, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to delete package %s') % (str(user),package.id)}
else:
return {'success': True}
+
+def related_delete(context, data_dict):
+ model = context['model']
+ user = context['user']
+ if not user:
+ return {'success': False, 'msg': _('Only the owner can delete a related item')}
+
+ if Authorizer().is_sysadmin(unicode(user)):
+ return {'success': True}
+
+ related = get_related_object(context, data_dict)
+ userobj = model.User.get( user )
+ if not userobj or userobj.id != related.owner_id:
+ return {'success': False, 'msg': _('Only the owner can delete a related item')}
+
+ return {'success': True}
+
+
def package_relationship_delete(context, data_dict):
can_edit_this_relationship = package_relationship_create(context, data_dict)
if not can_edit_this_relationship['success']:
return can_edit_this_relationship
-
+
model = context['model']
user = context['user']
relationship = context['relationship']
- authorized = check_access_old(relationship, model.Action.PURGE, context)
+ authorized = logic.check_access_old(relationship, model.Action.PURGE, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to delete relationship %s') % (str(user),relationship.id)}
else:
@@ -35,7 +53,7 @@ def group_delete(context, data_dict):
user = context['user']
group = get_group_object(context, data_dict)
- authorized = check_access_old(group, model.Action.PURGE, context)
+ authorized = logic.check_access_old(group, model.Action.PURGE, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to delete group %s') % (str(user),group.id)}
else:
diff --git a/ckan/logic/auth/get.py b/ckan/logic/auth/get.py
index 27e8436dfe5..2c32746ae09 100644
--- a/ckan/logic/auth/get.py
+++ b/ckan/logic/auth/get.py
@@ -1,7 +1,8 @@
-from ckan.logic import check_access_old, NotFound
+import ckan.logic as logic
from ckan.authz import Authorizer
from ckan.lib.base import _
-from ckan.logic.auth import get_package_object, get_group_object, get_resource_object
+from ckan.logic.auth import (get_package_object, get_group_object,
+ get_resource_object, get_related_object)
def site_read(context, data_dict):
@@ -84,12 +85,16 @@ def package_show(context, data_dict):
user = context.get('user')
package = get_package_object(context, data_dict)
- authorized = check_access_old(package, model.Action.READ, context)
+ authorized = logic.check_access_old(package, model.Action.READ, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to read package %s') % (str(user),package.id)}
else:
return {'success': True}
+def related_show(context, data_dict=None):
+ return {'success': True}
+
+
def resource_show(context, data_dict):
model = context['model']
user = context.get('user')
@@ -102,11 +107,11 @@ def resource_show(context, data_dict):
.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.'))
-
+ raise logic.NotFound(_('No package found for this resource, cannot check auth.'))
+
pkg_dict = {'id': pkg.id}
authorized = package_show(context, pkg_dict).get('success')
-
+
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to read resource %s') % (str(user), resource.id)}
else:
@@ -121,7 +126,7 @@ def group_show(context, data_dict):
user = context.get('user')
group = get_group_object(context, data_dict)
- authorized = check_access_old(group, model.Action.READ, context)
+ authorized = logic.check_access_old(group, model.Action.READ, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to read group %s') % (str(user),group.id)}
else:
@@ -154,6 +159,9 @@ def format_autocomplete(context, data_dict):
def task_status_show(context, data_dict):
return {'success': True}
+def resource_status_show(context, data_dict):
+ return {'success': True}
+
## Modifications for rest api
def package_show_rest(context, data_dict):
diff --git a/ckan/logic/auth/publisher/create.py b/ckan/logic/auth/publisher/create.py
index 17eb77d38d6..d1bece7ebaf 100644
--- a/ckan/logic/auth/publisher/create.py
+++ b/ckan/logic/auth/publisher/create.py
@@ -1,7 +1,7 @@
-from ckan.logic.auth import get_package_object, get_group_object, \
- get_user_object, get_resource_object
+from ckan.logic.auth import (get_package_object, get_group_object,
+ get_user_object, get_resource_object, get_related_object)
from ckan.logic.auth.publisher import _groups_intersect
-from ckan.logic import NotFound
+import ckan.logic as logic
from ckan.authz import Authorizer
from ckan.lib.base import _
@@ -19,6 +19,18 @@ def package_create(context, data_dict=None):
return {'success': False, 'msg': 'You must be logged in to create a package'}
+
+def related_create(context, data_dict=None):
+ model = context['model']
+ user = context['user']
+ userobj = model.User.get( user )
+
+ if userobj:
+ return {'success': True}
+
+ return {'success': False, 'msg': _('You must be logged in to add a related item')}
+
+
def resource_create(context, data_dict):
return {'success': False, 'msg': 'Not implemented yet in the auth refactor'}
@@ -68,7 +80,7 @@ def group_create(context, data_dict=None):
# If the user is doing this within another group then we need to make sure that
# the user has permissions for this group.
group = get_group_object( context )
- except NotFound:
+ except logic.NotFound:
return { 'success' : True }
userobj = model.User.get( user )
diff --git a/ckan/logic/auth/publisher/delete.py b/ckan/logic/auth/publisher/delete.py
index 00d26afdcbc..9d3388f2340 100644
--- a/ckan/logic/auth/publisher/delete.py
+++ b/ckan/logic/auth/publisher/delete.py
@@ -1,6 +1,6 @@
+import ckan.logic as logic
from ckan.logic.auth import get_package_object, get_group_object, \
- get_user_object, get_resource_object
-from ckan.logic.auth import get_package_object, get_group_object
+ get_user_object, get_resource_object, get_related_object
from ckan.logic.auth.publisher import _groups_intersect
from ckan.logic.auth.publisher.create import package_relationship_create
from ckan.authz import Authorizer
@@ -29,6 +29,23 @@ def package_delete(context, data_dict):
def package_relationship_delete(context, data_dict):
return package_relationship_create(context, data_dict)
+def related_delete(context, data_dict):
+ model = context['model']
+ user = context['user']
+ if not user:
+ return {'success': False, 'msg': _('Only the owner can delete a related item')}
+
+ if Authorizer().is_sysadmin(unicode(user)):
+ return {'success': True}
+
+ related = get_related_object(context, data_dict)
+ userobj = model.User.get( user )
+ if not userobj or userobj.id != related.owner_id:
+ return {'success': False, 'msg': _('Only the owner can delete a related item')}
+
+ return {'success': True}
+
+
def group_delete(context, data_dict):
"""
Group delete permission. Checks that the user specified is within the group to be deleted
diff --git a/ckan/logic/auth/publisher/get.py b/ckan/logic/auth/publisher/get.py
index b2dbd9a9d86..3a76048817d 100644
--- a/ckan/logic/auth/publisher/get.py
+++ b/ckan/logic/auth/publisher/get.py
@@ -1,5 +1,6 @@
+import ckan.logic as logic
from ckan.logic.auth import get_package_object, get_group_object, \
- get_user_object, get_resource_object
+ get_user_object, get_resource_object, get_related_object
from ckan.lib.base import _
from ckan.logic.auth.publisher import _groups_intersect
from ckan.authz import Authorizer
@@ -97,6 +98,10 @@ def package_show(context, data_dict):
return {'success': True}
+def related_show(context, data_dict=None):
+ return {'success': True}
+
+
def resource_show(context, data_dict):
""" Resource show permission checks the user group if the package state is deleted """
model = context['model']
@@ -160,6 +165,9 @@ def format_autocomplete(context, data_dict):
def task_status_show(context, data_dict):
return {'success': True}
+def resource_status_show(context, data_dict):
+ return {'success': True}
+
## Modifications for rest api
def package_show_rest(context, data_dict):
diff --git a/ckan/logic/auth/publisher/update.py b/ckan/logic/auth/publisher/update.py
index dbbf1942ebd..30d7a1d92bd 100644
--- a/ckan/logic/auth/publisher/update.py
+++ b/ckan/logic/auth/publisher/update.py
@@ -1,5 +1,7 @@
+import ckan.logic as logic
from ckan.logic.auth import get_package_object, get_group_object, \
- get_user_object, get_resource_object
+ get_user_object, get_resource_object, get_related_object, \
+ get_authorization_group_object
from ckan.logic.auth.publisher import _groups_intersect
from ckan.logic.auth.publisher.create import package_relationship_create
from ckan.authz import Authorizer
@@ -86,6 +88,19 @@ def group_update(context, data_dict):
return { 'success': True }
+def related_update(context, data_dict):
+ model = context['model']
+ user = context['user']
+ if not user:
+ return {'success': False, 'msg': _('Only the owner can update a related item')}
+
+ related = get_related_object(context, data_dict)
+ userobj = model.User.get( user )
+ if not userobj or userobj.id != related.owner_id:
+ return {'success': False, 'msg': _('Only the owner can update a related item')}
+
+ return {'success': True}
+
def group_change_state(context, data_dict):
return group_update(context, data_dict)
diff --git a/ckan/logic/auth/update.py b/ckan/logic/auth/update.py
index 93036b75b04..afd00688272 100644
--- a/ckan/logic/auth/update.py
+++ b/ckan/logic/auth/update.py
@@ -1,6 +1,7 @@
-from ckan.logic import check_access_old, NotFound
-from ckan.logic.auth import get_package_object, get_resource_object, get_group_object, get_authorization_group_object, \
- get_user_object, get_resource_object
+import ckan.logic as logic
+from ckan.logic.auth import (get_package_object, get_resource_object,
+ get_group_object, get_authorization_group_object,
+ get_user_object, get_resource_object, get_related_object)
from ckan.logic.auth.create import _check_group_auth, package_relationship_create
from ckan.authz import Authorizer
from ckan.lib.base import _
@@ -13,7 +14,7 @@ def package_update(context, data_dict):
user = context.get('user')
package = get_package_object(context, data_dict)
- check1 = check_access_old(package, model.Action.EDIT, context)
+ check1 = logic.check_access_old(package, model.Action.EDIT, context)
if not check1:
return {'success': False, 'msg': _('User %s not authorized to edit package %s') % (str(user), package.id)}
else:
@@ -35,11 +36,11 @@ def resource_update(context, data_dict):
.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.'))
-
+ raise logic.NotFound(_('No package found for this resource, cannot check auth.'))
+
pkg_dict = {'id': pkg.id}
authorized = package_update(context, pkg_dict).get('success')
-
+
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to read edit %s') % (str(user), resource.id)}
else:
@@ -53,7 +54,7 @@ def package_change_state(context, data_dict):
user = context['user']
package = get_package_object(context, data_dict)
- authorized = check_access_old(package, model.Action.CHANGE_STATE, context)
+ authorized = logic.check_access_old(package, model.Action.CHANGE_STATE, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to change state of package %s') % (str(user),package.id)}
else:
@@ -64,7 +65,7 @@ def package_edit_permissions(context, data_dict):
user = context['user']
package = get_package_object(context, data_dict)
- authorized = check_access_old(package, model.Action.EDIT_PERMISSIONS, context)
+ authorized = logic.check_access_old(package, model.Action.EDIT_PERMISSIONS, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to edit permissions of package %s') % (str(user),package.id)}
else:
@@ -74,19 +75,33 @@ def group_update(context, data_dict):
model = context['model']
user = context['user']
group = get_group_object(context, data_dict)
-
- authorized = check_access_old(group, model.Action.EDIT, context)
+
+ authorized = logic.check_access_old(group, model.Action.EDIT, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to edit group %s') % (str(user),group.id)}
else:
return {'success': True}
+def related_update(context, data_dict):
+ model = context['model']
+ user = context['user']
+ if not user:
+ return {'success': False, 'msg': _('Only the owner can update a related item')}
+
+ related = get_related_object(context, data_dict)
+ userobj = model.User.get( user )
+ if not userobj or userobj.id != related.owner_id:
+ return {'success': False, 'msg': _('Only the owner can update a related item')}
+
+ return {'success': True}
+
+
def group_change_state(context, data_dict):
model = context['model']
user = context['user']
group = get_group_object(context, data_dict)
- authorized = check_access_old(group, model.Action.CHANGE_STATE, context)
+ authorized = logic.check_access_old(group, model.Action.CHANGE_STATE, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to change state of group %s') % (str(user),group.id)}
else:
@@ -97,7 +112,7 @@ def group_edit_permissions(context, data_dict):
user = context['user']
group = get_group_object(context, data_dict)
- authorized = check_access_old(group, model.Action.EDIT_PERMISSIONS, context)
+ authorized = logic.check_access_old(group, model.Action.EDIT_PERMISSIONS, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to edit permissions of group %s') % (str(user),group.id)}
else:
@@ -108,7 +123,7 @@ def authorization_group_update(context, data_dict):
user = context['user']
authorization_group = get_authorization_group_object(context, data_dict)
- authorized = check_access_old(authorization_group, model.Action.EDIT, context)
+ authorized = logic.check_access_old(authorization_group, model.Action.EDIT, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to edit permissions of authorization group %s') % (str(user),authorization_group.id)}
else:
@@ -119,7 +134,7 @@ def authorization_group_edit_permissions(context, data_dict):
user = context['user']
authorization_group = get_authorization_group_object(context, data_dict)
- authorized = check_access_old(authorization_group, model.Action.EDIT_PERMISSIONS, context)
+ authorized = logic.check_access_old(authorization_group, model.Action.EDIT_PERMISSIONS, context)
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to edit permissions of authorization group %s') % (str(user),authorization_group.id)}
else:
@@ -152,7 +167,7 @@ def task_status_update(context, data_dict):
if 'ignore_auth' in context and context['ignore_auth']:
return {'success': True}
-
+
authorized = Authorizer().is_sysadmin(unicode(user))
if not authorized:
return {'success': False, 'msg': _('User %s not authorized to update task_status table') % str(user)}
diff --git a/ckan/logic/converters.py b/ckan/logic/converters.py
index 5de75a7f1e4..6b927ae3106 100644
--- a/ckan/logic/converters.py
+++ b/ckan/logic/converters.py
@@ -60,13 +60,11 @@ def callable(key, data, errors, context):
context['vocabulary'] = v
for tag in new_tags:
- tag_length_validator(tag, context)
- tag_name_validator(tag, context)
tag_in_vocabulary_validator(tag, context)
for num, tag in enumerate(new_tags):
- data[('tags', num+n, 'name')] = tag
- data[('tags', num+n, 'vocabulary_id')] = v.id
+ data[('tags', num + n, 'name')] = tag
+ data[('tags', num + n, 'vocabulary_id')] = v.id
return callable
def convert_from_tags(vocab):
diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py
index b07d2bb2d02..09ffe2217ae 100644
--- a/ckan/logic/schema.py
+++ b/ckan/logic/schema.py
@@ -62,9 +62,11 @@ def default_resource_schema():
'webstore_url': [ignore_missing, unicode],
'cache_url': [ignore_missing, unicode],
'size': [ignore_missing, int_validator],
+ 'created': [ignore_missing, isodate],
'last_modified': [ignore_missing, isodate],
'cache_last_updated': [ignore_missing, isodate],
'webstore_last_updated': [ignore_missing, isodate],
+ 'tracking_summary': [ignore],
'__extras': [ignore_missing, extras_unicode_convert, keep_extras],
}
@@ -85,6 +87,7 @@ def default_tags_schema():
'vocabulary_id': [ignore_missing, unicode, vocabulary_id_exists],
'revision_timestamp': [ignore],
'state': [ignore],
+ 'display_name': [ignore],
}
return schema
@@ -232,6 +235,20 @@ def default_update_group_schema():
return schema
+def default_related_schema():
+ schema = {
+ 'id': [ignore_missing, unicode],
+ 'title': [not_empty, unicode],
+ 'description': [ignore_missing, unicode],
+ 'type': [not_empty, unicode],
+ 'image_url': [ignore_missing, unicode],
+ 'url': [ignore_missing, unicode],
+ 'owner_id': [not_empty, unicode],
+ 'created': [ignore],
+ }
+ return schema
+
+
def default_extras_schema():
schema = {
diff --git a/ckan/migration/versions/054_add_resource_created_date.py b/ckan/migration/versions/054_add_resource_created_date.py
new file mode 100644
index 00000000000..03641509c3e
--- /dev/null
+++ b/ckan/migration/versions/054_add_resource_created_date.py
@@ -0,0 +1,9 @@
+def upgrade(migrate_engine):
+ migrate_engine.execute('''
+ ALTER TABLE resource
+ ADD COLUMN created timestamp without time zone;
+
+ ALTER TABLE resource_revision
+ ADD COLUMN created timestamp without time zone;
+ '''
+ )
diff --git a/ckan/migration/versions/055_update_user_and_activity_detail.py b/ckan/migration/versions/055_update_user_and_activity_detail.py
new file mode 100644
index 00000000000..5d349000333
--- /dev/null
+++ b/ckan/migration/versions/055_update_user_and_activity_detail.py
@@ -0,0 +1,9 @@
+def upgrade(migrate_engine):
+ migrate_engine.execute('''
+ ALTER TABLE activity_detail
+ ALTER COLUMN activity_id DROP NOT NULL;
+
+ ALTER TABLE "user"
+ ALTER COLUMN name SET NOT NULL;
+ '''
+ )
diff --git a/ckan/migration/versions/056_add_related_table.py b/ckan/migration/versions/056_add_related_table.py
new file mode 100644
index 00000000000..bcd909c7619
--- /dev/null
+++ b/ckan/migration/versions/056_add_related_table.py
@@ -0,0 +1,40 @@
+from sqlalchemy import *
+from migrate import *
+
+def upgrade(migrate_engine):
+ metadata = MetaData()
+ metadata.bind = migrate_engine
+ migrate_engine.execute('''
+BEGIN;
+CREATE TABLE related (
+ id text NOT NULL,
+ type text NOT NULL,
+ title text,
+ description text,
+ image_url text,
+ url text,
+ created timestamp without time zone,
+ owner_id text
+);
+
+CREATE TABLE related_dataset (
+ id text NOT NULL,
+ dataset_id text NOT NULL,
+ related_id text NOT NULL,
+ status text
+);
+
+ALTER TABLE related
+ ADD CONSTRAINT related_pkey PRIMARY KEY (id);
+
+ALTER TABLE related_dataset
+ ADD CONSTRAINT related_dataset_pkey PRIMARY KEY (id);
+
+ALTER TABLE related_dataset
+ ADD CONSTRAINT related_dataset_dataset_id_fkey FOREIGN KEY (dataset_id) REFERENCES package(id);
+
+ALTER TABLE related_dataset
+ ADD CONSTRAINT related_dataset_related_id_fkey FOREIGN KEY (related_id) REFERENCES related(id);
+COMMIT;
+ '''
+ )
diff --git a/ckan/migration/versions/057_tracking.py b/ckan/migration/versions/057_tracking.py
new file mode 100644
index 00000000000..5f2fe43ec53
--- /dev/null
+++ b/ckan/migration/versions/057_tracking.py
@@ -0,0 +1,33 @@
+from sqlalchemy import *
+from migrate import *
+
+def upgrade(migrate_engine):
+ migrate_engine.execute('''
+ BEGIN;
+ CREATE TABLE tracking_raw (
+ user_key character varying(100) NOT NULL,
+ url text NOT NULL,
+ tracking_type character varying(10) NOT NULL,
+ access_timestamp timestamp without time zone DEFAULT current_timestamp
+ );
+ CREATE INDEX tracking_raw_url ON tracking_raw(url);
+ CREATE INDEX tracking_raw_user_key ON tracking_raw(user_key);
+ CREATE INDEX tracking_raw_access_timestamp ON tracking_raw(access_timestamp);
+
+ CREATE TABLE tracking_summary(
+ url text NOT NULL,
+ package_id text,
+ tracking_type character varying(10) NOT NULL,
+ count int NOT NULL,
+ running_total int NOT NULL DEFAULT 0,
+ recent_views int NOT NULL DEFAULT 0,
+ tracking_date date
+ );
+
+ CREATE INDEX tracking_summary_url ON tracking_summary(url);
+ CREATE INDEX tracking_summary_package_id ON tracking_summary(package_id);
+ CREATE INDEX tracking_summary_date ON tracking_summary(tracking_date);
+
+ COMMIT;
+ '''
+ )
diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py
index 8f891cdc5db..a3431fcdb10 100644
--- a/ckan/model/__init__.py
+++ b/ckan/model/__init__.py
@@ -19,12 +19,14 @@
import authz
import package_extra
import resource
+import tracking
import rating
import package_relationship
import task_status
import vocabulary
import activity
import term_translation
+
import ckan.migration
import ckan.lib.helpers as h
@@ -128,6 +130,8 @@
term_translation_table = term_translation.term_translation_table
+tracking_summary_table = tracking.tracking_summary_table
+TrackingSummary = tracking.TrackingSummary
# set up in init_model after metadata is bound
version_table = None
@@ -146,14 +150,14 @@ def init_model(engine):
except sqlalchemy.exc.NoSuchTableError:
pass
-
+
class Repository(vdm.sqlalchemy.Repository):
migrate_repository = ckan.migration.__path__[0]
# note: tables_created value is not sustained between instantiations so
# only useful for tests. The alternative is to use are_tables_created().
- tables_created_and_initialised = False
+ tables_created_and_initialised = False
def init_db(self):
'''Ensures tables, const data and some default config is created.
@@ -174,6 +178,17 @@ def init_db(self):
else:
if not self.tables_created_and_initialised:
self.upgrade_db()
+ ## make sure celery tables are made as celery only makes them after
+ ## adding a task
+ import ckan.lib.celery_app as celery_app
+ import celery.db.session as celery_session
+
+ ##This creates the database tables it is a slight hack to celery.
+ backend = celery_app.celery.backend
+ celery_result_session = backend.ResultSession()
+ engine = celery_result_session.bind
+ celery_session.ResultModelBase.metadata.create_all(engine)
+
self.init_configuration_data()
self.tables_created_and_initialised = True
@@ -206,15 +221,20 @@ def init_configuration_data(self):
rev = Revision()
rev.author = 'system'
rev.message = u'Initialising the Repository'
+<<<<<<< HEAD
meta.Session.add(rev)
self.commit_and_remove()
+=======
+ Session.add(rev)
+ self.commit_and_remove()
+>>>>>>> master
def create_db(self):
'''Ensures tables, const data and some default config is created.
i.e. the same as init_db APART from when running tests, when init_db
has shortcuts.
'''
- self.metadata.create_all(bind=self.metadata.bind)
+ self.metadata.create_all(bind=self.metadata.bind)
self.init_const_data()
self.init_configuration_data()
@@ -238,7 +258,7 @@ def rebuild_db(self):
self.session.remove()
self.init_db()
self.session.flush()
-
+
def delete_all(self):
'''Delete all data from all tables.'''
self.session.remove()
@@ -275,7 +295,7 @@ def upgrade_db(self, version=None):
self.setup_migration_version_control()
mig.upgrade(self.metadata.bind, self.migrate_repository, version=version)
self.init_const_data()
-
+
##this prints the diffs in a readable format
##import pprint
##from migrate.versioning.schemadiff import getDiffOfModelAgainstDatabase
@@ -402,7 +422,7 @@ def revision_as_dict(revision, include_packages=True, include_groups=True,ref_pa
if include_groups:
revision_dict['groups'] = [getattr(grp, ref_package_by) \
for grp in revision.groups if grp]
-
+
return revision_dict
def is_id(id_string):
diff --git a/ckan/model/package.py b/ckan/model/package.py
index 226b6841a91..e2fc2809f90 100644
--- a/ckan/model/package.py
+++ b/ckan/model/package.py
@@ -113,6 +113,7 @@ def get_resource_identity(resource_obj_or_dict):
else:
resource = resource_obj_or_dict
res_dict = resource.as_dict(core_columns_only=True)
+ del res_dict['created']
return res_dict
existing_res_identites = [get_resource_identity(res) \
for res in self.resources]
@@ -169,6 +170,7 @@ def add_tag(self, tag):
package_tag = model.PackageTag(self, tag)
meta.Session.add(package_tag)
+
def add_tags(self, tags):
for tag in tags:
self.add_tag(tag)
@@ -272,6 +274,10 @@ def as_dict(self, ref_package_by='name', ref_group_by='name'):
_dict['metadata_created'] = self.metadata_created.isoformat() \
if self.metadata_created else None
_dict['notes_rendered'] = ckan.misc.MarkdownFormat().to_html(self.notes)
+ #tracking
+ import ckan.model as model
+ tracking = model.TrackingSummary.get_for_package(self.id)
+ _dict['tracking_summary'] = tracking
return _dict
def add_relationship(self, type_, related_package, comment=u''):
@@ -559,6 +565,13 @@ def metadata_modified(self):
timestamp_float = timegm(timestamp_without_usecs) + usecs
return datetime.datetime.utcfromtimestamp(timestamp_float)
+ @property
+ def is_private(self):
+ """
+ A package is private if belongs to any private groups
+ """
+ return bool(self.get_groups(capacity='private'))
+
def is_in_group(self, group):
return group in self.get_groups()
diff --git a/ckan/model/related.py b/ckan/model/related.py
new file mode 100644
index 00000000000..df2d5890317
--- /dev/null
+++ b/ckan/model/related.py
@@ -0,0 +1,72 @@
+import os
+import datetime
+import meta
+import sqlalchemy as sa
+from core import DomainObject
+from types import make_uuid
+from package import Package
+
+
+related_table = meta.Table('related',meta.metadata,
+ meta.Column('id', meta.UnicodeText, primary_key=True, default=make_uuid),
+ meta.Column('type', meta.UnicodeText, default=u'idea'),
+ meta.Column('title', meta.UnicodeText),
+ meta.Column('description', meta.UnicodeText),
+ meta.Column('image_url', meta.UnicodeText),
+ meta.Column('url', meta.UnicodeText),
+ meta.Column('created', meta.DateTime, default=datetime.datetime.now),
+ meta.Column('owner_id', meta.UnicodeText),
+ )
+
+related_dataset_table = meta.Table('related_dataset', meta.metadata,
+ meta.Column('id', meta.UnicodeText, primary_key=True, default=make_uuid),
+ meta.Column('dataset_id', meta.UnicodeText, meta.ForeignKey('package.id'),
+ nullable=False),
+ meta.Column('related_id', meta.UnicodeText, meta.ForeignKey('related.id'), nullable=False),
+ meta.Column('status', meta.UnicodeText, default=u'active'),
+ )
+
+class RelatedDataset(DomainObject):
+ pass
+
+class Related(DomainObject):
+
+ @classmethod
+ def get(cls, id):
+ return meta.Session.query(Related).filter(Related.id == id).first()
+
+ @classmethod
+ def get_for_dataset(cls, package, status=u'active'):
+ """
+ Allows the caller to get non-active state relations between
+ the dataset and related, using the RelatedDataset object
+ """
+ query = meta.Session.query(RelatedDataset).\
+ filter(RelatedDataset.dataset_id==package.id).\
+ filter(RelatedDataset.status==status).all()
+ return query
+
+ def deactivate(self, package):
+ related_ds = meta.Session.query(RelatedDataset).\
+ filter(RelatedDataset.dataset_id==package.id).\
+ filter(RelatedDataset.status=='active').first()
+ if related_ds:
+ related_ds.status = 'inactive'
+ meta.Session.commit()
+
+
+# We have avoided using SQLAlchemy association objects see
+# http://bit.ly/sqlalchemy_association_object by only having the
+# relation be for 'active' related objects. For non-active states
+# the caller will have to use get_for_dataset() in Related.
+meta.mapper(RelatedDataset, related_dataset_table, properties={
+ 'related': meta.relation(Related),
+ 'dataset': meta.relation(Package)
+})
+meta.mapper(Related, related_table, properties={
+'datasets': meta.relation(Package,
+ backref=meta.backref('related'),
+ secondary=related_dataset_table,
+ secondaryjoin=sa.and_(related_dataset_table.c.dataset_id==Package.id,
+ RelatedDataset.status=='active'))
+})
diff --git a/ckan/model/resource.py b/ckan/model/resource.py
index f20673ef9d0..95df2cbe9ef 100644
--- a/ckan/model/resource.py
+++ b/ckan/model/resource.py
@@ -14,7 +14,7 @@
from ckan.model.activity import ActivityDetail
import domain_object
-__all__ = ['Resource', 'resource_table',
+__all__ = ['Resource', 'resource_table',
'ResourceGroup', 'resource_group_table',
'ResourceRevision', 'resource_revision_table',
'ResourceGroupRevision', 'resource_group_revision_table',
@@ -22,9 +22,9 @@
CORE_RESOURCE_COLUMNS = ['url', 'format', 'description', 'hash', 'name',
'resource_type', 'mimetype', 'mimetype_inner',
- 'size', 'last_modified', 'cache_url', 'cache_last_updated',
- 'webstore_url', 'webstore_last_updated']
-
+ 'size', 'created', 'last_modified', 'cache_url',
+ 'cache_last_updated', 'webstore_url',
+ 'webstore_last_updated']
##formally package_resource
@@ -43,6 +43,7 @@
Column('mimetype', types.UnicodeText),
Column('mimetype_inner', types.UnicodeText),
Column('size', types.BigInteger),
+ Column('created', types.DateTime, default=datetime.datetime.now),
Column('last_modified', types.DateTime),
Column('cache_url', types.UnicodeText),
Column('cache_last_updated', types.DateTime),
@@ -110,6 +111,9 @@ def as_dict(self, core_columns_only=False):
_dict[k] = v
if self.resource_group and not core_columns_only:
_dict["package_id"] = self.resource_group.package_id
+ import ckan.model as model
+ tracking = model.TrackingSummary.get_for_resource(self.url)
+ _dict['tracking_summary'] = tracking
return _dict
@classmethod
diff --git a/ckan/model/tracking.py b/ckan/model/tracking.py
new file mode 100644
index 00000000000..384f84bb299
--- /dev/null
+++ b/ckan/model/tracking.py
@@ -0,0 +1,38 @@
+from meta import *
+from domain_object import DomainObject
+
+tracking_summary_table = Table('tracking_summary', metadata,
+ Column('url', UnicodeText, primary_key=True, nullable=False),
+ Column('package_id', UnicodeText),
+ Column('tracking_type', Unicode(10), nullable=False),
+ Column('count', Integer, nullable=False),
+ Column('running_total', Integer, nullable=False),
+ Column('recent_views', Integer, nullable=False),
+ Column('tracking_date', DateTime),
+ )
+
+class TrackingSummary(DomainObject):
+
+ @classmethod
+ def get_for_package(cls, package_id):
+ obj = Session.query(cls).autoflush(False)
+ obj = obj.filter_by(package_id=package_id)
+ data = obj.order_by('tracking_date desc').first()
+ if data:
+ return {'total' : data.running_total,
+ 'recent': data.recent_views}
+
+ return {'total' : 0, 'recent' : 0}
+
+
+ @classmethod
+ def get_for_resource(cls, url):
+ obj = Session.query(cls).autoflush(False)
+ data = obj.filter_by(url=url).order_by('tracking_date desc').first()
+ if data:
+ return {'total' : data.running_total,
+ 'recent': data.recent_views}
+
+ return {'total' : 0, 'recent' : 0}
+
+mapper(TrackingSummary, tracking_summary_table)
diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py
index ee092d79ae2..bc2eb889f60 100644
--- a/ckan/plugins/interfaces.py
+++ b/ckan/plugins/interfaces.py
@@ -13,7 +13,9 @@
'IPackageController', 'IPluginObserver',
'IConfigurable', 'IConfigurer', 'IAuthorizer',
'IActions', 'IResourceUrlChange', 'IDatasetForm',
- 'IGroupForm', 'ITemplateHelpers',
+ 'IGroupForm',
+ 'ITagController',
+ 'ITemplateHelpers',
]
from inspect import isclass
@@ -181,6 +183,21 @@ class IResourceUrlChange(Interface):
def notify(self, resource):
pass
+class ITagController(Interface):
+ '''
+ Hook into the Tag controller. These will usually be called just before
+ committing or returning the respective object, i.e. all validation,
+ synchronization and authorization setup are complete.
+
+ '''
+ def before_view(self, tag_dict):
+ '''
+ Extensions will recieve this before the tag gets displayed. The
+ dictionary passed will be the one that gets sent to the template.
+
+ '''
+ return tag_dict
+
class IGroupController(Interface):
"""
Hook into the Group controller. These will
diff --git a/ckan/plugins/toolkit.py b/ckan/plugins/toolkit.py
index 3eb898371a0..fa1f2c38cb0 100644
--- a/ckan/plugins/toolkit.py
+++ b/ckan/plugins/toolkit.py
@@ -40,7 +40,7 @@ class _Toolkit(object):
'literal', # stop tags in a string being escaped
'get_action', # get logic action function
'check_access', # check logic function authorisation
- 'ActionNotFound', # action not found exception (ckan.logic.NotFound)
+ 'ObjectNotFound', # action not found exception (ckan.logic.NotFound)
'NotAuthorized', # action not authorized exception
'ValidationError', # model update validation error
'CkanCommand', # class for providing cli interfaces
@@ -85,7 +85,7 @@ def _initialize(self):
t['get_action'] = logic.get_action
t['check_access'] = logic.check_access
- t['ActionNotFound'] = logic.NotFound ## Name change intentional
+ t['ObjectNotFound'] = logic.NotFound ## Name change intentional
t['NotAuthorized'] = logic.NotAuthorized
t['ValidationError'] = logic.ValidationError
@@ -183,6 +183,8 @@ def __getattr__(self, name):
if name in self._toolkit:
return self._toolkit[name]
else:
+ if name == '__bases__':
+ return self.__class__.__bases__
raise Exception('`%s` not found in plugins toolkit' % name)
toolkit = _Toolkit()
diff --git a/ckan/public/css/style.css b/ckan/public/css/style.css
index 981156e68a8..7e9e6093173 100644
--- a/ckan/public/css/style.css
+++ b/ckan/public/css/style.css
@@ -1,23 +1,23 @@
body.no-sidebar .sidebar-outer { display: none; }
body.no-sidebar #content { padding-right: 0; border-right: none; }
-body.no-sidebar .content-outer {
- width: 940px;
+body.no-sidebar .content-outer {
+ width: 940px;
}
.header.outer {
background-color: #e2e2e2;
- background-image: -webkit-gradient(linear, left top, left bottom, from(#e2e2e2), to(#cccccc));
- background-image: -webkit-linear-gradient(top, #e2e2e2, #cccccc);
- background-image: -moz-linear-gradient(top, #e2e2e2, #cccccc);
- background-image: -ms-linear-gradient(top, #e2e2e2, #cccccc);
- background-image: -o-linear-gradient(top, #e2e2e2, #cccccc);
+ background-image: -webkit-gradient(linear, left top, left bottom, from(#e2e2e2), to(#cccccc));
+ background-image: -webkit-linear-gradient(top, #e2e2e2, #cccccc);
+ background-image: -moz-linear-gradient(top, #e2e2e2, #cccccc);
+ background-image: -ms-linear-gradient(top, #e2e2e2, #cccccc);
+ background-image: -o-linear-gradient(top, #e2e2e2, #cccccc);
background-image: linear-gradient(top, #e2e2e2, #cccccc);
- filter: progid:DXImageTransform.Microsoft.gradient(startColorStr='#e2e2e2', EndColorStr='#cccccc');
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorStr='#e2e2e2', EndColorStr='#cccccc');
margin-bottom: 18px;
- -moz-box-shadow: 0px 2px 15px #dddddd;
- -webkit-box-shadow: 0px 2px 15px #dddddd;
- box-shadow: 0px 2px 15px #dddddd;
+ -moz-box-shadow: 0px 2px 15px #dddddd;
+ -webkit-box-shadow: 0px 2px 15px #dddddd;
+ box-shadow: 0px 2px 15px #dddddd;
border-bottom: 1px solid #ccc;
}
@@ -84,22 +84,22 @@ header .search {
padding: 0.4em;
font-weight: bold;
- -moz-border-radius: 5px;
- -webkit-border-radius: 5px;
- border-radius: 5px;
- -moz-background-clip: padding; -webkit-background-clip: padding-box; background-clip: padding-box;
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ border-radius: 5px;
+ -moz-background-clip: padding; -webkit-background-clip: padding-box; background-clip: padding-box;
}
.footer.outer {
border-top: 2px solid #ccc;
background-color: #dbdbdb;
- background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#ffffff));
- background-image: -webkit-linear-gradient(top, #dbdbdb, #ffffff);
- background-image: -moz-linear-gradient(top, #dbdbdb, #ffffff);
- background-image: -ms-linear-gradient(top, #dbdbdb, #ffffff);
- background-image: -o-linear-gradient(top, #dbdbdb, #ffffff);
+ background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#ffffff));
+ background-image: -webkit-linear-gradient(top, #dbdbdb, #ffffff);
+ background-image: -moz-linear-gradient(top, #dbdbdb, #ffffff);
+ background-image: -ms-linear-gradient(top, #dbdbdb, #ffffff);
+ background-image: -o-linear-gradient(top, #dbdbdb, #ffffff);
background-image: linear-gradient(top, #dbdbdb, #ffffff);
- fromilter: progid:DXImageTransform.Microsoft.gradient(startColorStr='#dbdbdb', EndColorStr='#ffffff');
+ fromilter: progid:DXImageTransform.Microsoft.gradient(startColorStr='#dbdbdb', EndColorStr='#ffffff');
}
html, body {
@@ -134,7 +134,7 @@ footer h3 {
}
h1, h2, h3, h4, h5 {
- font-family: 'Ubuntu', Georgia;
+ font-family: 'Ubuntu', Georgia;
font-weight: normal;
margin-bottom: 10px;
}
@@ -148,6 +148,10 @@ a:hover {
color: #183661;
}
+a.btn-primary:visited {
+ color: #fff;
+}
+
label.control-label {
font-weight: bold;
}
@@ -160,16 +164,16 @@ label.control-label {
table th {
border: 1px solid #e0e0e0;
background-color: #e2e2e2;
- background-image: -webkit-gradient(linear, left top, left bottom, from(#f0f0f0), to(#e2e2e2));
- background-image: -webkit-linear-gradient(top, #f0f0f0, #e2e2e2);
- background-image: -moz-linear-gradient(top, #f0f0f0, #e2e2e2);
- background-image: -ms-linear-gradient(top, #f0f0f0, #e2e2e2);
- background-image: -o-linear-gradient(top, #f0f0f0, #e2e2e2);
+ background-image: -webkit-gradient(linear, left top, left bottom, from(#f0f0f0), to(#e2e2e2));
+ background-image: -webkit-linear-gradient(top, #f0f0f0, #e2e2e2);
+ background-image: -moz-linear-gradient(top, #f0f0f0, #e2e2e2);
+ background-image: -ms-linear-gradient(top, #f0f0f0, #e2e2e2);
+ background-image: -o-linear-gradient(top, #f0f0f0, #e2e2e2);
background-image: linear-gradient(top, #f0f0f0, #e2e2e2);
filter: progid:DXImageTransform.Microsoft.gradient(startColorStr='#f0f0f0', EndColorStr='#e2e2e2');
}
table caption {
- caption-side: bottom;
+ caption-side: bottom;
color: #888;
font-size: 0.9em;
background-color: white;
@@ -241,10 +245,10 @@ img.gravatar {
vertical-align: top;
}
-.drag-drop-list {
- list-style-type: none;
- margin: 0;
- padding: 0;
+.drag-drop-list {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
}
.drag-drop-list li {
margin-bottom: 3px;
@@ -269,18 +273,18 @@ ul.no-break li {
margin: 0 0 1em 0 ;
border: 1px solid #e0e0e0;
background-color: #e2e2e2;
- background-image: -webkit-gradient(linear, left top, left bottom, from(#f0f0f0), to(#e2e2e2));
- background-image: -webkit-linear-gradient(top, #f0f0f0, #e2e2e2);
- background-image: -moz-linear-gradient(top, #f0f0f0, #e2e2e2);
- background-image: -ms-linear-gradient(top, #f0f0f0, #e2e2e2);
- background-image: -o-linear-gradient(top, #f0f0f0, #e2e2e2);
+ background-image: -webkit-gradient(linear, left top, left bottom, from(#f0f0f0), to(#e2e2e2));
+ background-image: -webkit-linear-gradient(top, #f0f0f0, #e2e2e2);
+ background-image: -moz-linear-gradient(top, #f0f0f0, #e2e2e2);
+ background-image: -ms-linear-gradient(top, #f0f0f0, #e2e2e2);
+ background-image: -o-linear-gradient(top, #f0f0f0, #e2e2e2);
background-image: linear-gradient(top, #f0f0f0, #e2e2e2);
filter: progid:DXImageTransform.Microsoft.gradient(startColorStr='#f0f0f0', EndColorStr='#e2e2e2');
- -moz-border-radius: 5px;
- -webkit-border-radius: 5px;
- border-radius: 5px;
- -moz-background-clip: padding; -webkit-background-clip: padding-box; background-clip: padding-box;
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ border-radius: 5px;
+ -moz-background-clip: padding; -webkit-background-clip: padding-box; background-clip: padding-box;
padding: 2px 4px;
font-weight: bold;
}
@@ -348,7 +352,7 @@ ul.no-break li {
#sidebar {
overflow: hidden;
}
-#sidebar h2,
+#sidebar h2,
#sidebar h3 {
font-size: 1.3em;
}
@@ -366,9 +370,9 @@ ul.no-break li {
background-color: #FFF7C0;
padding: 15px;
padding-top: 10px;
- -moz-border-radius: 15px;
- -webkit-border-radius: 15px;
- border-radius: 15px;
+ -moz-border-radius: 15px;
+ -webkit-border-radius: 15px;
+ border-radius: 15px;
}
@@ -411,7 +415,7 @@ ul.no-break li {
}
.facet-box .facet-options li {
padding-top: 0.2em;
- color: #000;
+ color: #000;
}
@@ -539,7 +543,7 @@ body.index.home .front-page .group strong {
/* = Login Form = */
/* ============== */
form.simple-form label {
- display: inline-block;
+ display: inline-block;
float: left;
min-width: 40%;
padding-top: 0.5em;
@@ -548,7 +552,7 @@ form.simple-form label {
form.simple-form input[type=text],
form.simple-form input[type=password] {
border: 1px solid #E7E7E7;
- padding: 0.5em;
+ padding: 0.5em;
width: 40%;
margin-bottom: 1em
}
@@ -593,7 +597,7 @@ form.simple-form input[type=password] {
/* = User Index = */
/* ============== */
-ul.userlist,
+ul.userlist,
ul.userlist ul {
list-style-type: none;
margin: 0;
@@ -631,9 +635,9 @@ ul.userlist .badge {
/* ================== */
body.user.read #sidebar { display: none; }
-body.user.read #content {
- border-right: 0;
- width: 950px;
+body.user.read #content {
+ border-right: 0;
+ width: 950px;
}
.user.read .page_heading {
@@ -738,10 +742,10 @@ input.search {
border: 1px solid #ccc;
padding: 0.5em;
font-weight: bold;
- -moz-border-radius: 5px;
- -webkit-border-radius: 5px;
- border-radius: 5px;
- -moz-background-clip: padding; -webkit-background-clip: padding-box; background-clip: padding-box;
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ border-radius: 5px;
+ -moz-background-clip: padding; -webkit-background-clip: padding-box; background-clip: padding-box;
}
.dataset-search input.button {
display: inline-block;
@@ -785,7 +789,7 @@ ul.datasets .search_meta {
}
ul.datasets ul.dataset_formats {
float: right;
- padding: 0 0 3px 0;
+ padding: 0 0 3px 0;
margin: 0;
font-family: monospace;
}
@@ -810,7 +814,7 @@ ul.datasets .openness img {
ul.datasets .openness li {
margin:0;
padding:0;
- border:none;
+ border:none;
}
@@ -1018,14 +1022,14 @@ body.package.read .related-datasets li {
}
img.open-data { margin: 1px 0 0 8px; vertical-align: top; }
-#dataset-resources {
- margin-top: 2em;
- margin-bottom: 2em;
+#dataset-resources {
+ margin-top: 2em;
+ margin-bottom: 2em;
}
body.package.read h3 {
margin-bottom: 8px;
}
-.search-result {
+.search-result {
border-left: 2px solid #eee;
margin: 0;
padding: 8px;
@@ -1059,14 +1063,14 @@ body.package.read h3 {
}
.search-result .result-url,
-.search-result .result-url a {
+.search-result .result-url a {
color: #888;
}
.search-result .result-lrl:hover,
-.search-result .result-url:hover a {
+.search-result .result-url:hover a {
color: #333;
}
-.search-result .result-url:hover a {
+.search-result .result-url:hover a {
text-decoration: underline;
}
.search-result .result-url img {
@@ -1137,9 +1141,9 @@ body.package.read #sidebar li.widget-container {
/* ====================== */
body.package.resource_read #sidebar { display: none; }
-body.package.resource_read #content {
- border-right: 0;
- width: 950px;
+body.package.resource_read #content {
+ border-right: 0;
+ width: 950px;
}
.resource_read .notes {
@@ -1185,7 +1189,7 @@ body.about #content { border-right: 0; }
/* ============== */
/* = Admin Page = */
/* ============== */
-body.admin form#form-purge-packages,
+body.admin form#form-purge-packages,
body.admin form#form-purge-revisions {
margin-bottom: 30px;
text-align: right;
@@ -1403,3 +1407,73 @@ body.editresources .error-explanation {
/* Let JS render the resource errors inline */
display: none;
}
+
+/* Modal Dialog Styles */
+
+.modal-header .heading {
+ margin-bottom: 0;
+}
+
+.modal-body form {
+ margin-bottom: 0;
+}
+
+/* Chosen Form Styles */
+
+.chzn-container-single {
+ margin-bottom: 9px; /* Keep Chosen inline with Bootstrap */
+}
+
+.form-inline .chzn-container-single,
+.form-horizontal .chzn-select {
+ margin-bottom: 0;
+}
+
+.required {
+ color: #808080;
+}
+
+.thumbnails li:nth-of-type(5n) {
+ clear: left;
+}
+
+.thumbnail .heading {
+ font-weight: bold;
+}
+
+.thumbnail .image {
+ display: block;
+ width: 210px;
+ height: 180px;
+ overflow: hidden;
+ background: #ececec;
+ border: 1px solid #dedede;
+ margin: -1px;
+}
+
+.thumbnail .image img {
+ max-width: 100%;
+ width: auto;
+ height: auto;
+}
+
+.thumbnail .empty {
+ color: #ccc;
+}
+
+.thumbnail .read-more {
+ margin-top: 10px;
+ margin-bottom: 0;
+}
+
+.no-related-items {
+ font-size: 16px;
+ color: #666;
+ background: #EBEBEB;
+ border: 1px solid #DBDBDB;
+ padding: 20px;
+ border-radius: 5px;
+ margin: 40px auto;
+ float: none;
+ text-align: center;
+}
diff --git a/ckan/public/images/photo-placeholder.png b/ckan/public/images/photo-placeholder.png
new file mode 100644
index 00000000000..efdea70ea20
Binary files /dev/null and b/ckan/public/images/photo-placeholder.png differ
diff --git a/ckan/public/scripts/application.js b/ckan/public/scripts/application.js
index 995e7033362..0d3628ebf76 100644
--- a/ckan/public/scripts/application.js
+++ b/ckan/public/scripts/application.js
@@ -9,6 +9,7 @@ CKAN.Utils = CKAN.Utils || {};
/* ================================= */
(function ($) {
$(document).ready(function () {
+ CKAN.Utils.relatedSetup($("#form-add-related"));
CKAN.Utils.setupUserAutocomplete($('input.autocomplete-user'));
CKAN.Utils.setupOrganizationUserAutocomplete($('input.autocomplete-organization-user'));
CKAN.Utils.setupGroupAutocomplete($('input.autocomplete-group'));
@@ -44,6 +45,12 @@ CKAN.Utils = CKAN.Utils || {};
if (isResourceView) {
CKAN.DataPreview.loadPreviewDialog(preload_resource);
}
+
+ var isEmbededDataviewer = $('body.package.resource_embedded_dataviewer').length > 0;
+ if (isEmbededDataviewer) {
+ CKAN.DataPreview.loadEmbeddedPreview(preload_resource, reclineState);
+ }
+
var isDatasetNew = $('body.package.new').length > 0;
if (isDatasetNew) {
// Set up magic URL slug editor
@@ -119,6 +126,32 @@ CKAN.Utils = CKAN.Utils || {};
});
}(jQuery));
+/* =============================== */
+/* jQuery Plugins */
+/* =============================== */
+
+jQuery.fn.truncate = function (max, suffix) {
+ return this.each(function () {
+ var element = jQuery(this),
+ cached = element.text(),
+ length = max || element.data('truncate') || 30,
+ text = cached.slice(0, length),
+ expand = jQuery('').text(suffix || '»');
+
+ // Try to truncate to nearest full word.
+ while ((/\S/).test(text[text.length - 1])) {
+ text = text.slice(0, text.length - 1);
+ }
+
+ element.html(jQuery.trim(text));
+
+ expand.appendTo(element.append(' '));
+ expand.click(function (event) {
+ event.preventDefault();
+ element.text(cached);
+ });
+ });
+};
/* =============================== */
/* Backbone Model: Resource object */
@@ -638,6 +671,7 @@ CKAN.View.Resource = Backbone.View.extend({
word=='format' ||
word=='hash' ||
word=='id' ||
+ word=='created' ||
word=='last_modified' ||
word=='mimetype' ||
word=='mimetype_inner' ||
@@ -692,7 +726,7 @@ CKAN.View.ResourceAddUpload = Backbone.View.extend({
setupFileUpload: function() {
var self = this;
this.el.find('.fileupload').fileupload({
- // needed because we are posting to remote url
+ // needed because we are posting to remote url
forceIframeTransport: true,
replaceFileInput: false,
autoUpload: false,
@@ -725,7 +759,7 @@ CKAN.View.ResourceAddUpload = Backbone.View.extend({
},
// Create an upload key/label for this file.
- //
+ //
// Form: {current-date}/file-name. Do not just use the file name as this
// would lead to collisions.
// (Could add userid/username and/or a small random string to reduce
@@ -790,7 +824,7 @@ CKAN.View.ResourceAddUpload = Backbone.View.extend({
newResource.set({
url: data._location
, name: name
- , size: data._content_length
+ , size: data._content_length
, last_modified: lastmod
, format: data._format
, mimetype: data._format
@@ -873,7 +907,7 @@ CKAN.View.ResourceAddUrl = Backbone.View.extend({
self.resetForm();
}
});
- }
+ }
else {
newResource.set({url: urlVal, resource_type: this.options.mode});
if (newResource.get('resource_type')=='file') {
@@ -958,7 +992,6 @@ CKAN.Utils = function($, my) {
, select: function(event, ui) {
var input_box = $(this);
input_box.val('');
- var parent_dd = input_box.parent('dd');
var old_name = input_box.attr('name');
var field_name_regex = /^(\S+)__(\d+)__(\S+)$/;
var split = old_name.match(field_name_regex);
@@ -967,10 +1000,15 @@ CKAN.Utils = function($, my) {
input_box.attr('name', new_name);
input_box.attr('id', new_name);
-
- parent_dd.before(
- '' + '
' + ui.item.label + ''
- );
+
+ var $new = $('');
+ $new.append($('').attr('name', old_name).val(ui.item.value));
+ $new.append(' ');
+ $new.append(ui.item.label);
+ input_box.after($new);
+
+ // prevent setting value in autocomplete box
+ return false;
}
});
};
@@ -1101,6 +1139,68 @@ CKAN.Utils = function($, my) {
});
};
+
+ my.relatedSetup = function(form) {
+ function addAlert(msg) {
+ $('').html(msg).hide().prependTo(form).fadeIn();
+ }
+
+ // Center thumbnails vertically.
+ $('.related-items').each(function () {
+ var item = $(this);
+
+ function vertiallyAlign() {
+ var img = $(this),
+ height = img.height(),
+ parent = img.parent().height(),
+ top = (height - parent) / 2;
+
+ if (parent < height) {
+ img.css('margin-top', -top);
+ }
+ }
+
+ item.find('img').load(vertiallyAlign);
+ item.find('.description').truncate();
+ });
+
+ $(form).submit(function (event) {
+ event.preventDefault();
+
+ // Validate the form
+ var form = $(this), data = {};
+ jQuery.each(form.serializeArray(), function () {
+ data[this.name] = this.value;
+ });
+
+ form.find('.alert').remove();
+ form.find('.error').removeClass('error');
+ if (!data.title) {
+ addAlert('Missing field: A title is required');
+ $('[name=title]').parent().addClass('error');
+ return;
+ }
+ if (!data.url) {
+ addAlert('Missing field: A url is required');
+ $('[name=url]').parent().addClass('error');
+ return;
+ }
+
+ $.ajax({
+ type: this.method,
+ url: CKAN.SITE_URL + '/api/3/action/related_create',
+ data: JSON.stringify(data),
+ success: function (data) {
+ window.location.reload();
+ },
+ error: function(err, txt, w) {
+ // This needs to be far more informative.
+ addAlert('Error: Unable to add related item');
+ }
+ });
+ });
+ };
+
// Attach authz group autocompletion to provided elements
//
// Requires: jquery-ui autocomplete
@@ -1250,6 +1350,81 @@ CKAN.DataPreview = function ($, my) {
my.dialogId = 'ckanext-datapreview';
my.$dialog = $('#' + my.dialogId);
+ // **Public: Loads a data previewer for an embedded page**
+ //
+ // Uses the provided reclineState to restore the Dataset. Creates a single
+ // view for the Dataset (the one defined by reclineState.currentView). And
+ // then passes the constructed Dataset, the constructed View, and the
+ // reclineState into the DataExplorer constructor.
+ my.loadEmbeddedPreview = function(resourceData, reclineState) {
+ my.$dialog.html('Loading ...
');
+
+ // Restore the Dataset from the given reclineState.
+ var dataset = recline.Model.Dataset.restore(reclineState);
+
+ // Only create the view defined in reclineState.currentView.
+ // TODO: tidy this up.
+ var views = null;
+ if (reclineState.currentView === 'grid') {
+ views = [ {
+ id: 'grid',
+ label: 'Grid',
+ view: new recline.View.Grid({
+ model: dataset,
+ state: reclineState['view-grid']
+ })
+ }];
+ } else if (reclineState.currentView === 'graph') {
+ views = [ {
+ id: 'graph',
+ label: 'Graph',
+ view: new recline.View.Graph({
+ model: dataset,
+ state: reclineState['view-graph']
+ })
+ }];
+ } else if (reclineState.currentView === 'map') {
+ views = [ {
+ id: 'map',
+ label: 'Map',
+ view: new recline.View.Map({
+ model: dataset,
+ state: reclineState['view-map']
+ })
+ }];
+ }
+
+ // Finally, construct the DataExplorer. Again, passing in the reclineState.
+ var dataExplorer = new recline.View.DataExplorer({
+ el: my.$dialog,
+ model: dataset,
+ state: reclineState,
+ views: views
+ });
+
+ Backbone.history.start();
+ };
+
+ // **Public: Creates a link to the embeddable page.
+ //
+ // For a given DataExplorer state, this function constructs and returns the
+ // url to the embeddable view of the current dataexplorer state.
+ my.makeEmbedLink = function(explorerState) {
+ var state = explorerState.toJSON();
+ state.state_version = 1;
+
+ var queryString = '?';
+ var items = [];
+ $.each(state, function(key, value) {
+ if (typeof(value) === 'object') {
+ value = JSON.stringify(value);
+ }
+ items.push(key + '=' + escape(value));
+ });
+ queryString += items.join('&');
+ return embedPath + queryString;
+ };
+
// **Public: Loads a data preview**
//
// Fetches the preview data object from the link provided and loads the
@@ -1267,14 +1442,14 @@ CKAN.DataPreview = function ($, my) {
{
id: 'grid',
label: 'Grid',
- view: new recline.View.DataGrid({
+ view: new recline.View.Grid({
model: dataset
})
},
{
id: 'graph',
label: 'Graph',
- view: new recline.View.FlotGraph({
+ view: new recline.View.Graph({
model: dataset
})
},
@@ -1294,6 +1469,58 @@ CKAN.DataPreview = function ($, my) {
readOnly: true
}
});
+
+ // -----------------------------
+ // Setup the Embed modal dialog.
+ // -----------------------------
+
+ // embedLink holds the url to the embeddable view of the current DataExplorer state.
+ var embedLink = $('.embedLink');
+
+ // embedIframeText contains the '