diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index 6c66f01f16b..7d21a4b8031 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -554,18 +554,18 @@ def unfollow(self, id): h.flash_error(error_message) h.redirect_to(controller='group', action='read', id=id) - def followers(self, id=None): + def followers(self, id): context = self._get_group_dict(id) c.followers = get_action('group_follower_list')(context, {'id': c.group_dict['id']}) return render('group/followers.html') - def admins(self, id=None): + def admins(self, id): context = self._get_group_dict(id) c.admins = self.authorizer.get_admins(context['group']) return render('group/admins.html') - def about(self, id=None): + def about(self, id): self._get_group_dict(id) return render('group/about.html') diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index 670ea8763b1..b2e97e23a7e 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -479,6 +479,7 @@ def followers(self, id=None): def activity(self, id, offset=0): '''Render this user's public activity stream page.''' + context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'for_view': True} data_dict = {'id': id, 'user_obj': c.userobj} @@ -499,7 +500,13 @@ def dashboard(self, id=None, offset=0): 'user': c.user or c.author, 'for_view': True} data_dict = {'id': id, 'user_obj': c.userobj, 'offset': offset} self._setup_template_variables(context, data_dict) - c.dashboard_offset = int(offset) + + c.dashboard_activity_stream = h.dashboard_activity_stream(id, offset) + + # Mark the user's new activities as old whenever they view their + # dashboard page. + get_action('dashboard_mark_all_new_activities_as_old')(context, {}) + return render('user/dashboard.html') def follow(self, id): diff --git a/ckan/lib/activity_streams.py b/ckan/lib/activity_streams.py index 12f7015ecf2..eb177aba02d 100644 --- a/ckan/lib/activity_streams.py +++ b/ckan/lib/activity_streams.py @@ -1,4 +1,5 @@ import re +import datetime from pylons.i18n import _ from webhelpers.html import literal @@ -228,11 +229,13 @@ def activity_list_to_html(context, activity_stream, activity_params): for match in matches: snippet = activity_snippet_functions[match](activity, detail) data[str(match)] = snippet + activity_list.append({'msg': activity_msg, 'type': activity_type.replace(' ', '-').lower(), 'icon': activity_icon, 'data': data, - 'timestamp': activity['timestamp']}) + 'timestamp': activity['timestamp'], + 'is_new': activity.get('is_new', False)}) activity_params['activities'] = activity_list return literal(base.render('activity_streams/activity_stream_items.html', extra_vars=activity_params)) diff --git a/ckan/lib/base.py b/ckan/lib/base.py index edc4264e2f1..2a34daa64dc 100644 --- a/ckan/lib/base.py +++ b/ckan/lib/base.py @@ -214,6 +214,16 @@ def __before__(self, action, **params): self._identify_user() i18n.handle_request(request, c) + # If the user is logged in add their number of new activities to the + # template context. + if c.userobj: + from ckan.logic import get_action + new_activities_count = get_action( + 'dashboard_new_activities_count') + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author} + c.new_activities = new_activities_count(context, { 'offset': 0 }) + def _identify_user(self): ''' Identifies the user using two methods: diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index e4c43056204..5b8f1924329 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -1,6 +1,7 @@ import uuid import logging import json +import datetime from pylons import config from pylons.i18n import _ @@ -1732,15 +1733,10 @@ def user_activity_list(context, data_dict): # FIXME: Filter out activities whose subject or object the user is not # authorized to read. _check_access('user_show', context, data_dict) - offset = data_dict['offset'] model = context['model'] user_id = _get_or_bust(data_dict, 'id') - query = model.Session.query(model.Activity) - query = query.filter_by(user_id=user_id) - query = query.order_by(_desc(model.Activity.timestamp)) - query = query.limit(31) - query = query.offset(offset) - activity_objects = query.all() + offset = _get_or_bust(data_dict, 'offset') + activity_objects = model.activity.user_activity_list(user_id, offset) return model_dictize.activity_list_dictize(activity_objects, context) def package_activity_list(context, data_dict): @@ -1757,15 +1753,9 @@ def package_activity_list(context, data_dict): # FIXME: Filter out activities whose subject or object the user is not # authorized to read. _check_access('package_show', context, data_dict) - offset = data_dict['offset'] model = context['model'] package_id = _get_or_bust(data_dict, 'id') - query = model.Session.query(model.Activity) - query = query.filter_by(object_id=package_id) - query = query.order_by(_desc(model.Activity.timestamp)) - query = query.limit(31) - query = query.offset(offset) - activity_objects = query.all() + activity_objects = model.activity.package_activity_list(package_id) return model_dictize.activity_list_dictize(activity_objects, context) def group_activity_list(context, data_dict): @@ -1791,6 +1781,14 @@ def group_activity_list(context, data_dict): group_show = logic.get_action('group_show') group_id = group_show(context, {'id': group_id})['id'] + # FIXME: The SQLAlchemy below should be moved into ckan/model/activity.py + # (to encapsulate SQLALchemy in the model and avoid using it from the + # logic) but it can't be because it requires the list of dataset_ids which + # comes from logic.group_package_show() (and I don't want to access the + # logic from the model). Need to change it to get the dataset_ids from the + # model instead. There seems to be multiple methods for getting a group's + # datasets, some in the logic and some in the model. + # Get a list of the IDs of the group's datasets. group_package_show = logic.get_action('group_package_show') datasets = group_package_show(context, {'id': group_id}) @@ -1816,11 +1814,7 @@ def recently_changed_packages_activity_list(context, data_dict): # FIXME: Filter out activities whose subject or object the user is not # authorized to read. model = context['model'] - query = model.Session.query(model.Activity) - query = query.filter(model.Activity.activity_type.endswith('package')) - query = query.order_by(_desc(model.Activity.timestamp)) - query = query.limit(30) - activity_objects = query.all() + activity_objects = model.activity.recently_changed_packages_activity_list() return model_dictize.activity_list_dictize(activity_objects, context) def activity_detail_list(context, data_dict): @@ -2228,52 +2222,59 @@ def group_followee_list(context, data_dict): def dashboard_activity_list(context, data_dict): '''Return the dashboard activity stream of the given user. + Unlike the activity dictionaries returned by other *_activity_list actions, + these activity dictionaries have an extra boolean value with key 'is_new' + that tells you whether the activity happened since the user last viewed her + dashboard ('is_new': True) or not ('is_new': False). + :param id: the id or name of the user :type id: string - :rtype: list of dictionaries + :rtype: list of activity dictionaries ''' - # FIXME: Filter out activities whose subject or object the user is not - # authorized to read. - offset = int(data_dict['offset']) - model = context['model'] - user_id = _get_or_bust(data_dict, 'id') + _check_access('dashboard_activity_list', context, data_dict) - activity_query = model.Session.query(model.Activity) - user_followees_query = activity_query.join(model.UserFollowingUser, model.UserFollowingUser.object_id == model.Activity.user_id) - dataset_followees_query = activity_query.join(model.UserFollowingDataset, model.UserFollowingDataset.object_id == model.Activity.object_id) + model = context['model'] + user_id = model.User.get(context['user']).id + offset = int(_get_or_bust(data_dict, 'offset')) + print(data_dict) - from_user_query = activity_query.filter(model.Activity.user_id==user_id) - about_user_query = activity_query.filter(model.Activity.object_id==user_id) - user_followees_query = user_followees_query.filter(model.UserFollowingUser.follower_id==user_id) - dataset_followees_query = dataset_followees_query.filter(model.UserFollowingDataset.follower_id==user_id) + # FIXME: Filter out activities whose subject or object the user is not + # authorized to read. + activity_objects = model.activity.dashboard_activity_list(user_id, offset) + + activity_dicts = model_dictize.activity_list_dictize( + activity_objects, context) + + # Mark the new (not yet seen by user) activities. + strptime = datetime.datetime.strptime + fmt = '%Y-%m-%dT%H:%M:%S.%f' + last_viewed = model.Dashboard.get_activity_stream_last_viewed(user_id) + for activity in activity_dicts: + if activity['user_id'] == user_id: + # Never mark the user's own activities as new. + activity['is_new'] = False + else: + activity['is_new'] = (strptime(activity['timestamp'], fmt) + > last_viewed) - query = from_user_query.union(about_user_query).union( - user_followees_query).union(dataset_followees_query) - query = query.order_by(_desc(model.Activity.timestamp)) - query = query.limit(31) - query = query.offset(offset) - activity_objects = query.all() + return activity_dicts - return model_dictize.activity_list_dictize(activity_objects, context) def dashboard_activity_list_html(context, data_dict): - '''Return the dashboard activity stream of the given user as HTML. + '''Return the authorized user's dashboard activity stream as HTML. The activity stream is rendered as a snippet of HTML meant to be included in an HTML page, i.e. it doesn't have any HTML header or footer. - :param id: The id or name of the user. - :type id: string - :rtype: string ''' activity_stream = dashboard_activity_list(context, data_dict) offset = int(data_dict['offset']) activity_params = ({ - 'controller': 'user', + 'controller': 'dashboard', 'action': 'dashboard', 'id': data_dict['id'], 'offset': offset @@ -2281,6 +2282,38 @@ def dashboard_activity_list_html(context, data_dict): return activity_streams.activity_list_to_html(context, activity_stream, activity_params) +def dashboard_new_activities_count(context, data_dict): + '''Return the number of new activities in the user's dashboard. + + Return the number of new activities in the authorized user's dashboard + activity stream. + + Activities from the user herself are not counted by this function even + though they appear in the dashboard (users don't want to be notified about + things they did themselves). + + :rtype: int + + ''' + _check_access('dashboard_new_activities_count', context, data_dict) + activities = logic.get_action('dashboard_activity_list')( + context, data_dict) + return len([activity for activity in activities if activity['is_new']]) + + +def dashboard_mark_all_new_activities_as_old(context, data_dict): + '''Mark all the authorized user's new dashboard activities as old. + + This will reset dashboard_new_activities_count to 0. + + ''' + _check_access('dashboard_mark_all_new_activities_as_old', context, + data_dict) + model = context['model'] + user_id = model.User.get(context['user']).id + model.Dashboard.update_activity_stream_last_viewed(user_id) + + def _unpick_search(sort, allowed_fields=None, total=None): ''' This is a helper function that takes a sort string eg 'name asc, last_modified desc' and returns a list of diff --git a/ckan/logic/auth/get.py b/ckan/logic/auth/get.py index 395bace3b15..cc3c20f88fe 100644 --- a/ckan/logic/auth/get.py +++ b/ckan/logic/auth/get.py @@ -189,3 +189,21 @@ def get_site_user(context, data_dict): return {'success': False, 'msg': 'Only internal services allowed to use this action'} else: return {'success': True} + + +def dashboard_activity_list(context, data_dict): + if 'user' in context: + return {'success': True} + else: + return {'success': False, + 'msg': _("You must be logged in to access your dashboard.")} + + +def dashboard_new_activities_count(context, data_dict): + return ckan.new_authz.is_authorized('dashboard_activity_list', + context, data_dict) + + +def dashboard_mark_all_new_activities_as_old(context, data_dict): + return ckan.new_authz.is_authorized('dashboard_activity_list', + context, data_dict) diff --git a/ckan/logic/auth/publisher/get.py b/ckan/logic/auth/publisher/get.py index 0e936c95105..41383de6a41 100644 --- a/ckan/logic/auth/publisher/get.py +++ b/ckan/logic/auth/publisher/get.py @@ -5,6 +5,11 @@ from ckan.logic.auth.publisher import _groups_intersect from ckan.authz import Authorizer from ckan.logic.auth import get_package_object, get_group_object, get_resource_object +from ckan.logic.auth.get import ( + dashboard_new_activities_count, + dashboard_activity_list, + dashboard_mark_all_new_activities_as_old, + ) def site_read(context, data_dict): """\ diff --git a/ckan/migration/versions/062_add_dashboard_table.py b/ckan/migration/versions/062_add_dashboard_table.py new file mode 100644 index 00000000000..f5f23c82138 --- /dev/null +++ b/ckan/migration/versions/062_add_dashboard_table.py @@ -0,0 +1,16 @@ +from sqlalchemy import * +from migrate import * + +def upgrade(migrate_engine): + metadata = MetaData() + metadata.bind = migrate_engine + migrate_engine.execute(''' +CREATE TABLE dashboard ( + user_id text NOT NULL, + activity_stream_last_viewed timestamp without time zone NOT NULL +); +ALTER TABLE dashboard + ADD CONSTRAINT dashboard_pkey PRIMARY KEY (user_id); +ALTER TABLE dashboard + ADD CONSTRAINT dashboard_user_id_fkey FOREIGN KEY (user_id) REFERENCES "user"(id) ON UPDATE CASCADE ON DELETE CASCADE; + ''') diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py index 5601f79bf5c..9ed834e0e6b 100644 --- a/ckan/model/__init__.py +++ b/ckan/model/__init__.py @@ -148,6 +148,9 @@ DomainObjectOperation, DomainObject, ) +from dashboard import ( + Dashboard, +) import ckan.migration diff --git a/ckan/model/activity.py b/ckan/model/activity.py index ff72337f04d..0d783ae795c 100644 --- a/ckan/model/activity.py +++ b/ckan/model/activity.py @@ -1,6 +1,6 @@ import datetime -from sqlalchemy import orm, types, Column, Table, ForeignKey +from sqlalchemy import orm, types, Column, Table, ForeignKey, desc import meta import types as _types @@ -48,6 +48,7 @@ def __init__(self, user_id, object_id, revision_id, activity_type, meta.mapper(Activity, activity_table) + class ActivityDetail(domain_object.DomainObject): def __init__(self, activity_id, object_id, object_type, activity_type, @@ -61,6 +62,147 @@ def __init__(self, activity_id, object_id, object_type, activity_type, else: self.data = data + meta.mapper(ActivityDetail, activity_detail_table, properties = { 'activity':orm.relation ( Activity, backref=orm.backref('activity_detail')) }) + + +def _most_recent_activities(q, limit): + import ckan.model as model + q = q.order_by(desc(model.Activity.timestamp)) + if limit: + q = q.limit(limit) + return q.all() + +def _activities_at_offset(q, offset, limit): + import ckan.model as model + q = q.order_by(desc(model.Activity.timestamp)) + if offset: + q = q.offset(offset) + if limit: + q = q.limit(limit) + return q.all() + +def _activities_from_user_query(user_id): + import ckan.model as model + q = model.Session.query(model.Activity) + q = q.filter(model.Activity.user_id == user_id) + return q + + +def _activities_about_user_query(user_id): + import ckan.model as model + q = model.Session.query(model.Activity) + q = q.filter(model.Activity.object_id == user_id) + return q + + +def _user_activity_query(user_id): + q = _activities_from_user_query(user_id) + q = q.union(_activities_about_user_query(user_id)) + return q + + +def user_activity_list(user_id, offset=0, limit=31): + '''Return the given user's public activity stream. + + Returns all activities from or about the given user, i.e. where the given + user is the subject or object of the activity, e.g.: + + "{USER} created the dataset {DATASET}" + "{OTHER_USER} started following {USER}" + etc. + + ''' + q = _user_activity_query(user_id) + return _activities_at_offset(q, offset, limit) + + +def _package_activity_query(package_id): + import ckan.model as model + q = model.Session.query(model.Activity) + q = q.filter_by(object_id=package_id) + return q + + +def package_activity_list(package_id, offset=0, limit=15): + '''Return the given dataset (package)'s public activity stream. + + Returns all activities about the given dataset, i.e. where the given + dataset is the object of the activity, e.g.: + + "{USER} created the dataset {DATASET}" + "{USER} updated the dataset {DATASET}" + etc. + + ''' + q = _package_activity_query(package_id) + return _activities_at_offset(q, offset, limit) + + +def _activites_from_users_followed_by_user_query(user_id): + import ckan.model as model + q = model.Session.query(model.Activity) + q = q.join(model.UserFollowingUser, + model.UserFollowingUser.object_id == model.Activity.user_id) + q = q.filter(model.UserFollowingUser.follower_id == user_id) + return q + + +def _activities_from_datasets_followed_by_user_query(user_id): + import ckan.model as model + q = model.Session.query(model.Activity) + q = q.join(model.UserFollowingDataset, + model.UserFollowingDataset.object_id == model.Activity.object_id) + q = q.filter(model.UserFollowingDataset.follower_id == user_id) + return q + + +def _activities_from_everything_followed_by_user_query(user_id): + q = _activites_from_users_followed_by_user_query(user_id) + q = q.union(_activities_from_datasets_followed_by_user_query(user_id)) + return q + + +def activities_from_everything_followed_by_user(user_id, offset=0, limit=31): + '''Return activities from everything that the given user is following. + + Returns all activities where the object of the activity is anything + (user, dataset, group...) that the given user is following. + + ''' + q = _activities_from_everything_followed_by_user_query(user_id) + return _activities_at_offset(q, offset, limit) + + +def _dashboard_activity_query(user_id): + q = _user_activity_query(user_id) + q = q.union(_activities_from_everything_followed_by_user_query(user_id)) + return q + + +def dashboard_activity_list(user_id, offset=0, limit=31): + '''Return the given user's dashboard activity stream. + + Returns activities from the user's public activity stream, plus + activities from everything that the user is following. + + This is the union of user_activity_list(user_id) and + activities_from_everything_followed_by_user(user_id). + + ''' + q = _dashboard_activity_query(user_id) + return _activities_at_offset(q, offset, limit) + + +def _recently_changed_packages_activity_query(): + import ckan.model as model + q = model.Session.query(model.Activity) + q = q.filter(model.Activity.activity_type.endswith('package')) + return q + + +def recently_changed_packages_activity_list(limit=15): + q = _recently_changed_packages_activity_query() + return _most_recent_activities(q, limit) diff --git a/ckan/model/dashboard.py b/ckan/model/dashboard.py new file mode 100644 index 00000000000..438fc865eca --- /dev/null +++ b/ckan/model/dashboard.py @@ -0,0 +1,47 @@ +import datetime +import sqlalchemy +import meta + +dashboard_table = sqlalchemy.Table('dashboard', meta.metadata, + sqlalchemy.Column('user_id', sqlalchemy.types.UnicodeText, + sqlalchemy.ForeignKey('user.id', onupdate='CASCADE', + ondelete='CASCADE'), + primary_key=True, nullable=False), + sqlalchemy.Column('activity_stream_last_viewed', sqlalchemy.types.DateTime, + nullable=False) +) + + +class Dashboard(object): + '''Saved data used for the user's dashboard.''' + + def __init__(self, user_id): + self.user_id = user_id + self.activity_stream_last_viewed = datetime.datetime.now() + + @classmethod + def get_activity_stream_last_viewed(cls, user_id): + query = meta.Session.query(Dashboard) + query = query.filter(Dashboard.user_id == user_id) + try: + row = query.one() + return row.activity_stream_last_viewed + except sqlalchemy.orm.exc.NoResultFound: + # No dashboard row has been created for this user so they have no + # activity_stream_last_viewed date. Return the oldest date we can + # (i.e. all activities are new to this user). + return datetime.datetime.min + + @classmethod + def update_activity_stream_last_viewed(cls, user_id): + query = meta.Session.query(Dashboard) + query = query.filter(Dashboard.user_id == user_id) + try: + row = query.one() + row.activity_stream_last_viewed = datetime.datetime.now() + except sqlalchemy.orm.exc.NoResultFound: + row = Dashboard(user_id) + meta.Session.add(row) + meta.Session.commit() + +meta.mapper(Dashboard, dashboard_table) diff --git a/ckan/public/base/css/main.css b/ckan/public/base/css/main.css index c5abc691441..10703174a6a 100644 --- a/ckan/public/base/css/main.css +++ b/ckan/public/base/css/main.css @@ -6750,7 +6750,11 @@ li .icon-large:before { .hero .tags .tag { margin-right: 15px; } -.masthead { +header.masthead { + *zoom: 1; + color: #ffffff; + padding: 5px 10px 3px; + height: 55px; background-color: #005974; background-image: -moz-linear-gradient(top, #005d7a, #00536b); background-image: -ms-linear-gradient(top, #005d7a, #00536b); @@ -6760,53 +6764,32 @@ li .icon-large:before { background-image: linear-gradient(top, #005d7a, #00536b); background-repeat: repeat-x; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#005d7a', endColorstr='#00536b', GradientType=0); - *zoom: 1; - color: #ffffff; - padding: 5px 10px 3px; } -.masthead:before, -.masthead:after { +header.masthead:before, +header.masthead:after { display: table; content: ""; } -.masthead:after { +header.masthead:after { clear: both; } -.masthead .container { +header.masthead .container { position: relative; } -.masthead a { +header.masthead a { color: #ffffff; } -.masthead hgroup, -.masthead nav { - *zoom: 1; - min-height: 52px; - position: relative; - display: inline-block; -} -.masthead hgroup:before, -.masthead nav:before, -.masthead hgroup:after, -.masthead nav:after { - display: table; - content: ""; -} -.masthead hgroup:after, -.masthead nav:after { - clear: both; -} -.masthead hgroup h1, -.masthead hgroup h2 { +header.masthead hgroup h1, +header.masthead hgroup h2 { + float: left; font-size: 34px; line-height: 1.5; - float: left; } -.masthead hgroup h1 { - font-family: "Arial Black", "Arial Bold", Gadget, sans-serif; - letter-spacing: -2px; +header.masthead hgroup h1 { + font-weight: 900; + letter-spacing: -1px; } -.masthead hgroup h2 { +header.masthead hgroup h2 { position: absolute; bottom: -3px; left: 0; @@ -6816,219 +6799,188 @@ li .icon-large:before { line-height: 1.2; white-space: nowrap; } -.masthead .tagline-right h2 { - left: auto; +header.masthead .content { + position: absolute; + top: 10px; right: 0; } -.masthead hgroup a { - text-decoration: none; -} -.masthead .logo { - margin-right: 10px; +header.masthead .section { float: left; } -.masthead .logo img { - display: block; +header.masthead .navigation { + margin-right: 20px; } -.masthead .content { - position: absolute; - top: 8px; - right: 0; - bottom: 0; -} -.masthead .content > * { - float: right; +header.masthead .navigation ul.unstyled { + *zoom: 1; + margin: 5px 0; } -.masthead nav { - display: inline-block; - line-height: 40px; +header.masthead .navigation ul.unstyled:before, +header.masthead .navigation ul.unstyled:after { + display: table; + content: ""; } -.masthead nav ul { - margin: 0; +header.masthead .navigation ul.unstyled:after { + clear: both; } -.masthead nav li { - display: inline-block; +header.masthead .navigation ul.unstyled li { + display: block; + float: left; } -.masthead nav li a { +header.masthead .navigation ul.unstyled li a { display: block; font-size: 12px; font-weight: bold; padding: 4px 10px; -} -.masthead nav li a.active { -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; - -webkit-box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.3); - -moz-box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.3); - box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.3); - box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.3), 0 1px 0 rgba(255, 255, 255, 0.3); - background-color: rgba(255, 255, 255, 0.08); } -.masthead .account { - position: relative; - padding-top: 7px; +header.masthead .navigation ul.unstyled li a.active { + background-color: #0d6581; + box-shadow: 0 -1px 0 #084152, 0 1px 0 #26758e; } -.masthead .account.avatar { - padding-left: 55px; -} -.masthead .section { - margin-left: 20px; - padding-left: 20px; - min-height: 35px; -} -.masthead .section:before, -.masthead .section:after { - content: ""; - display: block; - position: absolute; - top: NaN; - left: 0; - bottom: NaN; - width: 1px; - background-color: #007094; - background-image: -webkit-gradient(radial, center center, 0, center center, 460, from(rgba(255, 255, 255, 0.1)), to(rgba(255, 255, 255, 0))); - background-image: -webkit-radial-gradient(circle, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); - background-image: -moz-radial-gradient(circle, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); - background-image: -ms-radial-gradient(circle, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); - background-image: -o-radial-gradient(circle, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); - background-color: rgba(255, 255, 255, 0.08000000000000002); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(rgba(255, 255, 255, 0)), color-stop(0.5, rgba(255, 255, 255, 0.1)), to(rgba(255, 255, 255, 0))); - background-image: -webkit-linear-gradient(rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.1) 0.5, rgba(255, 255, 255, 0)); - background-image: -moz-linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.1) 0.5, rgba(255, 255, 255, 0)); - background-image: -ms-linear-gradient(rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.1) 0.5, rgba(255, 255, 255, 0)); - background-image: -o-linear-gradient(rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.1) 0.5, rgba(255, 255, 255, 0)); - background-image: linear-gradient(rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.1) 0.5, rgba(255, 255, 255, 0)); - background-repeat: no-repeat; - background-color: rgba(255, 255, 255, 0); -} -.masthead .section:after { - left: -1px; - background-color: #004a61; - background-image: -webkit-gradient(radial, center center, 0, center center, 460, from(rgba(0, 0, 0, 0.2)), to(rgba(0, 0, 0, 0))); - background-image: -webkit-radial-gradient(circle, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)); - background-image: -moz-radial-gradient(circle, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)); - background-image: -ms-radial-gradient(circle, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)); - background-image: -o-radial-gradient(circle, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)); - background-color: rgba(0, 0, 0, 0.16000000000000003); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(rgba(0, 0, 0, 0)), color-stop(0.5, rgba(0, 0, 0, 0.2)), to(rgba(0, 0, 0, 0))); - background-image: -webkit-linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2) 0.5, rgba(0, 0, 0, 0)); - background-image: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2) 0.5, rgba(0, 0, 0, 0)); - background-image: -ms-linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2) 0.5, rgba(0, 0, 0, 0)); - background-image: -o-linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2) 0.5, rgba(0, 0, 0, 0)); - background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2) 0.5, rgba(0, 0, 0, 0)); - background-repeat: no-repeat; - background-color: rgba(0, 0, 0, 0); -} -.masthead .section.first:after, -.masthead .section.first:before { - content: none; +header.masthead .site-search { + margin: 3px 0; } -.masthead .navigation { - margin-right: -10px; +header.masthead .site-search input { + width: 190px; + font-size: 11px; + padding: 4px; } -.masthead .account .image { - position: absolute; - left: 10px; - top: 6px; - padding-right: 10px; - opacity: 0.8; +header.masthead .account { + background: #003f52; + padding: 3px 5px; + margin: 2px 0 2px 30px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + -webkit-box-shadow: inset 0 2px 4px #002b38; + -moz-box-shadow: inset 0 2px 4px #002b38; + box-shadow: inset 0 2px 4px #002b38; } -.masthead .account .image img { - -webkit-border-radius: 100px; - -moz-border-radius: 100px; - border-radius: 100px; - -webkit-box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.3); - -moz-box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.3); - box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.3); +header.masthead .account ul.unstyled { + *zoom: 1; } -.masthead .account .image i { - position: absolute; - top: 0; - left: 30px; - line-height: 25px; +header.masthead .account ul.unstyled:before, +header.masthead .account ul.unstyled:after { + display: table; + content: ""; } -.masthead .account .image:hover { - opacity: 1; +header.masthead .account ul.unstyled:after { + clear: both; } -.masthead .account .image:hover i { - font-weight: normal; - text-decoration: none; +header.masthead .account ul.unstyled li { + display: block; + float: left; } -.masthead .account.open .image { - opacity: 1; +header.masthead .account ul.unstyled li a { + display: block; + font-size: 12px; + font-weight: bold; + padding: 4px 10px; } -.masthead .account.open .image img { - -webkit-box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.5); - -moz-box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.5); - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.5); +header.masthead .account ul.unstyled li a.sub { + font-weight: 300; + border-left: 1px solid #002b38; } -.masthead .account .image img { - display: block; +header.masthead .account .dropdown { + float: left; } -.masthead .links { +header.masthead .account .button { display: block; - position: relative; - top: -2px; + text-decoration: none; + background-color: #005d7a; + color: #80aebd; + text-shadow: 0 1px 1px #003647; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 0 #007094; + -moz-box-shadow: inset 0 1px 0 #007094; + box-shadow: inset 0 1px 0 #007094; } -.masthead .links a { - line-height: 13px; - float: left; +header.masthead .account .image { + margin: 2px 0; + padding: 0 4px 0 0; + overflow: hidden; + font-size: 10px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; } -.masthead .links .sub { - font-size: 11px; - clear: left; - color: rgba(255, 255, 255, 0.6); +header.masthead .account .image i { + vertical-align: -1px; } -.masthead .dropdown-menu { - min-width: 130px; - margin-top: -5px; +header.masthead .account .image img { + opacity: 0.7; + border-right: 1px solid #00536b; + -webkit-border-radius: 4px 0 0 4px; + -moz-border-radius: 4px 0 0 4px; + border-radius: 4px 0 0 4px; } -.masthead .dropdown-menu li a { - color: #187794; +header.masthead .account .notifications { + padding: 4px 8px 3px 8px; + margin: 2px 5px 2px 0; } -.masthead .dropdown-menu li a i { - margin-right: 5px; +header.masthead .account .notifications.notifications-important { + color: #ffffff; + background-color: #c9403a; + text-shadow: 0 1px 1px #a3322d; + -webkit-box-shadow: inset 0 1px 0 #ce534e; + -moz-box-shadow: inset 0 1px 0 #ce534e; + box-shadow: inset 0 1px 0 #ce534e; +} +header.masthead .account .dropdown.open .image img, +header.masthead .account .dropdown .image:hover img { + opacity: 1; + border-right-color: #007b9e; } -.masthead .dropdown-menu li a:hover { +header.masthead .account .dropdown.open .button, +header.masthead .account .dropdown .button:hover { color: #ffffff; + background-color: #669eaf; + -webkit-box-shadow: inset 0 1px 0 #80aebd; + -moz-box-shadow: inset 0 1px 0 #80aebd; + box-shadow: inset 0 1px 0 #80aebd; + text-decoration: none; } -.masthead .site-search { - position: relative; - margin: 0; - margin-left: 20px; - padding: 0 0 0 20px; +header.masthead .account .dropdown.open .notifications-important, +header.masthead .account .dropdown .notifications-important:hover { + background-color: #d46762; + text-shadow: 0 1px 1px #c9403a; + -webkit-box-shadow: inset 0 1px 0 #d97a76; + -moz-box-shadow: inset 0 1px 0 #d97a76; + box-shadow: inset 0 1px 0 #d97a76; } -.masthead .site-search .field { - padding: 0; - margin-top: 5px; +header.masthead .account.authed { + margin: 0 0 0 30px; } -.masthead .site-search input { - width: 190px; - font-size: 11px; - padding: 4px; +header.masthead .account.not-authed { + padding-top: 2px; + padding-bottom: 2px; } -.masthead .header-image { - font: inherit; - text-indent: -900em; - min-height: 50px; +header.masthead .dropdown-menu { + margin-top: -1px; } -.masthead .header-image .logo { - position: absolute; - top: 0; - left: 0; - display: block; - width: 240px; - height: 50px; - text-indent: 0; +header.masthead .user-dropdown-menu a { + color: #005d7a; +} +header.masthead .user-dropdown-menu a:hover { + color: #ffffff; } -.masthead .debug { +header.masthead .debug { position: absolute; - top: 10px; + bottom: 10px; left: 10px; + font-size: 11px; color: rgba(255, 255, 255, 0.5); + line-height: 1.2; } .site-footer { + *zoom: 1; + color: #ffffff; + padding: 5px 10px 3px; + height: 55px; background-color: #005974; background-image: -moz-linear-gradient(top, #005d7a, #00536b); background-image: -ms-linear-gradient(top, #005d7a, #00536b); @@ -7038,9 +6990,6 @@ li .icon-large:before { background-image: linear-gradient(top, #005d7a, #00536b); background-repeat: repeat-x; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#005d7a', endColorstr='#00536b', GradientType=0); - *zoom: 1; - color: #ffffff; - padding: 5px 10px 3px; font-size: 12px; padding: 20px 0; } @@ -7264,6 +7213,7 @@ li .icon-large:before { background: transparent url('../../../base/images/dotted.png') 14px 0 repeat-y; } .activity .item { + position: relative; margin: 0 0 15px 0; padding: 0; *zoom: 1; @@ -7316,6 +7266,24 @@ li .icon-large:before { white-space: nowrap; margin-left: 5px; } +.activity .item .new { + display: block; + position: absolute; + overflow: hidden; + top: -3px; + left: -3px; + width: 10px; + height: 10px; + background-color: #A35647; + border: 1px solid #FFF; + text-indent: -1000px; + -webkit-border-radius: 100px; + -moz-border-radius: 100px; + border-radius: 100px; + -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} .popover .about { margin-bottom: 10px; } @@ -7383,6 +7351,9 @@ li .icon-large:before { .activity .item.new-related-item i { background-color: #95a669; } +.activity .item.follow-group i { + background-color: #8ba669; +} .popover-context-loading .popover-title { display: none; } @@ -7405,6 +7376,9 @@ li .icon-large:before { -moz-border-radius: 100px; border-radius: 100px; } +.module-my-datasets .empty { + padding: 10px; +} body { background-color: #00536b; } @@ -7537,6 +7511,20 @@ iframe { -moz-box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); } +.ie7 .masthead nav ul li a.active, +.ie8 .masthead nav ul li a.active { + position: relative; + top: -1px; + background-color: #006584; + border-top: 1px solid #00516b; + border-bottom: 1px solid #007094; +} +.ie8 .masthead .account a.image { + display: block; + width: 25px; + padding-right: 10px; + white-space: nowrap; +} .ie7 .alert { position: relative; } @@ -7620,6 +7608,10 @@ iframe { .ie7 .stages li .highlight { width: auto; } +.ie7 .masthead { + position: relative; + z-index: 1; +} .ie7 .masthead .logo img, .ie7 .masthead nav { *display: inline; @@ -7643,6 +7635,9 @@ iframe { .ie7 .masthead .header-image { display: block; } +.ie7 .masthead .account .dropdown-menu { + z-index: 10000; +} .ie7 .footer-links { *zoom: 1; } @@ -7657,10 +7652,6 @@ iframe { .ie7 .footer-links li { float: left; } -.ie7 .nav-item.active > a { - background-image: url(../images/background-tag-ie7.png); - background-position: 0 0; -} .ie7 .module-narrow .nav-item.image { *zoom: 1; } diff --git a/ckan/public/base/javascript/modules/dashboard.js b/ckan/public/base/javascript/modules/dashboard.js new file mode 100644 index 00000000000..7c30c655c6c --- /dev/null +++ b/ckan/public/base/javascript/modules/dashboard.js @@ -0,0 +1,11 @@ +this.ckan.module('dashboard', function ($, _) { + return { + initialize: function () { + if ($('.new', this.el)) { + setTimeout(function() { + $('.masthead .notifications').removeClass('notifications-important').html('0'); + }, 1000); + } + } + }; +}); diff --git a/ckan/public/base/javascript/resource.config b/ckan/public/base/javascript/resource.config index 2fb76f25bb3..e5c909e8397 100644 --- a/ckan/public/base/javascript/resource.config +++ b/ckan/public/base/javascript/resource.config @@ -34,6 +34,7 @@ ckan = modules/follow.js modules/popover-context.js modules/activity-stream.js + modules/dashboard.js main = apply_html_class diff --git a/ckan/public/base/less/activity.less b/ckan/public/base/less/activity.less index a97132761e1..0bfbbc6e042 100644 --- a/ckan/public/base/less/activity.less +++ b/ckan/public/base/less/activity.less @@ -4,6 +4,7 @@ list-style-type: none; background: transparent url('@{imagePath}/dotted.png') 14px 0 repeat-y; .item { + position: relative; margin: 0 0 15px 0; padding: 0; .clearfix; @@ -41,6 +42,20 @@ white-space: nowrap; margin-left: 5px; } + .new { + display: block; + position: absolute; + overflow: hidden; + top: -3px; + left: -3px; + width: 10px; + height: 10px; + background-color: #A35647; + border: 1px solid #FFF; + text-indent: -1000px; + .border-radius(100px); + .box-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); + } } .load-less { margin-bottom: 15px; diff --git a/ckan/public/base/less/ckan.less b/ckan/public/base/less/ckan.less index 21a8d6ab07e..028c7de93a5 100644 --- a/ckan/public/base/less/ckan.less +++ b/ckan/public/base/less/ckan.less @@ -17,6 +17,7 @@ @import "activity.less"; @import "popover-context.less"; @import "follower-list.less"; +@import "dashboard.less"; body { // Using the masthead/footer gradient prevents the color from changing diff --git a/ckan/public/base/less/dashboard.less b/ckan/public/base/less/dashboard.less new file mode 100644 index 00000000000..63223bbcd7b --- /dev/null +++ b/ckan/public/base/less/dashboard.less @@ -0,0 +1,5 @@ +.module-my-datasets { + .empty { + padding: 10px; + } +} diff --git a/ckan/public/base/less/footer.less b/ckan/public/base/less/footer.less index 226ecc4249f..8783e68a85a 100644 --- a/ckan/public/base/less/footer.less +++ b/ckan/public/base/less/footer.less @@ -1,5 +1,5 @@ .site-footer { - .masthead; + .masthead(); font-size: 12px; padding: 20px 0; } diff --git a/ckan/public/base/less/iehacks.less b/ckan/public/base/less/iehacks.less index ece1e36bd2d..3d21b56ead5 100644 --- a/ckan/public/base/less/iehacks.less +++ b/ckan/public/base/less/iehacks.less @@ -68,6 +68,8 @@ .masthead .account a.image { display: block; width: 25px; + padding-right: 10px; + white-space: nowrap; } } @@ -192,10 +194,6 @@ } // Navs - .nav-item.active > a { - background-image: url(../images/background-tag-ie7.png); - background-position: 0 0; - } .module-narrow .nav-item.image { .clearfix; } diff --git a/ckan/public/base/less/masthead.less b/ckan/public/base/less/masthead.less index 59718a5f5e4..764261b4dd6 100644 --- a/ckan/public/base/less/masthead.less +++ b/ckan/public/base/less/masthead.less @@ -1,269 +1,204 @@ @mastheadPadding: 5px 10px 3px; +@notificationsBg: #C9403A; + +@menuActiveBg: mix(@mastheadBackgroundColorStart, @mastheadLinkColor, 95%); +@menuActiveHi: mix(@mastheadBackgroundColorStart, @mastheadLinkColor, 85%); +@menuActiveLo: darken(@menuActiveBg, 10%); + +.masthead() { + .clearfix(); + color: @mastheadTextColor; + padding: @mastheadPadding; + height: 55px; + #gradient > .vertical(@mastheadBackgroundColorStart, @mastheadBackgroundColorEnd); +} + +header.masthead { + + .masthead(); + + .container { + position: relative; + } + + a { + color: @mastheadLinkColor; + } + + hgroup { + h1, + h2 { + float: left; + font-size: 34px; + line-height: 1.5; + } + h1 { + font-weight: 900; + letter-spacing: -1px; + } + h2 { + position: absolute; + bottom: -3px; + left: 0; + margin: 0; + font-size: 15px; + font-weight: normal; + line-height: 1.2; + white-space: nowrap; + } + } + + .content { + position: absolute; + top: 10px; + right: 0; + } + + .section { + float: left; + } + + .navigation { + margin-right: 20px; + ul.unstyled { + .clearfix(); + margin: 5px 0; + li { + display: block; + float: left; + a { + display: block; + font-size: 12px; + font-weight: bold; + padding: 4px 10px; + .border-radius(3px); + &.active { + background-color: @menuActiveBg; + box-shadow: 0 -1px 0 @menuActiveLo, 0 1px 0 @menuActiveHi; + } + } + } + } + } + + .site-search { + margin: 3px 0; + input { + width: 190px; + font-size: 11px; + padding: 4px; + } + } + + .account { + background: darken(@mastheadBackgroundColorEnd, 5); + padding: 3px 5px; + margin: 2px 0 2px 30px; + .border-radius(4px); + .box-shadow(inset 0 2px 4px darken(@mastheadBackgroundColorEnd, 10)); + ul.unstyled { + .clearfix(); + li { + display: block; + float: left; + a { + display: block; + font-size: 12px; + font-weight: bold; + padding: 4px 10px; + &.sub { + font-weight: 300; + border-left: 1px solid darken(@mastheadBackgroundColorEnd, 10); + } + } + } + } + .dropdown { + float: left; + } + .button { + display: block; + text-decoration: none; + background-color: @mastheadBackgroundColorStart; + color: mix(@mastheadBackgroundColorStart, @mastheadLinkColor, 50%); + text-shadow: 0 1px 1px darken(@mastheadBackgroundColorStart, 10); + .border-radius(4px); + .box-shadow(inset 0 1px 0 lighten(@mastheadBackgroundColorStart, 5)); + } + .image { + margin: 2px 0; + padding: 0 4px 0 0; + overflow: hidden; + font-size: 10px; + .border-radius(4px); + i { + vertical-align: -1px; + } + img { + opacity: 0.7; + border-right: 1px solid @mastheadBackgroundColorEnd; + .border-radius(4px 0 0 4px); + } + } + .notifications { + padding: 4px 8px 3px 8px; + margin: 2px 5px 2px 0; + &.notifications-important { + color: @mastheadLinkColor; + background-color: @notificationsBg; + text-shadow: 0 1px 1px darken(@notificationsBg, 10); + .box-shadow(inset 0 1px 0 lighten(@notificationsBg, 5)); + } + } + .dropdown { + &.open .image img, + .image:hover img { + opacity: 1; + border-right-color: lighten(@mastheadBackgroundColorEnd, 10); + } + &.open .button, + .button:hover { + color: @mastheadLinkColor; + background-color: mix(@mastheadBackgroundColorStart, @mastheadLinkColor, 60%); + .box-shadow(inset 0 1px 0 mix(@mastheadBackgroundColorStart, @mastheadLinkColor, 50%)); + text-decoration: none; + } + &.open .notifications-important, + .notifications-important:hover { + background-color: lighten(@notificationsBg, 10); + text-shadow: 0 1px 1px @notificationsBg; + .box-shadow(inset 0 1px 0 lighten(@notificationsBg, 15)); + } + } + &.authed { + margin: 0 0 0 30px; + } + &.not-authed { + padding-top: 2px; + padding-bottom: 2px; + } + } + + .dropdown-menu { + margin-top: -1px; + } + + .user-dropdown-menu { + a { + color: @mastheadBackgroundColorStart; + &:hover { + color: @mastheadLinkColor; + } + } + } + + .debug { + position: absolute; + bottom: 10px; + left: 10px; + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + line-height: 1.2; + } -.masthead { - #gradient > .vertical(@mastheadBackgroundColorStart, @mastheadBackgroundColorEnd); - .clearfix(); - color: @mastheadTextColor; - padding: @mastheadPadding; -} - -.masthead .container { - position: relative; -} - -.masthead a { - color: @mastheadLinkColor; -} - -.masthead hgroup, -.masthead nav { - .clearfix; - min-height: 52px; - position: relative; - display: inline-block; -} - -.masthead hgroup h1, -.masthead hgroup h2 { - font-size: 34px; - line-height: 1.5; - float: left; -} - -.masthead hgroup h1 { - font-family: "Arial Black", "Arial Bold", Gadget, sans-serif; - letter-spacing: -2px; // Sorry world of typography. -} - -.masthead hgroup h2 { - position: absolute; - bottom: -3px; - left: 0; - margin: 0; - font-size: 15px; - font-weight: normal; - line-height: 1.2; - white-space: nowrap; -} - -.masthead .tagline-right h2 { - left: auto; - right: 0; -} - -.masthead hgroup a { - text-decoration: none; -} - -.masthead .logo { - margin-right: 10px; - float: left; -} - -.masthead .logo img { - display: block; -} - -.masthead .content { - position: absolute; - top: 8px; - right: 0; - bottom: 0; -} - -.masthead .content > * { - float: right; -} - -.masthead nav { - display: inline-block; - line-height: 40px; -} - -.masthead nav ul { - margin: 0; -} - -.masthead nav li { - display: inline-block; -} - -.masthead nav li a { - display: block; - font-size: 12px; - font-weight: bold; - padding: 4px 10px; -} - -.masthead nav li a.active { - @insetBoxShadow: inset 0 1px 0 rgba(0, 0, 0, 0.3); - .border-radius(3px); - .box-shadow(@insetBoxShadow); - box-shadow: @insetBoxShadow, 0 1px 0 rgba(255, 255, 255, 0.3); - background-color: rgba(255, 255, 255, 0.08); -} - -.masthead .account { - position: relative; - padding-top: 7px; -} - -.masthead .account.avatar { - padding-left: 55px; -} - -.masthead .section { - margin-left: 20px; - padding-left: 20px; - min-height: 35px; -} - -.masthead .section:before, -.masthead .section:after { - content: ""; - display: block; - position: absolute; - top: -@mastheadPadding - 10; - left: 0; - bottom: -@mastheadPadding - 10; - width: 1px; - background-color: lighten(@mastheadBackgroundColorStart, 5); - #gradient > .radial(rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); - .rgba-vertical-gradient-three-colors(rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.1), 0.5, rgba(255, 255, 255, 0)); - background-color: rgba(255, 255, 255, 0); -} - -.masthead .section:before { -} - -.masthead .section:after { - left: -1px; - background-color: darken(@mastheadBackgroundColorStart, 5); - #gradient > .radial(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)); - .rgba-vertical-gradient-three-colors(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2), 0.5, rgba(0, 0, 0, 0)); - background-color: rgba(0, 0, 0, 0); -} - -.masthead .section.first:after, -.masthead .section.first:before { - content: none; -} - -.masthead .navigation { - margin-right: -10px; // Pull nav in to the right. -} - -.masthead .account { - .image { - position: absolute; - left: 10px; - top: 6px; - padding-right: 10px; - opacity: 0.8; - img { - .border-radius(100px); - .box-shadow(0 -2px 4px rgba(0, 0, 0, 0.3)) - } - i { - position: absolute; - top: 0; - left: 30px; - line-height: 25px; - } - &:hover { - opacity: 1; - i { - font-weight: normal; - text-decoration: none; - } - } - } - &.open .image { - opacity: 1; - img { - .box-shadow(0 -1px 2px rgba(0, 0, 0, 0.5)) - } - } -} - -.masthead .account .image img { - display: block; -} - -.masthead .links { - display: block; - position: relative; - top: -2px; -} - -.masthead .links a { - line-height: 13px; - float: left; -} - -.masthead .links .sub { - font-size: 11px; - clear: left; - color: rgba(255, 255, 255, 0.6); -} - -.masthead .dropdown-menu { - min-width: 130px; - margin-top: -5px; - li a { - color: @layoutLinkColor; - i { - margin-right: 5px; - } - &:hover { - color: @mastheadLinkColor; - } - } -} - -.masthead .site-search { - position: relative; - margin: 0; - margin-left: 20px; - padding: 0 0 0 20px; -} - -.masthead .site-search .field { - padding: 0; - margin-top: 5px; -} - -.masthead .site-search input { - width: 190px; - font-size: 11px; - padding: 4px; -} - -.masthead .site-search button { - -} - -// Logo Image Replacement - -.masthead .header-image { - font: inherit; // Override Bootstrap .hide-text - text-indent: -900em; - min-height: 50px; -} - -.masthead .header-image .logo { - position: absolute; - top: 0; - left: 0; - display: block; - width: 240px; - height: 50px; - text-indent: 0; -} - -.masthead .debug { - position: absolute; - top: 10px; - left: 10px; - color: rgba(255, 255, 255, 0.5); } diff --git a/ckan/templates/header.html b/ckan/templates/header.html index 9fd3d6a3727..a86da127267 100644 --- a/ckan/templates/header.html +++ b/ckan/templates/header.html @@ -15,46 +15,11 @@

