This CKAN Extension demonstrates some common patterns for customising a CKAN instance. | This CKAN Extension demonstrates some common patterns for customising a CKAN instance. |
It comprises: | It comprises: |
* A CKAN Extension "plugin" at ``ckanext/example/plugin.py`` which, when | * A CKAN Extension "plugin" at ``ckanext/example/plugin.py`` which, when |
loaded, overrides various settings in the core ``ini``-file to provide: | loaded, overrides various settings in the core ``ini``-file to provide: |
* A path to local customisations of the core templates and stylesheets | * A path to local customisations of the core templates and stylesheets |
* A "stream filter" that replaces arbitrary strings in rendered templates | * A "stream filter" that replaces arbitrary strings in rendered templates |
* A "route" to override and extend the default behaviour of a core CKAN page | * A "route" to override and extend the default behaviour of a core CKAN page |
* A custom Pylons controller for overriding some core CKAN behaviour | * A custom Pylons controller for overriding some core CKAN behaviour |
* A custom Package edit form | * A custom Package edit form |
* A custom Group edit form | * A custom Group edit form |
* A plugin that allows for custom forms to be used for datasets based on | * A plugin that allows for custom forms to be used for datasets based on |
their "type". | their "type". |
* A custom User registration and edition form | * A custom User registration and edition form |
* Some simple template customisations | * Some simple template customisations |
Installation | Installation |
============ | ============ |
To install this package, from your CKAN virtualenv, run the following from your CKAN base folder (e.g. ``pyenv/``):: | To install this package, from your CKAN virtualenv, run the following from your CKAN base folder (e.g. ``pyenv/``):: |
pip install -e git+https://github.com/okfn/ckanext-example#egg=ckanext-example | pip install -e git+https://github.com/okfn/ckanext-example#egg=ckanext-example |
Then activate it by setting ``ckan.plugins = example`` in your main ``ini``-file. | Then activate it by setting ``ckan.plugins = example`` in your main ``ini``-file. |
Orientation | Orientation |
=========== | =========== |
* Examine the source code, starting with ``ckanext/example/plugin.py`` | * Examine the source code, starting with ``ckanext/example/plugin.py`` |
* To understand the nuts and bolts of this file, which is a CKAN | * To understand the nuts and bolts of this file, which is a CKAN |
*Extension*, read in conjunction with the "Extension | *Extension*, read in conjunction with the "Extension |
documentation": http://docs.ckan.org/en/latest/plugins.html | documentation": http://docs.ckan.org/en/latest/extensions.html |
* One thing the extension does is set the values of | * One thing the extension does is set the values of |
``extra_public_paths`` and ``extra_template_paths`` in the CKAN | ``extra_public_paths`` and ``extra_template_paths`` in the CKAN |
config, which are "documented | config, which are "documented |
here": http://docs.ckan.org/en/latest/configuration.html#extra-template-paths | here": http://docs.ckan.org/en/latest/configuration.html#extra-template-paths |
* These are set to point at directories within | * These are set to point at directories within |
``ckanext/example/theme/`` (in this package). Here we: | ``ckanext/example/theme/`` (in this package). Here we: |
* override the home page HTML ``ckanext/example/theme/templates/home/index.html`` | * override the home page HTML ``ckanext/example/theme/templates/home/index.html`` |
* provide some extra style by serving ``extra.css`` (which is loaded using the ``ckan.template_head_end`` option | * provide some extra style by serving ``extra.css`` (which is loaded using the ``ckan.template_head_end`` option |
* customise the navigation and header of the main template in the file ``layout.html``. | * customise the navigation and header of the main template in the file ``layout.html``. |
The latter file is a great place to make global theme alterations. | The latter file is a great place to make global theme alterations. |
It uses the _layout template_ pattern "described in the Genshi | It uses the _layout template_ pattern "described in the Genshi |
documentation":http://genshi.edgewall.org/wiki/GenshiTutorial#AddingaLayoutTemplate. | documentation":http://genshi.edgewall.org/wiki/GenshiTutorial#AddingaLayoutTemplate. |
This allows you to use Xpath selectors to override snippets of HTML | This allows you to use Xpath selectors to override snippets of HTML |
globally. | globally. |
* The custom package edit form at ``package_form.py`` follows a deprecated | * The custom package edit form at ``package_form.py`` follows a deprecated |
way to make a form (using FormAlchemy). This part of the Example Theme needs | way to make a form (using FormAlchemy). This part of the Example Theme needs |
updating. In the meantime, follow the instructions at: | updating. In the meantime, follow the instructions at: |
http://readthedocs.org/docs/ckan/en/latest/forms.html | http://docs.ckan.org/en/latest/forms.html |
Example Tags With Vocabularies | Example Tags With Vocabularies |
============================== | ============================== |
To add example tag vocabulary data to the database, from the ckanext-example directory run: | To add example tag vocabulary data to the database, from the ckanext-example directory run: |
:: | :: |
paster example create-example-vocabs -c <path to your ckan config file> | paster example create-example-vocabs -c <path to your ckan config file> |
This data can be removed with | This data can be removed with |
:: | :: |
paster example clean -c <path to your ckan config file> | paster example clean -c <path to your ckan config file> |
import os | import os |
from logging import getLogger | from logging import getLogger |
from pylons import request | from pylons import request |
from genshi.input import HTML | from genshi.input import HTML |
from genshi.filters.transform import Transformer | from genshi.filters.transform import Transformer |
from ckan.plugins import implements, SingletonPlugin | from ckan.plugins import implements, SingletonPlugin |
from ckan.plugins import IConfigurer | from ckan.plugins import IConfigurer |
from ckan.plugins import IGenshiStreamFilter | from ckan.plugins import IGenshiStreamFilter |
from ckan.plugins import IRoutes | from ckan.plugins import IRoutes |
log = getLogger(__name__) | log = getLogger(__name__) |
class ExamplePlugin(SingletonPlugin): | class ExamplePlugin(SingletonPlugin): |
"""This plugin demonstrates how a theme packaged as a CKAN | """This plugin demonstrates how a theme packaged as a CKAN |
extension might extend CKAN behaviour. | extension might extend CKAN behaviour. |
In this case, we implement three extension interfaces: | In this case, we implement three extension interfaces: |
- ``IConfigurer`` allows us to override configuration normally | - ``IConfigurer`` allows us to override configuration normally |
found in the ``ini``-file. Here we use it to specify the site | found in the ``ini``-file. Here we use it to specify the site |
title, and to tell CKAN to look in this package for templates | title, and to tell CKAN to look in this package for templates |
and resources that customise the core look and feel. | and resources that customise the core look and feel. |
- ``IGenshiStreamFilter`` allows us to filter and transform the | - ``IGenshiStreamFilter`` allows us to filter and transform the |
HTML stream just before it is rendered. In this case we use | HTML stream just before it is rendered. In this case we use |
it to rename "frob" to "foobar" | it to rename "frob" to "foobar" |
- ``IRoutes`` allows us to add new URLs, or override existing | - ``IRoutes`` allows us to add new URLs, or override existing |
URLs. In this example we use it to override the default | URLs. In this example we use it to override the default |
``/register`` behaviour with a custom controller | ``/register`` behaviour with a custom controller |
""" | """ |
implements(IConfigurer, inherit=True) | implements(IConfigurer, inherit=True) |
implements(IGenshiStreamFilter, inherit=True) | implements(IGenshiStreamFilter, inherit=True) |
implements(IRoutes, inherit=True) | implements(IRoutes, inherit=True) |
def update_config(self, config): | def update_config(self, config): |
"""This IConfigurer implementation causes CKAN to look in the | """This IConfigurer implementation causes CKAN to look in the |
```public``` and ```templates``` directories present in this | ```public``` and ```templates``` directories present in this |
package for any customisations. | package for any customisations. |
It also shows how to set the site title here (rather than in | It also shows how to set the site title here (rather than in |
the main site .ini file), and causes CKAN to use the | the main site .ini file), and causes CKAN to use the |
customised package form defined in ``package_form.py`` in this | customised package form defined in ``package_form.py`` in this |
directory. | directory. |
""" | """ |
here = os.path.dirname(__file__) | here = os.path.dirname(__file__) |
rootdir = os.path.dirname(os.path.dirname(here)) | rootdir = os.path.dirname(os.path.dirname(here)) |
our_public_dir = os.path.join(rootdir, 'ckanext', | our_public_dir = os.path.join(rootdir, 'ckanext', |
'example', 'theme', 'public') | 'example', 'theme', 'public') |
template_dir = os.path.join(rootdir, 'ckanext', | template_dir = os.path.join(rootdir, 'ckanext', |
'example', 'theme', | 'example', 'theme', 'templates') |
'templates') | |
# set our local template and resource overrides | # set our local template and resource overrides |
config['extra_public_paths'] = ','.join([our_public_dir, | config['extra_public_paths'] = ','.join([our_public_dir, |
config.get('extra_public_paths', '')]) | config.get('extra_public_paths', '')]) |
config['extra_template_paths'] = ','.join([template_dir, | config['extra_template_paths'] = ','.join([template_dir, |
config.get('extra_template_paths', '')]) | config.get('extra_template_paths', '')]) |
# add in the extra.css | # add in the extra.css |
config['ckan.template_head_end'] = config.get('ckan.template_head_end', '') +\ | config['ckan.template_head_end'] = config.get('ckan.template_head_end', '') +\ |
'<link rel="stylesheet" href="/css/extra.css" type="text/css"> ' | '<link rel="stylesheet" href="/css/extra.css" type="text/css"> ' |
# set the title | # set the title |
config['ckan.site_title'] = "Example CKAN theme" | config['ckan.site_title'] = "Example CKAN theme" |
# set the customised package form (see ``setup.py`` for entry point) | # set the customised package form (see ``setup.py`` for entry point) |
config['package_form'] = "example_form" | config['package_form'] = "example_form" |
def filter(self, stream): | def filter(self, stream): |
"""Conform to IGenshiStreamFilter interface. | """Conform to IGenshiStreamFilter interface. |
This example filter renames 'frob' to 'foobar' (this string is | This example filter renames 'frob' to 'foobar' (this string is |
found in the custom ``home/index.html`` template provided as | found in the custom ``home/index.html`` template provided as |
part of the package). | part of the package). |
It also adds the chosen JQuery plugin to the page if viewing the | It also adds the chosen JQuery plugin to the page if viewing the |
dataset edit page (provides a better UX for working with tags with vocabularies) | dataset edit page (provides a better UX for working with tags with vocabularies) |
""" | """ |
stream = stream | Transformer('//p[@id="examplething"]/text()')\ | stream = stream | Transformer('//p[@id="examplething"]/text()')\ |
.substitute(r'frob', r'foobar') | .substitute(r'frob', r'foobar') |
routes = request.environ.get('pylons.routes_dict') | routes = request.environ.get('pylons.routes_dict') |
if routes.get('controller') == 'package' \ | if routes.get('controller') == 'package' \ |
and routes.get('action') == 'edit': | and routes.get('action') == 'edit': |
stream = stream | Transformer('head').append(HTML( | stream = stream | Transformer('head').append(HTML( |
'<link rel="stylesheet" href="/css/chosen.css" />' | '<link rel="stylesheet" href="/css/chosen.css" />' |
)) | )) |
stream = stream | Transformer('body').append(HTML( | stream = stream | Transformer('body').append(HTML( |
''' | ''' |
<script src="/scripts/chosen.jquery.min.js" type="text/javascript"></script>' | <script src="/scripts/chosen.jquery.min.js" type="text/javascript"></script>' |
<script type="text/javascript">$(".chzn-select").chosen();</script> | <script type="text/javascript">$(".chzn-select").chosen();</script> |
''' | ''' |
)) | )) |
return stream | return stream |
def before_map(self, map): | def before_map(self, map): |
"""This IRoutes implementation overrides the standard | """This IRoutes implementation overrides the standard |
``/user/register`` behaviour with a custom controller. You | ``/user/register`` behaviour with a custom controller. You |
might instead use it to provide a completely new page, for | might instead use it to provide a completely new page, for |
example. | example. |
Note that we have also provided a custom register form | Note that we have also provided a custom register form |
template at ``theme/templates/user/register.html``. | template at ``theme/templates/user/register.html``. |
""" | """ |
# Hook in our custom user controller at the points of creation | # Hook in our custom user controller at the points of creation |
# and edition. | # and edition. |
map.connect('/user/register', | map.connect('/user/register', |
controller='ckanext.example.controller:CustomUserController', | controller='ckanext.example.controller:CustomUserController', |
action='register') | action='register') |
map.connect('/user/edit', | map.connect('/user/edit', |
controller='ckanext.example.controller:CustomUserController', | controller='ckanext.example.controller:CustomUserController', |
action='edit') | action='edit') |
map.connect('/user/edit/{id:.*}', | map.connect('/user/edit/{id:.*}', |
controller='ckanext.example.controller:CustomUserController', | controller='ckanext.example.controller:CustomUserController', |
action='edit') | action='edit') |
map.connect('/package/new', controller='package_formalchemy', action='new') | map.connect('/package/new', controller='package_formalchemy', action='new') |
map.connect('/package/edit/{id}', controller='package_formalchemy', action='edit') | map.connect('/package/edit/{id}', controller='package_formalchemy', action='edit') |
return map | return map |
<form id="dataset-edit" method="post" | <form id="dataset-edit" method="post" |
py:attrs="{'class':'has-errors'} if errors else {}" | py:attrs="{'class':'has-errors'} if errors else {}" |
xmlns:i18n="http://genshi.edgewall.org/i18n" | xmlns:i18n="http://genshi.edgewall.org/i18n" |
xmlns:py="http://genshi.edgewall.org/" | xmlns:py="http://genshi.edgewall.org/" |
xmlns:xi="http://www.w3.org/2001/XInclude"> | xmlns:xi="http://www.w3.org/2001/XInclude"> |
<div class="error-explanation" py:if="error_summary"> | <div class="error-explanation" py:if="error_summary"> |
<h2>Errors in form</h2> | <h2>Errors in form</h2> |
<p>The form contains invalid entries:</p> | <p>The form contains invalid entries:</p> |
<ul> | <ul> |
<li py:for="key, error in error_summary.items()">${"%s: %s" % (key, error)} | <li py:for="key, error in error_summary.items()">${"%s: %s" % (key, error)} |
<py:if test="key=='Resources'"> | <py:if test="key=='Resources'"> |
<ul> | <ul> |
<py:for each="idx, errordict in enumerate(errors.get('resources', []))"> | <py:for each="idx, errordict in enumerate(errors.get('resources', []))"> |
<li py:if="errordict"> | <li py:if="errordict"> |
Resource ${idx}: | Resource ${idx}: |
<ul> | <ul> |
<li py:for="thiskey, thiserror in errordict.items()">${thiskey}: <py:for each="errorinfo in thiserror">${errorinfo}; </py:for></li> | <li py:for="thiskey, thiserror in errordict.items()">${thiskey}: <py:for each="errorinfo in thiserror">${errorinfo}; </py:for></li> |
</ul> | </ul> |
</li> | </li> |
</py:for> | </py:for> |
</ul> | </ul> |
</py:if> | </py:if> |
</li> | </li> |
</ul> | </ul> |
</div> | </div> |
<fieldset id="basic-information"> | <fieldset id="basic-information"> |
<dl> | <dl> |
<dt class="title-label"><label class="field_opt" for="title">Title</label></dt> | <dt class="title-label"><label class="field_opt" for="title">Title</label></dt> |
<dd class="title-field"> | <dd class="title-field"> |
<input id="title" | <input id="title" |
class="js-title" | class="js-title" |
name="title" type="text" | name="title" type="text" |
value="${data.get('title', '')}" | value="${data.get('title', '')}" |
placeholder="${_('A short descriptive title for the dataset')}" | placeholder="${_('A short descriptive title for the dataset')}" |
/> | /> |
</dd> | </dd> |
<dd class="title-instructions field_error" py:if="errors.get('title', '')">${errors.get('title', '')}</dd> | <dd class="title-instructions field_error" py:if="errors.get('title', '')">${errors.get('title', '')}</dd> |
<dt class="name-label"><label class="field_req" for="name">Url</label></dt> | <dt class="name-label"><label class="field_req" for="name">Url</label></dt> |
<dd class="name-field"> | <dd class="name-field"> |
<span class="js-url-text url-text">${h.url(controller='package', action='index')+'/'}<span class="js-url-viewmode js-url-suffix"> </span><a href="#" style="display: none;" class="url-edit js-url-editlink js-url-viewmode">(edit)</a></span> | <span class="js-url-text url-text">${h.url(controller='package', action='search')+'/'}<span class="js-url-viewmode js-url-suffix"> </span><a href="#" style="display: none;" class="url-edit js-url-editlink js-url-viewmode">(edit)</a></span> |
<input style="display: none;" id="name" maxlength="100" name="name" type="text" class="url-input js-url-editmode js-url-input" value="${data.get('name', '')}" /> | <input style="display: none;" id="name" maxlength="100" name="name" type="text" class="url-input js-url-editmode js-url-input" value="${data.get('name', '')}" /> |
<p class="js-url-is-valid"> </p> | <p class="js-url-is-valid"> </p> |
</dd> | </dd> |
<dd style="display: none;" class="js-url-editmode name-instructions basic">2+ characters, lowercase, using only 'a-z0-9' and '-_'</dd> | <dd style="display: none;" class="js-url-editmode name-instructions basic">2+ characters, lowercase, using only 'a-z0-9' and '-_'</dd> |
<dd class="js-url-editmode name-instructions field_error" py:if="errors.get('name', '')">${errors.get('name', '')}</dd> | <dd class="js-url-editmode name-instructions field_error" py:if="errors.get('name', '')">${errors.get('name', '')}</dd> |
<dt class="homepage-label"><label class="field_opt" for="url">Home Page</label></dt> | <dt class="homepage-label"><label class="field_opt" for="url">Home Page</label></dt> |
<dd class="homepage-field"><input id="url" name="url" type="text" value="${data.get('url', '')}"/></dd> | <dd class="homepage-field"><input id="url" name="url" type="text" value="${data.get('url', '')}"/></dd> |
<dd class="homepage-instructions instructions basic">The URL for the web page describing the data (not the data itself).</dd> | <dd class="homepage-instructions instructions basic">The URL for the web page describing the data (not the data itself).</dd> |
<dd class="homepage-instructions hints">e.g. http://www.example.com/growth-figures.html</dd> | <dd class="homepage-instructions hints">e.g. http://www.example.com/growth-figures.html</dd> |
<dd class="homepage-instructions field_error" py:if="errors.get('url', '')">${errors.get('url', '')}</dd> | <dd class="homepage-instructions field_error" py:if="errors.get('url', '')">${errors.get('url', '')}</dd> |
<dt class="license-label"><label class="field_opt" for="license_id">License</label></dt> | <dt class="license-label"><label class="field_opt" for="license_id">License</label></dt> |
<dd class="license-field"> | <dd class="license-field"> |
<select id="license_id" name="license_id"> | <select id="license_id" name="license_id"> |
<py:for each="licence_desc, licence_id in c.licences"> | <py:for each="licence_desc, licence_id in c.licences"> |
<option value="${licence_id}" py:attrs="{'selected': 'selected' if data.get('license_id', '') == licence_id else None}" >${licence_desc}</option> | <option value="${licence_id}" py:attrs="{'selected': 'selected' if data.get('license_id', '') == licence_id else None}" >${licence_desc}</option> |
</py:for> | </py:for> |
</select> | </select> |
</dd> | </dd> |
<dd class="license-instructions instructions basic">The licence under which the dataset is released.</dd> | <dd class="license-instructions instructions basic">The licence under which the dataset is released.</dd> |
<dt class="description-label"><label class="field_opt" for="notes">Description</label></dt> | <dt class="description-label"><label class="field_opt" for="notes">Description</label></dt> |
<dd class="description-field"><div class="markdown-editor"> | <dd class="description-field"><div class="markdown-editor"> |
<ul class="button-row"> | <ul class="button-row"> |
<li><button class="pretty-button js-markdown-edit depressed">Edit</button></li> | <li><button class="pretty-button js-markdown-edit depressed">Edit</button></li> |
<li><button class="pretty-button js-markdown-preview">Preview</button></li> | <li><button class="pretty-button js-markdown-preview">Preview</button></li> |
</ul> | </ul> |
<textarea class="markdown-input" name="notes" id="notes" placeholder="${_('Start with a summary sentence ...')}">${data.get('notes','')}</textarea> | <textarea class="markdown-input" name="notes" id="notes" placeholder="${_('Start with a summary sentence ...')}">${data.get('notes','')}</textarea> |
<div class="markdown-preview" style="display: none;"></div> | <div class="markdown-preview" style="display: none;"></div> |
<span class="hints">You can use <a href="http://daringfireball.net/projects/markdown/syntax" target="_blank">Markdown formatting</a> here.</span> | <span class="hints">You can use <a href="http://daringfireball.net/projects/markdown/syntax" target="_blank">Markdown formatting</a> here.</span> |
<!-- | <!-- |
<dd class="instructions basic">The main description of the dataset</dd> | <dd class="instructions basic">The main description of the dataset</dd> |
<dd class="instructions further">It is often displayed with the dataset title. In particular, it should start with a short sentence that describes the dataset succinctly, because the fi |