[ckan-changes] [okfn/ckan] 14457b: Merge branch 'master' of https://github.com/okfn/c...
GitHub
noreply at github.com
Thu Apr 26 15:13:24 UTC 2012
Branch: refs/heads/master
Home: https://github.com/okfn/ckan
Commit: 14457b10fe3feee472faa01487e8aff1acb3838a
https://github.com/okfn/ckan/commit/14457b10fe3feee472faa01487e8aff1acb3838a
Author: Ross Jones <rossdjones at gmail.com>
Date: 2012-04-25 (Wed, 25 Apr 2012)
Log Message:
-----------
Merge branch 'master' of https://github.com/okfn/ckan
================================================================
Commit: 05de104fb72c4c6c3dc8b2aef51ce8215a977332
https://github.com/okfn/ckan/commit/05de104fb72c4c6c3dc8b2aef51ce8215a977332
Author: Ross Jones <rossdjones at gmail.com>
Date: 2012-04-26 (Thu, 26 Apr 2012)
Changed paths:
M ckan/__init__.py
M ckan/config/middleware.py
M ckan/config/routing.py
M ckan/config/solr/CHANGELOG.txt
A ckan/config/solr/schema-1.4.xml
M ckan/controllers/package.py
M ckan/lib/cli.py
M ckan/lib/dictization/model_dictize.py
M ckan/lib/dictization/model_save.py
M ckan/lib/search/__init__.py
M ckan/lib/search/index.py
M ckan/logic/schema.py
A ckan/migration/versions/057_tracking.py
M ckan/model/__init__.py
M ckan/model/package.py
M ckan/model/resource.py
A ckan/model/tracking.py
M ckan/public/scripts/application.js
M ckan/public/scripts/vendor/recline/css/data-explorer.css
R ckan/public/scripts/vendor/recline/css/graph-flot.css
A ckan/public/scripts/vendor/recline/css/graph.css
A ckan/public/scripts/vendor/recline/css/grid.css
M ckan/public/scripts/vendor/recline/css/map.css
M ckan/public/scripts/vendor/recline/recline.js
M ckan/templates/group/read.html
M ckan/templates/layout_base.html
M ckan/templates/package/layout.html
A ckan/templates/package/resource_embedded_dataviewer.html
M ckan/templates/package/resource_read.html
M ckan/templates/package/search.html
M ckan/templates/package/search_form.html
A ckan/templates/snippets/data-viewer-embed-branded-link.html
A ckan/templates/snippets/data-viewer-embed-dialog.html
M ckan/tests/functional/test_pagination.py
M ckan/tests/functional/test_search.py
M ckan/tests/lib/test_dictization.py
M ckan/tests/lib/test_resource_search.py
M ckan/tests/lib/test_solr_package_search.py
M ckanext/multilingual/solr/schema.xml
M doc/index.rst
M doc/using-data-api.rst
M pip-requirements.txt
M setup.py
Log Message:
-----------
Merge branch 'master' of https://github.com/okfn/ckan
diff --git a/ckan/__init__.py b/ckan/__init__.py
index b433319..b1b1544 100644
--- a/ckan/__init__.py
+++ b/ckan/__init__.py
@@ -1,4 +1,4 @@
-__version__ = '1.6.1b'
+__version__ = '1.8a'
__description__ = 'Comprehensive Knowledge Archive Network (CKAN) Software'
__long_description__ = \
'''CKAN software provides a hub for datasets. The flagship site running CKAN
diff --git a/ckan/config/middleware.py b/ckan/config/middleware.py
index c07948a..5d2be57 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 d67727d..6c03cb0 100644
--- a/ckan/config/routing.py
+++ b/ckan/config/routing.py
@@ -189,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')
diff --git a/ckan/config/solr/CHANGELOG.txt b/ckan/config/solr/CHANGELOG.txt
index 5fe664f..1e4e67f 100644
--- a/ckan/config/solr/CHANGELOG.txt
+++ b/ckan/config/solr/CHANGELOG.txt
@@ -1,6 +1,14 @@
CKAN SOLR schemas changelog
===========================
+v1.4 - (ckan>=1.7)
+--------------------
+* Add Ascii folding filter to text fields.
+* Add capacity field for public, private access.
+* Add title_string so you can sort alphabetically on title.
+* Fields related to analytics, access and view counts.
+* Add data_dict field for the whole package_dict.
+
v1.3 - (ckan>=1.5.1)
--------------------
* Use the index_id (hash of dataset id + site_id) as uniqueKey (#1430)
diff --git a/ckan/config/solr/schema-1.4.xml b/ckan/config/solr/schema-1.4.xml
new file mode 100644
index 0000000..0409e71
--- /dev/null
+++ b/ckan/config/solr/schema-1.4.xml
@@ -0,0 +1,180 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<schema name="ckan" version="1.4">
+
+<types>
+ <fieldType name="string" class="solr.StrField" sortMissingLast="true" omitNorms="true"/>
+ <fieldType name="boolean" class="solr.BoolField" sortMissingLast="true" omitNorms="true"/>
+ <fieldtype name="binary" class="solr.BinaryField"/>
+ <fieldType name="int" class="solr.TrieIntField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+ <fieldType name="float" class="solr.TrieFloatField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+ <fieldType name="long" class="solr.TrieLongField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+ <fieldType name="double" class="solr.TrieDoubleField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+ <fieldType name="tint" class="solr.TrieIntField" precisionStep="8" omitNorms="true" positionIncrementGap="0"/>
+ <fieldType name="tfloat" class="solr.TrieFloatField" precisionStep="8" omitNorms="true" positionIncrementGap="0"/>
+ <fieldType name="tlong" class="solr.TrieLongField" precisionStep="8" omitNorms="true" positionIncrementGap="0"/>
+ <fieldType name="tdouble" class="solr.TrieDoubleField" precisionStep="8" omitNorms="true" positionIncrementGap="0"/>
+ <fieldType name="date" class="solr.TrieDateField" omitNorms="true" precisionStep="0" positionIncrementGap="0"/>
+ <fieldType name="tdate" class="solr.TrieDateField" omitNorms="true" precisionStep="6" positionIncrementGap="0"/>
+
+ <fieldType name="text" class="solr.TextField" positionIncrementGap="100">
+ <analyzer type="index">
+ <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+ <!-- in this example, we will only use synonyms at query time
+ <filter class="solr.SynonymFilterFactory" synonyms="index_synonyms.txt" ignoreCase="true" expand="false"/>
+ -->
+ <!-- Case insensitive stop word removal.
+ add enablePositionIncrements=true in both the index and query
+ analyzers to leave a 'gap' for more accurate phrase queries.
+ -->
+ <filter class="solr.StopFilterFactory"
+ ignoreCase="true"
+ words="stopwords.txt"
+ enablePositionIncrements="true"
+ />
+ <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="1" catenateNumbers="1" catenateAll="0" splitOnCaseChange="1"/>
+ <filter class="solr.LowerCaseFilterFactory"/>
+ <filter class="solr.SnowballPorterFilterFactory" language="English" protected="protwords.txt"/>
+ <filter class="solr.ASCIIFoldingFilterFactory"/>
+ </analyzer>
+ <analyzer type="query">
+ <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+ <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="true"/>
+ <filter class="solr.StopFilterFactory"
+ ignoreCase="true"
+ words="stopwords.txt"
+ enablePositionIncrements="true"
+ />
+ <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="0" catenateNumbers="0" catenateAll="0" splitOnCaseChange="1"/>
+ <filter class="solr.LowerCaseFilterFactory"/>
+ <filter class="solr.SnowballPorterFilterFactory" language="English" protected="protwords.txt"/>
+ <filter class="solr.ASCIIFoldingFilterFactory"/>
+ </analyzer>
+ </fieldType>
+
+
+ <!-- A general unstemmed text field - good if one does not know the language of the field -->
+ <fieldType name="textgen" class="solr.TextField" positionIncrementGap="100">
+ <analyzer type="index">
+ <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+ <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" enablePositionIncrements="true" />
+ <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="1" catenateNumbers="1" catenateAll="0" splitOnCaseChange="0"/>
+ <filter class="solr.LowerCaseFilterFactory"/>
+ </analyzer>
+ <analyzer type="query">
+ <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+ <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="true"/>
+ <filter class="solr.StopFilterFactory"
+ ignoreCase="true"
+ words="stopwords.txt"
+ enablePositionIncrements="true"
+ />
+ <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="0" catenateNumbers="0" catenateAll="0" splitOnCaseChange="0"/>
+ <filter class="solr.LowerCaseFilterFactory"/>
+ </analyzer>
+ </fieldType>
+</types>
+
+
+<fields>
+ <field name="index_id" type="string" indexed="true" stored="true" required="true" />
+ <field name="id" type="string" indexed="true" stored="true" required="true" />
+ <field name="site_id" type="string" indexed="true" stored="true" required="true" />
+ <field name="title" type="text" indexed="true" stored="true" />
+ <field name="entity_type" type="string" indexed="true" stored="true" omitNorms="true" />
+ <field name="state" type="string" indexed="true" stored="true" omitNorms="true" />
+ <field name="name" type="string" indexed="true" stored="true" omitNorms="true" />
+ <field name="revision_id" type="string" indexed="true" stored="true" omitNorms="true" />
+ <field name="version" type="string" indexed="true" stored="true" />
+ <field name="url" type="string" indexed="true" stored="true" omitNorms="true" />
+ <field name="ckan_url" type="string" indexed="true" stored="true" omitNorms="true" />
+ <field name="download_url" type="string" indexed="true" stored="true" omitNorms="true" />
+ <field name="notes" type="text" indexed="true" stored="true"/>
+ <field name="author" type="textgen" indexed="true" stored="true" />
+ <field name="author_email" type="textgen" indexed="true" stored="true" />
+ <field name="maintainer" type="textgen" indexed="true" stored="true" />
+ <field name="maintainer_email" type="textgen" indexed="true" stored="true" />
+ <field name="license" type="string" indexed="true" stored="true" />
+ <field name="license_id" type="string" indexed="true" stored="true" />
+ <field name="ratings_count" type="int" indexed="true" stored="false" />
+ <field name="ratings_average" type="float" indexed="true" stored="false" />
+ <field name="tags" type="string" indexed="true" stored="true" multiValued="true"/>
+ <field name="groups" type="string" indexed="true" stored="true" multiValued="true"/>
+
+ <field name="capacity" type="string" indexed="true" stored="true" multiValued="false"/>
+
+ <field name="res_description" type="textgen" indexed="true" stored="true" multiValued="true"/>
+ <field name="res_format" type="string" indexed="true" stored="true" multiValued="true"/>
+ <field name="res_url" type="string" indexed="true" stored="true" multiValued="true"/>
+
+ <!-- catchall field, containing all other searchable text fields (implemented
+ via copyField further on in this schema -->
+ <field name="text" type="text" indexed="true" stored="false" multiValued="true"/>
+ <field name="urls" type="text" indexed="true" stored="false" multiValued="true"/>
+
+ <field name="depends_on" type="text" indexed="true" stored="false" multiValued="true"/>
+ <field name="dependency_of" type="text" indexed="true" stored="false" multiValued="true"/>
+ <field name="derives_from" type="text" indexed="true" stored="false" multiValued="true"/>
+ <field name="has_derivation" type="text" indexed="true" stored="false" multiValued="true"/>
+ <field name="links_to" type="text" indexed="true" stored="false" multiValued="true"/>
+ <field name="linked_from" type="text" indexed="true" stored="false" multiValued="true"/>
+ <field name="child_of" type="text" indexed="true" stored="false" multiValued="true"/>
+ <field name="parent_of" type="text" indexed="true" stored="false" multiValued="true"/>
+ <field name="views_total" type="int" indexed="true" stored="false"/>
+ <field name="views_recent" type="int" indexed="true" stored="false"/>
+ <field name="resources_accessed_total" type="int" indexed="true" stored="false"/>
+ <field name="resources_accessed_recent" type="int" indexed="true" stored="false"/>
+
+ <field name="metadata_created" type="date" indexed="true" stored="true" multiValued="false"/>
+ <field name="metadata_modified" type="date" indexed="true" stored="true" multiValued="false"/>
+
+ <field name="indexed_ts" type="date" indexed="true" stored="true" default="NOW" multiValued="false"/>
+
+ <!-- Copy the title field into titleString, and treat as a string
+ (rather than text type). This allows us to sort on the titleString -->
+ <field name="title_string" type="string" indexed="true" stored="false" />
+
+ <field name="data_dict" type="string" indexed="false" stored="true" />
+
+ <dynamicField name="extras_*" type="text" indexed="true" stored="true" multiValued="false"/>
+ <dynamicField name="*" type="string" indexed="true" stored="false"/>
+</fields>
+
+<uniqueKey>index_id</uniqueKey>
+<defaultSearchField>text</defaultSearchField>
+<solrQueryParser defaultOperator="AND"/>
+
+<copyField source="url" dest="urls"/>
+<copyField source="ckan_url" dest="urls"/>
+<copyField source="download_url" dest="urls"/>
+<copyField source="res_url" dest="urls"/>
+<copyField source="extras_*" dest="text"/>
+<copyField source="urls" dest="text"/>
+<copyField source="name" dest="text"/>
+<copyField source="title" dest="text"/>
+<copyField source="text" dest="text"/>
+<copyField source="license" dest="text"/>
+<copyField source="notes" dest="text"/>
+<copyField source="tags" dest="text"/>
+<copyField source="groups" dest="text"/>
+<copyField source="res_description" dest="text"/>
+<copyField source="maintainer" dest="text"/>
+<copyField source="author" dest="text"/>
+
+</schema>
diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py
index 37d5b31..ba12b86 100644
--- a/ckan/controllers/package.py
+++ b/ckan/controllers/package.py
@@ -751,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/lib/cli.py b/ckan/lib/cli.py
index fd52cc6..d11a1f4 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,6 +880,117 @@ 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.
diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py
index ce48032..75fe85e 100644
--- a/ckan/lib/dictization/model_dictize.py
+++ b/ckan/lib/dictization/model_dictize.py
@@ -48,6 +48,7 @@ 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"])
@@ -95,6 +96,10 @@ 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):
@@ -192,6 +197,9 @@ def package_dictize(pkg, context):
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
diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py
index fe585c4..8001dd4 100644
--- a/ckan/lib/dictization/model_save.py
+++ b/ckan/lib/dictization/model_save.py
@@ -26,7 +26,7 @@ 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):
diff --git a/ckan/lib/search/__init__.py b/ckan/lib/search/__init__.py
index fbb924a..b2774fc 100644
--- a/ckan/lib/search/__init__.py
+++ b/ckan/lib/search/__init__.py
@@ -26,7 +26,7 @@ def text_traceback():
SIMPLE_SEARCH = config.get('ckan.simple_search', False)
-SUPPORTED_SCHEMA_VERSIONS = ['1.3']
+SUPPORTED_SCHEMA_VERSIONS = ['1.4']
DEFAULT_OPTIONS = {
'limit': 20,
diff --git a/ckan/lib/search/index.py b/ckan/lib/search/index.py
index 2599115..992721f 100644
--- a/ckan/lib/search/index.py
+++ b/ckan/lib/search/index.py
@@ -99,6 +99,11 @@ def index_package(self, pkg_dict):
if pkg_dict is None:
return
+ # add to string field for sorting
+ title = pkg_dict.get('title')
+ if title:
+ pkg_dict['title_string'] = title
+
if (not pkg_dict.get('state')) or ('active' not in pkg_dict.get('state')):
return self.delete_package(pkg_dict)
@@ -132,6 +137,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'),
@@ -157,7 +168,7 @@ def index_package(self, pkg_dict):
pkg_dict = dict([(k.encode('ascii', 'ignore'), v) for (k, v) in pkg_dict.items()])
- for k in ('title','notes'):
+ for k in ('title', 'notes', 'title_string'):
if k in pkg_dict and pkg_dict[k]:
pkg_dict[k] = escape_xml_illegal_chars(pkg_dict[k])
diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py
index 72ec17a..09ffe22 100644
--- a/ckan/logic/schema.py
+++ b/ckan/logic/schema.py
@@ -66,6 +66,7 @@ def default_resource_schema():
'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],
}
diff --git a/ckan/migration/versions/057_tracking.py b/ckan/migration/versions/057_tracking.py
new file mode 100644
index 0000000..5f2fe43
--- /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 681e4ca..c49cb1b 100644
--- a/ckan/model/__init__.py
+++ b/ckan/model/__init__.py
@@ -22,6 +22,7 @@
from authz import *
from package_extra import *
from resource import *
+from tracking import *
from rating import *
from package_relationship import *
from task_status import *
diff --git a/ckan/model/package.py b/ckan/model/package.py
index dd1e0d3..0ad8106 100644
--- a/ckan/model/package.py
+++ b/ckan/model/package.py
@@ -171,6 +171,7 @@ def add_tag(self, tag):
package_tag = model.PackageTag(self, tag)
model.Session.add(package_tag)
+
def add_tags(self, tags):
for tag in tags:
self.add_tag(tag)
@@ -274,6 +275,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''):
@@ -562,6 +567,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/resource.py b/ckan/model/resource.py
index f370e02..b1422db 100644
--- a/ckan/model/resource.py
+++ b/ckan/model/resource.py
@@ -109,6 +109,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 0000000..384f84b
--- /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/public/scripts/application.js b/ckan/public/scripts/application.js
index 1c12a81..0d3628e 100644
--- a/ckan/public/scripts/application.js
+++ b/ckan/public/scripts/application.js
@@ -45,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
@@ -1344,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('<h4>Loading ... <img src="http://assets.okfn.org/images/icons/ajaxload-circle.gif" class="loading-spinner" /></h4>');
+
+ // 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
@@ -1361,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
})
},
@@ -1388,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 '<iframe>' construction, which sources
+ // the above link.
+ var embedIframeText = $('.embedIframeText');
+
+ // iframeWidth and iframeHeight control the width and height parameters
+ // used to construct the iframe, and are also used in the link.
+ var iframeWidth = $('.iframe-width');
+ var iframeHeight = $('.iframe-height');
+
+ // Update the embedLink and embedIframeText to contain the updated link
+ // and update width and height parameters.
+ function updateLink() {
+ var link = my.makeEmbedLink(dataExplorer.state);
+ var width = iframeWidth.val();
+ var height = iframeHeight.val();
+ link += '&width='+width+'&height='+height;
+
+ // Escape '"' characters in {{link}} in order not to prematurely close
+ // the src attribute value.
+ embedIframeText.val($.mustache('<iframe frameBorder="0" width="{{width}}" height="{{height}}" src="{{link}}"></iframe>',
+ {
+ link: link.replace(/"/g, '"'),
+ width: width,
+ height: height
+ }));
+ embedLink.attr('href', link);
+ }
+
+ // Bind changes to the DataExplorer, or the two width and height inputs
+ // to re-calculate the url.
+ dataExplorer.state.bind('change', updateLink);
+ for (var i=0; i<dataExplorer.pageViews.length; i++) {
+ dataExplorer.pageViews[i].view.state.bind('change', updateLink);
+ }
+
+ iframeWidth.change(updateLink);
+ iframeHeight.change(updateLink);
+
+ // Initial population of embedLink and embedIframeText
+ updateLink();
+
+ // Finally, since we have a DataExplorer, we can show the embed button.
+ $('.preview-header .btn').show();
+
// will have to refactor if this can get called multiple times
Backbone.history.start();
}
diff --git a/ckan/public/scripts/vendor/recline/css/data-explorer.css b/ckan/public/scripts/vendor/recline/css/data-explorer.css
index 6cc5b0a..766a91e 100644
--- a/ckan/public/scripts/vendor/recline/css/data-explorer.css
+++ b/ckan/public/scripts/vendor/recline/css/data-explorer.css
@@ -78,320 +78,3 @@
display: inline-block;
}
-
-/**********************************************************
- * Data Table
- *********************************************************/
-
-.recline-grid .btn-group .dropdown-toggle {
- padding: 1px 3px;
- line-height: auto;
-}
-
-.recline-grid-container {
- overflow: auto;
- height: 550px;
-}
-
-.recline-grid {
- border: 1px solid #ccc;
- width: 100%;
-}
-
-.recline-grid td, .recline-grid th {
- border-left: 1px solid #ccc;
- padding: 3px 4px;
- text-align: left;
-}
-
-.recline-grid tr td:first-child, .recline-grid tr th:first-child {
- width: 20px;
-}
-
-/* direct borrowing from twitter buttons */
-.recline-grid th,
-.transform-column-view .expression-preview-table-wrapper th
-{
- background-color: #e6e6e6;
- background-repeat: no-repeat;
- background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));
- background-image: -webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
- background-image: -moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);
- background-image: -ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
- background-image: -o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
- background-image: linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
- filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);
- text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
- color: #333;
- border: 1px solid #ccc;
- border-bottom-color: #bbb;
- -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
- -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
- -webkit-transition: 0.1s linear all;
- -moz-transition: 0.1s linear all;
- -ms-transition: 0.1s linear all;
- -o-transition: 0.1s linear all;
- transition: 0.1s linear all;
-}
-
-
-/**********************************************************
- * Data Table Menus
- *********************************************************/
-
-.column-header-menu, a.root-header-menu {
- float: right;
-}
-
-.read-only a.row-header-menu {
- display: none;
-}
-
-div.data-table-cell-content {
- line-height: 1.2;
- color: #222;
- position: relative;
-}
-
-div.data-table-cell-content-numeric {
- text-align: right;
-}
-
-a.data-table-cell-edit {
- position: absolute;
- top: 0;
- right: 0;
- display: block;
- width: 25px;
- height: 16px;
- text-decoration: none;
- background-image: url(images/edit-map.png);
- background-repeat: no-repeat;
- visibility: hidden;
-}
-
-a.data-table-cell-edit:hover {
- background-position: -25px 0px;
-}
-
-.recline-grid td:hover .data-table-cell-edit {
- visibility: visible;
-}
-
-div.data-table-cell-content-numeric > a.data-table-cell-edit {
- left: 0px;
- right: auto;
-}
-
-.data-table-value-nonstring {
- color: #282;
-}
-
-.data-table-error {
- color: red;
-}
-
-.data-table-cell-editor-editor {
- overflow: hidden;
- display: block;
- width: 98%;
- height: 3em;
- font-family: monospace;
- margin: 3px 0;
-}
-
-.data-table-cell-copypaste-editor {
- overflow: hidden;
- display: block;
- width: 98%;
- height: 10em;
- font-family: monospace;
- margin: 3px 0;
-}
-
-.data-table-cell-editor-action {
- float: left;
- vertical-align: bottom;
- text-align: center;
-}
-
-.data-table-cell-editor-key {
- font-size: 0.8em;
- color: #999;
-}
-
-
-/**********************************************************
- * Dialogs
- *********************************************************/
-
-.dialog-overlay {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: #666;
- opacity: 0.5;
-}
-
-.dialog {
- position: fixed;
- left: 0;
- width: 100%;
- text-align: center;
-}
-
-.dialog-frame {
- margin: 0 auto;
- text-align: left;
- background: white;
- border: 1px solid #3a5774;
-}
-
-.dialog-border {
- border: 4px solid #c1d9ff;
-}
-
-.dialog-header {
- background: #e0edfe;
- padding: 10px;
- font-weight: bold;
- font-size: 1.6em;
- color: #000;
- cursor: move;
-}
-
-.dialog-body {
- overflow: auto;
- font-size: 1.3em;
- padding: 15px;
-}
-
-.dialog-instruction {
- padding: 0 0 7px;
-}
-
-.dialog-footer {
- font-size: 1.3em;
- background: #eee;
- padding: 10px;
-}
-
-.dialog-busy {
- width: 400px;
- border: none;
- -moz-border-radius: 5px;
- -webkit-border-radius: 5px;
- border-radius: 5px;
-}
-
-/**********************************************************
- * Transform Dialog
- *********************************************************/
-
-#expression-preview-tabs .ui-tabs-nav li a {
- padding: 0.15em 1em;
-}
-
-textarea.expression-preview-code {
- font-family: monospace;
- height: 5em;
- vertical-align: top;
-}
-
-.expression-preview-parsing-status {
- color: #999;
-}
-
-.expression-preview-parsing-status.error {
- color: red;
-}
-
-#expression-preview-tabs-preview,
-#expression-preview-tabs-help,
-#expression-preview-tabs-history,
-#expression-preview-tabs-starred {
- padding: 5px;
- overflow: hidden;
-}
-
-#expression-preview-tabs-preview > div,
-#expression-preview-tabs-help > div,
-#expression-preview-tabs-history > div,
-#expression-preview-tabs-starred {
- height: 200px;
- overflow: auto;
-}
-
-#expression-preview-tabs-preview td, #expression-preview-tabs-preview th,
-#expression-preview-tabs-help td, #expression-preview-tabs-help th,
-#expression-preview-tabs-history td, #expression-preview-tabs-history th,
-#expression-preview-tabs-starred td, #expression-preview-tabs-starred th {
- padding: 5px;
-}
-
-.expression-preview-table-wrapper {
- padding: 7px;
-}
-
-.expression-preview-container td {
- padding: 2px 5px;
- border-top: 1px solid #ccc;
-}
-
-td.expression-preview-heading {
- border-top: none;
- background: #ddd;
- font-weight: bold;
-}
-
-td.expression-preview-value {
- max-width: 250px !important;
- overflow-x: hidden;
-}
-
-.expression-preview-special-value {
- color: #aaa;
-}
-
-.expression-preview-help-container h3 {
- margin-top: 15px;
- margin-bottom: 7px;
- border-bottom: 1px solid #999;
-}
-
-.expression-preview-doc-item-title {
- font-weight: bold;
- text-align: right;
-}
-
-.expression-preview-doc-item-params {
-}
-
-.expression-preview-doc-item-returns {
-}
-
-.expression-preview-doc-item-desc {
- color: #666;
-}
-
-
-/**********************************************************
- * Read-only mode
- *********************************************************/
-
-.read-only .no-hidden .recline-grid tr td:first-child,
-.read-only .no-hidden .recline-grid tr th:first-child
-{
- display: none;
-}
-
-
-.read-only .write-op,
-.read-only a.data-table-cell-edit
-{
- display: none;
-}
-
diff --git a/ckan/public/scripts/vendor/recline/css/graph-flot.css b/ckan/public/scripts/vendor/recline/css/graph-flot.css
deleted file mode 100644
index d50f11e..0000000
--- a/ckan/public/scripts/vendor/recline/css/graph-flot.css
+++ /dev/null
@@ -1,50 +0,0 @@
-.data-graph-container .graph {
- height: 500px;
- margin-right: 200px;
-}
-
-.data-graph-container .legend table {
- width: auto;
- margin-bottom: 0;
-}
-
-.data-graph-container .legend td {
- padding: 5px;
- line-height: 13px;
-}
-
-/**********************************************************
- * Editor
- *********************************************************/
-
-.data-graph-container .editor {
- float: right;
- width: 200px;
- padding-left: 0px;
-}
-
-.data-graph-container .editor-info {
- padding-left: 4px;
-}
-
-.data-graph-container .editor-info {
- cursor: pointer;
-}
-
-.data-graph-container .editor form {
- padding-left: 4px;
-}
-
-.data-graph-container .editor select {
- width: 100%;
-}
-
-.data-graph-container .editor-info {
- border-bottom: 1px solid #ddd;
- margin-bottom: 10px;
-}
-
-.data-graph-container .editor-hide-info p {
- display: none;
-}
-
diff --git a/ckan/public/scripts/vendor/recline/css/graph.css b/ckan/public/scripts/vendor/recline/css/graph.css
new file mode 100644
index 0000000..413ac14
--- /dev/null
+++ b/ckan/public/scripts/vendor/recline/css/graph.css
@@ -0,0 +1,55 @@
+.recline-graph .graph {
+ height: 500px;
+ margin-right: 200px;
+}
+
+.recline-graph .legend table {
+ width: auto;
+ margin-bottom: 0;
+}
+
+.recline-graph .legend td {
+ padding: 5px;
+ line-height: 13px;
+}
+
+.recline-graph .graph .alert {
+ width: 450px;
+ margin: auto;
+}
+
+/**********************************************************
+ * Editor
+ *********************************************************/
+
+.recline-graph .editor {
+ float: right;
+ width: 200px;
+ padding-left: 0px;
+}
+
+.recline-graph .editor-info {
+ padding-left: 4px;
+}
+
+.recline-graph .editor-info {
+ cursor: pointer;
+}
+
+.recline-graph .editor form {
+ padding-left: 4px;
+}
+
+.recline-graph .editor select {
+ width: 100%;
+}
+
+.recline-graph .editor-info {
+ border-bottom: 1px solid #ddd;
+ margin-bottom: 10px;
+}
+
+.recline-graph .editor-hide-info p {
+ display: none;
+}
+
diff --git a/ckan/public/scripts/vendor/recline/css/grid.css b/ckan/public/scripts/vendor/recline/css/grid.css
new file mode 100644
index 0000000..aeb9984
--- /dev/null
+++ b/ckan/public/scripts/vendor/recline/css/grid.css
@@ -0,0 +1,319 @@
+/**********************************************************
+ * (Data) Grid
+ *********************************************************/
+
+.recline-grid .btn-group .dropdown-toggle {
+ padding: 1px 3px;
+ line-height: auto;
+}
+
+.recline-grid-container {
+ overflow: auto;
+ height: 550px;
+}
+
+.recline-grid {
+ border: 1px solid #ccc;
+ width: 100%;
+}
+
+.recline-grid td, .recline-grid th {
+ border-left: 1px solid #ccc;
+ padding: 3px 4px;
+ text-align: left;
+}
+
+.recline-grid td {
+ vertical-align: top;
+}
+
+.recline-grid tr td:first-child, .recline-grid tr th:first-child {
+ width: 20px;
+}
+
+/* direct borrowing from twitter buttons */
+.recline-grid th,
+.transform-column-view .expression-preview-table-wrapper th
+{
+ background-color: #e6e6e6;
+ background-repeat: no-repeat;
+ background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));
+ background-image: -webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ background-image: -moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);
+ background-image: -ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ background-image: -o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ background-image: linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);
+ text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
+ color: #333;
+ border: 1px solid #ccc;
+ border-bottom-color: #bbb;
+ -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+ -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+ -webkit-transition: 0.1s linear all;
+ -moz-transition: 0.1s linear all;
+ -ms-transition: 0.1s linear all;
+ -o-transition: 0.1s linear all;
+ transition: 0.1s linear all;
+}
+
+
+/**********************************************************
+ * Data Table Menus
+ *********************************************************/
+
+.column-header-menu, a.root-header-menu {
+ float: right;
+}
+
+div.data-table-cell-content {
+ line-height: 1.2;
+ color: #222;
+ position: relative;
+}
+
+div.data-table-cell-content-numeric {
+ text-align: right;
+}
+
+a.data-table-cell-edit {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: block;
+ width: 25px;
+ height: 16px;
+ text-decoration: none;
+ background-image: url(images/edit-map.png);
+ background-repeat: no-repeat;
+ visibility: hidden;
+}
+
+a.data-table-cell-edit:hover {
+ background-position: -25px 0px;
+}
+
+.recline-grid td:hover .data-table-cell-edit {
+ visibility: visible;
+}
+
+div.data-table-cell-content-numeric > a.data-table-cell-edit {
+ left: 0px;
+ right: auto;
+}
+
+.data-table-value-nonstring {
+ color: #282;
+}
+
+.data-table-error {
+ color: red;
+}
+
+.data-table-cell-editor-editor {
+ overflow: hidden;
+ display: block;
+ width: 98%;
+ height: 3em;
+ font-family: monospace;
+ margin: 3px 0;
+}
+
+.data-table-cell-copypaste-editor {
+ overflow: hidden;
+ display: block;
+ width: 98%;
+ height: 10em;
+ font-family: monospace;
+ margin: 3px 0;
+}
+
+.data-table-cell-editor-action {
+ float: left;
+ vertical-align: bottom;
+ text-align: center;
+}
+
+.data-table-cell-editor-key {
+ font-size: 0.8em;
+ color: #999;
+}
+
+
+/**********************************************************
+ * Dialogs
+ *********************************************************/
+
+.dialog-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: #666;
+ opacity: 0.5;
+}
+
+.dialog {
+ position: fixed;
+ left: 0;
+ width: 100%;
+ text-align: center;
+}
+
+.dialog-frame {
+ margin: 0 auto;
+ text-align: left;
+ background: white;
+ border: 1px solid #3a5774;
+}
+
+.dialog-border {
+ border: 4px solid #c1d9ff;
+}
+
+.dialog-header {
+ background: #e0edfe;
+ padding: 10px;
+ font-weight: bold;
+ font-size: 1.6em;
+ color: #000;
+ cursor: move;
+}
+
+.dialog-body {
+ overflow: auto;
+ font-size: 1.3em;
+ padding: 15px;
+}
+
+.dialog-instruction {
+ padding: 0 0 7px;
+}
+
+.dialog-footer {
+ font-size: 1.3em;
+ background: #eee;
+ padding: 10px;
+}
+
+.dialog-busy {
+ width: 400px;
+ border: none;
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ border-radius: 5px;
+}
+
+/**********************************************************
+ * Transform Dialog
+ *********************************************************/
+
+#expression-preview-tabs .ui-tabs-nav li a {
+ padding: 0.15em 1em;
+}
+
+textarea.expression-preview-code {
+ font-family: monospace;
+ height: 5em;
+ vertical-align: top;
+}
+
+.expression-preview-parsing-status {
+ color: #999;
+}
+
+.expression-preview-parsing-status.error {
+ color: red;
+}
+
+#expression-preview-tabs-preview,
+#expression-preview-tabs-help,
+#expression-preview-tabs-history,
+#expression-preview-tabs-starred {
+ padding: 5px;
+ overflow: hidden;
+}
+
+#expression-preview-tabs-preview > div,
+#expression-preview-tabs-help > div,
+#expression-preview-tabs-history > div,
+#expression-preview-tabs-starred {
+ height: 200px;
+ overflow: auto;
+}
+
+#expression-preview-tabs-preview td, #expression-preview-tabs-preview th,
+#expression-preview-tabs-help td, #expression-preview-tabs-help th,
+#expression-preview-tabs-history td, #expression-preview-tabs-history th,
+#expression-preview-tabs-starred td, #expression-preview-tabs-starred th {
+ padding: 5px;
+}
+
+.expression-preview-table-wrapper {
+ padding: 7px;
+}
+
+.expression-preview-container td {
+ padding: 2px 5px;
+ border-top: 1px solid #ccc;
+}
+
+td.expression-preview-heading {
+ border-top: none;
+ background: #ddd;
+ font-weight: bold;
+}
+
+td.expression-preview-value {
+ max-width: 250px !important;
+ overflow-x: hidden;
+}
+
+.expression-preview-special-value {
+ color: #aaa;
+}
+
+.expression-preview-help-container h3 {
+ margin-top: 15px;
+ margin-bottom: 7px;
+ border-bottom: 1px solid #999;
+}
+
+.expression-preview-doc-item-title {
+ font-weight: bold;
+ text-align: right;
+}
+
+.expression-preview-doc-item-params {
+}
+
+.expression-preview-doc-item-returns {
+}
+
+.expression-preview-doc-item-desc {
+ color: #666;
+}
+
+
+/**********************************************************
+ * Read-only mode
+ *********************************************************/
+
+.recline-read-only .no-hidden .recline-grid tr td:first-child,
+.recline-read-only .no-hidden .recline-grid tr th:first-child
+{
+ display: none;
+}
+
+.recline-read-only .recline-grid .write-op,
+.recline-read-only .recline-grid a.data-table-cell-edit
+{
+ display: none;
+}
+
+.recline-read-only a.row-header-menu {
+ display: none;
+}
+
diff --git a/ckan/public/scripts/vendor/recline/css/map.css b/ckan/public/scripts/vendor/recline/css/map.css
index c8adde7..f1f2da2 100644
--- a/ckan/public/scripts/vendor/recline/css/map.css
+++ b/ckan/public/scripts/vendor/recline/css/map.css
@@ -1,4 +1,4 @@
-.data-map-container .map {
+.recline-map .map {
height: 500px;
}
@@ -6,18 +6,23 @@
* Editor
*********************************************************/
-.data-map-container .editor {
+.recline-map .editor {
float: right;
width: 200px;
padding-left: 0px;
margin-left: 10px;
}
-.data-map-container .editor form {
+.recline-map .editor form {
padding-left: 4px;
}
-.data-map-container .editor select {
- width: 100%;
+.recline-map .editor select {
+ width: 100%;
}
+.recline-map .editor .editor-options {
+ margin-top: 10px;
+ border-top: 1px solid gray;
+ padding: 5px 0;
+}
diff --git a/ckan/public/scripts/vendor/recline/recline.js b/ckan/public/scripts/vendor/recline/recline.js
index 2871c6d..271e9c5 100644
--- a/ckan/public/scripts/vendor/recline/recline.js
+++ b/ckan/public/scripts/vendor/recline/recline.js
@@ -86,7 +86,7 @@ this.recline.Model = this.recline.Model || {};
//
// @property {number} docCount: total number of documents in this dataset
//
-// @property {Backend} backend: the Backend (instance) for this Dataset
+// @property {Backend} backend: the Backend (instance) for this Dataset.
//
// @property {Query} queryState: `Query` object which stores current
// queryState. queryState may be edited by other components (e.g. a query
@@ -96,14 +96,24 @@ this.recline.Model = this.recline.Model || {};
// Facets.
my.Dataset = Backbone.Model.extend({
__type__: 'Dataset',
+
// ### initialize
//
// Sets up instance properties (see above)
+ //
+ // @param {Object} model: standard set of model attributes passed to Backbone models
+ //
+ // @param {Object or String} backend: Backend instance (see
+ // `recline.Backend.Base`) or a string specifying that instance. The
+ // string specifying may be a full class path e.g.
+ // 'recline.Backend.ElasticSearch' or a simple name e.g.
+ // 'elasticsearch' or 'ElasticSearch' (in this case must be a Backend in
+ // recline.Backend module)
initialize: function(model, backend) {
_.bindAll(this, 'query');
this.backend = backend;
- if (backend && backend.constructor == String) {
- this.backend = my.backends[backend];
+ if (typeof(backend) === 'string') {
+ this.backend = this._backendFromString(backend);
}
this.fields = new my.FieldList();
this.currentDocuments = new my.DocumentList();
@@ -167,9 +177,73 @@ my.Dataset = Backbone.Model.extend({
data.docCount = this.docCount;
data.fields = this.fields.toJSON();
return data;
+ },
+
+ // ### _backendFromString(backendString)
+ //
+ // See backend argument to initialize for details
+ _backendFromString: function(backendString) {
+ var parts = backendString.split('.');
+ // walk through the specified path xxx.yyy.zzz to get the final object which should be backend class
+ var current = window;
+ for(ii=0;ii<parts.length;ii++) {
+ if (!current) {
+ break;
+ }
+ current = current[parts[ii]];
+ }
+ if (current) {
+ return new current();
+ }
+
+ // alternatively we just had a simple string
+ var backend = null;
+ if (recline && recline.Backend) {
+ _.each(_.keys(recline.Backend), function(name) {
+ if (name.toLowerCase() === backendString.toLowerCase()) {
+ backend = new recline.Backend[name]();
+ }
+ });
+ }
+ return backend;
}
});
+
+// ### Dataset.restore
+//
+// Restore a Dataset instance from a serialized state. Serialized state for a
+// Dataset is an Object like:
+//
+// <pre>
+// {
+// backend: {backend type - i.e. value of dataset.backend.__type__}
+// dataset: {dataset info needed for loading -- result of dataset.toJSON() would be sufficient but can be simpler }
+// // convenience - if url provided and dataste not this be used as dataset url
+// url: {dataset url}
+// ...
+// }
+my.Dataset.restore = function(state) {
+ // hack-y - restoring a memory dataset does not mean much ...
+ var dataset = null;
+ if (state.url && !state.dataset) {
+ state.dataset = {url: state.url};
+ }
+ if (state.backend === 'memory') {
+ dataset = recline.Backend.createDataset(
+ [{stub: 'this is a stub dataset because we do not restore memory datasets'}],
+ [],
+ state.dataset // metadata
+ );
+ } else {
+ dataset = new recline.Model.Dataset(
+ state.dataset,
+ state.backend
+ );
+ }
+ return dataset;
+};
+
// ## <a id="document">A Document (aka Row)</a>
//
// A single entry or row in the dataset
@@ -211,7 +285,8 @@ my.DocumentList = Backbone.Collection.extend({
// * format: (optional) used to indicate how the data should be formatted. For example:
// * type=date, format=yyyy-mm-dd
// * type=float, format=percentage
-// * type=float, format='###,###.##'
+// * type=string, format=link (render as hyperlink)
+// * type=string, format=markdown (render as markdown if Showdown available)
// * is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below).
//
// Following additional instance properties:
@@ -267,6 +342,22 @@ my.Field = Backbone.Model.extend({
if (format === 'percentage') {
return val + '%';
}
+ return val;
+ },
+ 'string': function(val, field, doc) {
+ var format = field.get('format');
+ if (format === 'link') {
+ return '<a href="VAL">VAL</a>'.replace(/VAL/g, val);
+ } else if (format === 'markdown') {
+ if (typeof Showdown !== 'undefined') {
+ var showdown = new Showdown.converter();
+ out = showdown.makeHtml(val);
+ return out;
+ } else {
+ return val;
+ }
+ }
+ return val;
}
}
});
@@ -449,6 +540,13 @@ my.FacetList = Backbone.Collection.extend({
model: my.Facet
});
+// ## Object State
+//
+// Convenience Backbone model for storing (configuration) state of objects like Views.
+my.ObjectState = Backbone.Model.extend({
+});
+
+
// ## Backend registry
//
// Backends will register themselves by id into this registry
@@ -618,10 +716,10 @@ this.recline.View = this.recline.View || {};
// ## Graph view for a Dataset using Flot graphing library.
//
-// Initialization arguments:
+// Initialization arguments (in a hash in first parameter):
//
// * model: recline.Model.Dataset
-// * config: (optional) graph configuration hash of form:
+// * state: (optional) configuration hash of form:
//
// {
// group: {column name for x-axis},
@@ -631,10 +729,10 @@ this.recline.View = this.recline.View || {};
//
// NB: should *not* provide an el argument to the view but must let the view
// generate the element itself (you can then append view.el to the DOM.
-my.FlotGraph = Backbone.View.extend({
+my.Graph = Backbone.View.extend({
tagName: "div",
- className: "data-graph-container",
+ className: "recline-graph",
template: ' \
<div class="editor"> \
@@ -659,22 +757,13 @@ my.FlotGraph = Backbone.View.extend({
<label>Group Column (x-axis)</label> \
<div class="input editor-group"> \
<select> \
+ <option value="">Please choose ...</option> \
{{#fields}} \
<option value="{{id}}">{{label}}</option> \
{{/fields}} \
</select> \
</div> \
<div class="editor-series-group"> \
- <div class="editor-series"> \
- <label>Series <span>A (y-axis)</span></label> \
- <div class="input"> \
- <select> \
- {{#fields}} \
- <option value="{{id}}">{{label}}</option> \
- {{/fields}} \
- </select> \
- </div> \
- </div> \
</div> \
</div> \
<div class="editor-buttons"> \
@@ -686,18 +775,39 @@ my.FlotGraph = Backbone.View.extend({
</div> \
</form> \
</div> \
- <div class="panel graph"></div> \
+ <div class="panel graph"> \
+ <div class="js-temp-notice alert alert-block"> \
+ <h3 class="alert-heading">Hey there!</h3> \
+ <p>There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.</p> \
+ <p>Please tell us by <strong>using the menu on the right</strong> and a graph will automatically appear.</p> \
+ </div> \
+ </div> \
</div> \
',
+ templateSeriesEditor: ' \
+ <div class="editor-series js-series-{{seriesIndex}}"> \
+ <label>Series <span>{{seriesName}} (y-axis)</span> \
+ [<a href="#remove" class="action-remove-series">Remove</a>] \
+ </label> \
+ <div class="input"> \
+ <select> \
+ <option value="">Please choose ...</option> \
+ {{#fields}} \
+ <option value="{{id}}">{{label}}</option> \
+ {{/fields}} \
+ </select> \
+ </div> \
+ </div> \
+ ',
events: {
'change form select': 'onEditorSubmit',
- 'click .editor-add': 'addSeries',
+ 'click .editor-add': '_onAddSeries',
'click .action-remove-series': 'removeSeries',
'click .action-toggle-help': 'toggleHelp'
},
- initialize: function(options, config) {
+ initialize: function(options) {
var self = this;
this.el = $(this.el);
_.bindAll(this, 'render', 'redraw');
@@ -707,46 +817,66 @@ my.FlotGraph = Backbone.View.extend({
this.model.fields.bind('add', this.render);
this.model.currentDocuments.bind('add', this.redraw);
this.model.currentDocuments.bind('reset', this.redraw);
- var configFromHash = my.parseHashQueryString().graph;
- if (configFromHash) {
- configFromHash = JSON.parse(configFromHash);
- }
- this.chartConfig = _.extend({
+ var stateData = _.extend({
group: null,
- series: [],
+ // so that at least one series chooser box shows up
+ series: [""],
graphType: 'lines-and-points'
},
- configFromHash,
- config
- );
+ options.state
+ );
+ this.state = new recline.Model.ObjectState(stateData);
this.render();
},
render: function() {
- htmls = $.mustache(this.template, this.model.toTemplateJSON());
+ var self = this;
+ var tmplData = this.model.toTemplateJSON();
+ var htmls = $.mustache(this.template, tmplData);
$(this.el).html(htmls);
- // now set a load of stuff up
this.$graph = this.el.find('.panel.graph');
- // for use later when adding additional series
- // could be simpler just to have a common template!
- this.$seriesClone = this.el.find('.editor-series').clone();
- this._updateSeries();
+
+ // set up editor from state
+ if (this.state.get('graphType')) {
+ this._selectOption('.editor-type', this.state.get('graphType'));
+ }
+ if (this.state.get('group')) {
+ this._selectOption('.editor-group', this.state.get('group'));
+ }
+ _.each(this.state.get('series'), function(series, idx) {
+ self.addSeries(idx);
+ self._selectOption('.editor-series.js-series-' + idx, series);
+ });
return this;
},
+ // Private: Helper function to select an option from a select list
+ //
+ _selectOption: function(id,value){
+ var options = this.el.find(id + ' select > option');
+ if (options) {
+ options.each(function(opt){
+ if (this.value == value) {
+ $(this).attr('selected','selected');
+ return false;
+ }
+ });
+ }
+ },
+
onEditorSubmit: function(e) {
var select = this.el.find('.editor-group select');
- $editor = this;
- var series = this.$series.map(function () {
+ var $editor = this;
+ var $series = this.el.find('.editor-series select');
+ var series = $series.map(function () {
return $(this).val();
});
- this.chartConfig.series = $.makeArray(series);
- this.chartConfig.group = this.el.find('.editor-group select').val();
- this.chartConfig.graphType = this.el.find('.editor-type select').val();
- // update navigation
- var qs = my.parseHashQueryString();
- qs.graph = JSON.stringify(this.chartConfig);
- my.setHashQueryString(qs);
+ var updatedState = {
+ series: $.makeArray(series),
+ group: this.el.find('.editor-group select').val(),
+ graphType: this.el.find('.editor-type select').val()
+ };
+ this.state.set(updatedState);
this.redraw();
},
@@ -762,7 +892,7 @@ my.FlotGraph = Backbone.View.extend({
return;
}
var series = this.createSeries();
- var options = this.getGraphOptions(this.chartConfig.graphType);
+ var options = this.getGraphOptions(this.state.attributes.graphType);
this.plot = $.plot(this.$graph, series, options);
this.setupTooltips();
// create this.plot and cache it
@@ -777,13 +907,23 @@ my.FlotGraph = Backbone.View.extend({
// }
},
+ // ### getGraphOptions
+ //
+ // Get options for Flot Graph
+ //
// needs to be function as can depend on state
+ //
+ // @param typeId graphType id (lines, lines-and-points etc)
getGraphOptions: function(typeId) {
var self = this;
// special tickformatter to show labels rather than numbers
+ // TODO: we should really use tickFormatter and 1 interval ticks if (and
+ // only if) x-axis values are non-numeric
+ // However, that is non-trivial to work out from a dataset (datasets may
+ // have no field type info). Thus at present we only do this for bars.
var tickFormatter = function (val) {
if (self.model.currentDocuments.models[val]) {
- var out = self.model.currentDocuments.models[val].get(self.chartConfig.group);
+ var out = self.model.currentDocuments.models[val].get(self.state.attributes.group);
// if the value was in fact a number we want that not the
if (typeof(out) == 'number') {
return val;
@@ -793,20 +933,25 @@ my.FlotGraph = Backbone.View.extend({
}
return val;
};
- // TODO: we should really use tickFormatter and 1 interval ticks if (and
- // only if) x-axis values are non-numeric
- // However, that is non-trivial to work out from a dataset (datasets may
- // have no field type info). Thus at present we only do this for bars.
- var options = {
+
+ var xaxis = {};
+ // check for time series on x-axis
+ if (this.model.fields.get(this.state.get('group')).get('type') === 'date') {
+ xaxis.mode = 'time';
+ xaxis.timeformat = '%y-%b';
+ }
+ var optionsPerGraphType = {
lines: {
- series: {
- lines: { show: true }
- }
+ series: {
+ lines: { show: true }
+ },
+ xaxis: xaxis
},
points: {
series: {
points: { show: true }
},
+ xaxis: xaxis,
grid: { hoverable: true, clickable: true }
},
'lines-and-points': {
@@ -814,6 +959,7 @@ my.FlotGraph = Backbone.View.extend({
points: { show: true },
lines: { show: true }
},
+ xaxis: xaxis,
grid: { hoverable: true, clickable: true }
},
bars: {
@@ -837,7 +983,7 @@ my.FlotGraph = Backbone.View.extend({
}
}
};
- return options[typeId];
+ return optionsPerGraphType[typeId];
},
setupTooltips: function() {
@@ -866,14 +1012,14 @@ my.FlotGraph = Backbone.View.extend({
var y = item.datapoint[1];
// convert back from 'index' value on x-axis (e.g. in cases where non-number values)
if (self.model.currentDocuments.models[x]) {
- x = self.model.currentDocuments.models[x].get(self.chartConfig.group);
+ x = self.model.currentDocuments.models[x].get(self.state.attributes.group);
} else {
x = x.toFixed(2);
}
y = y.toFixed(2);
var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', {
- group: self.chartConfig.group,
+ group: self.state.attributes.group,
x: x,
series: item.series.label,
y: y
@@ -891,47 +1037,54 @@ my.FlotGraph = Backbone.View.extend({
createSeries: function () {
var self = this;
var series = [];
- if (this.chartConfig) {
- $.each(this.chartConfig.series, function (seriesIndex, field) {
- var points = [];
- $.each(self.model.currentDocuments.models, function (index, doc) {
- var x = doc.get(self.chartConfig.group);
- var y = doc.get(field);
- if (typeof x === 'string') {
- x = index;
- }
- // horizontal bar chart
- if (self.chartConfig.graphType == 'bars') {
- points.push([y, x]);
- } else {
- points.push([x, y]);
- }
- });
- series.push({data: points, label: field});
+ _.each(this.state.attributes.series, function(field) {
+ var points = [];
+ _.each(self.model.currentDocuments.models, function(doc, index) {
+ var xfield = self.model.fields.get(self.state.attributes.group);
+ var x = doc.getFieldValue(xfield);
+ // time series
+ var isDateTime = xfield.get('type') === 'date';
+ if (isDateTime) {
+ x = new Date(x);
+ }
+ var yfield = self.model.fields.get(field);
+ var y = doc.getFieldValue(yfield);
+ if (typeof x === 'string') {
+ x = index;
+ }
+ // horizontal bar chart
+ if (self.state.attributes.graphType == 'bars') {
+ points.push([y, x]);
+ } else {
+ points.push([x, y]);
+ }
});
- }
+ series.push({data: points, label: field});
+ });
return series;
},
// Public: Adds a new empty series select box to the editor.
//
- // All but the first select box will have a remove button that allows them
- // to be removed.
+ // @param [int] idx index of this series in the list of series
//
// Returns itself.
- addSeries: function (e) {
- e.preventDefault();
- var element = this.$seriesClone.clone(),
- label = element.find('label'),
- index = this.$series.length;
-
- this.el.find('.editor-series-group').append(element);
- this._updateSeries();
- label.append(' [<a href="#remove" class="action-remove-series">Remove</a>]');
- label.find('span').text(String.fromCharCode(this.$series.length + 64));
+ addSeries: function (idx) {
+ var data = _.extend({
+ seriesIndex: idx,
+ seriesName: String.fromCharCode(idx + 64 + 1),
+ }, this.model.toTemplateJSON());
+
+ var htmls = $.mustache(this.templateSeriesEditor, data);
+ this.el.find('.editor-series-group').append(htmls);
return this;
},
+ _onAddSeries: function(e) {
+ e.preventDefault();
+ this.addSeries(this.state.get('series').length);
+ },
+
// Public: Removes a series list item from the editor.
//
// Also updates the labels of the remaining series elements.
@@ -939,26 +1092,12 @@ my.FlotGraph = Backbone.View.extend({
e.preventDefault();
var $el = $(e.target);
$el.parent().parent().remove();
- this._updateSeries();
- this.$series.each(function (index) {
- if (index > 0) {
- var labelSpan = $(this).prev().find('span');
- labelSpan.text(String.fromCharCode(index + 65));
- }
- });
this.onEditorSubmit();
},
toggleHelp: function() {
this.el.find('.editor-info').toggleClass('editor-hide-info');
},
-
- // Private: Resets the series property to reference the select elements.
- //
- // Returns itself.
- _updateSeries: function () {
- this.$series = this.el.find('.editor-series select');
- }
});
})(jQuery, recline.View);
@@ -969,12 +1108,12 @@ this.recline = this.recline || {};
this.recline.View = this.recline.View || {};
(function($, my) {
-// ## DataGrid
+// ## (Data) Grid Dataset View
//
// Provides a tabular view on a Dataset.
//
// Initialize it with a `recline.Model.Dataset`.
-my.DataGrid = Backbone.View.extend({
+my.Grid = Backbone.View.extend({
tagName: "div",
className: "recline-grid-container",
@@ -985,12 +1124,16 @@ my.DataGrid = Backbone.View.extend({
this.model.currentDocuments.bind('add', this.render);
this.model.currentDocuments.bind('reset', this.render);
this.model.currentDocuments.bind('remove', this.render);
- this.state = {};
- this.hiddenFields = [];
+ this.tempState = {};
+ var state = _.extend({
+ hiddenFields: []
+ }, modelEtc.state
+ );
+ this.state = new recline.Model.ObjectState(state);
},
events: {
- 'click .column-header-menu': 'onColumnHeaderClick',
+ 'click .column-header-menu .data-table-menu li a': 'onColumnHeaderClick',
'click .row-header-menu': 'onRowHeaderClick',
'click .root-header-menu': 'onRootHeaderClick',
'click .data-table-menu li a': 'onMenuClick'
@@ -1012,11 +1155,11 @@ my.DataGrid = Backbone.View.extend({
// Column and row menus
onColumnHeaderClick: function(e) {
- this.state.currentColumn = $(e.target).closest('.column-header').attr('data-field');
+ this.tempState.currentColumn = $(e.target).closest('.column-header').attr('data-field');
},
onRowHeaderClick: function(e) {
- this.state.currentRow = $(e.target).parents('tr:first').attr('data-id');
+ this.tempState.currentRow = $(e.target).parents('tr:first').attr('data-id');
},
onRootHeaderClick: function(e) {
@@ -1024,7 +1167,7 @@ my.DataGrid = Backbone.View.extend({
{{#columns}} \
<li><a data-action="showColumn" data-column="{{.}}" href="JavaScript:void(0);">Show column: {{.}}</a></li> \
{{/columns}}';
- var tmp = $.mustache(tmpl, {'columns': this.hiddenFields});
+ var tmp = $.mustache(tmpl, {'columns': this.state.get('hiddenFields')});
this.el.find('.root-header-menu .dropdown-menu').html(tmp);
},
@@ -1032,15 +1175,15 @@ my.DataGrid = Backbone.View.extend({
var self = this;
e.preventDefault();
var actions = {
- bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}); },
+ bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.tempState.currentColumn}); },
facet: function() {
- self.model.queryState.addFacet(self.state.currentColumn);
+ self.model.queryState.addFacet(self.tempState.currentColumn);
},
facet_histogram: function() {
- self.model.queryState.addHistogramFacet(self.state.currentColumn);
+ self.model.queryState.addHistogramFacet(self.tempState.currentColumn);
},
filter: function() {
- self.model.queryState.addTermFilter(self.state.currentColumn, '');
+ self.model.queryState.addTermFilter(self.tempState.currentColumn, '');
},
transform: function() { self.showTransformDialog('transform'); },
sortAsc: function() { self.setColumnSort('asc'); },
@@ -1051,7 +1194,7 @@ my.DataGrid = Backbone.View.extend({
var doc = _.find(self.model.currentDocuments.models, function(doc) {
// important this is == as the currentRow will be string (as comes
// from DOM) while id may be int
- return doc.id == self.state.currentRow;
+ return doc.id == self.tempState.currentRow;
});
doc.destroy().then(function() {
self.model.currentDocuments.remove(doc);
@@ -1070,7 +1213,7 @@ my.DataGrid = Backbone.View.extend({
var view = new my.ColumnTransform({
model: this.model
});
- view.state = this.state;
+ view.state = this.tempState;
view.render();
$el.empty();
$el.append(view.el);
@@ -1096,17 +1239,22 @@ my.DataGrid = Backbone.View.extend({
setColumnSort: function(order) {
var sort = [{}];
- sort[0][this.state.currentColumn] = {order: order};
+ sort[0][this.tempState.currentColumn] = {order: order};
this.model.query({sort: sort});
},
hideColumn: function() {
- this.hiddenFields.push(this.state.currentColumn);
+ var hiddenFields = this.state.get('hiddenFields');
+ hiddenFields.push(this.tempState.currentColumn);
+ this.state.set({hiddenFields: hiddenFields});
+ // change event not being triggered (because it is an array?) so trigger manually
+ this.state.trigger('change');
this.render();
},
showColumn: function(e) {
- this.hiddenFields = _.without(this.hiddenFields, $(e.target).data('column'));
+ var hiddenFields = _.without(this.state.get('hiddenFields'), $(e.target).data('column'));
+ this.state.set({hiddenFields: hiddenFields});
this.render();
},
@@ -1162,41 +1310,41 @@ my.DataGrid = Backbone.View.extend({
render: function() {
var self = this;
this.fields = this.model.fields.filter(function(field) {
- return _.indexOf(self.hiddenFields, field.id) == -1;
+ return _.indexOf(self.state.get('hiddenFields'), field.id) == -1;
});
var htmls = $.mustache(this.template, this.toTemplateJSON());
this.el.html(htmls);
this.model.currentDocuments.forEach(function(doc) {
var tr = $('<tr />');
self.el.find('tbody').append(tr);
- var newView = new my.DataGridRow({
+ var newView = new my.GridRow({
model: doc,
el: tr,
fields: self.fields
});
newView.render();
});
- this.el.toggleClass('no-hidden', (self.hiddenFields.length === 0));
+ this.el.toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0));
return this;
}
});
-// ## DataGridRow View for rendering an individual document.
+// ## GridRow View for rendering an individual document.
//
// Since we want this to update in place it is up to creator to provider the element to attach to.
//
-// In addition you *must* pass in a FieldList in the constructor options. This should be list of fields for the DataGrid.
+// In addition you *must* pass in a FieldList in the constructor options. This should be list of fields for the Grid.
//
// Example:
//
// <pre>
-// var row = new DataGridRow({
+// var row = new GridRow({
// model: dataset-document,
// el: dom-element,
// fields: mydatasets.fields // a FieldList object
// });
// </pre>
-my.DataGridRow = Backbone.View.extend({
+my.GridRow = Backbone.View.extend({
initialize: function(initData) {
_.bindAll(this, 'render');
this._fields = initData.fields;
@@ -1301,21 +1449,21 @@ this.recline.View = this.recline.View || {};
// [GeoJSON](http://geojson.org) objects or two fields with latitude and
// longitude coordinates.
//
-// Initialization arguments:
-//
-// * options: initial options. They must contain a model:
-//
-// {
-// model: {recline.Model.Dataset}
-// }
-//
-// * config: (optional) map configuration hash (not yet used)
-//
+// Initialization arguments are as standard for Dataset Views. State object may
+// have the following (optional) configuration options:
//
+// <pre>
+// {
+// // geomField if specified will be used in preference to lat/lon
+// geomField: {id of field containing geometry in the dataset}
+// lonField: {id of field containing longitude in the dataset}
+// latField: {id of field containing latitude in the dataset}
+// }
+// </pre>
my.Map = Backbone.View.extend({
tagName: 'div',
- className: 'data-map-container',
+ className: 'recline-map',
template: ' \
<div class="editor"> \
@@ -1364,6 +1512,11 @@ my.Map = Backbone.View.extend({
<div class="editor-buttons"> \
<button class="btn editor-update-map">Update</button> \
</div> \
+ <div class="editor-options" > \
+ <label class="checkbox"> \
+ <input type="checkbox" id="editor-auto-zoom" checked="checked" /> \
+ Auto zoom to features</label> \
+ </div> \
<input type="hidden" class="editor-id" value="map-1" /> \
</div> \
</form> \
@@ -1381,17 +1534,16 @@ my.Map = Backbone.View.extend({
// Define here events for UI elements
events: {
'click .editor-update-map': 'onEditorSubmit',
- 'change .editor-field-type': 'onFieldTypeChange'
+ 'change .editor-field-type': 'onFieldTypeChange',
+ 'change #editor-auto-zoom': 'onAutoZoomChange'
},
-
- initialize: function(options, config) {
+ initialize: function(options) {
var self = this;
-
this.el = $(this.el);
// Listen to changes in the fields
- this.model.bind('change', function() {
+ this.model.fields.bind('change', function() {
self._setupGeometryField();
});
this.model.fields.bind('add', this.render);
@@ -1402,17 +1554,40 @@ my.Map = Backbone.View.extend({
// Listen to changes in the documents
this.model.currentDocuments.bind('add', function(doc){self.redraw('add',doc)});
+ this.model.currentDocuments.bind('change', function(doc){
+ self.redraw('remove',doc);
+ self.redraw('add',doc);
+ });
this.model.currentDocuments.bind('remove', function(doc){self.redraw('remove',doc)});
this.model.currentDocuments.bind('reset', function(){self.redraw('reset')});
- // If the div was hidden, Leaflet needs to recalculate some sizes
- // to display properly
this.bind('view:show',function(){
+ // If the div was hidden, Leaflet needs to recalculate some sizes
+ // to display properly
+ if (self.map){
self.map.invalidateSize();
+ if (self._zoomPending && self.autoZoom) {
+ self._zoomToFeatures();
+ self._zoomPending = false;
+ }
+ }
+ self.visible = true;
+ });
+ this.bind('view:hide',function(){
+ self.visible = false;
});
- this.mapReady = false;
+ var stateData = _.extend({
+ geomField: null,
+ lonField: null,
+ latField: null
+ },
+ options.state
+ );
+ this.state = new recline.Model.ObjectState(stateData);
+ this.autoZoom = true;
+ this.mapReady = false;
this.render();
},
@@ -1429,12 +1604,12 @@ my.Map = Backbone.View.extend({
this.$map = this.el.find('.panel.map');
if (this.geomReady && this.model.fields.length){
- if (this._geomFieldName){
- this._selectOption('editor-geom-field',this._geomFieldName);
+ if (this.state.get('geomField')){
+ this._selectOption('editor-geom-field',this.state.get('geomField'));
$('#editor-field-type-geom').attr('checked','checked').change();
} else{
- this._selectOption('editor-lon-field',this._lonFieldName);
- this._selectOption('editor-lat-field',this._latFieldName);
+ this._selectOption('editor-lon-field',this.state.get('lonField'));
+ this._selectOption('editor-lat-field',this.state.get('latField'));
$('#editor-field-type-latlon').attr('checked','checked').change();
}
}
@@ -1463,9 +1638,7 @@ my.Map = Backbone.View.extend({
// * refresh: Clear existing features and add all current documents
//
redraw: function(action,doc){
-
var self = this;
-
action = action || 'refresh';
if (this.geomReady && this.mapReady){
@@ -1479,6 +1652,13 @@ my.Map = Backbone.View.extend({
this.features.clearLayers();
this._add(this.model.currentDocuments.models);
}
+ if (action != 'reset' && this.autoZoom){
+ if (this.visible){
+ this._zoomToFeatures();
+ } else {
+ this._zoomPending = true;
+ }
+ }
}
},
@@ -1494,14 +1674,19 @@ my.Map = Backbone.View.extend({
onEditorSubmit: function(e){
e.preventDefault();
if ($('#editor-field-type-geom').attr('checked')){
- this._geomFieldName = $('.editor-geom-field > select > option:selected').val();
- this._latFieldName = this._lonFieldName = false;
+ this.state.set({
+ geomField: $('.editor-geom-field > select > option:selected').val(),
+ lonField: null,
+ latField: null
+ });
} else {
- this._geomFieldName = false;
- this._latFieldName = $('.editor-lat-field > select > option:selected').val();
- this._lonFieldName = $('.editor-lon-field > select > option:selected').val();
+ this.state.set({
+ geomField: null,
+ lonField: $('.editor-lon-field > select > option:selected').val(),
+ latField: $('.editor-lat-field > select > option:selected').val()
+ });
}
- this.geomReady = (this._geomFieldName || (this._latFieldName && this._lonFieldName));
+ this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
this.redraw();
return false;
@@ -1520,6 +1705,10 @@ my.Map = Backbone.View.extend({
}
},
+ onAutoZoomChange: function(e){
+ this.autoZoom = !this.autoZoom;
+ },
+
// Private: Add one or n features to the map
//
// For each document passed, a GeoJSON geometry will be extracted and added
@@ -1534,9 +1723,12 @@ my.Map = Backbone.View.extend({
if (!(docs instanceof Array)) docs = [docs];
+ var count = 0;
+ var wrongSoFar = 0;
_.every(docs,function(doc){
+ count += 1;
var feature = self._getGeometryFromDocument(doc);
- if (typeof feature === 'undefined'){
+ if (typeof feature === 'undefined' || feature === null){
// Empty field
return true;
} else if (feature instanceof Object){
@@ -1544,7 +1736,9 @@ my.Map = Backbone.View.extend({
// TODO: mustache?
html = ''
for (key in doc.attributes){
- html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>'
+ if (!(self.state.get('geomField') && key == self.state.get('geomField'))){
+ html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>';
+ }
}
feature.properties = {popupContent: html};
@@ -1553,16 +1747,20 @@ my.Map = Backbone.View.extend({
feature.properties.cid = doc.cid;
try {
- self.features.addGeoJSON(feature);
+ self.features.addGeoJSON(feature);
} catch (except) {
- var msg = 'Wrong geometry value';
- if (except.message) msg += ' (' + except.message + ')';
+ wrongSoFar += 1;
+ var msg = 'Wrong geometry value';
+ if (except.message) msg += ' (' + except.message + ')';
+ if (wrongSoFar <= 10) {
my.notify(msg,{category:'error'});
- return false;
+ }
}
} else {
- my.notify('Wrong geometry value',{category:'error'});
- return false;
+ wrongSoFar += 1
+ if (wrongSoFar <= 10) {
+ my.notify('Wrong geometry value',{category:'error'});
+ }
}
return true;
});
@@ -1576,7 +1774,7 @@ my.Map = Backbone.View.extend({
if (!(docs instanceof Array)) docs = [docs];
- _.each(doc,function(doc){
+ _.each(docs,function(doc){
for (key in self.features._layers){
if (self.features._layers[key].cid == doc.cid){
self.features.removeLayer(self.features._layers[key]);
@@ -1590,18 +1788,25 @@ my.Map = Backbone.View.extend({
//
_getGeometryFromDocument: function(doc){
if (this.geomReady){
- if (this._geomFieldName){
- // We assume that the contents of the field are a valid GeoJSON object
- return doc.attributes[this._geomFieldName];
- } else if (this._lonFieldName && this._latFieldName){
+ if (this.state.get('geomField')){
+ var value = doc.get(this.state.get('geomField'));
+ if (typeof(value) === 'string'){
+ // We have a GeoJSON string representation
+ return $.parseJSON(value);
+ } else {
+ // We assume that the contents of the field are a valid GeoJSON object
+ return value;
+ }
+ } else if (this.state.get('lonField') && this.state.get('latField')){
// We'll create a GeoJSON like point object from the two lat/lon fields
- return {
- type: 'Point',
- coordinates: [
- doc.attributes[this._lonFieldName],
- doc.attributes[this._latFieldName]
- ]
- };
+ var lon = doc.get(this.state.get('lonField'));
+ var lat = doc.get(this.state.get('latField'));
+ if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
+ return {
+ type: 'Point',
+ coordinates: [lon,lat]
+ };
+ }
}
return null;
}
@@ -1613,12 +1818,16 @@ my.Map = Backbone.View.extend({
// If not found, the user can define them via the UI form.
_setupGeometryField: function(){
var geomField, latField, lonField;
-
- this._geomFieldName = this._checkField(this.geometryFieldNames);
- this._latFieldName = this._checkField(this.latitudeFieldNames);
- this._lonFieldName = this._checkField(this.longitudeFieldNames);
-
- this.geomReady = (this._geomFieldName || (this._latFieldName && this._lonFieldName));
+ this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
+ // should not overwrite if we have already set this (e.g. explicitly via state)
+ if (!this.geomReady) {
+ this.state.set({
+ geomField: this._checkField(this.geometryFieldNames),
+ latField: this._checkField(this.latitudeFieldNames),
+ lonField: this._checkField(this.longitudeFieldNames)
+ });
+ this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
+ }
},
// Private: Check if a field in the current model exists in the provided
@@ -1637,6 +1846,18 @@ my.Map = Backbone.View.extend({
return null;
},
+ // Private: Zoom to map to current features extent if any, or to the full
+ // extent if none.
+ //
+ _zoomToFeatures: function(){
+ var bounds = this.features.getBounds();
+ if (bounds){
+ this.map.fitBounds(bounds);
+ } else {
+ this.map.setView(new L.LatLng(0, 0), 2);
+ }
+ },
+
// Private: Sets up the Leaflet map control and the features layer.
//
// The map uses a base layer from [MapQuest](http://www.mapquest.com) based
@@ -1661,6 +1882,24 @@ my.Map = Backbone.View.extend({
}
});
+
+ // This will be available in the next Leaflet stable release.
+ // In the meantime we add it manually to our layer.
+ this.features.getBounds = function(){
+ var bounds = new L.LatLngBounds();
+ this._iterateLayers(function (layer) {
+ if (layer instanceof L.Marker){
+ bounds.extend(layer.getLatLng());
+ } else {
+ if (layer.getBounds){
+ bounds.extend(layer.getBounds().getNorthEast());
+ bounds.extend(layer.getBounds().getSouthWest());
+ }
+ }
+ }, this);
+ return (typeof bounds.getNorthEast() !== 'undefined') ? bounds : null;
+ }
+
this.map.addLayer(this.features);
this.map.setView(new L.LatLng(0, 0), 2);
@@ -1895,6 +2134,85 @@ my.ColumnTransform = Backbone.View.extend({
})(jQuery, recline.View);
/*jshint multistr:true */
+
+// # Recline Views
+//
+// Recline Views are Backbone Views and in keeping with normal Backbone views
+// are Widgets / Components displaying something in the DOM. Like all Backbone
+// views they have a pointer to a model or a collection and is bound to an
+// element.
+//
+// Views provided by core Recline are crudely divided into two types:
+//
+// * Dataset Views: a View intended for displaying a recline.Model.Dataset
+// in some fashion. Examples are the Grid, Graph and Map views.
+// * Widget Views: a widget used for displaying some specific (and
+// smaller) aspect of a dataset or the application. Examples are
+// QueryEditor and FilterEditor which both provide a way for editing (a
+// part of) a `recline.Model.Query` associated to a Dataset.
+//
+// ## Dataset View
+//
+// These views are just Backbone views with a few additional conventions:
+//
+// 1. The model passed to the View should always be a recline.Model.Dataset instance
+// 2. Views should generate their own root element rather than having it passed
+// in.
+// 3. Views should apply a css class named 'recline-{view-name-lower-cased} to
+// the root element (and for all CSS for this view to be qualified using this
+// CSS class)
+// 4. Read-only mode: CSS for this view should respect/utilize
+// recline-read-only class to trigger read-only behaviour (this class will
+// usually be set on some parent element of the view's root element.
+// 5. State: state (configuration) information for the view should be stored on
+// an attribute named state that is an instance of a Backbone Model (or, more
+// speficially, be an instance of `recline.Model.ObjectState`). In addition,
+// a state attribute may be specified in the Hash passed to a View on
+// iniitialization and this information should be used to set the initial
+// state of the view.
+//
+// Example of state would be the set of fields being plotted in a graph
+// view.
+//
+// More information about State can be found below.
+//
+// To summarize some of this, the initialize function for a Dataset View should
+// look like:
+//
+// <pre>
+// initialize: {
+// model: {a recline.Model.Dataset instance}
+// // el: {do not specify - instead view should create}
+// state: {(optional) Object / Hash specifying initial state}
+// ...
+// }
+// </pre>
+//
+// Note: Dataset Views in core Recline have a common layout on disk as
+// follows, where ViewName is the named of View class:
+//
+// <pre>
+// src/view-{lower-case-ViewName}.js
+// css/{lower-case-ViewName}.css
+// test/view-{lower-case-ViewName}.js
+// </pre>
+//
+// ### State
+//
+// State information exists in order to support state serialization into the
+// url or elsewhere and reloading of application from a stored state.
+//
+// State is available not only for individual views (as described above) but
+// for the dataset (e.g. the current query). For an example of pulling together
+// state from across multiple components see `recline.View.DataExplorer`.
+//
+// ### Writing your own Views
+//
+// See the existing Views.
+//
+// ----
+
+// Standard JS module setup
this.recline = this.recline || {};
this.recline.View = this.recline.View || {};
@@ -1907,47 +2225,62 @@ this.recline.View = this.recline.View || {};
// var myExplorer = new model.recline.DataExplorer({
// model: {{recline.Model.Dataset instance}}
// el: {{an existing dom element}}
-// views: {{page views}}
-// config: {{config options -- see below}}
+// views: {{dataset views}}
+// state: {{state configuration -- see below}}
// });
// </pre>
//
// ### Parameters
//
-// **model**: (required) Dataset instance.
+// **model**: (required) recline.model.Dataset instance.
//
-// **el**: (required) DOM element.
+// **el**: (required) DOM element to bind to. NB: the element already
+// being in the DOM is important for rendering of some subviews (e.g.
+// Graph).
//
-// **views**: (optional) the views (Grid, Graph etc) for DataExplorer to
-// show. This is an array of view hashes. If not provided
-// just initialize a DataGrid with id 'grid'. Example:
+// **views**: (optional) the dataset views (Grid, Graph etc) for
+// DataExplorer to show. This is an array of view hashes. If not provided
+// initialize with (recline.View.)Grid, Graph, and Map views (with obvious id
+// and labels!).
//
// <pre>
// var views = [
// {
// id: 'grid', // used for routing
// label: 'Grid', // used for view switcher
-// 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
// })
// }
// ];
// </pre>
//
-// **config**: Config options like:
+// **state**: standard state config for this view. This state is slightly
+// special as it includes config of many of the subviews.
//
-// * readOnly: true/false (default: false) value indicating whether to
-// operate in read-only mode (hiding all editing options).
+// <pre>
+// state = {
+// query: {dataset query state - see dataset.queryState object}
+// view-{id1}: {view-state for this view}
+// view-{id2}: {view-state for }
+// ...
+// // Explorer
+// currentView: id of current view (defaults to first view if not specified)
+// readOnly: (default: false) run in read-only mode
+// }
+// </pre>
//
-// NB: the element already being in the DOM is important for rendering of
-// FlotGraph subview.
+// Note that at present we do *not* serialize information about the actual set
+// of views in use -- e.g. those specified by the views argument -- but instead
+// expect either that the default views are fine or that the client to have
+// initialized the DataExplorer with the relevant views themselves.
my.DataExplorer = Backbone.View.extend({
template: ' \
<div class="recline-data-explorer"> \
@@ -1956,7 +2289,7 @@ my.DataExplorer = Backbone.View.extend({
<div class="header"> \
<ul class="navigation"> \
{{#views}} \
- <li><a href="#{{id}}" class="btn">{{label}}</a> \
+ <li><a href="#{{id}}" data-view="{{id}}" class="btn">{{label}}</a> \
{{/views}} \
</ul> \
<div class="recline-results-info"> \
@@ -1979,19 +2312,14 @@ my.DataExplorer = Backbone.View.extend({
</div> \
',
events: {
- 'click .menu-right a': 'onMenuClick'
+ 'click .menu-right a': '_onMenuClick',
+ 'click .navigation a': '_onSwitchView'
},
initialize: function(options) {
var self = this;
this.el = $(this.el);
- this.config = _.extend({
- readOnly: false
- },
- options.config);
- if (this.config.readOnly) {
- this.setReadOnly();
- }
+ this._setupState(options.state);
// Hash of 'page' views (i.e. those for whole page) keyed by page name
if (options.views) {
this.pageViews = options.views;
@@ -1999,13 +2327,38 @@ my.DataExplorer = Backbone.View.extend({
this.pageViews = [{
id: 'grid',
label: 'Grid',
- view: new my.DataGrid({
- model: this.model
- })
+ view: new my.Grid({
+ model: this.model,
+ state: this.state.get('view-grid')
+ }),
+ }, {
+ id: 'graph',
+ label: 'Graph',
+ view: new my.Graph({
+ model: this.model,
+ state: this.state.get('view-graph')
+ }),
+ }, {
+ id: 'map',
+ label: 'Map',
+ view: new my.Map({
+ model: this.model,
+ state: this.state.get('view-map')
+ }),
}];
}
- // this must be called after pageViews are created
+ // these must be called after pageViews are created
this.render();
+ this._bindStateChanges();
+ // now do updates based on state (need to come after render)
+ if (this.state.get('readOnly')) {
+ this.setReadOnly();
+ }
+ if (this.state.get('currentView')) {
+ this.updateNav(this.state.get('currentView'));
+ } else {
+ this.updateNav(this.pageViews[0].id);
+ }
this.router = new Backbone.Router();
this.setupRouting();
@@ -2021,7 +2374,7 @@ my.DataExplorer = Backbone.View.extend({
var qs = my.parseHashQueryString();
qs.reclineQuery = JSON.stringify(self.model.queryState.toJSON());
var out = my.getNewHashForQueryString(qs);
- self.router.navigate(out);
+ // self.router.navigate(out);
});
this.model.bind('query:fail', function(error) {
my.clearNotifications();
@@ -2045,11 +2398,7 @@ my.DataExplorer = Backbone.View.extend({
// note this.model and dataset returned are the same
this.model.fetch()
.done(function(dataset) {
- var queryState = my.parseHashQueryString().reclineQuery;
- if (queryState) {
- queryState = JSON.parse(queryState);
- }
- self.model.query(queryState);
+ self.model.query(self.state.get('query'));
})
.fail(function(error) {
my.notify(error.message, {category: 'error', persist: true});
@@ -2057,12 +2406,11 @@ my.DataExplorer = Backbone.View.extend({
},
setReadOnly: function() {
- this.el.addClass('read-only');
+ this.el.addClass('recline-read-only');
},
render: function() {
var tmplData = this.model.toTemplateJSON();
- tmplData.displayCount = this.config.displayCount;
tmplData.views = this.pageViews;
var template = $.mustache(this.template, tmplData);
$(this.el).html(template);
@@ -2089,20 +2437,22 @@ my.DataExplorer = Backbone.View.extend({
setupRouting: function() {
var self = this;
// Default route
- this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) {
- self.updateNav(self.pageViews[0].id, queryString);
- });
- $.each(this.pageViews, function(idx, view) {
- self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) {
- self.updateNav(viewId, queryString);
- });
+// this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) {
+// self.updateNav(self.pageViews[0].id, queryString);
+// });
+// $.each(this.pageViews, function(idx, view) {
+// self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) {
+// self.updateNav(viewId, queryString);
+// });
+// });
+ this.router.route(/.*/, 'view', function() {
});
},
- updateNav: function(pageName, queryString) {
+ updateNav: function(pageName) {
this.el.find('.navigation li').removeClass('active');
this.el.find('.navigation li a').removeClass('disabled');
- var $el = this.el.find('.navigation li a[href=#' + pageName + ']');
+ var $el = this.el.find('.navigation li a[data-view="' + pageName + '"]');
$el.parent().addClass('active');
$el.addClass('disabled');
// show the specific page
@@ -2117,7 +2467,7 @@ my.DataExplorer = Backbone.View.extend({
});
},
- onMenuClick: function(e) {
+ _onMenuClick: function(e) {
e.preventDefault();
var action = $(e.target).attr('data-action');
if (action === 'filters') {
@@ -2125,9 +2475,78 @@ my.DataExplorer = Backbone.View.extend({
} else if (action === 'facets') {
this.$facetViewer.show();
}
+ },
+
+ _onSwitchView: function(e) {
+ e.preventDefault();
+ var viewName = $(e.target).attr('data-view');
+ this.updateNav(viewName);
+ this.state.set({currentView: viewName});
+ },
+
+ // create a state object for this view and do the job of
+ //
+ // a) initializing it from both data passed in and other sources (e.g. hash url)
+ //
+ // b) ensure the state object is updated in responese to changes in subviews, query etc.
+ _setupState: function(initialState) {
+ var self = this;
+ // get data from the query string / hash url plus some defaults
+ var qs = my.parseHashQueryString();
+ var query = qs.reclineQuery;
+ query = query ? JSON.parse(query) : self.model.queryState.toJSON();
+ // backwards compatability (now named view-graph but was named graph)
+ var graphState = qs['view-graph'] || qs.graph;
+ graphState = graphState ? JSON.parse(graphState) : {};
+
+ // now get default data + hash url plus initial state and initial our state object with it
+ var stateData = _.extend({
+ query: query,
+ 'view-graph': graphState,
+ backend: this.model.backend.__type__,
+ dataset: this.model.toJSON(),
+ currentView: null,
+ readOnly: false
+ },
+ initialState);
+ this.state = new recline.Model.ObjectState(stateData);
+ },
+
+ _bindStateChanges: function() {
+ var self = this;
+ // finally ensure we update our state object when state of sub-object changes so that state is always up to date
+ this.model.queryState.bind('change', function() {
+ self.state.set({query: self.model.queryState.toJSON()});
+ });
+ _.each(this.pageViews, function(pageView) {
+ if (pageView.view.state && pageView.view.state.bind) {
+ var update = {};
+ update['view-' + pageView.id] = pageView.view.state.toJSON();
+ self.state.set(update);
+ pageView.view.state.bind('change', function() {
+ var update = {};
+ update['view-' + pageView.id] = pageView.view.state.toJSON();
+ // had problems where change not being triggered for e.g. grid view so let's do it explicitly
+ self.state.set(update, {silent: true});
+ self.state.trigger('change');
+ });
+ }
+ });
}
});
+// ### DataExplorer.restore
+//
+// Restore a DataExplorer instance from a serialized state including the associated dataset
+my.DataExplorer.restore = function(state) {
+ var dataset = recline.Model.Dataset.restore(state);
+ var explorer = new my.DataExplorer({
+ model: dataset,
+ state: state
+ });
+ return explorer;
+}
+
my.QueryEditor = Backbone.View.extend({
className: 'recline-query-editor',
template: ' \
@@ -2403,6 +2822,9 @@ my.composeQueryString = function(queryParams) {
var queryString = '?';
var items = [];
$.each(queryParams, function(key, value) {
+ if (typeof(value) === 'object') {
+ value = JSON.stringify(value);
+ }
items.push(key + '=' + value);
});
queryString += items.join('&');
@@ -2484,10 +2906,27 @@ this.recline.Backend = this.recline.Backend || {};
// ## recline.Backend.Base
//
// Base class for backends providing a template and convenience functions.
- // You do not have to inherit from this class but even when not it does provide guidance on the functions you must implement.
+ // You do not have to inherit from this class but even when not it does
+ // provide guidance on the functions you must implement.
//
// Note also that while this (and other Backends) are implemented as Backbone models this is just a convenience.
my.Base = Backbone.Model.extend({
+ // ### __type__
+ //
+ // 'type' of this backend. This should be either the class path for this
+ // object as a string (e.g. recline.Backend.Memory) or for Backends within
+ // recline.Backend module it may be their class name.
+ //
+ // This value is used as an identifier for this backend when initializing
+ // backends (see recline.Model.Dataset.initialize).
+ __type__: 'base',
+
+
+ // ### readonly
+ //
+ // Class level attribute indicating that this backend is read-only (that
+ // is, cannot be written to).
+ readonly: true,
// ### sync
//
@@ -2549,6 +2988,32 @@ this.recline.Backend = this.recline.Backend || {};
query: function(model, queryObj) {
},
+ // ### _makeRequest
+ //
+ // Just $.ajax but in any headers in the 'headers' attribute of this
+ // Backend instance. Example:
+ //
+ // <pre>
+ // var jqxhr = this._makeRequest({
+ // url: the-url
+ // });
+ // </pre>
+ _makeRequest: function(data) {
+ var headers = this.get('headers');
+ var extras = {};
+ if (headers) {
+ extras = {
+ beforeSend: function(req) {
+ _.each(headers, function(value, key) {
+ req.setRequestHeader(key, value);
+ });
+ }
+ };
+ }
+ var data = _.extend(extras, data);
+ return $.ajax(data);
+ },
+
// convenience method to convert simple set of documents / rows to a QueryResult
_docsToQueryResult: function(rows) {
var hits = _.map(rows, function(row) {
@@ -2607,6 +3072,8 @@ this.recline.Backend = this.recline.Backend || {};
//
// Note that this is a **read-only** backend.
my.DataProxy = my.Base.extend({
+ __type__: 'dataproxy',
+ readonly: true,
defaults: {
dataproxy_url: 'http://jsonpdataproxy.appspot.com'
},
@@ -2661,8 +3128,6 @@ this.recline.Backend = this.recline.Backend || {};
return dfd.promise();
}
});
- recline.Model.backends['dataproxy'] = new my.DataProxy();
-
}(jQuery, this.recline.Backend));
this.recline = this.recline || {};
@@ -2673,35 +3138,39 @@ this.recline.Backend = this.recline.Backend || {};
//
// Connecting to [ElasticSearch](http://www.elasticsearch.org/).
//
- // To use this backend ensure your Dataset has one of the following
- // attributes (first one found is used):
+ // Usage:
+ //
+ // <pre>
+ // var backend = new recline.Backend.ElasticSearch({
+ // // optional as can also be provided by Dataset/Document
+ // url: {url to ElasticSearch endpoint i.e. ES 'type/table' url - more info below}
+ // // optional
+ // headers: {dict of headers to add to each request}
+ // });
+ //
+ // @param {String} url: url for ElasticSearch type/table, e.g. for ES running
+ // on localhost:9200 with index // twitter and type tweet it would be:
+ //
+ // <pre>http://localhost:9200/twitter/tweet</pre>
+ //
+ // This url is optional since the ES endpoint url may be specified on the the
+ // dataset (and on a Document by the document having a dataset attribute) by
+ // having one of the following (see also `_getESUrl` function):
//
// <pre>
// elasticsearch_url
// webstore_url
// url
// </pre>
- //
- // This should point to the ES type url. E.G. for ES running on
- // localhost:9200 with index twitter and type tweet it would be
- //
- // <pre>http://localhost:9200/twitter/tweet</pre>
my.ElasticSearch = my.Base.extend({
- _getESUrl: function(dataset) {
- var out = dataset.get('elasticsearch_url');
- if (out) return out;
- out = dataset.get('webstore_url');
- if (out) return out;
- out = dataset.get('url');
- return out;
- },
+ __type__: 'elasticsearch',
+ readonly: false,
sync: function(method, model, options) {
var self = this;
if (method === "read") {
if (model.__type__ == 'Dataset') {
- var base = self._getESUrl(model);
- var schemaUrl = base + '/_mapping';
- var jqxhr = $.ajax({
+ var schemaUrl = self._getESUrl(model) + '/_mapping';
+ var jqxhr = this._makeRequest({
url: schemaUrl,
dataType: 'jsonp'
});
@@ -2720,11 +3189,77 @@ this.recline.Backend = this.recline.Backend || {};
dfd.reject(arguments);
});
return dfd.promise();
+ } else if (model.__type__ == 'Document') {
+ var base = this._getESUrl(model.dataset) + '/' + model.id;
+ return this._makeRequest({
+ url: base,
+ dataType: 'json'
+ });
+ }
+ } else if (method === 'update') {
+ if (model.__type__ == 'Document') {
+ return this.upsert(model.toJSON(), this._getESUrl(model.dataset));
+ }
+ } else if (method === 'delete') {
+ if (model.__type__ == 'Document') {
+ var url = this._getESUrl(model.dataset);
+ return this.delete(model.id, url);
}
- } else {
- alert('This backend currently only supports read operations');
}
},
+
+ // ### upsert
+ //
+ // create / update a document to ElasticSearch backend
+ //
+ // @param {Object} doc an object to insert to the index.
+ // @param {string} url (optional) url for ElasticSearch endpoint (if not
+ // defined called this._getESUrl()
+ upsert: function(doc, url) {
+ var data = JSON.stringify(doc);
+ url = url ? url : this._getESUrl();
+ if (doc.id) {
+ url += '/' + doc.id;
+ }
+ return this._makeRequest({
+ url: url,
+ type: 'POST',
+ data: data,
+ dataType: 'json'
+ });
+ },
+
+ // ### delete
+ //
+ // Delete a document from the ElasticSearch backend.
+ //
+ // @param {Object} id id of object to delete
+ // @param {string} url (optional) url for ElasticSearch endpoint (if not
+ // provided called this._getESUrl()
+ delete: function(id, url) {
+ url = url ? url : this._getESUrl();
+ url += '/' + id;
+ return this._makeRequest({
+ url: url,
+ type: 'DELETE',
+ dataType: 'json'
+ });
+ },
+
+ // ### _getESUrl
+ //
+ // get url to ElasticSearch endpoint (see above)
+ _getESUrl: function(dataset) {
+ if (dataset) {
+ var out = dataset.get('elasticsearch_url');
+ if (out) return out;
+ out = dataset.get('webstore_url');
+ if (out) return out;
+ out = dataset.get('url');
+ return out;
+ }
+ return this.get('url');
+ },
_normalizeQuery: function(queryObj) {
var out = queryObj.toJSON ? queryObj.toJSON() : _.extend({}, queryObj);
if (out.q !== undefined && out.q.trim() === '') {
@@ -2761,7 +3296,7 @@ this.recline.Backend = this.recline.Backend || {};
var queryNormalized = this._normalizeQuery(queryObj);
var data = {source: JSON.stringify(queryNormalized)};
var base = this._getESUrl(model);
- var jqxhr = $.ajax({
+ var jqxhr = this._makeRequest({
url: base + '/_search',
data: data,
dataType: 'jsonp'
@@ -2782,7 +3317,6 @@ this.recline.Backend = this.recline.Backend || {};
return dfd.promise();
}
});
- recline.Model.backends['elasticsearch'] = new my.ElasticSearch();
}(jQuery, this.recline.Backend));
@@ -2805,6 +3339,8 @@ this.recline.Backend = this.recline.Backend || {};
// );
// </pre>
my.GDoc = my.Base.extend({
+ __type__: 'gdoc',
+ readonly: true,
getUrl: function(dataset) {
var url = dataset.get('url');
if (url.indexOf('feeds/list') != -1) {
@@ -2922,7 +3458,6 @@ this.recline.Backend = this.recline.Backend || {};
return results;
}
});
- recline.Model.backends['gdocs'] = new my.GDoc();
}(jQuery, this.recline.Backend));
@@ -2930,7 +3465,9 @@ this.recline = this.recline || {};
this.recline.Backend = this.recline.Backend || {};
(function($, my) {
- my.loadFromCSVFile = function(file, callback) {
+ my.loadFromCSVFile = function(file, callback, options) {
+ var encoding = options.encoding || 'UTF-8';
+
var metadata = {
id: file.name,
file: file
@@ -2938,17 +3475,17 @@ this.recline.Backend = this.recline.Backend || {};
var reader = new FileReader();
// TODO
reader.onload = function(e) {
- var dataset = my.csvToDataset(e.target.result);
+ var dataset = my.csvToDataset(e.target.result, options);
callback(dataset);
};
reader.onerror = function (e) {
alert('Failed to load file. Code: ' + e.target.error.code);
};
- reader.readAsText(file);
+ reader.readAsText(file, encoding);
};
- my.csvToDataset = function(csvString) {
- var out = my.parseCSV(csvString);
+ my.csvToDataset = function(csvString, options) {
+ var out = my.parseCSV(csvString, options);
fields = _.map(out[0], function(cell) {
return { id: cell, label: cell };
});
@@ -2963,128 +3500,135 @@ this.recline.Backend = this.recline.Backend || {};
return dataset;
};
- // Converts a Comma Separated Values string into an array of arrays.
- // Each line in the CSV becomes an array.
+ // Converts a Comma Separated Values string into an array of arrays.
+ // Each line in the CSV becomes an array.
//
- // Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats.
- //
- // @return The CSV parsed as an array
- // @type Array
- //
- // @param {String} s The string to convert
- // @param {Boolean} [trm=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported
+ // Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats.
//
+ // @return The CSV parsed as an array
+ // @type Array
+ //
+ // @param {String} s The string to convert
+ // @param {Object} options Options for loading CSV including
+ // @param {Boolean} [trim=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported
+ // @param {String} [separator=','] Separator for CSV file
// Heavily based on uselesscode's JS CSV parser (MIT Licensed):
// thttp://www.uselesscode.org/javascript/csv/
- my.parseCSV= function(s, trm) {
- // Get rid of any trailing \n
- s = chomp(s);
-
- var cur = '', // The character we are currently processing.
- inQuote = false,
- fieldQuoted = false,
- field = '', // Buffer for building up the current field
- row = [],
- out = [],
- i,
- processField;
-
- processField = function (field) {
- if (fieldQuoted !== true) {
- // If field is empty set to null
- if (field === '') {
- field = null;
- // If the field was not quoted and we are trimming fields, trim it
- } else if (trm === true) {
- field = trim(field);
- }
-
- // Convert unquoted numbers to their appropriate types
- if (rxIsInt.test(field)) {
- field = parseInt(field, 10);
- } else if (rxIsFloat.test(field)) {
- field = parseFloat(field, 10);
- }
- }
- return field;
- };
+ my.parseCSV= function(s, options) {
+ // Get rid of any trailing \n
+ s = chomp(s);
+
+ var options = options || {};
+ var trm = options.trim;
+ var separator = options.separator || ',';
+ var delimiter = options.delimiter || '"';
+
+
+ var cur = '', // The character we are currently processing.
+ inQuote = false,
+ fieldQuoted = false,
+ field = '', // Buffer for building up the current field
+ row = [],
+ out = [],
+ i,
+ processField;
+
+ processField = function (field) {
+ if (fieldQuoted !== true) {
+ // If field is empty set to null
+ if (field === '') {
+ field = null;
+ // If the field was not quoted and we are trimming fields, trim it
+ } else if (trm === true) {
+ field = trim(field);
+ }
- for (i = 0; i < s.length; i += 1) {
- cur = s.charAt(i);
-
- // If we are at a EOF or EOR
- if (inQuote === false && (cur === ',' || cur === "\n")) {
- field = processField(field);
- // Add the current field to the current row
- row.push(field);
- // If this is EOR append row to output and flush row
- if (cur === "\n") {
- out.push(row);
- row = [];
- }
- // Flush the field buffer
- field = '';
- fieldQuoted = false;
- } else {
- // If it's not a ", add it to the field buffer
- if (cur !== '"') {
- field += cur;
- } else {
- if (!inQuote) {
- // We are not in a quote, start a quote
- inQuote = true;
- fieldQuoted = true;
- } else {
- // Next char is ", this is an escaped "
- if (s.charAt(i + 1) === '"') {
- field += '"';
- // Skip the next char
- i += 1;
- } else {
- // It's not escaping, so end quote
- inQuote = false;
- }
- }
- }
- }
- }
-
- // Add the last field
- field = processField(field);
- row.push(field);
- out.push(row);
-
- return out;
- };
-
- var rxIsInt = /^\d+$/,
- rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,
- // If a string has leading or trailing space,
- // contains a comma double quote or a newline
- // it needs to be quoted in CSV output
- rxNeedsQuoting = /^\s|\s$|,|"|\n/,
- trim = (function () {
- // Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists
- if (String.prototype.trim) {
- return function (s) {
- return s.trim();
- };
- } else {
- return function (s) {
- return s.replace(/^\s*/, '').replace(/\s*$/, '');
- };
- }
- }());
-
- function chomp(s) {
- if (s.charAt(s.length - 1) !== "\n") {
- // Does not end with \n, just return string
- return s;
- } else {
- // Remove the \n
- return s.substring(0, s.length - 1);
- }
- }
+ // Convert unquoted numbers to their appropriate types
+ if (rxIsInt.test(field)) {
+ field = parseInt(field, 10);
+ } else if (rxIsFloat.test(field)) {
+ field = parseFloat(field, 10);
+ }
+ }
+ return field;
+ };
+
+ for (i = 0; i < s.length; i += 1) {
+ cur = s.charAt(i);
+
+ // If we are at a EOF or EOR
+ if (inQuote === false && (cur === separator || cur === "\n")) {
+ field = processField(field);
+ // Add the current field to the current row
+ row.push(field);
+ // If this is EOR append row to output and flush row
+ if (cur === "\n") {
+ out.push(row);
+ row = [];
+ }
+ // Flush the field buffer
+ field = '';
+ fieldQuoted = false;
+ } else {
+ // If it's not a delimiter, add it to the field buffer
+ if (cur !== delimiter) {
+ field += cur;
+ } else {
+ if (!inQuote) {
+ // We are not in a quote, start a quote
+ inQuote = true;
+ fieldQuoted = true;
+ } else {
+ // Next char is delimiter, this is an escaped delimiter
+ if (s.charAt(i + 1) === delimiter) {
+ field += delimiter;
+ // Skip the next char
+ i += 1;
+ } else {
+ // It's not escaping, so end quote
+ inQuote = false;
+ }
+ }
+ }
+ }
+ }
+
+ // Add the last field
+ field = processField(field);
+ row.push(field);
+ out.push(row);
+
+ return out;
+ };
+
+ var rxIsInt = /^\d+$/,
+ rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,
+ // If a string has leading or trailing space,
+ // contains a comma double quote or a newline
+ // it needs to be quoted in CSV output
+ rxNeedsQuoting = /^\s|\s$|,|"|\n/,
+ trim = (function () {
+ // Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists
+ if (String.prototype.trim) {
+ return function (s) {
+ return s.trim();
+ };
+ } else {
+ return function (s) {
+ return s.replace(/^\s*/, '').replace(/\s*$/, '');
+ };
+ }
+ }());
+
+ function chomp(s) {
+ if (s.charAt(s.length - 1) !== "\n") {
+ // Does not end with \n, just return string
+ return s;
+ } else {
+ // Remove the \n
+ return s.substring(0, s.length - 1);
+ }
+ }
}(jQuery, this.recline.Backend));
@@ -3110,7 +3654,7 @@ this.recline.Backend = this.recline.Backend || {};
if (!metadata.id) {
metadata.id = String(Math.floor(Math.random() * 100000000) + 1);
}
- var backend = recline.Model.backends['memory'];
+ var backend = new recline.Backend.Memory();
var datasetInfo = {
documents: data,
metadata: metadata
@@ -3125,7 +3669,7 @@ this.recline.Backend = this.recline.Backend || {};
}
}
backend.addDataset(datasetInfo);
- var dataset = new recline.Model.Dataset({id: metadata.id}, 'memory');
+ var dataset = new recline.Model.Dataset({id: metadata.id}, backend);
dataset.fetch();
return dataset;
};
@@ -3160,6 +3704,8 @@ this.recline.Backend = this.recline.Backend || {};
// etc ...
// </pre>
my.Memory = my.Base.extend({
+ __type__: 'memory',
+ readonly: false,
initialize: function() {
this.datasets = {};
},
@@ -3207,13 +3753,9 @@ this.recline.Backend = this.recline.Backend || {};
var out = {};
var numRows = queryObj.size;
var start = queryObj.from;
- results = this.datasets[model.id].documents;
- _.each(queryObj.filters, function(filter) {
- results = _.filter(results, function(doc) {
- var fieldId = _.keys(filter.term)[0];
- return (doc[fieldId] == filter.term[fieldId]);
- });
- });
+ var results = this.datasets[model.id].documents;
+ results = this._applyFilters(results, queryObj);
+ results = this._applyFreeTextQuery(model, results, queryObj);
// not complete sorting!
_.each(queryObj.sort, function(sortObj) {
var fieldName = _.keys(sortObj)[0];
@@ -3231,6 +3773,43 @@ this.recline.Backend = this.recline.Backend || {};
return dfd.promise();
},
+ // in place filtering
+ _applyFilters: function(results, queryObj) {
+ _.each(queryObj.filters, function(filter) {
+ results = _.filter(results, function(doc) {
+ var fieldId = _.keys(filter.term)[0];
+ return (doc[fieldId] == filter.term[fieldId]);
+ });
+ });
+ return results;
+ },
+
+ // we OR across fields but AND across terms in query string
+ _applyFreeTextQuery: function(dataset, results, queryObj) {
+ if (queryObj.q) {
+ var terms = queryObj.q.split(' ');
+ results = _.filter(results, function(rawdoc) {
+ var matches = true;
+ _.each(terms, function(term) {
+ var foundmatch = false;
+ dataset.fields.each(function(field) {
+ var value = rawdoc[field.id];
+ if (value !== null) { value = value.toString(); }
+ // TODO regexes?
+ foundmatch = foundmatch || (value === term);
+ // TODO: early out (once we are true should break to spare unnecessary testing)
+ // if (foundmatch) return true;
+ });
+ matches = matches && foundmatch;
+ // TODO: early out (once false should break to spare unnecessary testing)
+ // if (!matches) return false;
+ });
+ return matches;
+ });
+ }
+ return results;
+ },
+
_computeFacets: function(documents, queryObj) {
var facetResults = {};
if (!queryObj.facets) {
@@ -3267,6 +3846,5 @@ this.recline.Backend = this.recline.Backend || {};
return facetResults;
}
});
- recline.Model.backends['memory'] = new my.Memory();
}(jQuery, this.recline.Backend));
diff --git a/ckan/templates/group/read.html b/ckan/templates/group/read.html
index 7886bdd..c474402 100644
--- a/ckan/templates/group/read.html
+++ b/ckan/templates/group/read.html
@@ -43,48 +43,12 @@ <h3 py:if="c.group['state'] != 'active'">State: ${c.group['state']}</h3>
</div>
<div class="group-dataset-list">
<h3>Datasets</h3>
-
- <form id="dataset-search" class="dataset-search" method="GET">
- <input type="search" class="search" name="q" value="${c.q}" autocomplete="off" results="0" placeholder="${_('Search')}..." />
- <py:for each="(k, v) in c.fields">
- <input type="hidden" name="${k}" value="${v}" />
- </py:for>
- <input type="submit" value="${_('Search')}" class="btn primary button" />
- </form>
+ <xi:include href="../package/search_form.html" />
${field_list()}
<p i18n:msg="query, number_of_results"><span py:if="c.q">You searched for "${c.q}". </span>${c.page.item_count} datasets found.</p>
${c.page.pager()}
-
- <py:for each="package in c.page.items">
- <div class="search-result ${'fullyopen' if (package.isopen and package.get('resources')) else None}">
- <p class="extra-links">
- <a class="view-more-link" href="${h.url_for(controller='package', action='read', id=package.get('name'))}">View</a>
- </p>
- <a class="main-link" href="${h.url_for(controller='package', action='read', id=package.get('name'))}">${package.get('title') or package.get('name')}</a>
-
- <py:if test="package.resources">
- <py:for each="resource in package.resources">
- <py:if test="resource.get('format')">
- <a href="${resource.get('url')}"
- title="${resource.get('description')}"><span class="format-box">${resource.get('format')}</span></a>
- </py:if>
- </py:for>
- </py:if>
- <p class="result-description">${h.markdown_extract(package.notes)}</p>
-
- <span class="result-url">
- <py:if test="package.isopen">
- <a href="http://opendefinition.org/okd/" title="This dataset satisfies the Open Definition.">
- <img src="http://assets.okfn.org/images/ok_buttons/od_80x15_blue.png" alt="[Open Data]" />
- </a>
- </py:if>
- <py:if test="not package.isopen">
- ${h.icon('lock')} Not Openly Licensed
- </py:if>
- </span>
- </div>
- </py:for>
+ ${package_list_from_dict(c.page.items)}
${c.page.pager()}
</div>
</py:match>
diff --git a/ckan/templates/layout_base.html b/ckan/templates/layout_base.html
index 1518d72..a983d4b 100644
--- a/ckan/templates/layout_base.html
+++ b/ckan/templates/layout_base.html
@@ -266,6 +266,28 @@ <h3 class="widget-title">Meta</h3>
});
</script>
+ <script type="text/javascript">
+ $(function (){
+ // Tracking
+ var url = location.pathname;
+ // remove any site root from url
+ url = url.substring(CKAN.SITE_URL.length, url.length - 1);
+ $.ajax({url : CKAN.SITE_URL + '/_tracking',
+ type : 'POST',
+ data : {url:url, type:'page'},
+ timeout : 300 });
+ $('a.resource-url-analytics').click(function (e){
+ var url = $(e.target).closest('a').attr('href');
+ $.ajax({url : CKAN.SITE_URL + '/_tracking',
+ data : {url:url, type:'resource'},
+ type : 'POST',
+ complete : function () {location.href = url;},
+ timeout : 30});
+ e.preventDefault();
+ });
+ });
+ </script>
+
<py:if test="defined('optional_footer')">
${optional_footer()}
</py:if>
diff --git a/ckan/templates/package/layout.html b/ckan/templates/package/layout.html
index ea07cbb..5015093 100644
--- a/ckan/templates/package/layout.html
+++ b/ckan/templates/package/layout.html
@@ -27,7 +27,7 @@
<hr py:if="len(c.pkg_dict.get('resources',[]))>0"/>
</li>
<li py:for="res in c.pkg_dict.get('resources', [])">
- <a href="${h.url_for(controller='package', action='resource_read', id=c.pkg_dict['name'], resource_id=res['id'])}">${h.resource_icon(res) + h.resource_display_name(res)}</a>
+ <a href="${h.url_for(controller='package', action='resource_read', id=c.pkg_dict['name'], resource_id=res['id'])}">${h.resource_icon(res) + h.resource_display_name(res)}</a>
</li>
</ul>
</div>
diff --git a/ckan/templates/package/resource_embedded_dataviewer.html b/ckan/templates/package/resource_embedded_dataviewer.html
new file mode 100644
index 0000000..5022a4d
--- /dev/null
+++ b/ckan/templates/package/resource_embedded_dataviewer.html
@@ -0,0 +1,98 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:i18n="http://genshi.edgewall.org/i18n"
+ xmlns:py="http://genshi.edgewall.org/"
+ xmlns:xi="http://www.w3.org/2001/XInclude"
+ py:strip="">
+
+ <py:def function="optional_head">
+ <!-- data preview -->
+ <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/leaflet/0.3.1/leaflet.css')}" />
+ <!--[if lte IE 8]>
+ <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/leaflet/0.3.1/leaflet.ie.css')}" />
+ <![endif]-->
+ <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/data-explorer.css')}" />
+ <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/graph.css')}" />
+ <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/map.css')}" />
+ <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/grid.css')}" />
+ <style type="text/css">
+
+ /* Hide the query controls */
+ .header {
+ display: none;
+ }
+
+ /* Hide CKAN footer */
+ .footer.outer {
+ display: none;
+ }
+
+ /* Don't center the main container. And provide a little space to the
+ left and above the viewer. This is for the graph-view, which, if a
+ small amount of room is not given, the y-axis' labels are uncomfortably
+ close to the edge of the viewport.
+ */
+ #main.container {
+ width: auto;
+ margin-left: 2px;
+ }
+
+ /* Remove the border from the right-hand-side */
+ #content {
+ border: 0px;
+ }
+
+ #ckanext-datapreview {
+ width: ${c.width-2}px;
+ height: ${c.height-81}px;
+ }
+
+ .recline-grid-container {
+ height: ${c.height-81}px;
+ }
+
+ .recline-graph .graph {
+ height: ${c.height-81}px;
+ }
+
+ .recline-map .map {
+ height: ${c.height-81}px;
+ }
+
+ .branded-link {
+ height: 34px;
+ }
+
+ .alert-messages {
+ display: none;
+ }
+
+ </style>
+ <script type="text/javascript">
+ var preload_resource = ${h.literal(c.resource_json)};
+ var reclineState = ${h.literal(c.recline_state)};
+ </script>
+ </py:def>
+
+ <py:def function="page_title">
+ ${h.dataset_display_name(c.package)} /
+ ${h.resource_display_name(c.resource)} - Dataset - Resource
+ </py:def>
+
+ <div py:match="content">
+ <div class="resource-preview">
+ <div id="ckanext-datapreview"></div>
+ </div>
+ <xi:include href="../snippets/data-viewer-embed-branded-link.html" />
+ </div>
+
+ <py:def function="optional_footer">
+ <!-- data preview -->
+ <script type="text/javascript" src="${h.url_for_static('/scripts/vendor/jquery.mustache/jquery.mustache.js')}"></script>
+ <script type="text/javascript" src="${h.url_for_static('/scripts/vendor/flot/0.7/jquery.flot.js')}"></script>
+ <script type="text/javascript" src="${h.url_for_static('/scripts/vendor/flot/0.7/jquery.flot.js')}"></script>
+ <script type="text/javascript" src="${h.url_for_static('/scripts/vendor/leaflet/0.3.1/leaflet.js')}"></script>
+ <script src="${h.url_for_static('/scripts/vendor/recline/recline.js')}"></script>
+ </py:def>
+
+ <xi:include href="../layout_base.html" />
+</html>
diff --git a/ckan/templates/package/resource_read.html b/ckan/templates/package/resource_read.html
index b6c30e6..619c636 100644
--- a/ckan/templates/package/resource_read.html
+++ b/ckan/templates/package/resource_read.html
@@ -12,6 +12,7 @@
py:strip="">
<xi:include href="../_snippet/data-api-help.html" />
+ <xi:include href="../snippets/data-viewer-embed-dialog.html" />
<py:def function="optional_head">
<!-- data preview -->
@@ -20,8 +21,9 @@
<link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/leaflet/0.3.1/leaflet.ie.css')}" />
<![endif]-->
<link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/data-explorer.css')}" />
- <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/graph-flot.css')}" />
+ <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/graph.css')}" />
<link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/map.css')}" />
+ <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/grid.css')}" />
<style type="text/css">
.recline-query-editor form, .recline-query-editor .text-query {
height: 28px;
@@ -58,9 +60,23 @@
.resource-actions .download img {
margin: 0px 4px -4px 0;
}
+ .preview-header {
+ padding-bottom: 13px;
+ padding-top: 0px;
+ }
+ .preview-header h3 {
+ display: inline;
+ }
+ .preview-header .btn {
+ float: right;
+ position: relative;
+ bottom: 6px;
+ padding: 8px 15px;
+ }
</style>
<script type="text/javascript">
var preload_resource = ${h.literal(c.resource_json)};
+ var embedPath = "${g.site_url+h.url_for(controller='package', action='resource_embedded_dataviewer', id=c.package.id, resource_id=c.resource.id)}";
</script>
</py:def>
@@ -104,6 +120,8 @@
${data_api_help(c.datastore_api)}
</py:if>
+ ${data_viewer_embed_dialog()}
+
<div class="quick-info">
<dl>
<dt>Last updated</dt>
@@ -151,7 +169,11 @@
</div>
<div class="resource-preview">
- <h3>Preview</h3>
+ <div class="preview-header">
+ <h3>Preview</h3>
+ <a py:if="c.pkg.is_private" title="Cannot embed as resource is private." style="display: none;" class="btn disabled" data-toggle="modal" href=".modal-data-viewer-embed-dialog">Embed</a>
+ <a py:if="not c.pkg.is_private" style="display: none;" class="btn btn-primary" data-toggle="modal" href=".modal-data-viewer-embed-dialog">Embed</a>
+ </div>
<div id="ckanext-datapreview"></div>
</div>
diff --git a/ckan/templates/package/search.html b/ckan/templates/package/search.html
index e503295..cee817e 100644
--- a/ckan/templates/package/search.html
+++ b/ckan/templates/package/search.html
@@ -46,7 +46,6 @@
<div py:match="content">
<xi:include href="search_form.html" />
${field_list()}
-
<py:if test="c.query_error">
<p i18n:msg="item_count"><strong>There was an error while searching.</strong>
Please try again.</p>
diff --git a/ckan/templates/package/search_form.html b/ckan/templates/package/search_form.html
index 5313687..ba15882 100644
--- a/ckan/templates/package/search_form.html
+++ b/ckan/templates/package/search_form.html
@@ -5,7 +5,7 @@
py:strip=""
>
-<form id="dataset-search" class="dataset-search" action="${h.url_for(controller='package', action='search')}" method="GET">
+<form id="dataset-search" class="dataset-search" method="GET">
<input type="search" class="search" name="q" value="${c.q}" autocomplete="off" results="0" placeholder="${_('Search...')}" />
<span py:if="c.fields">
<py:for each="(k, v) in c.fields">
diff --git a/ckan/templates/snippets/data-viewer-embed-branded-link.html b/ckan/templates/snippets/data-viewer-embed-branded-link.html
new file mode 100644
index 0000000..4bd0e45
--- /dev/null
+++ b/ckan/templates/snippets/data-viewer-embed-branded-link.html
@@ -0,0 +1,19 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:i18n="http://genshi.edgewall.org/i18n"
+ xmlns:py="http://genshi.edgewall.org/"
+ xmlns:xi="http://www.w3.org/2001/XInclude"
+ py:strip=""
+ >
+
+<div class="branded-link">
+ <p>Powered by <a href="${h.url_for(controller='package', action='resource_read', id=c.package.id, resource_id=c.resource.id)}">
+
+ ${g.site_title}
+ <img width="28" src="${h.url_for_static(g.site_logo)}" alt="${g.site_title} Logo" title="${g.site_title} Logo" id="logo" />
+ </a>
+ </p>
+</div>
+
+</html>
+
diff --git a/ckan/templates/snippets/data-viewer-embed-dialog.html b/ckan/templates/snippets/data-viewer-embed-dialog.html
new file mode 100644
index 0000000..b0b382d
--- /dev/null
+++ b/ckan/templates/snippets/data-viewer-embed-dialog.html
@@ -0,0 +1,31 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:i18n="http://genshi.edgewall.org/i18n"
+ xmlns:py="http://genshi.edgewall.org/"
+ xmlns:xi="http://www.w3.org/2001/XInclude"
+ py:strip=""
+ >
+
+<div class="modal-data-viewer-embed-dialog modal fade in" style="display: none;"
+ py:def="data_viewer_embed_dialog()">
+ <div class="modal-header">
+ <a class="close" data-dismiss="modal">×</a>
+ <h3>
+ Embed Data Viewer
+ </h3>
+ </div>
+ <div class="modal-body">
+ <div>
+ <p><strong>Embed this view</strong> by copying this into your webpage:</p>
+ <textarea class="embedIframeText" style="width: 100%; height: 200px;"></textarea>
+ <p>Choose width and height in pixels:</p>
+ <label for="iframe-width">Width:</label>
+ <input class="iframe-width" name="iframe-width" value="800"/>
+ <label for="iframe-height">Height:</label>
+ <input class="iframe-height" name="iframe-height" value="500"/>
+ </div>
+ <a class="embedLink" href="">Preview</a>
+ </div>
+</div>
+
+</html>
diff --git a/ckan/tests/functional/test_pagination.py b/ckan/tests/functional/test_pagination.py
index a15f2f9..fb2e5ca 100644
--- a/ckan/tests/functional/test_pagination.py
+++ b/ckan/tests/functional/test_pagination.py
@@ -13,7 +13,7 @@ def scrape_search_results(response, object_type):
str(response))
else:
object_type = 'dataset'
- results = re.findall('class="main-link" href="/%s/%s_(\d\d)"' % (object_type, object_type),
+ results = re.findall('href="/%s/%s_(\d\d)"' % (object_type, object_type),
str(response))
return results
@@ -31,23 +31,6 @@ def test_scrape_user():
res = scrape_search_results(html, 'user')
assert_equal(res, ['00', '01'])
-def test_scrape_group_dataset():
- html = '''
- <div class="search-result ">
- <a class="view-more-link" href="/dataset/dataset_13">View</a>
- <a class="main-link" href="/dataset/dataset_13">dataset_13</a>
-
- <p class="result-description"></p>
-
- <span class="result-url">
-
- <img src="/images/icons/lock.png" height="16px" width="16px" alt="None" /> Not Openly Licensed
-
- </span>
- </div>
- '''
- res = scrape_search_results(html, 'group_dataset')
- assert_equal(res, ['13'])
class TestPaginationPackage(TestController):
@classmethod
diff --git a/ckan/tests/functional/test_search.py b/ckan/tests/functional/test_search.py
index fe1802c..a9a9339 100644
--- a/ckan/tests/functional/test_search.py
+++ b/ckan/tests/functional/test_search.py
@@ -108,7 +108,7 @@ def test_search_foreign_chars(self):
res = self.app.get(offset)
assert 'Search - ' in res
self._check_search_results(res, u'th\xfcmb', ['<strong>1</strong>'])
- self._check_search_results(res, 'thumb', ['<strong>0</strong>'])
+ self._check_search_results(res, 'thumb', ['<strong>1</strong>'])
@search_related
def test_search_escape_chars(self):
diff --git a/ckan/tests/lib/test_dictization.py b/ckan/tests/lib/test_dictization.py
index d32fd89..61fab80 100644
--- a/ckan/tests/lib/test_dictization.py
+++ b/ckan/tests/lib/test_dictization.py
@@ -80,6 +80,7 @@ def setup_class(cls):
u'size': None,
u'size_extra': u'123',
u'state': u'active',
+ u'tracking_summary': {'total': 0, 'recent': 0},
u'url': u'http://www.annakarenina.com/download/x=1&y=2',
u'webstore_last_updated': None,
u'webstore_url': None},
@@ -98,6 +99,7 @@ def setup_class(cls):
u'size': None,
u'size_extra': u'345',
u'state': u'active',
+ u'tracking_summary': {'total': 0, 'recent': 0},
u'url': u'http://www.annakarenina.com/index.json',
u'webstore_last_updated': None,
u'webstore_url': None}],
@@ -110,6 +112,7 @@ def setup_class(cls):
{'name': u'tolstoy', 'display_name': u'tolstoy',
'state': u'active'}],
'title': u'A Novel By Tolstoy',
+ 'tracking_summary': {'total': 0, 'recent': 0},
'url': u'http://www.annakarenina.com',
'version': u'0.7a'}
@@ -194,6 +197,7 @@ def test_01_dictize_main_objects_simple(self):
'size': None,
u'size_extra': u'123',
'state': u'active',
+ u'tracking_summary': {'total': 0, 'recent': 0},
'url': u'http://www.annakarenina.com/download/x=1&y=2',
'webstore_last_updated': None,
'webstore_url': None
@@ -706,6 +710,7 @@ def test_13_get_package_in_past(self):
u'resource_type': None,
u'size': None,
u'state': u'active',
+ u'tracking_summary': {'total': 0, 'recent': 0},
u'url': u'newurl',
u'webstore_last_updated': None,
u'webstore_url': None})
@@ -735,6 +740,7 @@ def test_14_resource_no_id(self):
'hash': u'abc123',
'description': u'Full text. Needs escaping: " Umlaut: \xfc',
'format': u'plain text',
+ 'tracking_summary': {'recent': 0, 'total': 0},
'url': u'test_new',
'cache_url': None,
'webstore_url': None,
diff --git a/ckan/tests/lib/test_resource_search.py b/ckan/tests/lib/test_resource_search.py
index 8562bf3..3d44757 100644
--- a/ckan/tests/lib/test_resource_search.py
+++ b/ckan/tests/lib/test_resource_search.py
@@ -120,7 +120,7 @@ def test_12_search_all_fields(self):
assert isinstance(res_dict, dict)
res_keys = set(res_dict.keys())
expected_res_keys = set(model.Resource.get_columns())
- expected_res_keys.update(['id', 'resource_group_id', 'package_id', 'position', 'size_extra'])
+ expected_res_keys.update(['id', 'resource_group_id', 'package_id', 'position', 'size_extra', 'tracking_summary'])
assert_equal(res_keys, expected_res_keys)
pkg1 = model.Package.by_name(u'pkg1')
ab = pkg1.resources[0]
diff --git a/ckan/tests/lib/test_solr_package_search.py b/ckan/tests/lib/test_solr_package_search.py
index 6ec2b2f..75d54c0 100644
--- a/ckan/tests/lib/test_solr_package_search.py
+++ b/ckan/tests/lib/test_solr_package_search.py
@@ -292,7 +292,7 @@ def test_search_foreign_chars(self):
result = search.query_for(model.Package).run({'q': 'umlaut'})
assert result['results'] == ['gils'], result['results']
result = search.query_for(model.Package).run({'q': u'thumb'})
- assert result['count'] == 0, result['results']
+ assert result['results'] == ['gils'], result['results']
result = search.query_for(model.Package).run({'q': u'th\xfcmb'})
assert result['results'] == ['gils'], result['results']
diff --git a/ckanext/multilingual/solr/schema.xml b/ckanext/multilingual/solr/schema.xml
index 8475187..fb957d3 100644
--- a/ckanext/multilingual/solr/schema.xml
+++ b/ckanext/multilingual/solr/schema.xml
@@ -16,7 +16,7 @@
limitations under the License.
-->
-<schema name="ckan" version="1.3">
+<schema name="ckan" version="1.4">
<types>
<fieldType name="string" class="solr.StrField" sortMissingLast="true" omitNorms="true"/>
@@ -373,6 +373,8 @@
<field name="tags" type="string" indexed="true" stored="true" multiValued="true"/>
<field name="groups" type="string" indexed="true" stored="true" multiValued="true"/>
+ <field name="capacity" type="string" indexed="true" stored="true" multiValued="false"/>
+
<field name="res_description" type="textgen" indexed="true" stored="true" multiValued="true"/>
<field name="res_format" type="string" indexed="true" stored="true" multiValued="true"/>
<field name="res_url" type="string" indexed="true" stored="true" multiValued="true"/>
@@ -390,11 +392,19 @@
<field name="linked_from" type="text" indexed="true" stored="false" multiValued="true"/>
<field name="child_of" type="text" indexed="true" stored="false" multiValued="true"/>
<field name="parent_of" type="text" indexed="true" stored="false" multiValued="true"/>
+ <field name="views_total" type="int" indexed="true" stored="false"/>
+ <field name="views_recent" type="int" indexed="true" stored="false"/>
+ <field name="resources_accessed_total" type="int" indexed="true" stored="false"/>
+ <field name="resources_accessed_recent" type="int" indexed="true" stored="false"/>
<field name="metadata_created" type="date" indexed="true" stored="true" multiValued="false"/>
<field name="metadata_modified" type="date" indexed="true" stored="true" multiValued="false"/>
<field name="indexed_ts" type="date" indexed="true" stored="true" default="NOW" multiValued="false"/>
+
+ <!-- Copy the title field into titleString, and treat as a string
+ (rather than text type). This allows us to sort on the titleString -->
+ <field name="title_string" type="string" indexed="true" stored="false" />
<!-- Multilingual -->
<field name="text_en" type="text_en" indexed="true" stored="true"/>
@@ -424,6 +434,8 @@
<field name="text_pl" type="text_pl" indexed="true" stored="true"/>
<field name="title_pl" type="text_pl" indexed="true" stored="true"/>
+ <field name="data_dict" type="string" indexed="false" stored="true" />
+
<dynamicField name="extras_*" type="text" indexed="true" stored="true" multiValued="false"/>
<dynamicField name="*" type="string" indexed="true" stored="false"/>
</fields>
diff --git a/doc/index.rst b/doc/index.rst
index b1c71f0..601d4c7 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -2,6 +2,10 @@
Welcome to CKAN's Administration Guide
=======================================
+.. note ::
+
+ This is the documentation for CKAN version '|version|'. If you are using a different version, use the links on the bottom right corner of the page to select the appropriate documentation.
+
This Administration Guide covers how to set up and manage `CKAN <http://ckan.org>`_ software.
* The first two sections cover your two options for installing CKAN: package or source install.
diff --git a/doc/using-data-api.rst b/doc/using-data-api.rst
index 09caf21..50c7db2 100644
--- a/doc/using-data-api.rst
+++ b/doc/using-data-api.rst
@@ -2,22 +2,44 @@
Using the Data API
==================
+The following provides an introduction to using the CKAN :doc:`DataStore
+<datastore>` Data API.
+
Introduction
============
-The Data API builds directly on ElasticSearch, with a resource API endpoint
-being equivalent to a single index 'type' in ElasticSearch (we tend to refer to
-it as a 'table'). This means you can often directly re-use `ElasticSearch
-client libraries`_ when connecting to the API endpoint.
+Each 'table' in the DataStore is an ElasticSearch_ index type ('table'). As
+such the Data API for each CKAN resource is directly equivalent to a single
+index 'type' in ElasticSearch (we tend to refer to it as a 'table').
+
+This means you can (usually) directly re-use `ElasticSearch client libraries`_
+when connecting to a Data API endpoint. It also means that what follows is, in
+essence, a tutorial in using the ElasticSearch_ API.
+
+The following short set of slides provide a brief overview and introduction to
+the DataStore and the Data API.
-Furthermore, it means that what is presented below is essentially a tutorial in the ElasticSearch API.
+.. raw:: html
+ <iframe src="https://docs.google.com/presentation/embed?id=1UhEqvEPoL_VWO5okYiEPfZTLcLYWqtvRRmB1NBsWXY8&start=false&loop=false&delayms=3000" frameborder="0" width="480" height="389" allowfullscreen="true" mozallowfullscreen="true" webkitallowfullscreen="true"></iframe>
+
+.. _ElasticSearch: http://elasticsearch.org/
.. _ElasticSearch client libraries: http://www.elasticsearch.org/guide/appendix/clients.html
Quickstart
==========
-``{{endpoint}}`` refers to the data API endpoint (or ElasticSearch index / table).
+``{{endpoint}}`` refers to the data API endpoint (or ElasticSearch index /
+table). For example, on the DataHub_ this gold prices data resource
+http://datahub.io/dataset/gold-prices/resource/b9aae52b-b082-4159-b46f-7bb9c158d013
+would have its Data API endpoint at:
+http://datahub.io/api/data/b9aae52b-b082-4159-b46f-7bb9c158d013. If you were
+just using ElasticSearch standalone an example of an endpoint would be:
+http://localhost:9200/gold-prices/monthly-price-table.
+
+.. note:: every resource on a CKAN instance for which a DataStore table is
+ enabled provides links to its Data API endpoint via the Data API
+ button at the top right of the resource page.
Key urls:
@@ -28,6 +50,8 @@ Key urls:
* Schema (Mapping): ``{{endpoint}}/_mapping``
+.. _DataHub: http://datahub.io/
+
Examples
--------
diff --git a/pip-requirements.txt b/pip-requirements.txt
index 4460f14..9ff9d10 100644
--- a/pip-requirements.txt
+++ b/pip-requirements.txt
@@ -5,11 +5,11 @@
#
# pip install --ignore-installed -r pip-requirements.txt
--e git+https://github.com/okfn/ckan@release-v1.6.1#egg=ckan
+-e git+https://github.com/okfn/ckan@master#egg=ckan
# CKAN dependencies
--r https://github.com/okfn/ckan/raw/release-v1.6.1/requires/lucid_conflict.txt
--r https://github.com/okfn/ckan/raw/release-v1.6.1/requires/lucid_present.txt
--r https://github.com/okfn/ckan/raw/release-v1.6.1/requires/lucid_missing.txt
+-r https://github.com/okfn/ckan/raw/master/requires/lucid_conflict.txt
+-r https://github.com/okfn/ckan/raw/master/requires/lucid_present.txt
+-r https://github.com/okfn/ckan/raw/master/requires/lucid_missing.txt
# NOTE: Developers, please do not edit this file. Changes should go in the
# appropriate files in the `requires' directory.
diff --git a/setup.py b/setup.py
index cd37609..d1e857d 100644
--- a/setup.py
+++ b/setup.py
@@ -71,6 +71,7 @@
roles = ckan.lib.authztool:RolesCommand
celeryd = ckan.lib.cli:Celery
rdf-export = ckan.lib.cli:RDFExport
+ tracking = ckan.lib.cli:Tracking
plugin-info = ckan.lib.cli:PluginInfo
[console_scripts]
================================================================
Commit: 71887e3d90d0c683f7dad5153311fda20ef361df
https://github.com/okfn/ckan/commit/71887e3d90d0c683f7dad5153311fda20ef361df
Author: Ross Jones <rossdjones at gmail.com>
Date: 2012-04-26 (Thu, 26 Apr 2012)
Changed paths:
M ckan/lib/alphabet_paginate.py
M ckan/public/css/style.css
M ckan/templates/tag/index.html
M ckan/templates/user/list.html
M ckan/templates/user/login.html
M ckan/templates/user/logout.html
M ckan/tests/functional/test_user.py
M ckan/tests/lib/test_alphabet_pagination.py
M doc/solr-setup.rst
Log Message:
-----------
Merge branch 'master' of https://github.com/okfn/ckan
diff --git a/ckan/lib/alphabet_paginate.py b/ckan/lib/alphabet_paginate.py
index 0afa88f..b75bf27 100644
--- a/ckan/lib/alphabet_paginate.py
+++ b/ckan/lib/alphabet_paginate.py
@@ -1,6 +1,7 @@
'''
-Based on webhelpers.paginator, but each page is for items beginning
- with a particular letter.
+Based on webhelpers.paginator, but:
+ * each page is for items beginning with a particular letter
+ * output is suitable for Bootstrap
Example:
c.page = h.Page(
@@ -43,7 +44,12 @@ def __init__(self, collection, alpha_attribute, page, other_text, paging_thresho
self.other_text = other_text
self.paging_threshold = paging_threshold
self.controller_name = controller_name
- self.available = dict( (c,0,) for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" )
+
+ self.letters = [char for char in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'] + [self.other_text]
+
+ # Work out which alphabet letters are 'available' i.e. have some results
+ # because we grey-out those which aren't.
+ self.available = dict( (c,0,) for c in self.letters )
for c in self.collection:
if isinstance(c, unicode):
x = c[0]
@@ -51,35 +57,42 @@ def __init__(self, collection, alpha_attribute, page, other_text, paging_thresho
x = c[self.alpha_attribute][0]
else:
x = getattr(c, self.alpha_attribute)[0]
+ x = x.upper()
+ if x not in self.letters:
+ x = self.other_text
self.available[x] = self.available.get(x, 0) + 1
def pager(self, q=None):
'''Returns pager html - for navigating between the pages.
e.g. Something like this:
- <div class='pager'>
- <span class="pager_curpage">A</span>
- <a class="pager_link" href="/package/list?page=B">B</a>
- <a class="pager_link" href="/package/list?page=C">C</a>
+ <ul class='pagination pagination-alphabet'>
+ <li class="active"><a href="/package/list?page=A">A</a></li>
+ <li><a href="/package/list?page=B">B</a></li>
+ <li><a href="/package/list?page=C">C</a></li>
...
- <a class="pager_link" href="/package/list?page=Z">Z</a
- <a class="pager_link" href="/package/list?page=Other">Other</a
- </div>
+ <li class="disabled"><a href="/package/list?page=Z">Z</a></li>
+ <li><a href="/package/list?page=Other">Other</a></li>
+ </ul>
'''
if self.item_count < self.paging_threshold:
return ''
pages = []
page = q or self.page
- letters = [char for char in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'] + [self.other_text]
- for letter in letters:
+ for letter in self.letters:
+ href = url_for(controller=self.controller_name, action='index', page=letter)
+ link = HTML.a(href=href, c=letter)
if letter != page:
if self.available.get(letter, 0):
- page_element = HTML.a(class_='pager_link', href=url_for(controller=self.controller_name, action='index', page=letter),c=letter)
+ li_class = ''
else:
- page_element = HTML.span(class_="pager_empty", c=letter)
+ li_class = 'disabled'
else:
- page_element = HTML.span(class_='pager_curpage', c=letter)
+ li_class = 'active'
+ attributes = {'class_': li_class} if li_class else {}
+ page_element = HTML.li(link, **attributes)
pages.append(page_element)
- div = HTML.tag('div', class_='pager', *pages)
+ ul = HTML.tag('ul', *pages)
+ div = HTML.div(ul, class_='pagination pagination-alphabet')
return div
diff --git a/ckan/public/css/style.css b/ckan/public/css/style.css
index 7e9e609..283f5d3 100644
--- a/ckan/public/css/style.css
+++ b/ckan/public/css/style.css
@@ -379,28 +379,8 @@ ul.no-break li {
/* ============== */
/* = Pagination = */
/* ============== */
-.pager {
- width: 100%;
- text-align: center;
- margin: 0 0 1.2em 0;
- clear: both;
-}
-.pager span, .pager a {
- text-decoration: none;
- margin: 0em;
- border: none;
- padding: 0.3em 0.1em;
-}
-.pager a:hover, .pager a:active {
- color: #fff;
- background-color: #c22;
-}
-.pager span.pager_dotdot {
- color: #aaa;
-}
-.pager span.pager_curpage {
- font-weight: bold;
- border: 1px solid #ddd;
+.pagination-alphabet a {
+ padding: 0 6px;
}
/* ====== */
diff --git a/ckan/templates/tag/index.html b/ckan/templates/tag/index.html
index 17292e9..8c383c8 100644
--- a/ckan/templates/tag/index.html
+++ b/ckan/templates/tag/index.html
@@ -8,9 +8,9 @@
<div py:match="content">
<h2>Tags</h2>
- <form id="tag-search" action="" method="GET">
+ <form class="form-inline" id="tag-search" action="" method="GET">
<input type="text" id="q" name="q" value="${c.q}" />
- <input type="submit" name="search" value="${_('Search')} »" />
+ <input class="btn btn-primary" type="submit" name="search" value="${_('Search')} »" />
</form>
<hr />
diff --git a/ckan/templates/user/list.html b/ckan/templates/user/list.html
index 3c736b4..1ef2655 100644
--- a/ckan/templates/user/list.html
+++ b/ckan/templates/user/list.html
@@ -8,9 +8,10 @@
<py:match path="primarysidebar">
<li class="widget-container widget_text">
- <form id="user-search" class="user-search" action="" method="GET">
- <input type="text" id="q" name="q" value="${c.q}" />
- <input type="submit" class="btn btn-small" name="" value="${_('Search')} »" />
+ <h3>Search Users</h3>
+ <form id="user-search" class="form-inline user-search" action="" method="GET">
+ <input type="text" class="input-medium" id="q" name="q" value="${c.q}" />
+ <input type="submit" class="btn btn-small btn-primary" name="" value="${_('Search')} »" />
</form>
<p py:if="c.q" i18n:msg="item_count">
<strong>${c.page.item_count}</strong> users found.
diff --git a/ckan/templates/user/login.html b/ckan/templates/user/login.html
index 6cabb81..9fdf3df 100644
--- a/ckan/templates/user/login.html
+++ b/ckan/templates/user/login.html
@@ -18,25 +18,39 @@
<py:def function="page_title">Login - User</py:def>
<py:def function="page_heading">Login to ${g.site_title}</py:def>
+ <py:def function="body_class">no-sidebar</py:def>
<div py:match="content">
-
- <form action="${h.url_for('/login_generic')}" method="post" class="simple-form" id="login">
+
+ <form action="${h.url_for('/login_generic')}" method="post" class="form-horizontal" id="login">
<fieldset>
<!--legend i18n:msg="site_title">Login</legend-->
+ <div class="control-group">
+ <label class="control-label" for="login">Login:</label>
+ <div class="controls">
+ <input type="text" class="input-xlarge" name="login" id="login" value="" />
+ </div>
+ </div>
+ <div class="control-group">
+ <label class="control-label" for="password">Password:</label>
+ <div class="controls">
+ <input type="password" name="password" id="password" value="" />
+ </div>
+ </div>
+ <div class="control-group">
+ <label class="control-label" for="remember">Remember me:</label>
+ <!-- optional 2 year cookie expiry -->
+ <div class="controls">
+ <input type="checkbox" name="remember" id="remember" value="63072000" checked="checked"/>
+ </div>
+ </div>
- <label for="login">Login:</label>
- <input type="text" name="login" value="" />
- <br/>
- <label for="password">Password:</label>
- <input type="password" name="password" value="" />
- <!-- 50 year timeout -->
- <input type="hidden" name="remember" value="1576800000" />
- <br/>
+ <div class="form-actions">
+ <button name="s" id="s" type="submit" class="btn btn-primary">${_('Sign In')}</button>
+ —
+ <a href="${h.url_for('reset')}">Forgot your password?</a>
+ </div>
</fieldset>
- <input name="s" id="s" type="submit" class="btn primary" value="${_('Sign In')}"/>
- —
- <a href="${h.url_for(controller='user', action='request_reset')}">Forgot your password?</a>
</form>
<br/>
<!-- Simple OpenID Selector -->
diff --git a/ckan/templates/user/logout.html b/ckan/templates/user/logout.html
index e40ec5c..0f37272 100644
--- a/ckan/templates/user/logout.html
+++ b/ckan/templates/user/logout.html
@@ -6,6 +6,7 @@
<py:def function="page_title">Logout</py:def>
<py:def function="page_heading">Logout from ${g.site_title}</py:def>
+ <py:def function="body_class">no-sidebar</py:def>
<div py:match="content">
<p>You have logged out successfully.</p>
diff --git a/ckan/tests/functional/test_user.py b/ckan/tests/functional/test_user.py
index 5ab1efc..eb44bd9 100644
--- a/ckan/tests/functional/test_user.py
+++ b/ckan/tests/functional/test_user.py
@@ -168,11 +168,14 @@ def test_login(self):
fv = res.forms['login']
fv['login'] = str(username)
fv['password'] = str(password)
+ fv['remember'] = False
res = fv.submit()
# check cookies set
cookies = self._get_cookie_headers(res)
assert cookies
+ for cookie in cookies:
+ assert not 'max-age' in cookie.lower(), cookie
# first get redirected to user/logged_in
assert_equal(res.status, 302)
@@ -206,6 +209,32 @@ def test_login(self):
print res
assert 'testlogin' in res.body, res.body
+ def test_login_remembered(self):
+ # create test user
+ username = u'testlogin2'
+ password = u'letmein'
+ CreateTestData.create_user(name=username,
+ password=password)
+ user = model.User.by_name(username)
+
+ # do the login
+ offset = url_for(controller='user', action='login')
+ res = self.app.get(offset)
+ fv = res.forms['login']
+ fv['login'] = str(username)
+ fv['password'] = str(password)
+ fv['remember'] = True
+ res = fv.submit()
+
+ # check cookies set
+ cookies = self._get_cookie_headers(res)
+ assert cookies
+ # check cookie is remembered via Max-Age and Expires
+ # (both needed for cross-browser compatibility)
+ for cookie in cookies:
+ assert 'Max-Age=63072000;' in cookie, cookie
+ assert 'Expires=' in cookie, cookie
+
def test_login_wrong_password(self):
# create test user
username = u'testloginwrong'
diff --git a/ckan/tests/lib/test_alphabet_pagination.py b/ckan/tests/lib/test_alphabet_pagination.py
index a1bf6ae..8afc276 100644
--- a/ckan/tests/lib/test_alphabet_pagination.py
+++ b/ckan/tests/lib/test_alphabet_pagination.py
@@ -1,5 +1,7 @@
import re
+from nose.tools import assert_equal
+
from ckan.tests import *
from ckan.tests import regex_related
from ckan.lib.create_test_data import CreateTestData
@@ -28,6 +30,16 @@ def setup_class(cls):
def teardown_class(cls):
model.repo.rebuild_db()
+ def test_00_model(self):
+ query = model.Session.query(model.Package)
+ page = h.AlphaPage(
+ collection=query,
+ alpha_attribute='title',
+ page='A',
+ other_text=other,
+ )
+ assert_equal(page.available, {'Other': 20, 'A': 10, 'C': 10, 'B': 10, 'E': 0, 'D': 10, 'G': 0, 'F': 0, 'I': 0, 'H': 0, 'K': 0, 'J': 0, 'M': 0, 'L': 0, 'O': 0, 'N': 0, 'Q': 0, 'P': 0, 'S': 0, 'R': 0, 'U': 0, 'T': 0, 'W': 0, 'V': 0, 'Y': 0, 'X': 0, 'Z': 0})
+
def test_01_package_page(self):
query = model.Session.query(model.Package)
page = h.AlphaPage(
@@ -37,11 +49,12 @@ def test_01_package_page(self):
other_text=other,
)
pager = page.pager()
- assert pager.startswith('<div class="pager">'), pager
- assert '<span class="pager_curpage">A</span>' in pager, pager
+ assert pager.startswith('<div class="pagination pagination-alphabet">'), pager
+ assert '<li class="active"><a href="/tag?page=A">A</a></li>' in pager, pager
url_base = '/packages'
- assert re.search('\<span class="pager_empty"\>B\<\/span\>', pager), pager
- assert re.search('\<span class="pager_empty"\>Other\<\/span\>', pager), pager
+ assert re.search(r'\<li\>\<a href="\/tag\?page=B"\>B\<\/a\>\<\/li\>', pager), pager
+ assert re.search(r'\<li class="disabled"\>\<a href="\/tag\?page=E"\>E\<\/a\>\<\/li\>', pager), pager
+ assert re.search(r'\<li\>\<a href="\/tag\?page=Other"\>Other\<\/a\>\<\/li\>', pager), pager
def test_02_package_items(self):
diff --git a/doc/solr-setup.rst b/doc/solr-setup.rst
index 4529797..3c158b2 100644
--- a/doc/solr-setup.rst
+++ b/doc/solr-setup.rst
@@ -71,7 +71,7 @@ so, create a symbolic link to the schema file in the config folder. Use the late
supported by the CKAN version you are installing (it will generally be the highest one)::
sudo mv /etc/solr/conf/schema.xml /etc/solr/conf/schema.xml.bak
- sudo ln -s ~/ckan/ckan/config/solr/schema-1.3.xml /etc/solr/conf/schema.xml
+ sudo ln -s ~/ckan/ckan/config/solr/schema-1.4.xml /etc/solr/conf/schema.xml
Now restart jetty::
@@ -93,6 +93,7 @@ will have different paths in the Solr server URL::
http://localhost:8983/solr/ckan-schema-1.2 # Used by CKAN up to 1.5
http://localhost:8983/solr/ckan-schema-1.3 # Used by CKAN 1.5.1
+ http://localhost:8983/solr/ckan-schema-1.4 # Used by CKAN 1.7
http://localhost:8983/solr/some-other-site # Used by another site
To set up a multicore Solr instance, repeat the steps on the previous section
================================================================
Commit: 62825e578c7bdb8942f45f1ffaf40169019225c1
https://github.com/okfn/ckan/commit/62825e578c7bdb8942f45f1ffaf40169019225c1
Author: Ross Jones <rossdjones at gmail.com>
Date: 2012-04-26 (Thu, 26 Apr 2012)
Changed paths:
M ckan/controllers/package.py
Log Message:
-----------
[xs,bug] Fixed the non appearance of current related item count on resource pages
diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py
index ba12b86..9e99e0f 100644
--- a/ckan/controllers/package.py
+++ b/ckan/controllers/package.py
@@ -480,6 +480,7 @@ def edit(self, id, data=None, errors=None, error_summary=None):
c.errors_json = json.dumps(errors)
self._setup_template_variables(context, {'id': id}, package_type=package_type)
+ c.related_count = len(c.pkg.related)
# TODO: This check is to maintain backwards compatibility with the old way of creating
# custom forms. This behaviour is now deprecated.
@@ -749,6 +750,8 @@ def resource_read(self, id, resource_id):
c.package['isopen'] = False
c.datastore_api = h.url_for('datastore_read', id=c.resource.get('id'),
qualified=True)
+
+ c.related_count = len(c.pkg.related)
return render('package/resource_read.html')
def resource_embedded_dataviewer(self, id, resource_id):
================================================================
Commit: afb4555e427d1ee9b1678f6098b54bc261f248bb
https://github.com/okfn/ckan/commit/afb4555e427d1ee9b1678f6098b54bc261f248bb
Author: Ross Jones <rossdjones at gmail.com>
Date: 2012-04-26 (Thu, 26 Apr 2012)
Changed paths:
M ckan/logic/auth/delete.py
M ckan/logic/auth/publisher/delete.py
M ckan/templates/_util.html
M ckan/templates/package/related_list.html
Log Message:
-----------
Fix for a bug related to deleting related items
diff --git a/ckan/logic/auth/delete.py b/ckan/logic/auth/delete.py
index c99bec3..c324049 100644
--- a/ckan/logic/auth/delete.py
+++ b/ckan/logic/auth/delete.py
@@ -27,6 +27,15 @@ def related_delete(context, data_dict):
related = get_related_object(context, data_dict)
userobj = model.User.get( user )
+
+ if related.datasets:
+ package = related.datasets[0]
+
+ pkg_dict = { 'id': package.id }
+ authorized = package_delete(context, pkg_dict).get('success')
+ if authorized:
+ return {'success': True}
+
if not userobj or userobj.id != related.owner_id:
return {'success': False, 'msg': _('Only the owner can delete a related item')}
diff --git a/ckan/logic/auth/publisher/delete.py b/ckan/logic/auth/publisher/delete.py
index 9d3388f..0aaa0d9 100644
--- a/ckan/logic/auth/publisher/delete.py
+++ b/ckan/logic/auth/publisher/delete.py
@@ -40,6 +40,12 @@ def related_delete(context, data_dict):
related = get_related_object(context, data_dict)
userobj = model.User.get( user )
+
+ if related.datasets:
+ package = related.datasets[0]
+ if _groups_intersect( userobj.get_groups('organization'), package.get_groups('organization') ):
+ return {'success': True}
+
if not userobj or userobj.id != related.owner_id:
return {'success': False, 'msg': _('Only the owner can delete a related item')}
diff --git a/ckan/templates/_util.html b/ckan/templates/_util.html
index 8e320d8..5aa7c07 100644
--- a/ckan/templates/_util.html
+++ b/ckan/templates/_util.html
@@ -128,6 +128,7 @@
<py:def function="related_summary(related)">
<li class="span3">
<div class="thumbnail">
+ <button py:if="c.user and (c.userobj.id == related.owner_id or h.check_access('package_update',{'id':c.pkg.id}))" class="close" onclick="related_delete('${related.id}');">×</button>
<a href="${related.url}" class="image">
<img src="${related.image_url}" width="210" py:if="related.image_url" />
<img src="/images/photo-placeholder.png" width="210" py:if="not related.image_url" />
diff --git a/ckan/templates/package/related_list.html b/ckan/templates/package/related_list.html
index 49a75ea..baced80 100644
--- a/ckan/templates/package/related_list.html
+++ b/ckan/templates/package/related_list.html
@@ -40,6 +40,25 @@
</div>
<py:def function="optional_head">
+ <script type="text/javascript" py:if="c.user">
+ function related_delete(related_id) {
+ var data = { 'id' : related_id }
+ $.ajax({
+ type: "post",
+ url: CKAN.SITE_URL + '/api/3/action/related_delete',
+ data: JSON.stringify(data),
+ success: function (data) {
+ window.location.reload();
+ },
+ error: function(err, txt, w) {
+ // This needs to be far more informative.
+ var msg = '<strong>Error:</strong> Unable to delete related item';
+ $('<div class="alert alert-error" />').html(msg).hide().prependTo($('div#main')).fadeIn();
+ }
+ });
+
+ }
+ </script>
<py:if test="config.get('rdf_packages')">
<link rel="alternate" type="application/rdf+xml" title="RDF/XML" href="${config['rdf_packages'] + '/' + c.pkg.id + '.rdf' }" />
<link rel="alternate" type="application/turtle" title="RDF/Turtle" href="${config['rdf_packages'] + '/' + c.pkg.id + '.ttl' }" />
================================================================
Compare: https://github.com/okfn/ckan/compare/ddba520...afb4555
More information about the ckan-changes
mailing list