{% endif %}
- {% if c.userobj %} - - {% else %} - - {% endif %} + - + {% if c.userobj %} + + {% else %} + + {% endif %}
diff --git a/ckan/templates/snippets/activity_item.html b/ckan/templates/snippets/activity_item.html index b7c3640da45..1cf57772027 100644 --- a/ckan/templates/snippets/activity_item.html +++ b/ckan/templates/snippets/activity_item.html @@ -1,4 +1,7 @@
  • + {% if activity.is_new %} + {{ _('New activity item') }} + {% endif %}

    {{ h.literal(activity.msg.format(**activity.data)) }} diff --git a/ckan/templates/user/dashboard.html b/ckan/templates/user/dashboard.html index d1612f35799..d50c1cf1797 100644 --- a/ckan/templates/user/dashboard.html +++ b/ckan/templates/user/dashboard.html @@ -10,14 +10,16 @@ {% endblock %} {% block actions_content %} -

  • {% link_for _('Add new dataset'), controller='package', action='new', class_="btn btn-primary icon-large", icon="plus" %}
  • + {% if h.check_access('package_create') %} +
  • {% link_for _('Add Dataset'), controller='package', action='new', class_="btn btn-primary icon-large", icon="plus" %}
  • + {% endif %} {% endblock %} {% block primary_content %}
    -
    +

    {{ _('News feed') }} {{ _('Activity from users and datasets that you follow') }}

    - {{ h.dashboard_activity_stream(c.user_dict['id'], offset=c.dashboard_offset) }} + {{ c.dashboard_activity_stream }}
    {% endblock %} diff --git a/ckan/tests/functional/api/test_activity.py b/ckan/tests/functional/api/test_activity.py index 1126efd0484..4f7eff2a380 100644 --- a/ckan/tests/functional/api/test_activity.py +++ b/ckan/tests/functional/api/test_activity.py @@ -216,10 +216,14 @@ def teardown_class(self): import ckan.model as model model.repo.rebuild_db() - def dashboard_activity_stream(self, user_id): - response = self.app.get( - "/api/2/rest/user/{0}/dashboard_activity".format(user_id)) - return json.loads(response.body) + def dashboard_activity_stream(self, apikey): + + response = self.app.get("/api/action/dashboard_activity_list", + json.dumps({}), + extra_environ={'Authorization': str(apikey)}) + response_dict = json.loads(response.body) + assert response_dict['success'] is True + return response_dict['result'] def user_activity_stream(self, user_id, apikey=None): if apikey: @@ -286,35 +290,43 @@ def record_details(self, user_id, package_id=None, group_ids=None, details['recently changed datasets stream'] = \ self.recently_changed_datasets_stream(apikey) + details['user dashboard activity stream'] = ( + self.dashboard_activity_stream(apikey)) + details['follower dashboard activity stream'] = ( - self.dashboard_activity_stream(self.follower['id'])) + self.dashboard_activity_stream(self.follower['apikey'])) details['time'] = datetime.datetime.now() return details - def check_dashboard( - self, - before, after, wanted_difference, - potential_followees): - difference = find_new_activities( - before['follower dashboard activity stream'], - after['follower dashboard activity stream']) - if any(potential_followee in self.followees - for potential_followee in potential_followees): - assert difference == wanted_difference - else: - assert len(difference) == 0 + def check_dashboards(self, before, after, activity): + new_activities = [activity_ for activity_ in + after['user dashboard activity stream'] + if activity_ not in before['user dashboard activity stream']] + assert [activity['id'] for activity in new_activities] == [ + activity['id']] + + new_activities = [activity_ for activity_ in + after['follower dashboard activity stream'] + if activity_ not in before['follower dashboard activity stream']] + assert [activity['id'] for activity in new_activities] == [ + activity['id']] def _create_package(self, user, name=None): if user: user_id = user['id'] + apikey = user['apikey'] else: user_id = 'not logged in' + apikey = None + + before = self.record_details(user_id, apikey=apikey) # Create a new package. request_data = make_package(name) before = self.record_details(user_id=user_id, - group_ids=[group['name'] for group in request_data['groups']]) + group_ids=[group['name'] for group in request_data['groups']], + apikey=apikey) extra_environ = {'Authorization': str(user['apikey'])} response = self.app.post('/api/action/package_create', json.dumps(request_data), extra_environ=extra_environ) @@ -324,7 +336,8 @@ def _create_package(self, user, name=None): after = self.record_details(user_id=user_id, package_id=package_created['id'], - group_ids=[group['name'] for group in package_created['groups']]) + group_ids=[group['name'] for group in package_created['groups']], + apikey=apikey) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -345,7 +358,30 @@ def _create_package(self, user, name=None): after['recently changed datasets stream']) assert new_rcd_activities == [activity] - self.check_dashboard(before, after, user_new_activities, [user_id]) + # The new activity should appear in the user's dashboard activity + # stream. + new_activities = [activity_ for activity_ in + after['user dashboard activity stream'] + if activity_ not in before['user dashboard activity stream']] + # There will be other new activities besides the 'follow dataset' one + # because all the dataset's old activities appear in the user's + # dashboard when she starts to follow the dataset. + assert activity['id'] in [ + activity['id'] for activity in new_activities] + + # The new activity should appear in the user "follower"'s dashboard + # activity stream because she follows all the other users and datasets. + new_activities = [activity_ for activity_ in + after['follower dashboard activity stream'] + if activity_ not in before['follower dashboard activity stream']] + # There will be other new activities besides the 'follow dataset' one + # because all the dataset's old activities appear in the user's + # dashboard when she starts to follow the dataset. + assert [activity['id'] for activity in new_activities] == [ + activity['id']] + + # The same new activity should appear on the dashboard's of the user's + # followers. # The same new activity should appear in the activity streams of the # package's groups. @@ -353,7 +389,8 @@ def _create_package(self, user, name=None): grp_new_activities = find_new_activities( before['group activity streams'][group_dict['name']], after['group activity streams'][group_dict['name']]) - assert grp_new_activities == [activity] + assert [activity['id'] for activity in grp_new_activities] == [ + activity['id']] # Check that the new activity has the right attributes. assert activity['object_id'] == package_created['id'], \ @@ -409,11 +446,13 @@ def _create_package(self, user, name=None): def _add_resource(self, package, user): if user: user_id = user['id'] + apikey = user['apikey'] else: user_id = 'not logged in' + apikey = None before = self.record_details(user_id, package['id'], - [group['id'] for group in package['groups']]) + [group['name'] for group in package['groups']], apikey=apikey) resource_ids_before = [resource['id'] for resource in package['resources']] @@ -424,7 +463,7 @@ def _add_resource(self, package, user): updated_package = package_update(self.app, package, user['apikey']) after = self.record_details(user_id, package['id'], - [group['id'] for group in package['groups']]) + [group['name'] for group in package['groups']], apikey=apikey) resource_ids_after = [resource['id'] for resource in updated_package['resources']] assert len(resource_ids_after) == len(resource_ids_before) + 1 @@ -458,8 +497,7 @@ def _add_resource(self, package, user): after['group activity streams'][group_dict['name']]) assert grp_new_activities == [activity] - self.check_dashboard(before, after, user_new_activities, - [user_id, package['id']]) + self.check_dashboards(before, after, activity) # Check that the new activity has the right attributes. assert activity['object_id'] == updated_package['id'], \ @@ -497,10 +535,14 @@ def _add_resource(self, package, user): def _delete_extra(self, package_dict, user): if user: user_id = user['id'] + apikey = user['apikey'] else: user_id = 'not logged in' + apikey = None - before = self.record_details(user_id, package_dict['id']) + before = self.record_details(user_id, package_dict['id'], + [group['name'] for group in package_dict['groups']], + apikey=apikey) extras_before = list(package_dict['extras']) assert len(extras_before) > 0, ( @@ -511,7 +553,9 @@ def _delete_extra(self, package_dict, user): updated_package = package_update(self.app, package_dict, user['apikey']) - after = self.record_details(user_id, package_dict['id']) + after = self.record_details(user_id, package_dict['id'], + [group['name'] for group in package_dict['groups']], + apikey=apikey) extras_after = updated_package['extras'] assert len(extras_after) == len(extras_before) - 1, ( "%s != %s" % (len(extras_after), len(extras_before) - 1)) @@ -545,8 +589,7 @@ def _delete_extra(self, package_dict, user): after['group activity streams'][group_dict['name']]) assert grp_new_activities == [activity] - self.check_dashboard(before, after, user_new_activities, - [user_id, package_dict['id']]) + self.check_dashboards(before, after, activity) # Check that the new activity has the right attributes. assert activity['object_id'] == updated_package['id'], \ @@ -585,11 +628,14 @@ def _delete_extra(self, package_dict, user): def _update_extra(self, package_dict, user): if user: user_id = user['id'] + apikey = user['apikey'] else: user_id = 'not logged in' + apikey=None before = self.record_details(user_id, package_dict['id'], - [group['name'] for group in package_dict['groups']]) + [group['name'] for group in package_dict['groups']], + apikey=apikey) extras_before = package_dict['extras'] assert len(extras_before) > 0, ( @@ -606,7 +652,8 @@ def _update_extra(self, package_dict, user): user['apikey']) after = self.record_details(user_id, package_dict['id'], - [group['name'] for group in package_dict['groups']]) + [group['name'] for group in package_dict['groups']], + apikey=apikey) extras_after = updated_package['extras'] assert len(extras_after) == len(extras_before), ( "%s != %s" % (len(extras_after), len(extras_before))) @@ -632,8 +679,7 @@ def _update_extra(self, package_dict, user): after['recently changed datasets stream']) \ == user_new_activities - self.check_dashboard(before, after, user_new_activities, - [user_id, package_dict['id']]) + self.check_dashboards(before, after, activity) # If the package has any groups, the same new activity should appear # in the activity stream of each group. @@ -682,10 +728,14 @@ def _add_extra(self, package_dict, user, key=None): key = 'quality' if user: user_id = user['id'] + apikey = user['apikey'] else: user_id = 'not logged in' + apikey = None - before = self.record_details(user_id, package_dict['id']) + before = self.record_details(user_id, package_dict['id'], + [group['name'] for group in package_dict['groups']], + apikey=apikey) # Make a copy of the package's extras before we add a new extra, # so we can compare the extras before and after updating the package. @@ -697,7 +747,9 @@ def _add_extra(self, package_dict, user, key=None): updated_package = package_update(self.app, package_dict, user['apikey']) - after = self.record_details(user_id, package_dict['id']) + after = self.record_details(user_id, package_dict['id'], + [group['name'] for group in package_dict['groups']], + apikey=apikey) extras_after = updated_package['extras'] assert len(extras_after) == len(extras_before) + 1, ( "%s != %s" % (len(extras_after), len(extras_before) + 1)) @@ -731,8 +783,7 @@ def _add_extra(self, package_dict, user, key=None): after['group activity streams'][group_dict['name']]) assert grp_new_activities == [activity] - self.check_dashboard(before, after, user_new_activities, - [user_id, package_dict['id']]) + self.check_dashboards(before, after, activity) # Check that the new activity has the right attributes. assert activity['object_id'] == updated_package['id'], \ @@ -769,14 +820,16 @@ def _add_extra(self, package_dict, user, key=None): str(detail['activity_type'])) def _create_activity(self, user, package, params): - before = self.record_details(user['id'], package['id']) + before = self.record_details(user['id'], package['id'], + apikey=user['apikey']) response = self.app.post('/api/action/activity_create', params=json.dumps(params), extra_environ={'Authorization': str(self.sysadmin_user['apikey'])}) assert response.json['success'] is True - after = self.record_details(user['id'], package['id']) + after = self.record_details(user['id'], package['id'], + apikey=user['apikey']) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -792,8 +845,7 @@ def _create_activity(self, user, package, params): after['package activity stream'])) assert pkg_new_activities == user_new_activities - self.check_dashboard(before, after, user_new_activities, - [user['id'], package['id']]) + self.check_dashboards(before, after, activity) # Check that the new activity has the right attributes. assert activity['object_id'] == params['object_id'], ( @@ -840,7 +892,7 @@ def _delete_group(self, group, user): new_activities, ("The same activity should also " "appear in the group's activity stream.") - self.check_dashboard(before, after, new_activities, [user['id']]) + self.check_dashboards(before, after, activity) # Check that the new activity has the right attributes. assert activity['object_id'] == group['id'], str(activity['object_id']) @@ -862,13 +914,15 @@ def _update_group(self, group, user): item and detail are emitted. """ - before = self.record_details(user['id'], group_ids=[group['id']]) + before = self.record_details(user['id'], group_ids=[group['id']], + apikey=user['apikey']) # Update the group. group_dict = {'id': group['id'], 'title': 'edited'} group_update(self.app, group_dict, user['apikey']) - after = self.record_details(user['id'], group_ids=[group['id']]) + after = self.record_details(user['id'], group_ids=[group['id']], + apikey=user['apikey']) # Find the new activity. new_activities = find_new_activities(before['user activity stream'], @@ -883,7 +937,7 @@ def _update_group(self, group, user): new_activities, ("The same activity should also " "appear in the group's activity stream.") - self.check_dashboard(before, after, new_activities, [user['id']]) + self.check_dashboards(before, after, activity) # Check that the new activity has the right attributes. assert activity['object_id'] == group['id'], str(activity['object_id']) @@ -912,7 +966,8 @@ def _update_user(self, user): assert response_dict['success'] is True user_dict = response_dict['result'] - before = self.record_details(user_dict['id']) + before = self.record_details(user_dict['id'], + apikey=user_dict['apikey']) # Update the user. user_dict['about'] = 'edited' @@ -921,7 +976,8 @@ def _update_user(self, user): self.app.post('/api/action/user_update', json.dumps(user_dict), extra_environ={'Authorization': str(user['apikey'])}) - after = self.record_details(user_dict['id']) + after = self.record_details(user_dict['id'], + apikey=user_dict['apikey']) # Find the new activity. new_activities = find_new_activities(before['user activity stream'], @@ -930,7 +986,7 @@ def _update_user(self, user): "the user's activity stream, but found %i" % len(new_activities)) activity = new_activities[0] - self.check_dashboard(before, after, new_activities, [user_dict['id']]) + self.check_dashboards(before, after, activity) # Check that the new activity has the right attributes. assert activity['object_id'] == user_dict['id'], ( @@ -954,7 +1010,8 @@ def _delete_resources(self, package): """ before = self.record_details(self.normal_user['id'], package['id'], - [group['name'] for group in package['groups']]) + [group['name'] for group in package['groups']], + apikey=self.normal_user['apikey']) num_resources = len(package['resources']) assert num_resources > 0, \ @@ -965,7 +1022,8 @@ def _delete_resources(self, package): package_update(self.app, package, self.normal_user['apikey']) after = self.record_details(self.normal_user['id'], package['id'], - [group['name'] for group in package['groups']]) + [group['name'] for group in package['groups']], + apikey=self.normal_user['apikey']) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -996,8 +1054,7 @@ def _delete_resources(self, package): after['group activity streams'][group_dict['name']]) assert grp_new_activities == [activity] - self.check_dashboard(before, after, user_new_activities, - [package['id']]) + self.check_dashboards(before, after, activity) # Check that the new activity has the right attributes. assert activity['object_id'] == package['id'], ( @@ -1037,10 +1094,12 @@ def _update_package(self, package, user): """ if user: user_id = user['id'] + apikey = user['apikey'] else: user_id = 'not logged in' + apikey = None - before = self.record_details(user_id, package['id']) + before = self.record_details(user_id, package['id'], apikey=apikey) # Update the package. if package['title'] != 'edited': @@ -1050,7 +1109,7 @@ def _update_package(self, package, user): package['title'] = 'edited again' package_update(self.app, package, user['apikey']) - after = self.record_details(user_id, package['id']) + after = self.record_details(user_id, package['id'], apikey=apikey) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -1073,8 +1132,7 @@ def _update_package(self, package, user): after['recently changed datasets stream']) \ == user_new_activities - self.check_dashboard(before, after, user_new_activities, - [user_id, package['id']]) + self.check_dashboards(before, after, activity) # If the package has any groups, the same new activity should appear # in the activity stream of each group. @@ -1119,16 +1177,18 @@ def _update_resource(self, package, resource, user): """ if user: user_id = user['id'] + apikey = user['apikey'] else: user_id = 'not logged in' + apikey = None - before = self.record_details(user_id, package['id']) + before = self.record_details(user_id, package['id'], apikey=apikey) # Update the resource. resource['name'] = 'edited' package_update(self.app, package) - after = self.record_details(user_id, package['id']) + after = self.record_details(user_id, package['id'], apikey=apikey) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -1151,8 +1211,7 @@ def _update_resource(self, package, resource, user): after['recently changed datasets stream']) \ == user_new_activities - self.check_dashboard(before, after, user_new_activities, - [user_id, package['id']]) + self.check_dashboards(before, after, activity) # If the package has any groups, the same new activity should appear # in the activity stream of each group. @@ -1230,8 +1289,7 @@ def _delete_package(self, package): after['recently changed datasets stream']) \ == user_new_activities - self.check_dashboard(before, after, user_new_activities, - [self.sysadmin_user['id'], package['id']]) + self.check_dashboards(before, after, activity) # If the package has any groups, the same new activity should appear # in the activity stream of each group. @@ -1316,14 +1374,16 @@ def test_01_remove_tag(self): assert len(pkg_dict['tags']) >= 1, ("The package has to have at least" " one tag to test removing a tag.") before = self.record_details(user['id'], pkg_dict['id'], - [group['name'] for group in pkg_dict['groups']]) + [group['name'] for group in pkg_dict['groups']], + apikey=user['apikey']) data_dict = { 'id': pkg_dict['id'], 'tags': pkg_dict['tags'][0:-1], } package_update(self.app, data_dict, user['apikey']) after = self.record_details(user['id'], pkg_dict['id'], - [group['name'] for group in pkg_dict['groups']]) + [group['name'] for group in pkg_dict['groups']], + apikey=user['apikey']) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -1346,8 +1406,7 @@ def test_01_remove_tag(self): after['recently changed datasets stream']) \ == user_new_activities - self.check_dashboard(before, after, user_new_activities, - [user['id'], pkg_dict['id']]) + self.check_dashboards(before, after, activity) # If the package has any groups, the same new activity should appear # in the activity stream of each group. @@ -1491,7 +1550,8 @@ def test_create_user(self): assert response_dict['success'] is True user_created = response_dict['result'] - after = self.record_details(user_created['id']) + after = self.record_details(user_created['id'], + apikey=user_created['apikey']) user_activities = after['user activity stream'] assert len(user_activities) == 1, ("There should be 1 activity in " @@ -1533,7 +1593,7 @@ def test_create_group(self): user = self.normal_user - before = self.record_details(user['id']) + before = self.record_details(user['id'], apikey=user['apikey']) # Create a new group. request_data = {'name': 'a-new-group', 'title': 'A New Group'} @@ -1545,7 +1605,7 @@ def test_create_group(self): group_created = response_dict['result'] after = self.record_details(user['id'], - group_ids=[group_created['id']]) + group_ids=[group_created['id']], apikey=user['apikey']) # Find the new activity. new_activities = find_new_activities(before['user activity stream'], @@ -1558,7 +1618,7 @@ def test_create_group(self): new_activities, ("The same activity should also appear in " "the group's activity stream.") - self.check_dashboard(before, after, new_activities, [user['id']]) + self.check_dashboards(before, after, activity) # Check that the new activity has the right attributes. assert activity['object_id'] == group_created['id'], \ @@ -1601,13 +1661,15 @@ def test_add_tag(self): pkg_dict = package_show(self.app, {'id': pkg_name}) # Add one new tag to the package. - before = self.record_details(user['id'], pkg_dict['id']) + before = self.record_details(user['id'], pkg_dict['id'], + apikey=user['apikey']) new_tag_name = 'test tag' assert new_tag_name not in [tag['name'] for tag in pkg_dict['tags']] pkg_dict['tags'].append({'name': new_tag_name}) package_update(self.app, pkg_dict, user['apikey']) - after = self.record_details(user['id'], pkg_dict['id']) + after = self.record_details(user['id'], pkg_dict['id'], + apikey=user['apikey']) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -1630,8 +1692,7 @@ def test_add_tag(self): after['recently changed datasets stream']) \ == user_new_activities - self.check_dashboard(before, after, user_new_activities, - [user['id'], pkg_dict['id']]) + self.check_dashboards(before, after, activity) # If the package has any groups, the same new activity should appear # in the activity stream of each group. @@ -2033,7 +2094,8 @@ def test_delete_extras(self): def test_follow_dataset(self): user = self.normal_user - before = self.record_details(user['id']) + before = self.record_details(user['id'], self.warandpeace['id'], + apikey=user['apikey']) data = {'id': self.warandpeace['id']} extra_environ = {'Authorization': str(user['apikey'])} response = self.app.post('/api/action/follow_dataset', @@ -2041,7 +2103,8 @@ def test_follow_dataset(self): response_dict = json.loads(response.body) assert response_dict['success'] is True - after = self.record_details(user['id'], self.warandpeace['id']) + after = self.record_details(user['id'], self.warandpeace['id'], + apikey=user['apikey']) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -2056,7 +2119,27 @@ def test_follow_dataset(self): for activity in user_new_activities: assert activity in pkg_new_activities - self.check_dashboard(before, after, user_new_activities, [user['id']]) + # The new activity should appear in the user's dashboard activity + # stream. + new_activities = [activity_ for activity_ in + after['user dashboard activity stream'] + if activity_ not in before['user dashboard activity stream']] + # There will be other new activities besides the 'follow dataset' one + # because all the dataset's old activities appear in the user's + # dashboard when she starts to follow the dataset. + assert activity['id'] in [ + activity['id'] for activity in new_activities] + + # The new activity should appear in the user "follower"'s dashboard + # activity stream because she follows all the other users and datasets. + new_activities = [activity_ for activity_ in + after['follower dashboard activity stream'] + if activity_ not in before['follower dashboard activity stream']] + # There will be other new activities besides the 'follow dataset' one + # because all the dataset's old activities appear in the user's + # dashboard when she starts to follow the dataset. + assert [activity['id'] for activity in new_activities] == [ + activity['id']] # Check that the new activity has the right attributes. assert activity['object_id'] == self.warandpeace['id'], \ @@ -2077,8 +2160,9 @@ def test_follow_dataset(self): def test_follow_user(self): user = self.normal_user - before = self.record_details(user['id']) - followee_before = self.record_details(self.sysadmin_user['id']) + before = self.record_details(user['id'], apikey=user['apikey']) + followee_before = self.record_details(self.sysadmin_user['id'], + apikey=self.sysadmin_user['apikey']) data = {'id': self.sysadmin_user['id']} extra_environ = {'Authorization': str(user['apikey'])} response = self.app.post('/api/action/follow_user', @@ -2086,8 +2170,9 @@ def test_follow_user(self): response_dict = json.loads(response.body) assert response_dict['success'] is True - after = self.record_details(user['id']) - followee_after = self.record_details(self.sysadmin_user['id']) + after = self.record_details(user['id'], apikey=user['apikey']) + followee_after = self.record_details(self.sysadmin_user['id'], + apikey=self.sysadmin_user['apikey']) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -2105,17 +2190,7 @@ def test_follow_user(self): assert len(user_new_activities) == 1, ("There should be 1 new " " activity in the user's activity stream, but found %i" % len(user_new_activities)) - assert user_new_activities[0] == activity - - # Check that the new activity appears in the followee's private - # activity stream. - followee_new_activities = (find_new_activities( - followee_before['follower dashboard activity stream'], - followee_after['follower dashboard activity stream'])) - assert len(followee_new_activities) == 1, ("There should be 1 new " - " activity in the user's activity stream, but found %i" % - len(followee_new_activities)) - assert followee_new_activities[0] == activity + assert user_new_activities[0]['id'] == activity['id'] # Check that the new activity has the right attributes. assert activity['object_id'] == self.sysadmin_user['id'], \ diff --git a/ckan/tests/functional/api/test_dashboard.py b/ckan/tests/functional/api/test_dashboard.py new file mode 100644 index 00000000000..5c5cbd8443f --- /dev/null +++ b/ckan/tests/functional/api/test_dashboard.py @@ -0,0 +1,205 @@ +import ckan +from ckan.lib.helpers import json +import paste +import pylons.test + + +class TestDashboard(object): + '''Tests for the logic action functions related to the user's dashboard.''' + + @classmethod + def user_create(cls): + '''Create a new user.''' + params = json.dumps({ + 'name': 'mr_new_user', + 'email': 'mr@newuser.com', + 'password': 'iammrnew', + }) + response = cls.app.post('/api/action/user_create', params=params, + extra_environ={'Authorization': str(cls.joeadmin['apikey'])}) + assert response.json['success'] is True + new_user = response.json['result'] + return new_user + + @classmethod + def setup_class(cls): + ckan.tests.CreateTestData.create() + cls.app = paste.fixture.TestApp(pylons.test.pylonsapp) + joeadmin = ckan.model.User.get('joeadmin') + cls.joeadmin = { + 'id': joeadmin.id, + 'apikey': joeadmin.apikey + } + annafan = ckan.model.User.get('annafan') + cls.annafan = { + 'id': annafan.id, + 'apikey': annafan.apikey + } + testsysadmin = ckan.model.User.get('testsysadmin') + cls.testsysadmin = { + 'id': testsysadmin.id, + 'apikey': testsysadmin.apikey + } + cls.new_user = cls.user_create() + + @classmethod + def teardown_class(cls): + ckan.model.repo.rebuild_db() + + def dashboard_new_activities_count(self, user): + '''Return the given user's new activities count from the CKAN API.''' + params = json.dumps({}) + response = self.app.post('/api/action/dashboard_new_activities_count', + params=params, + extra_environ={'Authorization': str(user['apikey'])}) + assert response.json['success'] is True + new_activities_count = response.json['result'] + return new_activities_count + + def dashboard_activity_list(self, user): + '''Return the given user's dashboard activity list from the CKAN API. + + ''' + params = json.dumps({}) + response = self.app.post('/api/action/dashboard_activity_list', + params=params, + extra_environ={'Authorization': str(user['apikey'])}) + assert response.json['success'] is True + activity_list = response.json['result'] + return activity_list + + def dashboard_new_activities(self, user): + '''Return the activities from the user's dashboard activity stream + that are currently marked as new.''' + activity_list = self.dashboard_activity_list(user) + return [activity for activity in activity_list if activity['is_new']] + + def dashboard_mark_all_new_activities_as_old(self, user): + params = json.dumps({}) + response = self.app.post( + '/api/action/dashboard_mark_all_new_activities_as_old', + params=params, + extra_environ={'Authorization': str(user['apikey'])}) + assert response.json['success'] is True + + def test_01_new_activities_count_for_new_user(self): + '''Test that a newly registered user's new activities count is 0.''' + assert self.dashboard_new_activities_count(self.new_user) == 0 + + def test_01_new_activities_for_new_user(self): + '''Test that a newly registered user has no activities marked as new + in their dashboard activity stream.''' + assert len(self.dashboard_new_activities(self.new_user)) == 0 + + def test_02_own_activities_do_not_count_as_new(self): + '''Make a user do some activities and check that her own activities + don't increase her new activities count.''' + + # The user has to view her dashboard activity stream first to mark any + # existing activities as read. For example when she follows a dataset + # below, past activities from the dataset (e.g. when someone created + # the dataset, etc.) will appear in her dashboard, and if she has never + # viewed her dashboard then those activities will be considered + # "unseen". + # We would have to do this if, when you follow something, you only get + # the activities from that object since you started following it, and + # not all its past activities as well. + self.dashboard_mark_all_new_activities_as_old(self.new_user) + + # Create a new dataset. + params = json.dumps({ + 'name': 'my_new_package', + }) + response = self.app.post('/api/action/package_create', params=params, + extra_environ={'Authorization': str(self.new_user['apikey'])}) + assert response.json['success'] is True + + # Follow a dataset. + params = json.dumps({'id': 'warandpeace'}) + response = self.app.post('/api/action/follow_dataset', params=params, + extra_environ={'Authorization': str(self.new_user['apikey'])}) + assert response.json['success'] is True + + # Follow a user. + params = json.dumps({'id': 'annafan'}) + response = self.app.post('/api/action/follow_user', params=params, + extra_environ={'Authorization': str(self.new_user['apikey'])}) + assert response.json['success'] is True + + # Follow a group. + params = json.dumps({'id': 'roger'}) + response = self.app.post('/api/action/follow_group', params=params, + extra_environ={'Authorization': str(self.new_user['apikey'])}) + assert response.json['success'] is True + + # Update the dataset that we're following. + params = json.dumps({'name': 'warandpeace', 'notes': 'updated'}) + response = self.app.post('/api/action/package_update', params=params, + extra_environ={'Authorization': str(self.new_user['apikey'])}) + assert response.json['success'] is True + + # User's own actions should not increase her activity count. + assert self.dashboard_new_activities_count(self.new_user) == 0 + + def test_03_own_activities_not_marked_as_new(self): + '''Make a user do some activities and check that her own activities + aren't marked as new in her dashboard activity stream.''' + assert len(self.dashboard_new_activities(self.new_user)) == 0 + + def test_04_new_activities_count(self): + '''Test that new activities from objects that a user follows increase + her new activities count.''' + + # Make someone else who new_user is not following update a dataset that + # new_user is following. + params = json.dumps({'name': 'warandpeace', 'notes': 'updated again'}) + response = self.app.post('/api/action/package_update', params=params, + extra_environ={'Authorization': str(self.joeadmin['apikey'])}) + assert response.json['success'] is True + + # Make someone that the user is following create a new dataset. + params = json.dumps({'name': 'annas_new_dataset'}) + response = self.app.post('/api/action/package_create', params=params, + extra_environ={'Authorization': str(self.annafan['apikey'])}) + assert response.json['success'] is True + + # Make someone that the user is not following update a dataset that + # the user is not following, but that belongs to a group that the user + # is following. + params = json.dumps({'name': 'annakarenina', 'notes': 'updated'}) + response = self.app.post('/api/action/package_update', params=params, + extra_environ={'Authorization': str(self.testsysadmin['apikey'])}) + assert response.json['success'] is True + + # FIXME: The number here should be 3 but activities from followed + # groups are not appearing in dashboard. When that is fixed, fix this + # number. + assert self.dashboard_new_activities_count(self.new_user) == 2 + + def test_05_activities_marked_as_new(self): + '''Test that new activities from objects that a user follows are + marked as new in her dashboard activity stream.''' + # FIXME: The number here should be 3 but activities from followed + # groups are not appearing in dashboard. When that is fixed, fix this + # number. + assert len(self.dashboard_new_activities(self.new_user)) == 2 + + def test_06_mark_new_activities_as_read(self): + '''Test that a user's new activities are marked as old when she views + her dashboard activity stream.''' + assert self.dashboard_new_activities_count(self.new_user) > 0 + assert len(self.dashboard_new_activities(self.new_user)) > 0 + self.dashboard_mark_all_new_activities_as_old(self.new_user) + assert self.dashboard_new_activities_count(self.new_user) == 0 + assert len(self.dashboard_new_activities(self.new_user)) == 0 + + def test_07_maximum_number_of_new_activities(self): + '''Test that the new activities count does not go higher than 15, even + if there are more than 15 new activities from the user's followers.''' + for n in range(0,20): + notes = "Updated {n} times".format(n=n) + params = json.dumps({'name': 'warandpeace', 'notes': notes}) + response = self.app.post('/api/action/package_update', params=params, + extra_environ={'Authorization': str(self.joeadmin['apikey'])}) + assert response.json['success'] is True + assert self.dashboard_new_activities_count(self.new_user) == 15 diff --git a/ckan_deb/usr/lib/ckan/common.sh b/ckan_deb/usr/lib/ckan/common.sh index 0c4a33306eb..acd9abf863b 100644 --- a/ckan_deb/usr/lib/ckan/common.sh +++ b/ckan_deb/usr/lib/ckan/common.sh @@ -155,7 +155,7 @@ ckan_ensure_db_exists () { COMMAND_OUTPUT=`sudo -u postgres psql -c "select datname from pg_database where datname='$INSTANCE'"` if ! [[ "$COMMAND_OUTPUT" =~ ${INSTANCE} ]] ; then echo "Creating the database ..." - sudo -u postgres createdb -O ${INSTANCE} ${INSTANCE} + sudo -u postgres createdb -O ${INSTANCE} ${INSTANCE} -E utf-8 paster --plugin=ckan db init --config=/etc/ckan/${INSTANCE}/${INSTANCE}.ini fi fi diff --git a/doc/architecture.rst b/doc/architecture.rst new file mode 100644 index 00000000000..13098bddb5b --- /dev/null +++ b/doc/architecture.rst @@ -0,0 +1,206 @@ +====================== +CKAN Code Architecture +====================== + +This section tries to give some guidelines for writing code that is consistent +with the intended, overall design and architecture of CKAN. + + +Encapsulate SQLAlchemy in ``ckan.model`` +```````````````````````````````````````` + +Ideally SQLAlchemy should only be used within ``ckan.model`` and not from other +packages such as ``ckan.logic``. For example instead of using an SQLAlchemy +query from the logic package to retrieve a particular user from the database, +we add a ``get()`` method to ``ckan.model.user.User``:: + + @classmethod + def get(cls, user_id): + query = ... + . + . + . + return query.first() + +Now we can call this method from the logic package. + +Database Migrations +``````````````````` + +When changes are made to the model classes in ``ckan.model`` that alter CKAN's +database schema, a migration script has to be added to migrate old CKAN +databases to the new database schema when they upgrade their copies of CKAN. +See :doc:`migration`. + +Always go through the Action Functions +`````````````````````````````````````` + +Whenever some code, for example in ``ckan.lib`` or ``ckan.controllers``, wants +to get, create, update or delete an object from CKAN's model it should do so by +calling a function from the ``ckan.logic.action`` package, and *not* by +accessing ``ckan.model`` directly. + + +Action Functions are Exposed in the API +``````````````````````````````````````` + +The functions in ``ckan.logic.action`` are exposed to the world as the +:doc:`apiv3`. The API URL for an action function is automatically generated +from the function name, for example +``ckan.logic.action.create.package_create()`` is exposed at +``/api/action/package_create``. See `Steve Yegge's Google platforms rant +`_ for some +interesting discussion about APIs. + +**All** publicly visible functions in the +``ckan.logic.action.{create,delete,get,update}`` namespaces will be exposed +through the :doc:`apiv3`. **This includes functions imported** by those +modules, **as well as any helper functions** defined within those modules. To +prevent inadvertent exposure of non-action functions through the action api, +care should be taken to: + +1. Import modules correctly (see `Imports`_). For example: :: + + import ckan.lib.search as search + + search.query_for(...) + +2. Hide any locally defined helper functions: :: + + def _a_useful_helper_function(x, y, z): + '''This function is not exposed because it is marked as private``` + return x+y+z + +3. Bring imported convenience functions into the module namespace as private + members: :: + + _get_or_bust = logic.get_or_bust + + +Use ``get_action()`` +```````````````` + +Don't call ``logic.action`` functions directly, instead use ``get_action()``. +This allows plugins to override action functions using the ``IActions`` plugin +interface. For example:: + + ckan.logic.get_action('group_activity_list_html')(...) + +Instead of :: + + ckan.logic.action.get.group_activity_list_html(...) + + +Auth Functions and ``check_access()`` +`````````````` + +Each action function defined in ``ckan.logic.action`` should use its own +corresponding auth function defined in ``ckan.logic.auth``. Instead of calling +its auth function directly, an action function should go through +``ckan.logic.check_access`` (which is aliased ``_check_access`` in the action +modules) because this allows plugins to override auth functions using the +``IAuthFunctions`` plugin interface. For example:: + + def package_show(context, data_dict): + _check_access('package_show', context, data_dict) + +``check_access`` will raise an exception if the user is not authorized, which +the action function should not catch. When this happens the user will be shown +an authorization error in their browser (or will receive one in their response +from the API). + + +``logic.get_or_bust()`` +````````````` + +The ``data_dict`` parameter of logic action functions may be user provided, so +required files may be invalid or absent. Naive Code like:: + + id = data_dict['id'] + +may raise a ``KeyError`` and cause CKAN to crash with a 500 Server Error +and no message to explain what went wrong. Instead do:: + + id = _get_or_bust(data_dict, "id") + +which will raise ``ValidationError`` if ``"id"`` is not in ``data_dict``. The +``ValidationError`` will be caught and the user will get a 400 Bad Request +response and an error message explaining the problem. + + +Validation and ``ckan.logic.schema`` +```````````````````````````````````` + +Logic action functions can use schema defined in ``ckan.logic.schema`` to +validate the contents of the ``data_dict`` parameters that users pass to them. + +An action function should first check for a custom schema provided in the +context, and failing that should retrieve its default schema directly, and +then call ``_validate()`` to validate and convert the data. For example, here +is the validation code from the ``user_create()`` action function:: + + schema = context.get('schema') or ckan.logic.schema.default_user_schema() + session = context['session'] + validated_data_dict, errors = _validate(data_dict, schema, context) + if errors: + session.rollback() + raise ValidationError(errors) + + +Controller & Template Helper Functions +-------------------------------------- + +``ckan.lib.helpers`` contains helper functions that can be used from +``ckan.controllers`` or from templates. When developing for ckan core, only use +the helper functions found in ``ckan.lib.helpers.__allowed_functions__``. + + +.. _Testing: + +Testing +------- + +- Functional tests which test the behaviour of the web user interface, and the + APIs should be placed within ``ckan/tests/functional``. These tests can be a + lot slower to run that unit tests which don't access the database or solr. So + try to bear that in mind, and attempt to cover just what is neccessary, leaving + what can be tested via unit-testing in unit-tests. + +- ``nose.tools.assert_in`` and ``nose.tools.assert_not_in`` are only available + in Python>=2.7. So import them from ``ckan.tests``, which will provide + alternatives if they're not available. + +- the `mock`_ library can be used to create and interrogate mock objects. + +See :doc:`test` for further information on testing in CKAN. + +.. _mock: http://pypi.python.org/pypi/mock + +Writing Extensions +------------------ + +Please see :doc:`writing-extensions` for information about writing ckan +extensions, including details on the API available to extensions. + +Deprecation +----------- + +- Anything that may be used by extensions (see :doc:`writing-extensions`) needs + to maintain backward compatibility at call-site. ie - template helper + functions and functions defined in the plugins toolkit. + +- The length of time of deprecation is evaluated on a function-by-function + basis. At minimum, a function should be marked as deprecated during a point + release. + +- To mark a helper function, use the ``deprecated`` decorator found in + ``ckan.lib.maintain`` eg: :: + + + @deprecated() + def facet_items(*args, **kwargs): + """ + DEPRECATED: Use the new facet data structure, and `unselected_facet_items()` + """ + # rest of function definition. + diff --git a/doc/buildbot.rst b/doc/buildbot.rst deleted file mode 100644 index 484518b555e..00000000000 --- a/doc/buildbot.rst +++ /dev/null @@ -1,169 +0,0 @@ -================ -Install Buildbot -================ - -This section provides information for CKAN core developers setting up buildbot on an Ubuntu Lucid machine. - -If you simply want to check the status of the latest CKAN builds, visit http://buildbot.okfn.org/. - -Apt Installs -============ - -Install CKAN core dependencies from Lucid distribution:: - - sudo apt-get install build-essential libxml2-dev libxslt-dev - sudo apt-get install wget mercurial postgresql libpq-dev git-core - sudo apt-get install python-dev python-psycopg2 python-virtualenv - sudo apt-get install subversion - -Maybe need this too:: - - sudo apt-get install python-include - -Buildbot software:: - - sudo apt-get install buildbot - -Deb building software:: - - sudo apt-get install -y dh-make devscripts fakeroot cdbs - -Fabric:: - - sudo apt-get install -y fabric - -If you get errors with postgres and locales you might need to do these:: - - sudo apt-get install language-pack-en-base - sudo dpkg-reconfigure locales - - -Postgres Setup -============== - -If installation before failed to create a cluster, do this after fixing errors:: - - sudo pg_createcluster 8.4 main --start - -Create users and databases:: - - sudo -u postgres createuser -S -D -R -P buildslave - # set this password (matches buildbot scripts): biomaik15 - sudo -u postgres createdb -O buildslave ckan1 - sudo -u postgres createdb -O buildslave ckanext - - -Buildslave Setup -================ - -Rough commands:: - - sudo useradd -m -s /bin/bash buildslave - sudo chown buildslave:buildslave /home/buildslave - sudo su buildslave - cd ~ - git clone https://github.com/okfn/buildbot-scripts.git - ssh-keygen -t rsa - cp /home/buildslave/.ssh/id_rsa.pub ~/.ssh/authorized_keys - mkdir -p ckan/build - cd ckan/build - python ~/ckan-default.py - buildbot create-slave ~ localhost:9989 okfn - vim ~/info/admin - vim ~/info/host - mkdir /home/buildslave/pip_cache - virtualenv pyenv-tools - pip -E pyenv-tools install buildkit - - -Buildmaster Setup -================= - -Rough commands:: - - mkdir ~/buildmaster - buildbot create-master ~/buildmaster - ln -s /home/buildslave/master/master.cfg ~/buildmaster/master.cfg - cd ~/buildmaster - buildbot checkconfig - - -Startup -======= - -Setup the daemons for master and slave:: - - sudo vim /etc/default/buildbot - -This file should be edited to be like this:: - - BB_NUMBER[0]=0 # index for the other values; negative disables the bot - BB_NAME[0]="okfn" # short name printed on startup / stop - BB_USER[0]="okfn" # user to run as - BB_BASEDIR[0]="/home/okfn/buildmaster" # basedir argument to buildbot (absolute path) - BB_OPTIONS[0]="" # buildbot options - BB_PREFIXCMD[0]="" # prefix command, i.e. nice, linux32, dchroot - - BB_NUMBER[1]=1 # index for the other values; negative disables the bot - BB_NAME[1]="okfn" # short name printed on startup / stop - BB_USER[1]="buildslave" # user to run as - BB_BASEDIR[1]="/home/buildslave" # basedir argument to buildbot (absolute path) - BB_OPTIONS[1]="" # buildbot options - BB_PREFIXCMD[1]="" # prefix command, i.e. nice, linux32, dchroot - -Start master and slave (according to /etc/default/buildbot):: - - sudo /etc/init.d/buildbot start - -Now check you can view buildbot at http://localhost:8010/ - - -Connect Ports -============= - -It's preferable to view the buildbot site at port 80 rather than 8010. - -If there is no other web service on this machine, you might connect up the addresses using ``iptables``:: - - sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8010 - -Otherwise it is best to set up a reverse proxy. Using Apache, edit this file:: - - sudo vim /etc/apache2/sites-available/buildbot.okfn.org - -to look like this:: - - - ServerName buildbot.okfn.org - - ProxyPassReverse ts Off - - Order deny,allow - Allow from all - - ProxyPass / http://127.0.0.1:8010/ - ProxyPassReverse / http://127.0.0.1:8010/ - ProxyPreserveHost On - - -or the old one had:: - - - ServerAdmin sysadmin@okfn.org - ServerName buildbot.okfn.org - DocumentRoot /var/www/ - - Order allow,deny - allow from all - - RewriteEngine On - RewriteRule /(.*) http://localhost:8010/$1 [P,L] - - -Then:: - - sudo apt-get install libapache2-mod-proxy-html - sudo a2enmod proxy_http - sudo a2ensite buildbot.okfn.org - sudo /etc/init.d/apache2 reload - diff --git a/doc/coding-standards.rst b/doc/coding-standards.rst deleted file mode 100644 index 731c12cb760..00000000000 --- a/doc/coding-standards.rst +++ /dev/null @@ -1,1346 +0,0 @@ -===================== -CKAN Coding Standards -===================== - -Commit Guidelines -================= - -Generally, follow the `commit guidelines from the Pro Git book`_: - -- Try to make each commit a logically separate, digestible changeset. - -- The first line of the commit message should concisely summarise the - changeset. - -- Optionally, follow with a blank line and then a more detailed explanation of - the changeset. - -- Use the imperative present tense as if you were giving commands to the - codebase to change its behaviour, e.g. *Add tests for...*, *make xyzzy do - frotz...*, this helps to make the commit message easy to read. - -- Try to write the commit message so that a new CKAN developer could understand - it, i.e. using plain English as far as possible, and not referring to too - much assumed knowledge or to external resources such as mailing list - discussions (summarize the relevant points in the commit message instead). - -.. _commit guidelines from the Pro Git book: http://git-scm.com/book/en/Distributed-Git-Contributing-to-a-Project#Commit-Guidelines - -In CKAN we also refer to `trac.ckan.org`_ ticket numbers in commit messages -wherever relevant. This makes the release manager's job much easier! Of -course, you don't have to reference a ticket from your commit message if there -isn't a ticket for it, e.g. if you find a typo in a docstring and quickly fix -it you wouldn't bother to create a ticket for this. - -Put the ticket number in square brackets (e.g. ``[#123]``) at the start of the -first line of the commit message. You can also reference other Trac tickets -elsewhere in your commit message by just using the ticket number on its own -(e.g. ``see #456``). For example: - -:: - - [#2505] Update source install instructions - - Following feedback from markw (see #2406). - -.. _trac.ckan.org: http://trac.ckan.org/ - -Longer example CKAN commit message: - -:: - - [#2304] Refactor user controller a little - - Move initialisation of a few more template variables into - _setup_template_variables(), and change read(), edit(), and followers() to use - it. This removes some code duplication and fixes issues with the followers - count and follow button not being initialisd on all user controller pages. - - Change new() to _not_ use _setup_template_variables() as it only needs - c.is_sysadmin and not the rest. - - Also fix templates/user/layout.html so that the Followers tab appears on both - your own user page (when logged in) and on other user's pages. - -Feature Branches ----------------- - -All ticketed work should be developed on a corresponding feature branch forked -from master. The name of the branch should inlude the ticket's number, the -ticket type, and a brief one-line synopsis of the purpose of the ticket. eg: -``2298-feature-add-sort-by-controls-to-search-page``. This allows the ticket -number to be esaily searchable through github's web interface. - -Once work on the branch has been completed and it is ready to be merged into -master, make a pull request on github. Another member of the CKAN team will -review the changes; and provide feedback through the github pull request page. -If the piece of work touches on an area of code `owned` by another team member, -then notify them of the changes by email. - -Submitting Code Patches ------------------------ - -See the wiki for instructions on `how to submit a patch`_ via GitHub or email. - -.. _how to submit a patch: http://wiki.ckan.org/Submitting_a_code_patch - -Releases --------- - -See :doc:`release-cycle` for details on the release process. - -Merging -------- - -When merging a feature or bug branch into master: - -- Use the ``--no-ff`` option in the ``git merge`` command -- Add an entry to the ``CHANGELOG`` file - -The full postgresql test suite must pass before merging into master. :: - - nosetests --ckan --with-pylons=test-core.ini ckan - -See :doc:`test` for more information on running tests, including running the -core extension tests. - -Python Coding Standards -======================= - -For python code, we follow `PEP 8`_, plus a few of our own rules. The -important bits are laid out below, but if in doubt, refer to `PEP 8`_ and -common sense. - -Layout and formatting ---------------------- - -- Don't use tabs. Use 4 spaces. - -- Maximum line length is 79 characters. - -- Continuation lines should align vertically within the parentheses, or with - a hanging indent. See `PEP 8's Indent Section`_ for more details. - -- Avoid extraneous whitespace. See `PEP 8's Whitespace Section`_ for more details. - -- Clean up formatting issues in master, not on a feature branch. Unless of - course you're changing that piece of code anyway. This will help avoid - spurious merge conflicts, and aid in reading pull requests. - -- Use the single-quote character, ``'``, rather than the double-quote - character, ``"``, for string literals. - -.. _PEP 8: http://www.python.org/dev/peps/pep-0008/ -.. _PEP 8's Indent Section: http://www.python.org/dev/peps/pep-0008/#indentation -.. _PEP 8's Whitespace Section: http://www.python.org/dev/peps/pep-0008/#whitespace-in-expressions-and-statements - -Imports -------- - -- Import whole modules, rather than using ``from foo import bar``. It's ok - to alias imported modules to make things more concise, ie this *is* - acceptable: :: - - import foo.bar.baz as f - -- Make all imports at the start of the file, after the module docstring. - Imports should be grouped in the following order: - - 1. Standard library imports - 2. Third-party imports - 3. CKAN imports - -Logging -------- - -- Keep messages short. - -- Don't include object representations in the log message. It **is** useful - to include an domain model identifier where appropriate. - -- Choose an appropriate log-level: - - +----------+--------------------------------------------------------------+ - | Level | Description | - +==========+==============================================================+ - | DEBUG | Detailed information, of no interest when everything is | - | | working well but invaluable when diagnosing problems. | - +----------+--------------------------------------------------------------+ - | INFO | Affirmations that things are working as expected, e.g. | - | | "service has started" or "indexing run complete". Often | - | | ignored. | - +----------+--------------------------------------------------------------+ - | WARNING | There may be a problem in the near future, and this gives | - | | advance warning of it. But the application is able to proceed| - | | normally. | - +----------+--------------------------------------------------------------+ - | ERROR | The application has been unable to proceed as expected, due | - | | to the problem being logged. | - +----------+--------------------------------------------------------------+ - | CRITICAL | This is a serious error, and some kind of application | - | | meltdown might be imminent. | - +----------+--------------------------------------------------------------+ - - (`Source - `_) - -i18n ----- - -To construct an internationalised string, use `str.format`_, giving -meaningful names to each replacement field. For example: :: - - _(' ... {foo} ... {bar} ...').format(foo='foo-value', bar='bar-value') - -.. _str.format: http://docs.python.org/library/stdtypes.html#str.format - -Docstring Standards -------------------- - -We want CKAN's docstrings to be clear and easy to read for programmers who are -smart and competent but who may not know a lot of CKAN technical jargon and -whose first language may not be English. We also want it to be easy to maintain -the docstrings and keep them up to date with the actual behaviour of the code -as it changes over time. So: - -- Keep docstrings short, describe only what's necessary and no more -- Keep docstrings simple, use plain English, try not to use a long word - where a short one will do, and try to cut out words where possible -- Try to avoid repetition - -PEP 257 -``````` - -Generally, follow `PEP 257`_. We'll only describe the ways that CKAN differs -from or extends PEP 257 below. - -.. _PEP 257: http://www.python.org/dev/peps/pep-0257/ - -CKAN docstrings deviate from PEP 257 in a couple of ways: - -- We use ``'''triple single quotes'''`` around docstrings, not ``"""triple - double quotes"""`` (put triple single quotes around one-line docstrings as - well as multi-line ones, it makes them easier to expand later) -- We use Sphinx directives for documenting parameters, exceptions and return - values (see below) - -Sphinx -`````` -Use `Sphinx directives`_ for documenting the parameters, exceptions and returns -of functions: - -- Use ``:param`` and ``:type`` to describe each parameter -- Use ``:returns`` and ``:rtype`` to describe each return -- Use ``:raises`` to describe each exception raised - -Example of a short docstring: - -:: - - @property - def packages(self): - '''Return a list of all packages that have this tag, sorted by name. - - :rtype: list of ckan.model.package.Package objects - - ''' - -Example of a longer docstring: - -:: - - @classmethod - def search_by_name(cls, search_term, vocab_id_or_name=None): - '''Return all tags whose names contain a given string. - - By default only free tags (tags which do not belong to any vocabulary) - are returned. If the optional argument ``vocab_id_or_name`` is given - then only tags from that vocabulary are returned. - - :param search_term: the string to search for in the tag names - :type search_term: string - :param vocab_id_or_name: the id or name of the vocabulary to look in - (optional, default: None) - :type vocab_id_or_name: string - - :returns: a list of tags that match the search term - :rtype: list of ckan.model.tag.Tag objects - - ''' - - -The phrases that follow ``:param foo:``, ``:type foo:``, or ``:returns:`` -should not start with capital letters or end with full stops. These should be -short phrases and not full sentences. If more detail is required put it in the -function description instead. - -Indicate optional arguments by ending their descriptions with (optional) in -brackets. Where relevant also indicate the default value: (optional, default: -5). It's also helpful to list all required parameters before optional ones. - -.. _Sphinx directives: http://sphinx.pocoo.org/markup/desc.html#info-field-lists - -You can also use a little inline `reStructuredText markup`_ in docstrings, e.g. -``*stars for emphasis*`` or ````double-backticks for literal text```` - -.. _reStructuredText markup: http://docutils.sourceforge.net/docs/user/rst/quickref.html#inline-markup - -CKAN Action API Docstrings -`````````````````````````` - -Docstrings from CKAN's action API are processed with `autodoc`_ and -included in the API chapter of CKAN's documentation. The intended audience of -these docstrings is users of the CKAN API and not (just) CKAN core developers. - -In the Python source each API function has the same two arguments (``context`` -and ``data_dict``), but the docstrings should document the keys that the -functions read from ``data_dict`` and not ``context`` and ``data_dict`` -themselves, as this is what the user has to POST in the JSON dict when calling -the API. - -Where practical, it's helpful to give examples of param and return values in -API docstrings. - -CKAN datasets used to be called packages and the old name still appears in the -source, e.g. in function names like package_list(). When documenting functions -like this write dataset not package, but the first time you do this put package -after it in brackets to avoid any confusion, e.g. - -:: - - def package_show(context, data_dict): - '''Return the metadata of a dataset (package) and its resources. - -Example of a ckan.logic.action API docstring: - -:: - - def vocabulary_create(context, data_dict): - '''Create a new tag vocabulary. - - You must be a sysadmin to create vocabularies. - - :param name: the name of the new vocabulary, e.g. ``'Genre'`` - :type name: string - :param tags: the new tags to add to the new vocabulary, for the format of - tag dictionaries see ``tag_create()`` - :type tags: list of tag dictionaries - - :returns: the newly-created vocabulary - :rtype: dictionary - - ''' - -.. _Autodoc: http://sphinx.pocoo.org/ext/autodoc.html - -Tools ------ - -Running the `PEP 8 style guide checker`_ is good for checking adherence to `PEP -8`_ formatting. As mentioned above, only perform style clean-ups on master to -help avoid spurious merge conflicts. - -`PyLint`_ is a useful tool for analysing python source code for errors and signs of poor quality. - -`pyflakes`_ is another useful tool for passive analysis of python source code. -There's also a `pyflakes vim plugin`_ which will highlight unused variables, -undeclared variables, syntax errors and unused imports. - -.. _PEP 8 style guide checker: http://pypi.python.org/pypi/pep8 -.. _PyLint: http://www.logilab.org/857 -.. _pyflakes: http://pypi.python.org/pypi/pyflakes -.. _pyflakes vim plugin: http://www.vim.org/scripts/script.php?script_id=2441 - -CKAN Code Areas -=============== - -This section describes some guidelines for making changes in particular areas -of the codebase, as well as general concepts particular to CKAN. - -General -------- - -Some rules to adhere to when making changes to the codebase in general. - -.. todo:: Is there anything to include in this 'General' section? - -Domain Models -------------- - -This section describes things to bear in mind when making changes to the domain -models. For more information about CKAN's domain models, see -:doc:`domain-model`. - -The structure of the CKAN data is described in the 'model'. This is in the code -at `ckan/model`. - -Many of the domain objects are Revisioned and some are Stateful. These are -concepts introduced by `vdm`_. - -.. _vdm: http://okfn.org/projects/vdm/ -.. _sqlalchemy migrate: http://code.google.com/p/sqlalchemy-migrate SQLAlchemy Migrate - -Migration -````````` -When edits are made to the model code, then before the code can be used on a -CKAN instance with existing data, the existing data has to be migrated. This is -achieved with a migration script. - -CKAN currently uses to manage these scripts. When you deploy new code to a -CKAN instance, as part of the process you run any required migration scripts -with: :: - - paster --plugin=ckan db upgrade --config={.ini file} - -The scripts give their model version numbers in their filenames and are stored -in ``ckan/migration/versions/``. - -The current version the database is migrated to is also stored in the database. -When you run the upgrade, as each migration script is run it prints to the -console something like ``11->12``. If no upgrade is required because it is up -to date, then nothing is printed. - -Creating a new migration script -``````````````````````````````` -A migration script should be checked into CKAN at the same time as the model -changes it is related to. Before pushing the changes, ensure the tests pass -when running against the migrated model, which requires the -``--ckan-migration`` setting. - -To create a new migration script, create a python file in -``ckan/migration/versions/`` and name it with a prefix numbered one higher than -the previous one and some words describing the change. - -You need to use the special engine provided by the SqlAlchemy Migrate. Here is -the standard header for your migrate script: :: - - from sqlalchemy import * - from migrate import * - -The migration operations go in the upgrade function: :: - - def upgrade(migrate_engine): - metadata = MetaData() - metadata.bind = migrate_engine - -The following process should be followed when doing a migration. This process -is here to make the process easier and to validate if any mistakes have been -made: - -1. Get a dump of the database schema before you add your new migrate scripts. :: - - paster --plugin=ckan db clean --config={.ini file} - paster --plugin=ckan db upgrade --config={.ini file} - pg_dump -h host -s -f old.sql dbname - -2. Get a dump of the database as you have specified it in the model. :: - - paster --plugin=ckan db clean --config={.ini file} - - #this makes the database as defined in the model - paster --plugin=ckan db create-from-model -config={.ini file} - pg_dump -h host -s -f new.sql dbname - -3. Get agpdiff (apt-get it). It produces sql it thinks that you need to run on - the database in order to get it to the updated schema. :: - - apgdiff old.sql new.sql > upgrade.diff - -(or if you don't want to install java use http://apgdiff.startnet.biz/diff_online.php) - -4. The upgrade.diff file created will have all the changes needed in sql. - Delete the drop index lines as they are not created in the model. - -5. Put the resulting sql in your migrate script, e.g. :: - - migrate_engine.execute('''update table .........; update table ....''') - -6. Do a dump again, then a diff again to see if the the only thing left are drop index statements. - -7. run nosetests with ``--ckan-migration`` flag. - -It's that simple. Well almost. - -* If you are doing any table/field renaming adding that to your new migrate - script first and use this as a base for your diff (i.e add a migrate script - with these renaming before 1). This way the resulting sql won't try to drop and - recreate the field/table! - -* It sometimes drops the foreign key constraints in the wrong order causing an - error so you may need to rearrange the order in the resulting upgrade.diff. - -* If you need to do any data transfer in the migrations then do it between the - dropping of the constraints and adding of new ones. - -* May need to add some tests if you are doing data migrations. - -An example of a script doing it this way is ``034_resource_group_table.py``. -This script copies the definitions of the original tables in order to do the -renaming the tables/fields. - -In order to do some basic data migration testing extra assertions should be -added to the migration script. Examples of this can also be found in -``034_resource_group_table.py`` for example. - -This statement is run at the top of the migration script to get the count of -rows: :: - - package_count = migrate_engine.execute('''select count(*) from package''').first()[0] - -And the following is run after to make sure that row count is the same: :: - - resource_group_after = migrate_engine.execute('''select count(*) from resource_group''').first()[0] - assert resource_group_after == package_count - -The Action Layer ----------------- - -When making changes to the action layer, found in the four modules -``ckan/logic/action/{create,delete,get,update}`` there are a few things to bear -in mind. - -Server Errors -````````````` - -When writing action layer code, bear in mind that the input provided in the -``data_dict`` may be user-provided. This means that required fields should be -checked for existence and validity prior to use. For example, code such as :: - - id = data_dict['id'] - -will raise a ``KeyError`` if the user hasn't provided an ``id`` field in their -data dict. This results in a 500 error, and no message to explain what went -wrong. The correct response by the action function would be to raise a -``ValidationError`` instead, as this will be caught and will provide the user -with a `bad request` response, alongside an error message explaining the issue. - -To this end, there's a helper function, ``logic.get_or_bust()`` which can be -used to safely retrieve a value from a dict: :: - - id = _get_or_bust(data_dict, "id") - -Function visibility -``````````````````` - -**All** publicly visible functions in the -``ckan.logic.action.{create,delete,get,update}`` namespaces will be exposed -through the :doc:`apiv3`. **This includes functions imported** by those -modules, **as well as any helper functions** defined within those modules. To -prevent inadvertent exposure of non-action functions through the action api, -care should be taken to: - -1. Import modules correctly (see `Imports`_). For example: :: - - import ckan.lib.search as search - - search.query_for(...) - -2. Hide any locally defined helper functions: :: - - def _a_useful_helper_function(x, y, z): - '''This function is not exposed because it is marked as private``` - return x+y+z - -3. Bring imported convenience functions into the module namespace as private - members: :: - - _get_or_bust = logic.get_or_bust - -Documentation -````````````` - -Please refer to `CKAN Action API Docstrings`_ for information about writing -docstrings for the action functions. It is **very** important that action -functions are documented as they are not only consumed by CKAN developers but -by CKAN users. - -Controllers ------------ - -Guidelines when writing controller actions: - -- Use ``get_action``, rather than calling the action directly; and rather than - calling the action directly, as this allows extensions to overide the action's - behaviour. ie use :: - - ckan.logic.get_action('group_activity_list_html')(...) - - Instead of :: - - ckan.logic.action.get.group_activity_list_html(...) - -- Controllers have access to helper functions in ``ckan.lib.helpers``. - When developing for ckan core, only use the helper functions found in - ``ckan.lib.helpers.__allowed_functions__``. - -.. todo:: Anything else for controllers? - -Templating ----------- - -Helper Functions -```````````````` - -Templates have access to a set of helper functions in ``ckan.lib.helpers``. -When developing for ckan core, only use the helper functions found in -``ckan.lib.helpers.__allowed_functions__``. - -.. todo:: Jinja2 templates - -Testing -------- - -- Functional tests which test the behaviour of the web user interface, and the - APIs should be placed within ``ckan/tests/functional``. These tests can be a - lot slower to run that unit tests which don't access the database or solr. So - try to bear that in mind, and attempt to cover just what is neccessary, leaving - what can be tested via unit-testing in unit-tests. - -- ``nose.tools.assert_in`` and ``nose.tools.assert_not_in`` are only available - in Python>=2.7. So import them from ``ckan.tests``, which will provide - alternatives if they're not available. - -- the `mock`_ library can be used to create and interrogate mock objects. - -See :doc:`test` for further information on testing in CKAN. - -.. _mock: http://pypi.python.org/pypi/mock - -Writing Extensions ------------------- - -Please see :doc:`writing-extensions` for information about writing ckan -extensions, including details on the API available to extensions. - -Deprecation ------------ - -- Anything that may be used by extensions (see :doc:`writing-extensions`) needs - to maintain backward compatibility at call-site. ie - template helper - functions and functions defined in the plugins toolkit. - -- The length of time of deprecation is evaluated on a function-by-function - basis. At minimum, a function should be marked as deprecated during a point - release. - -- To mark a helper function, use the ``deprecated`` decorator found in - ``ckan.lib.maintain`` eg: :: - - - @deprecated() - def facet_items(*args, **kwargs): - """ - DEPRECATED: Use the new facet data structure, and `unselected_facet_items()` - """ - # rest of function definition. - -Javascript Coding Standards -=========================== - -Formatting ----------- - -.. _OKFN Coding Standards: http://wiki.okfn.org/Coding_Standards#Javascript -.. _idiomatic.js: https://github.com/rwldrn/idiomatic.js/ -.. _Douglas Crockford's: http://javascript.crockford.com/code.html - -All JavaScript documents must use **two spaces** for indentation and files -should have no trailing whitespace. This is contrary to the `OKFN Coding -Standards`_ but matches what's in use in the current code base. - -Coding style must follow the `idiomatic.js`_ style but with the following -exceptions. - -.. note:: Idiomatic is heavily based upon `Douglas Crockford's`_ style - guide which is recommended by the `OKFN Coding Standards`_. - -White Space -``````````` - -Two spaces must be used for indentation at all times. Unlike in idiomatic -whitespace must not be used _inside_ parentheses between the parentheses -and their Contents. :: - - // BAD: Too much whitespace. - function getUrl( full ) { - var url = '/styleguide/javascript/'; - if ( full ) { - url = 'http://okfn.github.com/ckan' + url; - } - return url; - } - - // GOOD: - function getUrl(full) { - var url = '/styleguide/javascript/'; - if (full) { - url = 'http://okfn.github.com/ckan' + url; - } - return url; - } - -.. note:: See section 2.D.1.1 of idiomatic for more examples of this syntax. - -Quotes -`````` - -Single quotes should be used everywhere unless writing JSON or the string -contains them. This makes it easier to create strings containing HTML. :: - - jQuery('
    ').appendTo('body'); - -Object properties need not be quoted unless required by the interpreter. :: - - var object = { - name: 'bill', - 'class': 'user-name' - }; - -Variable declarations -````````````````````` - -One ``var`` statement must be used per variable assignment. These must be -declared at the top of the function in which they are being used. :: - - // GOOD: - var good = "string"; - var alsoGood = "another; - - // GOOD: - var good = "string"; - var okay = [ - "hmm", "a bit", "better" - ]; - - // BAD: - var good = "string", - iffy = [ - "hmm", "not", "great" - ]; - -Declare variables at the top of the function in which they are first used. This -avoids issues with variable hoisting. If a variable is not assigned a value -until later in the function then it it okay to define more than one per -statement. :: - - // BAD: contrived example. - function lowercaseNames(names) { - var names = []; - - for (var index = 0, length = names.length; index < length; index += 1) { - var name = names[index]; - names.push(name.toLowerCase()); - } - - var sorted = names.sort(); - return sorted; - } - - // GOOD: - function lowercaseNames(names) { - var names = []; - var index, sorted, name; - - for (index = 0, length = names.length; index < length; index += 1) { - name = names[index]; - names.push(names[index].toLowerCase()); - } - - sorted = names.sort(); - return sorted; - } - -Naming ------- - -All properties, functions and methods must use lowercase camelCase: :: - - var myUsername = 'bill'; - var methods = { - getSomething: function () {} - }; - -Constructor functions must use uppercase CamelCase: :: - - function DatasetSearchView() { - } - -Constants must be uppercase with spaces delimited by underscores: :: - - var env = { - PRODUCTION: 'production', - DEVELOPMENT: 'development', - TESTING: 'testing' - }; - -Event handlers and callback functions should be prefixed with "on": :: - - function onDownloadClick(event) {} - - jQuery('.download').click(onDownloadClick); - -Boolean variables or methods returning boolean functions should prefix -the variable name with "is": :: - - function isAdmin() {} - - var canEdit = isUser() && isAdmin(); - - -.. note:: Alternatives are "has", "can" and "should" if they make more sense - -Private methods should be prefixed with an underscore: :: - - View.extend({ - "click": "_onClick", - _onClick: function (event) { - } - }); - -Functions should be declared as named functions rather than assigning an -anonymous function to a variable. :: - - // GOOD: - function getName() { - } - - // BAD: - var getName = function () { - }; - -Named functions are generally easier to debug as they appear named in the -debugger. - -Comments --------- - -Comments should be used to explain anything that may be unclear when you return -to it in six months time. Single line comments should be used for all inline -comments that do not form part of the documentation. :: - - // Export the function to either the exports or global object depending - // on the current environment. This can be either an AMD module, CommonJS - // module or a browser. - if (typeof module.define === 'function' && module.define.amd) { - module.define('broadcast', function () { - return Broadcast; - }); - } else if (module.exports) { - module.exports = Broadcast; - } else { - module.Broadcast = Broadcast; - } - -JSHint ------- - -All JavaScript should pass `JSHint`_ before being committed. This can -be installed using ``npm`` (which is bundled with `node`_) by running: :: - - $ npm -g install jshint - -Each project should include a jshint.json file with appropriate configuration -options for the tool. Most text editors can also be configured to read from -this file. - -.. _node: http://nodejs.org -.. _jshint: http://www.jshint.com - -Documentation -------------- - -For documentation we use a simple markup format to document all methods. The -documentation should provide enough information to show the reader what the -method does, arguments it accepts and a general example of usage. Also -for API's and third party libraries, providing links to external documentation -is encouraged. - -The formatting is as follows:: - - /* My method description. Should describe what the method does and where - * it should be used. - * - * param1 - The method params, one per line (default: null) - * param2 - A default can be provided in brackets at the end. - * - * Example - * - * // Indented two spaces. Should give a common example of use. - * client.getTemplate('index.html', {limit: 1}, function (html) { - * module.el.html(html); - * }); - * - * Returns describes what the object returns. - */ - -For example:: - - /* Loads an HTML template from the CKAN snippet API endpoint. Template - * variables can be passed through the API using the params object. - * - * Optional success and error callbacks can be provided or these can - * be attached using the returns jQuery promise object. - * - * filename - The filename of the template to load. - * params - An optional object containing key/value arguments to be - * passed into the template. - * success - An optional success callback to be called on load. This will - * recieve the HTML string as the first argument. - * error - An optional error callback to be called if the request fails. - * - * Example - * - * client.getTemplate('index.html', {limit: 1}, function (html) { - * module.el.html(html); - * }); - * - * Returns a jqXHR promise object that can be used to attach callbacks. - */ - -Testing -------- - -For unit testing we use the following libraries. - -- `Mocha`_: As a BDD unit testing framework. -- `Sinon`_: Provides spies, stubs and mocks for methods and functions. -- `Chai`_: Provides common assertions. - -.. _Mocha: http://visionmedia.github.com/mocha/ -.. _Sinon: http://chaijs.com/ -.. _Chai: http://sinonjs.org/docs/ - -Tests are run from the test/index.html directory. We use the BDD interface -(``describe()``, ``it()`` etc.) provided by mocha and the assert interface -provided by chai. - -Generally we try and have the core functionality of all libraries and modules -unit tested. - -Best Practices --------------- - -Forms -````` - -All forms should work without JavaScript enabled. This means that they must -submit ``application/x-www-form-urlencoded`` data to the server and receive an appropriate -response. The server should check for the ``X-Requested-With: XMLHTTPRequest`` -header to determine if the request is an ajax one. If so it can return an -appropriate format, otherwise it should issue a 303 redirect. - -The one exception to this rule is if a form or button is injected with -JavaScript after the page has loaded. It's then not part of the HTML document -and can submit any data format it pleases. - -Ajax -```````` - -Ajax requests can be used to improve the experience of submitting forms and -other actions that require server interactions. Nearly all requests will -go through the following states. - -1. User clicks button. -2. JavaScript intercepts the click and disables the button (add ``disabled`` - attr). -3. A loading indicator is displayed (add class ``.loading`` to button). -4. The request is made to the server. -5. a) On success the interface is updated. - b) On error a message is displayed to the user if there is no other way to - resolve the issue. -6. The loading indicator is removed. -7. The button is re-enabled. - -Here's a possible example for submitting a search form using jQuery. :: - - jQuery('#search-form').submit(function (event) { - var form = $(this); - var button = form.find('[type=submit]'); - - // Prevent the browser submitting the form. - event.preventDefault(); - - button.prop('disabled', true).addClass('loading'); - - jQuery.ajax({ - type: this.method, - data: form.serialize(), - success: function (results) { - updatePageWithResults(results); - }, - error: function () { - showSearchError('Sorry we were unable to complete this search'); - }, - complete: function () { - button.prop('disabled', false).removeClass('loading'); - } - }); - }); - -This covers possible issues that might arise from submitting the form as well -as providing the user with adequate feedback that the page is doing something. -Disabling the button prevents the form being submitted twice and the error -feedback should hopefully offer a solution for the error that occurred. - -Event Handlers -`````````````` - -When using event handlers to listen for browser events it's a common -requirement to want to cancel the default browser action. This should be -done by calling the ``event.preventDefault()`` method: :: - - jQuery('button').click(function (event) { - event.preventDefault(); - }); - -It is also possible to return ``false`` from the callback function. Avoid doing -this as it also calls the ``event.stopPropagation()`` method which prevents the -event from bubbling up the DOM tree. This prevents other handlers listening -for the same event. For example an analytics click handler attached to the -```` element. - -Also jQuery (1.7+) now provides the `.on()`_ and `.off()`_ methods as -alternatives to ``.bind()``, ``.unbind()``, ``.delegate()`` and -``.undelegate()`` and they should be preferred for all tasks. - -.. _.on(): http://api.jquery.com/on/ -.. _.off(): http://api.jquery.com/off/ - -Templating -`````````` - -Small templates that will not require customisation by the instance can be -placed inline. If you need to create multi-line templates use an array rather -than escaping newlines within a string:: - - var template = [ - '
  • ', - '', - '
  • ' - ].join(''); - -Always localise text strings within your templates. If you are including them -inline this can always be done with jQuery:: - - jQuery(template).find('span').text(_('This is my text string')); - -Larger templates can be loaded in using the CKAN snippet API. Modules get -access to this functionality via the ``sandbox.client`` object:: - - initialize: function () { - var el = this.el; - this.sandbox.client.getTemplate('dataset.html', function (html) { - el.html(html); - }); - } - -The primary benefits of this is that the localisation can be done by the server -and it keeps the JavaScript modules free from large strings. - -HTML Coding Standards -===================== - -Formatting ----------- - -All HTML documents must use **two spaces** for indentation and there should be -no trailing whitespace. XHTML syntax must be used (this is more a Genshi -requirement) and all attributes must use double quotes around attributes. :: - - - - -HTML5 elements should be used where appropriate reserving ``
    `` and ```` -elements for situations where there is no semantic value (such as wrapping -elements to provide styling hooks). - -Doctype and layout ------------------- - -All documents must be using the HTML5 doctype and the ```` element should -have a ``"lang"`` attribute. The ```` should also at a minimum include -``"viewport"`` and ``"charset"`` meta tags. :: - - - - - - - Example Site - - - - -Forms ------ - -Form fields must always include a ``