diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index dccc85ca3ef..698bf3ecfca 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -172,7 +172,7 @@ def action(self, logic_function): 'message': _('Access denied')} return_dict['success'] = False return self._finish(403, return_dict, content_type='json') - except NotFound: + except NotFound, e: return_dict['error'] = {'__type': 'Not Found Error', 'message': _('Not found')} if e.extra_msg: diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index b962a8d7c99..22397c4160e 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -205,7 +205,8 @@ def read(self, id): group_type = self._get_group_type(id.split('@')[0]) context = {'model': model, 'session': model.Session, 'user': c.user or c.author, - 'schema': self._form_to_db_schema(group_type=type)} + 'schema': self._form_to_db_schema(group_type=type), + 'for_view': True} data_dict = {'id': id} q = c.q = request.params.get('q', '') # unicode format (decoded from utf8) diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index 961661d1a7b..5884e8c98c1 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -24,7 +24,7 @@ from ckan.logic import tuplize_dict, clean_dict, parse_params, flatten_to_string_key from ckan.lib.dictization import table_dictize from ckan.lib.i18n import get_lang -from ckan.plugins import PluginImplementations, IDatasetForm +from ckan.plugins import PluginImplementations, IDatasetForm, IPackageController import ckan.forms import ckan.authz import ckan.rating @@ -66,6 +66,8 @@ def register_pluggable_behaviour(map): exception will be raised. """ global _default_controller_behaviour + _default_controller_behaviour = None + _controller_behaviour_for.clear() # Create the mappings and register the fallback behaviour if one is found. for plugin in PluginImplementations(IDatasetForm): @@ -247,7 +249,7 @@ def pager_url(q=None, page=None): search_extras[param] = value context = {'model': model, 'session': model.Session, - 'user': c.user or c.author} + 'user': c.user or c.author, 'for_view': True} data_dict = { 'q':q, @@ -281,7 +283,8 @@ def read(self, id): package_type = self._get_package_type(id.split('@')[0]) context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'extras_as_string': True, - 'schema': self._form_to_db_schema(package_type=package_type)} + 'schema': self._form_to_db_schema(package_type=package_type), + 'for_view': True} data_dict = {'id': id} # interpret @ or @ suffix diff --git a/ckan/forms/common.py b/ckan/forms/common.py index 7bd0db97e63..e106984f5b6 100644 --- a/ckan/forms/common.py +++ b/ckan/forms/common.py @@ -452,16 +452,12 @@ def get_configured(self): class TagField(formalchemy.Field): @property def raw_value(self): - tag_objects = self.model.tags + tag_objects = self.model.get_tags() tag_names = [tag.name for tag in tag_objects] return tag_names def sync(self): if not self.is_readonly(): - # Note: You might think that you could just assign - # self.model.tags with tag objects, but the model - # (add_stateful_versioned_m2m) doesn't support this - - # you must edit each PackageTag individually. self._update_tags() def _update_tags(self): diff --git a/ckan/forms/package_dict.py b/ckan/forms/package_dict.py index 347ebcbab69..e830cd928f8 100644 --- a/ckan/forms/package_dict.py +++ b/ckan/forms/package_dict.py @@ -47,7 +47,7 @@ def get_package_dict(pkg=None, blank=False, fs=None, user_editable_groups=None): if field.renderer.name.endswith('-extras'): indict[field.renderer.name] = dict(pkg.extras) if pkg else {} if field.renderer.name.endswith('-tags'): - indict[field.renderer.name] = ','.join([tag.name for tag in pkg.tags]) if pkg else '' + indict[field.renderer.name] = ','.join([tag.name for tag in pkg.get_tags()]) if pkg else '' if field.renderer.name.endswith('-resources'): indict[field.renderer.name] = [dict([(key, getattr(res, key)) for key in model.Resource.get_columns()]) for res in pkg.resources] if pkg else [] diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py index d2b06cd6c30..3027e50d825 100644 --- a/ckan/lib/create_test_data.py +++ b/ckan/lib/create_test_data.py @@ -161,7 +161,7 @@ def create_arbitrary(cls, package_dicts, relationships=[], tag = model.Tag(name=tag_name) cls.tag_names.append(tag_name) model.Session.add(tag) - pkg.tags.append(tag) + pkg.add_tag(tag) model.Session.flush() elif attr == 'groups': model.Session.flush() @@ -388,8 +388,8 @@ def create(cls, auth_profile=""): for obj in [pkg2, tag1, tag2, tag3]: model.Session.add(obj) - pkg1.tags = [tag1, tag2, tag3] - pkg2.tags = [ tag1, tag3 ] + pkg1.add_tags([tag1, tag2, tag3]) + pkg2.add_tags([ tag1, tag3 ]) cls.tag_names = [ t.name for t in (tag1, tag2, tag3) ] pkg1.license_id = u'other-open' pkg2.license_id = u'cc-nc' # closed license diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index f1f48edf7c6..4238a77d2ab 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -1,5 +1,6 @@ from pylons import config from sqlalchemy.sql import select, and_ +from ckan.plugins import PluginImplementations, IDatasetForm, IPackageController, IGroupController import datetime from ckan.model import PackageRevision @@ -194,12 +195,26 @@ def package_dictize(pkg, context): # type result_dict['type']= pkg.type + # licence + if pkg.license and pkg.license.url: + result_dict['license_url']= pkg.license.url + result_dict['license_title']= pkg.license.title.split('::')[-1] + elif pkg.license: + result_dict['license_title']= pkg.license.title + else: + result_dict['license_title']= pkg.license_id + # creation and modification date result_dict['metadata_modified'] = pkg.metadata_modified.isoformat() \ if pkg.metadata_modified else None result_dict['metadata_created'] = pkg.metadata_created.isoformat() \ if pkg.metadata_created else None + if context.get('for_view'): + for item in PluginImplementations(IPackageController): + result_dict = item.before_view(result_dict) + + return result_dict def _get_members(context, group, member_type): @@ -242,6 +257,10 @@ def group_dictize(group, context): context['with_capacity'] = False + if context.get('for_view'): + for item in PluginImplementations(IGroupController): + result_dict = item.before_view(result_dict) + return result_dict def tag_list_dictize(tag_list, context): @@ -262,7 +281,7 @@ def tag_dictize(tag, context): result_dict = table_dictize(tag, context) result_dict["packages"] = obj_list_dictize( - tag.packages_ordered, context) + tag.packages, context) return result_dict @@ -340,7 +359,7 @@ def package_to_api1(pkg, context): dictized.pop("revision_timestamp") dictized["groups"] = [group["name"] for group in dictized["groups"]] - dictized["tags"] = [tag["name"] for tag in dictized["tags"]] + dictized["tags"] = [tag["name"] for tag in dictized["tags"] if not tag.get('vocabulary_id')] dictized["extras"] = dict((extra["key"], json.loads(extra["value"])) for extra in dictized["extras"]) dictized['notes_rendered'] = ckan.misc.MarkdownFormat().to_html(pkg.notes) @@ -397,7 +416,7 @@ def package_to_api2(pkg, context): dictized["groups"] = [group["id"] for group in dictized["groups"]] dictized.pop("revision_timestamp") - dictized["tags"] = [tag["name"] for tag in dictized["tags"]] + dictized["tags"] = [tag["name"] for tag in dictized["tags"] if not tag.get('vocabulary_id')] dictized["extras"] = dict((extra["key"], json.loads(extra["value"])) for extra in dictized["extras"]) @@ -441,24 +460,24 @@ def package_to_api2(pkg, context): dictized['relationships'] = relationships return dictized +def vocabulary_dictize(vocabulary, context): + vocabulary_dict = table_dictize(vocabulary, context) + return vocabulary_dict + +def vocabulary_list_dictize(vocabulary_list, context): + return [vocabulary_dictize(vocabulary, context) + for vocabulary in vocabulary_list] + def activity_dictize(activity, context): activity_dict = table_dictize(activity, context) return activity_dict def activity_list_dictize(activity_list, context): - activity_dicts = [] - for activity in activity_list: - activity_dict = activity_dictize(activity, context) - activity_dicts.append(activity_dict) - return activity_dicts + return [activity_dictize(activity, context) for activity in activity_list] def activity_detail_dictize(activity_detail, context): return table_dictize(activity_detail, context) def activity_detail_list_dictize(activity_detail_list, context): - activity_detail_dicts = [] - for activity_detail in activity_detail_list: - activity_detail_dict = activity_detail_dictize(activity_detail, - context) - activity_detail_dicts.append(activity_detail_dict) - return activity_detail_dicts + return [activity_detail_dictize(activity_detail, context) + for activity_detail in activity_detail_list] diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py index 47a46b8b5a4..ff78a662eaf 100644 --- a/ckan/lib/dictization/model_save.py +++ b/ckan/lib/dictization/model_save.py @@ -138,7 +138,6 @@ def group_extras_save(extras_dicts, context): return result_dict def package_tag_list_save(tag_dicts, package, context): - allow_partial_update = context.get("allow_partial_update", False) if not tag_dicts and allow_partial_update: return @@ -156,13 +155,13 @@ def package_tag_list_save(tag_dicts, package, context): pt.state in ['deleted', 'pending-deleted'] ] ) - tag_names = set() + tag_name_vocab = set() tags = set() for tag_dict in tag_dicts: - if tag_dict.get('name') not in tag_names: + if (tag_dict.get('name'), tag_dict.get('vocabulary_id')) not in tag_name_vocab: tag_obj = table_dict_save(tag_dict, model.Tag, context) tags.add(tag_obj) - tag_names.add(tag_obj.name) + tag_name_vocab.add((tag_obj.name, tag_obj.vocabulary_id)) # 3 cases # case 1: currently active but not in new list @@ -173,14 +172,14 @@ def package_tag_list_save(tag_dicts, package, context): else: package_tag.state = 'deleted' - # in new list but never used before + # case 2: in new list but never used before for tag in tags - set(tag_package_tag.keys()): state = 'pending' if pending else 'active' package_tag_obj = model.PackageTag(package, tag, state) session.add(package_tag_obj) tag_package_tag[tag] = package_tag_obj - # in new list and already used but in deleted state + # case 3: in new list and already used but in deleted state for tag in tags.intersection(set(tag_package_tag_inactive.keys())): state = 'pending' if pending else 'active' package_tag = tag_package_tag[tag] @@ -463,7 +462,6 @@ def activity_dict_save(activity_dict, context): model = context['model'] session = context['session'] - user_id = activity_dict['user_id'] object_id = activity_dict['object_id'] revision_id = activity_dict['revision_id'] @@ -479,3 +477,31 @@ def activity_dict_save(activity_dict, context): # TODO: Handle activity details. return activity_obj + +def vocabulary_dict_save(vocabulary_dict, context): + model = context['model'] + session = context['session'] + vocabulary_name = vocabulary_dict['name'] + + vocabulary_obj = model.Vocabulary(vocabulary_name) + session.add(vocabulary_obj) + + return vocabulary_obj + +def vocabulary_dict_update(vocabulary_dict, context): + + model = context['model'] + session = context['session'] + + vocabulary_obj = model.vocabulary.Vocabulary.get(vocabulary_dict['id']) + vocabulary_obj.name = vocabulary_dict['name'] + + return vocabulary_obj + +def tag_dict_save(tag_dict, context): + model = context['model'] + tag = context.get('tag') + if tag: + tag_dict['id'] = tag.id + tag = table_dict_save(tag_dict, model.Tag, context) + return tag diff --git a/ckan/lib/search/query.py b/ckan/lib/search/query.py index 3e1904c3921..3ad1608f4ea 100644 --- a/ckan/lib/search/query.py +++ b/ckan/lib/search/query.py @@ -171,7 +171,7 @@ def run(self, query=[], fields={}, options=None, **kwargs): if options.all_fields: results['results'] = [r.as_dict() for r in results['results']] else: - results['results'] = [r.name for r in results['results']] + results['results'] = [r['name'] for r in results['results']] self.count = results['count'] self.results = results['results'] diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index 535bb337042..d1bbba69935 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -18,18 +18,23 @@ package_api_to_dict, package_dict_save, user_dict_save, + vocabulary_dict_save, + tag_dict_save, activity_dict_save) from ckan.lib.dictization.model_dictize import (group_dictize, package_dictize, user_dictize, + vocabulary_dictize, + tag_dictize, activity_dictize) - from ckan.logic.schema import (default_create_package_schema, default_resource_schema, default_create_relationship_schema, - default_create_activity_schema) + default_create_vocabulary_schema, + default_create_activity_schema, + default_create_tag_schema) from ckan.logic.schema import default_group_schema, default_user_schema from ckan.lib.navl.dictization_functions import validate @@ -340,6 +345,32 @@ def group_create_rest(context, data_dict): return group_dict +def vocabulary_create(context, data_dict): + + model = context['model'] + user = context['user'] + schema = context.get('schema') or default_create_vocabulary_schema() + + model.Session.remove() + model.Session()._context = context + + check_access('vocabulary_create', context, data_dict) + + data, errors = validate(data_dict, schema, context) + + if errors: + model.Session.rollback() + raise ValidationError(errors, package_error_summary(errors)) + + vocabulary = vocabulary_dict_save(data, context) + + if not context.get('defer_commit'): + model.repo.commit() + + log.debug('Created Vocabulary %s' % str(vocabulary.name)) + + return vocabulary_dictize(vocabulary, context) + def activity_create(context, activity_dict, ignore_auth=False): '''Create a new activity stream activity and return a dictionary representation of it. @@ -383,3 +414,22 @@ def package_relationship_create_rest(context, data_dict): relationship_dict = package_relationship_create(context, data_dict) return relationship_dict +def tag_create(context, tag_dict): + '''Create a new tag and return a dictionary representation of it.''' + + model = context['model'] + + check_access('tag_create', context, tag_dict) + + schema = context.get('schema') or default_create_tag_schema() + data, errors = validate(tag_dict, schema, context) + if errors: + raise ValidationError(errors) + + tag = tag_dict_save(tag_dict, context) + + if not context.get('defer_commit'): + model.repo.commit() + + log.debug("Created tag '%s' " % tag) + return tag_dictize(tag, context) diff --git a/ckan/logic/action/delete.py b/ckan/logic/action/delete.py index f8f8c14bb02..df89cc85e6f 100644 --- a/ckan/logic/action/delete.py +++ b/ckan/logic/action/delete.py @@ -1,4 +1,4 @@ -from ckan.logic import NotFound +from ckan.logic import NotFound, ParameterError, ValidationError from ckan.lib.base import _ from ckan.logic import check_access from ckan.logic.action import rename_keys @@ -103,6 +103,41 @@ def task_status_delete(context, data_dict): entity.delete() model.Session.commit() +def vocabulary_delete(context, data_dict): + model = context['model'] + + vocab_id = data_dict.get('id') + if not vocab_id: + raise ValidationError({'id': _('id not in data')}) + + vocab_obj = model.vocabulary.Vocabulary.get(vocab_id) + if vocab_obj is None: + raise NotFound(_('Could not find vocabulary "%s"') % vocab_id) + + check_access('vocabulary_delete', context, data_dict) + + vocab_obj.delete() + model.repo.commit() + +def tag_delete(context, data_dict): + model = context['model'] + + if not data_dict.has_key('id') or not data_dict['id']: + raise ValidationError({'id': _('id not in data')}) + tag_id_or_name = data_dict['id'] + + vocab_id_or_name = data_dict.get('vocabulary_id') + + tag_obj = model.tag.Tag.get(tag_id_or_name, vocab_id_or_name) + + if tag_obj is None: + raise NotFound(_('Could not find tag "%s"') % tag_id_or_name) + + check_access('tag_delete', context, data_dict) + + tag_obj.delete() + model.repo.commit() + def package_relationship_delete_rest(context, data_dict): # rename keys @@ -116,5 +151,3 @@ def package_relationship_delete_rest(context, data_dict): data_dict = rename_keys(data_dict, key_map, destructive=True) package_relationship_delete(context, data_dict) - - diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 8ff532b746b..edd56409190 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -7,7 +7,7 @@ import ckan from ckan.lib.base import _ -from ckan.logic import NotFound, ParameterError +from ckan.logic import NotFound, ParameterError, ValidationError from ckan.logic import check_access from ckan.model import misc from ckan.plugins import (PluginImplementations, @@ -24,6 +24,8 @@ tag_dictize, task_status_dictize, user_dictize, + vocabulary_dictize, + vocabulary_list_dictize, activity_list_dictize, activity_detail_list_dictize) @@ -33,6 +35,7 @@ group_to_api2, tag_to_api1, tag_to_api2) + from ckan.lib.search import query_for, SearchError from ckan.lib.base import render from webhelpers.html import literal @@ -195,38 +198,40 @@ def licence_list(context, data_dict): return licences def tag_list(context, data_dict): - '''Returns a list of tags''' + '''Return a list of tag dictionaries. + + If a query is provided in the data_dict with key 'query' or 'q', then only + tags whose names match the given query will be returned. Otherwise, all + tags will be returned. + + By default only free tags (tags that don't belong to a vocabulary) are + returned. If a 'vocabulary_id' is provided in the data_dict then tags + belonging to the given vocabulary (id or name) will be returned instead. + ''' model = context['model'] - user = context['user'] - all_fields = data_dict.get('all_fields',None) + vocab_id_or_name = data_dict.get('vocabulary_id') + query = data_dict.get('query') or data_dict.get('q') + if query: + query = query.strip() + all_fields = data_dict.get('all_fields', None) - check_access('tag_list',context, data_dict) + check_access('tag_list', context, data_dict) - q = data_dict.get('q','') - if q: - limit = data_dict.get('limit',25) - offset = data_dict.get('offset',0) - return_objects = data_dict.get('return_objects',True) - - query = query_for(model.Tag) - query.run(query=q, - limit=limit, - offset=offset, - return_objects=return_objects, - username=user) - tags = query.results + if query: + tags = _tag_search(context, data_dict) else: - tags = model.Session.query(model.Tag).all() + tags = model.Tag.all(vocab_id_or_name) tag_list = [] - if all_fields: - for tag in tags: - result_dict = tag_dictize(tag, context) - tag_list.append(result_dict) - else: - tag_list = [tag.name for tag in tags] + if tags: + if all_fields: + for tag in tags: + result_dict = tag_dictize(tag, context) + tag_list.append(result_dict) + else: + tag_list.extend([tag.name for tag in tags]) return tag_list @@ -442,7 +447,6 @@ def tag_show(context, data_dict): '''Shows tag details''' model = context['model'] - api = context.get('api_version') or '1' id = data_dict['id'] tag = model.Tag.get(id) @@ -595,29 +599,6 @@ def package_autocomplete(context, data_dict): return pkg_list -def tag_autocomplete(context, data_dict): - '''Returns tags containing the provided string''' - - model = context['model'] - session = context['session'] - user = context['user'] - - check_access('tag_autocomplete', context, data_dict) - - q = data_dict.get('q', None) - if not q: - return [] - - limit = data_dict.get('limit',10) - - query = query_for('tag') - query.run(query=q, - return_objects=True, - limit=10, - username=user) - - return [tag.name for tag in query.results] - def format_autocomplete(context, data_dict): '''Returns formats containing the provided string''' model = context['model'] @@ -731,28 +712,6 @@ def package_search(context, data_dict): return search_results -def _extend_package_dict(package_dict,context): - model = context['model'] - - resources = model.Session.query(model.Resource)\ - .join(model.ResourceGroup)\ - .filter(model.ResourceGroup.package_id == package_dict['id'])\ - .all() - if resources: - package_dict['resources'] = resource_list_dictize(resources, context) - else: - package_dict['resources'] = [] - license_id = package_dict.get('license_id') - if license_id: - try: - isopen = model.Package.get_license_register()[license_id].isopen() - except KeyError: - isopen = False - package_dict['isopen'] = isopen - else: - package_dict['isopen'] = False - - return package_dict def resource_search(context, data_dict): model = context['model'] @@ -805,11 +764,22 @@ def resource_search(context, data_dict): return {'count': count, 'results': results} -def tag_search(context, data_dict): +def _tag_search(context, data_dict): + '''Return a list of tag objects that contain the given string. + + The query string should be provided in the data_dict with key 'query' or + 'q'. + + By default only free tags (tags that don't belong to a vocabulary) are + searched. If a 'vocabulary_id' is provided in the data_dict then tags + belonging to the given vocabulary (id or name) will be searched instead. + + ''' model = context['model'] - session = context['session'] - query = data_dict.get('query') + query = data_dict.get('query') or data_dict.get('q') + if query: + query = query.strip() terms = [query] if query else [] fields = data_dict.get('fields', {}) @@ -818,23 +788,70 @@ def tag_search(context, data_dict): # TODO: should we check for user authentication first? q = model.Session.query(model.Tag) - q = q.distinct().join(model.Tag.package_tags) + + if data_dict.has_key('vocabulary_id'): + # Filter by vocabulary. + vocab = model.Vocabulary.get(data_dict['vocabulary_id']) + if not vocab: + raise NotFound + q = q.filter(model.Tag.vocabulary_id == vocab.id) + else: + # If no vocabulary_name in data dict then show free tags only. + q = q.filter(model.Tag.vocabulary_id == None) + # If we're searching free tags, limit results to tags that are + # currently applied to a package. + q = q.distinct().join(model.Tag.package_tags) + for field, value in fields.items(): if field in ('tag', 'tags'): terms.append(value) if not len(terms): - return + return [] for term in terms: escaped_term = misc.escape_sql_like_special_characters(term, escape='\\') q = q.filter(model.Tag.name.ilike('%' + escaped_term + '%')) - count = q.count() q = q.offset(offset) q = q.limit(limit) - results = [r for r in q] - return {'count': count, 'results': results} + return q.all() + +def tag_search(context, data_dict): + '''Return a list of tag dictionaries that contain the given string. + + The query string should be provided in the data_dict with key 'query' or + 'q'. + + By default only free tags (tags that don't belong to a vocabulary) are + searched. If a 'vocabulary_id' is provided in the data_dict then tags + belonging to the given vocabulary (id or name) will be searched instead. + + Returns a dictionary with keys 'count' (the number of tags in the result) + and 'results' (the list of tag dicts). + + ''' + tags = _tag_search(context, data_dict) + return {'count': len(tags), + 'results': [table_dictize(tag, context) for tag in tags]} + +def tag_autocomplete(context, data_dict): + '''Return a list of tag names that contain the given string. + + The query string should be provided in the data_dict with key 'query' or + 'q'. + + By default only free tags (tags that don't belong to a vocabulary) are + searched. If a 'vocabulary_id' is provided in the data_dict then tags + belonging to the given vocabulary (id or name) will be searched instead. + + ''' + check_access('tag_autocomplete', context, data_dict) + matching_tags = _tag_search(context, data_dict) + if matching_tags: + return [tag.name for tag in matching_tags] + else: + return [] def task_status_show(context, data_dict): model = context['model'] @@ -861,6 +878,32 @@ def task_status_show(context, data_dict): task_status_dict = task_status_dictize(task_status, context) return task_status_dict + +def term_translation_show(context, data_dict): + model = context['model'] + + trans_table = model.term_translation_table + + q = select([trans_table]) + + if 'term' not in data_dict: + raise ValidationError({'term': 'term not it data'}) + + q = q.where(trans_table.c.term == data_dict['term']) + + if 'lang_code' in data_dict: + q = q.where(trans_table.c.lang_code == data_dict['lang_code']) + + conn = model.Session.connection() + cursor = conn.execute(q) + + results = [] + + for row in cursor: + results.append(table_dictize(row, context)) + + return results + def get_site_user(context, data_dict): check_access('get_site_user', context, data_dict) model = context['model'] @@ -947,6 +990,22 @@ def status_show(context, data_dict): 'extensions': config.get('ckan.plugins').split(), } +def vocabulary_list(context, data_dict): + model = context['model'] + vocabulary_objects = model.Session.query(model.Vocabulary).all() + return vocabulary_list_dictize(vocabulary_objects, context) + +def vocabulary_show(context, data_dict): + model = context['model'] + vocab_id = data_dict.get('id') + if not vocab_id: + raise ValidationError({'id': _('id not in data')}) + vocabulary = model.vocabulary.Vocabulary.get(vocab_id) + if vocabulary is None: + raise NotFound(_('Could not find vocabulary "%s"') % vocab_id) + vocabulary_dict = vocabulary_dictize(vocabulary, context) + return vocabulary_dict + def user_activity_list(context, data_dict): '''Return a user\'s public activity stream as a list of dicts.''' model = context['model'] diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index fe835fdcf5b..84793fae2b6 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -17,21 +17,25 @@ group_dictize, group_to_api1, group_to_api2, - user_dictize) + user_dictize, + vocabulary_dictize) from ckan.lib.dictization.model_save import (group_api_to_dict, package_api_to_dict, group_dict_save, user_dict_save, task_status_dict_save, package_dict_save, - resource_dict_save) + resource_dict_save, + vocabulary_dict_update) from ckan.logic.schema import (default_update_group_schema, default_update_package_schema, default_update_user_schema, default_update_resource_schema, + default_task_status_schema, default_update_relationship_schema, - default_task_status_schema) + default_update_vocabulary_schema) from ckan.lib.navl.dictization_functions import validate +import ckan.lib.navl.validators as validators from ckan.logic.action import rename_keys, get_domain_object from ckan.logic.action.get import roles_show @@ -492,6 +496,60 @@ def task_status_update_many(context, data_dict): model.Session.commit() return {'results': results} +def term_translation_update(context, data_dict): + model = context['model'] + + check_access('term_translation_update', context, data_dict) + + schema = {'term': [validators.not_empty, unicode], + 'term_translation': [validators.not_empty, unicode], + 'lang_code': [validators.not_empty, unicode]} + + data, errors = validate(data_dict, schema, context) + + if errors: + model.Session.rollback() + raise ValidationError(errors) + + trans_table = model.term_translation_table + + update = trans_table.update() + update = update.where(trans_table.c.term == data['term']) + update = update.where(trans_table.c.lang_code == data['lang_code']) + update = update.values(term_translation = data['term_translation']) + + conn = model.Session.connection() + result = conn.execute(update) + + # insert if not updated + if not result.rowcount: + conn.execute(trans_table.insert().values(**data)) + + if not context.get('defer_commit'): + model.Session.commit() + + return data + +def term_translation_update_many(context, data_dict): + model = context['model'] + + + if not data_dict.get('data') and isinstance(data_dict, list): + raise ValidationError( + {'error': + 'term_translation_update_many needs to have a list of dicts in field data'} + ) + + context['defer_commit'] = True + + for num, row in enumerate(data_dict['data']): + term_translation_update(context, row) + + model.Session.commit() + + return {'success': '%s rows updated' % (num + 1)} + + ## Modifications for rest api def package_update_rest(context, data_dict): @@ -555,6 +613,33 @@ def group_update_rest(context, data_dict): return group_dict +def vocabulary_update(context, data_dict): + model = context['model'] + + vocab_id = data_dict.get('id') + if not vocab_id: + raise ValidationError({'id': _('id not in data')}) + + vocab = model.vocabulary.Vocabulary.get(vocab_id) + if vocab is None: + raise NotFound(_('Could not find vocabulary "%s"') % vocab_id) + + check_access('vocabulary_update', context, data_dict) + + schema = context.get('schema') or default_update_vocabulary_schema() + data, errors = validate(data_dict, schema, context) + + if errors: + model.Session.rollback() + raise ValidationError(errors) + + updated_vocab = vocabulary_dict_update(data, context) + + if not context.get('defer_commit'): + model.repo.commit() + + return vocabulary_dictize(updated_vocab, context) + def package_relationship_update_rest(context, data_dict): # rename keys @@ -656,4 +741,3 @@ def user_role_bulk_update(context, data_dict): 'domain_object': data_dict['domain_object']} user_role_update(context, uro_data_dict) return roles_show(context, data_dict) - diff --git a/ckan/logic/auth/create.py b/ckan/logic/auth/create.py index fc0672138ef..0d98c0d26c1 100644 --- a/ckan/logic/auth/create.py +++ b/ckan/logic/auth/create.py @@ -130,6 +130,14 @@ def group_create_rest(context, data_dict): return group_create(context, data_dict) +def vocabulary_create(context, data_dict): + user = context['user'] + return {'success': Authorizer.is_sysadmin(user)} + def activity_create(context, data_dict): user = context['user'] return {'success': Authorizer.is_sysadmin(user)} + +def tag_create(context, data_dict): + user = context['user'] + return {'success': Authorizer.is_sysadmin(user)} diff --git a/ckan/logic/auth/delete.py b/ckan/logic/auth/delete.py index 52628046fec..12937c284d9 100644 --- a/ckan/logic/auth/delete.py +++ b/ckan/logic/auth/delete.py @@ -56,3 +56,11 @@ def task_status_delete(context, data_dict): return {'success': False, 'msg': _('User %s not authorized to delete task_status') % str(user)} else: return {'success': True} + +def vocabulary_delete(context, data_dict): + user = context['user'] + return {'success': Authorizer.is_sysadmin(user)} + +def tag_delete(context, data_dict): + user = context['user'] + return {'success': Authorizer.is_sysadmin(user)} diff --git a/ckan/logic/auth/publisher/create.py b/ckan/logic/auth/publisher/create.py index c87f8637243..e0136cb8861 100644 --- a/ckan/logic/auth/publisher/create.py +++ b/ckan/logic/auth/publisher/create.py @@ -5,6 +5,9 @@ from ckan.authz import Authorizer from ckan.lib.base import _ +# FIXME: Which is worse, 'from module import foo' or duplicating these +# functions in this module? +from ckan.logic.auth.create import vocabulary_create, tag_create def package_create(context, data_dict=None): model = context['model'] diff --git a/ckan/logic/auth/publisher/delete.py b/ckan/logic/auth/publisher/delete.py index 57c9ee84260..28a129819a9 100644 --- a/ckan/logic/auth/publisher/delete.py +++ b/ckan/logic/auth/publisher/delete.py @@ -6,6 +6,10 @@ from ckan.authz import Authorizer from ckan.lib.base import _ +# FIXME: Which is worse, 'from module import foo' or duplicating these +# functions in this module? +from ckan.logic.auth.delete import vocabulary_delete, tag_delete + def package_delete(context, data_dict): """ Delete a package permission. User must be in at least one group that that diff --git a/ckan/logic/auth/publisher/update.py b/ckan/logic/auth/publisher/update.py index cc8ca288f9b..46daf0037d5 100644 --- a/ckan/logic/auth/publisher/update.py +++ b/ckan/logic/auth/publisher/update.py @@ -5,6 +5,10 @@ from ckan.authz import Authorizer from ckan.lib.base import _ +# FIXME: Which is worse, 'from module import foo' or duplicating these +# functions in this module? +from ckan.logic.auth.update import vocabulary_update + def make_latest_pending_package_active(context, data_dict): return package_update(context, data_dict) @@ -117,12 +121,31 @@ def task_status_update(context, data_dict): model = context['model'] user = context['user'] + if 'ignore_auth' in context and context['ignore_auth']: + return {'success': True} + authorized = Authorizer().is_sysadmin(unicode(user)) if not authorized: return {'success': False, 'msg': _('User %s not authorized to update task_status table') % str(user)} else: return {'success': True} +def term_translation_update(context, data_dict): + + model = context['model'] + user = context['user'] + + if 'ignore_auth' in context and context['ignore_auth']: + return {'success': True} + + authorized = Authorizer().is_sysadmin(unicode(user)) + if not authorized: + return {'success': False, 'msg': _('User %s not authorized to update term_translation table') % str(user)} + else: + return {'success': True} + + + ## Modifications for rest api def package_update_rest(context, data_dict): diff --git a/ckan/logic/auth/update.py b/ckan/logic/auth/update.py index 7b84b430f2e..93036b75b04 100644 --- a/ckan/logic/auth/update.py +++ b/ckan/logic/auth/update.py @@ -159,6 +159,24 @@ def task_status_update(context, data_dict): else: return {'success': True} +def vocabulary_update(context, data_dict): + user = context['user'] + return {'success': Authorizer.is_sysadmin(user)} + +def term_translation_update(context, data_dict): + + model = context['model'] + user = context['user'] + + if 'ignore_auth' in context and context['ignore_auth']: + return {'success': True} + + authorized = Authorizer().is_sysadmin(unicode(user)) + if not authorized: + return {'success': False, 'msg': _('User %s not authorized to update term_translation table') % str(user)} + else: + return {'success': True} + ## Modifications for rest api def package_update_rest(context, data_dict): diff --git a/ckan/logic/converters.py b/ckan/logic/converters.py index 540ee5a07e6..066520cc1e8 100644 --- a/ckan/logic/converters.py +++ b/ckan/logic/converters.py @@ -1,18 +1,17 @@ +from pylons.i18n import _ +from ckan import model from ckan.lib.navl.dictization_functions import Invalid from ckan.lib.field_types import DateType, DateConvertError - - +from ckan.logic.validators import tag_length_validator, tag_name_validator, \ + tag_in_vocabulary_validator def convert_to_extras(key, data, errors, context): - extras = data.get(('extras',), []) if not extras: data[('extras',)] = extras - extras.append({'key': key[-1], 'value': data[key]}) def convert_from_extras(key, data, errors, context): - for data_key, data_value in data.iteritems(): if (data_key[0] == 'extras' and data_key[-1] == 'key' @@ -33,3 +32,56 @@ def date_to_form(value, context): raise Invalid(str(e)) return value +def free_tags_only(key, data, errors, context): + tag_number = key[1] + to_delete = [] + if data.get(('tags', tag_number, 'vocabulary_id')): + to_delete.append(tag_number) + for k in data.keys(): + for n in to_delete: + if k[0] == 'tags' and k[1] == n: + del data[k] + +def convert_to_tags(vocab): + def callable(key, data, errors, context): + new_tags = data.get(key) + if not new_tags: + return + if isinstance(new_tags, basestring): + new_tags = [new_tags] + + # get current number of tags + n = 0 + for k in data.keys(): + if k[0] == 'tags': + n = max(n, k[1] + 1) + + v = model.Vocabulary.get(vocab) + if not v: + raise Invalid(_('Tag vocabulary "%s" does not exist') % vocab) + context['vocabulary'] = v + + for tag in new_tags: + tag_length_validator(tag, context) + tag_name_validator(tag, context) + tag_in_vocabulary_validator(tag, context) + + for num, tag in enumerate(new_tags): + data[('tags', num+n, 'name')] = tag + data[('tags', num+n, 'vocabulary_id')] = v.id + return callable + +def convert_from_tags(vocab): + def callable(key, data, errors, context): + v = model.Vocabulary.get(vocab) + if not v: + raise Invalid(_('Tag vocabulary "%s" does not exist') % vocab) + + tags = [] + for k in data.keys(): + if k[0] == 'tags': + if data[k].get('vocabulary_id') == v.id: + tags.append(data[k]['name']) + data[key] = tags + return callable + diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index aa2610309bb..7f9718dc3e0 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -31,9 +31,13 @@ isodate, int_validator, user_about_validator, + vocabulary_name_validator, + vocabulary_id_not_changed, + vocabulary_id_exists, user_id_exists, object_id_validator, - activity_type_exists) + activity_type_exists, + tag_not_in_vocabulary) from formencode.validators import OneOf import ckan.model @@ -71,18 +75,30 @@ def default_update_resource_schema(): return schema def default_tags_schema(): - schema = { - 'name': [not_empty, + 'name': [not_missing, + not_empty, unicode, tag_length_validator, tag_name_validator, ], + 'vocabulary_id': [ignore_missing, unicode, vocabulary_id_exists], 'revision_timestamp': [ignore], 'state': [ignore], } return schema +def default_create_tag_schema(): + schema = default_tags_schema() + # When creating a tag via the create_tag() logic action function, a + # vocabulary_id _must_ be given (you cannot create free tags via this + # function). + schema['vocabulary_id'] = [not_missing, not_empty, unicode, + vocabulary_id_exists, tag_not_in_vocabulary] + # You're not allowed to specify your own ID when creating a tag. + schema['id'] = [empty] + return schema + def default_package_schema(): schema = { @@ -300,6 +316,23 @@ def default_task_status_schema(): } return schema +def default_vocabulary_schema(): + schema = { + 'id': [ignore_empty, ignore_missing, unicode], + 'name': [not_empty, unicode, vocabulary_name_validator], + } + return schema + +def default_create_vocabulary_schema(): + schema = default_vocabulary_schema() + schema['id'] = [empty] + return schema + +def default_update_vocabulary_schema(): + schema = default_vocabulary_schema() + schema['id'] = schema['id'] + [vocabulary_id_not_changed] + return schema + def default_create_activity_schema(): schema = { 'id': [ignore], diff --git a/ckan/logic/validators.py b/ckan/logic/validators.py index 0101bc726c0..28a3b278a84 100644 --- a/ckan/logic/validators.py +++ b/ckan/logic/validators.py @@ -1,4 +1,5 @@ import datetime +from pylons.i18n import _ from itertools import count import re from pylons.i18n import _, ungettext, N_, gettext @@ -8,7 +9,9 @@ from ckan.lib.helpers import date_str_to_datetime from ckan.model import (MAX_TAG_LENGTH, MIN_TAG_LENGTH, PACKAGE_NAME_MIN_LENGTH, PACKAGE_NAME_MAX_LENGTH, - PACKAGE_VERSION_MAX_LENGTH) + PACKAGE_VERSION_MAX_LENGTH, + VOCABULARY_NAME_MAX_LENGTH, + VOCABULARY_NAME_MIN_LENGTH) def package_id_not_changed(value, context): @@ -270,6 +273,12 @@ def tag_string_convert(key, data, errors, context): and parses tag names. These are added to the data dict, enumerated. They are also validated.''' + tag_string = data[key] + + tags = [tag.strip() \ + for tag in tag_string.split(',') \ + if tag.strip()] + if isinstance(data[key], basestring): tags = [tag.strip() \ for tag in data[key].split(',') \ @@ -392,3 +401,68 @@ def user_about_validator(value,context): raise Invalid(_('Edit not allowed as it looks like spam. Please avoid links in your description.')) return value + +def vocabulary_name_validator(name, context): + model = context['model'] + session = context['session'] + + if len(name) < VOCABULARY_NAME_MIN_LENGTH: + raise Invalid(_('Name must be at least %s characters long') % + VOCABULARY_NAME_MIN_LENGTH) + if len(name) > VOCABULARY_NAME_MAX_LENGTH: + raise Invalid(_('Name must be a maximum of %i characters long') % + VOCABULARY_NAME_MAX_LENGTH) + query = session.query(model.Vocabulary.name).filter_by(name=name) + result = query.first() + if result: + raise Invalid(_('That vocabulary name is already in use.')) + return name + +def vocabulary_id_not_changed(value, context): + vocabulary = context.get('vocabulary') + if vocabulary and value != vocabulary.id: + raise Invalid(_('Cannot change value of key from %s to %s. ' + 'This key is read-only') % (vocabulary.id, value)) + return value + +def vocabulary_id_exists(value, context): + model = context['model'] + session = context['session'] + result = session.query(model.Vocabulary).get(value) + if not result: + raise Invalid(_('Tag vocabulary was not found.')) + return value + +def tag_in_vocabulary_validator(value, context): + model = context['model'] + session = context['session'] + vocabulary = context.get('vocabulary') + if vocabulary: + query = session.query(model.Tag)\ + .filter(model.Tag.vocabulary_id==vocabulary.id)\ + .filter(model.Tag.name==value)\ + .count() + if not query: + raise Invalid(_('Tag %s does not belong to vocabulary %s') % (value, vocabulary.name)) + return value + +def tag_not_in_vocabulary(key, tag_dict, errors, context): + tag_name = tag_dict[('name',)] + if not tag_name: + raise Invalid(_('No tag name')) + if tag_dict.has_key(('vocabulary_id',)): + vocabulary_id = tag_dict[('vocabulary_id',)] + else: + vocabulary_id = None + model = context['model'] + session = context['session'] + + query = session.query(model.Tag) + query = query.filter(model.Tag.vocabulary_id==vocabulary_id) + query = query.filter(model.Tag.name==tag_name) + count = query.count() + if count > 0: + raise Invalid(_('Tag %s already belongs to vocabulary %s') % + (tag_name, vocabulary_id)) + else: + return diff --git a/ckan/migration/versions/050_term_translation_table.py b/ckan/migration/versions/050_term_translation_table.py new file mode 100644 index 00000000000..074da9b3ca3 --- /dev/null +++ b/ckan/migration/versions/050_term_translation_table.py @@ -0,0 +1,20 @@ +from sqlalchemy import * +from migrate import * + +def upgrade(migrate_engine): + migrate_engine.execute(''' + CREATE TABLE term_translation ( + term text NOT NULL, + term_translation text NOT NULL, + lang_code text NOT NULL + ); + + create index term_lang on term_translation(term, lang_code); + create index term on term_translation(term); + ''' + ) + + + + + diff --git a/ckan/migration/versions/051_add_tag_vocabulary.py b/ckan/migration/versions/051_add_tag_vocabulary.py new file mode 100644 index 00000000000..548f7b383eb --- /dev/null +++ b/ckan/migration/versions/051_add_tag_vocabulary.py @@ -0,0 +1,29 @@ +from sqlalchemy import * +from migrate import * + +def upgrade(migrate_engine): + migrate_engine.execute(''' + ALTER TABLE tag + DROP CONSTRAINT tag_name_key; + + CREATE TABLE vocabulary ( + id text NOT NULL, + name character varying(100) NOT NULL + ); + + ALTER TABLE tag + ADD COLUMN vocabulary_id character varying(100); + + ALTER TABLE vocabulary + ADD CONSTRAINT vocabulary_pkey PRIMARY KEY (id); + + ALTER TABLE tag + ADD CONSTRAINT tag_name_vocabulary_id_key UNIQUE (name, vocabulary_id); + + ALTER TABLE tag + ADD CONSTRAINT tag_vocabulary_id_fkey FOREIGN KEY (vocabulary_id) REFERENCES vocabulary(id); + + ALTER TABLE vocabulary + ADD CONSTRAINT vocabulary_name_key UNIQUE (name); + ''' + ) diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py index d2b5d839305..8e191fda3cf 100644 --- a/ckan/model/__init__.py +++ b/ckan/model/__init__.py @@ -23,7 +23,9 @@ from rating import * from package_relationship import * from task_status import * +from vocabulary import * from activity import * +from term_translation import * import ckan.migration from ckan.lib.helpers import OrderedDict, datetime_to_date_str from vdm.sqlalchemy.base import SQLAlchemySession diff --git a/ckan/model/package.py b/ckan/model/package.py index 7cff7b5e258..4e4a62b97f3 100644 --- a/ckan/model/package.py +++ b/ckan/model/package.py @@ -1,10 +1,11 @@ import datetime from time import gmtime from calendar import timegm +from operator import attrgetter import logging logger = logging.getLogger(__name__) -from sqlalchemy.sql import select, and_, union, expression, or_ +from sqlalchemy.sql import select, and_, union, expression, or_, desc from sqlalchemy.orm import eagerload_all from sqlalchemy import types, Column, Table from pylons import config, session, c, request @@ -50,7 +51,6 @@ ## ------------------- ## Mapped classes - class Package(vdm.sqlalchemy.RevisionedObjectMixin, vdm.sqlalchemy.StatefulObjectMixin, DomainObject): @@ -87,7 +87,7 @@ def resources(self): assert len(self.resource_groups_all) == 1, "can only use resources on packages if there is only one resource_group" return self.resource_groups_all[0].resources - + def update_resources(self, res_dicts, autoflush=True): '''Change this package\'s resources. @param res_dicts - ordered list of dicts, each detailing a resource @@ -162,20 +162,76 @@ def add_resource(self, url, format=u'', description=u'', hash=u'', **kw): hash=hash, **kw)) - def add_tag_by_name(self, tagname, autoflush=True): + def add_tag(self, tag): + import ckan.model as model + if tag in self.get_tags(tag.vocabulary): + return + else: + package_tag = model.PackageTag(self, tag) + model.Session.add(package_tag) + + def add_tags(self, tags): + for tag in tags: + self.add_tag(tag) + + def add_tag_by_name(self, tag_name, vocab=None, autoflush=True): + """Add a tag with the given name to this package's tags. + + By default the given tag_name will be searched for among the free tags + (tags which do not belong to any vocabulary) only. If the optional + argument `vocab` is given then the named vocab will be searched for the + tag name instead. + + If no tag with the given name is found, one will be created. If the + optional argument vocab is given and there is no tag with the given + name in the given vocabulary, then a new tag will be created and added + to the vocabulary. + + """ from tag import Tag - if not tagname: + if not tag_name: return - tag = Tag.by_name(tagname, autoflush=autoflush) + # Get the named tag. + tag = Tag.by_name(tag_name, vocab=vocab, autoflush=autoflush) if not tag: - tag = Tag(name=tagname) - if not tag in self.tags: - self.tags.append(tag) + # Tag doesn't exist yet, make a new one. + if vocab: + tag = Tag(name=tag_name, vocabulary_id=vocab.id) + else: + tag = Tag(name=tag_name) + assert tag is not None + self.add_tag(tag) - @property - def tags_ordered(self): - ourcmp = lambda tag1, tag2: cmp(tag1.name, tag2.name) - return sorted(self.tags, cmp=ourcmp) + def get_tags(self, vocab=None): + """Return a sorted list of this package's tags + + Tags are sorted by their names. + + """ + import ckan.model as model + query = model.Session.query(model.Tag) + query = query.join(model.PackageTagRevision) + query = query.filter(model.PackageTagRevision.tag_id == model.Tag.id) + query = query.filter(model.PackageTagRevision.package_id == self.id) + query = query.filter(and_( + model.PackageTagRevision.state == 'active', + model.PackageTagRevision.current == True)) + if vocab: + query = query.filter(model.Tag.vocabulary_id == vocab.id) + else: + query = query.filter(model.Tag.vocabulary_id == None) + query = query.order_by(model.Tag.name) + tags = query.all() + return tags + + def remove_tag(self, tag): + import ckan.model as model + query = model.Session.query(model.PackageTag) + query = query.filter(model.PackageTag.package_id == self.id) + query = query.filter(model.PackageTag.tag_id == tag.id) + package_tag = query.one() + package_tag.delete() + model.Session.commit() def isopen(self): if self.license and self.license.isopen(): @@ -197,7 +253,7 @@ def as_dict(self, ref_package_by='name', ref_group_by='name'): # Todo: Remove from Version 2? _dict['license'] = self.license.title if self.license else _dict.get('license_id', '') _dict['isopen'] = self.isopen() - tags = [tag.name for tag in self.tags] + tags = [tag.name for tag in self.get_tags()] tags.sort() # so it is determinable _dict['tags'] = tags groups = [getattr(group, ref_group_by) for group in self.get_groups()] diff --git a/ckan/model/tag.py b/ckan/model/tag.py index 03808141e52..009b47395f0 100644 --- a/ckan/model/tag.py +++ b/ckan/model/tag.py @@ -1,5 +1,4 @@ -from sqlalchemy.orm import eagerload_all -from sqlalchemy import and_ +import sqlalchemy import vdm.sqlalchemy from types import make_uuid @@ -7,7 +6,9 @@ from domain_object import DomainObject from package import Package from core import * +import vocabulary import activity +import ckan __all__ = ['tag_table', 'package_tag_table', 'Tag', 'PackageTag', 'PackageTagRevision', 'package_tag_revision_table', @@ -18,7 +19,11 @@ tag_table = Table('tag', metadata, Column('id', types.UnicodeText, primary_key=True, default=make_uuid), - Column('name', types.Unicode(MAX_TAG_LENGTH), nullable=False, unique=True), + Column('name', types.Unicode(MAX_TAG_LENGTH), nullable=False), + Column('vocabulary_id', + types.Unicode(vocabulary.VOCABULARY_NAME_MAX_LENGTH), + ForeignKey('vocabulary.id')), + sqlalchemy.UniqueConstraint('name', 'vocabulary_id') ) package_tag_table = Table('package_tag', metadata, @@ -32,57 +37,149 @@ package_tag_revision_table = make_revisioned_table(package_tag_table) class Tag(DomainObject): - def __init__(self, name=''): + def __init__(self, name='', vocabulary_id=None): self.name = name + self.vocabulary_id = vocabulary_id # not stateful so same as purge def delete(self): self.purge() @classmethod - def get(cls, reference): - '''Returns a tag object referenced by its id or name.''' - query = Session.query(cls).filter(cls.id==reference) - query = query.options(eagerload_all('package_tags')) + def by_id(cls, tag_id, autoflush=True): + """Return the tag object with the given id, or None if there is no + tag with that id. + + Arguments: + tag_id -- The id of the tag to return. + + """ + query = Session.query(Tag).filter(Tag.id==tag_id) + query = query.autoflush(autoflush) tag = query.first() - if tag == None: - tag = cls.by_name(reference) return tag - # Todo: Make sure tag names can't be changed to look like tag IDs? @classmethod - def search_by_name(cls, text_query): - text_query = text_query.strip().lower() - q = Session.query(cls).filter(cls.name.contains(text_query)) - q = q.distinct().join(cls.package_tags) - return q - - #@classmethod - #def by_name(self, name, autoflush=True): - # q = Session.query(self).autoflush(autoflush).filter_by(name=name) - # q = q.distinct().join(self.package_tags) - # return q.first() - + def by_name(cls, name, vocab=None, autoflush=True): + """Return the tag object with the given name, or None if there is no + tag with that name. + + By default only free tags (tags which do not belong to any vocabulary) + are returned. If the optional argument vocab is given then only tags + from that vocabulary are returned, or None if there is no tag with that + name in that vocabulary. + + Arguments: + name -- The name of the tag to return. + vocab -- A Vocabulary object for the vocabulary to look in (optional). + + """ + if vocab: + query = Session.query(Tag).filter(Tag.name==name).filter( + Tag.vocabulary_id==vocab.id) + else: + query = Session.query(Tag).filter(Tag.name==name).filter( + Tag.vocabulary_id==None) + query = query.autoflush(autoflush) + tag = query.first() + return tag + + @classmethod + def get(cls, tag_id_or_name, vocab_id_or_name=None): + """Return the tag object with the given id or name, or None if there is + no tag with that id or name. + + 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 that belong to that vocabulary will be returned, and None + will be returned if there is no vocabulary with that vocabulary id or + name or if there is no tag with that tag id or name in that vocabulary. + + Arguments: + tag_id_or_name -- The id or name of the tag to return. + vocab_id_or_name -- The id or name of the vocabulary to look in. + + """ + # First try to get the tag by ID. + tag = Tag.by_id(tag_id_or_name) + if tag: + return tag + else: + # If that didn't work, try to get the tag by name and vocabulary. + if vocab_id_or_name: + vocab = vocabulary.Vocabulary.get(vocab_id_or_name) + if vocab is None: + # The user specified an invalid vocab. + raise ckan.logic.NotFound("could not find vocabulary '%s'" + % vocab_id_or_name) + else: + vocab = None + tag = Tag.by_name(tag_id_or_name, vocab=vocab) + return tag + # Todo: Make sure tag names can't be changed to look like tag IDs? + @classmethod - def all(cls): - q = Session.query(cls) - q = q.distinct().join(PackageTagRevision) - q = q.filter(and_( - PackageTagRevision.state == 'active', PackageTagRevision.current == True - )) - return q + def search_by_name(cls, search_term, vocab_id_or_name=None): + """Return all tags that match the given search term. + + 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. + + """ + if vocab_id_or_name: + vocab = vocabulary.Vocabulary.get(vocab_id_or_name) + if vocab is None: + # The user specified an invalid vocab. + return None + query = Session.query(Tag).filter(Tag.vocabulary_id==vocab.id) + else: + query = Session.query(Tag) + search_term = search_term.strip().lower() + query = query.filter(Tag.name.contains(search_term)) + query = query.distinct().join(Tag.package_tags) + return query + + @classmethod + def all(cls, vocab_id_or_name=None): + """Return all tags that are currently applied to a package. + + 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. + + """ + if vocab_id_or_name: + vocab = vocabulary.Vocabulary.get(vocab_id_or_name) + if vocab is None: + # The user specified an invalid vocab. + raise ckan.logic.NotFound("could not find vocabulary '%s'" + % vocab_id_or_name) + query = Session.query(Tag).filter(Tag.vocabulary_id==vocab.id) + else: + query = Session.query(Tag).filter(Tag.vocabulary_id == None) + query = query.distinct().join(PackageTagRevision) + query = query.filter(sqlalchemy.and_( + PackageTagRevision.state == 'active', + PackageTagRevision.current == True)) + return query @property - def packages_ordered(self): + def packages(self): + """Return a list of all packages currently tagged with this tag. + + The list is sorted by package name. + + """ q = Session.query(Package) q = q.join(PackageTagRevision) q = q.filter(PackageTagRevision.tag_id == self.id) - q = q.filter(and_( - PackageTagRevision.state == 'active', PackageTagRevision.current == True - )) - packages = [p for p in q] - ourcmp = lambda pkg1, pkg2: cmp(pkg1.name, pkg2.name) - return sorted(packages, cmp=ourcmp) + q = q.filter(sqlalchemy.and_( + PackageTagRevision.state == 'active', + PackageTagRevision.current == True)) + q = q.order_by(Package.name) + packages = q.all() + return packages def __repr__(self): return '' % self.name @@ -129,20 +226,42 @@ def activity_stream_detail(self, activity_id, activity_type): data=d) @classmethod - def by_name(self, package_name, tag_name, autoflush=True): - q = Session.query(self).autoflush(autoflush).\ - join('package').filter(Package.name==package_name).\ - join('tag').filter(Tag.name==tag_name) - assert q.count() <= 1, q.all() - return q.first() + def by_name(self, package_name, tag_name, vocab_id_or_name=None, + autoflush=True): + """Return the one PackageTag for the given package and tag names, or + None if there is no PackageTag for that package and tag. + + By default only PackageTags for free tags (tags which do not belong to + any vocabulary) are returned. If the optional argument vocab_id_or_name + is given then only PackageTags for tags from that vocabulary are + returned. + + """ + if vocab_id_or_name: + vocab = vocabulary.Vocabulary.get(vocab_id_or_name) + if vocab is None: + # The user specified an invalid vocab. + return None + query = (Session.query(PackageTag, Tag, Package) + .filter(Tag.vocabulary_id == vocab.id) + .filter(Package.name==package_name) + .filter(Tag.name==tag_name)) + else: + query = (Session.query(PackageTag) + .filter(Package.name==package_name) + .filter(Tag.name==tag_name)) + query = query.autoflush(autoflush) + return query.one()[0] def related_packages(self): return [self.package] mapper(Tag, tag_table, properties={ - 'package_tags':relation(PackageTag, backref='tag', + 'package_tags': relation(PackageTag, backref='tag', cascade='all, delete, delete-orphan', ), + 'vocabulary': relation(vocabulary.Vocabulary, backref='tags', + order_by=tag_table.c.name) }, order_by=tag_table.c.name, ) @@ -165,10 +284,3 @@ def related_packages(self): package_tag_revision_table) PackageTagRevision.related_packages = lambda self: [self.continuity.package] - -from vdm.sqlalchemy.base import add_stateful_versioned_m2m -vdm.sqlalchemy.add_stateful_versioned_m2m(Package, PackageTag, 'tags', 'tag', - 'package_tags') -vdm.sqlalchemy.add_stateful_versioned_m2m_on_version(PackageRevision, 'tags') -vdm.sqlalchemy.add_stateful_versioned_m2m(Tag, PackageTag, 'packages', 'package', - 'package_tags') diff --git a/ckan/model/term_translation.py b/ckan/model/term_translation.py new file mode 100644 index 00000000000..394754cceed --- /dev/null +++ b/ckan/model/term_translation.py @@ -0,0 +1,14 @@ +import sqlalchemy as sa +from meta import * +from core import * +from types import make_uuid +from datetime import datetime + +__all__ = ['term_translation_table'] + +term_translation_table = Table('term_translation', metadata, + Column('term', UnicodeText, nullable=False), + Column('term_translation', UnicodeText, nullable=False), + Column('lang_code', UnicodeText, nullable=False), +) + diff --git a/ckan/model/vocabulary.py b/ckan/model/vocabulary.py new file mode 100644 index 00000000000..c4d2dec401a --- /dev/null +++ b/ckan/model/vocabulary.py @@ -0,0 +1,33 @@ +from meta import Table, types, Session +from core import metadata, Column, DomainObject, mapper +from types import make_uuid + +VOCABULARY_NAME_MIN_LENGTH = 2 +VOCABULARY_NAME_MAX_LENGTH = 100 + +vocabulary_table = Table( + 'vocabulary', metadata, + Column('id', types.UnicodeText, primary_key=True, default=make_uuid), + Column('name', types.Unicode(VOCABULARY_NAME_MAX_LENGTH), nullable=False, + unique=True), + ) + +class Vocabulary(DomainObject): + + def __init__(self, name): + self.id = make_uuid() + self.name = name + + @classmethod + def get(cls, id_or_name): + """Return a Vocabulary object referenced by its id or name, or None if + there is no vocabulary with the given id or name. + + """ + query = Session.query(Vocabulary).filter(Vocabulary.id==id_or_name) + vocab = query.first() + if vocab is None: + vocab = Vocabulary.by_name(id_or_name) + return vocab + +mapper(Vocabulary, vocabulary_table) diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index d18452f599f..2f5252d808d 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -207,6 +207,13 @@ def authz_remove_role(self, object_role): def delete(self, entity): pass + def before_view(self, pkg_dict): + ''' + Extensions will recieve this before the group gets displayed. The dictionary + passed will be the one that gets sent to the template. + ''' + return pkg_dict + class IPackageController(Interface): """ Hook into the package controller. @@ -270,6 +277,13 @@ def before_index(self, pkg_dict): an altered version. ''' return pkg_dict + + def before_view(self, pkg_dict): + ''' + Extensions will recieve this before the dataset gets displayed. The dictionary + passed will be the one that gets sent to the template. + ''' + return pkg_dict class IPluginObserver(Interface): diff --git a/ckan/templates/_util.html b/ckan/templates/_util.html index 9e8a7fce367..31a741b3799 100644 --- a/ckan/templates/_util.html +++ b/ckan/templates/_util.html @@ -26,9 +26,11 @@
    -
  • + +
  • ${h.link_to(tag['name'], h.url_for(controller='tag', action='read', id=tag['name']))}
  • +