From 44aa9699f667f300d57e5f7291686f5fb2d216ea Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Tue, 9 Jul 2013 15:55:50 -0400 Subject: [PATCH 001/130] [#1078] avoid json loads/dumps with LazyJSONObject --- ckan/controllers/api.py | 8 +++++++- ckan/lib/lazyjson.py | 36 ++++++++++++++++++++++++++++++++++++ ckan/logic/action/get.py | 7 ++++++- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 ckan/lib/lazyjson.py diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index 22ea7d4f5c3..be131ede046 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -34,6 +34,7 @@ 'text': 'text/plain;charset=utf-8', 'html': 'text/html;charset=utf-8', 'json': 'application/json;charset=utf-8', + 'json_string': 'application/json;charset=utf-8', } @@ -161,7 +162,7 @@ def action(self, logic_function, ver=None): _('Action name not known: %s') % logic_function) context = {'model': model, 'session': model.Session, 'user': c.user, - 'api_version': ver} + 'api_version': ver, 'return_type': 'LazyJSONObject'} model.Session()._context = context return_dict = {'help': function.__doc__} try: @@ -185,6 +186,11 @@ def action(self, logic_function, ver=None): try: result = function(context, request_data) return_dict['success'] = True + if hasattr(result, 'to_json_string'): + return_dict['result'] = 463455395108 # magic placeholder + return self._finish_ok(h.json.dumps( + return_dict).replace('463455395108', + result.to_json_string()), 'json_string') return_dict['result'] = result except DataError, e: log.error('Format incorrect: %s - %s' % (e.error, request_data)) diff --git a/ckan/lib/lazyjson.py b/ckan/lib/lazyjson.py new file mode 100644 index 00000000000..321cb2fb806 --- /dev/null +++ b/ckan/lib/lazyjson.py @@ -0,0 +1,36 @@ +import json + +class LazyJSONObject(object): + '''An object that behaves like a dict returned from json.loads''' + def __init__(self, json_string): + self._json_string = json_string + self._json_dict = None + + def _loads(self): + if not self._json_dict: + self._json_dict = json.loads(self._json_string) + self._json_string = None + return self._json_dict + + def __nonzero__(self): + return True + + def to_json_string(self, *args, **kwargs): + if self._json_string: + return self._json_string + return json.dumps(self._json_dict, *args, **kwargs) + + +def _loads_method(name): + def method(self, *args, **kwargs): + return getattr(self._loads(), name)(*args, **kwargs) + return method + +for fn in ['__cmp__', '__contains__', '__delitem__', '__eq__', '__ge__', + '__getitem__', '__gt__', '__iter__', '__le__', '__len__', '__lt__', + '__ne__', '__setitem__', 'clear', 'copy', 'fromkeys', 'get', 'has_key', + 'items', 'iteritems', 'iterkeys', 'itervalues', 'keys', 'pop', + 'popitem', 'setdefault', 'update', 'values']: + setattr(LazyJSONObject, fn, _loads_method(fn)) + + diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 7c9cdfddace..123c431759d 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -21,6 +21,7 @@ import ckan.lib.plugins as lib_plugins import ckan.lib.activity_streams as activity_streams import ckan.new_authz as new_authz +import ckan.lib.lazyjson as lazyjson from ckan.common import _ @@ -781,7 +782,11 @@ def package_show(context, data_dict): else: use_validated_cache = 'schema' not in context if use_validated_cache and 'validated_data_dict' in search_result: - package_dict = json.loads(search_result['validated_data_dict']) + package_json = search_result['validated_data_dict'] + if context.get('return_type') == 'LazyJSONObject': + package_dict = lazyjson.LazyJSONObject(package_json) + else: + package_dict = json.loads(package_json) package_dict_validated = True else: package_dict = json.loads(search_result['data_dict']) From 3ae69be07908ab3fb6305e2b284d2ec0d61d45c9 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Tue, 9 Jul 2013 17:19:36 -0400 Subject: [PATCH 002/130] [#1078] LazyJSONEncoder as fallback for api call responses --- ckan/controllers/api.py | 13 ++++++------- ckan/lib/lazyjson.py | 6 ++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index be131ede046..5b23fcc1bec 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -16,6 +16,7 @@ import ckan.lib.navl.dictization_functions import ckan.lib.jsonp as jsonp import ckan.lib.munge as munge +import ckan.lib.lazyjson as lazyjson from ckan.common import _, c, request, response @@ -34,7 +35,6 @@ 'text': 'text/plain;charset=utf-8', 'html': 'text/html;charset=utf-8', 'json': 'application/json;charset=utf-8', - 'json_string': 'application/json;charset=utf-8', } @@ -83,7 +83,11 @@ def _finish(self, status_int, response_data=None, if response_data is not None: response.headers['Content-Type'] = CONTENT_TYPES[content_type] if content_type == 'json': - response_msg = h.json.dumps(response_data) + try: + response_msg = h.json.dumps(response_data) + except TypeError: + response_msg = lazyjson.LazyJSONEncoder().encode( + response_data) else: response_msg = response_data # Support "JSONP" callback. @@ -186,11 +190,6 @@ def action(self, logic_function, ver=None): try: result = function(context, request_data) return_dict['success'] = True - if hasattr(result, 'to_json_string'): - return_dict['result'] = 463455395108 # magic placeholder - return self._finish_ok(h.json.dumps( - return_dict).replace('463455395108', - result.to_json_string()), 'json_string') return_dict['result'] = result except DataError, e: log.error('Format incorrect: %s - %s' % (e.error, request_data)) diff --git a/ckan/lib/lazyjson.py b/ckan/lib/lazyjson.py index 321cb2fb806..b5ec164864c 100644 --- a/ckan/lib/lazyjson.py +++ b/ckan/lib/lazyjson.py @@ -34,3 +34,9 @@ def method(self, *args, **kwargs): setattr(LazyJSONObject, fn, _loads_method(fn)) +class LazyJSONEncoder(json.JSONEncoder): + '''JSON encoder that handles LazyJSONObject elements''' + def _iterencode_default(self, o, markers=None): + if hasattr(o, 'to_json_string'): + return iter([o.to_json_string()]) + return json.JSONEncoder._iterencode_default(self, o, markers) From ce5d7e85280bdd350abbc39c19a9f37066472603 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Tue, 9 Jul 2013 17:27:44 -0400 Subject: [PATCH 003/130] [#1078] force package_show return type within resource_update --- ckan/logic/action/update.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index c841a9bb25c..2e363eb6748 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -210,7 +210,8 @@ def resource_update(context, data_dict): del context["resource"] package_id = resource.resource_group.package.id - pkg_dict = _get_action('package_show')(context, {'id': package_id}) + pkg_dict = _get_action('package_show')(dict(context, return_type='dict'), + {'id': package_id}) for n, p in enumerate(pkg_dict['resources']): if p['id'] == id: From 9330b47e0e314ee461aac1529a77a3fb6acb30a0 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Tue, 9 Jul 2013 18:40:55 -0400 Subject: [PATCH 004/130] [#1078] pep8 --- ckan/lib/lazyjson.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ckan/lib/lazyjson.py b/ckan/lib/lazyjson.py index b5ec164864c..d2d7d83deb8 100644 --- a/ckan/lib/lazyjson.py +++ b/ckan/lib/lazyjson.py @@ -1,5 +1,6 @@ import json + class LazyJSONObject(object): '''An object that behaves like a dict returned from json.loads''' def __init__(self, json_string): @@ -27,10 +28,10 @@ def method(self, *args, **kwargs): return method for fn in ['__cmp__', '__contains__', '__delitem__', '__eq__', '__ge__', - '__getitem__', '__gt__', '__iter__', '__le__', '__len__', '__lt__', - '__ne__', '__setitem__', 'clear', 'copy', 'fromkeys', 'get', 'has_key', - 'items', 'iteritems', 'iterkeys', 'itervalues', 'keys', 'pop', - 'popitem', 'setdefault', 'update', 'values']: + '__getitem__', '__gt__', '__iter__', '__le__', '__len__', '__lt__', + '__ne__', '__setitem__', 'clear', 'copy', 'fromkeys', 'get', + 'has_key', 'items', 'iteritems', 'iterkeys', 'itervalues', 'keys', + 'pop', 'popitem', 'setdefault', 'update', 'values']: setattr(LazyJSONObject, fn, _loads_method(fn)) From 12420cefcc8ad3e40e429201189857cf99abd69e Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Tue, 9 Jul 2013 18:44:45 -0400 Subject: [PATCH 005/130] [#1078] force package_show return type within resource_create --- ckan/logic/action/create.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index fcb55ceae64..d7d470cba20 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -245,7 +245,8 @@ def resource_create(context, data_dict): package_id = _get_or_bust(data_dict, 'package_id') data_dict.pop('package_id') - pkg_dict = _get_action('package_show')(context, {'id': package_id}) + pkg_dict = _get_action('package_show')(dict(context, return_type='dict'), + {'id': package_id}) _check_access('resource_create', context, data_dict) From 5f0ca73ed2e2e65ec071884f8c17498c666dd448 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Tue, 9 Jul 2013 19:52:34 -0400 Subject: [PATCH 006/130] [#1078] 2.7 comatibility fix: use simplejson for lazyjson --- ckan/lib/lazyjson.py | 61 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/ckan/lib/lazyjson.py b/ckan/lib/lazyjson.py index d2d7d83deb8..14ca4daf2e8 100644 --- a/ckan/lib/lazyjson.py +++ b/ckan/lib/lazyjson.py @@ -1,4 +1,5 @@ -import json +import simplejson as json +import simplejson.encoder as json_encoder class LazyJSONObject(object): @@ -35,9 +36,61 @@ def method(self, *args, **kwargs): setattr(LazyJSONObject, fn, _loads_method(fn)) +class JSONString(str): + '''a type for already-encoded JSON''' + pass + + +def _encode_jsonstring(s): + if isinstance(s, JSONString): + return s + return json_encoder.encode_basestring(s) + + class LazyJSONEncoder(json.JSONEncoder): '''JSON encoder that handles LazyJSONObject elements''' - def _iterencode_default(self, o, markers=None): + def iterencode(self, o, _one_shot=False): + ''' + most of JSONEncoder.iterencode() copied so that _encode_jsonstring + may be used instead of encode_basestring + ''' + if self.check_circular: + markers = {} + else: + markers = None + def floatstr(o, allow_nan=self.allow_nan, + _repr=json_encoder.FLOAT_REPR, + _inf=json_encoder.PosInf, + _neginf=-json_encoder.PosInf): + # Check for specials. Note that this type of test is processor + # and/or platform-specific, so do tests which don't depend on the + # internals. + + if o != o: + text = 'NaN' + elif o == _inf: + text = 'Infinity' + elif o == _neginf: + text = '-Infinity' + else: + return _repr(o) + + if not allow_nan: + raise ValueError( + "Out of range float values are not JSON compliant: " + + repr(o)) + + return text + _iterencode = json_encoder._make_iterencode( + markers, self.default, _encode_jsonstring, self.indent, floatstr, + self.key_separator, self.item_separator, self.sort_keys, + self.skipkeys, _one_shot, self.use_decimal, + self.namedtuple_as_object, self.tuple_as_array, + self.bigint_as_string, self.item_sort_key, + self.encoding, Decimal=json_encoder.Decimal) + return _iterencode(o, 0) + + def default(self, o): if hasattr(o, 'to_json_string'): - return iter([o.to_json_string()]) - return json.JSONEncoder._iterencode_default(self, o, markers) + return JSONString(o.to_json_string()) + return json.JSONEncoder.default(self, o) From 5a795f2bd9424d6bd0d30389b0477f7567e83019 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Tue, 9 Jul 2013 21:17:07 -0400 Subject: [PATCH 007/130] [#1078] pep8 --- ckan/lib/lazyjson.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ckan/lib/lazyjson.py b/ckan/lib/lazyjson.py index 14ca4daf2e8..50bcf0a4f6c 100644 --- a/ckan/lib/lazyjson.py +++ b/ckan/lib/lazyjson.py @@ -58,10 +58,11 @@ def iterencode(self, o, _one_shot=False): markers = {} else: markers = None + def floatstr(o, allow_nan=self.allow_nan, - _repr=json_encoder.FLOAT_REPR, - _inf=json_encoder.PosInf, - _neginf=-json_encoder.PosInf): + _repr=json_encoder.FLOAT_REPR, + _inf=json_encoder.PosInf, + _neginf=-json_encoder.PosInf): # Check for specials. Note that this type of test is processor # and/or platform-specific, so do tests which don't depend on the # internals. From 3462450d5deb018d5c67d47b7872cfee075f19ee Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Thu, 11 Dec 2014 17:07:56 -0500 Subject: [PATCH 008/130] [#1078] smaller simplejson hack: use for_json and pretend to be an int --- ckan/controllers/api.py | 9 ++---- ckan/lib/lazyjson.py | 72 ++++++++--------------------------------- 2 files changed, 16 insertions(+), 65 deletions(-) diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index 934d531c87f..f81e5d718f9 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -16,7 +16,6 @@ import ckan.lib.navl.dictization_functions import ckan.lib.jsonp as jsonp import ckan.lib.munge as munge -import ckan.lib.lazyjson as lazyjson from ckan.common import _, c, request, response @@ -84,11 +83,9 @@ def _finish(self, status_int, response_data=None, if response_data is not None: response.headers['Content-Type'] = CONTENT_TYPES[content_type] if content_type == 'json': - try: - response_msg = h.json.dumps(response_data) - except TypeError: - response_msg = lazyjson.LazyJSONEncoder().encode( - response_data) + response_msg = h.json.dumps( + response_data, + for_json=True) # handle objects with for_json methods else: response_msg = response_data # Support "JSONP" callback. diff --git a/ckan/lib/lazyjson.py b/ckan/lib/lazyjson.py index 50bcf0a4f6c..2d5d6b5a82a 100644 --- a/ckan/lib/lazyjson.py +++ b/ckan/lib/lazyjson.py @@ -17,10 +17,10 @@ def _loads(self): def __nonzero__(self): return True - def to_json_string(self, *args, **kwargs): + def for_json(self): if self._json_string: - return self._json_string - return json.dumps(self._json_dict, *args, **kwargs) + return JSONString(self._json_string) + return self._json_dict def _loads_method(name): @@ -36,62 +36,16 @@ def method(self, *args, **kwargs): setattr(LazyJSONObject, fn, _loads_method(fn)) -class JSONString(str): - '''a type for already-encoded JSON''' - pass +class JSONString(int): + ''' + A type for already-encoded JSON + Fake-out simplejson by subclassing int so that simplejson calls + our __str__ method to produce JSON. + ''' + def __init__(self, s): + self.s = s + super(JSONString, self).__init__(-1) -def _encode_jsonstring(s): - if isinstance(s, JSONString): + def __str__(self): return s - return json_encoder.encode_basestring(s) - - -class LazyJSONEncoder(json.JSONEncoder): - '''JSON encoder that handles LazyJSONObject elements''' - def iterencode(self, o, _one_shot=False): - ''' - most of JSONEncoder.iterencode() copied so that _encode_jsonstring - may be used instead of encode_basestring - ''' - if self.check_circular: - markers = {} - else: - markers = None - - def floatstr(o, allow_nan=self.allow_nan, - _repr=json_encoder.FLOAT_REPR, - _inf=json_encoder.PosInf, - _neginf=-json_encoder.PosInf): - # Check for specials. Note that this type of test is processor - # and/or platform-specific, so do tests which don't depend on the - # internals. - - if o != o: - text = 'NaN' - elif o == _inf: - text = 'Infinity' - elif o == _neginf: - text = '-Infinity' - else: - return _repr(o) - - if not allow_nan: - raise ValueError( - "Out of range float values are not JSON compliant: " + - repr(o)) - - return text - _iterencode = json_encoder._make_iterencode( - markers, self.default, _encode_jsonstring, self.indent, floatstr, - self.key_separator, self.item_separator, self.sort_keys, - self.skipkeys, _one_shot, self.use_decimal, - self.namedtuple_as_object, self.tuple_as_array, - self.bigint_as_string, self.item_sort_key, - self.encoding, Decimal=json_encoder.Decimal) - return _iterencode(o, 0) - - def default(self, o): - if hasattr(o, 'to_json_string'): - return JSONString(o.to_json_string()) - return json.JSONEncoder.default(self, o) From 0afe2dbfbb3155bd5cf7ec4ddf9439a0eb355b08 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Mon, 15 Dec 2014 09:07:54 -0500 Subject: [PATCH 009/130] [#1078] inherit from dict to pass some isinstance checks --- ckan/lib/lazyjson.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/lib/lazyjson.py b/ckan/lib/lazyjson.py index 2d5d6b5a82a..56a1e5c5377 100644 --- a/ckan/lib/lazyjson.py +++ b/ckan/lib/lazyjson.py @@ -2,7 +2,7 @@ import simplejson.encoder as json_encoder -class LazyJSONObject(object): +class LazyJSONObject(dict): '''An object that behaves like a dict returned from json.loads''' def __init__(self, json_string): self._json_string = json_string From 97620dd84cf87161a9cc85edf70e68c427e04471 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Mon, 15 Dec 2014 09:08:09 -0500 Subject: [PATCH 010/130] [#1078] pep8 --- ckan/logic/action/create.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index 95dac7dc30b..07a1c33ad6b 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -274,7 +274,8 @@ def resource_create(context, data_dict): package_id = _get_or_bust(data_dict, 'package_id') _get_or_bust(data_dict, 'url') - pkg_dict = _get_action('package_show')(dict(context, return_type='dict'), + pkg_dict = _get_action('package_show')( + dict(context, return_type='dict'), {'id': package_id}) _check_access('resource_create', context, data_dict) From 5bb1349c1b95a3d3ccee7ca27adfc9d8f3ec2a76 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Mon, 15 Dec 2014 09:38:08 -0500 Subject: [PATCH 011/130] [#1078] in my own defense --- ckan/lib/lazyjson.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ckan/lib/lazyjson.py b/ckan/lib/lazyjson.py index 56a1e5c5377..6305cb7d894 100644 --- a/ckan/lib/lazyjson.py +++ b/ckan/lib/lazyjson.py @@ -42,6 +42,10 @@ class JSONString(int): Fake-out simplejson by subclassing int so that simplejson calls our __str__ method to produce JSON. + + This trick is unpleasant, but significantly less fragile than + subclassing JSONEncoder and modifying its internal workings, or + monkeypatching the simplejson library. ''' def __init__(self, s): self.s = s From 822fd181e5abb1c039ea33b9a8edd04a9dc8dbb6 Mon Sep 17 00:00:00 2001 From: Ross Jones Date: Mon, 24 Nov 2014 22:16:53 +0000 Subject: [PATCH 012/130] Removes the old auth models and their use. Removes all of the old auth models, and contains a migration to delete the now unused tables. There are a few components still depending (secretly behind the scenes) on PackageRole and as a result for this PR to be complete it needs to re-implement `number_administered_packages`` --- ckan/lib/base.py | 1 - ckan/lib/create_test_data.py | 27 -- ckan/lib/helpers.py | 2 - ckan/logic/action/create.py | 3 +- ckan/logic/action/get.py | 75 +-- ckan/logic/auth/create.py | 4 +- ckan/logic/auth/update.py | 4 +- .../versions/075_remove_old_authz_model.py | 14 + ckan/model/__init__.py | 39 -- ckan/model/authz.py | 432 ------------------ ckan/model/user.py | 7 +- ckan/tests/functional/api/model/test_group.py | 4 - .../functional/api/model/test_package.py | 9 - ckan/tests/functional/test_admin.py | 124 ----- ckan/tests/functional/test_group.py | 1 - ckan/tests/functional/test_package.py | 3 - ckan/tests/functional/test_pagination.py | 5 - ckan/tests/logic/test_action.py | 117 +---- ckan/tests/models/test_user.py | 16 +- 19 files changed, 47 insertions(+), 840 deletions(-) create mode 100644 ckan/migration/versions/075_remove_old_authz_model.py delete mode 100644 ckan/model/authz.py diff --git a/ckan/lib/base.py b/ckan/lib/base.py index 00b6da6f553..6ad2794b173 100644 --- a/ckan/lib/base.py +++ b/ckan/lib/base.py @@ -119,7 +119,6 @@ def render(template_name, extra_vars=None, cache_key=None, cache_type=None, def render_template(): globs = extra_vars or {} globs.update(pylons_globals()) - globs['actions'] = model.Action # Using pylons.url() directly destroys the localisation stuff so # we remove it so any bad templates crash and burn diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py index 3d05984a241..c521781e3b8 100644 --- a/ckan/lib/create_test_data.py +++ b/ckan/lib/create_test_data.py @@ -257,7 +257,6 @@ def create_arbitrary(cls, package_dicts, relationships=[], else: raise NotImplementedError(attr) cls.pkg_names.append(item['name']) - model.setup_default_user_roles(pkg, admins=[]) for admin in admins: admins_list[item['name']].append(admin) model.repo.commit_and_remove() @@ -287,24 +286,9 @@ def create_arbitrary(cls, package_dicts, relationships=[], model.repo.commit_and_remove() needs_commit = False - # setup authz for admins - for pkg_name, admins in admins_list.items(): - pkg = model.Package.by_name(unicode(pkg_name)) - admins_obj_list = [] - for admin in admins: - if isinstance(admin, model.User): - admin_obj = admin - else: - admin_obj = model.User.by_name(unicode(admin)) - assert admin_obj, admin - admins_obj_list.append(admin_obj) - model.setup_default_user_roles(pkg, admins_obj_list) - needs_commit = True - # setup authz for groups just created for group_name in new_group_names: group = model.Group.by_name(unicode(group_name)) - model.setup_default_user_roles(group) cls.group_names.add(group_name) needs_commit = True @@ -390,7 +374,6 @@ def create_groups(cls, group_dicts, admin_user_name=None, auth_profile=""): member = model.Member(group=group, table_id=parent.id, table_name='group', capacity='parent') model.Session.add(member) - #model.setup_default_user_roles(group, admin_users) cls.group_names.add(group_dict['name']) model.repo.commit_and_remove() @@ -516,23 +499,13 @@ def create(cls, auth_profile="", package_type=None): cls.user_refs.extend([u'tester', u'joeadmin', u'annafan', u'russianfan', u'testsysadmin']) model.repo.commit_and_remove() - visitor = model.User.by_name(model.PSEUDO_USER__VISITOR) anna = model.Package.by_name(u'annakarenina') war = model.Package.by_name(u'warandpeace') annafan = model.User.by_name(u'annafan') russianfan = model.User.by_name(u'russianfan') - model.setup_default_user_roles(anna, [annafan]) - model.setup_default_user_roles(war, [russianfan]) - model.add_user_to_role(visitor, model.Role.ADMIN, war) david = model.Group.by_name(u'david') roger = model.Group.by_name(u'roger') - model.setup_default_user_roles(david, [russianfan]) - model.setup_default_user_roles(roger, [russianfan]) - # in new_authz you can't give a visitor permissions to a - # group it seems, so this is a bit meaningless - model.add_user_to_role(visitor, model.Role.ADMIN, roger) - model.repo.commit_and_remove() # method used in DGU and all good tests elsewhere @classmethod diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 8946e7d5bc4..85f10f16e84 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -765,8 +765,6 @@ def get_action(action_name, data_dict=None): def linked_user(user, maxlength=0, avatar=20): - if user in [model.PSEUDO_USER__LOGGED_IN, model.PSEUDO_USER__VISITOR]: - return user if not isinstance(user, model.User): user_name = unicode(user) user = model.User.get(user_name) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index 3ff4a688d4b..56bf319ccbc 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -186,7 +186,6 @@ def package_create(context, data_dict): pkg = model_save.package_dict_save(data, context) - model.setup_default_user_roles(pkg, admins) # Needed to let extensions know the package id model.Session.flush() data['id'] = pkg.id @@ -633,7 +632,7 @@ def _group_or_org_create(context, data_dict, is_org=False): admins = [model.User.by_name(user.decode('utf8'))] else: admins = [] - model.setup_default_user_roles(group, admins) + # Needed to let extensions know the group id session.flush() diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 2fc2cdbb67f..b96878b5efd 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -797,12 +797,12 @@ def user_list(context, data_dict): model.Revision.author == model.User.name, model.Revision.author == model.User.openid )).label('number_of_edits'), - _select([_func.count(model.UserObjectRole.id)], - _and_( - model.UserObjectRole.user_id == model.User.id, - model.UserObjectRole.context == 'Package', - model.UserObjectRole.role == 'admin' - )).label('number_administered_packages') + #_select([_func.count(model.UserObjectRole.id)], + # _and_( + # model.UserObjectRole.user_id == model.User.id, + # model.UserObjectRole.context == 'Package', + # model.UserObjectRole.role == 'admin' + # )).label('number_administered_packages') ) if q: @@ -1334,10 +1334,11 @@ def user_show(context, data_dict): user_dict['activity'] = revisions_list user_dict['datasets'] = [] - dataset_q = (model.Session.query(model.Package) - .join(model.PackageRole) - .filter_by(user=user_obj, role=model.Role.ADMIN) - .limit(50)) + #dataset_q = (model.Session.query(model.Package) + # .join(model.PackageRole) + # .filter_by(user=user_obj, role=model.Role.ADMIN) + # .limit(50)) + dataset_q = [] for dataset in dataset_q: try: @@ -2195,60 +2196,6 @@ def get_site_user(context, data_dict): 'apikey': user.apikey} -def roles_show(context, data_dict): - '''Return the roles of all users and authorization groups for an object. - - :param domain_object: a package or group name or id - to filter the results by - :type domain_object: string - :param user: a user name or id - :type user: string - - :rtype: list of dictionaries - - ''' - model = context['model'] - session = context['session'] - domain_object_ref = _get_or_bust(data_dict, 'domain_object') - user_ref = data_dict.get('user') - - domain_object = ckan.logic.action.get_domain_object( - model, domain_object_ref) - if isinstance(domain_object, model.Package): - query = session.query(model.PackageRole).join('package') - elif isinstance(domain_object, model.Group): - query = session.query(model.GroupRole).join('group') - elif domain_object is model.System: - query = session.query(model.SystemRole) - else: - raise NotFound(_('Cannot list entity of this type: %s') - % type(domain_object).__name__) - # Filter by the domain_obj (apart from if it is the system object) - if not isinstance(domain_object, type): - query = query.filter_by(id=domain_object.id) - - # Filter by the user - if user_ref: - user = model.User.get(user_ref) - if not user: - raise NotFound(_('unknown user:') + repr(user_ref)) - query = query.join('user').filter_by(id=user.id) - - uors = query.all() - - uors_dictized = [_table_dictize(uor, context) for uor in uors] - - result = { - 'domain_object_type': type(domain_object).__name__, - 'domain_object_id': - domain_object.id if domain_object != model.System else None, - 'roles': uors_dictized} - if user_ref: - result['user'] = user.id - - return result - - def status_show(context, data_dict): '''Return a dictionary with information about the site's configuration. diff --git a/ckan/logic/auth/create.py b/ckan/logic/auth/create.py index d7f9f598cb2..c0ad3b42930 100644 --- a/ckan/logic/auth/create.py +++ b/ckan/logic/auth/create.py @@ -214,7 +214,7 @@ def _check_group_auth(context, data_dict): def package_create_rest(context, data_dict): model = context['model'] user = context['user'] - if user in (model.PSEUDO_USER__VISITOR, ''): + if not user: return {'success': False, 'msg': _('Valid API key needed to create a package')} return package_create(context, data_dict) @@ -222,7 +222,7 @@ def package_create_rest(context, data_dict): def group_create_rest(context, data_dict): model = context['model'] user = context['user'] - if user in (model.PSEUDO_USER__VISITOR, ''): + if not user: return {'success': False, 'msg': _('Valid API key needed to create a group')} return group_create(context, data_dict) diff --git a/ckan/logic/auth/update.py b/ckan/logic/auth/update.py index 19dc1cb3d28..045fb60ddfe 100644 --- a/ckan/logic/auth/update.py +++ b/ckan/logic/auth/update.py @@ -276,7 +276,7 @@ def send_email_notifications(context, data_dict): def package_update_rest(context, data_dict): model = context['model'] user = context['user'] - if user in (model.PSEUDO_USER__VISITOR, ''): + if not user: return {'success': False, 'msg': _('Valid API key needed to edit a package')} @@ -286,7 +286,7 @@ def package_update_rest(context, data_dict): def group_update_rest(context, data_dict): model = context['model'] user = context['user'] - if user in (model.PSEUDO_USER__VISITOR, ''): + if not user: return {'success': False, 'msg': _('Valid API key needed to edit a group')} diff --git a/ckan/migration/versions/075_remove_old_authz_model.py b/ckan/migration/versions/075_remove_old_authz_model.py new file mode 100644 index 00000000000..8896afb2d6f --- /dev/null +++ b/ckan/migration/versions/075_remove_old_authz_model.py @@ -0,0 +1,14 @@ +import ckan.model + + +def upgrade(migrate_engine): + migrate_engine.execute( + ''' + DROP TABLE "role_action"; + DROP TABLE "package_role"; + DROP TABLE "group_role"; + DROP TABLE "system_role"; + DROP TABLE "authorization_group_role"; + DROP TABLE "user_object_role"; + ''' + ) diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py index 41b52729469..bb53a18b26d 100644 --- a/ckan/model/__init__.py +++ b/ckan/model/__init__.py @@ -44,28 +44,6 @@ User, user_table, ) -from authz import ( - NotRealUserException, - Enum, - Action, - Role, - RoleAction, - UserObjectRole, - PackageRole, - GroupRole, - SystemRole, - PSEUDO_USER__VISITOR, - PSEUDO_USER__LOGGED_IN, - init_authz_const_data, - init_authz_configuration_data, - add_user_to_role, - setup_user_roles, - setup_default_user_roles, - give_all_packages_default_user_roles, - user_has_role, - remove_user_from_role, - clear_user_roles, -) from group import ( Member, Group, @@ -239,22 +217,9 @@ def clean_db(self): self.tables_created_and_initialised = False log.info('Database tables dropped') - def init_const_data(self): - '''Creates 'constant' objects that should always be there in - the database. If they are already there, this method does nothing.''' - for username in (PSEUDO_USER__LOGGED_IN, - PSEUDO_USER__VISITOR): - if not User.by_name(username): - user = User(name=username) - meta.Session.add(user) - meta.Session.flush() # so that these objects can be used - # straight away - init_authz_const_data() - def init_configuration_data(self): '''Default configuration, for when CKAN is first used out of the box. This state may be subsequently configured by the user.''' - init_authz_configuration_data() if meta.Session.query(Revision).count() == 0: rev = Revision() rev.author = 'system' @@ -268,7 +233,6 @@ def create_db(self): has shortcuts. ''' self.metadata.create_all(bind=self.metadata.bind) - self.init_const_data() self.init_configuration_data() log.info('Database tables created') @@ -283,7 +247,6 @@ def rebuild_db(self): # just delete data, leaving tables - this is faster self.delete_all() # re-add default data - self.init_const_data() self.init_configuration_data() self.session.commit() else: @@ -336,8 +299,6 @@ def upgrade_db(self, version=None): else: log.info('CKAN database version remains as: %s', version_after) - self.init_const_data() - ##this prints the diffs in a readable format ##import pprint ##from migrate.versioning.schemadiff import getDiffOfModelAgainstDatabase diff --git a/ckan/model/authz.py b/ckan/model/authz.py deleted file mode 100644 index d42c08f49e3..00000000000 --- a/ckan/model/authz.py +++ /dev/null @@ -1,432 +0,0 @@ -'''For an overview of CKAN authorization system and model see -doc/authorization.rst. - -''' -import simplejson as json -import weakref - -from sqlalchemy import orm, types, Column, Table, ForeignKey -from pylons import config - -import meta -import core -import domain_object -import package as _package -import group -import user as _user -import types as _types - -__all__ = ['NotRealUserException', 'Enum', 'Action', 'Role', 'RoleAction', - 'UserObjectRole', 'PackageRole', 'GroupRole', - 'SystemRole', 'PSEUDO_USER__VISITOR', - 'PSEUDO_USER__LOGGED_IN', 'init_authz_const_data', - 'init_authz_configuration_data', 'add_user_to_role', - 'setup_user_roles', 'setup_default_user_roles', - 'give_all_packages_default_user_roles', - 'user_has_role', 'remove_user_from_role', 'clear_user_roles'] - -PSEUDO_USER__LOGGED_IN = u'logged_in' -PSEUDO_USER__VISITOR = u'visitor' - -class NotRealUserException(Exception): - pass - -## ====================================== -## Action and Role Enums - -class Enum(object): - @classmethod - def is_valid(cls, val): - return val in cls.get_all() - - @classmethod - def get_all(cls): - if not hasattr(cls, '_all_items'): - vals = [] - for key, val in cls.__dict__.items(): - if not key.startswith('_'): - vals.append(val) - cls._all_items = vals - return cls._all_items - -class Action(Enum): - EDIT = u'edit' - CHANGE_STATE = u'change-state' - READ = u'read' - PURGE = u'purge' - EDIT_PERMISSIONS = u'edit-permissions' - PACKAGE_CREATE = u'create-package' - GROUP_CREATE = u'create-group' - SITE_READ = u'read-site' - USER_READ = u'read-user' - USER_CREATE = u'create-user' - UPLOAD_ACTION = u'file-upload' - -class Role(Enum): - ADMIN = u'admin' - EDITOR = u'editor' - ANON_EDITOR = u'anon_editor' - READER = u'reader' - -# These define what is meant by 'editor' and 'reader' for all ckan -# instances - locked down or otherwise. They get refreshed on every db_upgrade. -# So if you want to lock down an ckan instance, change Visitor and LoggedIn -# to have a new role which for which you can allow your customised actions. -default_role_actions = [ - (Role.EDITOR, Action.EDIT), - (Role.EDITOR, Action.PACKAGE_CREATE), - (Role.EDITOR, Action.GROUP_CREATE), - (Role.EDITOR, Action.USER_CREATE), - (Role.EDITOR, Action.USER_READ), - (Role.EDITOR, Action.SITE_READ), - (Role.EDITOR, Action.READ), - (Role.EDITOR, Action.UPLOAD_ACTION), - (Role.ANON_EDITOR, Action.EDIT), - (Role.ANON_EDITOR, Action.PACKAGE_CREATE), - (Role.ANON_EDITOR, Action.USER_CREATE), - (Role.ANON_EDITOR, Action.USER_READ), - (Role.ANON_EDITOR, Action.SITE_READ), - (Role.ANON_EDITOR, Action.READ), - (Role.ANON_EDITOR, Action.UPLOAD_ACTION), - (Role.READER, Action.USER_CREATE), - (Role.READER, Action.USER_READ), - (Role.READER, Action.SITE_READ), - (Role.READER, Action.READ), - ] - - -## ====================================== -## Table Definitions - -role_action_table = Table('role_action', meta.metadata, - Column('id', types.UnicodeText, primary_key=True, default=_types.make_uuid), - Column('role', types.UnicodeText), - Column('context', types.UnicodeText, nullable=False), - Column('action', types.UnicodeText), - ) - -user_object_role_table = Table('user_object_role', meta.metadata, - Column('id', types.UnicodeText, primary_key=True, default=_types.make_uuid), - Column('user_id', types.UnicodeText, ForeignKey('user.id'), nullable=True), -# Column('authorized_group_id', types.UnicodeText, ForeignKey('authorization_group.id'), nullable=True), - Column('context', types.UnicodeText, nullable=False), # stores subtype - Column('role', types.UnicodeText) - ) - -package_role_table = Table('package_role', meta.metadata, - Column('user_object_role_id', types.UnicodeText, ForeignKey('user_object_role.id'), primary_key=True), - Column('package_id', types.UnicodeText, ForeignKey('package.id')), - ) - -group_role_table = Table('group_role', meta.metadata, - Column('user_object_role_id', types.UnicodeText, ForeignKey('user_object_role.id'), primary_key=True), - Column('group_id', types.UnicodeText, ForeignKey('group.id')), - ) - -system_role_table = Table('system_role', meta.metadata, - Column('user_object_role_id', types.UnicodeText, ForeignKey('user_object_role.id'), primary_key=True), - ) - - -class RoleAction(domain_object.DomainObject): - def __repr__(self): - return '<%s role="%s" action="%s" context="%s">' % \ - (self.__class__.__name__, self.role, self.action, self.context) - - -# dictionary mapping protected objects (e.g. Package) to related ObjectRole -protected_objects = {} - -class UserObjectRole(domain_object.DomainObject): - name = None - protected_object = None - - def __repr__(self): - if self.user: - return '<%s user="%s" role="%s" context="%s">' % \ - (self.__class__.__name__, self.user.name, self.role, self.context) - else: - assert False, "UserObjectRole is not a user" - - @classmethod - def get_object_role_class(cls, domain_obj): - protected_object = protected_objects.get(domain_obj.__class__, None) - if protected_object is None: - # TODO: make into an authz exception - msg = '%s is not a protected object, i.e. a subject of authorization' % domain_obj - raise Exception(msg) - else: - return protected_object - - @classmethod - def user_has_role(cls, user, role, domain_obj): - assert isinstance(user, _user.User), user - q = cls._user_query(user, role, domain_obj) - return q.count() == 1 - - - @classmethod - def _user_query(cls, user, role, domain_obj): - q = meta.Session.query(cls).filter_by(role=role) - # some protected objects are not "contextual" - if cls.name is not None: - # e.g. filter_by(package=domain_obj) - q = q.filter_by(**dict({cls.name: domain_obj})) - q = q.filter_by(user=user) - return q - - - @classmethod - def add_user_to_role(cls, user, role, domain_obj): - '''NB: Leaves the caller to commit the change. If called twice without a - commit, will add the role to the database twice. Since some other - functions count the number of occurrences, that leaves a fairly obvious - bug. But adding a commit here seems to break various tests. - So don't call this twice without committing, I guess... - ''' - # Here we're trying to guard against adding the same role twice, but - # that won't work if the transaction hasn't been committed yet, which allows a role to be added twice (you can do this from the interface) - if cls.user_has_role(user, role, domain_obj): - return - objectrole = cls(role=role, user=user) - if cls.name is not None: - setattr(objectrole, cls.name, domain_obj) - meta.Session.add(objectrole) - - - @classmethod - def remove_user_from_role(cls, user, role, domain_obj): - q = cls._user_query(user, role, domain_obj) - for uo_role in q.all(): - meta.Session.delete(uo_role) - meta.Session.commit() - meta.Session.remove() - - -class PackageRole(UserObjectRole): - protected_object = _package.Package - name = 'package' - - def __repr__(self): - if self.user: - return '<%s user="%s" role="%s" package="%s">' % \ - (self.__class__.__name__, self.user.name, self.role, self.package.name) - else: - assert False, "%s is not a user" % self.__class__.__name__ - -protected_objects[PackageRole.protected_object] = PackageRole - -class GroupRole(UserObjectRole): - protected_object = group.Group - name = 'group' - - def __repr__(self): - if self.user: - return '<%s user="%s" role="%s" group="%s">' % \ - (self.__class__.__name__, self.user.name, self.role, self.group.name) - else: - assert False, "%s is not a user" % self.__class__.__name__ - -protected_objects[GroupRole.protected_object] = GroupRole - - -class SystemRole(UserObjectRole): - protected_object = core.System - name = None -protected_objects[SystemRole.protected_object] = SystemRole - - - -## ====================================== -## Helpers - - -def user_has_role(user, role, domain_obj): - objectrole = UserObjectRole.get_object_role_class(domain_obj) - return objectrole.user_has_role(user, role, domain_obj) - -def add_user_to_role(user, role, domain_obj): - objectrole = UserObjectRole.get_object_role_class(domain_obj) - objectrole.add_user_to_role(user, role, domain_obj) - -def remove_user_from_role(user, role, domain_obj): - objectrole = UserObjectRole.get_object_role_class(domain_obj) - objectrole.remove_user_from_role(user, role, domain_obj) - - -def init_authz_configuration_data(): - setup_default_user_roles(core.System()) - meta.Session.commit() - meta.Session.remove() - -def init_authz_const_data(): - '''Setup all default role-actions. - - These should be the same for all CKAN instances. Make custom roles if - you want to divert from these. - - Note that Role.ADMIN can already do anything - hardcoded in. - - ''' - for role, action in default_role_actions: - ra = meta.Session.query(RoleAction).filter_by(role=role, action=action).first() - if ra is not None: continue - ra = RoleAction(role=role, context=u'', action=action) - meta.Session.add(ra) - meta.Session.commit() - meta.Session.remove() - -## TODO: this should be removed -def setup_user_roles(_domain_object, visitor_roles, logged_in_roles, admins=[]): - '''NB: leaves caller to commit change''' - assert type(admins) == type([]) - admin_roles = [Role.ADMIN] - visitor = _user.User.by_name(PSEUDO_USER__VISITOR) - assert visitor - for role in visitor_roles: - add_user_to_role(visitor, role, _domain_object) - logged_in = _user.User.by_name(PSEUDO_USER__LOGGED_IN) - assert logged_in - for role in logged_in_roles: - add_user_to_role(logged_in, role, _domain_object) - for admin in admins: - # not sure if admin would reasonably by None - if admin is not None: - assert isinstance(admin, _user.User), admin - if admin.name in (PSEUDO_USER__LOGGED_IN, PSEUDO_USER__VISITOR): - raise NotRealUserException('Invalid user for domain object admin %r' % admin.name) - for role in admin_roles: - add_user_to_role(admin, role, _domain_object) - -def give_all_packages_default_user_roles(): - # if this command gives an exception, you probably - # forgot to do 'paster db init' - pkgs = meta.Session.query(_package.Package).all() - - for pkg in pkgs: - print pkg - # weird - should already be in session but complains w/o this - meta.Session.add(pkg) - if len(pkg.roles) > 0: - print 'Skipping (already has roles): %s' % pkg.name - continue - # work out the authors and make them admins - admins = [] - revs = pkg.all_revisions - for rev in revs: - if rev.revision.author: - # rev author is not Unicode!! - user = _user.User.by_name(unicode(rev.revision.author)) - if user: - admins.append(user) - # remove duplicates - admins = list(set(admins)) - # gives default permissions - print 'Creating default user for for %s with admins %s' % (pkg.name, admins) - setup_default_user_roles(pkg, admins) - -# default user roles - used when the config doesn\'t specify them -default_default_user_roles = { - 'Package': {"visitor": ["reader"], "logged_in": ["reader"]}, - 'Group': {"visitor": ["reader"], "logged_in": ["reader"]}, - 'System': {"visitor": ["reader"], "logged_in": ["editor"]}, - } - -global _default_user_roles_cache -_default_user_roles_cache = weakref.WeakKeyDictionary() - -def get_default_user_roles(_domain_object): - # TODO: Should this func go in lib rather than model now? - def _get_default_user_roles(_domain_object): - config_key = 'ckan.default_roles.%s' % obj_type - user_roles_json = config.get(config_key) - if user_roles_json is None: - user_roles_str = default_default_user_roles[obj_type] - else: - user_roles_str = json.loads(user_roles_json) if user_roles_json else {} - unknown_keys = set(user_roles_str.keys()) - set(('visitor', 'logged_in')) - assert not unknown_keys, 'Auth config for %r has unknown key %r' % \ - (_domain_object, unknown_keys) - user_roles_ = {} - for user in ('visitor', 'logged_in'): - roles_str = user_roles_str.get(user, []) - user_roles_[user] = [getattr(Role, role_str.upper()) for role_str in roles_str] - return user_roles_ - obj_type = _domain_object.__class__.__name__ - global _default_user_roles_cache - if not _default_user_roles_cache.has_key(_domain_object): - _default_user_roles_cache[_domain_object] = _get_default_user_roles(_domain_object) - return _default_user_roles_cache[_domain_object] - -def setup_default_user_roles(_domain_object, admins=[]): - ''' sets up roles for visitor, logged-in user and any admins provided - @param admins - a list of User objects - NB: leaves caller to commit change. - ''' - assert isinstance(_domain_object, (_package.Package, group.Group, core.System)), _domain_object - assert isinstance(admins, list) - user_roles_ = get_default_user_roles(_domain_object) - setup_user_roles(_domain_object, - user_roles_['visitor'], - user_roles_['logged_in'], - admins) - -def clear_user_roles(_domain_object): - assert isinstance(_domain_object, domain_object.DomainObject) - if isinstance(_domain_object, _package.Package): - q = meta.Session.query(PackageRole).filter_by(package=_domain_object) - elif isinstance(_domain_object, group.Group): - q = meta.Session.query(GroupRole).filter_by(group=_domain_object) - else: - raise NotImplementedError() - user_roles = q.all() - for user_role in user_roles: - meta.Session.delete(user_role) - - -## ====================================== -## Mappers - -meta.mapper(RoleAction, role_action_table) - -meta.mapper(UserObjectRole, user_object_role_table, - polymorphic_on=user_object_role_table.c.context, - polymorphic_identity=u'user_object', - properties={ - 'user': orm.relation(_user.User, - backref=orm.backref('roles', - cascade='all, delete, delete-orphan' - ) - ) - }, - order_by=[user_object_role_table.c.id], -) - -meta.mapper(PackageRole, package_role_table, inherits=UserObjectRole, - polymorphic_identity=unicode(_package.Package.__name__), - properties={ - 'package': orm.relation(_package.Package, - backref=orm.backref('roles', - cascade='all, delete, delete-orphan' - ) - ), - }, - order_by=[package_role_table.c.user_object_role_id], -) - -meta.mapper(GroupRole, group_role_table, inherits=UserObjectRole, - polymorphic_identity=unicode(group.Group.__name__), - properties={ - 'group': orm.relation(group.Group, - backref=orm.backref('roles', - cascade='all, delete, delete-orphan' - ), - ) - }, - order_by=[group_role_table.c.user_object_role_id], -) - -meta.mapper(SystemRole, system_role_table, inherits=UserObjectRole, - polymorphic_identity=unicode(core.System.__name__), - order_by=[system_role_table.c.user_object_role_id], -) diff --git a/ckan/model/user.py b/ckan/model/user.py index e937acee587..a94f08f77a4 100644 --- a/ckan/model/user.py +++ b/ckan/model/user.py @@ -199,9 +199,10 @@ def number_of_edits(self): def number_administered_packages(self): # have to import here to avoid circular imports import ckan.model as model - q = meta.Session.query(model.PackageRole) - q = q.filter_by(user=self, role=model.Role.ADMIN) - return q.count() + #q = meta.Session.query(model.PackageRole) + #q = q.filter_by(user=self, role=model.Role.ADMIN) + #return q.count() + return 0 def activate(self): ''' Activate the user ''' diff --git a/ckan/tests/functional/api/model/test_group.py b/ckan/tests/functional/api/model/test_group.py index a284ebaa11d..142ff118990 100644 --- a/ckan/tests/functional/api/model/test_group.py +++ b/ckan/tests/functional/api/model/test_group.py @@ -113,7 +113,6 @@ def test_10_edit_group(self): assert group assert len(group.member_all) == 3, group.member_all user = model.User.by_name(self.user_name) - model.setup_default_user_roles(group, [user]) # edit it group_vals = {'name':u'somethingnew', 'title':u'newtesttitle', @@ -143,7 +142,6 @@ def test_10_edit_group_name_duplicate(self): model.Session.commit() group = model.Group.by_name(self.testgroupvalues['name']) - model.setup_default_user_roles(group, [self.user]) rev = model.repo.new_revision() model.repo.commit_and_remove() assert model.Group.by_name(self.testgroupvalues['name']) @@ -181,11 +179,9 @@ def test_11_delete_group(self): rev = model.repo.new_revision() group = model.Group.by_name(self.testgroupvalues['name']) - model.setup_default_user_roles(group, [self.user]) model.repo.commit_and_remove() assert group user = model.User.by_name(self.user_name) - model.setup_default_user_roles(group, [user]) # delete it offset = self.group_offset(self.testgroupvalues['name']) diff --git a/ckan/tests/functional/api/model/test_package.py b/ckan/tests/functional/api/model/test_package.py index 3354cc71089..d4c9ef7a741 100644 --- a/ckan/tests/functional/api/model/test_package.py +++ b/ckan/tests/functional/api/model/test_package.py @@ -38,9 +38,6 @@ def get_groups_identifiers(self, test_groups, users=[]): groups.append(group.name) else: groups.append(group.id) - - if users: - model.setup_default_user_roles(group, users) return groups def test_register_get_ok(self): @@ -834,12 +831,6 @@ def test_10_edit_pkg_with_download_url(self): pkg.download_url = test_params['download_url'] model.Session.commit() - pkg = self.get_package_by_name(test_params['name']) - model.setup_default_user_roles(pkg, [self.user]) - rev = model.repo.new_revision() - model.repo.commit_and_remove() - assert self.get_package_by_name(test_params['name']) - # edit it pkg_vals = {'download_url':u'newurl'} offset = self.package_offset(test_params['name']) diff --git a/ckan/tests/functional/test_admin.py b/ckan/tests/functional/test_admin.py index 4be776c0bf6..a5fb50ae199 100644 --- a/ckan/tests/functional/test_admin.py +++ b/ckan/tests/functional/test_admin.py @@ -25,127 +25,3 @@ def test_index(self): extra_environ={'REMOTE_USER': username}) assert 'Administration' in response, response -## This is no longer used -class _TestAdminAuthzController(WsgiAppCase): - @classmethod - def setup_class(cls): - # setup test data including testsysadmin user - CreateTestData.create() - model.Session.commit() - - @classmethod - def teardown_class(self): - model.repo.rebuild_db() - - def test_role_table(self): - - #logged in as testsysadmin for all actions - as_testsysadmin = {'REMOTE_USER': 'testsysadmin'} - - def get_system_user_roles(): - sys_query=model.Session.query(model.SystemRole) - return sorted([(x.user.name,x.role) for x in sys_query.all() if x.user]) - - def get_response(): - response = self.app.get( - url_for('ckanadmin', action='authz'), - extra_environ=as_testsysadmin) - assert 'Administration - Authorization' in response, response - return response - - def get_user_form(): - response = get_response() - return response.forms['theform'] - - - def check_and_set_checkbox(theform, user, role, should_be, set_to): - user_role_string = '%s$%s' % (user, role) - checkboxes = [x for x in theform.fields[user_role_string] \ - if x.__class__.__name__ == 'Checkbox'] - - assert(len(checkboxes)==1), \ - "there should only be one checkbox for %s/%s" % (user, role) - checkbox = checkboxes[0] - - #checkbox should be unticked - assert checkbox.checked==should_be, \ - "%s/%s checkbox in unexpected state" % (user, role) - - #tick or untick the box and submit the form - checkbox.checked=set_to - return theform - - def submit(form): - return form.submit('save', extra_environ=as_testsysadmin) - - def authz_submit(form): - return form.submit('authz_save', extra_environ=as_testsysadmin) - - # get and store the starting state of the system roles - original_user_roles = get_system_user_roles() - - # before we start changing things, check that the roles on the system are as expected - assert original_user_roles == \ - [(u'logged_in', u'editor'), (u'testsysadmin', u'admin'), (u'visitor', u'reader')] , \ - "original user roles not as expected " + str(original_user_roles) - - - # visitor is not an admin. check that his admin box is unticked, tick it, and submit - submit(check_and_set_checkbox(get_user_form(), u'visitor', u'admin', False, True)) - - # try again, this time we expect the box to be ticked already - submit(check_and_set_checkbox(get_user_form(), u'visitor', u'admin', True, True)) - - # put it back how it was - submit(check_and_set_checkbox(get_user_form(), u'visitor', u'admin', True, False)) - - # should be back to our starting state - assert original_user_roles == get_system_user_roles() - - - # change lots of things - form = get_user_form() - check_and_set_checkbox(form, u'visitor', u'editor', False, True) - check_and_set_checkbox(form, u'visitor', u'reader', True, False) - check_and_set_checkbox(form, u'logged_in', u'editor', True, False) - check_and_set_checkbox(form, u'logged_in', u'reader', False, True) - submit(form) - - roles=get_system_user_roles() - # and assert that they've actually changed - assert (u'visitor', u'editor') in roles and \ - (u'logged_in', u'editor') not in roles and \ - (u'logged_in', u'reader') in roles and \ - (u'visitor', u'reader') not in roles, \ - "visitor and logged_in roles seem not to have reversed" - - - def get_roles_by_name(user=None, group=None): - if user: - return [y for (x,y) in get_system_user_roles() if x==user] - else: - assert False, 'miscalled' - - - # now we test the box for giving roles to an arbitrary user - - # check that tester doesn't have a system role - assert len(get_roles_by_name(user=u'tester'))==0, \ - "tester should not have roles" - - # get the put tester in the username box - form = get_response().forms['addform'] - form.fields['new_user_name'][0].value='tester' - # get the admin checkbox - checkbox = [x for x in form.fields['admin'] \ - if x.__class__.__name__ == 'Checkbox'][0] - # check it's currently unticked - assert checkbox.checked == False - # tick it and submit - checkbox.checked=True - response = form.submit('add', extra_environ=as_testsysadmin) - assert "User Added" in response, "don't see flash message" - - assert get_roles_by_name(user=u'tester') == ['admin'], \ - "tester should be an admin now" - diff --git a/ckan/tests/functional/test_group.py b/ckan/tests/functional/test_group.py index 20a511e57b2..4cb3268ba7c 100644 --- a/ckan/tests/functional/test_group.py +++ b/ckan/tests/functional/test_group.py @@ -189,7 +189,6 @@ def setup_class(self): self.grp = model.Group(name=self.name) self.grp.description = self.description[0] model.Session.add(self.grp) - model.setup_default_user_roles(self.grp) model.repo.commit_and_remove() # edit pkg diff --git a/ckan/tests/functional/test_package.py b/ckan/tests/functional/test_package.py index d19ac86b739..c2f5b8fb356 100644 --- a/ckan/tests/functional/test_package.py +++ b/ckan/tests/functional/test_package.py @@ -285,7 +285,6 @@ def setup_class(cls): rev.timestamp = cls.date1 pkg = model.Package(name=cls.pkg_name, title=u'title1') model.Session.add(pkg) - model.setup_default_user_roles(pkg) model.repo.commit_and_remove() # edit dataset @@ -692,7 +691,6 @@ def setup_class(self): pkg = model.Session.query(model.Package).filter_by(name=self.non_active_name).one() admin = model.User.by_name(u'joeadmin') - model.setup_default_user_roles(pkg, [admin]) model.repo.commit_and_remove() model.repo.new_revision() @@ -728,7 +726,6 @@ def setup_class(cls): cls.pkg1 = model.Package(name=cls.name) cls.pkg1.notes = cls.notes[0] model.Session.add(cls.pkg1) - model.setup_default_user_roles(cls.pkg1) model.repo.commit_and_remove() # edit pkg diff --git a/ckan/tests/functional/test_pagination.py b/ckan/tests/functional/test_pagination.py index a245256119e..5cc170f3f3b 100644 --- a/ckan/tests/functional/test_pagination.py +++ b/ckan/tests/functional/test_pagination.py @@ -112,10 +112,6 @@ def test_group_index(self): class TestPaginationUsers(TestController): @classmethod def setup_class(cls): - # Delete default user as it appears in the first page of results - model.User.by_name(u'logged_in').purge() - model.repo.commit_and_remove() - # no. entities per page is hardcoded into the controllers, so # create enough of each here so that we can test pagination cls.num_users = 21 @@ -132,7 +128,6 @@ def teardown_class(self): model.repo.rebuild_db() def test_users_index(self): - # allow for 2 extra users shown on user listing, 'logged_in' and 'visitor' res = self.app.get(url_for(controller='user', action='index')) assert 'href="/user?q=&order_by=name&page=2"' in res user_numbers = scrape_search_results(res, 'user') diff --git a/ckan/tests/logic/test_action.py b/ckan/tests/logic/test_action.py index 1e2f3a4fe48..98d339981f3 100644 --- a/ckan/tests/logic/test_action.py +++ b/ckan/tests/logic/test_action.py @@ -337,7 +337,7 @@ def test_04_user_list(self): res_obj = json.loads(res.body) assert "/api/3/action/help_show?name=user_list" in res_obj['help'] assert res_obj['success'] == True - assert len(res_obj['result']) == 7 + assert len(res_obj['result']) == 5, len(res_obj['result']) assert res_obj['result'][0]['name'] == 'annafan' assert res_obj['result'][0]['about'] == 'I love reading Annakarenina. My site: http://anna.com' assert not 'apikey' in res_obj['result'][0] @@ -405,9 +405,10 @@ def test_05b_user_show_datasets(self): res_obj = json.loads(res.body) result = res_obj['result'] datasets = result['datasets'] - assert_equal(len(datasets), 1) - dataset = result['datasets'][0] - assert_equal(dataset['name'], u'annakarenina') + # FIXME: + #assert_equal(len(datasets), 1) + #dataset = result['datasets'][0] + #assert_equal(dataset['name'], u'annakarenina') def test_10_user_create_parameters_missing(self): @@ -884,114 +885,6 @@ def test_32_get_domain_object(self): assert_equal(get_domain_object(model, group.name).name, group.name) assert_equal(get_domain_object(model, group.id).name, group.name) - def test_33_roles_show(self): - anna = model.Package.by_name(u'annakarenina') - annafan = model.User.by_name(u'annafan') - postparams = '%s=1' % json.dumps({'domain_object': anna.id}) - res = self.app.post('/api/action/roles_show', params=postparams, - extra_environ={'Authorization': str(annafan.apikey)}, - status=200) - results = json.loads(res.body)['result'] - anna = model.Package.by_name(u'annakarenina') - assert_equal(results['domain_object_id'], anna.id) - assert_equal(results['domain_object_type'], 'Package') - roles = results['roles'] - assert len(roles) > 2, results - assert set(roles[0].keys()) > set(('user_id', 'package_id', 'role', - 'context', 'user_object_role_id')) - - def test_34_roles_show_for_user(self): - anna = model.Package.by_name(u'annakarenina') - annafan = model.User.by_name(u'annafan') - postparams = '%s=1' % json.dumps({'domain_object': anna.id, - 'user': 'annafan'}) - res = self.app.post('/api/action/roles_show', params=postparams, - extra_environ={'Authorization': str(annafan.apikey)}, - status=200) - results = json.loads(res.body)['result'] - anna = model.Package.by_name(u'annakarenina') - assert_equal(results['domain_object_id'], anna.id) - assert_equal(results['domain_object_type'], 'Package') - roles = results['roles'] - assert_equal(len(roles), 1) - assert set(roles[0].keys()) > set(('user_id', 'package_id', 'role', - 'context', 'user_object_role_id')) - - - def test_35_user_role_update(self): - anna = model.Package.by_name(u'annakarenina') - annafan = model.User.by_name(u'annafan') - roles_before = get_action('roles_show') \ - ({'model': model, 'session': model.Session}, \ - {'domain_object': anna.id, - 'user': 'tester'}) - postparams = '%s=1' % json.dumps({'user': 'tester', - 'domain_object': anna.id, - 'roles': ['reader']}) - - res = self.app.post('/api/action/user_role_update', params=postparams, - extra_environ={'Authorization': str(annafan.apikey)}, - status=200) - results = json.loads(res.body)['result'] - assert_equal(len(results['roles']), 1) - anna = model.Package.by_name(u'annakarenina') - tester = model.User.by_name(u'tester') - assert_equal(results['roles'][0]['role'], 'reader') - assert_equal(results['roles'][0]['package_id'], anna.id) - assert_equal(results['roles'][0]['user_id'], tester.id) - - roles_after = get_action('roles_show') \ - ({'model': model, 'session': model.Session}, \ - {'domain_object': anna.id, - 'user': 'tester'}) - assert_equal(results['roles'], roles_after['roles']) - - - def test_37_user_role_update_disallowed(self): - # Roles are no longer used so ignore this test - raise SkipTest - anna = model.Package.by_name(u'annakarenina') - postparams = '%s=1' % json.dumps({'user': 'tester', - 'domain_object': anna.id, - 'roles': ['editor']}) - # tester has no admin priviledges for this package - res = self.app.post('/api/action/user_role_update', params=postparams, - extra_environ={'Authorization': 'tester'}, - status=403) - - def test_38_user_role_bulk_update(self): - anna = model.Package.by_name(u'annakarenina') - annafan = model.User.by_name(u'annafan') - all_roles_before = TestRoles.get_roles(anna.id) - user_roles_before = TestRoles.get_roles(anna.id, user_ref=annafan.name) - roles_before = get_action('roles_show') \ - ({'model': model, 'session': model.Session}, \ - {'domain_object': anna.id}) - postparams = '%s=1' % json.dumps({'domain_object': anna.id, - 'user_roles': [ - {'user': 'annafan', - 'roles': ('admin', 'editor')}, - {'user': 'russianfan', - 'roles': ['editor']}, - ]}) - - res = self.app.post('/api/action/user_role_bulk_update', params=postparams, - extra_environ={'Authorization': str(annafan.apikey)}, - status=200) - results = json.loads(res.body)['result'] - - # check there are 2 new roles (not 3 because annafan is already admin) - all_roles_after = TestRoles.get_roles(anna.id) - user_roles_after = TestRoles.get_roles(anna.id, user_ref=annafan.name) - assert_equal(set(all_roles_before) ^ set(all_roles_after), - set([u'"annafan" is "editor" on "annakarenina"', - u'"russianfan" is "editor" on "annakarenina"'])) - - roles_after = get_action('roles_show') \ - ({'model': model, 'session': model.Session}, \ - {'domain_object': anna.id}) - assert_equal(results['roles'], roles_after['roles']) - def test_40_task_resource_status(self): try: diff --git a/ckan/tests/models/test_user.py b/ckan/tests/models/test_user.py index bd34641b936..ee1970b2926 100644 --- a/ckan/tests/models/test_user.py +++ b/ckan/tests/models/test_user.py @@ -18,7 +18,7 @@ def setup_class(self): @classmethod def teardown_class(self): model.repo.rebuild_db() - + def test_0_basic(self): out = model.User.by_name(u'brian') assert_equal(out.name, u'brian') @@ -116,11 +116,11 @@ def setup_class(self): capacity='admin') ) model.repo.commit_and_remove() - + @classmethod def teardown_class(self): model.repo.rebuild_db() - + def test_get_groups(self): brian = model.User.by_name(u'brian') groups = brian.get_groups() @@ -169,11 +169,11 @@ def test_number_of_edits(self): "annafan should have made %i edit(s)" % i - def test_number_of_administered_packages(self): - model.User.by_name(u'annafan').number_administered_packages() == 1, \ - "annafan should own one package" - model.User.by_name(u'joeadmin').number_administered_packages() == 0, \ - "joeadmin shouldn't own any packages" + #def test_number_of_administered_packages(self): + # model.User.by_name(u'annafan').number_administered_packages() == 1, \ + # "annafan should own one package" + # model.User.by_name(u'joeadmin').number_administered_packages() == 0, \ + # "joeadmin shouldn't own any packages" def test_search(self): From 5578c4254eb821779cdec971d3ae62347929a479 Mon Sep 17 00:00:00 2001 From: Ross Jones Date: Tue, 9 Dec 2014 17:46:28 +0000 Subject: [PATCH 013/130] Remove unused init_configuration_data --- ckan/model/__init__.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py index bb53a18b26d..478816a097d 100644 --- a/ckan/model/__init__.py +++ b/ckan/model/__init__.py @@ -202,7 +202,6 @@ def init_db(self): except ImportError: pass - self.init_configuration_data() self.tables_created_and_initialised = True log.info('Database initialised') @@ -217,23 +216,12 @@ def clean_db(self): self.tables_created_and_initialised = False log.info('Database tables dropped') - def init_configuration_data(self): - '''Default configuration, for when CKAN is first used out of the box. - This state may be subsequently configured by the user.''' - if meta.Session.query(Revision).count() == 0: - rev = Revision() - rev.author = 'system' - rev.message = u'Initialising the Repository' - Session.add(rev) - self.commit_and_remove() - def create_db(self): '''Ensures tables, const data and some default config is created. i.e. the same as init_db APART from when running tests, when init_db has shortcuts. ''' self.metadata.create_all(bind=self.metadata.bind) - self.init_configuration_data() log.info('Database tables created') def latest_migration_version(self): @@ -246,8 +234,6 @@ def rebuild_db(self): if self.tables_created_and_initialised: # just delete data, leaving tables - this is faster self.delete_all() - # re-add default data - self.init_configuration_data() self.session.commit() else: # delete tables and data From 13b46c62f09c9251cb9c07da878a441da3d7fd02 Mon Sep 17 00:00:00 2001 From: Ross Jones Date: Tue, 16 Dec 2014 16:26:57 +0000 Subject: [PATCH 014/130] Remove spurious commit --- ckan/model/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py index 478816a097d..76d0bedd341 100644 --- a/ckan/model/__init__.py +++ b/ckan/model/__init__.py @@ -234,7 +234,6 @@ def rebuild_db(self): if self.tables_created_and_initialised: # just delete data, leaving tables - this is faster self.delete_all() - self.session.commit() else: # delete tables and data self.clean_db() From 8c54930584e787407342dbcf6441f2132482b53f Mon Sep 17 00:00:00 2001 From: David Read Date: Tue, 14 Apr 2015 12:40:56 +0000 Subject: [PATCH 015/130] Reenable test that had the code fixed on master. --- ckan/tests/legacy/models/test_user.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/ckan/tests/legacy/models/test_user.py b/ckan/tests/legacy/models/test_user.py index deb9b787e24..38d60b95931 100644 --- a/ckan/tests/legacy/models/test_user.py +++ b/ckan/tests/legacy/models/test_user.py @@ -168,13 +168,11 @@ def test_number_of_edits(self): assert model.User.by_name(u'annafan').number_of_edits() == i, \ "annafan should have made %i edit(s)" % i - - #def test_number_of_administered_packages(self): - # model.User.by_name(u'annafan').number_created_packages() == 1, \ - # "annafan should have created one package" - # model.User.by_name(u'joeadmin').number_created_packages() == 0, \ - # "joeadmin shouldn't have created any packages" - + def test_number_of_administered_packages(self): + model.User.by_name(u'annafan').number_created_packages() == 1, \ + "annafan should have created one package" + model.User.by_name(u'joeadmin').number_created_packages() == 0, \ + "joeadmin shouldn't have created any packages" def test_search(self): anna_names = [a.name for a in model.User.search('anna').all()] From 80225dc908a598f47a30a35ddf1e8f0e06a73f3e Mon Sep 17 00:00:00 2001 From: David Read Date: Tue, 14 Apr 2015 14:07:18 +0100 Subject: [PATCH 016/130] Clean up a test. --- ckan/tests/logic/action/test_get.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/ckan/tests/logic/action/test_get.py b/ckan/tests/logic/action/test_get.py index d07c83dfe1d..fc6be109864 100644 --- a/ckan/tests/logic/action/test_get.py +++ b/ckan/tests/logic/action/test_get.py @@ -369,7 +369,6 @@ def test_user_list_default_values(self): user = factories.User() got_users = helpers.call_action('user_list') - remove_pseudo_users(got_users) assert len(got_users) == 1 got_user = got_users[0] @@ -398,7 +397,6 @@ def test_user_list_edits(self): **dataset) got_users = helpers.call_action('user_list') - remove_pseudo_users(got_users) assert len(got_users) == 1 got_user = got_users[0] @@ -411,7 +409,6 @@ def test_user_list_excludes_deleted_users(self): factories.User(state='deleted') got_users = helpers.call_action('user_list') - remove_pseudo_users(got_users) assert len(got_users) == 1 assert got_users[0]['name'] == user['name'] @@ -1176,9 +1173,3 @@ def test_help_show_not_found(self): nose.tools.assert_raises( logic.NotFound, helpers.call_action, 'help_show', name=function_name) - - -def remove_pseudo_users(user_list): - pseudo_users = set(('logged_in', 'visitor')) - user_list[:] = [user for user in user_list - if user['name'] not in pseudo_users] From 7d72bddb37529f3757c482bb590fc44798d137e9 Mon Sep 17 00:00:00 2001 From: David Read Date: Tue, 14 Apr 2015 14:37:43 +0100 Subject: [PATCH 017/130] Fix stats extension. --- ckanext/stats/controller.py | 2 +- ckanext/stats/stats.py | 25 ++++++++----------- .../stats/templates/ckanext/stats/index.html | 8 +++--- .../templates_legacy/ckanext/stats/index.html | 4 +-- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/ckanext/stats/controller.py b/ckanext/stats/controller.py index 468d34f17dd..72d87592b72 100644 --- a/ckanext/stats/controller.py +++ b/ckanext/stats/controller.py @@ -13,7 +13,7 @@ def index(self): c.most_edited_packages = stats.most_edited_packages() c.largest_groups = stats.largest_groups() c.top_tags = stats.top_tags() - c.top_package_owners = stats.top_package_owners() + c.top_package_creators = stats.top_package_creators() c.new_packages_by_week = rev_stats.get_by_week('new_packages') c.deleted_packages_by_week = rev_stats.get_by_week('deleted_packages') c.num_packages_by_week = rev_stats.get_num_packages_by_week() diff --git a/ckanext/stats/stats.py b/ckanext/stats/stats.py index 63281005e1f..04f18f61fa6 100644 --- a/ckanext/stats/stats.py +++ b/ckanext/stats/stats.py @@ -99,20 +99,17 @@ def top_tags(cls, limit=10, returned_tag_info='object'): # by package return res_tags @classmethod - def top_package_owners(cls, limit=10): - package_role = table('package_role') - user_object_role = table('user_object_role') - package = table('package') - s = select([user_object_role.c.user_id, func.count(user_object_role.c.role)], from_obj=[user_object_role.join(package_role).join(package)]).\ - where(user_object_role.c.role==model.authz.Role.ADMIN).\ - where(user_object_role.c.user_id!=None).\ - where(and_(package.c.private==False, package.c.state=='active')). \ - group_by(user_object_role.c.user_id).\ - order_by(func.count(user_object_role.c.role).desc()).\ - limit(limit) - res_ids = model.Session.execute(s).fetchall() - res_users = [(model.Session.query(model.User).get(unicode(user_id)), val) for user_id, val in res_ids] - return res_users + def top_package_creators(cls, limit=10): + userid_count = \ + model.Session.query(model.Package.creator_user_id, + func.count(model.Package.creator_user_id))\ + .group_by(model.Package.creator_user_id) \ + .order_by(func.count(model.Package.creator_user_id).desc())\ + .limit(limit).all() + user_count = [ + (model.Session.query(model.User).get(unicode(user_id)), count) + for user_id, count in userid_count] + return user_count class RevisionStats(object): @classmethod diff --git a/ckanext/stats/templates/ckanext/stats/index.html b/ckanext/stats/templates/ckanext/stats/index.html index 10ef39cae5c..a65812e6e13 100644 --- a/ckanext/stats/templates/ckanext/stats/index.html +++ b/ckanext/stats/templates/ckanext/stats/index.html @@ -148,8 +148,8 @@

{{ _('Top Tags') }}

-
-

{{ _('Users Owning Most Datasets') }}

+
+

{{ _('Users Creating Most Datasets') }}

@@ -158,7 +158,7 @@

{{ _('Users Owning Most Datasets') }}

- {% for user, num_packages in c.top_package_owners %} + {% for user, num_packages in c.top_package_creators %} @@ -181,7 +181,7 @@

{{ _('Stat - + diff --git a/ckanext/stats/templates_legacy/ckanext/stats/index.html b/ckanext/stats/templates_legacy/ckanext/stats/index.html index 4f53d747555..1fb5206254b 100644 --- a/ckanext/stats/templates_legacy/ckanext/stats/index.html +++ b/ckanext/stats/templates_legacy/ckanext/stats/index.html @@ -92,9 +92,9 @@

Top Tags

{{ h.linked_user(user) }} {{ num_packages }}
-

Users owning most datasets

+

Users creating most datasets

- +
${h.linked_user(user)}${num_packages}
From 07544b6cb9c898ad21155986e30aafa19088f32e Mon Sep 17 00:00:00 2001 From: David Read Date: Tue, 14 Apr 2015 14:38:49 +0100 Subject: [PATCH 018/130] Remove reference to deleted table --- ckan/tests/legacy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/tests/legacy/__init__.py b/ckan/tests/legacy/__init__.py index 663a5ab5316..355682dae5d 100644 --- a/ckan/tests/legacy/__init__.py +++ b/ckan/tests/legacy/__init__.py @@ -389,7 +389,7 @@ def prettify_role_dicts(cls, role_dicts, one_per_line=True): for role_dict in role_dicts: pretty_role = {} for key, value in role_dict.items(): - if key.endswith('_id') and value and key != 'user_object_role_id': + if key.endswith('_id') and value: pretty_key = key[:key.find('_id')] domain_object = get_domain_object(model, value) pretty_value = domain_object.name From 62a568554bc513c8d8f44ebf20f6656e631f1a01 Mon Sep 17 00:00:00 2001 From: David Read Date: Tue, 14 Apr 2015 14:49:44 +0100 Subject: [PATCH 019/130] Remove user_role_update which used the user_object_role table. --- ckan/logic/action/update.py | 94 ---------------------- ckan/tests/legacy/__init__.py | 36 --------- ckan/tests/legacy/logic/test_action.py | 2 +- ckan/tests/legacy/test_coding_standards.py | 2 - 4 files changed, 1 insertion(+), 133 deletions(-) diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index 9288f507899..a21a4e1f541 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -1021,100 +1021,6 @@ def package_relationship_update_rest(context, data_dict): return relationship_dict -def user_role_update(context, data_dict): - '''Update a user or authorization group's roles for a domain object. - - The ``user`` parameter must be given. - - You must be authorized to update the domain object. - - To delete all of a user or authorization group's roles for domain object, - pass an empty list ``[]`` to the ``roles`` parameter. - - :param user: the name or id of the user - :type user: string - :param domain_object: the name or id of the domain object (e.g. a package, - group or authorization group) - :type domain_object: string - :param roles: the new roles, e.g. ``['editor']`` - :type roles: list of strings - - :returns: the updated roles of all users for the - domain object - :rtype: dictionary - - ''' - model = context['model'] - - new_user_ref = data_dict.get('user') # the user who is being given the new role - if not bool(new_user_ref): - raise ValidationError('You must provide the "user" parameter.') - domain_object_ref = _get_or_bust(data_dict, 'domain_object') - if not isinstance(data_dict['roles'], (list, tuple)): - raise ValidationError('Parameter "%s" must be of type: "%s"' % ('role', 'list')) - desired_roles = set(data_dict['roles']) - - if new_user_ref: - user_object = model.User.get(new_user_ref) - if not user_object: - raise NotFound('Cannot find user %r' % new_user_ref) - data_dict['user'] = user_object.id - add_user_to_role_func = model.add_user_to_role - remove_user_from_role_func = model.remove_user_from_role - - domain_object = logic.action.get_domain_object(model, domain_object_ref) - data_dict['id'] = domain_object.id - - # current_uors: in order to avoid either creating a role twice or - # deleting one which is non-existent, we need to get the users\' - # current roles (if any) - current_role_dicts = _get_action('roles_show')(context, data_dict)['roles'] - current_roles = set([role_dict['role'] for role_dict in current_role_dicts]) - - # Whenever our desired state is different from our current state, - # change it. - for role in (desired_roles - current_roles): - add_user_to_role_func(user_object, role, domain_object) - for role in (current_roles - desired_roles): - remove_user_from_role_func(user_object, role, domain_object) - - # and finally commit all these changes to the database - if not (current_roles == desired_roles): - model.repo.commit_and_remove() - - return _get_action('roles_show')(context, data_dict) - -def user_role_bulk_update(context, data_dict): - '''Update the roles of many users or authorization groups for an object. - - You must be authorized to update the domain object. - - :param user_roles: the updated user roles, for the format of user role - dictionaries see :py:func:`~user_role_update` - :type user_roles: list of dictionaries - - :returns: the updated roles of all users and authorization groups for the - domain object - :rtype: dictionary - - ''' - # Collate all the roles for each user - roles_by_user = {} # user:roles - for user_role_dict in data_dict['user_roles']: - user = user_role_dict.get('user') - if user: - roles = user_role_dict['roles'] - if user not in roles_by_user: - roles_by_user[user] = [] - roles_by_user[user].extend(roles) - # For each user, update its roles - for user in roles_by_user: - uro_data_dict = {'user': user, - 'roles': roles_by_user[user], - 'domain_object': data_dict['domain_object']} - user_role_update(context, uro_data_dict) - return _get_action('roles_show')(context, data_dict) - def dashboard_mark_activities_old(context, data_dict): '''Mark all the authorized user's new dashboard activities as old. diff --git a/ckan/tests/legacy/__init__.py b/ckan/tests/legacy/__init__.py index 355682dae5d..47e7df80a50 100644 --- a/ckan/tests/legacy/__init__.py +++ b/ckan/tests/legacy/__init__.py @@ -368,42 +368,6 @@ def assert_in(a, b, msg=None): def assert_not_in(a, b, msg=None): assert a not in b, msg or '%r was in %r' % (a, b) -class TestRoles: - @classmethod - def get_roles(cls, domain_object_ref, user_ref=None, - prettify=True): - data_dict = {'domain_object': domain_object_ref} - if user_ref: - data_dict['user'] = user_ref - role_dicts = get_action('roles_show') \ - ({'model': model, 'session': model.Session}, \ - data_dict)['roles'] - if prettify: - role_dicts = cls.prettify_role_dicts(role_dicts) - return role_dicts - - @classmethod - def prettify_role_dicts(cls, role_dicts, one_per_line=True): - '''Replace ids with names''' - pretty_roles = [] - for role_dict in role_dicts: - pretty_role = {} - for key, value in role_dict.items(): - if key.endswith('_id') and value: - pretty_key = key[:key.find('_id')] - domain_object = get_domain_object(model, value) - pretty_value = domain_object.name - pretty_role[pretty_key] = pretty_value - else: - pretty_role[key] = value - if one_per_line: - pretty_role = '"%s" is "%s" on "%s"' % ( - pretty_role.get('user'), - pretty_role['role'], - pretty_role.get('package') or pretty_role.get('group') or pretty_role.get('context')) - pretty_roles.append(pretty_role) - return pretty_roles - class StatusCodes: STATUS_200_OK = 200 diff --git a/ckan/tests/legacy/logic/test_action.py b/ckan/tests/legacy/logic/test_action.py index fcca501e78e..156c80f70ca 100644 --- a/ckan/tests/legacy/logic/test_action.py +++ b/ckan/tests/legacy/logic/test_action.py @@ -20,7 +20,7 @@ from ckan.tests.legacy import StatusCodes from ckan.logic import get_action, NotAuthorized from ckan.logic.action import get_domain_object -from ckan.tests.legacy import TestRoles, call_action_api +from ckan.tests.legacy import call_action_api import ckan.lib.search as search from ckan import plugins diff --git a/ckan/tests/legacy/test_coding_standards.py b/ckan/tests/legacy/test_coding_standards.py index 4fba6d1e7c5..6107c8de8be 100644 --- a/ckan/tests/legacy/test_coding_standards.py +++ b/ckan/tests/legacy/test_coding_standards.py @@ -779,8 +779,6 @@ class TestActionAuth(object): 'update: package_relationship_update_rest', 'update: task_status_update_many', 'update: term_translation_update_many', - 'update: user_role_bulk_update', - 'update: user_role_update', ] AUTH_NO_ACTION_BLACKLIST = [ From 274aae4b8aa5cd5d3491468b90ee0c04d80e4996 Mon Sep 17 00:00:00 2001 From: David Read Date: Wed, 15 Apr 2015 16:33:17 +0100 Subject: [PATCH 020/130] package admin has no meaning now, so removed from create_arbitrary(). Fix stat test. --- ckan/lib/create_test_data.py | 17 ++------- ckanext/stats/stats.py | 5 ++- ckanext/stats/tests/test_stats_lib.py | 52 +++++++++++++++------------ 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py index e88cb22b037..3874c4f0c74 100644 --- a/ckan/lib/create_test_data.py +++ b/ckan/lib/create_test_data.py @@ -133,19 +133,14 @@ def create_vocabs_test_data(cls): @classmethod def create_arbitrary(cls, package_dicts, relationships=[], - extra_user_names=[], extra_group_names=[], - admins=[]): + extra_user_names=[], extra_group_names=[]): '''Creates packages and a few extra objects as well at the same time if required. @param package_dicts - a list of dictionaries with the package properties. Extra keys allowed: - "admins" - list of user names to make admin - for this package. @param extra_group_names - a list of group names to create. No properties get set though. - @param admins - a list of user names to make admins of all the - packages created. ''' assert isinstance(relationships, (list, tuple)) assert isinstance(extra_user_names, (list, tuple)) @@ -155,8 +150,6 @@ def create_arbitrary(cls, package_dicts, relationships=[], new_group_names = set() new_groups = {} - - admins_list = defaultdict(list) # package_name: admin_names if package_dicts: if isinstance(package_dicts, dict): package_dicts = [package_dicts] @@ -249,16 +242,10 @@ def create_arbitrary(cls, package_dicts, relationships=[], elif attr == 'extras': pkg.extras = val elif attr == 'admins': - assert isinstance(val, list) - admins_list[item['name']].extend(val) - for user_name in val: - if user_name not in new_user_names: - new_user_names.append(user_name) + assert 0, 'Deprecated param "admins"' else: raise NotImplementedError(attr) cls.pkg_names.append(item['name']) - for admin in admins: - admins_list[item['name']].append(admin) model.repo.commit_and_remove() needs_commit = False diff --git a/ckanext/stats/stats.py b/ckanext/stats/stats.py index 04f18f61fa6..a69de08b09b 100644 --- a/ckanext/stats/stats.py +++ b/ckanext/stats/stats.py @@ -103,12 +103,15 @@ def top_package_creators(cls, limit=10): userid_count = \ model.Session.query(model.Package.creator_user_id, func.count(model.Package.creator_user_id))\ + .filter(model.Package.state == 'active')\ + .filter(model.Package.private == False)\ .group_by(model.Package.creator_user_id) \ .order_by(func.count(model.Package.creator_user_id).desc())\ .limit(limit).all() user_count = [ (model.Session.query(model.User).get(unicode(user_id)), count) - for user_id, count in userid_count] + for user_id, count in userid_count + if user_id] return user_count class RevisionStats(object): diff --git a/ckanext/stats/tests/test_stats_lib.py b/ckanext/stats/tests/test_stats_lib.py index 05173dfc2d4..daa3fd0a50d 100644 --- a/ckanext/stats/tests/test_stats_lib.py +++ b/ckanext/stats/tests/test_stats_lib.py @@ -1,12 +1,13 @@ import datetime from nose.tools import assert_equal -from ckan.lib.create_test_data import CreateTestData from ckan import model +from ckan.tests import factories from ckanext.stats.stats import Stats, RevisionStats from ckanext.stats.tests import StatsFixture + class TestStatsPlugin(StatsFixture): @classmethod def setup_class(cls): @@ -15,41 +16,46 @@ def setup_class(cls): model.repo.rebuild_db() - CreateTestData.create_arbitrary([ - {'name':'test1', 'groups':['grp1'], 'tags':['tag1']}, - {'name':'test2', 'groups':['grp1', 'grp2'], 'tags':['tag1']}, - {'name':'test3', 'groups':['grp1', 'grp2'], 'tags':['tag1', 'tag2'], 'private': True}, - {'name':'test4'}, - ], - extra_user_names=['bob'], - admins=['bob'], - ) + user = factories.User(name='bob') + org_users = [{'name': user['name'], 'capacity': 'editor'}] + org1 = factories.Organization(name='org1', users=org_users) + group2 = factories.Group() + tag1 = {'name': 'tag1'} + tag2 = {'name': 'tag2'} + factories.Dataset(name='test1', owner_org=org1['id'], tags=[tag1], + user=user) + factories.Dataset(name='test2', owner_org=org1['id'], groups=[{'name': + group2['name']}], tags=[tag1], user=user) + factories.Dataset(name='test3', owner_org=org1['id'], groups=[{'name': + group2['name']}], tags=[tag1, tag2], user=user, + private=True) + factories.Dataset(name='test4', user=user) # hack revision timestamps to be this date week1 = datetime.datetime(2011, 1, 5) for rev in model.Session.query(model.Revision): rev.timestamp = week1 + datetime.timedelta(seconds=1) # week 2 - rev = model.repo.new_revision() + rev = model.repo.new_revision() rev.author = 'bob' rev.timestamp = datetime.datetime(2011, 1, 12) model.Package.by_name(u'test2').delete() model.repo.commit_and_remove() # week 3 - rev = model.repo.new_revision() + rev = model.repo.new_revision() rev.author = 'sandra' rev.timestamp = datetime.datetime(2011, 1, 19) model.Package.by_name(u'test3').title = 'Test 3' model.repo.commit_and_remove() - rev = model.repo.new_revision() + rev = model.repo.new_revision() rev.author = 'sandra' rev.timestamp = datetime.datetime(2011, 1, 20) model.Package.by_name(u'test4').title = 'Test 4' model.repo.commit_and_remove() # week 4 - rev = model.repo.new_revision() + rev = model.repo.new_revision() rev.author = 'bob' rev.timestamp = datetime.datetime(2011, 1, 26) model.Package.by_name(u'test3').notes = 'Test 3 notes' @@ -79,18 +85,19 @@ def test_largest_groups(self): grps = [(grp.name, count) for grp, count in grps] # test2 does not come up because it was deleted # test3 does not come up because it is private - assert_equal(grps, [('grp1', 1), ]) + assert_equal(grps, [('org1', 1), ]) def test_top_tags(self): tags = Stats.top_tags() tags = [(tag.name, count) for tag, count in tags] assert_equal(tags, [('tag1', 1L)]) - def test_top_package_owners(self): - owners = Stats.top_package_owners() - owners = [(owner.name, count) for owner, count in owners] - # Only 2 shown because one of them was deleted and the other one is private - assert_equal(owners, [('bob', 2)]) + def test_top_package_creators(self): + creators = Stats.top_package_creators() + creators = [(creator.name, count) for creator, count in creators] + # Only 2 shown because one of them was deleted and the other one is + # private + assert_equal(creators, [('bob', 2)]) def test_new_packages_by_week(self): new_packages_by_week = RevisionStats.get_by_week('new_packages') @@ -105,12 +112,13 @@ def get_results(week_number): ('2011-01-17', set([]), 0, 4)) assert_equal(get_results(3), ('2011-01-24', set([]), 0, 4)) - + def test_deleted_packages_by_week(self): deleted_packages_by_week = RevisionStats.get_by_week('deleted_packages') def get_results(week_number): date, ids, num, cumulative = deleted_packages_by_week[week_number] - return (date, [model.Session.query(model.Package).get(id).name for id in ids], num, cumulative) + return (date, [model.Session.query(model.Package).get(id).name for + id in ids], num, cumulative) assert_equal(get_results(0), ('2011-01-10', [u'test2'], 1, 1)) assert_equal(get_results(1), From b4a99a1b2e2cd5f7d81cd1631aee9159f13c56db Mon Sep 17 00:00:00 2001 From: David Read Date: Wed, 15 Apr 2015 20:36:38 +0100 Subject: [PATCH 021/130] Fix tests - package admin role has gone. --- ckan/tests/legacy/__init__.py | 4 ++-- ckan/tests/legacy/functional/api/model/test_package.py | 6 +++--- ckan/tests/legacy/functional/test_package.py | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/ckan/tests/legacy/__init__.py b/ckan/tests/legacy/__init__.py index 47e7df80a50..982461a46bc 100644 --- a/ckan/tests/legacy/__init__.py +++ b/ckan/tests/legacy/__init__.py @@ -95,9 +95,9 @@ def _paster(cls, cmd, config_path_rel): class CommonFixtureMethods(BaseCase): @classmethod - def create_package(self, data={}, admins=[], **kwds): + def create_package(self, data={}, **kwds): # Todo: A simpler method for just creating a package. - CreateTestData.create_arbitrary(package_dicts=[data or kwds], admins=admins) + CreateTestData.create_arbitrary(package_dicts=[data or kwds]) @classmethod def create_user(cls, **kwds): diff --git a/ckan/tests/legacy/functional/api/model/test_package.py b/ckan/tests/legacy/functional/api/model/test_package.py index c72ee53d016..b90dcfa13f4 100644 --- a/ckan/tests/legacy/functional/api/model/test_package.py +++ b/ckan/tests/legacy/functional/api/model/test_package.py @@ -366,7 +366,7 @@ def test_09_update_package_entity_not_found(self): def create_package_with_admin_user(self, package_data): '''Creates a package with self.user as admin and provided package_data. ''' - self.create_package(admins=[self.user], data=package_data) + self.create_package(data=package_data) def assert_package_update_ok(self, package_ref_attribute, method_str): @@ -695,7 +695,7 @@ def test_package_update_delete_resource(self): def test_entity_delete_ok(self): # create a package with package_fixture_data if not self.get_package_by_name(self.package_fixture_data['name']): - self.create_package(admins=[self.user], name=self.package_fixture_data['name']) + self.create_package(name=self.package_fixture_data['name']) assert self.get_package_by_name(self.package_fixture_data['name']) # delete it offset = self.package_offset(self.package_fixture_data['name']) @@ -708,7 +708,7 @@ def test_entity_delete_ok(self): def test_entity_delete_ok_without_request_headers(self): # create a package with package_fixture_data if not self.get_package_by_name(self.package_fixture_data['name']): - self.create_package(admins=[self.user], name=self.package_fixture_data['name']) + self.create_package(name=self.package_fixture_data['name']) assert self.get_package_by_name(self.package_fixture_data['name']) # delete it offset = self.package_offset(self.package_fixture_data['name']) diff --git a/ckan/tests/legacy/functional/test_package.py b/ckan/tests/legacy/functional/test_package.py index c2e45ac61fa..5c62ea41183 100644 --- a/ckan/tests/legacy/functional/test_package.py +++ b/ckan/tests/legacy/functional/test_package.py @@ -409,7 +409,6 @@ def _reset_data(self): 'resources':[{'url':u'url escape: & umlaut: \xfc quote: "', 'description':u'description escape: & umlaut: \xfc quote "', }], - 'admins':[u'testadmin'], }) self.editpkg = model.Package.by_name(self.editpkg_name) From 01c2d139f25607e06a5dd09a489879ea7f1a422a Mon Sep 17 00:00:00 2001 From: David Read Date: Fri, 15 May 2015 16:05:30 +0100 Subject: [PATCH 022/130] [#2426] Acknowledge that before_delete only fires on PURGE not DELETE of an object. Adjusted docs and added test. --- ckan/plugins/interfaces.py | 8 +++-- ckan/tests/legacy/ckantestplugins.py | 15 ++++++--- ckan/tests/legacy/test_plugins.py | 50 ++++++++++++++++++++-------- 3 files changed, 54 insertions(+), 19 deletions(-) diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index bd151dbb279..f81b76f9ba2 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -129,7 +129,9 @@ def before_update(self, mapper, connection, instance): def before_delete(self, mapper, connection, instance): """ - Receive an object instance before that instance is DELETEed. + Receive an object instance before that instance is PURGEd. + (whereas usually in ckan 'delete' means to change the state property to + deleted, so use before_update for that case.) """ def after_insert(self, mapper, connection, instance): @@ -144,7 +146,9 @@ def after_update(self, mapper, connection, instance): def after_delete(self, mapper, connection, instance): """ - Receive an object instance after that instance is DELETEed. + Receive an object instance after that instance is PURGEd. + (whereas usually in ckan 'delete' means to change the state property to + deleted, so use before_update for that case.) """ diff --git a/ckan/tests/legacy/ckantestplugins.py b/ckan/tests/legacy/ckantestplugins.py index 81b51091718..49f81b17bd9 100644 --- a/ckan/tests/legacy/ckantestplugins.py +++ b/ckan/tests/legacy/ckantestplugins.py @@ -8,18 +8,25 @@ class MapperPlugin(p.SingletonPlugin): p.implements(p.IMapper, inherit=True) def __init__(self, *args, **kw): - self.added = [] - self.deleted = [] + self.calls = [] def before_insert(self, mapper, conn, instance): - self.added.append(instance) + self.calls.append(('before_insert', instance.name)) + + def after_insert(self, mapper, conn, instance): + self.calls.append(('after_insert', instance.name)) def before_delete(self, mapper, conn, instance): - self.deleted.append(instance) + self.calls.append(('before_delete', instance.name)) + + def after_delete(self, mapper, conn, instance): + self.calls.append(('after_delete', instance.name)) + class MapperPlugin2(MapperPlugin): p.implements(p.IMapper) + class SessionPlugin(p.SingletonPlugin): p.implements(p.ISession, inherit=True) diff --git a/ckan/tests/legacy/test_plugins.py b/ckan/tests/legacy/test_plugins.py index 401e6eb5f13..954ba03bd02 100644 --- a/ckan/tests/legacy/test_plugins.py +++ b/ckan/tests/legacy/test_plugins.py @@ -1,7 +1,7 @@ """ Tests for plugin loading via PCA """ -from nose.tools import raises +from nose.tools import raises, assert_equal from unittest import TestCase from pyutilib.component.core import PluginGlobals from pylons import config @@ -11,7 +11,7 @@ import ckan.plugins as plugins from ckan.plugins.core import find_system_plugins from ckan.lib.create_test_data import CreateTestData - +from ckan.tests import factories def _make_calls(*args): out = [] @@ -19,6 +19,11 @@ def _make_calls(*args): out.append(((arg,), {})) return out +def get_calls(mock_observer_func): + '''Given a mock IPluginObserver method, returns the plugins that caused its + methods to be called, so basically a list of plugins that + loaded/unloaded''' + return [call_tuple[0][0].name for call_tuple in mock_observer_func.calls] class IFoo(plugins.Interface): pass @@ -52,8 +57,8 @@ def test_provided_by(self): assert IFoo.provided_by(FooBarImpl()) assert not IFoo.provided_by(BarImpl()) -class TestIPluginObserverPlugin(object): +class TestIPluginObserverPlugin(object): @classmethod def setup(cls): @@ -67,11 +72,11 @@ def test_notified_on_load(self): observer = self.observer observer.reset_calls() - with plugins.use_plugin('action_plugin') as action: - assert observer.before_load.calls == _make_calls(action), observer.before_load.calls - assert observer.after_load.calls == _make_calls(action), observer.after_load.calls - assert observer.before_unload.calls == [] - assert observer.after_unload.calls == [] + with plugins.use_plugin('action_plugin'): + assert_equal(get_calls(observer.before_load), ['action_plugin']) + assert_equal(get_calls(observer.after_load), ['action_plugin']) + assert_equal(get_calls(observer.before_unload), []) + assert_equal(get_calls(observer.after_unload), []) def test_notified_on_unload(self): @@ -139,13 +144,32 @@ def test_plugin_loading_order(self): config['ckan.plugins'] = config_plugins plugins.load_all(config) - def test_mapper_plugin_fired(self): + def test_mapper_plugin_fired_on_insert(self): + with plugins.use_plugin('mapper_plugin') as mapper_plugin: + CreateTestData.create_arbitrary([{'name': u'testpkg'}]) + assert mapper_plugin.calls == [ + ('before_insert', 'testpkg'), + ('after_insert', 'testpkg'), + ] + + def test_mapper_plugin_fired_on_delete(self): with plugins.use_plugin('mapper_plugin') as mapper_plugin: - CreateTestData.create_arbitrary([{'name':u'testpkg'}]) + CreateTestData.create_arbitrary([{'name': u'testpkg'}]) + mapper_plugin.calls = [] # remove this data - CreateTestData.delete() - assert len(mapper_plugin.added) == 1 - assert mapper_plugin.added[0].name == 'testpkg' + user = factories.User() + context = {'user': user['name']} + logic.get_action('package_delete')(context, {'id': 'testpkg'}) + # state=deleted doesn't trigger before_delete() + assert_equal(mapper_plugin.calls, []) + from ckan import model + # purging the package does trigger before_delete() + model.Package.get('testpkg').purge() + model.Session.commit() + model.Session.remove() + assert_equal(mapper_plugin.calls, + [('before_delete', 'testpkg'), + ('after_delete', 'testpkg')]) def test_routes_plugin_fired(self): with plugins.use_plugin('routes_plugin'): From 573c35e0ff525d8ac2503fe86a7408aa1c3907a8 Mon Sep 17 00:00:00 2001 From: Henri Kotkanen Date: Tue, 26 May 2015 13:34:45 +0300 Subject: [PATCH 023/130] Tag autocomplete: decode percent encoded queries Using urllib.unquote to decode percent encoded query strings before sending them on for matching against the tag db. Also getting the query string as a str object instead of a unicode object for this to work. --- ckan/controllers/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index ddd18f688e2..fe566a90093 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -718,7 +718,8 @@ def dataset_autocomplete(self): return self._finish_ok(resultSet) def tag_autocomplete(self): - q = request.params.get('incomplete', '') + q = request.str_params.get('incomplete', '') + q = unicode(urllib.unquote(q), 'utf-8') limit = request.params.get('limit', 10) tag_names = [] if q: From 88f99cf736eecd051348ca2e266fde94335a2498 Mon Sep 17 00:00:00 2001 From: amercader Date: Mon, 22 Jun 2015 11:56:05 +0100 Subject: [PATCH 024/130] [#2415] Allow uppercase emails on user invites Just lowercase the user name created from the email address --- ckan/logic/action/create.py | 2 +- ckan/tests/logic/action/test_create.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index 28c9103203d..dafbefb484b 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -1093,7 +1093,7 @@ def user_invite(context, data_dict): def _get_random_username_from_email(email): localpart = email.split('@')[0] - cleaned_localpart = re.sub(r'[^\w]', '-', localpart) + cleaned_localpart = re.sub(r'[^\w]', '-', localpart).lower() # if we can't create a unique user name within this many attempts # then something else is probably wrong and we should give up diff --git a/ckan/tests/logic/action/test_create.py b/ckan/tests/logic/action/test_create.py index 1b1b2611a44..bbf160d13ee 100644 --- a/ckan/tests/logic/action/test_create.py +++ b/ckan/tests/logic/action/test_create.py @@ -75,6 +75,12 @@ def test_requires_role(self, _): def test_requires_group_id(self, _): self._invite_user_to_group(group={'id': None}) + @mock.patch('ckan.lib.mailer.send_invite') + def test_user_name_lowercase_when_email_is_uppercase(self, _): + invited_user = self._invite_user_to_group(email='Maria@example.com') + + assert_equals(invited_user.name.split('-')[0], 'maria') + def _invite_user_to_group(self, email='user@email.com', group=None, role='member'): user = factories.User() From 4f0af501c65fc78605f451e60440a6c28f7e7117 Mon Sep 17 00:00:00 2001 From: amercader Date: Mon, 22 Jun 2015 13:13:18 +0100 Subject: [PATCH 025/130] [#1940] Fix failing test after change on form id --- ckan/tests/controllers/test_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py index ba4ca411dd2..e420b94c0e9 100644 --- a/ckan/tests/controllers/test_user.py +++ b/ckan/tests/controllers/test_user.py @@ -55,7 +55,7 @@ def test_edit_user(self): extra_environ=env, ) # existing values in the form - form = response.forms['user-edit'] + form = response.forms['user-edit-form'] assert_equal(form['name'].value, user['name']) assert_equal(form['fullname'].value, user['fullname']) assert_equal(form['email'].value, user['email']) From 4d04e6ad3cd302a8c7f82bb3a4a7cf6cb651b763 Mon Sep 17 00:00:00 2001 From: joetsoi Date: Mon, 22 Jun 2015 13:51:44 +0100 Subject: [PATCH 026/130] [#2483] user controller front end tests register user test, move login tests to controller/test_user.py --- ckan/templates/user/new_user_form.html | 2 +- ckan/tests/controllers/test_user.py | 100 +++++++++++++++++++++++++ ckan/tests/helpers.py | 19 ++++- ckan/tests/lib/test_base.py | 83 -------------------- 4 files changed, 119 insertions(+), 85 deletions(-) diff --git a/ckan/templates/user/new_user_form.html b/ckan/templates/user/new_user_form.html index ba2a097cd89..a49c51d9186 100644 --- a/ckan/templates/user/new_user_form.html +++ b/ckan/templates/user/new_user_form.html @@ -1,6 +1,6 @@ {% import "macros/form.html" as form %} -
+ {{ form.errors(error_summary) }} {{ form.input("name", id="field-username", label=_("Username"), placeholder=_("username"), value=data.name, error=errors.name, classes=["control-medium"]) }} {{ form.input("fullname", id="field-fullname", label=_("Full Name"), placeholder=_("Joe Bloggs"), value=data.fullname, error=errors.fullname, classes=["control-medium"]) }} diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py index 3c9cc961727..538c3e94155 100644 --- a/ckan/tests/controllers/test_user.py +++ b/ckan/tests/controllers/test_user.py @@ -22,6 +22,106 @@ def _get_user_edit_page(app): return env, response, user +class TestRegisterUser(helpers.FunctionalTestBase): + def test_register_a_user(self): + app = helpers._get_test_app() + response = app.get(url=url_for(controller='user', action='register')) + + form = response.forms['user-register-form'] + form['name'] = 'newuser' + form['fullname'] = 'New User' + form['email'] = 'test@test.com' + form['password1'] = 'testpassword' + form['password2'] = 'testpassword' + response = submit_and_follow(app, form, name='save') + response = response.follow() + assert_equal(200, response.status_int) + + user = helpers.call_action('user_show', id='newuser') + assert_equal(user['name'], 'newuser') + assert_equal(user['fullname'], 'New User') + assert_false(user['sysadmin']) + + def test_register_user_bad_password(self): + app = helpers._get_test_app() + response = app.get(url=url_for(controller='user', action='register')) + + form = response.forms['user-register-form'] + form['name'] = 'newuser' + form['fullname'] = 'New User' + form['email'] = 'test@test.com' + form['password1'] = 'testpassword' + form['password2'] = '' + + response = form.submit('save') + assert_true('The passwords you entered do not match') + + +class TestLoginView(helpers.FunctionalTestBase): + def test_registered_user_login(self): + ''' + Registered user can submit valid login details at /user/login and + be returned to appropriate place. + ''' + app = helpers._get_test_app() + + # make a user + user = factories.User() + + # get the form + response = app.get('/user/login') + # ...it's the second one + login_form = response.forms[1] + + # fill it in + login_form['login'] = user['name'] + login_form['password'] = 'pass' + + # submit it + submit_response = login_form.submit() + # let's go to the last redirect in the chain + final_response = helpers.webtest_maybe_follow(submit_response) + + # the response is the user dashboard, right? + final_response.mustcontain('Dashboard', + '{0}' + .format(user['fullname'])) + # and we're definitely not back on the login page. + final_response.mustcontain(no='

Login

') + + def test_registered_user_login_bad_password(self): + ''' + Registered user is redirected to appropriate place if they submit + invalid login details at /user/login. + ''' + app = helpers._get_test_app() + + # make a user + user = factories.User() + + # get the form + response = app.get('/user/login') + # ...it's the second one + login_form = response.forms[1] + + # fill it in + login_form['login'] = user['name'] + login_form['password'] = 'badpass' + + # submit it + submit_response = login_form.submit() + # let's go to the last redirect in the chain + final_response = helpers.webtest_maybe_follow(submit_response) + + # the response is the login page again + final_response.mustcontain('

Login

', + 'Login failed. Bad username or password.') + # and we're definitely not on the dashboard. + final_response.mustcontain(no='Dashboard'), + final_response.mustcontain(no='{0}' + .format(user['fullname'])) + + class TestUser(helpers.FunctionalTestBase): def test_own_datasets_show_up_on_user_dashboard(self): diff --git a/ckan/tests/helpers.py b/ckan/tests/helpers.py index 729f21269cf..6d4fc4fbec1 100644 --- a/ckan/tests/helpers.py +++ b/ckan/tests/helpers.py @@ -197,7 +197,7 @@ def teardown_class(cls): config.update(cls._original_config) -def submit_and_follow(app, form, extra_environ, name=None, +def submit_and_follow(app, form, extra_environ=None, name=None, value=None, **args): ''' Call webtest_submit with name/value passed expecting a redirect @@ -271,6 +271,23 @@ def webtest_submit_fields(form, name=None, index=None, submit_value=None): return submit +def webtest_maybe_follow(response, **kw): + """ + Follow all redirects. If this response is not a redirect, do nothing. + Returns another response object. + + (backported from WebTest 2.0.1) + """ + remaining_redirects = 100 # infinite loops protection + + while 300 <= response.status_int < 400 and remaining_redirects: + response = response.follow(**kw) + remaining_redirects -= 1 + + assert remaining_redirects > 0, "redirects chain looks infinite" + return response + + def change_config(key, value): '''Decorator to temporarily changes Pylons' config to a new value diff --git a/ckan/tests/lib/test_base.py b/ckan/tests/lib/test_base.py index 2772116d237..25b30e14ff0 100644 --- a/ckan/tests/lib/test_base.py +++ b/ckan/tests/lib/test_base.py @@ -1,91 +1,8 @@ from nose import tools as nose_tools -import ckan.tests.factories as factories import ckan.tests.helpers as helpers -class TestLoginView(helpers.FunctionalTestBase): - - def _maybe_follow(self, response, **kw): - """ - Follow all redirects. If this response is not a redirect, do nothing. - Returns another response object. - - (backported from WebTest 2.0.1) - """ - remaining_redirects = 100 # infinite loops protection - - while 300 <= response.status_int < 400 and remaining_redirects: - response = response.follow(**kw) - remaining_redirects -= 1 - - assert remaining_redirects > 0, "redirects chain looks infinite" - return response - - def test_registered_user_login(self): - ''' - Registered user can submit valid login details at /user/login and - be returned to appropriate place. - ''' - app = helpers._get_test_app() - - # make a user - user = factories.User() - - # get the form - response = app.get('/user/login') - # ...it's the second one - login_form = response.forms[1] - - # fill it in - login_form['login'] = user['name'] - login_form['password'] = 'pass' - - # submit it - submit_response = login_form.submit() - # let's go to the last redirect in the chain - final_response = self._maybe_follow(submit_response) - - # the response is the user dashboard, right? - final_response.mustcontain('Dashboard', - '{0}' - .format(user['fullname'])) - # and we're definitely not back on the login page. - final_response.mustcontain(no='

Login

') - - def test_registered_user_login_bad_password(self): - ''' - Registered user is redirected to appropriate place if they submit - invalid login details at /user/login. - ''' - app = helpers._get_test_app() - - # make a user - user = factories.User() - - # get the form - response = app.get('/user/login') - # ...it's the second one - login_form = response.forms[1] - - # fill it in - login_form['login'] = user['name'] - login_form['password'] = 'badpass' - - # submit it - submit_response = login_form.submit() - # let's go to the last redirect in the chain - final_response = self._maybe_follow(submit_response) - - # the response is the login page again - final_response.mustcontain('

Login

', - 'Login failed. Bad username or password.') - # and we're definitely not on the dashboard. - final_response.mustcontain(no='Dashboard'), - final_response.mustcontain(no='{0}' - .format(user['fullname'])) - - class TestCORS(helpers.FunctionalTestBase): def test_options(self): From 76b6f1b5c1522b8d73b4acd5e7129a4347a8fbca Mon Sep 17 00:00:00 2001 From: joetsoi Date: Mon, 22 Jun 2015 17:44:35 +0100 Subject: [PATCH 027/130] [#2486] package controller tests, fix factories.Dataset package/resource delete, package read fix Dataset factories to use 'notes' instead of 'description' --- ckan/controllers/package.py | 5 - ckan/templates/package/confirm_delete.html | 2 +- .../package/confirm_delete_resource.html | 2 +- ckan/tests/controllers/test_package.py | 301 ++++++++++++++++-- ckan/tests/factories.py | 2 +- 5 files changed, 279 insertions(+), 33 deletions(-) diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index 224537e0ead..a70a268dfd4 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -1054,11 +1054,6 @@ def delete(self, id): context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'auth_user_obj': c.userobj} - try: - check_access('package_delete', context, {'id': id}) - except NotAuthorized: - abort(401, _('Unauthorized to delete package %s') % '') - try: if request.method == 'POST': get_action('package_delete')(context, {'id': id}) diff --git a/ckan/templates/package/confirm_delete.html b/ckan/templates/package/confirm_delete.html index 7788af265f1..e56699d99f3 100644 --- a/ckan/templates/package/confirm_delete.html +++ b/ckan/templates/package/confirm_delete.html @@ -10,7 +10,7 @@ {% block form %}

{{ _('Are you sure you want to delete dataset - {name}?').format(name=c.pkg_dict.name) }}

- +

diff --git a/ckan/templates/package/confirm_delete_resource.html b/ckan/templates/package/confirm_delete_resource.html index 6a4c9083307..f03278c3b4a 100644 --- a/ckan/templates/package/confirm_delete_resource.html +++ b/ckan/templates/package/confirm_delete_resource.html @@ -10,7 +10,7 @@ {% block form %}

{{ _('Are you sure you want to delete resource - {name}?').format(name=h.resource_display_name(c.resource_dict)) }}

-

+
diff --git a/ckan/tests/controllers/test_package.py b/ckan/tests/controllers/test_package.py index 3b312bf90e0..91fa6fd2ed5 100644 --- a/ckan/tests/controllers/test_package.py +++ b/ckan/tests/controllers/test_package.py @@ -1,4 +1,10 @@ -from nose.tools import assert_equal, assert_true, assert_not_equal +from nose.tools import ( + assert_equal, + assert_not_equal, + assert_raises, + assert_true, +) + from routes import url_for @@ -329,7 +335,158 @@ def test_dataset_edit_org_dropdown_visible_to_sysadmin_with_no_orgs_available(se assert 'value="{0}"'.format(org['id']) in pkg_edit_response -class TestPackageResourceRead(helpers.FunctionalTestBase): +class TestPackageRead(helpers.FunctionalTestBase): + @classmethod + def setup_class(cls): + super(cls, cls).setup_class() + helpers.reset_db() + + def setup(self): + model.repo.rebuild_db() + + def test_read(self): + user = factories.User() + dataset = factories.Dataset(user=user['name']) + app = helpers._get_test_app() + response = app.get(url_for(controller='package', action='read', + id=dataset['name'])) + response.mustcontain('Test Dataset') + response.mustcontain('Just another test dataset') + + def test_read_rdf(self): + dataset1 = factories.Dataset() + + offset = url_for(controller='package', action='read', + id=dataset1['name']) + ".rdf" + app = self._get_test_app() + res = app.get(offset, status=200) + + assert 'dcat' in res, res + assert '{{' not in res, res + + def test_read_n3(self): + dataset1 = factories.Dataset() + + offset = url_for(controller='package', action='read', + id=dataset1['name']) + ".n3" + app = self._get_test_app() + res = app.get(offset, status=200) + + assert 'dcat' in res, res + assert '{{' not in res, res + + +class TestPackageDelete(helpers.FunctionalTestBase): + def test_owner_delete(self): + user = factories.User() + owner_org = factories.Organization( + users=[{'name': user['id'], 'capacity': 'admin'}] + ) + dataset = factories.Dataset(owner_org=owner_org['id']) + + app = helpers._get_test_app() + env = {'REMOTE_USER': user['name'].encode('ascii')} + response = app.post( + url_for(controller='package', action='delete', id=dataset['name']), + extra_environ=env, + ) + response = response.follow() + assert_equal(200, response.status_int) + + deleted = helpers.call_action('package_show', id=dataset['id']) + assert_equal('deleted', deleted['state']) + + def test_delete_on_non_existing_dataset(self): + app = helpers._get_test_app() + response = app.post( + url_for(controller='package', action='delete', + id='schrodingersdatset'), + expect_errors=True, + ) + assert_equal(404, response.status_int) + + def test_sysadmin_can_delete_any_dataset(self): + owner_org = factories.Organization() + dataset = factories.Dataset(owner_org=owner_org['id']) + app = helpers._get_test_app() + + user = factories.Sysadmin() + env = {'REMOTE_USER': user['name'].encode('ascii')} + + response = app.post( + url_for(controller='package', action='delete', id=dataset['name']), + extra_environ=env, + ) + response = response.follow() + assert_equal(200, response.status_int) + + deleted = helpers.call_action('package_show', id=dataset['id']) + assert_equal('deleted', deleted['state']) + + def test_anon_user_cannot_delete_owned_dataset(self): + user = factories.User() + owner_org = factories.Organization( + users=[{'name': user['id'], 'capacity': 'admin'}] + ) + dataset = factories.Dataset(owner_org=owner_org['id']) + + app = helpers._get_test_app() + response = app.post( + url_for(controller='package', action='delete', id=dataset['name']), + ) + response = response.follow() + assert_equal(200, response.status_int) + response.mustcontain('Unauthorized to delete package') + + deleted = helpers.call_action('package_show', id=dataset['id']) + assert_equal('active', deleted['state']) + + def test_logged_in_user_cannot_delete_owned_dataset(self): + owner = factories.User() + owner_org = factories.Organization( + users=[{'name': owner['id'], 'capacity': 'admin'}] + ) + dataset = factories.Dataset(owner_org=owner_org['id']) + + app = helpers._get_test_app() + user = factories.User() + env = {'REMOTE_USER': user['name'].encode('ascii')} + response = app.post( + url_for(controller='package', action='delete', id=dataset['name']), + extra_environ=env, + expect_errors=True + ) + assert_equal(401, response.status_int) + response.mustcontain('Unauthorized to delete package') + + def test_confirm_cancel_delete(self): + '''Test confirmation of deleting datasets + + When package_delete is made as a get request, it should return a + 'do you want to delete this dataset? confirmation page''' + user = factories.User() + owner_org = factories.Organization( + users=[{'name': user['id'], 'capacity': 'admin'}] + ) + dataset = factories.Dataset(owner_org=owner_org['id']) + + app = helpers._get_test_app() + env = {'REMOTE_USER': user['name'].encode('ascii')} + response = app.get( + url_for(controller='package', action='delete', id=dataset['name']), + extra_environ=env, + ) + assert_equal(200, response.status_int) + message = 'Are you sure you want to delete dataset - {name}?' + response.mustcontain(message.format(name=dataset['name'])) + + form = response.forms['confirm-dataset-delete-form'] + response = form.submit('cancel') + response = helpers.webtest_maybe_follow(response) + assert_equal(200, response.status_int) + + +class TestResourceRead(helpers.FunctionalTestBase): @classmethod def setup_class(cls): super(cls, cls).setup_class() @@ -446,36 +603,130 @@ def test_resource_read_sysadmin(self): app.get(url, status=200, extra_environ=env) -class TestPackageRead(helpers.FunctionalTestBase): - @classmethod - def setup_class(cls): - super(cls, cls).setup_class() - helpers.reset_db() +class TestResourceDelete(helpers.FunctionalTestBase): + def test_dataset_owners_can_delete_resources(self): + user = factories.User() + owner_org = factories.Organization( + users=[{'name': user['id'], 'capacity': 'admin'}] + ) + dataset = factories.Dataset(owner_org=owner_org['id']) + resource = factories.Resource(package_id=dataset['id']) + app = helpers._get_test_app() + env = {'REMOTE_USER': user['name'].encode('ascii')} + response = app.post( + url_for(controller='package', action='resource_delete', + id=dataset['name'], resource_id=resource['id']), + extra_environ=env, + ) + response = response.follow() + assert_equal(200, response.status_int) + response.mustcontain('This dataset has no data') - def setup(self): - model.repo.rebuild_db() + assert_raises(p.toolkit.ObjectNotFound, helpers.call_action, + 'resource_show', id=resource['id']) - def test_read_rdf(self): - dataset1 = factories.Dataset() + def test_deleting_non_existing_resource_404s(self): + user = factories.User() + owner_org = factories.Organization( + users=[{'name': user['id'], 'capacity': 'admin'}] + ) + dataset = factories.Dataset(owner_org=owner_org['id']) + env = {'REMOTE_USER': user['name'].encode('ascii')} + app = helpers._get_test_app() + response = app.post( + url_for(controller='package', action='resource_delete', + id=dataset['name'], resource_id='doesnotexist'), + extra_environ=env, + expect_errors=True + ) + assert_equal(404, response.status_int) - offset = url_for(controller='package', action='read', - id=dataset1['name']) + ".rdf" - app = self._get_test_app() - res = app.get(offset, status=200) + def test_anon_users_cannot_delete_owned_resources(self): + user = factories.User() + owner_org = factories.Organization( + users=[{'name': user['id'], 'capacity': 'admin'}] + ) + dataset = factories.Dataset(owner_org=owner_org['id']) + resource = factories.Resource(package_id=dataset['id']) - assert 'dcat' in res, res - assert '{{' not in res, res + app = helpers._get_test_app() + response = app.post( + url_for(controller='package', action='resource_delete', + id=dataset['name'], resource_id=resource['id']), + ) + response = response.follow() + assert_equal(200, response.status_int) + response.mustcontain('Unauthorized to delete package') + + def test_logged_in_users_cannot_delete_resources_they_do_not_own(self): + # setup our dataset + owner = factories.User() + owner_org = factories.Organization( + users=[{'name': owner['id'], 'capacity': 'admin'}] + ) + dataset = factories.Dataset(owner_org=owner_org['id']) + resource = factories.Resource(package_id=dataset['id']) - def test_read_n3(self): - dataset1 = factories.Dataset() + # access as another user + user = factories.User() + env = {'REMOTE_USER': user['name'].encode('ascii')} + app = helpers._get_test_app() + response = app.post( + url_for(controller='package', action='resource_delete', + id=dataset['name'], resource_id=resource['id']), + extra_environ=env, + expect_errors=True + ) + assert_equal(401, response.status_int) + response.mustcontain('Unauthorized to delete package') - offset = url_for(controller='package', action='read', - id=dataset1['name']) + ".n3" - app = self._get_test_app() - res = app.get(offset, status=200) + def test_sysadmins_can_delete_any_resource(self): + owner_org = factories.Organization() + dataset = factories.Dataset(owner_org=owner_org['id']) + resource = factories.Resource(package_id=dataset['id']) - assert 'dcat' in res, res - assert '{{' not in res, res + sysadmin = factories.Sysadmin() + app = helpers._get_test_app() + env = {'REMOTE_USER': sysadmin['name'].encode('ascii')} + response = app.post( + url_for(controller='package', action='resource_delete', + id=dataset['name'], resource_id=resource['id']), + extra_environ=env, + ) + response = response.follow() + assert_equal(200, response.status_int) + response.mustcontain('This dataset has no data') + + assert_raises(p.toolkit.ObjectNotFound, helpers.call_action, + 'resource_show', id=resource['id']) + + def test_confirm_and_cancel_deleting_a_resource(self): + '''Test confirmation of deleting resources + + When resource_delete is made as a get request, it should return a + 'do you want to delete this reource? confirmation page''' + user = factories.User() + owner_org = factories.Organization( + users=[{'name': user['id'], 'capacity': 'admin'}] + ) + dataset = factories.Dataset(owner_org=owner_org['id']) + resource = factories.Resource(package_id=dataset['id']) + app = helpers._get_test_app() + env = {'REMOTE_USER': user['name'].encode('ascii')} + response = app.get( + url_for(controller='package', action='resource_delete', + id=dataset['name'], resource_id=resource['id']), + extra_environ=env, + ) + assert_equal(200, response.status_int) + message = 'Are you sure you want to delete resource - {name}?' + response.mustcontain(message.format(name=resource['name'])) + + # cancelling sends us back to the resource edit page + form = response.forms['confirm-delete-resource-form'] + response = form.submit('cancel') + response = response.follow() + assert_equal(200, response.status_int) class TestSearch(helpers.FunctionalTestBase): diff --git a/ckan/tests/factories.py b/ckan/tests/factories.py index a74406eb88e..b401fa2d486 100644 --- a/ckan/tests/factories.py +++ b/ckan/tests/factories.py @@ -330,7 +330,7 @@ class Dataset(factory.Factory): # These are the default params that will be used to create new groups. title = 'Test Dataset' - description = 'Just another test dataset.' + notes = 'Just another test dataset.' # Generate a different group name param for each user that gets created. name = factory.Sequence(lambda n: 'test_dataset_{n}'.format(n=n)) From 4332c72d57e259335064e87e22f1319487596a42 Mon Sep 17 00:00:00 2001 From: David Read Date: Mon, 22 Jun 2015 17:53:44 +0100 Subject: [PATCH 028/130] [#2472] Offer existing license in form if it is missing from current license. (Extracted from 2478-licenses-specific) --- ckan/lib/helpers.py | 17 +++++++++++++++++ .../package/snippets/package_basic_fields.html | 5 +++-- ckan/tests/lib/test_helpers.py | 8 ++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 770210179f0..cf24e4212eb 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -2032,6 +2032,22 @@ def get_organization(org=None, include_datasets=False): except (NotFound, ValidationError, NotAuthorized): return {} + +def license_options(existing_license_id=None): + '''Returns [(l.title, l.id), ...] for the licenses configured to be + offered. Always includes the existing_license_id, if supplied. + ''' + register = model.Package.get_license_register() + sorted_licenses = sorted(register.values(), key=lambda x: x.title) + license_ids = [license.id for license in sorted_licenses] + if existing_license_id and existing_license_id not in license_ids: + license_ids.insert(0, existing_license_id) + return [ + (license_id, + register[license_id].title if license_id in register else license_id) + for license_id in license_ids] + + # these are the functions that will end up in `h` template helpers __allowed_functions__ = [ # functions defined in ckan.lib.helpers @@ -2150,4 +2166,5 @@ def get_organization(org=None, include_datasets=False): 'urlencode', 'check_config_permission', 'view_resource_url', + 'license_options', ] diff --git a/ckan/templates/package/snippets/package_basic_fields.html b/ckan/templates/package/snippets/package_basic_fields.html index 23ede701fa4..8d948e09c59 100644 --- a/ckan/templates/package/snippets/package_basic_fields.html +++ b/ckan/templates/package/snippets/package_basic_fields.html @@ -30,8 +30,9 @@
{% if error %}{{ error }}{% endif %} diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index 8e016292f68..d7777940158 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -101,3 +101,11 @@ class StringLike(str): strType = ''.__class__ assert result.__class__ == strType,\ '"remove_linebreaks" casts into str()' + + +class TestLicenseOptions(object): + def test_includes_existing_license(self): + licenses = h.license_options('some-old-license') + eq_(dict(licenses)['some-old-license'], 'some-old-license') + # and it is first on the list + eq_(licenses[0][0], 'some-old-license') From 417182318b574f6690a4cecc03126da915d22099 Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Tue, 23 Jun 2015 10:09:03 +0100 Subject: [PATCH 029/130] [#2489] Frontend tests for admin config. Also adds BeatifulSoup4 to dev-requirements.txt. --- ckan/templates/admin/config.html | 2 +- ckan/tests/controllers/test_admin.py | 210 +++++++++++++++++++++++++++ dev-requirements.txt | 1 + 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 ckan/tests/controllers/test_admin.py diff --git a/ckan/templates/admin/config.html b/ckan/templates/admin/config.html index 3495de0c5a2..641fb4459f8 100644 --- a/ckan/templates/admin/config.html +++ b/ckan/templates/admin/config.html @@ -3,7 +3,7 @@ {% extends "admin/base.html" %} {% block primary_content_inner %} -
+ {% block admin_form %} {{ autoform.generate(form_items, data, errors) }} {% endblock %} diff --git a/ckan/tests/controllers/test_admin.py b/ckan/tests/controllers/test_admin.py new file mode 100644 index 00000000000..c21caf8e1ae --- /dev/null +++ b/ckan/tests/controllers/test_admin.py @@ -0,0 +1,210 @@ +from nose.tools import assert_true, assert_equal + +from bs4 import BeautifulSoup +from routes import url_for + +import ckan.tests.helpers as helpers +import ckan.tests.factories as factories + + +submit_and_follow = helpers.submit_and_follow +webtest_submit = helpers.webtest_submit + + +def _get_admin_config_page(app): + user = factories.Sysadmin() + env = {'REMOTE_USER': user['name'].encode('ascii')} + response = app.get( + url=url_for(controller='admin', action='config'), + extra_environ=env, + ) + return env, response + + +class TestConfig(helpers.FunctionalTestBase): + '''View tests to go along with 'Customizing look and feel' docs.''' + + def _reset_config(self): + '''Reset config via action''' + app = self._get_test_app() + user = factories.Sysadmin() + env = {'REMOTE_USER': user['name'].encode('ascii')} + app.get( + url=url_for(controller='admin', action='reset_config'), + extra_environ=env, + ) + + def teardown(self): + '''Make sure the config is reset after tests''' + self._reset_config() + helpers.reset_db() + + def test_form_renders(self): + '''admin-config-form in the response''' + app = self._get_test_app() + env, response = _get_admin_config_page(app) + assert_true('admin-config-form' in response.forms) + + def test_site_title(self): + '''Configure the site title''' + # current site title + app = self._get_test_app() + + index_response = app.get('/') + assert_true('Welcome - CKAN' in index_response) + + # change site title + env, config_response = _get_admin_config_page(app) + config_form = config_response.forms['admin-config-form'] + config_form['ckan.site_title'] = 'Test Site Title' + webtest_submit(config_form, 'save', status=302, extra_environ=env) + + # new site title + new_index_response = app.get('/') + assert_true('Welcome - Test Site Title' in new_index_response) + + def test_main_css_list(self): + '''Style list contains pre-configured styles''' + + STYLE_NAMES = [ + 'Default', + 'Red', + 'Green', + 'Maroon', + 'Fuchsia' + ] + + app = self._get_test_app() + + env, config_response = _get_admin_config_page(app) + config_response_html = BeautifulSoup(config_response.body) + style_select_options = \ + config_response_html.select('#field-ckan-main-css option') + for option in style_select_options: + assert_true(option.string in STYLE_NAMES) + + def test_main_css(self): + '''Select a colour style''' + app = self._get_test_app() + + # current style + index_response = app.get('/') + assert_true('main.css' in index_response) + + # set new style css + env, config_response = _get_admin_config_page(app) + config_form = config_response.forms['admin-config-form'] + config_form['ckan.main_css'] = '/base/css/red.css' + webtest_submit(config_form, 'save', status=302, extra_environ=env) + + # new style + new_index_response = app.get('/') + assert_true('red.css' in new_index_response) + assert_true('main.css' not in new_index_response) + + def test_tag_line(self): + '''Add a tag line (only when no logo)''' + app = self._get_test_app() + + # current tagline + index_response = app.get('/') + assert_true('Special Tagline' not in index_response) + + # set new tagline css + env, config_response = _get_admin_config_page(app) + config_form = config_response.forms['admin-config-form'] + config_form['ckan.site_description'] = 'Special Tagline' + webtest_submit(config_form, 'save', status=302, extra_environ=env) + + # new tagline not visible yet + new_index_response = app.get('/') + assert_true('Special Tagline' not in new_index_response) + + # remove logo + env, config_response = _get_admin_config_page(app) + config_form = config_response.forms['admin-config-form'] + config_form['ckan.site_logo'] = '' + webtest_submit(config_form, 'save', status=302, extra_environ=env) + + # new tagline + new_index_response = app.get('/') + assert_true('Special Tagline' in new_index_response) + + def test_about(self): + '''Add some About tag text''' + app = self._get_test_app() + + # current about + about_response = app.get('/about') + assert_true('My special about text' not in about_response) + + # set new about + env, config_response = _get_admin_config_page(app) + config_form = config_response.forms['admin-config-form'] + config_form['ckan.site_about'] = 'My special about text' + webtest_submit(config_form, 'save', status=302, extra_environ=env) + + # new about + new_about_response = app.get('/about') + assert_true('My special about text' in new_about_response) + + def test_intro(self): + '''Add some Intro tag text''' + app = self._get_test_app() + + # current intro + intro_response = app.get('/') + assert_true('My special intro text' not in intro_response) + + # set new intro + env, config_response = _get_admin_config_page(app) + config_form = config_response.forms['admin-config-form'] + config_form['ckan.site_intro_text'] = 'My special intro text' + webtest_submit(config_form, 'save', status=302, extra_environ=env) + + # new intro + new_intro_response = app.get('/') + assert_true('My special intro text' in new_intro_response) + + def test_custom_css(self): + '''Add some custom css to the head element''' + app = self._get_test_app() + + # current tagline + intro_response_html = BeautifulSoup(app.get('/').body) + style_tag = intro_response_html.select('head style') + assert_equal(len(style_tag), 0) + + # set new tagline css + env, config_response = _get_admin_config_page(app) + config_form = config_response.forms['admin-config-form'] + config_form['ckan.site_custom_css'] = 'body {background-color:red}' + webtest_submit(config_form, 'save', status=302, extra_environ=env) + + # new tagline not visible yet + new_intro_response_html = BeautifulSoup(app.get('/').body) + style_tag = new_intro_response_html.select('head style') + assert_equal(len(style_tag), 1) + assert_equal(style_tag[0].text.strip(), 'body {background-color:red}') + + def test_homepage_style(self): + '''Select a homepage style''' + app = self._get_test_app() + + # current style + index_response = app.get('/') + assert_true('' + in index_response) + + # set new style css + env, config_response = _get_admin_config_page(app) + config_form = config_response.forms['admin-config-form'] + config_form['ckan.homepage_style'] = '2' + webtest_submit(config_form, 'save', status=302, extra_environ=env) + + # new style + new_index_response = app.get('/') + assert_true('' + not in new_index_response) + assert_true('' + in new_index_response) diff --git a/dev-requirements.txt b/dev-requirements.txt index 8fce2d7c672..4a876ec63a6 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -10,3 +10,4 @@ mock==1.0.1 factory-boy==2.1.1 coveralls==0.4.1 sphinx-rtd-theme==0.1.6 +beautifulsoup4==4.3.2 From 5bd44ccea828ec3a7d0d5d2dc0fc7896134a5ebd Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Tue, 23 Jun 2015 11:03:05 +0100 Subject: [PATCH 030/130] [#2489] Add asserts after config reset. Also fixes reset config method bug. --- ckan/tests/controllers/test_admin.py | 59 ++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/ckan/tests/controllers/test_admin.py b/ckan/tests/controllers/test_admin.py index c21caf8e1ae..a95e61674b5 100644 --- a/ckan/tests/controllers/test_admin.py +++ b/ckan/tests/controllers/test_admin.py @@ -21,22 +21,20 @@ def _get_admin_config_page(app): return env, response +def _reset_config(app): + '''Reset config via action''' + user = factories.Sysadmin() + env = {'REMOTE_USER': user['name'].encode('ascii')} + app.post( + url=url_for(controller='admin', action='reset_config'), + extra_environ=env, + ) + + class TestConfig(helpers.FunctionalTestBase): '''View tests to go along with 'Customizing look and feel' docs.''' - def _reset_config(self): - '''Reset config via action''' - app = self._get_test_app() - user = factories.Sysadmin() - env = {'REMOTE_USER': user['name'].encode('ascii')} - app.get( - url=url_for(controller='admin', action='reset_config'), - extra_environ=env, - ) - def teardown(self): - '''Make sure the config is reset after tests''' - self._reset_config() helpers.reset_db() def test_form_renders(self): @@ -63,6 +61,11 @@ def test_site_title(self): new_index_response = app.get('/') assert_true('Welcome - Test Site Title' in new_index_response) + # reset config value + _reset_config(app) + reset_index_response = app.get('/') + assert_true('Welcome - CKAN' in reset_index_response) + def test_main_css_list(self): '''Style list contains pre-configured styles''' @@ -102,6 +105,11 @@ def test_main_css(self): assert_true('red.css' in new_index_response) assert_true('main.css' not in new_index_response) + # reset config value + _reset_config(app) + reset_index_response = app.get('/') + assert_true('main.css' in reset_index_response) + def test_tag_line(self): '''Add a tag line (only when no logo)''' app = self._get_test_app() @@ -130,6 +138,11 @@ def test_tag_line(self): new_index_response = app.get('/') assert_true('Special Tagline' in new_index_response) + # reset config value + _reset_config(app) + reset_index_response = app.get('/') + assert_true('Special Tagline' not in reset_index_response) + def test_about(self): '''Add some About tag text''' app = self._get_test_app() @@ -148,6 +161,11 @@ def test_about(self): new_about_response = app.get('/about') assert_true('My special about text' in new_about_response) + # reset config value + _reset_config(app) + reset_about_response = app.get('/about') + assert_true('My special about text' not in reset_about_response) + def test_intro(self): '''Add some Intro tag text''' app = self._get_test_app() @@ -166,6 +184,11 @@ def test_intro(self): new_intro_response = app.get('/') assert_true('My special intro text' in new_intro_response) + # reset config value + _reset_config(app) + reset_intro_response = app.get('/') + assert_true('My special intro text' not in reset_intro_response) + def test_custom_css(self): '''Add some custom css to the head element''' app = self._get_test_app() @@ -187,6 +210,12 @@ def test_custom_css(self): assert_equal(len(style_tag), 1) assert_equal(style_tag[0].text.strip(), 'body {background-color:red}') + # reset config value + _reset_config(app) + reset_intro_response_html = BeautifulSoup(app.get('/').body) + style_tag = reset_intro_response_html.select('head style') + assert_equal(len(style_tag), 0) + def test_homepage_style(self): '''Select a homepage style''' app = self._get_test_app() @@ -208,3 +237,9 @@ def test_homepage_style(self): not in new_index_response) assert_true('' in new_index_response) + + # reset config value + _reset_config(app) + reset_index_response = app.get('/') + assert_true('' + in reset_index_response) From 3e28a557047e3dbecbc03e6e60686c18f4a11303 Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Tue, 23 Jun 2015 12:40:06 +0100 Subject: [PATCH 031/130] [#2491] Move/refactor user edit tests from legacy --- ckan/tests/controllers/test_user.py | 35 +++++++++++++++++++++++ ckan/tests/legacy/functional/test_user.py | 26 ----------------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py index ba4ca411dd2..88a6de8c2e5 100644 --- a/ckan/tests/controllers/test_user.py +++ b/ckan/tests/controllers/test_user.py @@ -46,6 +46,41 @@ def test_other_datasets_dont_show_up_on_user_dashboard(self): assert_false(dataset_title in response) + +class TestUserEdit(helpers.FunctionalTestBase): + + def test_user_edit_no_user(self): + app = self._get_test_app() + response = app.get( + url_for(controller='user', action='edit', id=None), + status=400 + ) + assert_true('No user specified' in response) + + def test_user_edit_unknown_user(self): + '''Attempt to read edit user for an unknown user redirects to login + page.''' + app = self._get_test_app() + response = app.get( + url_for(controller='user', action='edit', id='unknown_person'), + status=302 # redirect to login page + ) + response = response.follow() + assert_true('Login' in response) + + def test_user_edit_not_logged_in(self): + '''Attempt to read edit user for an existing, not-logged in user + redirects to login page.''' + app = self._get_test_app() + user = factories.User() + username = user['name'] + response = app.get( + url_for(controller='user', action='edit', id=username), + status=302 + ) + response = response.follow() + assert_true('Login' in response) + def test_edit_user(self): user = factories.User() app = self._get_test_app() diff --git a/ckan/tests/legacy/functional/test_user.py b/ckan/tests/legacy/functional/test_user.py index 0b607001e1c..a338afeec63 100644 --- a/ckan/tests/legacy/functional/test_user.py +++ b/ckan/tests/legacy/functional/test_user.py @@ -101,32 +101,6 @@ def test_apikey(self): res = self.app.get(offset, extra_environ={'REMOTE_USER': 'okfntest'}) assert user.apikey in res, res - - def test_user_edit_no_user(self): - offset = url_for(controller='user', action='edit', id=None) - res = self.app.get(offset, status=400) - assert 'No user specified' in res, res - - def test_user_edit_unknown_user(self): - offset = url_for(controller='user', action='edit', id='unknown_person') - res = self.app.get(offset, status=302) # redirect to login page - res = res.follow() - assert 'Login' in res, res - - def test_user_edit_not_logged_in(self): - # create user - username = 'testedit' - about = u'Test About' - user = model.User.by_name(unicode(username)) - if not user: - model.Session.add(model.User(name=unicode(username), about=about, - password='letmein')) - model.repo.commit_and_remove() - user = model.User.by_name(unicode(username)) - - offset = url_for(controller='user', action='edit', id=username) - res = self.app.get(offset, status=302) - def test_perform_reset_user_password_link_key_incorrect(self): CreateTestData.create_user(name='jack', password='test1') # Make up a key - i.e. trying to hack this From 30e4f6c22f7abe49d22a6ae4b94cfd426a5d0810 Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Wed, 24 Jun 2015 15:05:52 +0100 Subject: [PATCH 032/130] [#2495] User Follow and Unfollow tests --- ckan/tests/controllers/test_user.py | 87 +++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py index 3c9cc961727..fcaa099cbda 100644 --- a/ckan/tests/controllers/test_user.py +++ b/ckan/tests/controllers/test_user.py @@ -145,3 +145,90 @@ def test_password_reset_incorrect_password(self): response = webtest_submit(form, 'save', status=200, extra_environ=env) assert_true('Old Password: incorrect password' in response) + + +class TestUserFollow(helpers.FunctionalTestBase): + + def test_user_follow(self): + app = self._get_test_app() + + user_one = factories.User() + user_two = factories.User() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + follow_url = url_for(controller='user', + action='follow', + id=user_two['id']) + response = app.post(follow_url, extra_environ=env, status=302) + response = response.follow() + assert_true('You are now following {0}' + .format(user_two['display_name']) + in response) + + def test_user_follow_not_exist(self): + '''Pass an id for a user that doesn't exist''' + app = self._get_test_app() + + user_one = factories.User() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + follow_url = url_for(controller='user', + action='follow', + id='not-here') + response = app.post(follow_url, extra_environ=env, status=302) + response = response.follow(status=404) + assert_true('User not found' in response) + + def test_user_unfollow(self): + app = self._get_test_app() + + user_one = factories.User() + user_two = factories.User() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + follow_url = url_for(controller='user', + action='follow', + id=user_two['id']) + app.post(follow_url, extra_environ=env, status=302) + + unfollow_url = url_for(controller='user', action='unfollow', + id=user_two['id']) + unfollow_response = app.post(unfollow_url, extra_environ=env, + status=302) + unfollow_response = unfollow_response.follow() + + assert_true('You are no longer following {0}' + .format(user_two['display_name']) + in unfollow_response) + + def test_user_unfollow_not_following(self): + '''Unfollow a user not currently following''' + app = self._get_test_app() + + user_one = factories.User() + user_two = factories.User() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + unfollow_url = url_for(controller='user', action='unfollow', + id=user_two['id']) + unfollow_response = app.post(unfollow_url, extra_environ=env, + status=302) + unfollow_response = unfollow_response.follow() + + assert_true('You are not following {0}'.format(user_two['id']) + in unfollow_response) + + def test_user_unfollow_not_exist(self): + '''Unfollow a user that doesn't exist.''' + app = self._get_test_app() + + user_one = factories.User() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + unfollow_url = url_for(controller='user', action='unfollow', + id='not-here') + unfollow_response = app.post(unfollow_url, extra_environ=env, + status=302) + unfollow_response = unfollow_response.follow(status=404) + + assert_true('User not found' in unfollow_response) From 3466537af9e68f5d95cb025ba5826cfb18c5b076 Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Wed, 24 Jun 2015 15:17:22 +0100 Subject: [PATCH 033/130] [#2495] User followers list page tests. Also fixes error where followers page needs include_num_followers set to true to return the followers number from user_show. --- ckan/controllers/user.py | 3 ++- ckan/tests/controllers/test_user.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index d1470f1e377..bea4ad7ecf9 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -539,7 +539,8 @@ def _get_form_password(self): def followers(self, id=None): context = {'for_view': True, 'user': c.user or c.author, 'auth_user_obj': c.userobj} - data_dict = {'id': id, 'user_obj': c.userobj} + data_dict = {'id': id, 'user_obj': c.userobj, + 'include_num_followers': True} self._setup_template_variables(context, data_dict) f = get_action('user_follower_list') try: diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py index fcaa099cbda..5c10d469388 100644 --- a/ckan/tests/controllers/test_user.py +++ b/ckan/tests/controllers/test_user.py @@ -232,3 +232,24 @@ def test_user_unfollow_not_exist(self): unfollow_response = unfollow_response.follow(status=404) assert_true('User not found' in unfollow_response) + + def test_user_follower_list(self): + '''Following users appear on followers list page.''' + app = self._get_test_app() + + user_one = factories.Sysadmin() + user_two = factories.User() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + follow_url = url_for(controller='user', + action='follow', + id=user_two['id']) + app.post(follow_url, extra_environ=env, status=302) + + followers_url = url_for(controller='user', action='followers', + id=user_two['id']) + + # Only sysadmins can view the followers list pages + followers_response = app.get(followers_url, extra_environ=env, + status=200) + assert_true(user_one['display_name'] in followers_response) From 2d35ab43d7fc0cc17c20a46a0d15a7ff2608f53c Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Wed, 24 Jun 2015 16:10:59 +0100 Subject: [PATCH 034/130] [#2496] Group Follow, Unfollow and Followers tests --- ckan/tests/controllers/test_group.py | 105 +++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/ckan/tests/controllers/test_group.py b/ckan/tests/controllers/test_group.py index 1e85ef74a1e..73dfc43da50 100644 --- a/ckan/tests/controllers/test_group.py +++ b/ckan/tests/controllers/test_group.py @@ -149,3 +149,108 @@ def test_all_fields_saved(self): assert_equal(group.title, u'Science') assert_equal(group.description, 'Sciencey datasets') assert_equal(group.image_url, 'http://example.com/image.png') + + +class TestGroupFollow(helpers.FunctionalTestBase): + + def test_group_follow(self): + app = self._get_test_app() + + user = factories.User() + group = factories.Group() + + env = {'REMOTE_USER': user['name'].encode('ascii')} + follow_url = url_for(controller='group', + action='follow', + id=group['id']) + response = app.post(follow_url, extra_environ=env, status=302) + response = response.follow() + assert_true('You are now following {0}' + .format(group['display_name']) + in response) + + def test_group_follow_not_exist(self): + '''Pass an id for a group that doesn't exist''' + app = self._get_test_app() + + user_one = factories.User() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + follow_url = url_for(controller='group', + action='follow', + id='not-here') + response = app.post(follow_url, extra_environ=env, status=404) + assert_true('Group not found' in response) + + def test_group_unfollow(self): + app = self._get_test_app() + + user_one = factories.User() + group = factories.Group() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + follow_url = url_for(controller='group', + action='follow', + id=group['id']) + app.post(follow_url, extra_environ=env, status=302) + + unfollow_url = url_for(controller='group', action='unfollow', + id=group['id']) + unfollow_response = app.post(unfollow_url, extra_environ=env, + status=302) + unfollow_response = unfollow_response.follow() + + assert_true('You are no longer following {0}' + .format(group['display_name']) + in unfollow_response) + + def test_group_unfollow_not_following(self): + '''Unfollow a group not currently following''' + app = self._get_test_app() + + user_one = factories.User() + group = factories.Group() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + unfollow_url = url_for(controller='group', action='unfollow', + id=group['id']) + unfollow_response = app.post(unfollow_url, extra_environ=env, + status=302) + unfollow_response = unfollow_response.follow() + + assert_true('You are not following {0}'.format(group['id']) + in unfollow_response) + + def test_group_unfollow_not_exist(self): + '''Unfollow a group that doesn't exist.''' + app = self._get_test_app() + + user_one = factories.User() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + unfollow_url = url_for(controller='group', action='unfollow', + id='not-here') + unfollow_response = app.post(unfollow_url, extra_environ=env, + status=404) + assert_true('Group not found' in unfollow_response) + + def test_group_follower_list(self): + '''Following users appear on followers list page.''' + app = self._get_test_app() + + user_one = factories.Sysadmin() + group = factories.Group() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + follow_url = url_for(controller='group', + action='follow', + id=group['id']) + app.post(follow_url, extra_environ=env, status=302) + + followers_url = url_for(controller='group', action='followers', + id=group['id']) + + # Only sysadmins can view the followers list pages + followers_response = app.get(followers_url, extra_environ=env, + status=200) + assert_true(user_one['display_name'] in followers_response) From 2ae5f16b5eea4f0bae745bcf337f4b42a0731adf Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Wed, 24 Jun 2015 16:46:10 +0100 Subject: [PATCH 035/130] [#2497] Package Follow, Unfollow and Follower test --- ckan/tests/controllers/test_package.py | 107 +++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/ckan/tests/controllers/test_package.py b/ckan/tests/controllers/test_package.py index 3b312bf90e0..b751e0387ae 100644 --- a/ckan/tests/controllers/test_package.py +++ b/ckan/tests/controllers/test_package.py @@ -514,3 +514,110 @@ def test_search_plugin_hooks(self): # get redirected ... assert plugin.calls['before_search'] == 1, plugin.calls assert plugin.calls['after_search'] == 1, plugin.calls + + +class TestPackageFollow(helpers.FunctionalTestBase): + + def test_package_follow(self): + app = self._get_test_app() + + user = factories.User() + package = factories.Dataset() + + env = {'REMOTE_USER': user['name'].encode('ascii')} + follow_url = url_for(controller='package', + action='follow', + id=package['id']) + response = app.post(follow_url, extra_environ=env, status=302) + response = response.follow() + assert_true('You are now following {0}' + .format(package['title']) + in response) + + def test_package_follow_not_exist(self): + '''Pass an id for a package that doesn't exist''' + app = self._get_test_app() + + user_one = factories.User() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + follow_url = url_for(controller='package', + action='follow', + id='not-here') + response = app.post(follow_url, extra_environ=env, status=302) + response = response.follow(status=404) + assert_true('Dataset not found' in response) + + def test_package_unfollow(self): + app = self._get_test_app() + + user_one = factories.User() + package = factories.Dataset() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + follow_url = url_for(controller='package', + action='follow', + id=package['id']) + app.post(follow_url, extra_environ=env, status=302) + + unfollow_url = url_for(controller='package', action='unfollow', + id=package['id']) + unfollow_response = app.post(unfollow_url, extra_environ=env, + status=302) + unfollow_response = unfollow_response.follow() + + assert_true('You are no longer following {0}' + .format(package['title']) + in unfollow_response) + + def test_package_unfollow_not_following(self): + '''Unfollow a package not currently following''' + app = self._get_test_app() + + user_one = factories.User() + package = factories.Dataset() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + unfollow_url = url_for(controller='package', action='unfollow', + id=package['id']) + unfollow_response = app.post(unfollow_url, extra_environ=env, + status=302) + unfollow_response = unfollow_response.follow() + + assert_true('You are not following {0}'.format(package['id']) + in unfollow_response) + + def test_package_unfollow_not_exist(self): + '''Unfollow a package that doesn't exist.''' + app = self._get_test_app() + + user_one = factories.User() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + unfollow_url = url_for(controller='package', action='unfollow', + id='not-here') + unfollow_response = app.post(unfollow_url, extra_environ=env, + status=302) + unfollow_response = unfollow_response.follow(status=404) + assert_true('Dataset not found' in unfollow_response) + + def test_package_follower_list(self): + '''Following users appear on followers list page.''' + app = self._get_test_app() + + user_one = factories.Sysadmin() + package = factories.Dataset() + + env = {'REMOTE_USER': user_one['name'].encode('ascii')} + follow_url = url_for(controller='package', + action='follow', + id=package['id']) + app.post(follow_url, extra_environ=env, status=302) + + followers_url = url_for(controller='package', action='followers', + id=package['id']) + + # Only sysadmins can view the followers list pages + followers_response = app.get(followers_url, extra_environ=env, + status=200) + assert_true(user_one['display_name'] in followers_response) From a2cf3583cb2a835570c8c490f2c36c192fe9e6db Mon Sep 17 00:00:00 2001 From: David Read Date: Thu, 25 Jun 2015 15:32:03 +0000 Subject: [PATCH 036/130] [#2498] Fix setting of globals when blank - fixes the "None" seen, from g.template_head_end --- ckan/lib/app_globals.py | 2 +- ckan/tests/lib/test_app_globals.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 ckan/tests/lib/test_app_globals.py diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index 021ec532904..d7b0a67dbce 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -221,7 +221,7 @@ def _init(self): # process the config details to set globals for key in app_globals_from_config_details.keys(): - new_key, value = process_app_global(key, config.get(key)) + new_key, value = process_app_global(key, config.get(key) or '') setattr(self, new_key, value) diff --git a/ckan/tests/lib/test_app_globals.py b/ckan/tests/lib/test_app_globals.py new file mode 100644 index 00000000000..6ad6c0bf3c5 --- /dev/null +++ b/ckan/tests/lib/test_app_globals.py @@ -0,0 +1,17 @@ +from ckan.lib.app_globals import app_globals as g + + +class TestGlobals(object): + def test_config_not_set(self): + # ckan.site_about has not been configured. + # Behaviour has always been to return an empty string. + assert g.site_about == '' + + def test_config_set_to_blank(self): + # ckan.site_description is configured but with no value. + # Behaviour has always been to return an empty string. + assert g.site_description == '' + + def test_set_from_ini(self): + # ckan.template_head_end is configured in test-core.ini + assert g.template_head_end == '' From 9183b986c8beb7e8187bc6e8085694e9396be745 Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 26 Jun 2015 14:12:31 +0100 Subject: [PATCH 037/130] [#2484] Pass brand new context to function creating default views --- ckan/logic/action/create.py | 4 +++- ckan/logic/action/update.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index 24a04918fdf..0d7a5c220c2 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -213,7 +213,9 @@ def package_create(context, data_dict): # Create default views for resources if necessary if data.get('resources'): logic.get_action('package_create_default_resource_views')( - context, {'package': data}) + {'model': context['model'], 'user': context['user'], + 'ignore_auth': True}, + {'package': data}) if not context.get('defer_commit'): model.repo.commit() diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index 2cadd359929..f87b81ad078 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -366,7 +366,9 @@ def package_update(context, data_dict): # Create default views for resources if necessary if data.get('resources'): logic.get_action('package_create_default_resource_views')( - context, {'package': data}) + {'model': context['model'], 'user': context['user'], + 'ignore_auth': True}, + {'package': data}) if not context.get('defer_commit'): model.repo.commit() From bba552d35eb923e68522556064eb3c67b9f5c9eb Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Fri, 26 Jun 2015 15:14:48 +0100 Subject: [PATCH 038/130] [#2489] Dataset purge tests for admin trash --- ckan/tests/controllers/test_admin.py | 105 +++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/ckan/tests/controllers/test_admin.py b/ckan/tests/controllers/test_admin.py index a95e61674b5..b65464bf5b2 100644 --- a/ckan/tests/controllers/test_admin.py +++ b/ckan/tests/controllers/test_admin.py @@ -3,6 +3,7 @@ from bs4 import BeautifulSoup from routes import url_for +import ckan.model as model import ckan.tests.helpers as helpers import ckan.tests.factories as factories @@ -243,3 +244,107 @@ def test_homepage_style(self): reset_index_response = app.get('/') assert_true('' in reset_index_response) + + +class TestTrashView(helpers.FunctionalTestBase): + '''View tests for permanently deleting datasets with Admin Trash.''' + + def test_trash_view_anon_user(self): + '''An anon user shouldn't be able to access trash view.''' + app = self._get_test_app() + + trash_url = url_for(controller='admin', action='trash') + trash_response = app.get(trash_url, status=302) + # redirects to login page with flash message + trash_response = trash_response.follow() + assert_true('Need to be system administrator to administer' + in trash_response) + assert_true('' + in trash_response) + + def test_trash_view_normal_user(self): + '''A normal logged in user shouldn't be able to access trash view.''' + user = factories.User() + app = self._get_test_app() + + env = {'REMOTE_USER': user['name'].encode('ascii')} + trash_url = url_for(controller='admin', action='trash') + trash_response = app.get(trash_url, extra_environ=env, status=401) + assert_true('Need to be system administrator to administer' + in trash_response) + + def test_trash_view_sysadmin(self): + '''A sysadmin should be able to access trash view.''' + user = factories.Sysadmin() + app = self._get_test_app() + + env = {'REMOTE_USER': user['name'].encode('ascii')} + trash_url = url_for(controller='admin', action='trash') + trash_response = app.get(trash_url, extra_environ=env, status=200) + # On the purge page + assert_true('form-purge-packages' in trash_response) + + def test_trash_no_datasets(self): + '''Getting the trash view with no 'deleted' datasets should list no + datasets.''' + factories.Dataset() + user = factories.Sysadmin() + app = self._get_test_app() + + env = {'REMOTE_USER': user['name'].encode('ascii')} + trash_url = url_for(controller='admin', action='trash') + trash_response = app.get(trash_url, extra_environ=env, status=200) + + trash_response_html = BeautifulSoup(trash_response.body) + # it's called a 'user list' for some reason + trash_pkg_list = trash_response_html.select('ul.user-list li') + # no packages available to purge + assert_equal(len(trash_pkg_list), 0) + + def test_trash_with_deleted_datasets(self): + '''Getting the trash view with 'deleted' datasets should list the + datasets.''' + user = factories.Sysadmin() + factories.Dataset(state='deleted') + factories.Dataset(state='deleted') + factories.Dataset() + app = self._get_test_app() + + env = {'REMOTE_USER': user['name'].encode('ascii')} + trash_url = url_for(controller='admin', action='trash') + trash_response = app.get(trash_url, extra_environ=env, status=200) + + trash_response_html = BeautifulSoup(trash_response.body) + # it's called a 'user list' for some reason + trash_pkg_list = trash_response_html.select('ul.user-list li') + # Two packages in the list to purge + assert_equal(len(trash_pkg_list), 2) + + def test_trash_purge_deleted_datasets(self): + '''Posting the trash view with 'deleted' datasets, purges the + datasets.''' + user = factories.Sysadmin() + factories.Dataset(state='deleted') + factories.Dataset(state='deleted') + factories.Dataset() + app = self._get_test_app() + + # how many datasets before purge + pkgs_before_purge = model.Session.query(model.Package).count() + assert_equal(pkgs_before_purge, 3) + + env = {'REMOTE_USER': user['name'].encode('ascii')} + trash_url = url_for(controller='admin', action='trash') + trash_response = app.get(trash_url, extra_environ=env, status=200) + + # submit the purge form + purge_form = trash_response.forms['form-purge-packages'] + purge_response = webtest_submit(purge_form, 'purge-packages', + status=302, extra_environ=env) + purge_response = purge_response.follow(extra_environ=env) + # redirected back to trash page + assert_true('Purge complete' in purge_response) + + # how many datasets after purge + pkgs_before_purge = model.Session.query(model.Package).count() + assert_equal(pkgs_before_purge, 1) From 05a40e0683478932789919b904a28cc2ddd34326 Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Fri, 26 Jun 2015 17:02:37 +0100 Subject: [PATCH 039/130] [#2504] View test for the user list/search page. --- ckan/templates/user/snippets/user_search.html | 2 +- ckan/tests/controllers/test_user.py | 114 ++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/ckan/templates/user/snippets/user_search.html b/ckan/templates/user/snippets/user_search.html index 38e39837824..2019a21ae7b 100644 --- a/ckan/templates/user/snippets/user_search.html +++ b/ckan/templates/user/snippets/user_search.html @@ -1,6 +1,6 @@

{{ _('Users') }}

- +
diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py index 3c9cc961727..954da6dd933 100644 --- a/ckan/tests/controllers/test_user.py +++ b/ckan/tests/controllers/test_user.py @@ -1,3 +1,4 @@ +from bs4 import BeautifulSoup from nose.tools import assert_true, assert_false, assert_equal from routes import url_for @@ -145,3 +146,116 @@ def test_password_reset_incorrect_password(self): response = webtest_submit(form, 'save', status=200, extra_environ=env) assert_true('Old Password: incorrect password' in response) + + +class TestUserSearch(helpers.FunctionalTestBase): + + def test_user_page_anon_access(self): + '''Anon users can access the user list page''' + app = self._get_test_app() + + user_url = url_for(controller='user', action='index') + user_response = app.get(user_url, status=200) + assert_true('All Users - CKAN' + in user_response) + + def test_user_page_lists_users(self): + '''/users/ lists registered users''' + app = self._get_test_app() + factories.User(fullname='User One') + factories.User(fullname='User Two') + factories.User(fullname='User Three') + + user_url = url_for(controller='user', action='index') + user_response = app.get(user_url, status=200) + + user_response_html = BeautifulSoup(user_response.body) + user_list = user_response_html.select('ul.user-list li') + # two pseudo users + the users we've added + assert_equal(len(user_list), 2+3) + + user_names = [u.text.strip() for u in user_list] + assert_true('User One' in user_names) + assert_true('User Two' in user_names) + assert_true('User Three' in user_names) + + def test_user_page_doesnot_list_deleted_users(self): + '''/users/ doesn't list deleted users''' + app = self._get_test_app() + factories.User(fullname='User One', state='deleted') + factories.User(fullname='User Two') + factories.User(fullname='User Three') + + user_url = url_for(controller='user', action='index') + user_response = app.get(user_url, status=200) + + user_response_html = BeautifulSoup(user_response.body) + user_list = user_response_html.select('ul.user-list li') + # two pseudo users + the users we've added + assert_equal(len(user_list), 2+2) + + user_names = [u.text.strip() for u in user_list] + assert_true('User One' not in user_names) + assert_true('User Two' in user_names) + assert_true('User Three' in user_names) + + def test_user_page_anon_search(self): + '''Anon users can search for users by username.''' + app = self._get_test_app() + factories.User(fullname='User One', email='useroneemail@example.com') + factories.User(fullname='Person Two') + factories.User(fullname='Person Three') + + user_url = url_for(controller='user', action='index') + user_response = app.get(user_url, status=200) + search_form = user_response.forms['user-search-form'] + search_form['q'] = 'Person' + search_response = webtest_submit(search_form, status=200) + + search_response_html = BeautifulSoup(search_response.body) + user_list = search_response_html.select('ul.user-list li') + assert_equal(len(user_list), 2) + + user_names = [u.text.strip() for u in user_list] + assert_true('Person Two' in user_names) + assert_true('Person Three' in user_names) + assert_true('User One' not in user_names) + + def test_user_page_anon_search_not_by_email(self): + '''Anon users can not search for users by email.''' + app = self._get_test_app() + factories.User(fullname='User One', email='useroneemail@example.com') + factories.User(fullname='Person Two') + factories.User(fullname='Person Three') + + user_url = url_for(controller='user', action='index') + user_response = app.get(user_url, status=200) + search_form = user_response.forms['user-search-form'] + search_form['q'] = 'useroneemail@example.com' + search_response = webtest_submit(search_form, status=200) + + search_response_html = BeautifulSoup(search_response.body) + user_list = search_response_html.select('ul.user-list li') + assert_equal(len(user_list), 0) + + def test_user_page_sysadmin_user(self): + '''Sysadmin can search for users by email.''' + app = self._get_test_app() + sysadmin = factories.Sysadmin() + + factories.User(fullname='User One', email='useroneemail@example.com') + factories.User(fullname='Person Two') + factories.User(fullname='Person Three') + + env = {'REMOTE_USER': sysadmin['name'].encode('ascii')} + user_url = url_for(controller='user', action='index') + user_response = app.get(user_url, status=200, extra_environ=env) + search_form = user_response.forms['user-search-form'] + search_form['q'] = 'useroneemail@example.com' + search_response = webtest_submit(search_form, status=200, + extra_environ=env) + + search_response_html = BeautifulSoup(search_response.body) + user_list = search_response_html.select('ul.user-list li') + assert_equal(len(user_list), 1) + assert_equal(user_list[0].text.strip(), 'User One') From 700d16825e35aff81a9658097c67f77466a3fb57 Mon Sep 17 00:00:00 2001 From: Eduardo Grajeda Date: Sun, 28 Jun 2015 23:45:24 -0300 Subject: [PATCH 040/130] [#1749] Change the way of getting the number of followers of a dataset in the CKAN's JavaScript documentation. --- .../templates/ajax_snippets/example_theme_popover.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckanext/example_theme/v18_snippet_api/templates/ajax_snippets/example_theme_popover.html b/ckanext/example_theme/v18_snippet_api/templates/ajax_snippets/example_theme_popover.html index 032077f6616..b4198bd2b2d 100644 --- a/ckanext/example_theme/v18_snippet_api/templates/ajax_snippets/example_theme_popover.html +++ b/ckanext/example_theme/v18_snippet_api/templates/ajax_snippets/example_theme_popover.html @@ -10,7 +10,7 @@
{{ _('Followers') }}
-
{{ h.get_action('dataset_follower_count', {'id': id}) }}
+
{{ h.follow_count('dataset', id) }}
{{ _('Resources') }}
{{ num_resources }}
From 8414627ba1b6734cb2ce007af5cb8128b9afd215 Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Mon, 29 Jun 2015 09:18:49 +0100 Subject: [PATCH 041/130] [#2504] Add BeautifulSoup to dev reqs --- dev-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-requirements.txt b/dev-requirements.txt index 8fce2d7c672..4a876ec63a6 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -10,3 +10,4 @@ mock==1.0.1 factory-boy==2.1.1 coveralls==0.4.1 sphinx-rtd-theme==0.1.6 +beautifulsoup4==4.3.2 From 1e2ae5b335ad11ed0870db8b0ed1977bd08d9775 Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Mon, 29 Jun 2015 10:26:25 +0100 Subject: [PATCH 042/130] [#2504] Fix PEP8 issue --- ckan/tests/controllers/test_user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py index 954da6dd933..c50db65f090 100644 --- a/ckan/tests/controllers/test_user.py +++ b/ckan/tests/controllers/test_user.py @@ -172,7 +172,7 @@ def test_user_page_lists_users(self): user_response_html = BeautifulSoup(user_response.body) user_list = user_response_html.select('ul.user-list li') # two pseudo users + the users we've added - assert_equal(len(user_list), 2+3) + assert_equal(len(user_list), 2 + 3) user_names = [u.text.strip() for u in user_list] assert_true('User One' in user_names) @@ -192,7 +192,7 @@ def test_user_page_doesnot_list_deleted_users(self): user_response_html = BeautifulSoup(user_response.body) user_list = user_response_html.select('ul.user-list li') # two pseudo users + the users we've added - assert_equal(len(user_list), 2+2) + assert_equal(len(user_list), 2 + 2) user_names = [u.text.strip() for u in user_list] assert_true('User One' not in user_names) From 88218c2944b34a8d0ef84001ad10a92106cc9076 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Sat, 27 Jun 2015 01:11:32 +0200 Subject: [PATCH 043/130] [#2494] New option to change the timezone of displayed datetimes --- ckan/lib/formatters.py | 38 ++++++++++++++++++++++++------- doc/maintaining/configuration.rst | 15 ++++++++++++ requirements.txt | 1 + 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py index dde93325f4e..3066c6a21d0 100644 --- a/ckan/lib/formatters.py +++ b/ckan/lib/formatters.py @@ -1,11 +1,15 @@ import datetime - +import pytz +import logging +from pylons import config from babel import numbers import ckan.lib.i18n as i18n from ckan.common import _, ungettext +log = logging.getLogger(__name__) + ################################################## # # @@ -124,18 +128,36 @@ def months_between(date1, date2): months).format(months=months) return ungettext('over {years} year ago', 'over {years} years ago', months / 12).format(years=months / 12) + + # all dates are considered UTC internally, + # change output if `ckan.timezone` is available + tz_datetime = datetime_.replace(tzinfo=pytz.utc) + try: + tz_datetime = tz_datetime.astimezone( + pytz.timezone(config.get('ckan.timezone', '')) + ) + except pytz.UnknownTimeZoneError: + log.warning( + 'Timezone `%s` not found. ' + 'Please provide a valid timezone setting in `ckan.timezone` ' + 'or leave the field empty. All valid values can be found in ' + 'pytz.all_timezones.' % config.get('ckan.timezone', '') + ) + # actual date details = { - 'min': datetime_.minute, - 'hour': datetime_.hour, - 'day': datetime_.day, - 'year': datetime_.year, - 'month': _MONTH_FUNCTIONS[datetime_.month - 1](), + 'min': tz_datetime.minute, + 'hour': tz_datetime.hour, + 'day': tz_datetime.day, + 'year': tz_datetime.year, + 'month': _MONTH_FUNCTIONS[tz_datetime.month - 1](), + 'timezone': tz_datetime.tzinfo.zone, } if with_hours: return ( - # NOTE: This is for translating dates like `April 24, 2013, 10:45` - _('{month} {day}, {year}, {hour:02}:{min:02}').format(**details)) + # NOTE: This is for translating dates like `April 24, 2013, 10:45 (UTC)` + _('{month} {day}, {year}, {hour:02}:{min:02} ({timezone})') \ + .format(**details)) else: return ( # NOTE: This is for translating dates like `April 24, 2013` diff --git a/doc/maintaining/configuration.rst b/doc/maintaining/configuration.rst index dab12ee9895..871dc8c9a15 100644 --- a/doc/maintaining/configuration.rst +++ b/doc/maintaining/configuration.rst @@ -1556,6 +1556,21 @@ Default value: (none) By default, the locales are searched for in the ``ckan/i18n`` directory. Use this option if you want to use another folder. +.. _ckan.timezone: + +ckan.timezone +^^^^^^^^^^^^^ + +Example:: + + ckan.timezone = Europe/Zurich + +Default value: UTC + +By default, all datetimes are considered to be in the UTC timezone. Use this option to change the displayed dates on the frontend. Internally, the dates are always saved as UTC. This option only changes the way the dates are displayed. + +The valid values for this options [can be found at pytz](http://pytz.sourceforge.net/#helpers) (``pytz.all_timezones``) + .. _ckan.root_path: ckan.root_path diff --git a/requirements.txt b/requirements.txt index 5b2ab057319..ee87f798219 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,3 +41,4 @@ unicodecsv==0.9.4 vdm==0.13 wsgiref==0.1.2 zope.interface==4.1.1 +pytz==2012j From ec775eda559318ca03976a371583224c1bab9d92 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Tue, 30 Jun 2015 17:50:02 +0200 Subject: [PATCH 044/130] [#2494] Add a new API endpoint to set timezone offset --- ckan/config/routing.py | 1 + ckan/controllers/util.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ckan/config/routing.py b/ckan/config/routing.py index 4f92b715991..abadcce83aa 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -443,6 +443,7 @@ def make_map(): with SubMapper(map, controller='util') as m: m.connect('/i18n/strings_{lang}.js', action='i18n_js_strings') + m.connect('/util/set_timezone_offset/{offset}', action='set_timezone_offset') m.connect('/util/redirect', action='redirect') m.connect('/testing/primer', action='primer') m.connect('/testing/markup', action='markup') diff --git a/ckan/controllers/util.py b/ckan/controllers/util.py index 840c6833a04..e563fdff9a3 100644 --- a/ckan/controllers/util.py +++ b/ckan/controllers/util.py @@ -3,7 +3,7 @@ import ckan.lib.base as base import ckan.lib.i18n as i18n import ckan.lib.helpers as h -from ckan.common import _ +from ckan.common import _, request class UtilController(base.BaseController): @@ -25,6 +25,12 @@ def primer(self): This is useful for development/styling of ckan. ''' return base.render('development/primer.html') + def set_timezone_offset(self, offset): + session = request.environ['beaker.session'] + session['utc_offset_mins'] = offset + session.save() + return session.get('utc_offset_mins', 'No offset set') + def markup(self): ''' Render all html elements out onto a single page. This is useful for development/styling of ckan. ''' From 58abf6899e913d0a7e31f65aae249ee6f3db5b0c Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Tue, 30 Jun 2015 17:55:25 +0200 Subject: [PATCH 045/130] [#2494] Make sure the new timezone endpoint gets called in JavaScript --- ckan/public/base/javascript/main.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ckan/public/base/javascript/main.js b/ckan/public/base/javascript/main.js index b33924d132e..8171fcacb6c 100644 --- a/ckan/public/base/javascript/main.js +++ b/ckan/public/base/javascript/main.js @@ -30,6 +30,20 @@ this.ckan = this.ckan || {}; ckan.SITE_ROOT = getRootFromData('siteRoot'); ckan.LOCALE_ROOT = getRootFromData('localeRoot'); + /* Save UTC offset of user in browser to display dates correctly + * getTimezoneOffset returns the offset between the local time and UTC, + * but we want to store it the other way round. + * see http://mdn.io/getTimezoneOffset for details + */ + now = new Date(); + utc_timezone_offset = -(now.getTimezoneOffset()); + $.ajax( + ckan.sandbox().client.url('/util/set_timezone_offset/' + utc_timezone_offset), + { + async:false + } + ); + // Load the localisations before instantiating the modules. ckan.sandbox().client.getLocaleData(locale).done(function (data) { ckan.i18n.load(data); From dee22ac0e9dc5211fbfe17d888501054e47c9f69 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Tue, 30 Jun 2015 17:56:41 +0200 Subject: [PATCH 046/130] [#2494] Displayed all datetimes with the users utc offset - This is either saved in the beaker session - otherwise it defaults to zero - The display of a full date also shows the currently used timezone incl. the offset (e.g. UTC+2) --- ckan/lib/formatters.py | 32 +++++++++++++++++++++++--------- ckan/lib/helpers.py | 7 ++++++- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py index 3066c6a21d0..e917b65adc1 100644 --- a/ckan/lib/formatters.py +++ b/ckan/lib/formatters.py @@ -72,7 +72,7 @@ def _month_dec(): _month_sept, _month_oct, _month_nov, _month_dec] -def localised_nice_date(datetime_, show_date=False, with_hours=False): +def localised_nice_date(datetime_, show_date=False, with_hours=False, utc_offset_mins=0): ''' Returns a friendly localised unicode representation of a datetime. :param datetime_: The date to format @@ -132,16 +132,29 @@ def months_between(date1, date2): # all dates are considered UTC internally, # change output if `ckan.timezone` is available tz_datetime = datetime_.replace(tzinfo=pytz.utc) + timezone_name = config.get('ckan.timezone', '') try: tz_datetime = tz_datetime.astimezone( - pytz.timezone(config.get('ckan.timezone', '')) + pytz.timezone(timezone_name) ) + timezone_display_name = tc_dateimt.tzinfo.zone except pytz.UnknownTimeZoneError: - log.warning( - 'Timezone `%s` not found. ' - 'Please provide a valid timezone setting in `ckan.timezone` ' - 'or leave the field empty. All valid values can be found in ' - 'pytz.all_timezones.' % config.get('ckan.timezone', '') + if timezone_name != '': + log.warning( + 'Timezone `%s` not found. ' + 'Please provide a valid timezone setting in `ckan.timezone` ' + 'or leave the field empty. All valid values can be found in ' + 'pytz.all_timezones. You can specify the special value ' + '`browser` to displayed the dates according to the browser ' + 'settings of the visiting user.' % timezone_name + ) + offset = datetime.timedelta(minutes=utc_offset_mins) + tz_datetime = tz_datetime + offset + + utc_offset_hours = utc_offset_mins / 60 + timezone_display_name = "UTC{1:+0.{0}f}".format( + int(utc_offset_hours % 1 > 0), + utc_offset_hours ) # actual date @@ -151,11 +164,12 @@ def months_between(date1, date2): 'day': tz_datetime.day, 'year': tz_datetime.year, 'month': _MONTH_FUNCTIONS[tz_datetime.month - 1](), - 'timezone': tz_datetime.tzinfo.zone, + 'timezone': timezone_display_name, } + if with_hours: return ( - # NOTE: This is for translating dates like `April 24, 2013, 10:45 (UTC)` + # NOTE: This is for translating dates like `April 24, 2013, 10:45 (UTC+2)` _('{month} {day}, {year}, {hour:02}:{min:02} ({timezone})') \ .format(**details)) else: diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index f604da3ab7d..882d9d097fe 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -963,6 +963,10 @@ def render_datetime(datetime_, date_format=None, with_hours=False): :rtype: string ''' datetime_ = _datestamp_to_datetime(datetime_) + try: + utc_offset_mins = int(session.get('utc_offset_mins', 0)) + except TypeError: + utc_offset_mins = 0 if not datetime_: return '' # if date_format was supplied we use it @@ -970,7 +974,8 @@ def render_datetime(datetime_, date_format=None, with_hours=False): return datetime_.strftime(date_format) # the localised date return formatters.localised_nice_date(datetime_, show_date=True, - with_hours=with_hours) + with_hours=with_hours, + utc_offset_mins=utc_offset_mins) def date_str_to_datetime(date_str): From b7626cc2ab5d1b00e4e6fcb274da6f452d7f97b6 Mon Sep 17 00:00:00 2001 From: joetsoi Date: Wed, 1 Jul 2015 00:07:20 +0100 Subject: [PATCH 047/130] [#2512] fix group and org deletion change controller to check for the correct group type, self.group_type is now self.group_types --- ckan/controllers/group.py | 4 +- .../organization/confirm_delete.html | 2 +- ckan/tests/controllers/test_organization.py | 67 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 ckan/tests/controllers/test_organization.py diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index abe149db307..3377f7bb98b 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -625,9 +625,9 @@ def delete(self, id): try: if request.method == 'POST': self._action('group_delete')(context, {'id': id}) - if self.group_type == 'organization': + if group_type == 'organization': h.flash_notice(_('Organization has been deleted.')) - elif self.group_type == 'group': + elif group_type == 'group': h.flash_notice(_('Group has been deleted.')) else: h.flash_notice(_('%s has been deleted.') diff --git a/ckan/templates/organization/confirm_delete.html b/ckan/templates/organization/confirm_delete.html index a8aca943f93..6d93e6802cf 100644 --- a/ckan/templates/organization/confirm_delete.html +++ b/ckan/templates/organization/confirm_delete.html @@ -10,7 +10,7 @@ {% block form %}

{{ _('Are you sure you want to delete organization - {name}?').format(name=c.group_dict.name) }}

- +

diff --git a/ckan/tests/controllers/test_organization.py b/ckan/tests/controllers/test_organization.py new file mode 100644 index 00000000000..b83c063dc22 --- /dev/null +++ b/ckan/tests/controllers/test_organization.py @@ -0,0 +1,67 @@ +from nose.tools import assert_equal +from routes import url_for + +from ckan.tests import factories, helpers +from ckan.tests.helpers import submit_and_follow + + +class TestOrganizationDelete(helpers.FunctionalTestBase): + def setup(self): + super(TestOrganizationDelete, self).setup() + self.app = helpers._get_test_app() + self.user = factories.User() + self.user_env = {'REMOTE_USER': self.user['name'].encode('ascii')} + self.organization = factories.Organization(user=self.user) + + def test_owner_delete(self): + response = self.app.get(url=url_for(controller='organization', + action='delete', + id=self.organization['id']), + status=200, + extra_environ=self.user_env) + + form = response.forms['organization-confirm-delete-form'] + response = submit_and_follow(self.app, form, name='delete', + extra_environ=self.user_env) + organization = helpers.call_action('organization_show', + id=self.organization['id']) + assert_equal(organization['state'], 'deleted') + + def test_sysadmin_delete(self): + sysadmin = factories.Sysadmin() + extra_environ = {'REMOTE_USER': sysadmin['name'].encode('ascii')} + response = self.app.get(url=url_for(controller='organization', + action='delete', + id=self.organization['id']), + status=200, + extra_environ=extra_environ) + + form = response.forms['organization-confirm-delete-form'] + response = submit_and_follow(self.app, form, name='delete', + extra_environ=self.user_env) + organization = helpers.call_action('organization_show', + id=self.organization['id']) + assert_equal(organization['state'], 'deleted') + + def test_non_authorized_user_trying_to_delete_fails(self): + user = factories.User() + extra_environ = {'REMOTE_USER': user['name'].encode('ascii')} + self.app.get(url=url_for(controller='organization', + action='delete', + id=self.organization['id']), + status=401, + extra_environ=extra_environ) + + organization = helpers.call_action('organization_show', + id=self.organization['id']) + assert_equal(organization['state'], 'active') + + def test_anon_user_trying_to_delete_fails(self): + self.app.get(url=url_for(controller='organization', + action='delete', + id=self.organization['id']), + status=302) # redirects to login form + + organization = helpers.call_action('organization_show', + id=self.organization['id']) + assert_equal(organization['state'], 'active') From c622d2367046938d5c26cb28f7536dcfaf7c90f6 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Wed, 1 Jul 2015 10:46:41 +0200 Subject: [PATCH 048/130] [#2494] Validate the offset before saving it in the session --- ckan/controllers/util.py | 12 +++++++++++- ckan/lib/helpers.py | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ckan/controllers/util.py b/ckan/controllers/util.py index e563fdff9a3..231c4b82224 100644 --- a/ckan/controllers/util.py +++ b/ckan/controllers/util.py @@ -26,10 +26,20 @@ def primer(self): return base.render('development/primer.html') def set_timezone_offset(self, offset): + ''' save the users UTC timezone offset in the beaker session ''' + # check if the value can be successfully casted to an int + try: + offset = int(offset) + # UTC offsets are between UTC-12 until UTC+14 + if not (60*12 >= offset >= -(60*14)): + raise ValueError + except ValueError: + base.abort(400, _('Not a valid UTC offset value, must be between 720 (UTC-12) and -840 (UTC+14)')) + session = request.environ['beaker.session'] session['utc_offset_mins'] = offset session.save() - return session.get('utc_offset_mins', 'No offset set') + return h.json.dumps({'utc_offset_mins': session.get('utc_offset_mins', 'No offset set')}) def markup(self): ''' Render all html elements out onto a single page. diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 882d9d097fe..8c2c5603d15 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -964,7 +964,7 @@ def render_datetime(datetime_, date_format=None, with_hours=False): ''' datetime_ = _datestamp_to_datetime(datetime_) try: - utc_offset_mins = int(session.get('utc_offset_mins', 0)) + utc_offset_mins = session.get('utc_offset_mins', 0) except TypeError: utc_offset_mins = 0 if not datetime_: From 53ed142963b02c59de8bf71c5a764f9903bc9e49 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Wed, 1 Jul 2015 11:18:54 +0200 Subject: [PATCH 049/130] [#2494] Add timzone tests for util controller and helper --- ckan/tests/controllers/test_util.py | 29 +++++++++++++++++++++++++++ ckan/tests/legacy/lib/test_helpers.py | 11 +++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/ckan/tests/controllers/test_util.py b/ckan/tests/controllers/test_util.py index 51572273c57..f7d772a5aae 100644 --- a/ckan/tests/controllers/test_util.py +++ b/ckan/tests/controllers/test_util.py @@ -41,3 +41,32 @@ def test_redirect_no_params_2(self): params={'url': ''}, status=400, ) + + def test_set_timezone_valid(self): + app = self._get_test_app() + response = app.get( + url=url_for(controller='util', action='set_timezone_offset') + '/600', + status=200, + ) + assert_true('utc_timezone_offset: 600' in response) + + def test_set_timezone_string(self): + app = self._get_test_app() + response = app.get( + url=url_for(controller='util', action='set_timezone_offset') + '/test', + status=400, + ) + + def test_set_timezone_too_big(self): + app = self._get_test_app() + response = app.get( + url=url_for(controller='util', action='set_timezone_offset') + '/1000', + status=400, + ) + + def test_set_timezone_too_big(self): + app = self._get_test_app() + response = app.get( + url=url_for(controller='util', action='set_timezone_offset') + '/-841', + status=400, + ) diff --git a/ckan/tests/legacy/lib/test_helpers.py b/ckan/tests/legacy/lib/test_helpers.py index 2d7aee0ab8d..b505e95460b 100644 --- a/ckan/tests/legacy/lib/test_helpers.py +++ b/ckan/tests/legacy/lib/test_helpers.py @@ -2,7 +2,7 @@ import datetime from nose.tools import assert_equal, assert_raises -from pylons import config +from pylons import config, session from ckan.tests.legacy import * import ckan.lib.helpers as h @@ -26,6 +26,10 @@ def test_render_datetime(self): res = h.render_datetime(datetime.datetime(2008, 4, 13, 20, 40, 20, 123456)) assert_equal(res, 'April 13, 2008') + def test_render_datetime_with_hours(self): + res = h.render_datetime(datetime.datetime(2008, 4, 13, 20, 40, 20, 123456), with_hours=True) + assert_equal(res, 'April 13, 2008, 20:40') + def test_render_datetime_but_from_string(self): res = h.render_datetime('2008-04-13T20:40:20.123456') assert_equal(res, 'April 13, 2008') @@ -34,6 +38,11 @@ def test_render_datetime_blank(self): res = h.render_datetime(None) assert_equal(res, '') + def test_render_datetime_with_utc_offset_from_session(self): + session['utc_timezone_offset'] = 120 + res = h.render_datetime(datetime.datetime(2008, 4, 13, 20, 40, 20, 123456), with_hours=True) + assert_equal(res, 'April 13, 2008, 22:40') + def test_datetime_to_date_str(self): res = datetime.datetime(2008, 4, 13, 20, 40, 20, 123456).isoformat() assert_equal(res, '2008-04-13T20:40:20.123456') From b27e31e3ba423eaf690b7d6c3b482fc67601acb7 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Wed, 1 Jul 2015 11:23:59 +0200 Subject: [PATCH 050/130] [#2494] Make PEP-8 happy --- ckan/controllers/util.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ckan/controllers/util.py b/ckan/controllers/util.py index 231c4b82224..a74ce2b5c36 100644 --- a/ckan/controllers/util.py +++ b/ckan/controllers/util.py @@ -34,12 +34,18 @@ def set_timezone_offset(self, offset): if not (60*12 >= offset >= -(60*14)): raise ValueError except ValueError: - base.abort(400, _('Not a valid UTC offset value, must be between 720 (UTC-12) and -840 (UTC+14)')) + base.abort( + 400, + _( + 'Not a valid UTC offset value, must be ' + 'between 720 (UTC-12) and -840 (UTC+14)' + ) + ) session = request.environ['beaker.session'] session['utc_offset_mins'] = offset session.save() - return h.json.dumps({'utc_offset_mins': session.get('utc_offset_mins', 'No offset set')}) + return h.json.dumps({'utc_offset_mins': offset}) def markup(self): ''' Render all html elements out onto a single page. From ae10c834f48799085dae131fd113e90444d6b3de Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Wed, 1 Jul 2015 12:25:58 +0200 Subject: [PATCH 051/130] [#2494] Fix broken tests --- ckan/tests/controllers/test_util.py | 8 ++++---- ckan/tests/legacy/lib/test_helpers.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ckan/tests/controllers/test_util.py b/ckan/tests/controllers/test_util.py index f7d772a5aae..38bae1982e1 100644 --- a/ckan/tests/controllers/test_util.py +++ b/ckan/tests/controllers/test_util.py @@ -45,7 +45,7 @@ def test_redirect_no_params_2(self): def test_set_timezone_valid(self): app = self._get_test_app() response = app.get( - url=url_for(controller='util', action='set_timezone_offset') + '/600', + url=url_for(controller='util', action='set_timezone_offset', offset='600'), status=200, ) assert_true('utc_timezone_offset: 600' in response) @@ -53,20 +53,20 @@ def test_set_timezone_valid(self): def test_set_timezone_string(self): app = self._get_test_app() response = app.get( - url=url_for(controller='util', action='set_timezone_offset') + '/test', + url=url_for(controller='util', action='set_timezone_offset', offset='test'), status=400, ) def test_set_timezone_too_big(self): app = self._get_test_app() response = app.get( - url=url_for(controller='util', action='set_timezone_offset') + '/1000', + url=url_for(controller='util', action='set_timezone_offset', offset='721'), status=400, ) def test_set_timezone_too_big(self): app = self._get_test_app() response = app.get( - url=url_for(controller='util', action='set_timezone_offset') + '/-841', + url=url_for(controller='util', action='set_timezone_offset', offset='-841'), status=400, ) diff --git a/ckan/tests/legacy/lib/test_helpers.py b/ckan/tests/legacy/lib/test_helpers.py index b505e95460b..5735c71debb 100644 --- a/ckan/tests/legacy/lib/test_helpers.py +++ b/ckan/tests/legacy/lib/test_helpers.py @@ -28,7 +28,7 @@ def test_render_datetime(self): def test_render_datetime_with_hours(self): res = h.render_datetime(datetime.datetime(2008, 4, 13, 20, 40, 20, 123456), with_hours=True) - assert_equal(res, 'April 13, 2008, 20:40') + assert_equal(res, 'April 13, 2008, 20:40 (UTC+0)') def test_render_datetime_but_from_string(self): res = h.render_datetime('2008-04-13T20:40:20.123456') @@ -41,7 +41,7 @@ def test_render_datetime_blank(self): def test_render_datetime_with_utc_offset_from_session(self): session['utc_timezone_offset'] = 120 res = h.render_datetime(datetime.datetime(2008, 4, 13, 20, 40, 20, 123456), with_hours=True) - assert_equal(res, 'April 13, 2008, 22:40') + assert_equal(res, 'April 13, 2008, 22:40 (UTC+2)') def test_datetime_to_date_str(self): res = datetime.datetime(2008, 4, 13, 20, 40, 20, 123456).isoformat() From 4aa691a44fb621c97b75df6a56f40a655edde144 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Wed, 1 Jul 2015 13:11:51 +0200 Subject: [PATCH 052/130] Try to fix the session-based test --- ckan/tests/legacy/lib/test_helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ckan/tests/legacy/lib/test_helpers.py b/ckan/tests/legacy/lib/test_helpers.py index 5735c71debb..f0b41b04267 100644 --- a/ckan/tests/legacy/lib/test_helpers.py +++ b/ckan/tests/legacy/lib/test_helpers.py @@ -40,6 +40,7 @@ def test_render_datetime_blank(self): def test_render_datetime_with_utc_offset_from_session(self): session['utc_timezone_offset'] = 120 + session.save() res = h.render_datetime(datetime.datetime(2008, 4, 13, 20, 40, 20, 123456), with_hours=True) assert_equal(res, 'April 13, 2008, 22:40 (UTC+2)') From 2863a05ed9c7da1a221e6faafeeb376c21a62301 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Wed, 1 Jul 2015 13:42:56 +0200 Subject: [PATCH 053/130] Import assert_true in test --- ckan/tests/controllers/test_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/tests/controllers/test_util.py b/ckan/tests/controllers/test_util.py index 38bae1982e1..2c249df7f27 100644 --- a/ckan/tests/controllers/test_util.py +++ b/ckan/tests/controllers/test_util.py @@ -1,4 +1,4 @@ -from nose.tools import assert_equal +from nose.tools import assert_equal, assert_true from pylons.test import pylonsapp import paste.fixture From 52451f705e0de6825bcb6704060cb706bb0d4414 Mon Sep 17 00:00:00 2001 From: joetsoi Date: Wed, 1 Jul 2015 14:00:27 +0100 Subject: [PATCH 054/130] [#2513] organization controller tests --- .../snippets/organization_form.html | 2 +- ckan/tests/controllers/test_organization.py | 198 +++++++++++++++++- 2 files changed, 197 insertions(+), 3 deletions(-) diff --git a/ckan/templates/organization/snippets/organization_form.html b/ckan/templates/organization/snippets/organization_form.html index f980a2ab6c0..73fa115e2d2 100644 --- a/ckan/templates/organization/snippets/organization_form.html +++ b/ckan/templates/organization/snippets/organization_form.html @@ -1,6 +1,6 @@ {% import 'macros/form.html' as form %} -
+ Date: Wed, 1 Jul 2015 16:36:45 +0200 Subject: [PATCH 055/130] Replace assert_true with assert_in --- ckan/tests/controllers/test_util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ckan/tests/controllers/test_util.py b/ckan/tests/controllers/test_util.py index 2c249df7f27..c352c41f9ed 100644 --- a/ckan/tests/controllers/test_util.py +++ b/ckan/tests/controllers/test_util.py @@ -1,4 +1,4 @@ -from nose.tools import assert_equal, assert_true +from nose.tools import assert_equal, assert_in from pylons.test import pylonsapp import paste.fixture @@ -48,7 +48,7 @@ def test_set_timezone_valid(self): url=url_for(controller='util', action='set_timezone_offset', offset='600'), status=200, ) - assert_true('utc_timezone_offset: 600' in response) + assert_in('"utc_timezone_offset": 600', response) def test_set_timezone_string(self): app = self._get_test_app() From d8d0f79f6d02d2f70421ae11e35b4f8ca01c8e81 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Wed, 1 Jul 2015 16:56:05 +0200 Subject: [PATCH 056/130] Add PylonsTestCase to test session-based behaviour --- ckan/tests/legacy/lib/test_helpers.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ckan/tests/legacy/lib/test_helpers.py b/ckan/tests/legacy/lib/test_helpers.py index f0b41b04267..99cf7ced1c9 100644 --- a/ckan/tests/legacy/lib/test_helpers.py +++ b/ckan/tests/legacy/lib/test_helpers.py @@ -38,12 +38,6 @@ def test_render_datetime_blank(self): res = h.render_datetime(None) assert_equal(res, '') - def test_render_datetime_with_utc_offset_from_session(self): - session['utc_timezone_offset'] = 120 - session.save() - res = h.render_datetime(datetime.datetime(2008, 4, 13, 20, 40, 20, 123456), with_hours=True) - assert_equal(res, 'April 13, 2008, 22:40 (UTC+2)') - def test_datetime_to_date_str(self): res = datetime.datetime(2008, 4, 13, 20, 40, 20, 123456).isoformat() assert_equal(res, '2008-04-13T20:40:20.123456') @@ -186,3 +180,11 @@ def test_get_pkg_dict_extra(self): assert_equal(h.get_pkg_dict_extra(pkg_dict, 'extra_not_found'), None) assert_equal(h.get_pkg_dict_extra(pkg_dict, 'extra_not_found', 'default_value'), 'default_value') + + +class TestHelpersWithPylons(pylons_controller.PylonsTestCase): + def test_render_datetime_with_utc_offset_from_session(self): + session['utc_timezone_offset'] = 120 + session.save() + res = h.render_datetime(datetime.datetime(2008, 4, 13, 20, 40, 20, 123456), with_hours=True) + assert_equal(res, 'April 13, 2008, 22:40 (UTC+2)') From 7c131dd53069b9233032a833825ccc50558ed2e7 Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Fri, 3 Jul 2015 14:52:16 +0100 Subject: [PATCH 057/130] [#2515] Update coveralls badge in template --- ckan/pastertemplates/template/README.rst_tmpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ckan/pastertemplates/template/README.rst_tmpl b/ckan/pastertemplates/template/README.rst_tmpl index 140e25c1a4b..b7bb16a4bf2 100644 --- a/ckan/pastertemplates/template/README.rst_tmpl +++ b/ckan/pastertemplates/template/README.rst_tmpl @@ -5,8 +5,8 @@ .. image:: https://travis-ci.org/{{ github_user_name }}/{{ project }}.svg?branch=master :target: https://travis-ci.org/{{ github_user_name }}/{{ project }} -.. image:: https://coveralls.io/repos/{{ github_user_name }}/{{ project }}/badge.png?branch=master - :target: https://coveralls.io/r/{{ github_user_name }}/{{ project }}?branch=master +.. image:: https://coveralls.io/repos/{{ github_user_name }}/{{ project }}/badge.svg + :target: https://coveralls.io/r/{{ github_user_name }}/{{ project }} .. image:: https://pypip.in/download/{{ project }}/badge.svg :target: https://pypi.python.org/pypi//{{ project }}/ From 3e3a7c8db91428e772e0545315bf9663d4cbc43a Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Fri, 3 Jul 2015 16:30:15 -0400 Subject: [PATCH 058/130] [#2519] update resource last_modified on file uploads --- ckan/lib/dictization/model_save.py | 5 +++-- ckan/lib/uploader.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py index ad0c1e8e454..cfdf4973217 100644 --- a/ckan/lib/dictization/model_save.py +++ b/ckan/lib/dictization/model_save.py @@ -27,7 +27,6 @@ def resource_dict_save(res_dict, context): table = class_mapper(model.Resource).mapped_table fields = [field.name for field in table.c] - # Resource extras not submitted will be removed from the existing extras # dict new_extras = {} @@ -40,7 +39,9 @@ def resource_dict_save(res_dict, context): if isinstance(getattr(obj, key), datetime.datetime): if getattr(obj, key).isoformat() == value: continue - if key == 'url' and not new and obj.url <> value: + if key == 'last_modified' and not new: + obj.url_changed = True + if key == 'url' and not new and obj.url != value: obj.url_changed = True setattr(obj, key, value) else: diff --git a/ckan/lib/uploader.py b/ckan/lib/uploader.py index f9246386d2b..775f8adac1b 100644 --- a/ckan/lib/uploader.py +++ b/ckan/lib/uploader.py @@ -173,6 +173,7 @@ def __init__(self, resource): self.filename = munge.munge_filename(self.filename) resource['url'] = self.filename resource['url_type'] = 'upload' + resource['last_modified'] = datetime.datetime.utcnow() self.upload_file = upload_field_storage.file elif self.clear: resource['url_type'] = '' From 99f58ba99e69bfdd50e5dc5ac2b66cb622686a28 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Fri, 3 Jul 2015 17:03:58 -0400 Subject: [PATCH 059/130] [#2520] remove AttributeDict --- ckan/logic/__init__.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/ckan/logic/__init__.py b/ckan/logic/__init__.py index 883b15c121c..2bc7626af4f 100644 --- a/ckan/logic/__init__.py +++ b/ckan/logic/__init__.py @@ -24,19 +24,6 @@ class UsernamePasswordError(Exception): pass -class AttributeDict(dict): - def __getattr__(self, name): - try: - return self[name] - except KeyError: - raise AttributeError('No such attribute %r' % name) - - def __setattr__(self, name, value): - raise AttributeError( - 'You cannot set attributes of this object directly' - ) - - class ActionError(Exception): pass From 2b3f0523d985cb19597fb9316d8ddfa988072689 Mon Sep 17 00:00:00 2001 From: joetsoi Date: Mon, 6 Jul 2015 09:33:37 +0100 Subject: [PATCH 060/130] [#2489] PEP8 --- ckan/tests/controllers/test_admin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ckan/tests/controllers/test_admin.py b/ckan/tests/controllers/test_admin.py index 32264acdd03..1dc5ee5d917 100644 --- a/ckan/tests/controllers/test_admin.py +++ b/ckan/tests/controllers/test_admin.py @@ -413,5 +413,4 @@ def test_admin_config_update(self): # title tag contains new value home_page_after = app.get('/', status=200) - assert_true('Welcome - My Updated Site Title' - in home_page_after) + assert_true('Welcome - My Updated Site Title' in home_page_after) From 0ae0573724bc79da8865d7890b90b721471f5fd3 Mon Sep 17 00:00:00 2001 From: David Read Date: Mon, 6 Jul 2015 11:22:53 +0000 Subject: [PATCH 061/130] [#1431] Add "since_id" and "since_time" to revision_list so that you can page through revisions. --- ckan/logic/action/get.py | 23 +++++++++- ckan/tests/logic/action/test_get.py | 68 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 1596563bb7c..0f4dc1e183e 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -192,11 +192,30 @@ def revision_list(context, data_dict): ''' model = context['model'] + since_id = data_dict.get('since_id') + since_time_str = data_dict.get('since_time') + PAGE_LIMIT = 50 _check_access('revision_list', context, data_dict) - revs = model.Session.query(model.Revision).all() - return [rev.id for rev in revs] + since_time = None + if since_id: + rev = model.Session.query(model.Revision).get(since_id) + if rev is None: + raise NotFound + since_time = rev.timestamp + elif since_time_str: + try: + from ckan.lib import helpers as h + since_time = h.date_str_to_datetime(since_time_str) + except ValueError: + raise logic.ValidationError('Timestamp did not parse') + revs = model.Session.query(model.Revision) + if since_time: + revs = revs.filter(model.Revision.timestamp > since_time) + revs = revs.order_by(model.Revision.timestamp) \ + .limit(PAGE_LIMIT) + return [rev_.id for rev_ in revs] def package_revision_list(context, data_dict): diff --git a/ckan/tests/logic/action/test_get.py b/ckan/tests/logic/action/test_get.py index 38702a53949..bde47a29ca8 100644 --- a/ckan/tests/logic/action/test_get.py +++ b/ckan/tests/logic/action/test_get.py @@ -1642,3 +1642,71 @@ def test_tag_list_vocab_not_found(self): nose.tools.assert_raises( logic.NotFound, helpers.call_action, 'tag_list', vocabulary_id='does-not-exist') + + +class TestRevisionList(helpers.FunctionalTestBase): + + @classmethod + def setup_class(cls): + super(TestRevisionList, cls).setup_class() + helpers.reset_db() + + # Error cases + + def test_date_instead_of_revision(self): + nose.tools.assert_raises( + logic.NotFound, + helpers.call_action, + 'revision_list', + since_id='2010-01-01T00:00:00') + + def test_date_invalid(self): + nose.tools.assert_raises( + logic.ValidationError, + helpers.call_action, + 'revision_list', + since_time='2010-02-31T00:00:00') + + def test_revision_doesnt_exist(self): + nose.tools.assert_raises( + logic.NotFound, + helpers.call_action, + 'revision_list', + since_id='1234') + + # Normal usage + + @classmethod + def _create_revisions(cls, num_revisions): + from ckan import model + rev_ids = [] + for i in xrange(num_revisions): + rev = model.repo.new_revision() + rev.id = unicode(i) + model.Session.commit() + rev_ids.append(rev.id) + return rev_ids + + def test_all_revisions(self): + rev_ids = self._create_revisions(2) + revs = helpers.call_action('revision_list') + eq(revs[-len(rev_ids):], rev_ids) + + def test_revisions_since_id(self): + rev_ids = self._create_revisions(4) + revs = helpers.call_action('revision_list', since_id=rev_ids[1]) + eq(revs, rev_ids[2:]) + + def test_revisions_since_time(self): + from ckan import model + rev_ids = self._create_revisions(4) + + rev1 = model.Session.query(model.Revision).get(rev_ids[1]) + revs = helpers.call_action('revision_list', + since_time=rev1.timestamp.isoformat()) + eq(revs, rev_ids[2:]) + + def test_revisions_returned_are_limited(self): + rev_ids = self._create_revisions(55) + revs = helpers.call_action('revision_list', since_id=rev_ids[1]) + eq(revs, rev_ids[2:52]) # i.e. limited to 50 From 73cd88df87630f56a61c4f17647ae96028abbe0a Mon Sep 17 00:00:00 2001 From: David Read Date: Mon, 6 Jul 2015 11:30:31 +0000 Subject: [PATCH 062/130] [#1431] Docstring. --- ckan/logic/action/get.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 0f4dc1e183e..88c55bad798 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -186,9 +186,16 @@ def current_package_list_with_resources(context, data_dict): def revision_list(context, data_dict): - '''Return a list of the IDs of the site's revisions. + '''Return a list of the IDs of the site's revisions in chronological order. - :rtype: list of strings + Since the results are limited to 50 IDs, you can page through them using + parameter ``since_id``. + + :param since_id: the revision ID after which you want the revisions + :type id: string + :param since_time: the timestamp after which you want the revisions + :type id: string + :rtype: list of revision IDs, limited to 50 ''' model = context['model'] From bf64b5689ba6d5dfba3cd0387d2d33e0f5b30c89 Mon Sep 17 00:00:00 2001 From: David Read Date: Mon, 6 Jul 2015 14:53:55 +0000 Subject: [PATCH 063/130] [#1431] Revert sort to "newest first" as it always was. Add sort param to allow chronological, which makes more sense. --- ckan/logic/action/get.py | 21 +++++++++++++--- ckan/tests/logic/action/test_get.py | 37 +++++++++++++++++++++-------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 88c55bad798..e2bed14d499 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -186,7 +186,8 @@ def current_package_list_with_resources(context, data_dict): def revision_list(context, data_dict): - '''Return a list of the IDs of the site's revisions in chronological order. + '''Return a list of the IDs of the site's revisions. They are sorted with + the newest first. Since the results are limited to 50 IDs, you can page through them using parameter ``since_id``. @@ -195,12 +196,16 @@ def revision_list(context, data_dict): :type id: string :param since_time: the timestamp after which you want the revisions :type id: string + :param sort: the order to sort the related items in, possible values are + 'time_asc', 'time_desc' (default). (optional) + :type sort: string :rtype: list of revision IDs, limited to 50 ''' model = context['model'] since_id = data_dict.get('since_id') since_time_str = data_dict.get('since_time') + sort_str = data_dict.get('sort') PAGE_LIMIT = 50 _check_access('revision_list', context, data_dict) @@ -220,8 +225,18 @@ def revision_list(context, data_dict): revs = model.Session.query(model.Revision) if since_time: revs = revs.filter(model.Revision.timestamp > since_time) - revs = revs.order_by(model.Revision.timestamp) \ - .limit(PAGE_LIMIT) + + sortables = { + 'time_asc': model.Revision.timestamp.asc, + 'time_desc': model.Revision.timestamp.desc, + } + if sort_str and sort_str not in sortables: + raise logic.ValidationError( + 'Invalid sort value. Allowable values: %r' % sortables.keys()) + sort_func = sortables.get(sort_str or 'time_desc') + revs = revs.order_by(sort_func()) + + revs = revs.limit(PAGE_LIMIT) return [rev_.id for rev_ in revs] diff --git a/ckan/tests/logic/action/test_get.py b/ckan/tests/logic/action/test_get.py index bde47a29ca8..47a17acb29f 100644 --- a/ckan/tests/logic/action/test_get.py +++ b/ckan/tests/logic/action/test_get.py @@ -1674,6 +1674,13 @@ def test_revision_doesnt_exist(self): 'revision_list', since_id='1234') + def test_sort_param_not_valid(self): + nose.tools.assert_raises( + logic.ValidationError, + helpers.call_action, + 'revision_list', + sort='invalid') + # Normal usage @classmethod @@ -1690,23 +1697,33 @@ def _create_revisions(cls, num_revisions): def test_all_revisions(self): rev_ids = self._create_revisions(2) revs = helpers.call_action('revision_list') - eq(revs[-len(rev_ids):], rev_ids) + # only test the 2 newest revisions, since the system creates one at + # start-up. + eq(revs[:2], rev_ids[::-1]) def test_revisions_since_id(self): - rev_ids = self._create_revisions(4) - revs = helpers.call_action('revision_list', since_id=rev_ids[1]) - eq(revs, rev_ids[2:]) + self._create_revisions(4) + revs = helpers.call_action('revision_list', since_id='1') + eq(revs, ['3', '2']) def test_revisions_since_time(self): from ckan import model - rev_ids = self._create_revisions(4) + self._create_revisions(4) - rev1 = model.Session.query(model.Revision).get(rev_ids[1]) + rev1 = model.Session.query(model.Revision).get('1') revs = helpers.call_action('revision_list', since_time=rev1.timestamp.isoformat()) - eq(revs, rev_ids[2:]) + eq(revs, ['3', '2']) def test_revisions_returned_are_limited(self): - rev_ids = self._create_revisions(55) - revs = helpers.call_action('revision_list', since_id=rev_ids[1]) - eq(revs, rev_ids[2:52]) # i.e. limited to 50 + self._create_revisions(55) + revs = helpers.call_action('revision_list', since_id='1') + eq(len(revs), 50) # i.e. limited to 50 + eq(revs[0], '54') + eq(revs[-1], '5') + + def test_sort_asc(self): + self._create_revisions(4) + revs = helpers.call_action('revision_list', since_id='1', + sort='time_asc') + eq(revs, ['2', '3']) From 17ede31f2edeeec1403bf7e343a3e9e5a9e1c1f2 Mon Sep 17 00:00:00 2001 From: David Read Date: Tue, 7 Jul 2015 15:49:34 +0000 Subject: [PATCH 064/130] Creates reams of logging on the first request - not needed unless working on this specific code. --- ckan/authz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/authz.py b/ckan/authz.py index 63b7221ae1b..74cdae77dfd 100644 --- a/ckan/authz.py +++ b/ckan/authz.py @@ -81,7 +81,7 @@ def _build(self): resolved_auth_function_plugins[name] ) ) - log.debug('Auth function {0} from plugin {1} was inserted'.format(name, plugin.name)) + #log.debug('Auth function {0} from plugin {1} was inserted'.format(name, plugin.name)) resolved_auth_function_plugins[name] = plugin.name fetched_auth_functions[name] = auth_function # Use the updated ones in preference to the originals. From 8796897b8fe021c339eaf108fb9a60ca29b08f77 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 12:52:21 +0200 Subject: [PATCH 065/130] Add pytz requirement --- requirements.in | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.in b/requirements.in index de34d1a0f88..71210901858 100644 --- a/requirements.in +++ b/requirements.in @@ -29,3 +29,4 @@ WebHelpers==1.3 WebOb==1.0.8 zope.interface==4.1.1 unicodecsv>=0.9 +pytz==2012j From 75db95755a19366870ceb6cf6a43a58f86c0cf71 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 12:52:06 +0200 Subject: [PATCH 066/130] Remove timezone api --- ckan/config/routing.py | 1 - ckan/controllers/util.py | 24 +-------------------- ckan/lib/formatters.py | 17 +++------------ ckan/lib/helpers.py | 7 +----- ckan/public/base/javascript/main.js | 14 ------------ ckan/tests/controllers/test_util.py | 31 +-------------------------- ckan/tests/legacy/lib/test_helpers.py | 10 +-------- 7 files changed, 7 insertions(+), 97 deletions(-) diff --git a/ckan/config/routing.py b/ckan/config/routing.py index abadcce83aa..4f92b715991 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -443,7 +443,6 @@ def make_map(): with SubMapper(map, controller='util') as m: m.connect('/i18n/strings_{lang}.js', action='i18n_js_strings') - m.connect('/util/set_timezone_offset/{offset}', action='set_timezone_offset') m.connect('/util/redirect', action='redirect') m.connect('/testing/primer', action='primer') m.connect('/testing/markup', action='markup') diff --git a/ckan/controllers/util.py b/ckan/controllers/util.py index a74ce2b5c36..840c6833a04 100644 --- a/ckan/controllers/util.py +++ b/ckan/controllers/util.py @@ -3,7 +3,7 @@ import ckan.lib.base as base import ckan.lib.i18n as i18n import ckan.lib.helpers as h -from ckan.common import _, request +from ckan.common import _ class UtilController(base.BaseController): @@ -25,28 +25,6 @@ def primer(self): This is useful for development/styling of ckan. ''' return base.render('development/primer.html') - def set_timezone_offset(self, offset): - ''' save the users UTC timezone offset in the beaker session ''' - # check if the value can be successfully casted to an int - try: - offset = int(offset) - # UTC offsets are between UTC-12 until UTC+14 - if not (60*12 >= offset >= -(60*14)): - raise ValueError - except ValueError: - base.abort( - 400, - _( - 'Not a valid UTC offset value, must be ' - 'between 720 (UTC-12) and -840 (UTC+14)' - ) - ) - - session = request.environ['beaker.session'] - session['utc_offset_mins'] = offset - session.save() - return h.json.dumps({'utc_offset_mins': offset}) - def markup(self): ''' Render all html elements out onto a single page. This is useful for development/styling of ckan. ''' diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py index e917b65adc1..20852668e10 100644 --- a/ckan/lib/formatters.py +++ b/ckan/lib/formatters.py @@ -72,7 +72,7 @@ def _month_dec(): _month_sept, _month_oct, _month_nov, _month_dec] -def localised_nice_date(datetime_, show_date=False, with_hours=False, utc_offset_mins=0): +def localised_nice_date(datetime_, show_date=False, with_hours=False): ''' Returns a friendly localised unicode representation of a datetime. :param datetime_: The date to format @@ -137,25 +137,14 @@ def months_between(date1, date2): tz_datetime = tz_datetime.astimezone( pytz.timezone(timezone_name) ) - timezone_display_name = tc_dateimt.tzinfo.zone except pytz.UnknownTimeZoneError: if timezone_name != '': log.warning( 'Timezone `%s` not found. ' 'Please provide a valid timezone setting in `ckan.timezone` ' 'or leave the field empty. All valid values can be found in ' - 'pytz.all_timezones. You can specify the special value ' - '`browser` to displayed the dates according to the browser ' - 'settings of the visiting user.' % timezone_name + 'pytz.all_timezones.' % timezone_name ) - offset = datetime.timedelta(minutes=utc_offset_mins) - tz_datetime = tz_datetime + offset - - utc_offset_hours = utc_offset_mins / 60 - timezone_display_name = "UTC{1:+0.{0}f}".format( - int(utc_offset_hours % 1 > 0), - utc_offset_hours - ) # actual date details = { @@ -164,7 +153,7 @@ def months_between(date1, date2): 'day': tz_datetime.day, 'year': tz_datetime.year, 'month': _MONTH_FUNCTIONS[tz_datetime.month - 1](), - 'timezone': timezone_display_name, + 'timezone': tz_datetime.tzinfo.zone, } if with_hours: diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 8c2c5603d15..f604da3ab7d 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -963,10 +963,6 @@ def render_datetime(datetime_, date_format=None, with_hours=False): :rtype: string ''' datetime_ = _datestamp_to_datetime(datetime_) - try: - utc_offset_mins = session.get('utc_offset_mins', 0) - except TypeError: - utc_offset_mins = 0 if not datetime_: return '' # if date_format was supplied we use it @@ -974,8 +970,7 @@ def render_datetime(datetime_, date_format=None, with_hours=False): return datetime_.strftime(date_format) # the localised date return formatters.localised_nice_date(datetime_, show_date=True, - with_hours=with_hours, - utc_offset_mins=utc_offset_mins) + with_hours=with_hours) def date_str_to_datetime(date_str): diff --git a/ckan/public/base/javascript/main.js b/ckan/public/base/javascript/main.js index 8171fcacb6c..b33924d132e 100644 --- a/ckan/public/base/javascript/main.js +++ b/ckan/public/base/javascript/main.js @@ -30,20 +30,6 @@ this.ckan = this.ckan || {}; ckan.SITE_ROOT = getRootFromData('siteRoot'); ckan.LOCALE_ROOT = getRootFromData('localeRoot'); - /* Save UTC offset of user in browser to display dates correctly - * getTimezoneOffset returns the offset between the local time and UTC, - * but we want to store it the other way round. - * see http://mdn.io/getTimezoneOffset for details - */ - now = new Date(); - utc_timezone_offset = -(now.getTimezoneOffset()); - $.ajax( - ckan.sandbox().client.url('/util/set_timezone_offset/' + utc_timezone_offset), - { - async:false - } - ); - // Load the localisations before instantiating the modules. ckan.sandbox().client.getLocaleData(locale).done(function (data) { ckan.i18n.load(data); diff --git a/ckan/tests/controllers/test_util.py b/ckan/tests/controllers/test_util.py index c352c41f9ed..51572273c57 100644 --- a/ckan/tests/controllers/test_util.py +++ b/ckan/tests/controllers/test_util.py @@ -1,4 +1,4 @@ -from nose.tools import assert_equal, assert_in +from nose.tools import assert_equal from pylons.test import pylonsapp import paste.fixture @@ -41,32 +41,3 @@ def test_redirect_no_params_2(self): params={'url': ''}, status=400, ) - - def test_set_timezone_valid(self): - app = self._get_test_app() - response = app.get( - url=url_for(controller='util', action='set_timezone_offset', offset='600'), - status=200, - ) - assert_in('"utc_timezone_offset": 600', response) - - def test_set_timezone_string(self): - app = self._get_test_app() - response = app.get( - url=url_for(controller='util', action='set_timezone_offset', offset='test'), - status=400, - ) - - def test_set_timezone_too_big(self): - app = self._get_test_app() - response = app.get( - url=url_for(controller='util', action='set_timezone_offset', offset='721'), - status=400, - ) - - def test_set_timezone_too_big(self): - app = self._get_test_app() - response = app.get( - url=url_for(controller='util', action='set_timezone_offset', offset='-841'), - status=400, - ) diff --git a/ckan/tests/legacy/lib/test_helpers.py b/ckan/tests/legacy/lib/test_helpers.py index 99cf7ced1c9..c6b178bf968 100644 --- a/ckan/tests/legacy/lib/test_helpers.py +++ b/ckan/tests/legacy/lib/test_helpers.py @@ -2,7 +2,7 @@ import datetime from nose.tools import assert_equal, assert_raises -from pylons import config, session +from pylons import config from ckan.tests.legacy import * import ckan.lib.helpers as h @@ -180,11 +180,3 @@ def test_get_pkg_dict_extra(self): assert_equal(h.get_pkg_dict_extra(pkg_dict, 'extra_not_found'), None) assert_equal(h.get_pkg_dict_extra(pkg_dict, 'extra_not_found', 'default_value'), 'default_value') - - -class TestHelpersWithPylons(pylons_controller.PylonsTestCase): - def test_render_datetime_with_utc_offset_from_session(self): - session['utc_timezone_offset'] = 120 - session.save() - res = h.render_datetime(datetime.datetime(2008, 4, 13, 20, 40, 20, 123456), with_hours=True) - assert_equal(res, 'April 13, 2008, 22:40 (UTC+2)') From 38e4f5071f0eefc2574b5d6462a093f04305ab60 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 13:07:33 +0200 Subject: [PATCH 067/130] [#2494] Fix comment of timezone format --- ckan/lib/formatters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py index 20852668e10..697dba3c19e 100644 --- a/ckan/lib/formatters.py +++ b/ckan/lib/formatters.py @@ -158,7 +158,7 @@ def months_between(date1, date2): if with_hours: return ( - # NOTE: This is for translating dates like `April 24, 2013, 10:45 (UTC+2)` + # NOTE: This is for translating dates like `April 24, 2013, 10:45 (Europe/Zurich)` _('{month} {day}, {year}, {hour:02}:{min:02} ({timezone})') \ .format(**details)) else: From 5d3f42d3f788820024d2f653165374accd9a8128 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 13:36:46 +0200 Subject: [PATCH 068/130] [#2494] Move timezone conversion to _datestamp_to_datetime() --- ckan/lib/formatters.py | 36 +++++++++++++----------------------- ckan/lib/helpers.py | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py index 697dba3c19e..eafedb43b16 100644 --- a/ckan/lib/formatters.py +++ b/ckan/lib/formatters.py @@ -101,7 +101,11 @@ def months_between(date1, date2): return months if not show_date: - now = datetime.datetime.now() + now = datetime.datetime.utcnow() + if datetime_.tzinfo is not None: + now = now.replace(tzinfo=datetime_.tzinfo) + else: + now = now.replace(tzinfo=pytz.utc) date_diff = now - datetime_ days = date_diff.days if days < 1 and now > datetime_: @@ -129,31 +133,17 @@ def months_between(date1, date2): return ungettext('over {years} year ago', 'over {years} years ago', months / 12).format(years=months / 12) - # all dates are considered UTC internally, - # change output if `ckan.timezone` is available - tz_datetime = datetime_.replace(tzinfo=pytz.utc) - timezone_name = config.get('ckan.timezone', '') - try: - tz_datetime = tz_datetime.astimezone( - pytz.timezone(timezone_name) - ) - except pytz.UnknownTimeZoneError: - if timezone_name != '': - log.warning( - 'Timezone `%s` not found. ' - 'Please provide a valid timezone setting in `ckan.timezone` ' - 'or leave the field empty. All valid values can be found in ' - 'pytz.all_timezones.' % timezone_name - ) + if datetime_.tzinfo is not None: + datetime_ = datetime_.replace(tzinfo=pytz.utc) # actual date details = { - 'min': tz_datetime.minute, - 'hour': tz_datetime.hour, - 'day': tz_datetime.day, - 'year': tz_datetime.year, - 'month': _MONTH_FUNCTIONS[tz_datetime.month - 1](), - 'timezone': tz_datetime.tzinfo.zone, + 'min': datetime_.minute, + 'hour': datetime_.hour, + 'day': datetime_.day, + 'year': datetime_.year, + 'month': _MONTH_FUNCTIONS[datetime_.month - 1](), + 'timezone': datetime_.tzinfo.zone, } if with_hours: diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index f604da3ab7d..5bfa530e06b 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -10,6 +10,7 @@ import logging import re import os +import pytz import urllib import urlparse import pprint @@ -72,6 +73,27 @@ def _datestamp_to_datetime(datetime_): # check we are now a datetime if not isinstance(datetime_, datetime.datetime): return None + + if datetime_.tzinfo is not None: + return datetime_ + + # all dates are considered UTC internally, + # change output if `ckan.timezone` is available + datetime_ = datetime_.replace(tzinfo=pytz.utc) + timezone_name = config.get('ckan.timezone', '') + try: + datetime_ = datetime_.astimezone( + pytz.timezone(timezone_name) + ) + except pytz.UnknownTimeZoneError: + if timezone_name != '': + log.warning( + 'Timezone `%s` not found. ' + 'Please provide a valid timezone setting in `ckan.timezone` ' + 'or leave the field empty. All valid values can be found in ' + 'pytz.all_timezones.' % timezone_name + ) + return datetime_ @@ -965,6 +987,7 @@ def render_datetime(datetime_, date_format=None, with_hours=False): datetime_ = _datestamp_to_datetime(datetime_) if not datetime_: return '' + # if date_format was supplied we use it if date_format: return datetime_.strftime(date_format) From 7d1d391def7d61fe08d1824ceaa3e0e230f8d21b Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 15:00:30 +0200 Subject: [PATCH 069/130] [#2494] Only set UTC if datetime has no timezone info yet --- ckan/lib/formatters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py index eafedb43b16..2c7b3ccdf90 100644 --- a/ckan/lib/formatters.py +++ b/ckan/lib/formatters.py @@ -133,7 +133,7 @@ def months_between(date1, date2): return ungettext('over {years} year ago', 'over {years} years ago', months / 12).format(years=months / 12) - if datetime_.tzinfo is not None: + if datetime_.tzinfo is None: datetime_ = datetime_.replace(tzinfo=pytz.utc) # actual date From 514444ffe077071c9b6925288ab8120d26948ea1 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 16:44:50 +0200 Subject: [PATCH 070/130] [#2494] Add moment.js and load dates in browser timezone --- ckan/public/base/javascript/main.js | 8 ++ ckan/public/base/vendor/moment.js | 81 +++++++++++++++++++ ckan/public/base/vendor/resource.config | 1 + .../package/snippets/additional_info.html | 13 ++- 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 ckan/public/base/vendor/moment.js diff --git a/ckan/public/base/javascript/main.js b/ckan/public/base/javascript/main.js index b33924d132e..25f8755ca5a 100644 --- a/ckan/public/base/javascript/main.js +++ b/ckan/public/base/javascript/main.js @@ -20,6 +20,7 @@ this.ckan = this.ckan || {}; ckan.initialize = function () { var body = jQuery('body'); var locale = jQuery('html').attr('lang'); + var browserLocale = window.navigator.userLanguage || window.navigator.language; var location = window.location; var root = location.protocol + '//' + location.host; @@ -30,6 +31,13 @@ this.ckan = this.ckan || {}; ckan.SITE_ROOT = getRootFromData('siteRoot'); ckan.LOCALE_ROOT = getRootFromData('localeRoot'); + // Convert all datetimes to the users timezone + jQuery('.datetime').each(function() { + moment.locale(browserLocale); + var date = moment(jQuery(this).data().datetime); + jQuery(this).html(date.format("LL, LT ([UTC]Z)")); + }) + // Load the localisations before instantiating the modules. ckan.sandbox().client.getLocaleData(locale).done(function (data) { ckan.i18n.load(data); diff --git a/ckan/public/base/vendor/moment.js b/ckan/public/base/vendor/moment.js new file mode 100644 index 00000000000..071bdf9afed --- /dev/null +++ b/ckan/public/base/vendor/moment.js @@ -0,0 +1,81 @@ +!function(a,b){"object"==typeof exports&&"undefined"!=typeof module?module.exports=b():"function"==typeof define&&define.amd?define(b):a.moment=b()}(this,function(){"use strict";function a(){return Gd.apply(null,arguments)}function b(a){Gd=a}function c(a){return"[object Array]"===Object.prototype.toString.call(a)}function d(a){return a instanceof Date||"[object Date]"===Object.prototype.toString.call(a)}function e(a,b){var c,d=[];for(c=0;c0)for(c in Id)d=Id[c],e=b[d],"undefined"!=typeof e&&(a[d]=e);return a}function n(b){m(this,b),this._d=new Date(+b._d),Jd===!1&&(Jd=!0,a.updateOffset(this),Jd=!1)}function o(a){return a instanceof n||null!=a&&null!=a._isAMomentObject}function p(a){var b=+a,c=0;return 0!==b&&isFinite(b)&&(c=b>=0?Math.floor(b):Math.ceil(b)),c}function q(a,b,c){var d,e=Math.min(a.length,b.length),f=Math.abs(a.length-b.length),g=0;for(d=0;e>d;d++)(c&&a[d]!==b[d]||!c&&p(a[d])!==p(b[d]))&&g++;return g+f}function r(){}function s(a){return a?a.toLowerCase().replace("_","-"):a}function t(a){for(var b,c,d,e,f=0;f0;){if(d=u(e.slice(0,b).join("-")))return d;if(c&&c.length>=b&&q(e,c,!0)>=b-1)break;b--}f++}return null}function u(a){var b=null;if(!Kd[a]&&"undefined"!=typeof module&&module&&module.exports)try{b=Hd._abbr,require("./locale/"+a),v(b)}catch(c){}return Kd[a]}function v(a,b){var c;return a&&(c="undefined"==typeof b?x(a):w(a,b),c&&(Hd=c)),Hd._abbr}function w(a,b){return null!==b?(b.abbr=a,Kd[a]||(Kd[a]=new r),Kd[a].set(b),v(a),Kd[a]):(delete Kd[a],null)}function x(a){var b;if(a&&a._locale&&a._locale._abbr&&(a=a._locale._abbr),!a)return Hd;if(!c(a)){if(b=u(a))return b;a=[a]}return t(a)}function y(a,b){var c=a.toLowerCase();Ld[c]=Ld[c+"s"]=Ld[b]=a}function z(a){return"string"==typeof a?Ld[a]||Ld[a.toLowerCase()]:void 0}function A(a){var b,c,d={};for(c in a)f(a,c)&&(b=z(c),b&&(d[b]=a[c]));return d}function B(b,c){return function(d){return null!=d?(D(this,b,d),a.updateOffset(this,c),this):C(this,b)}}function C(a,b){return a._d["get"+(a._isUTC?"UTC":"")+b]()}function D(a,b,c){return a._d["set"+(a._isUTC?"UTC":"")+b](c)}function E(a,b){var c;if("object"==typeof a)for(c in a)this.set(c,a[c]);else if(a=z(a),"function"==typeof this[a])return this[a](b);return this}function F(a,b,c){for(var d=""+Math.abs(a),e=a>=0;d.lengthb;b++)Pd[d[b]]?d[b]=Pd[d[b]]:d[b]=H(d[b]);return function(e){var f="";for(b=0;c>b;b++)f+=d[b]instanceof Function?d[b].call(e,a):d[b];return f}}function J(a,b){return a.isValid()?(b=K(b,a.localeData()),Od[b]||(Od[b]=I(b)),Od[b](a)):a.localeData().invalidDate()}function K(a,b){function c(a){return b.longDateFormat(a)||a}var d=5;for(Nd.lastIndex=0;d>=0&&Nd.test(a);)a=a.replace(Nd,c),Nd.lastIndex=0,d-=1;return a}function L(a,b,c){ce[a]="function"==typeof b?b:function(a){return a&&c?c:b}}function M(a,b){return f(ce,a)?ce[a](b._strict,b._locale):new RegExp(N(a))}function N(a){return a.replace("\\","").replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g,function(a,b,c,d,e){return b||c||d||e}).replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}function O(a,b){var c,d=b;for("string"==typeof a&&(a=[a]),"number"==typeof b&&(d=function(a,c){c[b]=p(a)}),c=0;cd;d++){if(e=h([2e3,d]),c&&!this._longMonthsParse[d]&&(this._longMonthsParse[d]=new RegExp("^"+this.months(e,"").replace(".","")+"$","i"),this._shortMonthsParse[d]=new RegExp("^"+this.monthsShort(e,"").replace(".","")+"$","i")),c||this._monthsParse[d]||(f="^"+this.months(e,"")+"|^"+this.monthsShort(e,""),this._monthsParse[d]=new RegExp(f.replace(".",""),"i")),c&&"MMMM"===b&&this._longMonthsParse[d].test(a))return d;if(c&&"MMM"===b&&this._shortMonthsParse[d].test(a))return d;if(!c&&this._monthsParse[d].test(a))return d}}function V(a,b){var c;return"string"==typeof b&&(b=a.localeData().monthsParse(b),"number"!=typeof b)?a:(c=Math.min(a.date(),R(a.year(),b)),a._d["set"+(a._isUTC?"UTC":"")+"Month"](b,c),a)}function W(b){return null!=b?(V(this,b),a.updateOffset(this,!0),this):C(this,"Month")}function X(){return R(this.year(),this.month())}function Y(a){var b,c=a._a;return c&&-2===j(a).overflow&&(b=c[fe]<0||c[fe]>11?fe:c[ge]<1||c[ge]>R(c[ee],c[fe])?ge:c[he]<0||c[he]>24||24===c[he]&&(0!==c[ie]||0!==c[je]||0!==c[ke])?he:c[ie]<0||c[ie]>59?ie:c[je]<0||c[je]>59?je:c[ke]<0||c[ke]>999?ke:-1,j(a)._overflowDayOfYear&&(ee>b||b>ge)&&(b=ge),j(a).overflow=b),a}function Z(b){a.suppressDeprecationWarnings===!1&&"undefined"!=typeof console&&console.warn&&console.warn("Deprecation warning: "+b)}function $(a,b){var c=!0,d=a+"\n"+(new Error).stack;return g(function(){return c&&(Z(d),c=!1),b.apply(this,arguments)},b)}function _(a,b){ne[a]||(Z(b),ne[a]=!0)}function aa(a){var b,c,d=a._i,e=oe.exec(d);if(e){for(j(a).iso=!0,b=0,c=pe.length;c>b;b++)if(pe[b][1].exec(d)){a._f=pe[b][0]+(e[6]||" ");break}for(b=0,c=qe.length;c>b;b++)if(qe[b][1].exec(d)){a._f+=qe[b][0];break}d.match(_d)&&(a._f+="Z"),ta(a)}else a._isValid=!1}function ba(b){var c=re.exec(b._i);return null!==c?void(b._d=new Date(+c[1])):(aa(b),void(b._isValid===!1&&(delete b._isValid,a.createFromInputFallback(b))))}function ca(a,b,c,d,e,f,g){var h=new Date(a,b,c,d,e,f,g);return 1970>a&&h.setFullYear(a),h}function da(a){var b=new Date(Date.UTC.apply(null,arguments));return 1970>a&&b.setUTCFullYear(a),b}function ea(a){return fa(a)?366:365}function fa(a){return a%4===0&&a%100!==0||a%400===0}function ga(){return fa(this.year())}function ha(a,b,c){var d,e=c-b,f=c-a.day();return f>e&&(f-=7),e-7>f&&(f+=7),d=Aa(a).add(f,"d"),{week:Math.ceil(d.dayOfYear()/7),year:d.year()}}function ia(a){return ha(a,this._week.dow,this._week.doy).week}function ja(){return this._week.dow}function ka(){return this._week.doy}function la(a){var b=this.localeData().week(this);return null==a?b:this.add(7*(a-b),"d")}function ma(a){var b=ha(this,1,4).week;return null==a?b:this.add(7*(a-b),"d")}function na(a,b,c,d,e){var f,g,h=da(a,0,1).getUTCDay();return h=0===h?7:h,c=null!=c?c:e,f=e-h+(h>d?7:0)-(e>h?7:0),g=7*(b-1)+(c-e)+f+1,{year:g>0?a:a-1,dayOfYear:g>0?g:ea(a-1)+g}}function oa(a){var b=Math.round((this.clone().startOf("day")-this.clone().startOf("year"))/864e5)+1;return null==a?b:this.add(a-b,"d")}function pa(a,b,c){return null!=a?a:null!=b?b:c}function qa(a){var b=new Date;return a._useUTC?[b.getUTCFullYear(),b.getUTCMonth(),b.getUTCDate()]:[b.getFullYear(),b.getMonth(),b.getDate()]}function ra(a){var b,c,d,e,f=[];if(!a._d){for(d=qa(a),a._w&&null==a._a[ge]&&null==a._a[fe]&&sa(a),a._dayOfYear&&(e=pa(a._a[ee],d[ee]),a._dayOfYear>ea(e)&&(j(a)._overflowDayOfYear=!0),c=da(e,0,a._dayOfYear),a._a[fe]=c.getUTCMonth(),a._a[ge]=c.getUTCDate()),b=0;3>b&&null==a._a[b];++b)a._a[b]=f[b]=d[b];for(;7>b;b++)a._a[b]=f[b]=null==a._a[b]?2===b?1:0:a._a[b];24===a._a[he]&&0===a._a[ie]&&0===a._a[je]&&0===a._a[ke]&&(a._nextDay=!0,a._a[he]=0),a._d=(a._useUTC?da:ca).apply(null,f),null!=a._tzm&&a._d.setUTCMinutes(a._d.getUTCMinutes()-a._tzm),a._nextDay&&(a._a[he]=24)}}function sa(a){var b,c,d,e,f,g,h;b=a._w,null!=b.GG||null!=b.W||null!=b.E?(f=1,g=4,c=pa(b.GG,a._a[ee],ha(Aa(),1,4).year),d=pa(b.W,1),e=pa(b.E,1)):(f=a._locale._week.dow,g=a._locale._week.doy,c=pa(b.gg,a._a[ee],ha(Aa(),f,g).year),d=pa(b.w,1),null!=b.d?(e=b.d,f>e&&++d):e=null!=b.e?b.e+f:f),h=na(c,d,e,g,f),a._a[ee]=h.year,a._dayOfYear=h.dayOfYear}function ta(b){if(b._f===a.ISO_8601)return void aa(b);b._a=[],j(b).empty=!0;var c,d,e,f,g,h=""+b._i,i=h.length,k=0;for(e=K(b._f,b._locale).match(Md)||[],c=0;c0&&j(b).unusedInput.push(g),h=h.slice(h.indexOf(d)+d.length),k+=d.length),Pd[f]?(d?j(b).empty=!1:j(b).unusedTokens.push(f),Q(f,d,b)):b._strict&&!d&&j(b).unusedTokens.push(f);j(b).charsLeftOver=i-k,h.length>0&&j(b).unusedInput.push(h),j(b).bigHour===!0&&b._a[he]<=12&&b._a[he]>0&&(j(b).bigHour=void 0),b._a[he]=ua(b._locale,b._a[he],b._meridiem),ra(b),Y(b)}function ua(a,b,c){var d;return null==c?b:null!=a.meridiemHour?a.meridiemHour(b,c):null!=a.isPM?(d=a.isPM(c),d&&12>b&&(b+=12),d||12!==b||(b=0),b):b}function va(a){var b,c,d,e,f;if(0===a._f.length)return j(a).invalidFormat=!0,void(a._d=new Date(0/0));for(e=0;ef)&&(d=f,c=b));g(a,c||b)}function wa(a){if(!a._d){var b=A(a._i);a._a=[b.year,b.month,b.day||b.date,b.hour,b.minute,b.second,b.millisecond],ra(a)}}function xa(a){var b,e=a._i,f=a._f;return a._locale=a._locale||x(a._l),null===e||void 0===f&&""===e?l({nullInput:!0}):("string"==typeof e&&(a._i=e=a._locale.preparse(e)),o(e)?new n(Y(e)):(c(f)?va(a):f?ta(a):d(e)?a._d=e:ya(a),b=new n(Y(a)),b._nextDay&&(b.add(1,"d"),b._nextDay=void 0),b))}function ya(b){var f=b._i;void 0===f?b._d=new Date:d(f)?b._d=new Date(+f):"string"==typeof f?ba(b):c(f)?(b._a=e(f.slice(0),function(a){return parseInt(a,10)}),ra(b)):"object"==typeof f?wa(b):"number"==typeof f?b._d=new Date(f):a.createFromInputFallback(b)}function za(a,b,c,d,e){var f={};return"boolean"==typeof c&&(d=c,c=void 0),f._isAMomentObject=!0,f._useUTC=f._isUTC=e,f._l=c,f._i=a,f._f=b,f._strict=d,xa(f)}function Aa(a,b,c,d){return za(a,b,c,d,!1)}function Ba(a,b){var d,e;if(1===b.length&&c(b[0])&&(b=b[0]),!b.length)return Aa();for(d=b[0],e=1;ea&&(a=-a,c="-"),c+F(~~(a/60),2)+b+F(~~a%60,2)})}function Ha(a){var b=(a||"").match(_d)||[],c=b[b.length-1]||[],d=(c+"").match(we)||["-",0,0],e=+(60*d[1])+p(d[2]);return"+"===d[0]?e:-e}function Ia(b,c){var e,f;return c._isUTC?(e=c.clone(),f=(o(b)||d(b)?+b:+Aa(b))-+e,e._d.setTime(+e._d+f),a.updateOffset(e,!1),e):Aa(b).local();return c._isUTC?Aa(b).zone(c._offset||0):Aa(b).local()}function Ja(a){return 15*-Math.round(a._d.getTimezoneOffset()/15)}function Ka(b,c){var d,e=this._offset||0;return null!=b?("string"==typeof b&&(b=Ha(b)),Math.abs(b)<16&&(b=60*b),!this._isUTC&&c&&(d=Ja(this)),this._offset=b,this._isUTC=!0,null!=d&&this.add(d,"m"),e!==b&&(!c||this._changeInProgress?$a(this,Va(b-e,"m"),1,!1):this._changeInProgress||(this._changeInProgress=!0,a.updateOffset(this,!0),this._changeInProgress=null)),this):this._isUTC?e:Ja(this)}function La(a,b){return null!=a?("string"!=typeof a&&(a=-a),this.utcOffset(a,b),this):-this.utcOffset()}function Ma(a){return this.utcOffset(0,a)}function Na(a){return this._isUTC&&(this.utcOffset(0,a),this._isUTC=!1,a&&this.subtract(Ja(this),"m")),this}function Oa(){return this._tzm?this.utcOffset(this._tzm):"string"==typeof this._i&&this.utcOffset(Ha(this._i)),this}function Pa(a){return a=a?Aa(a).utcOffset():0,(this.utcOffset()-a)%60===0}function Qa(){return this.utcOffset()>this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()}function Ra(){if(this._a){var a=this._isUTC?h(this._a):Aa(this._a);return this.isValid()&&q(this._a,a.toArray())>0}return!1}function Sa(){return!this._isUTC}function Ta(){return this._isUTC}function Ua(){return this._isUTC&&0===this._offset}function Va(a,b){var c,d,e,g=a,h=null;return Fa(a)?g={ms:a._milliseconds,d:a._days,M:a._months}:"number"==typeof a?(g={},b?g[b]=a:g.milliseconds=a):(h=xe.exec(a))?(c="-"===h[1]?-1:1,g={y:0,d:p(h[ge])*c,h:p(h[he])*c,m:p(h[ie])*c,s:p(h[je])*c,ms:p(h[ke])*c}):(h=ye.exec(a))?(c="-"===h[1]?-1:1,g={y:Wa(h[2],c),M:Wa(h[3],c),d:Wa(h[4],c),h:Wa(h[5],c),m:Wa(h[6],c),s:Wa(h[7],c),w:Wa(h[8],c)}):null==g?g={}:"object"==typeof g&&("from"in g||"to"in g)&&(e=Ya(Aa(g.from),Aa(g.to)),g={},g.ms=e.milliseconds,g.M=e.months),d=new Ea(g),Fa(a)&&f(a,"_locale")&&(d._locale=a._locale),d}function Wa(a,b){var c=a&&parseFloat(a.replace(",","."));return(isNaN(c)?0:c)*b}function Xa(a,b){var c={milliseconds:0,months:0};return c.months=b.month()-a.month()+12*(b.year()-a.year()),a.clone().add(c.months,"M").isAfter(b)&&--c.months,c.milliseconds=+b-+a.clone().add(c.months,"M"),c}function Ya(a,b){var c;return b=Ia(b,a),a.isBefore(b)?c=Xa(a,b):(c=Xa(b,a),c.milliseconds=-c.milliseconds,c.months=-c.months),c}function Za(a,b){return function(c,d){var e,f;return null===d||isNaN(+d)||(_(b,"moment()."+b+"(period, number) is deprecated. Please use moment()."+b+"(number, period)."),f=c,c=d,d=f),c="string"==typeof c?+c:c,e=Va(c,d),$a(this,e,a),this}}function $a(b,c,d,e){var f=c._milliseconds,g=c._days,h=c._months;e=null==e?!0:e,f&&b._d.setTime(+b._d+f*d),g&&D(b,"Date",C(b,"Date")+g*d),h&&V(b,C(b,"Month")+h*d),e&&a.updateOffset(b,g||h)}function _a(a){var b=a||Aa(),c=Ia(b,this).startOf("day"),d=this.diff(c,"days",!0),e=-6>d?"sameElse":-1>d?"lastWeek":0>d?"lastDay":1>d?"sameDay":2>d?"nextDay":7>d?"nextWeek":"sameElse";return this.format(this.localeData().calendar(e,this,Aa(b)))}function ab(){return new n(this)}function bb(a,b){var c;return b=z("undefined"!=typeof b?b:"millisecond"),"millisecond"===b?(a=o(a)?a:Aa(a),+this>+a):(c=o(a)?+a:+Aa(a),c<+this.clone().startOf(b))}function cb(a,b){var c;return b=z("undefined"!=typeof b?b:"millisecond"),"millisecond"===b?(a=o(a)?a:Aa(a),+a>+this):(c=o(a)?+a:+Aa(a),+this.clone().endOf(b)a?Math.ceil(a):Math.floor(a)}function gb(a,b,c){var d,e,f=Ia(a,this),g=6e4*(f.utcOffset()-this.utcOffset());return b=z(b),"year"===b||"month"===b||"quarter"===b?(e=hb(this,f),"quarter"===b?e/=3:"year"===b&&(e/=12)):(d=this-f,e="second"===b?d/1e3:"minute"===b?d/6e4:"hour"===b?d/36e5:"day"===b?(d-g)/864e5:"week"===b?(d-g)/6048e5:d),c?e:fb(e)}function hb(a,b){var c,d,e=12*(b.year()-a.year())+(b.month()-a.month()),f=a.clone().add(e,"months");return 0>b-f?(c=a.clone().add(e-1,"months"),d=(b-f)/(f-c)):(c=a.clone().add(e+1,"months"),d=(b-f)/(c-f)),-(e+d)}function ib(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")}function jb(){var a=this.clone().utc();return 0b;b++)if(this._weekdaysParse[b]||(c=Aa([2e3,1]).day(b),d="^"+this.weekdays(c,"")+"|^"+this.weekdaysShort(c,"")+"|^"+this.weekdaysMin(c,""),this._weekdaysParse[b]=new RegExp(d.replace(".",""),"i")),this._weekdaysParse[b].test(a))return b}function Mb(a){var b=this._isUTC?this._d.getUTCDay():this._d.getDay();return null!=a?(a=Hb(a,this.localeData()),this.add(a-b,"d")):b}function Nb(a){var b=(this.day()+7-this.localeData()._week.dow)%7;return null==a?b:this.add(a-b,"d")}function Ob(a){return null==a?this.day()||7:this.day(this.day()%7?a:a-7)}function Pb(a,b){G(a,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),b)})}function Qb(a,b){return b._meridiemParse}function Rb(a){return"p"===(a+"").toLowerCase().charAt(0)}function Sb(a,b,c){return a>11?c?"pm":"PM":c?"am":"AM"}function Tb(a){G(0,[a,3],0,"millisecond")}function Ub(){return this._isUTC?"UTC":""}function Vb(){return this._isUTC?"Coordinated Universal Time":""}function Wb(a){return Aa(1e3*a)}function Xb(){return Aa.apply(null,arguments).parseZone()}function Yb(a,b,c){var d=this._calendar[a];return"function"==typeof d?d.call(b,c):d}function Zb(a){var b=this._longDateFormat[a];return!b&&this._longDateFormat[a.toUpperCase()]&&(b=this._longDateFormat[a.toUpperCase()].replace(/MMMM|MM|DD|dddd/g,function(a){return a.slice(1)}),this._longDateFormat[a]=b),b}function $b(){return this._invalidDate}function _b(a){return this._ordinal.replace("%d",a)}function ac(a){return a}function bc(a,b,c,d){var e=this._relativeTime[c];return"function"==typeof e?e(a,b,c,d):e.replace(/%d/i,a)}function cc(a,b){var c=this._relativeTime[a>0?"future":"past"];return"function"==typeof c?c(b):c.replace(/%s/i,b)}function dc(a){var b,c;for(c in a)b=a[c],"function"==typeof b?this[c]=b:this["_"+c]=b;this._ordinalParseLenient=new RegExp(this._ordinalParse.source+"|"+/\d{1,2}/.source)}function ec(a,b,c,d){var e=x(),f=h().set(d,b);return e[c](f,a)}function fc(a,b,c,d,e){if("number"==typeof a&&(b=a,a=void 0),a=a||"",null!=b)return ec(a,b,c,e);var f,g=[];for(f=0;d>f;f++)g[f]=ec(a,f,c,e);return g}function gc(a,b){return fc(a,b,"months",12,"month")}function hc(a,b){return fc(a,b,"monthsShort",12,"month")}function ic(a,b){return fc(a,b,"weekdays",7,"day")}function jc(a,b){return fc(a,b,"weekdaysShort",7,"day")}function kc(a,b){return fc(a,b,"weekdaysMin",7,"day")}function lc(){var a=this._data;return this._milliseconds=Ue(this._milliseconds),this._days=Ue(this._days),this._months=Ue(this._months),a.milliseconds=Ue(a.milliseconds),a.seconds=Ue(a.seconds),a.minutes=Ue(a.minutes),a.hours=Ue(a.hours),a.months=Ue(a.months),a.years=Ue(a.years),this}function mc(a,b,c,d){var e=Va(b,c);return a._milliseconds+=d*e._milliseconds,a._days+=d*e._days,a._months+=d*e._months,a._bubble()}function nc(a,b){return mc(this,a,b,1)}function oc(a,b){return mc(this,a,b,-1)}function pc(){var a,b,c,d=this._milliseconds,e=this._days,f=this._months,g=this._data,h=0;return g.milliseconds=d%1e3,a=fb(d/1e3),g.seconds=a%60,b=fb(a/60),g.minutes=b%60,c=fb(b/60),g.hours=c%24,e+=fb(c/24),h=fb(qc(e)),e-=fb(rc(h)),f+=fb(e/30),e%=30,h+=fb(f/12),f%=12,g.days=e,g.months=f,g.years=h,this}function qc(a){return 400*a/146097}function rc(a){return 146097*a/400}function sc(a){var b,c,d=this._milliseconds;if(a=z(a),"month"===a||"year"===a)return b=this._days+d/864e5,c=this._months+12*qc(b),"month"===a?c:c/12;switch(b=this._days+Math.round(rc(this._months/12)),a){case"week":return b/7+d/6048e5;case"day":return b+d/864e5;case"hour":return 24*b+d/36e5;case"minute":return 1440*b+d/6e4;case"second":return 86400*b+d/1e3;case"millisecond":return Math.floor(864e5*b)+d;default:throw new Error("Unknown unit "+a)}}function tc(){return this._milliseconds+864e5*this._days+this._months%12*2592e6+31536e6*p(this._months/12)}function uc(a){return function(){return this.as(a)}}function vc(a){return a=z(a),this[a+"s"]()}function wc(a){return function(){return this._data[a]}}function xc(){return fb(this.days()/7)}function yc(a,b,c,d,e){return e.relativeTime(b||1,!!c,a,d)}function zc(a,b,c){var d=Va(a).abs(),e=jf(d.as("s")),f=jf(d.as("m")),g=jf(d.as("h")),h=jf(d.as("d")),i=jf(d.as("M")),j=jf(d.as("y")),k=e0,k[4]=c,yc.apply(null,k)}function Ac(a,b){return void 0===kf[a]?!1:void 0===b?kf[a]:(kf[a]=b,!0)}function Bc(a){var b=this.localeData(),c=zc(this,!a,b);return a&&(c=b.pastFuture(+this,c)),b.postformat(c)}function Cc(){var a=lf(this.years()),b=lf(this.months()),c=lf(this.days()),d=lf(this.hours()),e=lf(this.minutes()),f=lf(this.seconds()+this.milliseconds()/1e3),g=this.asSeconds();return g?(0>g?"-":"")+"P"+(a?a+"Y":"")+(b?b+"M":"")+(c?c+"D":"")+(d||e||f?"T":"")+(d?d+"H":"")+(e?e+"M":"")+(f?f+"S":""):"P0D"} +//! moment.js locale configuration +//! locale : belarusian (be) +//! author : Dmitry Demidov : https://github.com/demidov91 +//! author: Praleska: http://praleska.pro/ +//! Author : Menelion Elensúle : https://github.com/Oire +function Dc(a,b){var c=a.split("_");return b%10===1&&b%100!==11?c[0]:b%10>=2&&4>=b%10&&(10>b%100||b%100>=20)?c[1]:c[2]}function Ec(a,b,c){var d={mm:b?"хвіліна_хвіліны_хвілін":"хвіліну_хвіліны_хвілін",hh:b?"гадзіна_гадзіны_гадзін":"гадзіну_гадзіны_гадзін",dd:"дзень_дні_дзён",MM:"месяц_месяцы_месяцаў",yy:"год_гады_гадоў"};return"m"===c?b?"хвіліна":"хвіліну":"h"===c?b?"гадзіна":"гадзіну":a+" "+Dc(d[c],+a)}function Fc(a,b){var c={nominative:"студзень_люты_сакавік_красавік_травень_чэрвень_ліпень_жнівень_верасень_кастрычнік_лістапад_снежань".split("_"),accusative:"студзеня_лютага_сакавіка_красавіка_траўня_чэрвеня_ліпеня_жніўня_верасня_кастрычніка_лістапада_снежня".split("_")},d=/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/.test(b)?"accusative":"nominative";return c[d][a.month()]}function Gc(a,b){var c={nominative:"нядзеля_панядзелак_аўторак_серада_чацвер_пятніца_субота".split("_"),accusative:"нядзелю_панядзелак_аўторак_сераду_чацвер_пятніцу_суботу".split("_")},d=/\[ ?[Вв] ?(?:мінулую|наступную)? ?\] ?dddd/.test(b)?"accusative":"nominative";return c[d][a.day()]} +//! moment.js locale configuration +//! locale : breton (br) +//! author : Jean-Baptiste Le Duigou : https://github.com/jbleduigou +function Hc(a,b,c){var d={mm:"munutenn",MM:"miz",dd:"devezh"};return a+" "+Kc(d[c],a)}function Ic(a){switch(Jc(a)){case 1:case 3:case 4:case 5:case 9:return a+" bloaz";default:return a+" vloaz"}}function Jc(a){return a>9?Jc(a%10):a}function Kc(a,b){return 2===b?Lc(a):a}function Lc(a){var b={m:"v",b:"v",d:"z"};return void 0===b[a.charAt(0)]?a:b[a.charAt(0)]+a.substring(1)} +//! moment.js locale configuration +//! locale : bosnian (bs) +//! author : Nedim Cholich : https://github.com/frontyard +//! based on (hr) translation by Bojan Marković +function Mc(a,b,c){var d=a+" ";switch(c){case"m":return b?"jedna minuta":"jedne minute";case"mm":return d+=1===a?"minuta":2===a||3===a||4===a?"minute":"minuta";case"h":return b?"jedan sat":"jednog sata";case"hh":return d+=1===a?"sat":2===a||3===a||4===a?"sata":"sati";case"dd":return d+=1===a?"dan":"dana";case"MM":return d+=1===a?"mjesec":2===a||3===a||4===a?"mjeseca":"mjeseci";case"yy":return d+=1===a?"godina":2===a||3===a||4===a?"godine":"godina"}}function Nc(a){return a>1&&5>a&&1!==~~(a/10)}function Oc(a,b,c,d){var e=a+" ";switch(c){case"s":return b||d?"pár sekund":"pár sekundami";case"m":return b?"minuta":d?"minutu":"minutou";case"mm":return b||d?e+(Nc(a)?"minuty":"minut"):e+"minutami";break;case"h":return b?"hodina":d?"hodinu":"hodinou";case"hh":return b||d?e+(Nc(a)?"hodiny":"hodin"):e+"hodinami";break;case"d":return b||d?"den":"dnem";case"dd":return b||d?e+(Nc(a)?"dny":"dní"):e+"dny";break;case"M":return b||d?"měsíc":"měsícem";case"MM":return b||d?e+(Nc(a)?"měsíce":"měsíců"):e+"měsíci";break;case"y":return b||d?"rok":"rokem";case"yy":return b||d?e+(Nc(a)?"roky":"let"):e+"lety"}} +//! moment.js locale configuration +//! locale : austrian german (de-at) +//! author : lluchs : https://github.com/lluchs +//! author: Menelion Elensúle: https://github.com/Oire +//! author : Martin Groller : https://github.com/MadMG +function Pc(a,b,c,d){var e={m:["eine Minute","einer Minute"],h:["eine Stunde","einer Stunde"],d:["ein Tag","einem Tag"],dd:[a+" Tage",a+" Tagen"],M:["ein Monat","einem Monat"],MM:[a+" Monate",a+" Monaten"],y:["ein Jahr","einem Jahr"],yy:[a+" Jahre",a+" Jahren"]};return b?e[c][0]:e[c][1]} +//! moment.js locale configuration +//! locale : german (de) +//! author : lluchs : https://github.com/lluchs +//! author: Menelion Elensúle: https://github.com/Oire +function Qc(a,b,c,d){var e={m:["eine Minute","einer Minute"],h:["eine Stunde","einer Stunde"],d:["ein Tag","einem Tag"],dd:[a+" Tage",a+" Tagen"],M:["ein Monat","einem Monat"],MM:[a+" Monate",a+" Monaten"],y:["ein Jahr","einem Jahr"],yy:[a+" Jahre",a+" Jahren"]};return b?e[c][0]:e[c][1]} +//! moment.js locale configuration +//! locale : estonian (et) +//! author : Henry Kehlmann : https://github.com/madhenry +//! improvements : Illimar Tambek : https://github.com/ragulka +function Rc(a,b,c,d){var e={s:["mõne sekundi","mõni sekund","paar sekundit"],m:["ühe minuti","üks minut"],mm:[a+" minuti",a+" minutit"],h:["ühe tunni","tund aega","üks tund"],hh:[a+" tunni",a+" tundi"],d:["ühe päeva","üks päev"],M:["kuu aja","kuu aega","üks kuu"],MM:[a+" kuu",a+" kuud"],y:["ühe aasta","aasta","üks aasta"],yy:[a+" aasta",a+" aastat"]};return b?e[c][2]?e[c][2]:e[c][1]:d?e[c][0]:e[c][1]}function Sc(a,b,c,d){var e="";switch(c){case"s":return d?"muutaman sekunnin":"muutama sekunti";case"m":return d?"minuutin":"minuutti";case"mm":e=d?"minuutin":"minuuttia";break;case"h":return d?"tunnin":"tunti";case"hh":e=d?"tunnin":"tuntia";break;case"d":return d?"päivän":"päivä";case"dd":e=d?"päivän":"päivää";break;case"M":return d?"kuukauden":"kuukausi";case"MM":e=d?"kuukauden":"kuukautta";break;case"y":return d?"vuoden":"vuosi";case"yy":e=d?"vuoden":"vuotta"}return e=Tc(a,d)+" "+e}function Tc(a,b){return 10>a?b?If[a]:Hf[a]:a} +//! moment.js locale configuration +//! locale : hrvatski (hr) +//! author : Bojan Marković : https://github.com/bmarkovic +function Uc(a,b,c){var d=a+" ";switch(c){case"m":return b?"jedna minuta":"jedne minute";case"mm":return d+=1===a?"minuta":2===a||3===a||4===a?"minute":"minuta";case"h":return b?"jedan sat":"jednog sata";case"hh":return d+=1===a?"sat":2===a||3===a||4===a?"sata":"sati";case"dd":return d+=1===a?"dan":"dana";case"MM":return d+=1===a?"mjesec":2===a||3===a||4===a?"mjeseca":"mjeseci";case"yy":return d+=1===a?"godina":2===a||3===a||4===a?"godine":"godina"}}function Vc(a,b,c,d){var e=a;switch(c){case"s":return d||b?"néhány másodperc":"néhány másodperce";case"m":return"egy"+(d||b?" perc":" perce");case"mm":return e+(d||b?" perc":" perce");case"h":return"egy"+(d||b?" óra":" órája");case"hh":return e+(d||b?" óra":" órája");case"d":return"egy"+(d||b?" nap":" napja");case"dd":return e+(d||b?" nap":" napja");case"M":return"egy"+(d||b?" hónap":" hónapja");case"MM":return e+(d||b?" hónap":" hónapja");case"y":return"egy"+(d||b?" év":" éve");case"yy":return e+(d||b?" év":" éve")}return""}function Wc(a){return(a?"":"[múlt] ")+"["+Nf[this.day()]+"] LT[-kor]"} +//! moment.js locale configuration +//! locale : Armenian (hy-am) +//! author : Armendarabyan : https://github.com/armendarabyan +function Xc(a,b){var c={nominative:"հունվար_փետրվար_մարտ_ապրիլ_մայիս_հունիս_հուլիս_օգոստոս_սեպտեմբեր_հոկտեմբեր_նոյեմբեր_դեկտեմբեր".split("_"),accusative:"հունվարի_փետրվարի_մարտի_ապրիլի_մայիսի_հունիսի_հուլիսի_օգոստոսի_սեպտեմբերի_հոկտեմբերի_նոյեմբերի_դեկտեմբերի".split("_")},d=/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/.test(b)?"accusative":"nominative";return c[d][a.month()]}function Yc(a,b){var c="հնվ_փտր_մրտ_ապր_մյս_հնս_հլս_օգս_սպտ_հկտ_նմբ_դկտ".split("_");return c[a.month()]}function Zc(a,b){var c="կիրակի_երկուշաբթի_երեքշաբթի_չորեքշաբթի_հինգշաբթի_ուրբաթ_շաբաթ".split("_");return c[a.day()]} +//! moment.js locale configuration +//! locale : icelandic (is) +//! author : Hinrik Örn Sigurðsson : https://github.com/hinrik +function $c(a){return a%100===11?!0:a%10===1?!1:!0}function _c(a,b,c,d){var e=a+" ";switch(c){case"s":return b||d?"nokkrar sekúndur":"nokkrum sekúndum";case"m":return b?"mínúta":"mínútu";case"mm":return $c(a)?e+(b||d?"mínútur":"mínútum"):b?e+"mínúta":e+"mínútu";case"hh":return $c(a)?e+(b||d?"klukkustundir":"klukkustundum"):e+"klukkustund";case"d":return b?"dagur":d?"dag":"degi";case"dd":return $c(a)?b?e+"dagar":e+(d?"daga":"dögum"):b?e+"dagur":e+(d?"dag":"degi");case"M":return b?"mánuður":d?"mánuð":"mánuði";case"MM":return $c(a)?b?e+"mánuðir":e+(d?"mánuði":"mánuðum"):b?e+"mánuður":e+(d?"mánuð":"mánuði");case"y":return b||d?"ár":"ári";case"yy":return $c(a)?e+(b||d?"ár":"árum"):e+(b||d?"ár":"ári")}} +//! moment.js locale configuration +//! locale : Georgian (ka) +//! author : Irakli Janiashvili : https://github.com/irakli-janiashvili +function ad(a,b){var c={nominative:"იანვარი_თებერვალი_მარტი_აპრილი_მაისი_ივნისი_ივლისი_აგვისტო_სექტემბერი_ოქტომბერი_ნოემბერი_დეკემბერი".split("_"),accusative:"იანვარს_თებერვალს_მარტს_აპრილის_მაისს_ივნისს_ივლისს_აგვისტს_სექტემბერს_ოქტომბერს_ნოემბერს_დეკემბერს".split("_")},d=/D[oD] *MMMM?/.test(b)?"accusative":"nominative";return c[d][a.month()]}function bd(a,b){var c={nominative:"კვირა_ორშაბათი_სამშაბათი_ოთხშაბათი_ხუთშაბათი_პარასკევი_შაბათი".split("_"),accusative:"კვირას_ორშაბათს_სამშაბათს_ოთხშაბათს_ხუთშაბათს_პარასკევს_შაბათს".split("_")},d=/(წინა|შემდეგ)/.test(b)?"accusative":"nominative";return c[d][a.day()]} +//! moment.js locale configuration +//! locale : Luxembourgish (lb) +//! author : mweimerskirch : https://github.com/mweimerskirch, David Raison : https://github.com/kwisatz +function cd(a,b,c,d){var e={m:["eng Minutt","enger Minutt"],h:["eng Stonn","enger Stonn"],d:["een Dag","engem Dag"],M:["ee Mount","engem Mount"],y:["ee Joer","engem Joer"]};return b?e[c][0]:e[c][1]}function dd(a){var b=a.substr(0,a.indexOf(" "));return fd(b)?"a "+a:"an "+a}function ed(a){var b=a.substr(0,a.indexOf(" "));return fd(b)?"viru "+a:"virun "+a}function fd(a){if(a=parseInt(a,10),isNaN(a))return!1;if(0>a)return!0;if(10>a)return a>=4&&7>=a?!0:!1;if(100>a){var b=a%10,c=a/10;return fd(0===b?c:b)}if(1e4>a){for(;a>=10;)a/=10;return fd(a)}return a/=1e3,fd(a)}function gd(a,b,c,d){return b?"kelios sekundės":d?"kelių sekundžių":"kelias sekundes"}function hd(a,b,c,d){return b?jd(c)[0]:d?jd(c)[1]:jd(c)[2]}function id(a){return a%10===0||a>10&&20>a}function jd(a){return Of[a].split("_")}function kd(a,b,c,d){var e=a+" ";return 1===a?e+hd(a,b,c[0],d):b?e+(id(a)?jd(c)[1]:jd(c)[0]):d?e+jd(c)[1]:e+(id(a)?jd(c)[1]:jd(c)[2])}function ld(a,b){var c=-1===b.indexOf("dddd HH:mm"),d=Pf[a.day()];return c?d:d.substring(0,d.length-2)+"į"}function md(a,b,c){return c?b%10===1&&11!==b?a[2]:a[3]:b%10===1&&11!==b?a[0]:a[1]}function nd(a,b,c){return a+" "+md(Qf[c],a,b)}function od(a,b,c){return md(Qf[c],a,b)}function pd(a,b){return b?"dažas sekundes":"dažām sekundēm"}function qd(a){return 5>a%10&&a%10>1&&~~(a/10)%10!==1}function rd(a,b,c){var d=a+" ";switch(c){case"m":return b?"minuta":"minutę";case"mm":return d+(qd(a)?"minuty":"minut");case"h":return b?"godzina":"godzinę";case"hh":return d+(qd(a)?"godziny":"godzin");case"MM":return d+(qd(a)?"miesiące":"miesięcy");case"yy":return d+(qd(a)?"lata":"lat")}} +//! moment.js locale configuration +//! locale : romanian (ro) +//! author : Vlad Gurdiga : https://github.com/gurdiga +//! author : Valentin Agachi : https://github.com/avaly +function sd(a,b,c){var d={mm:"minute",hh:"ore",dd:"zile",MM:"luni",yy:"ani"},e=" ";return(a%100>=20||a>=100&&a%100===0)&&(e=" de "),a+e+d[c]} +//! moment.js locale configuration +//! locale : russian (ru) +//! author : Viktorminator : https://github.com/Viktorminator +//! Author : Menelion Elensúle : https://github.com/Oire +function td(a,b){var c=a.split("_");return b%10===1&&b%100!==11?c[0]:b%10>=2&&4>=b%10&&(10>b%100||b%100>=20)?c[1]:c[2]}function ud(a,b,c){var d={mm:b?"минута_минуты_минут":"минуту_минуты_минут",hh:"час_часа_часов",dd:"день_дня_дней",MM:"месяц_месяца_месяцев",yy:"год_года_лет"};return"m"===c?b?"минута":"минуту":a+" "+td(d[c],+a)}function vd(a,b){var c={nominative:"январь_февраль_март_апрель_май_июнь_июль_август_сентябрь_октябрь_ноябрь_декабрь".split("_"),accusative:"января_февраля_марта_апреля_мая_июня_июля_августа_сентября_октября_ноября_декабря".split("_")},d=/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/.test(b)?"accusative":"nominative";return c[d][a.month()]}function wd(a,b){var c={nominative:"янв_фев_март_апр_май_июнь_июль_авг_сен_окт_ноя_дек".split("_"),accusative:"янв_фев_мар_апр_мая_июня_июля_авг_сен_окт_ноя_дек".split("_")},d=/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/.test(b)?"accusative":"nominative";return c[d][a.month()]}function xd(a,b){var c={nominative:"воскресенье_понедельник_вторник_среда_четверг_пятница_суббота".split("_"),accusative:"воскресенье_понедельник_вторник_среду_четверг_пятницу_субботу".split("_")},d=/\[ ?[Вв] ?(?:прошлую|следующую|эту)? ?\] ?dddd/.test(b)?"accusative":"nominative";return c[d][a.day()]}function yd(a){return a>1&&5>a}function zd(a,b,c,d){var e=a+" ";switch(c){case"s":return b||d?"pár sekúnd":"pár sekundami";case"m":return b?"minúta":d?"minútu":"minútou";case"mm":return b||d?e+(yd(a)?"minúty":"minút"):e+"minútami";break;case"h":return b?"hodina":d?"hodinu":"hodinou";case"hh":return b||d?e+(yd(a)?"hodiny":"hodín"):e+"hodinami";break;case"d":return b||d?"deň":"dňom";case"dd":return b||d?e+(yd(a)?"dni":"dní"):e+"dňami";break;case"M":return b||d?"mesiac":"mesiacom";case"MM":return b||d?e+(yd(a)?"mesiace":"mesiacov"):e+"mesiacmi";break;case"y":return b||d?"rok":"rokom";case"yy":return b||d?e+(yd(a)?"roky":"rokov"):e+"rokmi"}} +//! moment.js locale configuration +//! locale : slovenian (sl) +//! author : Robert Sedovšek : https://github.com/sedovsek +function Ad(a,b,c,d){var e=a+" ";switch(c){case"s":return b||d?"nekaj sekund":"nekaj sekundami";case"m":return b?"ena minuta":"eno minuto";case"mm":return e+=1===a?b?"minuta":"minuto":2===a?b||d?"minuti":"minutama":5>a?b||d?"minute":"minutami":b||d?"minut":"minutami";case"h":return b?"ena ura":"eno uro";case"hh":return e+=1===a?b?"ura":"uro":2===a?b||d?"uri":"urama":5>a?b||d?"ure":"urami":b||d?"ur":"urami";case"d":return b||d?"en dan":"enim dnem";case"dd":return e+=1===a?b||d?"dan":"dnem":2===a?b||d?"dni":"dnevoma":b||d?"dni":"dnevi";case"M":return b||d?"en mesec":"enim mesecem";case"MM":return e+=1===a?b||d?"mesec":"mesecem":2===a?b||d?"meseca":"mesecema":5>a?b||d?"mesece":"meseci":b||d?"mesecev":"meseci";case"y":return b||d?"eno leto":"enim letom";case"yy":return e+=1===a?b||d?"leto":"letom":2===a?b||d?"leti":"letoma":5>a?b||d?"leta":"leti":b||d?"let":"leti"}} +//! moment.js locale configuration +//! locale : ukrainian (uk) +//! author : zemlanin : https://github.com/zemlanin +//! Author : Menelion Elensúle : https://github.com/Oire +function Bd(a,b){var c=a.split("_");return b%10===1&&b%100!==11?c[0]:b%10>=2&&4>=b%10&&(10>b%100||b%100>=20)?c[1]:c[2]}function Cd(a,b,c){var d={mm:"хвилина_хвилини_хвилин",hh:"година_години_годин",dd:"день_дні_днів",MM:"місяць_місяці_місяців",yy:"рік_роки_років"};return"m"===c?b?"хвилина":"хвилину":"h"===c?b?"година":"годину":a+" "+Bd(d[c],+a)}function Dd(a,b){var c={nominative:"січень_лютий_березень_квітень_травень_червень_липень_серпень_вересень_жовтень_листопад_грудень".split("_"),accusative:"січня_лютого_березня_квітня_травня_червня_липня_серпня_вересня_жовтня_листопада_грудня".split("_")},d=/D[oD]? *MMMM?/.test(b)?"accusative":"nominative";return c[d][a.month()]}function Ed(a,b){var c={nominative:"неділя_понеділок_вівторок_середа_четвер_п’ятниця_субота".split("_"),accusative:"неділю_понеділок_вівторок_середу_четвер_п’ятницю_суботу".split("_"),genitive:"неділі_понеділка_вівторка_середи_четверга_п’ятниці_суботи".split("_")},d=/(\[[ВвУу]\]) ?dddd/.test(b)?"accusative":/\[?(?:минулої|наступної)? ?\] ?dddd/.test(b)?"genitive":"nominative";return c[d][a.day()]}function Fd(a){return function(){return a+"о"+(11===this.hours()?"б":"")+"] LT"}}var Gd,Hd,Id=a.momentProperties=[],Jd=!1,Kd={},Ld={},Md=/(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Q|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,4}|x|X|zz?|ZZ?|.)/g,Nd=/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,Od={},Pd={},Qd=/\d/,Rd=/\d\d/,Sd=/\d{3}/,Td=/\d{4}/,Ud=/[+-]?\d{6}/,Vd=/\d\d?/,Wd=/\d{1,3}/,Xd=/\d{1,4}/,Yd=/[+-]?\d{1,6}/,Zd=/\d+/,$d=/[+-]?\d+/,_d=/Z|[+-]\d\d:?\d\d/gi,ae=/[+-]?\d+(\.\d{1,3})?/,be=/[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i,ce={},de={},ee=0,fe=1,ge=2,he=3,ie=4,je=5,ke=6;G("M",["MM",2],"Mo",function(){return this.month()+1}),G("MMM",0,0,function(a){return this.localeData().monthsShort(this,a)}),G("MMMM",0,0,function(a){return this.localeData().months(this,a)}),y("month","M"),L("M",Vd),L("MM",Vd,Rd),L("MMM",be),L("MMMM",be),O(["M","MM"],function(a,b){b[fe]=p(a)-1}),O(["MMM","MMMM"],function(a,b,c,d){var e=c._locale.monthsParse(a,d,c._strict);null!=e?b[fe]=e:j(c).invalidMonth=a});var le="January_February_March_April_May_June_July_August_September_October_November_December".split("_"),me="Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),ne={};a.suppressDeprecationWarnings=!1;var oe=/^\s*(?:[+-]\d{6}|\d{4})-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,pe=[["YYYYYY-MM-DD",/[+-]\d{6}-\d{2}-\d{2}/],["YYYY-MM-DD",/\d{4}-\d{2}-\d{2}/],["GGGG-[W]WW-E",/\d{4}-W\d{2}-\d/],["GGGG-[W]WW",/\d{4}-W\d{2}/],["YYYY-DDD",/\d{4}-\d{3}/]],qe=[["HH:mm:ss.SSSS",/(T| )\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss",/(T| )\d\d:\d\d:\d\d/],["HH:mm",/(T| )\d\d:\d\d/],["HH",/(T| )\d\d/]],re=/^\/?Date\((\-?\d+)/i;a.createFromInputFallback=$("moment construction falls back to js Date. This is discouraged and will be removed in upcoming major release. Please refer to https://github.com/moment/moment/issues/1407 for more info.",function(a){a._d=new Date(a._i+(a._useUTC?" UTC":""))}),G(0,["YY",2],0,function(){return this.year()%100}),G(0,["YYYY",4],0,"year"),G(0,["YYYYY",5],0,"year"),G(0,["YYYYYY",6,!0],0,"year"),y("year","y"),L("Y",$d),L("YY",Vd,Rd),L("YYYY",Xd,Td),L("YYYYY",Yd,Ud),L("YYYYYY",Yd,Ud),O(["YYYY","YYYYY","YYYYYY"],ee),O("YY",function(b,c){c[ee]=a.parseTwoDigitYear(b)}),a.parseTwoDigitYear=function(a){return p(a)+(p(a)>68?1900:2e3)};var se=B("FullYear",!1);G("w",["ww",2],"wo","week"),G("W",["WW",2],"Wo","isoWeek"),y("week","w"),y("isoWeek","W"),L("w",Vd),L("ww",Vd,Rd),L("W",Vd),L("WW",Vd,Rd),P(["w","ww","W","WW"],function(a,b,c,d){b[d.substr(0,1)]=p(a)});var te={dow:0,doy:6};G("DDD",["DDDD",3],"DDDo","dayOfYear"),y("dayOfYear","DDD"),L("DDD",Wd),L("DDDD",Sd),O(["DDD","DDDD"],function(a,b,c){c._dayOfYear=p(a)}),a.ISO_8601=function(){};var ue=$("moment().min is deprecated, use moment.min instead. https://github.com/moment/moment/issues/1548",function(){var a=Aa.apply(null,arguments);return this>a?this:a}),ve=$("moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548",function(){var a=Aa.apply(null,arguments);return a>this?this:a});Ga("Z",":"),Ga("ZZ",""),L("Z",_d),L("ZZ",_d),O(["Z","ZZ"],function(a,b,c){c._useUTC=!0,c._tzm=Ha(a)});var we=/([\+\-]|\d\d)/gi;a.updateOffset=function(){};var xe=/(\-)?(?:(\d*)\.)?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?)?/,ye=/^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/;Va.fn=Ea.prototype;var ze=Za(1,"add"),Ae=Za(-1,"subtract");a.defaultFormat="YYYY-MM-DDTHH:mm:ssZ";var Be=$("moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.",function(a){return void 0===a?this.localeData():this.locale(a)});G(0,["gg",2],0,function(){return this.weekYear()%100}),G(0,["GG",2],0,function(){return this.isoWeekYear()%100}),Ab("gggg","weekYear"),Ab("ggggg","weekYear"),Ab("GGGG","isoWeekYear"),Ab("GGGGG","isoWeekYear"),y("weekYear","gg"),y("isoWeekYear","GG"),L("G",$d),L("g",$d),L("GG",Vd,Rd),L("gg",Vd,Rd),L("GGGG",Xd,Td),L("gggg",Xd,Td),L("GGGGG",Yd,Ud),L("ggggg",Yd,Ud),P(["gggg","ggggg","GGGG","GGGGG"],function(a,b,c,d){b[d.substr(0,2)]=p(a)}),P(["gg","GG"],function(b,c,d,e){c[e]=a.parseTwoDigitYear(b)}),G("Q",0,0,"quarter"),y("quarter","Q"),L("Q",Qd),O("Q",function(a,b){b[fe]=3*(p(a)-1)}),G("D",["DD",2],"Do","date"),y("date","D"),L("D",Vd),L("DD",Vd,Rd),L("Do",function(a,b){return a?b._ordinalParse:b._ordinalParseLenient}),O(["D","DD"],ge),O("Do",function(a,b){b[ge]=p(a.match(Vd)[0],10)});var Ce=B("Date",!0);G("d",0,"do","day"),G("dd",0,0,function(a){return this.localeData().weekdaysMin(this,a)}),G("ddd",0,0,function(a){return this.localeData().weekdaysShort(this,a)}),G("dddd",0,0,function(a){return this.localeData().weekdays(this,a)}),G("e",0,0,"weekday"),G("E",0,0,"isoWeekday"),y("day","d"),y("weekday","e"),y("isoWeekday","E"),L("d",Vd),L("e",Vd),L("E",Vd),L("dd",be),L("ddd",be),L("dddd",be),P(["dd","ddd","dddd"],function(a,b,c){var d=c._locale.weekdaysParse(a);null!=d?b.d=d:j(c).invalidWeekday=a}),P(["d","e","E"],function(a,b,c,d){b[d]=p(a)});var De="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),Ee="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),Fe="Su_Mo_Tu_We_Th_Fr_Sa".split("_");G("H",["HH",2],0,"hour"),G("h",["hh",2],0,function(){return this.hours()%12||12}),Pb("a",!0),Pb("A",!1),y("hour","h"),L("a",Qb),L("A",Qb),L("H",Vd),L("h",Vd),L("HH",Vd,Rd),L("hh",Vd,Rd),O(["H","HH"],he),O(["a","A"],function(a,b,c){c._isPm=c._locale.isPM(a),c._meridiem=a}),O(["h","hh"],function(a,b,c){b[he]=p(a),j(c).bigHour=!0});var Ge=/[ap]\.?m?\.?/i,He=B("Hours",!0);G("m",["mm",2],0,"minute"),y("minute","m"),L("m",Vd),L("mm",Vd,Rd),O(["m","mm"],ie);var Ie=B("Minutes",!1);G("s",["ss",2],0,"second"),y("second","s"),L("s",Vd),L("ss",Vd,Rd),O(["s","ss"],je);var Je=B("Seconds",!1);G("S",0,0,function(){return~~(this.millisecond()/100)}),G(0,["SS",2],0,function(){return~~(this.millisecond()/10)}),Tb("SSS"),Tb("SSSS"),y("millisecond","ms"),L("S",Wd,Qd),L("SS",Wd,Rd),L("SSS",Wd,Sd),L("SSSS",Zd),O(["S","SS","SSS","SSSS"],function(a,b){b[ke]=p(1e3*("0."+a))});var Ke=B("Milliseconds",!1);G("z",0,0,"zoneAbbr"),G("zz",0,0,"zoneName");var Le=n.prototype;Le.add=ze,Le.calendar=_a,Le.clone=ab,Le.diff=gb,Le.endOf=sb,Le.format=kb,Le.from=lb,Le.fromNow=mb,Le.to=nb,Le.toNow=ob,Le.get=E,Le.invalidAt=zb,Le.isAfter=bb,Le.isBefore=cb,Le.isBetween=db,Le.isSame=eb,Le.isValid=xb,Le.lang=Be,Le.locale=pb,Le.localeData=qb,Le.max=ve,Le.min=ue,Le.parsingFlags=yb,Le.set=E,Le.startOf=rb,Le.subtract=Ae,Le.toArray=wb,Le.toDate=vb,Le.toISOString=jb,Le.toJSON=jb,Le.toString=ib,Le.unix=ub,Le.valueOf=tb,Le.year=se,Le.isLeapYear=ga,Le.weekYear=Cb,Le.isoWeekYear=Db,Le.quarter=Le.quarters=Gb,Le.month=W,Le.daysInMonth=X,Le.week=Le.weeks=la,Le.isoWeek=Le.isoWeeks=ma,Le.weeksInYear=Fb,Le.isoWeeksInYear=Eb,Le.date=Ce,Le.day=Le.days=Mb,Le.weekday=Nb,Le.isoWeekday=Ob,Le.dayOfYear=oa,Le.hour=Le.hours=He,Le.minute=Le.minutes=Ie,Le.second=Le.seconds=Je,Le.millisecond=Le.milliseconds=Ke,Le.utcOffset=Ka,Le.utc=Ma,Le.local=Na,Le.parseZone=Oa,Le.hasAlignedHourOffset=Pa,Le.isDST=Qa,Le.isDSTShifted=Ra,Le.isLocal=Sa,Le.isUtcOffset=Ta,Le.isUtc=Ua,Le.isUTC=Ua,Le.zoneAbbr=Ub,Le.zoneName=Vb,Le.dates=$("dates accessor is deprecated. Use date instead.",Ce),Le.months=$("months accessor is deprecated. Use month instead",W),Le.years=$("years accessor is deprecated. Use year instead",se),Le.zone=$("moment().zone is deprecated, use moment().utcOffset instead. https://github.com/moment/moment/issues/1779",La);var Me=Le,Ne={sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},Oe={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY LT",LLLL:"dddd, MMMM D, YYYY LT"},Pe="Invalid date",Qe="%d",Re=/\d{1,2}/,Se={future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},Te=r.prototype;Te._calendar=Ne,Te.calendar=Yb,Te._longDateFormat=Oe,Te.longDateFormat=Zb,Te._invalidDate=Pe,Te.invalidDate=$b,Te._ordinal=Qe,Te.ordinal=_b,Te._ordinalParse=Re,Te.preparse=ac,Te.postformat=ac,Te._relativeTime=Se,Te.relativeTime=bc,Te.pastFuture=cc,Te.set=dc,Te.months=S,Te._months=le,Te.monthsShort=T,Te._monthsShort=me,Te.monthsParse=U,Te.week=ia,Te._week=te,Te.firstDayOfYear=ka,Te.firstDayOfWeek=ja,Te.weekdays=Ib,Te._weekdays=De,Te.weekdaysMin=Kb,Te._weekdaysMin=Fe,Te.weekdaysShort=Jb,Te._weekdaysShort=Ee,Te.weekdaysParse=Lb,Te.isPM=Rb,Te._meridiemParse=Ge,Te.meridiem=Sb,v("en",{ordinalParse:/\d{1,2}(th|st|nd|rd)/,ordinal:function(a){var b=a%10,c=1===p(a%100/10)?"th":1===b?"st":2===b?"nd":3===b?"rd":"th";return a+c}}),a.lang=$("moment.lang is deprecated. Use moment.locale instead.",v),a.langData=$("moment.langData is deprecated. Use moment.localeData instead.",x);var Ue=Math.abs,Ve=uc("ms"),We=uc("s"),Xe=uc("m"),Ye=uc("h"),Ze=uc("d"),$e=uc("w"),_e=uc("M"),af=uc("y"),bf=wc("milliseconds"),cf=wc("seconds"),df=wc("minutes"),ef=wc("hours"),ff=wc("days"),gf=wc("months"),hf=wc("years"),jf=Math.round,kf={s:45,m:45,h:22,d:26,M:11},lf=Math.abs,mf=Ea.prototype;mf.abs=lc,mf.add=nc,mf.subtract=oc,mf.as=sc,mf.asMilliseconds=Ve,mf.asSeconds=We,mf.asMinutes=Xe,mf.asHours=Ye,mf.asDays=Ze,mf.asWeeks=$e,mf.asMonths=_e,mf.asYears=af,mf.valueOf=tc,mf._bubble=pc,mf.get=vc,mf.milliseconds=bf,mf.seconds=cf,mf.minutes=df,mf.hours=ef,mf.days=ff,mf.weeks=xc,mf.months=gf,mf.years=hf,mf.humanize=Bc,mf.toISOString=Cc,mf.toString=Cc,mf.toJSON=Cc,mf.locale=pb,mf.localeData=qb,mf.toIsoString=$("toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)",Cc),mf.lang=Be,G("X",0,0,"unix"),G("x",0,0,"valueOf"),L("x",$d),L("X",ae),O("X",function(a,b,c){c._d=new Date(1e3*parseFloat(a,10))}),O("x",function(a,b,c){c._d=new Date(p(a))}), +//! moment.js +//! version : 2.10.3 +//! authors : Tim Wood, Iskren Chernev, Moment.js contributors +//! license : MIT +//! momentjs.com +a.version="2.10.3",b(Aa),a.fn=Me,a.min=Ca,a.max=Da,a.utc=h,a.unix=Wb,a.months=gc,a.isDate=d,a.locale=v,a.invalid=l,a.duration=Va,a.isMoment=o,a.weekdays=ic,a.parseZone=Xb,a.localeData=x,a.isDuration=Fa,a.monthsShort=hc,a.weekdaysMin=kc,a.defineLocale=w,a.weekdaysShort=jc,a.normalizeUnits=z,a.relativeTimeThreshold=Ac;var nf=a,of=(nf.defineLocale("af",{months:"Januarie_Februarie_Maart_April_Mei_Junie_Julie_Augustus_September_Oktober_November_Desember".split("_"),monthsShort:"Jan_Feb_Mar_Apr_Mei_Jun_Jul_Aug_Sep_Okt_Nov_Des".split("_"),weekdays:"Sondag_Maandag_Dinsdag_Woensdag_Donderdag_Vrydag_Saterdag".split("_"),weekdaysShort:"Son_Maa_Din_Woe_Don_Vry_Sat".split("_"),weekdaysMin:"So_Ma_Di_Wo_Do_Vr_Sa".split("_"),meridiemParse:/vm|nm/i,isPM:function(a){return/^nm$/i.test(a)},meridiem:function(a,b,c){return 12>a?c?"vm":"VM":c?"nm":"NM"},longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Vandag om] LT",nextDay:"[Môre om] LT",nextWeek:"dddd [om] LT",lastDay:"[Gister om] LT",lastWeek:"[Laas] dddd [om] LT",sameElse:"L"},relativeTime:{future:"oor %s",past:"%s gelede",s:"'n paar sekondes",m:"'n minuut",mm:"%d minute",h:"'n uur",hh:"%d ure",d:"'n dag",dd:"%d dae",M:"'n maand",MM:"%d maande",y:"'n jaar",yy:"%d jaar"},ordinalParse:/\d{1,2}(ste|de)/,ordinal:function(a){return a+(1===a||8===a||a>=20?"ste":"de")},week:{dow:1,doy:4}}),nf.defineLocale("ar-ma",{months:"يناير_فبراير_مارس_أبريل_ماي_يونيو_يوليوز_غشت_شتنبر_أكتوبر_نونبر_دجنبر".split("_"),monthsShort:"يناير_فبراير_مارس_أبريل_ماي_يونيو_يوليوز_غشت_شتنبر_أكتوبر_نونبر_دجنبر".split("_"),weekdays:"الأحد_الإتنين_الثلاثاء_الأربعاء_الخميس_الجمعة_السبت".split("_"),weekdaysShort:"احد_اتنين_ثلاثاء_اربعاء_خميس_جمعة_سبت".split("_"),weekdaysMin:"ح_ن_ث_ر_خ_ج_س".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[اليوم على الساعة] LT",nextDay:"[غدا على الساعة] LT",nextWeek:"dddd [على الساعة] LT",lastDay:"[أمس على الساعة] LT",lastWeek:"dddd [على الساعة] LT",sameElse:"L"},relativeTime:{future:"في %s",past:"منذ %s",s:"ثوان",m:"دقيقة",mm:"%d دقائق",h:"ساعة",hh:"%d ساعات",d:"يوم",dd:"%d أيام",M:"شهر",MM:"%d أشهر",y:"سنة",yy:"%d سنوات"},week:{dow:6,doy:12}}),{1:"١",2:"٢",3:"٣",4:"٤",5:"٥",6:"٦",7:"٧",8:"٨",9:"٩",0:"٠"}),pf={"١":"1","٢":"2","٣":"3","٤":"4","٥":"5","٦":"6","٧":"7","٨":"8","٩":"9","٠":"0"},qf=(nf.defineLocale("ar-sa",{months:"يناير_فبراير_مارس_أبريل_مايو_يونيو_يوليو_أغسطس_سبتمبر_أكتوبر_نوفمبر_ديسمبر".split("_"),monthsShort:"يناير_فبراير_مارس_أبريل_مايو_يونيو_يوليو_أغسطس_سبتمبر_أكتوبر_نوفمبر_ديسمبر".split("_"),weekdays:"الأحد_الإثنين_الثلاثاء_الأربعاء_الخميس_الجمعة_السبت".split("_"),weekdaysShort:"أحد_إثنين_ثلاثاء_أربعاء_خميس_جمعة_سبت".split("_"),weekdaysMin:"ح_ن_ث_ر_خ_ج_س".split("_"),longDateFormat:{LT:"HH:mm",LTS:"HH:mm:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},meridiemParse:/ص|م/,isPM:function(a){return"م"===a},meridiem:function(a,b,c){return 12>a?"ص":"م"},calendar:{sameDay:"[اليوم على الساعة] LT",nextDay:"[غدا على الساعة] LT",nextWeek:"dddd [على الساعة] LT",lastDay:"[أمس على الساعة] LT",lastWeek:"dddd [على الساعة] LT",sameElse:"L"},relativeTime:{future:"في %s",past:"منذ %s",s:"ثوان",m:"دقيقة",mm:"%d دقائق",h:"ساعة",hh:"%d ساعات",d:"يوم",dd:"%d أيام",M:"شهر",MM:"%d أشهر",y:"سنة",yy:"%d سنوات"},preparse:function(a){return a.replace(/[١٢٣٤٥٦٧٨٩٠]/g,function(a){return pf[a]}).replace(/،/g,",")},postformat:function(a){return a.replace(/\d/g,function(a){return of[a]}).replace(/,/g,"،")},week:{dow:6,doy:12}}),nf.defineLocale("ar-tn",{months:"جانفي_فيفري_مارس_أفريل_ماي_جوان_جويلية_أوت_سبتمبر_أكتوبر_نوفمبر_ديسمبر".split("_"),monthsShort:"جانفي_فيفري_مارس_أفريل_ماي_جوان_جويلية_أوت_سبتمبر_أكتوبر_نوفمبر_ديسمبر".split("_"),weekdays:"الأحد_الإثنين_الثلاثاء_الأربعاء_الخميس_الجمعة_السبت".split("_"),weekdaysShort:"أحد_إثنين_ثلاثاء_أربعاء_خميس_جمعة_سبت".split("_"),weekdaysMin:"ح_ن_ث_ر_خ_ج_س".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[اليوم على الساعة] LT",nextDay:"[غدا على الساعة] LT",nextWeek:"dddd [على الساعة] LT",lastDay:"[أمس على الساعة] LT",lastWeek:"dddd [على الساعة] LT",sameElse:"L"},relativeTime:{future:"في %s",past:"منذ %s",s:"ثوان",m:"دقيقة",mm:"%d دقائق",h:"ساعة",hh:"%d ساعات",d:"يوم",dd:"%d أيام",M:"شهر",MM:"%d أشهر",y:"سنة",yy:"%d سنوات"},week:{dow:1,doy:4}}),{1:"١",2:"٢",3:"٣",4:"٤",5:"٥",6:"٦",7:"٧",8:"٨",9:"٩",0:"٠"}),rf={"١":"1","٢":"2","٣":"3","٤":"4","٥":"5","٦":"6","٧":"7","٨":"8","٩":"9","٠":"0"},sf=function(a){return 0===a?0:1===a?1:2===a?2:a%100>=3&&10>=a%100?3:a%100>=11?4:5},tf={s:["أقل من ثانية","ثانية واحدة",["ثانيتان","ثانيتين"],"%d ثوان","%d ثانية","%d ثانية"],m:["أقل من دقيقة","دقيقة واحدة",["دقيقتان","دقيقتين"],"%d دقائق","%d دقيقة","%d دقيقة"],h:["أقل من ساعة","ساعة واحدة",["ساعتان","ساعتين"],"%d ساعات","%d ساعة","%d ساعة"],d:["أقل من يوم","يوم واحد",["يومان","يومين"],"%d أيام","%d يومًا","%d يوم"],M:["أقل من شهر","شهر واحد",["شهران","شهرين"],"%d أشهر","%d شهرا","%d شهر"],y:["أقل من عام","عام واحد",["عامان","عامين"],"%d أعوام","%d عامًا","%d عام"]},uf=function(a){return function(b,c,d,e){var f=sf(b),g=tf[a][sf(b)];return 2===f&&(g=g[c?0:1]),g.replace(/%d/i,b)}},vf=["كانون الثاني يناير","شباط فبراير","آذار مارس","نيسان أبريل","أيار مايو","حزيران يونيو","تموز يوليو","آب أغسطس","أيلول سبتمبر","تشرين الأول أكتوبر","تشرين الثاني نوفمبر","كانون الأول ديسمبر"],wf=(nf.defineLocale("ar",{months:vf,monthsShort:vf,weekdays:"الأحد_الإثنين_الثلاثاء_الأربعاء_الخميس_الجمعة_السبت".split("_"),weekdaysShort:"أحد_إثنين_ثلاثاء_أربعاء_خميس_جمعة_سبت".split("_"),weekdaysMin:"ح_ن_ث_ر_خ_ج_س".split("_"),longDateFormat:{LT:"HH:mm",LTS:"HH:mm:ss",L:"D/‏M/‏YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},meridiemParse:/ص|م/,isPM:function(a){return"م"===a},meridiem:function(a,b,c){return 12>a?"ص":"م"},calendar:{sameDay:"[اليوم عند الساعة] LT",nextDay:"[غدًا عند الساعة] LT",nextWeek:"dddd [عند الساعة] LT",lastDay:"[أمس عند الساعة] LT",lastWeek:"dddd [عند الساعة] LT",sameElse:"L"},relativeTime:{future:"بعد %s",past:"منذ %s",s:uf("s"),m:uf("m"),mm:uf("m"),h:uf("h"),hh:uf("h"),d:uf("d"),dd:uf("d"),M:uf("M"),MM:uf("M"),y:uf("y"),yy:uf("y")},preparse:function(a){return a.replace(/\u200f/g,"").replace(/[١٢٣٤٥٦٧٨٩٠]/g,function(a){return rf[a]}).replace(/،/g,",")},postformat:function(a){return a.replace(/\d/g,function(a){return qf[a]}).replace(/,/g,"،")},week:{dow:6,doy:12}}),{1:"-inci",5:"-inci",8:"-inci",70:"-inci",80:"-inci",2:"-nci",7:"-nci",20:"-nci",50:"-nci",3:"-üncü",4:"-üncü",100:"-üncü",6:"-ncı",9:"-uncu",10:"-uncu",30:"-uncu",60:"-ıncı",90:"-ıncı"}),xf=(nf.defineLocale("az",{months:"yanvar_fevral_mart_aprel_may_iyun_iyul_avqust_sentyabr_oktyabr_noyabr_dekabr".split("_"),monthsShort:"yan_fev_mar_apr_may_iyn_iyl_avq_sen_okt_noy_dek".split("_"),weekdays:"Bazar_Bazar ertəsi_Çərşənbə axşamı_Çərşənbə_Cümə axşamı_Cümə_Şənbə".split("_"),weekdaysShort:"Baz_BzE_ÇAx_Çər_CAx_Cüm_Şən".split("_"),weekdaysMin:"Bz_BE_ÇA_Çə_CA_Cü_Şə".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[bugün saat] LT",nextDay:"[sabah saat] LT",nextWeek:"[gələn həftə] dddd [saat] LT",lastDay:"[dünən] LT",lastWeek:"[keçən həftə] dddd [saat] LT",sameElse:"L"},relativeTime:{future:"%s sonra",past:"%s əvvəl",s:"birneçə saniyyə",m:"bir dəqiqə",mm:"%d dəqiqə",h:"bir saat",hh:"%d saat",d:"bir gün",dd:"%d gün",M:"bir ay",MM:"%d ay",y:"bir il",yy:"%d il"},meridiemParse:/gecə|səhər|gündüz|axşam/,isPM:function(a){return/^(gündüz|axşam)$/.test(a)},meridiem:function(a,b,c){return 4>a?"gecə":12>a?"səhər":17>a?"gündüz":"axşam"},ordinalParse:/\d{1,2}-(ıncı|inci|nci|üncü|ncı|uncu)/,ordinal:function(a){if(0===a)return a+"-ıncı";var b=a%10,c=a%100-b,d=a>=100?100:null;return a+(wf[b]||wf[c]||wf[d])},week:{dow:1,doy:7}}),nf.defineLocale("be",{months:Fc,monthsShort:"студ_лют_сак_крас_трав_чэрв_ліп_жнів_вер_каст_ліст_снеж".split("_"),weekdays:Gc,weekdaysShort:"нд_пн_ат_ср_чц_пт_сб".split("_"),weekdaysMin:"нд_пн_ат_ср_чц_пт_сб".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY г.",LLL:"D MMMM YYYY г., LT",LLLL:"dddd, D MMMM YYYY г., LT"},calendar:{sameDay:"[Сёння ў] LT",nextDay:"[Заўтра ў] LT",lastDay:"[Учора ў] LT",nextWeek:function(){return"[У] dddd [ў] LT"},lastWeek:function(){switch(this.day()){case 0:case 3:case 5:case 6:return"[У мінулую] dddd [ў] LT";case 1:case 2:case 4:return"[У мінулы] dddd [ў] LT"}},sameElse:"L"},relativeTime:{future:"праз %s",past:"%s таму",s:"некалькі секунд",m:Ec,mm:Ec,h:Ec,hh:Ec,d:"дзень",dd:Ec,M:"месяц",MM:Ec,y:"год",yy:Ec},meridiemParse:/ночы|раніцы|дня|вечара/,isPM:function(a){return/^(дня|вечара)$/.test(a)},meridiem:function(a,b,c){return 4>a?"ночы":12>a?"раніцы":17>a?"дня":"вечара"},ordinalParse:/\d{1,2}-(і|ы|га)/,ordinal:function(a,b){switch(b){case"M":case"d":case"DDD":case"w":case"W":return a%10!==2&&a%10!==3||a%100===12||a%100===13?a+"-ы":a+"-і";case"D":return a+"-га";default:return a}},week:{dow:1,doy:7}}),nf.defineLocale("bg",{months:"януари_февруари_март_април_май_юни_юли_август_септември_октомври_ноември_декември".split("_"),monthsShort:"янр_фев_мар_апр_май_юни_юли_авг_сеп_окт_ное_дек".split("_"),weekdays:"неделя_понеделник_вторник_сряда_четвъртък_петък_събота".split("_"),weekdaysShort:"нед_пон_вто_сря_чет_пет_съб".split("_"),weekdaysMin:"нд_пн_вт_ср_чт_пт_сб".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"D.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Днес в] LT",nextDay:"[Утре в] LT",nextWeek:"dddd [в] LT",lastDay:"[Вчера в] LT",lastWeek:function(){switch(this.day()){case 0:case 3:case 6:return"[В изминалата] dddd [в] LT";case 1:case 2:case 4:case 5:return"[В изминалия] dddd [в] LT"}},sameElse:"L"},relativeTime:{future:"след %s",past:"преди %s",s:"няколко секунди",m:"минута",mm:"%d минути",h:"час",hh:"%d часа",d:"ден",dd:"%d дни",M:"месец",MM:"%d месеца",y:"година",yy:"%d години"},ordinalParse:/\d{1,2}-(ев|ен|ти|ви|ри|ми)/,ordinal:function(a){var b=a%10,c=a%100;return 0===a?a+"-ев":0===c?a+"-ен":c>10&&20>c?a+"-ти":1===b?a+"-ви":2===b?a+"-ри":7===b||8===b?a+"-ми":a+"-ти"},week:{dow:1,doy:7}}),{1:"১",2:"২",3:"৩",4:"৪",5:"৫",6:"৬",7:"৭",8:"৮",9:"৯",0:"০"}),yf={"১":"1","২":"2","৩":"3","৪":"4","৫":"5","৬":"6","৭":"7","৮":"8","৯":"9","০":"0"},zf=(nf.defineLocale("bn",{months:"জানুয়ারী_ফেবুয়ারী_মার্চ_এপ্রিল_মে_জুন_জুলাই_অগাস্ট_সেপ্টেম্বর_অক্টোবর_নভেম্বর_ডিসেম্বর".split("_"),monthsShort:"জানু_ফেব_মার্চ_এপর_মে_জুন_জুল_অগ_সেপ্ট_অক্টো_নভ_ডিসেম্".split("_"),weekdays:"রবিবার_সোমবার_মঙ্গলবার_বুধবার_বৃহস্পত্তিবার_শুক্রুবার_শনিবার".split("_"),weekdaysShort:"রবি_সোম_মঙ্গল_বুধ_বৃহস্পত্তি_শুক্রু_শনি".split("_"),weekdaysMin:"রব_সম_মঙ্গ_বু_ব্রিহ_শু_শনি".split("_"),longDateFormat:{LT:"A h:mm সময়",LTS:"A h:mm:ss সময়",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY, LT",LLLL:"dddd, D MMMM YYYY, LT"},calendar:{sameDay:"[আজ] LT",nextDay:"[আগামীকাল] LT",nextWeek:"dddd, LT",lastDay:"[গতকাল] LT",lastWeek:"[গত] dddd, LT",sameElse:"L"},relativeTime:{future:"%s পরে",past:"%s আগে",s:"কএক সেকেন্ড",m:"এক মিনিট",mm:"%d মিনিট",h:"এক ঘন্টা",hh:"%d ঘন্টা",d:"এক দিন",dd:"%d দিন",M:"এক মাস",MM:"%d মাস",y:"এক বছর",yy:"%d বছর"},preparse:function(a){return a.replace(/[১২৩৪৫৬৭৮৯০]/g,function(a){return yf[a]})},postformat:function(a){return a.replace(/\d/g,function(a){return xf[a]})},meridiemParse:/রাত|শকাল|দুপুর|বিকেল|রাত/,isPM:function(a){return/^(দুপুর|বিকেল|রাত)$/.test(a)},meridiem:function(a,b,c){return 4>a?"রাত":10>a?"শকাল":17>a?"দুপুর":20>a?"বিকেল":"রাত"},week:{dow:0,doy:6}}),{1:"༡",2:"༢",3:"༣",4:"༤",5:"༥",6:"༦",7:"༧",8:"༨",9:"༩",0:"༠"}),Af={"༡":"1","༢":"2","༣":"3","༤":"4","༥":"5","༦":"6","༧":"7","༨":"8","༩":"9","༠":"0"},Bf=(nf.defineLocale("bo",{months:"ཟླ་བ་དང་པོ_ཟླ་བ་གཉིས་པ_ཟླ་བ་གསུམ་པ_ཟླ་བ་བཞི་པ_ཟླ་བ་ལྔ་པ_ཟླ་བ་དྲུག་པ_ཟླ་བ་བདུན་པ_ཟླ་བ་བརྒྱད་པ_ཟླ་བ་དགུ་པ_ཟླ་བ་བཅུ་པ_ཟླ་བ་བཅུ་གཅིག་པ_ཟླ་བ་བཅུ་གཉིས་པ".split("_"),monthsShort:"ཟླ་བ་དང་པོ_ཟླ་བ་གཉིས་པ_ཟླ་བ་གསུམ་པ_ཟླ་བ་བཞི་པ_ཟླ་བ་ལྔ་པ_ཟླ་བ་དྲུག་པ_ཟླ་བ་བདུན་པ_ཟླ་བ་བརྒྱད་པ_ཟླ་བ་དགུ་པ_ཟླ་བ་བཅུ་པ_ཟླ་བ་བཅུ་གཅིག་པ_ཟླ་བ་བཅུ་གཉིས་པ".split("_"),weekdays:"གཟའ་ཉི་མ་_གཟའ་ཟླ་བ་_གཟའ་མིག་དམར་_གཟའ་ལྷག་པ་_གཟའ་ཕུར་བུ_གཟའ་པ་སངས་_གཟའ་སྤེན་པ་".split("_"),weekdaysShort:"ཉི་མ་_ཟླ་བ་_མིག་དམར་_ལྷག་པ་_ཕུར་བུ_པ་སངས་_སྤེན་པ་".split("_"),weekdaysMin:"ཉི་མ་_ཟླ་བ་_མིག་དམར་_ལྷག་པ་_ཕུར་བུ_པ་སངས་_སྤེན་པ་".split("_"),longDateFormat:{LT:"A h:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY, LT",LLLL:"dddd, D MMMM YYYY, LT"},calendar:{sameDay:"[དི་རིང] LT",nextDay:"[སང་ཉིན] LT",nextWeek:"[བདུན་ཕྲག་རྗེས་མ], LT",lastDay:"[ཁ་སང] LT",lastWeek:"[བདུན་ཕྲག་མཐའ་མ] dddd, LT",sameElse:"L"},relativeTime:{future:"%s ལ་",past:"%s སྔན་ལ",s:"ལམ་སང",m:"སྐར་མ་གཅིག",mm:"%d སྐར་མ",h:"ཆུ་ཚོད་གཅིག",hh:"%d ཆུ་ཚོད",d:"ཉིན་གཅིག",dd:"%d ཉིན་",M:"ཟླ་བ་གཅིག",MM:"%d ཟླ་བ",y:"ལོ་གཅིག",yy:"%d ལོ"},preparse:function(a){return a.replace(/[༡༢༣༤༥༦༧༨༩༠]/g,function(a){return Af[a]})},postformat:function(a){return a.replace(/\d/g,function(a){return zf[a]})},meridiemParse:/མཚན་མོ|ཞོགས་ཀས|ཉིན་གུང|དགོང་དག|མཚན་མོ/,isPM:function(a){return/^(ཉིན་གུང|དགོང་དག|མཚན་མོ)$/.test(a)},meridiem:function(a,b,c){return 4>a?"མཚན་མོ":10>a?"ཞོགས་ཀས":17>a?"ཉིན་གུང":20>a?"དགོང་དག":"མཚན་མོ"},week:{dow:0,doy:6}}),nf.defineLocale("br",{months:"Genver_C'hwevrer_Meurzh_Ebrel_Mae_Mezheven_Gouere_Eost_Gwengolo_Here_Du_Kerzu".split("_"),monthsShort:"Gen_C'hwe_Meu_Ebr_Mae_Eve_Gou_Eos_Gwe_Her_Du_Ker".split("_"),weekdays:"Sul_Lun_Meurzh_Merc'her_Yaou_Gwener_Sadorn".split("_"),weekdaysShort:"Sul_Lun_Meu_Mer_Yao_Gwe_Sad".split("_"),weekdaysMin:"Su_Lu_Me_Mer_Ya_Gw_Sa".split("_"),longDateFormat:{LT:"h[e]mm A",LTS:"h[e]mm:ss A",L:"DD/MM/YYYY",LL:"D [a viz] MMMM YYYY",LLL:"D [a viz] MMMM YYYY LT",LLLL:"dddd, D [a viz] MMMM YYYY LT"},calendar:{sameDay:"[Hiziv da] LT",nextDay:"[Warc'hoazh da] LT",nextWeek:"dddd [da] LT",lastDay:"[Dec'h da] LT",lastWeek:"dddd [paset da] LT",sameElse:"L"},relativeTime:{future:"a-benn %s",past:"%s 'zo",s:"un nebeud segondennoù",m:"ur vunutenn",mm:Hc,h:"un eur",hh:"%d eur",d:"un devezh",dd:Hc,M:"ur miz",MM:Hc,y:"ur bloaz",yy:Ic},ordinalParse:/\d{1,2}(añ|vet)/,ordinal:function(a){var b=1===a?"añ":"vet";return a+b},week:{dow:1,doy:4}}),nf.defineLocale("bs",{months:"januar_februar_mart_april_maj_juni_juli_august_septembar_oktobar_novembar_decembar".split("_"),monthsShort:"jan._feb._mar._apr._maj._jun._jul._aug._sep._okt._nov._dec.".split("_"),weekdays:"nedjelja_ponedjeljak_utorak_srijeda_četvrtak_petak_subota".split("_"),weekdaysShort:"ned._pon._uto._sri._čet._pet._sub.".split("_"),weekdaysMin:"ne_po_ut_sr_če_pe_su".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD. MM. YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[danas u] LT",nextDay:"[sutra u] LT",nextWeek:function(){switch(this.day()){case 0:return"[u] [nedjelju] [u] LT";case 3:return"[u] [srijedu] [u] LT";case 6:return"[u] [subotu] [u] LT";case 1:case 2:case 4:case 5:return"[u] dddd [u] LT"}},lastDay:"[jučer u] LT",lastWeek:function(){switch(this.day()){case 0:case 3:return"[prošlu] dddd [u] LT";case 6:return"[prošle] [subote] [u] LT";case 1:case 2:case 4:case 5:return"[prošli] dddd [u] LT"}},sameElse:"L"},relativeTime:{future:"za %s",past:"prije %s",s:"par sekundi",m:Mc,mm:Mc,h:Mc,hh:Mc,d:"dan",dd:Mc,M:"mjesec",MM:Mc,y:"godinu",yy:Mc},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),nf.defineLocale("ca",{months:"gener_febrer_març_abril_maig_juny_juliol_agost_setembre_octubre_novembre_desembre".split("_"),monthsShort:"gen._febr._mar._abr._mai._jun._jul._ag._set._oct._nov._des.".split("_"),weekdays:"diumenge_dilluns_dimarts_dimecres_dijous_divendres_dissabte".split("_"),weekdaysShort:"dg._dl._dt._dc._dj._dv._ds.".split("_"),weekdaysMin:"Dg_Dl_Dt_Dc_Dj_Dv_Ds".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:function(){return"[avui a "+(1!==this.hours()?"les":"la")+"] LT"},nextDay:function(){return"[demà a "+(1!==this.hours()?"les":"la")+"] LT"},nextWeek:function(){return"dddd [a "+(1!==this.hours()?"les":"la")+"] LT"},lastDay:function(){return"[ahir a "+(1!==this.hours()?"les":"la")+"] LT"},lastWeek:function(){return"[el] dddd [passat a "+(1!==this.hours()?"les":"la")+"] LT"},sameElse:"L"},relativeTime:{future:"en %s",past:"fa %s",s:"uns segons",m:"un minut",mm:"%d minuts",h:"una hora",hh:"%d hores",d:"un dia",dd:"%d dies",M:"un mes",MM:"%d mesos",y:"un any",yy:"%d anys"},ordinalParse:/\d{1,2}(r|n|t|è|a)/,ordinal:function(a,b){var c=1===a?"r":2===a?"n":3===a?"r":4===a?"t":"è";return("w"===b||"W"===b)&&(c="a"),a+c},week:{dow:1,doy:4}}),"leden_únor_březen_duben_květen_červen_červenec_srpen_září_říjen_listopad_prosinec".split("_")),Cf="led_úno_bře_dub_kvě_čvn_čvc_srp_zář_říj_lis_pro".split("_"),Df=(nf.defineLocale("cs",{months:Bf,monthsShort:Cf,monthsParse:function(a,b){var c,d=[];for(c=0;12>c;c++)d[c]=new RegExp("^"+a[c]+"$|^"+b[c]+"$","i");return d}(Bf,Cf),weekdays:"neděle_pondělí_úterý_středa_čtvrtek_pátek_sobota".split("_"),weekdaysShort:"ne_po_út_st_čt_pá_so".split("_"),weekdaysMin:"ne_po_út_st_čt_pá_so".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd D. MMMM YYYY LT"},calendar:{sameDay:"[dnes v] LT",nextDay:"[zítra v] LT",nextWeek:function(){switch(this.day()){case 0:return"[v neděli v] LT";case 1:case 2:return"[v] dddd [v] LT";case 3:return"[ve středu v] LT";case 4:return"[ve čtvrtek v] LT";case 5:return"[v pátek v] LT";case 6:return"[v sobotu v] LT"}},lastDay:"[včera v] LT",lastWeek:function(){switch(this.day()){case 0:return"[minulou neděli v] LT";case 1:case 2:return"[minulé] dddd [v] LT";case 3:return"[minulou středu v] LT";case 4:case 5:return"[minulý] dddd [v] LT";case 6:return"[minulou sobotu v] LT"}},sameElse:"L"},relativeTime:{future:"za %s",past:"před %s",s:Oc,m:Oc,mm:Oc,h:Oc,hh:Oc,d:Oc,dd:Oc,M:Oc,MM:Oc,y:Oc,yy:Oc},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),nf.defineLocale("cv",{months:"кӑрлач_нарӑс_пуш_ака_май_ҫӗртме_утӑ_ҫурла_авӑн_юпа_чӳк_раштав".split("_"),monthsShort:"кӑр_нар_пуш_ака_май_ҫӗр_утӑ_ҫур_авн_юпа_чӳк_раш".split("_"),weekdays:"вырсарникун_тунтикун_ытларикун_юнкун_кӗҫнерникун_эрнекун_шӑматкун".split("_"),weekdaysShort:"выр_тун_ытл_юн_кӗҫ_эрн_шӑм".split("_"),weekdaysMin:"вр_тн_ыт_юн_кҫ_эр_шм".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD-MM-YYYY",LL:"YYYY [ҫулхи] MMMM [уйӑхӗн] D[-мӗшӗ]",LLL:"YYYY [ҫулхи] MMMM [уйӑхӗн] D[-мӗшӗ], LT",LLLL:"dddd, YYYY [ҫулхи] MMMM [уйӑхӗн] D[-мӗшӗ], LT"},calendar:{sameDay:"[Паян] LT [сехетре]",nextDay:"[Ыран] LT [сехетре]",lastDay:"[Ӗнер] LT [сехетре]",nextWeek:"[Ҫитес] dddd LT [сехетре]",lastWeek:"[Иртнӗ] dddd LT [сехетре]",sameElse:"L"},relativeTime:{future:function(a){var b=/сехет$/i.exec(a)?"рен":/ҫул$/i.exec(a)?"тан":"ран";return a+b},past:"%s каялла",s:"пӗр-ик ҫеккунт",m:"пӗр минут",mm:"%d минут",h:"пӗр сехет",hh:"%d сехет",d:"пӗр кун",dd:"%d кун",M:"пӗр уйӑх",MM:"%d уйӑх",y:"пӗр ҫул",yy:"%d ҫул"},ordinalParse:/\d{1,2}-мӗш/,ordinal:"%d-мӗш",week:{dow:1,doy:7}}),nf.defineLocale("cy",{months:"Ionawr_Chwefror_Mawrth_Ebrill_Mai_Mehefin_Gorffennaf_Awst_Medi_Hydref_Tachwedd_Rhagfyr".split("_"),monthsShort:"Ion_Chwe_Maw_Ebr_Mai_Meh_Gor_Aws_Med_Hyd_Tach_Rhag".split("_"),weekdays:"Dydd Sul_Dydd Llun_Dydd Mawrth_Dydd Mercher_Dydd Iau_Dydd Gwener_Dydd Sadwrn".split("_"),weekdaysShort:"Sul_Llun_Maw_Mer_Iau_Gwe_Sad".split("_"),weekdaysMin:"Su_Ll_Ma_Me_Ia_Gw_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Heddiw am] LT",nextDay:"[Yfory am] LT",nextWeek:"dddd [am] LT",lastDay:"[Ddoe am] LT",lastWeek:"dddd [diwethaf am] LT",sameElse:"L"},relativeTime:{future:"mewn %s",past:"%s yn ôl",s:"ychydig eiliadau",m:"munud",mm:"%d munud",h:"awr",hh:"%d awr",d:"diwrnod",dd:"%d diwrnod",M:"mis",MM:"%d mis",y:"blwyddyn",yy:"%d flynedd"},ordinalParse:/\d{1,2}(fed|ain|af|il|ydd|ed|eg)/,ordinal:function(a){var b=a,c="",d=["","af","il","ydd","ydd","ed","ed","ed","fed","fed","fed","eg","fed","eg","eg","fed","eg","eg","fed","eg","fed"];return b>20?c=40===b||50===b||60===b||80===b||100===b?"fed":"ain":b>0&&(c=d[b]),a+c},week:{dow:1,doy:4}}),nf.defineLocale("da",{months:"januar_februar_marts_april_maj_juni_juli_august_september_oktober_november_december".split("_"),monthsShort:"jan_feb_mar_apr_maj_jun_jul_aug_sep_okt_nov_dec".split("_"),weekdays:"søndag_mandag_tirsdag_onsdag_torsdag_fredag_lørdag".split("_"),weekdaysShort:"søn_man_tir_ons_tor_fre_lør".split("_"),weekdaysMin:"sø_ma_ti_on_to_fr_lø".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd [d.] D. MMMM YYYY LT"},calendar:{sameDay:"[I dag kl.] LT",nextDay:"[I morgen kl.] LT",nextWeek:"dddd [kl.] LT",lastDay:"[I går kl.] LT",lastWeek:"[sidste] dddd [kl] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"%s siden",s:"få sekunder",m:"et minut",mm:"%d minutter",h:"en time",hh:"%d timer",d:"en dag",dd:"%d dage",M:"en måned",MM:"%d måneder",y:"et år",yy:"%d år"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),nf.defineLocale("de-at",{months:"Jänner_Februar_März_April_Mai_Juni_Juli_August_September_Oktober_November_Dezember".split("_"),monthsShort:"Jän._Febr._Mrz._Apr._Mai_Jun._Jul._Aug._Sept._Okt._Nov._Dez.".split("_"),weekdays:"Sonntag_Montag_Dienstag_Mittwoch_Donnerstag_Freitag_Samstag".split("_"),weekdaysShort:"So._Mo._Di._Mi._Do._Fr._Sa.".split("_"),weekdaysMin:"So_Mo_Di_Mi_Do_Fr_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"HH:mm:ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[Heute um] LT [Uhr]",sameElse:"L",nextDay:"[Morgen um] LT [Uhr]",nextWeek:"dddd [um] LT [Uhr]",lastDay:"[Gestern um] LT [Uhr]",lastWeek:"[letzten] dddd [um] LT [Uhr]"},relativeTime:{future:"in %s",past:"vor %s",s:"ein paar Sekunden",m:Pc,mm:"%d Minuten",h:Pc,hh:"%d Stunden",d:Pc,dd:Pc,M:Pc,MM:Pc,y:Pc,yy:Pc},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),nf.defineLocale("de",{months:"Januar_Februar_März_April_Mai_Juni_Juli_August_September_Oktober_November_Dezember".split("_"),monthsShort:"Jan._Febr._Mrz._Apr._Mai_Jun._Jul._Aug._Sept._Okt._Nov._Dez.".split("_"),weekdays:"Sonntag_Montag_Dienstag_Mittwoch_Donnerstag_Freitag_Samstag".split("_"),weekdaysShort:"So._Mo._Di._Mi._Do._Fr._Sa.".split("_"),weekdaysMin:"So_Mo_Di_Mi_Do_Fr_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"HH:mm:ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[Heute um] LT [Uhr]",sameElse:"L",nextDay:"[Morgen um] LT [Uhr]",nextWeek:"dddd [um] LT [Uhr]",lastDay:"[Gestern um] LT [Uhr]",lastWeek:"[letzten] dddd [um] LT [Uhr]"},relativeTime:{future:"in %s",past:"vor %s",s:"ein paar Sekunden",m:Qc,mm:"%d Minuten",h:Qc,hh:"%d Stunden",d:Qc,dd:Qc,M:Qc,MM:Qc,y:Qc,yy:Qc},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),nf.defineLocale("el",{monthsNominativeEl:"Ιανουάριος_Φεβρουάριος_Μάρτιος_Απρίλιος_Μάιος_Ιούνιος_Ιούλιος_Αύγουστος_Σεπτέμβριος_Οκτώβριος_Νοέμβριος_Δεκέμβριος".split("_"),monthsGenitiveEl:"Ιανουαρίου_Φεβρουαρίου_Μαρτίου_Απριλίου_Μαΐου_Ιουνίου_Ιουλίου_Αυγούστου_Σεπτεμβρίου_Οκτωβρίου_Νοεμβρίου_Δεκεμβρίου".split("_"),months:function(a,b){return/D/.test(b.substring(0,b.indexOf("MMMM")))?this._monthsGenitiveEl[a.month()]:this._monthsNominativeEl[a.month()]},monthsShort:"Ιαν_Φεβ_Μαρ_Απρ_Μαϊ_Ιουν_Ιουλ_Αυγ_Σεπ_Οκτ_Νοε_Δεκ".split("_"),weekdays:"Κυριακή_Δευτέρα_Τρίτη_Τετάρτη_Πέμπτη_Παρασκευή_Σάββατο".split("_"),weekdaysShort:"Κυρ_Δευ_Τρι_Τετ_Πεμ_Παρ_Σαβ".split("_"),weekdaysMin:"Κυ_Δε_Τρ_Τε_Πε_Πα_Σα".split("_"),meridiem:function(a,b,c){return a>11?c?"μμ":"ΜΜ":c?"πμ":"ΠΜ"},isPM:function(a){return"μ"===(a+"").toLowerCase()[0]},meridiemParse:/[ΠΜ]\.?Μ?\.?/i,longDateFormat:{LT:"h:mm A",LTS:"h:mm:ss A",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendarEl:{sameDay:"[Σήμερα {}] LT",nextDay:"[Αύριο {}] LT",nextWeek:"dddd [{}] LT",lastDay:"[Χθες {}] LT",lastWeek:function(){switch(this.day()){case 6:return"[το προηγούμενο] dddd [{}] LT";default:return"[την προηγούμενη] dddd [{}] LT"}},sameElse:"L"},calendar:function(a,b){var c=this._calendarEl[a],d=b&&b.hours();return"function"==typeof c&&(c=c.apply(b)),c.replace("{}",d%12===1?"στη":"στις")},relativeTime:{future:"σε %s",past:"%s πριν",s:"λίγα δευτερόλεπτα",m:"ένα λεπτό",mm:"%d λεπτά",h:"μία ώρα",hh:"%d ώρες",d:"μία μέρα",dd:"%d μέρες",M:"ένας μήνας",MM:"%d μήνες",y:"ένας χρόνος",yy:"%d χρόνια"},ordinalParse:/\d{1,2}η/,ordinal:"%dη",week:{dow:1,doy:4}}),nf.defineLocale("en-au",{months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),monthsShort:"Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),weekdaysShort:"Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),weekdaysMin:"Su_Mo_Tu_We_Th_Fr_Sa".split("_"),longDateFormat:{LT:"h:mm A",LTS:"h:mm:ss A",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},ordinalParse:/\d{1,2}(st|nd|rd|th)/,ordinal:function(a){var b=a%10,c=1===~~(a%100/10)?"th":1===b?"st":2===b?"nd":3===b?"rd":"th";return a+c},week:{dow:1,doy:4}}),nf.defineLocale("en-ca",{months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),monthsShort:"Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),weekdaysShort:"Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),weekdaysMin:"Su_Mo_Tu_We_Th_Fr_Sa".split("_"),longDateFormat:{LT:"h:mm A",LTS:"h:mm:ss A",L:"YYYY-MM-DD",LL:"D MMMM, YYYY",LLL:"D MMMM, YYYY LT",LLLL:"dddd, D MMMM, YYYY LT"},calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},ordinalParse:/\d{1,2}(st|nd|rd|th)/,ordinal:function(a){var b=a%10,c=1===~~(a%100/10)?"th":1===b?"st":2===b?"nd":3===b?"rd":"th";return a+c}}),nf.defineLocale("en-gb",{months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),monthsShort:"Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),weekdaysShort:"Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),weekdaysMin:"Su_Mo_Tu_We_Th_Fr_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"HH:mm:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},ordinalParse:/\d{1,2}(st|nd|rd|th)/,ordinal:function(a){var b=a%10,c=1===~~(a%100/10)?"th":1===b?"st":2===b?"nd":3===b?"rd":"th";return a+c},week:{dow:1,doy:4}}),nf.defineLocale("eo",{months:"januaro_februaro_marto_aprilo_majo_junio_julio_aŭgusto_septembro_oktobro_novembro_decembro".split("_"),monthsShort:"jan_feb_mar_apr_maj_jun_jul_aŭg_sep_okt_nov_dec".split("_"),weekdays:"Dimanĉo_Lundo_Mardo_Merkredo_Ĵaŭdo_Vendredo_Sabato".split("_"),weekdaysShort:"Dim_Lun_Mard_Merk_Ĵaŭ_Ven_Sab".split("_"),weekdaysMin:"Di_Lu_Ma_Me_Ĵa_Ve_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"YYYY-MM-DD",LL:"D[-an de] MMMM, YYYY",LLL:"D[-an de] MMMM, YYYY LT",LLLL:"dddd, [la] D[-an de] MMMM, YYYY LT"},meridiemParse:/[ap]\.t\.m/i,isPM:function(a){return"p"===a.charAt(0).toLowerCase()},meridiem:function(a,b,c){return a>11?c?"p.t.m.":"P.T.M.":c?"a.t.m.":"A.T.M."},calendar:{sameDay:"[Hodiaŭ je] LT",nextDay:"[Morgaŭ je] LT",nextWeek:"dddd [je] LT",lastDay:"[Hieraŭ je] LT",lastWeek:"[pasinta] dddd [je] LT",sameElse:"L"},relativeTime:{future:"je %s",past:"antaŭ %s",s:"sekundoj",m:"minuto",mm:"%d minutoj",h:"horo",hh:"%d horoj",d:"tago",dd:"%d tagoj",M:"monato",MM:"%d monatoj",y:"jaro",yy:"%d jaroj"},ordinalParse:/\d{1,2}a/,ordinal:"%da",week:{dow:1,doy:7}}),"Ene._Feb._Mar._Abr._May._Jun._Jul._Ago._Sep._Oct._Nov._Dic.".split("_")),Ef="Ene_Feb_Mar_Abr_May_Jun_Jul_Ago_Sep_Oct_Nov_Dic".split("_"),Ff=(nf.defineLocale("es",{months:"Enero_Febrero_Marzo_Abril_Mayo_Junio_Julio_Agosto_Septiembre_Octubre_Noviembre_Diciembre".split("_"),monthsShort:function(a,b){return/-MMM-/.test(b)?Ef[a.month()]:Df[a.month()]},weekdays:"Domingo_Lunes_Martes_Miércoles_Jueves_Viernes_Sábado".split("_"),weekdaysShort:"Dom._Lun._Mar._Mié._Jue._Vie._Sáb.".split("_"),weekdaysMin:"Do_Lu_Ma_Mi_Ju_Vi_Sá".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY LT",LLLL:"dddd, D [de] MMMM [de] YYYY LT"},calendar:{sameDay:function(){return"[hoy a la"+(1!==this.hours()?"s":"")+"] LT"},nextDay:function(){return"[mañana a la"+(1!==this.hours()?"s":"")+"] LT"},nextWeek:function(){return"dddd [a la"+(1!==this.hours()?"s":"")+"] LT"},lastDay:function(){return"[ayer a la"+(1!==this.hours()?"s":"")+"] LT"},lastWeek:function(){return"[el] dddd [pasado a la"+(1!==this.hours()?"s":"")+"] LT"},sameElse:"L"},relativeTime:{future:"en %s",past:"hace %s",s:"unos segundos",m:"un minuto",mm:"%d minutos",h:"una hora",hh:"%d horas",d:"un día",dd:"%d días",M:"un mes",MM:"%d meses",y:"un año",yy:"%d años"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4}}),nf.defineLocale("et",{months:"jaanuar_veebruar_märts_aprill_mai_juuni_juuli_august_september_oktoober_november_detsember".split("_"),monthsShort:"jaan_veebr_märts_apr_mai_juuni_juuli_aug_sept_okt_nov_dets".split("_"),weekdays:"pühapäev_esmaspäev_teisipäev_kolmapäev_neljapäev_reede_laupäev".split("_"),weekdaysShort:"P_E_T_K_N_R_L".split("_"),weekdaysMin:"P_E_T_K_N_R_L".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[Täna,] LT",nextDay:"[Homme,] LT",nextWeek:"[Järgmine] dddd LT",lastDay:"[Eile,] LT",lastWeek:"[Eelmine] dddd LT",sameElse:"L"},relativeTime:{future:"%s pärast",past:"%s tagasi",s:Rc,m:Rc,mm:Rc,h:Rc,hh:Rc,d:Rc,dd:"%d päeva",M:Rc,MM:Rc,y:Rc,yy:Rc},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),nf.defineLocale("eu",{months:"urtarrila_otsaila_martxoa_apirila_maiatza_ekaina_uztaila_abuztua_iraila_urria_azaroa_abendua".split("_"),monthsShort:"urt._ots._mar._api._mai._eka._uzt._abu._ira._urr._aza._abe.".split("_"),weekdays:"igandea_astelehena_asteartea_asteazkena_osteguna_ostirala_larunbata".split("_"),weekdaysShort:"ig._al._ar._az._og._ol._lr.".split("_"),weekdaysMin:"ig_al_ar_az_og_ol_lr".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"YYYY-MM-DD",LL:"YYYY[ko] MMMM[ren] D[a]",LLL:"YYYY[ko] MMMM[ren] D[a] LT",LLLL:"dddd, YYYY[ko] MMMM[ren] D[a] LT",l:"YYYY-M-D",ll:"YYYY[ko] MMM D[a]",lll:"YYYY[ko] MMM D[a] LT",llll:"ddd, YYYY[ko] MMM D[a] LT"},calendar:{sameDay:"[gaur] LT[etan]",nextDay:"[bihar] LT[etan]",nextWeek:"dddd LT[etan]",lastDay:"[atzo] LT[etan]",lastWeek:"[aurreko] dddd LT[etan]",sameElse:"L"},relativeTime:{future:"%s barru",past:"duela %s",s:"segundo batzuk",m:"minutu bat",mm:"%d minutu",h:"ordu bat",hh:"%d ordu",d:"egun bat", +dd:"%d egun",M:"hilabete bat",MM:"%d hilabete",y:"urte bat",yy:"%d urte"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),{1:"۱",2:"۲",3:"۳",4:"۴",5:"۵",6:"۶",7:"۷",8:"۸",9:"۹",0:"۰"}),Gf={"۱":"1","۲":"2","۳":"3","۴":"4","۵":"5","۶":"6","۷":"7","۸":"8","۹":"9","۰":"0"},Hf=(nf.defineLocale("fa",{months:"ژانویه_فوریه_مارس_آوریل_مه_ژوئن_ژوئیه_اوت_سپتامبر_اکتبر_نوامبر_دسامبر".split("_"),monthsShort:"ژانویه_فوریه_مارس_آوریل_مه_ژوئن_ژوئیه_اوت_سپتامبر_اکتبر_نوامبر_دسامبر".split("_"),weekdays:"یک‌شنبه_دوشنبه_سه‌شنبه_چهارشنبه_پنج‌شنبه_جمعه_شنبه".split("_"),weekdaysShort:"یک‌شنبه_دوشنبه_سه‌شنبه_چهارشنبه_پنج‌شنبه_جمعه_شنبه".split("_"),weekdaysMin:"ی_د_س_چ_پ_ج_ش".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},meridiemParse:/قبل از ظهر|بعد از ظهر/,isPM:function(a){return/بعد از ظهر/.test(a)},meridiem:function(a,b,c){return 12>a?"قبل از ظهر":"بعد از ظهر"},calendar:{sameDay:"[امروز ساعت] LT",nextDay:"[فردا ساعت] LT",nextWeek:"dddd [ساعت] LT",lastDay:"[دیروز ساعت] LT",lastWeek:"dddd [پیش] [ساعت] LT",sameElse:"L"},relativeTime:{future:"در %s",past:"%s پیش",s:"چندین ثانیه",m:"یک دقیقه",mm:"%d دقیقه",h:"یک ساعت",hh:"%d ساعت",d:"یک روز",dd:"%d روز",M:"یک ماه",MM:"%d ماه",y:"یک سال",yy:"%d سال"},preparse:function(a){return a.replace(/[۰-۹]/g,function(a){return Gf[a]}).replace(/،/g,",")},postformat:function(a){return a.replace(/\d/g,function(a){return Ff[a]}).replace(/,/g,"،")},ordinalParse:/\d{1,2}م/,ordinal:"%dم",week:{dow:6,doy:12}}),"nolla yksi kaksi kolme neljä viisi kuusi seitsemän kahdeksan yhdeksän".split(" ")),If=["nolla","yhden","kahden","kolmen","neljän","viiden","kuuden",Hf[7],Hf[8],Hf[9]],Jf=(nf.defineLocale("fi",{months:"tammikuu_helmikuu_maaliskuu_huhtikuu_toukokuu_kesäkuu_heinäkuu_elokuu_syyskuu_lokakuu_marraskuu_joulukuu".split("_"),monthsShort:"tammi_helmi_maalis_huhti_touko_kesä_heinä_elo_syys_loka_marras_joulu".split("_"),weekdays:"sunnuntai_maanantai_tiistai_keskiviikko_torstai_perjantai_lauantai".split("_"),weekdaysShort:"su_ma_ti_ke_to_pe_la".split("_"),weekdaysMin:"su_ma_ti_ke_to_pe_la".split("_"),longDateFormat:{LT:"HH.mm",LTS:"HH.mm.ss",L:"DD.MM.YYYY",LL:"Do MMMM[ta] YYYY",LLL:"Do MMMM[ta] YYYY, [klo] LT",LLLL:"dddd, Do MMMM[ta] YYYY, [klo] LT",l:"D.M.YYYY",ll:"Do MMM YYYY",lll:"Do MMM YYYY, [klo] LT",llll:"ddd, Do MMM YYYY, [klo] LT"},calendar:{sameDay:"[tänään] [klo] LT",nextDay:"[huomenna] [klo] LT",nextWeek:"dddd [klo] LT",lastDay:"[eilen] [klo] LT",lastWeek:"[viime] dddd[na] [klo] LT",sameElse:"L"},relativeTime:{future:"%s päästä",past:"%s sitten",s:Sc,m:Sc,mm:Sc,h:Sc,hh:Sc,d:Sc,dd:Sc,M:Sc,MM:Sc,y:Sc,yy:Sc},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),nf.defineLocale("fo",{months:"januar_februar_mars_apríl_mai_juni_juli_august_september_oktober_november_desember".split("_"),monthsShort:"jan_feb_mar_apr_mai_jun_jul_aug_sep_okt_nov_des".split("_"),weekdays:"sunnudagur_mánadagur_týsdagur_mikudagur_hósdagur_fríggjadagur_leygardagur".split("_"),weekdaysShort:"sun_mán_týs_mik_hós_frí_ley".split("_"),weekdaysMin:"su_má_tý_mi_hó_fr_le".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D. MMMM, YYYY LT"},calendar:{sameDay:"[Í dag kl.] LT",nextDay:"[Í morgin kl.] LT",nextWeek:"dddd [kl.] LT",lastDay:"[Í gjár kl.] LT",lastWeek:"[síðstu] dddd [kl] LT",sameElse:"L"},relativeTime:{future:"um %s",past:"%s síðani",s:"fá sekund",m:"ein minutt",mm:"%d minuttir",h:"ein tími",hh:"%d tímar",d:"ein dagur",dd:"%d dagar",M:"ein mánaði",MM:"%d mánaðir",y:"eitt ár",yy:"%d ár"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),nf.defineLocale("fr-ca",{months:"janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split("_"),monthsShort:"janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split("_"),weekdays:"dimanche_lundi_mardi_mercredi_jeudi_vendredi_samedi".split("_"),weekdaysShort:"dim._lun._mar._mer._jeu._ven._sam.".split("_"),weekdaysMin:"Di_Lu_Ma_Me_Je_Ve_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"YYYY-MM-DD",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[Aujourd'hui à] LT",nextDay:"[Demain à] LT",nextWeek:"dddd [à] LT",lastDay:"[Hier à] LT",lastWeek:"dddd [dernier à] LT",sameElse:"L"},relativeTime:{future:"dans %s",past:"il y a %s",s:"quelques secondes",m:"une minute",mm:"%d minutes",h:"une heure",hh:"%d heures",d:"un jour",dd:"%d jours",M:"un mois",MM:"%d mois",y:"un an",yy:"%d ans"},ordinalParse:/\d{1,2}(er|)/,ordinal:function(a){return a+(1===a?"er":"")}}),nf.defineLocale("fr",{months:"janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split("_"),monthsShort:"janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split("_"),weekdays:"dimanche_lundi_mardi_mercredi_jeudi_vendredi_samedi".split("_"),weekdaysShort:"dim._lun._mar._mer._jeu._ven._sam.".split("_"),weekdaysMin:"Di_Lu_Ma_Me_Je_Ve_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[Aujourd'hui à] LT",nextDay:"[Demain à] LT",nextWeek:"dddd [à] LT",lastDay:"[Hier à] LT",lastWeek:"dddd [dernier à] LT",sameElse:"L"},relativeTime:{future:"dans %s",past:"il y a %s",s:"quelques secondes",m:"une minute",mm:"%d minutes",h:"une heure",hh:"%d heures",d:"un jour",dd:"%d jours",M:"un mois",MM:"%d mois",y:"un an",yy:"%d ans"},ordinalParse:/\d{1,2}(er|)/,ordinal:function(a){return a+(1===a?"er":"")},week:{dow:1,doy:4}}),"jan._feb._mrt._apr._mai_jun._jul._aug._sep._okt._nov._des.".split("_")),Kf="jan_feb_mrt_apr_mai_jun_jul_aug_sep_okt_nov_des".split("_"),Lf=(nf.defineLocale("fy",{months:"jannewaris_febrewaris_maart_april_maaie_juny_july_augustus_septimber_oktober_novimber_desimber".split("_"),monthsShort:function(a,b){return/-MMM-/.test(b)?Kf[a.month()]:Jf[a.month()]},weekdays:"snein_moandei_tiisdei_woansdei_tongersdei_freed_sneon".split("_"),weekdaysShort:"si._mo._ti._wo._to._fr._so.".split("_"),weekdaysMin:"Si_Mo_Ti_Wo_To_Fr_So".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD-MM-YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[hjoed om] LT",nextDay:"[moarn om] LT",nextWeek:"dddd [om] LT",lastDay:"[juster om] LT",lastWeek:"[ôfrûne] dddd [om] LT",sameElse:"L"},relativeTime:{future:"oer %s",past:"%s lyn",s:"in pear sekonden",m:"ien minút",mm:"%d minuten",h:"ien oere",hh:"%d oeren",d:"ien dei",dd:"%d dagen",M:"ien moanne",MM:"%d moannen",y:"ien jier",yy:"%d jierren"},ordinalParse:/\d{1,2}(ste|de)/,ordinal:function(a){return a+(1===a||8===a||a>=20?"ste":"de")},week:{dow:1,doy:4}}),nf.defineLocale("gl",{months:"Xaneiro_Febreiro_Marzo_Abril_Maio_Xuño_Xullo_Agosto_Setembro_Outubro_Novembro_Decembro".split("_"),monthsShort:"Xan._Feb._Mar._Abr._Mai._Xuñ._Xul._Ago._Set._Out._Nov._Dec.".split("_"),weekdays:"Domingo_Luns_Martes_Mércores_Xoves_Venres_Sábado".split("_"),weekdaysShort:"Dom._Lun._Mar._Mér._Xov._Ven._Sáb.".split("_"),weekdaysMin:"Do_Lu_Ma_Mé_Xo_Ve_Sá".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:function(){return"[hoxe "+(1!==this.hours()?"ás":"á")+"] LT"},nextDay:function(){return"[mañá "+(1!==this.hours()?"ás":"á")+"] LT"},nextWeek:function(){return"dddd ["+(1!==this.hours()?"ás":"a")+"] LT"},lastDay:function(){return"[onte "+(1!==this.hours()?"á":"a")+"] LT"},lastWeek:function(){return"[o] dddd [pasado "+(1!==this.hours()?"ás":"a")+"] LT"},sameElse:"L"},relativeTime:{future:function(a){return"uns segundos"===a?"nuns segundos":"en "+a},past:"hai %s",s:"uns segundos",m:"un minuto",mm:"%d minutos",h:"unha hora",hh:"%d horas",d:"un día",dd:"%d días",M:"un mes",MM:"%d meses",y:"un ano",yy:"%d anos"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:7}}),nf.defineLocale("he",{months:"ינואר_פברואר_מרץ_אפריל_מאי_יוני_יולי_אוגוסט_ספטמבר_אוקטובר_נובמבר_דצמבר".split("_"),monthsShort:"ינו׳_פבר׳_מרץ_אפר׳_מאי_יוני_יולי_אוג׳_ספט׳_אוק׳_נוב׳_דצמ׳".split("_"),weekdays:"ראשון_שני_שלישי_רביעי_חמישי_שישי_שבת".split("_"),weekdaysShort:"א׳_ב׳_ג׳_ד׳_ה׳_ו׳_ש׳".split("_"),weekdaysMin:"א_ב_ג_ד_ה_ו_ש".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [ב]MMMM YYYY",LLL:"D [ב]MMMM YYYY LT",LLLL:"dddd, D [ב]MMMM YYYY LT",l:"D/M/YYYY",ll:"D MMM YYYY",lll:"D MMM YYYY LT",llll:"ddd, D MMM YYYY LT"},calendar:{sameDay:"[היום ב־]LT",nextDay:"[מחר ב־]LT",nextWeek:"dddd [בשעה] LT",lastDay:"[אתמול ב־]LT",lastWeek:"[ביום] dddd [האחרון בשעה] LT",sameElse:"L"},relativeTime:{future:"בעוד %s",past:"לפני %s",s:"מספר שניות",m:"דקה",mm:"%d דקות",h:"שעה",hh:function(a){return 2===a?"שעתיים":a+" שעות"},d:"יום",dd:function(a){return 2===a?"יומיים":a+" ימים"},M:"חודש",MM:function(a){return 2===a?"חודשיים":a+" חודשים"},y:"שנה",yy:function(a){return 2===a?"שנתיים":a%10===0&&10!==a?a+" שנה":a+" שנים"}}}),{1:"१",2:"२",3:"३",4:"४",5:"५",6:"६",7:"७",8:"८",9:"९",0:"०"}),Mf={"१":"1","२":"2","३":"3","४":"4","५":"5","६":"6","७":"7","८":"8","९":"9","०":"0"},Nf=(nf.defineLocale("hi",{months:"जनवरी_फ़रवरी_मार्च_अप्रैल_मई_जून_जुलाई_अगस्त_सितम्बर_अक्टूबर_नवम्बर_दिसम्बर".split("_"),monthsShort:"जन._फ़र._मार्च_अप्रै._मई_जून_जुल._अग._सित._अक्टू._नव._दिस.".split("_"),weekdays:"रविवार_सोमवार_मंगलवार_बुधवार_गुरूवार_शुक्रवार_शनिवार".split("_"),weekdaysShort:"रवि_सोम_मंगल_बुध_गुरू_शुक्र_शनि".split("_"),weekdaysMin:"र_सो_मं_बु_गु_शु_श".split("_"),longDateFormat:{LT:"A h:mm बजे",LTS:"A h:mm:ss बजे",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY, LT",LLLL:"dddd, D MMMM YYYY, LT"},calendar:{sameDay:"[आज] LT",nextDay:"[कल] LT",nextWeek:"dddd, LT",lastDay:"[कल] LT",lastWeek:"[पिछले] dddd, LT",sameElse:"L"},relativeTime:{future:"%s में",past:"%s पहले",s:"कुछ ही क्षण",m:"एक मिनट",mm:"%d मिनट",h:"एक घंटा",hh:"%d घंटे",d:"एक दिन",dd:"%d दिन",M:"एक महीने",MM:"%d महीने",y:"एक वर्ष",yy:"%d वर्ष"},preparse:function(a){return a.replace(/[१२३४५६७८९०]/g,function(a){return Mf[a]})},postformat:function(a){return a.replace(/\d/g,function(a){return Lf[a]})},meridiemParse:/रात|सुबह|दोपहर|शाम/,meridiemHour:function(a,b){return 12===a&&(a=0),"रात"===b?4>a?a:a+12:"सुबह"===b?a:"दोपहर"===b?a>=10?a:a+12:"शाम"===b?a+12:void 0},meridiem:function(a,b,c){return 4>a?"रात":10>a?"सुबह":17>a?"दोपहर":20>a?"शाम":"रात"},week:{dow:0,doy:6}}),nf.defineLocale("hr",{months:"siječanj_veljača_ožujak_travanj_svibanj_lipanj_srpanj_kolovoz_rujan_listopad_studeni_prosinac".split("_"),monthsShort:"sij._velj._ožu._tra._svi._lip._srp._kol._ruj._lis._stu._pro.".split("_"),weekdays:"nedjelja_ponedjeljak_utorak_srijeda_četvrtak_petak_subota".split("_"),weekdaysShort:"ned._pon._uto._sri._čet._pet._sub.".split("_"),weekdaysMin:"ne_po_ut_sr_če_pe_su".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD. MM. YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[danas u] LT",nextDay:"[sutra u] LT",nextWeek:function(){switch(this.day()){case 0:return"[u] [nedjelju] [u] LT";case 3:return"[u] [srijedu] [u] LT";case 6:return"[u] [subotu] [u] LT";case 1:case 2:case 4:case 5:return"[u] dddd [u] LT"}},lastDay:"[jučer u] LT",lastWeek:function(){switch(this.day()){case 0:case 3:return"[prošlu] dddd [u] LT";case 6:return"[prošle] [subote] [u] LT";case 1:case 2:case 4:case 5:return"[prošli] dddd [u] LT"}},sameElse:"L"},relativeTime:{future:"za %s",past:"prije %s",s:"par sekundi",m:Uc,mm:Uc,h:Uc,hh:Uc,d:"dan",dd:Uc,M:"mjesec",MM:Uc,y:"godinu",yy:Uc},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),"vasárnap hétfőn kedden szerdán csütörtökön pénteken szombaton".split(" ")),Of=(nf.defineLocale("hu",{months:"január_február_március_április_május_június_július_augusztus_szeptember_október_november_december".split("_"),monthsShort:"jan_feb_márc_ápr_máj_jún_júl_aug_szept_okt_nov_dec".split("_"),weekdays:"vasárnap_hétfő_kedd_szerda_csütörtök_péntek_szombat".split("_"),weekdaysShort:"vas_hét_kedd_sze_csüt_pén_szo".split("_"),weekdaysMin:"v_h_k_sze_cs_p_szo".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"YYYY.MM.DD.",LL:"YYYY. MMMM D.",LLL:"YYYY. MMMM D., LT",LLLL:"YYYY. MMMM D., dddd LT"},meridiemParse:/de|du/i,isPM:function(a){return"u"===a.charAt(1).toLowerCase()},meridiem:function(a,b,c){return 12>a?c===!0?"de":"DE":c===!0?"du":"DU"},calendar:{sameDay:"[ma] LT[-kor]",nextDay:"[holnap] LT[-kor]",nextWeek:function(){return Wc.call(this,!0)},lastDay:"[tegnap] LT[-kor]",lastWeek:function(){return Wc.call(this,!1)},sameElse:"L"},relativeTime:{future:"%s múlva",past:"%s",s:Vc,m:Vc,mm:Vc,h:Vc,hh:Vc,d:Vc,dd:Vc,M:Vc,MM:Vc,y:Vc,yy:Vc},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),nf.defineLocale("hy-am",{months:Xc,monthsShort:Yc,weekdays:Zc,weekdaysShort:"կրկ_երկ_երք_չրք_հնգ_ուրբ_շբթ".split("_"),weekdaysMin:"կրկ_երկ_երք_չրք_հնգ_ուրբ_շբթ".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY թ.",LLL:"D MMMM YYYY թ., LT",LLLL:"dddd, D MMMM YYYY թ., LT"},calendar:{sameDay:"[այսօր] LT",nextDay:"[վաղը] LT",lastDay:"[երեկ] LT",nextWeek:function(){return"dddd [օրը ժամը] LT"},lastWeek:function(){return"[անցած] dddd [օրը ժամը] LT"},sameElse:"L"},relativeTime:{future:"%s հետո",past:"%s առաջ",s:"մի քանի վայրկյան",m:"րոպե",mm:"%d րոպե",h:"ժամ",hh:"%d ժամ",d:"օր",dd:"%d օր",M:"ամիս",MM:"%d ամիս",y:"տարի",yy:"%d տարի"},meridiemParse:/գիշերվա|առավոտվա|ցերեկվա|երեկոյան/,isPM:function(a){return/^(ցերեկվա|երեկոյան)$/.test(a)},meridiem:function(a){return 4>a?"գիշերվա":12>a?"առավոտվա":17>a?"ցերեկվա":"երեկոյան"},ordinalParse:/\d{1,2}|\d{1,2}-(ին|րդ)/,ordinal:function(a,b){switch(b){case"DDD":case"w":case"W":case"DDDo":return 1===a?a+"-ին":a+"-րդ";default:return a}},week:{dow:1,doy:7}}),nf.defineLocale("id",{months:"Januari_Februari_Maret_April_Mei_Juni_Juli_Agustus_September_Oktober_November_Desember".split("_"),monthsShort:"Jan_Feb_Mar_Apr_Mei_Jun_Jul_Ags_Sep_Okt_Nov_Des".split("_"),weekdays:"Minggu_Senin_Selasa_Rabu_Kamis_Jumat_Sabtu".split("_"),weekdaysShort:"Min_Sen_Sel_Rab_Kam_Jum_Sab".split("_"),weekdaysMin:"Mg_Sn_Sl_Rb_Km_Jm_Sb".split("_"),longDateFormat:{LT:"HH.mm",LTS:"LT.ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY [pukul] LT",LLLL:"dddd, D MMMM YYYY [pukul] LT"},meridiemParse:/pagi|siang|sore|malam/,meridiemHour:function(a,b){return 12===a&&(a=0),"pagi"===b?a:"siang"===b?a>=11?a:a+12:"sore"===b||"malam"===b?a+12:void 0},meridiem:function(a,b,c){return 11>a?"pagi":15>a?"siang":19>a?"sore":"malam"},calendar:{sameDay:"[Hari ini pukul] LT",nextDay:"[Besok pukul] LT",nextWeek:"dddd [pukul] LT",lastDay:"[Kemarin pukul] LT",lastWeek:"dddd [lalu pukul] LT",sameElse:"L"},relativeTime:{future:"dalam %s",past:"%s yang lalu",s:"beberapa detik",m:"semenit",mm:"%d menit",h:"sejam",hh:"%d jam",d:"sehari",dd:"%d hari",M:"sebulan",MM:"%d bulan",y:"setahun",yy:"%d tahun"},week:{dow:1,doy:7}}),nf.defineLocale("is",{months:"janúar_febrúar_mars_apríl_maí_júní_júlí_ágúst_september_október_nóvember_desember".split("_"),monthsShort:"jan_feb_mar_apr_maí_jún_júl_ágú_sep_okt_nóv_des".split("_"),weekdays:"sunnudagur_mánudagur_þriðjudagur_miðvikudagur_fimmtudagur_föstudagur_laugardagur".split("_"),weekdaysShort:"sun_mán_þri_mið_fim_fös_lau".split("_"),weekdaysMin:"Su_Má_Þr_Mi_Fi_Fö_La".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY [kl.] LT",LLLL:"dddd, D. MMMM YYYY [kl.] LT"},calendar:{sameDay:"[í dag kl.] LT",nextDay:"[á morgun kl.] LT",nextWeek:"dddd [kl.] LT",lastDay:"[í gær kl.] LT",lastWeek:"[síðasta] dddd [kl.] LT",sameElse:"L"},relativeTime:{future:"eftir %s",past:"fyrir %s síðan",s:_c,m:_c,mm:_c,h:"klukkustund",hh:_c,d:_c,dd:_c,M:_c,MM:_c,y:_c,yy:_c},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),nf.defineLocale("it",{months:"gennaio_febbraio_marzo_aprile_maggio_giugno_luglio_agosto_settembre_ottobre_novembre_dicembre".split("_"),monthsShort:"gen_feb_mar_apr_mag_giu_lug_ago_set_ott_nov_dic".split("_"),weekdays:"Domenica_Lunedì_Martedì_Mercoledì_Giovedì_Venerdì_Sabato".split("_"),weekdaysShort:"Dom_Lun_Mar_Mer_Gio_Ven_Sab".split("_"),weekdaysMin:"D_L_Ma_Me_G_V_S".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Oggi alle] LT",nextDay:"[Domani alle] LT",nextWeek:"dddd [alle] LT",lastDay:"[Ieri alle] LT",lastWeek:function(){switch(this.day()){case 0:return"[la scorsa] dddd [alle] LT";default:return"[lo scorso] dddd [alle] LT"}},sameElse:"L"},relativeTime:{future:function(a){return(/^[0-9].+$/.test(a)?"tra":"in")+" "+a},past:"%s fa",s:"alcuni secondi",m:"un minuto",mm:"%d minuti",h:"un'ora",hh:"%d ore",d:"un giorno",dd:"%d giorni",M:"un mese",MM:"%d mesi",y:"un anno",yy:"%d anni"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4}}),nf.defineLocale("ja",{months:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),monthsShort:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),weekdays:"日曜日_月曜日_火曜日_水曜日_木曜日_金曜日_土曜日".split("_"),weekdaysShort:"日_月_火_水_木_金_土".split("_"),weekdaysMin:"日_月_火_水_木_金_土".split("_"),longDateFormat:{LT:"Ah時m分",LTS:"LTs秒",L:"YYYY/MM/DD",LL:"YYYY年M月D日",LLL:"YYYY年M月D日LT",LLLL:"YYYY年M月D日LT dddd"},meridiemParse:/午前|午後/i,isPM:function(a){return"午後"===a},meridiem:function(a,b,c){return 12>a?"午前":"午後"},calendar:{sameDay:"[今日] LT",nextDay:"[明日] LT",nextWeek:"[来週]dddd LT",lastDay:"[昨日] LT",lastWeek:"[前週]dddd LT",sameElse:"L"},relativeTime:{future:"%s後",past:"%s前",s:"数秒",m:"1分",mm:"%d分",h:"1時間",hh:"%d時間",d:"1日",dd:"%d日",M:"1ヶ月",MM:"%dヶ月",y:"1年",yy:"%d年"}}),nf.defineLocale("jv",{months:"Januari_Februari_Maret_April_Mei_Juni_Juli_Agustus_September_Oktober_Nopember_Desember".split("_"),monthsShort:"Jan_Feb_Mar_Apr_Mei_Jun_Jul_Ags_Sep_Okt_Nop_Des".split("_"),weekdays:"Minggu_Senen_Seloso_Rebu_Kemis_Jemuwah_Septu".split("_"),weekdaysShort:"Min_Sen_Sel_Reb_Kem_Jem_Sep".split("_"),weekdaysMin:"Mg_Sn_Sl_Rb_Km_Jm_Sp".split("_"),longDateFormat:{LT:"HH.mm",LTS:"LT.ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY [pukul] LT",LLLL:"dddd, D MMMM YYYY [pukul] LT"},meridiemParse:/enjing|siyang|sonten|ndalu/,meridiemHour:function(a,b){return 12===a&&(a=0),"enjing"===b?a:"siyang"===b?a>=11?a:a+12:"sonten"===b||"ndalu"===b?a+12:void 0},meridiem:function(a,b,c){return 11>a?"enjing":15>a?"siyang":19>a?"sonten":"ndalu"},calendar:{sameDay:"[Dinten puniko pukul] LT",nextDay:"[Mbenjang pukul] LT",nextWeek:"dddd [pukul] LT",lastDay:"[Kala wingi pukul] LT",lastWeek:"dddd [kepengker pukul] LT",sameElse:"L"},relativeTime:{future:"wonten ing %s",past:"%s ingkang kepengker",s:"sawetawis detik",m:"setunggal menit",mm:"%d menit",h:"setunggal jam",hh:"%d jam",d:"sedinten",dd:"%d dinten",M:"sewulan",MM:"%d wulan",y:"setaun",yy:"%d taun"},week:{dow:1,doy:7}}),nf.defineLocale("ka",{months:ad,monthsShort:"იან_თებ_მარ_აპრ_მაი_ივნ_ივლ_აგვ_სექ_ოქტ_ნოე_დეკ".split("_"),weekdays:bd,weekdaysShort:"კვი_ორშ_სამ_ოთხ_ხუთ_პარ_შაბ".split("_"),weekdaysMin:"კვ_ორ_სა_ოთ_ხუ_პა_შა".split("_"),longDateFormat:{LT:"h:mm A",LTS:"h:mm:ss A",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[დღეს] LT[-ზე]",nextDay:"[ხვალ] LT[-ზე]",lastDay:"[გუშინ] LT[-ზე]",nextWeek:"[შემდეგ] dddd LT[-ზე]",lastWeek:"[წინა] dddd LT-ზე",sameElse:"L"},relativeTime:{future:function(a){return/(წამი|წუთი|საათი|წელი)/.test(a)?a.replace(/ი$/,"ში"):a+"ში"},past:function(a){return/(წამი|წუთი|საათი|დღე|თვე)/.test(a)?a.replace(/(ი|ე)$/,"ის წინ"):/წელი/.test(a)?a.replace(/წელი$/,"წლის წინ"):void 0},s:"რამდენიმე წამი",m:"წუთი",mm:"%d წუთი",h:"საათი",hh:"%d საათი",d:"დღე",dd:"%d დღე",M:"თვე",MM:"%d თვე",y:"წელი",yy:"%d წელი"},ordinalParse:/0|1-ლი|მე-\d{1,2}|\d{1,2}-ე/,ordinal:function(a){return 0===a?a:1===a?a+"-ლი":20>a||100>=a&&a%20===0||a%100===0?"მე-"+a:a+"-ე"},week:{dow:1,doy:7}}),nf.defineLocale("km",{months:"មករា_កុម្ភៈ_មិនា_មេសា_ឧសភា_មិថុនា_កក្កដា_សីហា_កញ្ញា_តុលា_វិច្ឆិកា_ធ្នូ".split("_"),monthsShort:"មករា_កុម្ភៈ_មិនា_មេសា_ឧសភា_មិថុនា_កក្កដា_សីហា_កញ្ញា_តុលា_វិច្ឆិកា_ធ្នូ".split("_"),weekdays:"អាទិត្យ_ច័ន្ទ_អង្គារ_ពុធ_ព្រហស្បតិ៍_សុក្រ_សៅរ៍".split("_"),weekdaysShort:"អាទិត្យ_ច័ន្ទ_អង្គារ_ពុធ_ព្រហស្បតិ៍_សុក្រ_សៅរ៍".split("_"),weekdaysMin:"អាទិត្យ_ច័ន្ទ_អង្គារ_ពុធ_ព្រហស្បតិ៍_សុក្រ_សៅរ៍".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[ថ្ងៃនៈ ម៉ោង] LT",nextDay:"[ស្អែក ម៉ោង] LT",nextWeek:"dddd [ម៉ោង] LT",lastDay:"[ម្សិលមិញ ម៉ោង] LT",lastWeek:"dddd [សប្តាហ៍មុន] [ម៉ោង] LT",sameElse:"L"},relativeTime:{future:"%sទៀត",past:"%sមុន",s:"ប៉ុន្មានវិនាទី",m:"មួយនាទី",mm:"%d នាទី",h:"មួយម៉ោង",hh:"%d ម៉ោង",d:"មួយថ្ងៃ",dd:"%d ថ្ងៃ",M:"មួយខែ",MM:"%d ខែ",y:"មួយឆ្នាំ",yy:"%d ឆ្នាំ"},week:{dow:1,doy:4}}),nf.defineLocale("ko",{months:"1월_2월_3월_4월_5월_6월_7월_8월_9월_10월_11월_12월".split("_"),monthsShort:"1월_2월_3월_4월_5월_6월_7월_8월_9월_10월_11월_12월".split("_"),weekdays:"일요일_월요일_화요일_수요일_목요일_금요일_토요일".split("_"),weekdaysShort:"일_월_화_수_목_금_토".split("_"),weekdaysMin:"일_월_화_수_목_금_토".split("_"),longDateFormat:{LT:"A h시 m분",LTS:"A h시 m분 s초",L:"YYYY.MM.DD",LL:"YYYY년 MMMM D일",LLL:"YYYY년 MMMM D일 LT",LLLL:"YYYY년 MMMM D일 dddd LT"},calendar:{sameDay:"오늘 LT",nextDay:"내일 LT",nextWeek:"dddd LT",lastDay:"어제 LT",lastWeek:"지난주 dddd LT",sameElse:"L"},relativeTime:{future:"%s 후",past:"%s 전",s:"몇초",ss:"%d초",m:"일분",mm:"%d분",h:"한시간",hh:"%d시간",d:"하루",dd:"%d일",M:"한달",MM:"%d달",y:"일년",yy:"%d년"},ordinalParse:/\d{1,2}일/,ordinal:"%d일",meridiemParse:/오전|오후/,isPM:function(a){return"오후"===a},meridiem:function(a,b,c){return 12>a?"오전":"오후"}}),nf.defineLocale("lb",{months:"Januar_Februar_Mäerz_Abrëll_Mee_Juni_Juli_August_September_Oktober_November_Dezember".split("_"),monthsShort:"Jan._Febr._Mrz._Abr._Mee_Jun._Jul._Aug._Sept._Okt._Nov._Dez.".split("_"),weekdays:"Sonndeg_Méindeg_Dënschdeg_Mëttwoch_Donneschdeg_Freideg_Samschdeg".split("_"),weekdaysShort:"So._Mé._Dë._Më._Do._Fr._Sa.".split("_"),weekdaysMin:"So_Mé_Dë_Më_Do_Fr_Sa".split("_"),longDateFormat:{LT:"H:mm [Auer]",LTS:"H:mm:ss [Auer]",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[Haut um] LT",sameElse:"L",nextDay:"[Muer um] LT",nextWeek:"dddd [um] LT",lastDay:"[Gëschter um] LT",lastWeek:function(){switch(this.day()){case 2:case 4:return"[Leschten] dddd [um] LT";default:return"[Leschte] dddd [um] LT"}}},relativeTime:{future:dd,past:ed,s:"e puer Sekonnen",m:cd,mm:"%d Minutten",h:cd,hh:"%d Stonnen",d:cd,dd:"%d Deeg",M:cd,MM:"%d Méint",y:cd,yy:"%d Joer"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),{m:"minutė_minutės_minutę",mm:"minutės_minučių_minutes",h:"valanda_valandos_valandą",hh:"valandos_valandų_valandas",d:"diena_dienos_dieną",dd:"dienos_dienų_dienas",M:"mėnuo_mėnesio_mėnesį",MM:"mėnesiai_mėnesių_mėnesius",y:"metai_metų_metus",yy:"metai_metų_metus"}),Pf="sekmadienis_pirmadienis_antradienis_trečiadienis_ketvirtadienis_penktadienis_šeštadienis".split("_"),Qf=(nf.defineLocale("lt",{months:"sausio_vasario_kovo_balandžio_gegužės_birželio_liepos_rugpjūčio_rugsėjo_spalio_lapkričio_gruodžio".split("_"),monthsShort:"sau_vas_kov_bal_geg_bir_lie_rgp_rgs_spa_lap_grd".split("_"),weekdays:ld,weekdaysShort:"Sek_Pir_Ant_Tre_Ket_Pen_Šeš".split("_"),weekdaysMin:"S_P_A_T_K_Pn_Š".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"YYYY-MM-DD",LL:"YYYY [m.] MMMM D [d.]",LLL:"YYYY [m.] MMMM D [d.], LT [val.]",LLLL:"YYYY [m.] MMMM D [d.], dddd, LT [val.]",l:"YYYY-MM-DD",ll:"YYYY [m.] MMMM D [d.]",lll:"YYYY [m.] MMMM D [d.], LT [val.]",llll:"YYYY [m.] MMMM D [d.], ddd, LT [val.]"},calendar:{sameDay:"[Šiandien] LT",nextDay:"[Rytoj] LT",nextWeek:"dddd LT",lastDay:"[Vakar] LT",lastWeek:"[Praėjusį] dddd LT",sameElse:"L"},relativeTime:{future:"po %s",past:"prieš %s",s:gd,m:hd,mm:kd,h:hd,hh:kd,d:hd,dd:kd,M:hd,MM:kd,y:hd,yy:kd},ordinalParse:/\d{1,2}-oji/,ordinal:function(a){return a+"-oji"},week:{dow:1,doy:4}}),{m:"minūtes_minūtēm_minūte_minūtes".split("_"),mm:"minūtes_minūtēm_minūte_minūtes".split("_"),h:"stundas_stundām_stunda_stundas".split("_"),hh:"stundas_stundām_stunda_stundas".split("_"),d:"dienas_dienām_diena_dienas".split("_"),dd:"dienas_dienām_diena_dienas".split("_"),M:"mēneša_mēnešiem_mēnesis_mēneši".split("_"),MM:"mēneša_mēnešiem_mēnesis_mēneši".split("_"),y:"gada_gadiem_gads_gadi".split("_"),yy:"gada_gadiem_gads_gadi".split("_")}),Rf=(nf.defineLocale("lv",{months:"janvāris_februāris_marts_aprīlis_maijs_jūnijs_jūlijs_augusts_septembris_oktobris_novembris_decembris".split("_"),monthsShort:"jan_feb_mar_apr_mai_jūn_jūl_aug_sep_okt_nov_dec".split("_"),weekdays:"svētdiena_pirmdiena_otrdiena_trešdiena_ceturtdiena_piektdiena_sestdiena".split("_"),weekdaysShort:"Sv_P_O_T_C_Pk_S".split("_"),weekdaysMin:"Sv_P_O_T_C_Pk_S".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY.",LL:"YYYY. [gada] D. MMMM",LLL:"YYYY. [gada] D. MMMM, LT",LLLL:"YYYY. [gada] D. MMMM, dddd, LT"},calendar:{sameDay:"[Šodien pulksten] LT",nextDay:"[Rīt pulksten] LT",nextWeek:"dddd [pulksten] LT",lastDay:"[Vakar pulksten] LT",lastWeek:"[Pagājušā] dddd [pulksten] LT",sameElse:"L"},relativeTime:{future:"pēc %s",past:"pirms %s",s:pd,m:od,mm:nd,h:od,hh:nd,d:od,dd:nd,M:od,MM:nd,y:od,yy:nd},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),{words:{m:["jedan minut","jednog minuta"],mm:["minut","minuta","minuta"],h:["jedan sat","jednog sata"],hh:["sat","sata","sati"],dd:["dan","dana","dana"],MM:["mjesec","mjeseca","mjeseci"],yy:["godina","godine","godina"]},correctGrammaticalCase:function(a,b){return 1===a?b[0]:a>=2&&4>=a?b[1]:b[2]},translate:function(a,b,c){var d=Rf.words[c];return 1===c.length?b?d[0]:d[1]:a+" "+Rf.correctGrammaticalCase(a,d)}}),Sf=(nf.defineLocale("me",{months:["januar","februar","mart","april","maj","jun","jul","avgust","septembar","oktobar","novembar","decembar"],monthsShort:["jan.","feb.","mar.","apr.","maj","jun","jul","avg.","sep.","okt.","nov.","dec."],weekdays:["nedjelja","ponedjeljak","utorak","srijeda","četvrtak","petak","subota"],weekdaysShort:["ned.","pon.","uto.","sri.","čet.","pet.","sub."],weekdaysMin:["ne","po","ut","sr","če","pe","su"],longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD. MM. YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[danas u] LT",nextDay:"[sjutra u] LT",nextWeek:function(){switch(this.day()){case 0:return"[u] [nedjelju] [u] LT";case 3:return"[u] [srijedu] [u] LT";case 6:return"[u] [subotu] [u] LT";case 1:case 2:case 4:case 5:return"[u] dddd [u] LT"}},lastDay:"[juče u] LT",lastWeek:function(){var a=["[prošle] [nedjelje] [u] LT","[prošlog] [ponedjeljka] [u] LT","[prošlog] [utorka] [u] LT","[prošle] [srijede] [u] LT","[prošlog] [četvrtka] [u] LT","[prošlog] [petka] [u] LT","[prošle] [subote] [u] LT"];return a[this.day()]},sameElse:"L"},relativeTime:{future:"za %s",past:"prije %s",s:"nekoliko sekundi",m:Rf.translate,mm:Rf.translate,h:Rf.translate,hh:Rf.translate,d:"dan",dd:Rf.translate,M:"mjesec",MM:Rf.translate,y:"godinu",yy:Rf.translate},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),nf.defineLocale("mk",{months:"јануари_февруари_март_април_мај_јуни_јули_август_септември_октомври_ноември_декември".split("_"),monthsShort:"јан_фев_мар_апр_мај_јун_јул_авг_сеп_окт_ное_дек".split("_"),weekdays:"недела_понеделник_вторник_среда_четврток_петок_сабота".split("_"),weekdaysShort:"нед_пон_вто_сре_чет_пет_саб".split("_"),weekdaysMin:"нe_пo_вт_ср_че_пе_сa".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"D.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Денес во] LT",nextDay:"[Утре во] LT",nextWeek:"dddd [во] LT",lastDay:"[Вчера во] LT",lastWeek:function(){switch(this.day()){case 0:case 3:case 6:return"[Во изминатата] dddd [во] LT";case 1:case 2:case 4:case 5:return"[Во изминатиот] dddd [во] LT"}},sameElse:"L"},relativeTime:{future:"после %s",past:"пред %s",s:"неколку секунди",m:"минута",mm:"%d минути",h:"час",hh:"%d часа",d:"ден",dd:"%d дена",M:"месец",MM:"%d месеци",y:"година",yy:"%d години"},ordinalParse:/\d{1,2}-(ев|ен|ти|ви|ри|ми)/,ordinal:function(a){var b=a%10,c=a%100;return 0===a?a+"-ев":0===c?a+"-ен":c>10&&20>c?a+"-ти":1===b?a+"-ви":2===b?a+"-ри":7===b||8===b?a+"-ми":a+"-ти"},week:{dow:1,doy:7}}),nf.defineLocale("ml",{months:"ജനുവരി_ഫെബ്രുവരി_മാർച്ച്_ഏപ്രിൽ_മേയ്_ജൂൺ_ജൂലൈ_ഓഗസ്റ്റ്_സെപ്റ്റംബർ_ഒക്ടോബർ_നവംബർ_ഡിസംബർ".split("_"),monthsShort:"ജനു._ഫെബ്രു._മാർ._ഏപ്രി._മേയ്_ജൂൺ_ജൂലൈ._ഓഗ._സെപ്റ്റ._ഒക്ടോ._നവം._ഡിസം.".split("_"),weekdays:"ഞായറാഴ്ച_തിങ്കളാഴ്ച_ചൊവ്വാഴ്ച_ബുധനാഴ്ച_വ്യാഴാഴ്ച_വെള്ളിയാഴ്ച_ശനിയാഴ്ച".split("_"),weekdaysShort:"ഞായർ_തിങ്കൾ_ചൊവ്വ_ബുധൻ_വ്യാഴം_വെള്ളി_ശനി".split("_"),weekdaysMin:"ഞാ_തി_ചൊ_ബു_വ്യാ_വെ_ശ".split("_"),longDateFormat:{LT:"A h:mm -നു",LTS:"A h:mm:ss -നു",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY, LT",LLLL:"dddd, D MMMM YYYY, LT"},calendar:{sameDay:"[ഇന്ന്] LT",nextDay:"[നാളെ] LT",nextWeek:"dddd, LT",lastDay:"[ഇന്നലെ] LT",lastWeek:"[കഴിഞ്ഞ] dddd, LT",sameElse:"L"},relativeTime:{future:"%s കഴിഞ്ഞ്",past:"%s മുൻപ്",s:"അൽപ നിമിഷങ്ങൾ",m:"ഒരു മിനിറ്റ്",mm:"%d മിനിറ്റ്",h:"ഒരു മണിക്കൂർ",hh:"%d മണിക്കൂർ",d:"ഒരു ദിവസം",dd:"%d ദിവസം",M:"ഒരു മാസം",MM:"%d മാസം",y:"ഒരു വർഷം",yy:"%d വർഷം"},meridiemParse:/രാത്രി|രാവിലെ|ഉച്ച കഴിഞ്ഞ്|വൈകുന്നേരം|രാത്രി/i,isPM:function(a){return/^(ഉച്ച കഴിഞ്ഞ്|വൈകുന്നേരം|രാത്രി)$/.test(a)},meridiem:function(a,b,c){return 4>a?"രാത്രി":12>a?"രാവിലെ":17>a?"ഉച്ച കഴിഞ്ഞ്":20>a?"വൈകുന്നേരം":"രാത്രി"}}),{1:"१",2:"२",3:"३",4:"४",5:"५",6:"६",7:"७",8:"८",9:"९",0:"०"}),Tf={"१":"1","२":"2","३":"3","४":"4","५":"5","६":"6","७":"7","८":"8","९":"9","०":"0"},Uf=(nf.defineLocale("mr",{months:"जानेवारी_फेब्रुवारी_मार्च_एप्रिल_मे_जून_जुलै_ऑगस्ट_सप्टेंबर_ऑक्टोबर_नोव्हेंबर_डिसेंबर".split("_"),monthsShort:"जाने._फेब्रु._मार्च._एप्रि._मे._जून._जुलै._ऑग._सप्टें._ऑक्टो._नोव्हें._डिसें.".split("_"),weekdays:"रविवार_सोमवार_मंगळवार_बुधवार_गुरूवार_शुक्रवार_शनिवार".split("_"),weekdaysShort:"रवि_सोम_मंगळ_बुध_गुरू_शुक्र_शनि".split("_"),weekdaysMin:"र_सो_मं_बु_गु_शु_श".split("_"),longDateFormat:{LT:"A h:mm वाजता",LTS:"A h:mm:ss वाजता",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY, LT",LLLL:"dddd, D MMMM YYYY, LT"},calendar:{sameDay:"[आज] LT",nextDay:"[उद्या] LT",nextWeek:"dddd, LT",lastDay:"[काल] LT",lastWeek:"[मागील] dddd, LT",sameElse:"L"},relativeTime:{future:"%s नंतर",past:"%s पूर्वी",s:"सेकंद",m:"एक मिनिट",mm:"%d मिनिटे",h:"एक तास",hh:"%d तास",d:"एक दिवस",dd:"%d दिवस",M:"एक महिना",MM:"%d महिने",y:"एक वर्ष",yy:"%d वर्षे"},preparse:function(a){return a.replace(/[१२३४५६७८९०]/g,function(a){return Tf[a]})},postformat:function(a){return a.replace(/\d/g,function(a){return Sf[a]})},meridiemParse:/रात्री|सकाळी|दुपारी|सायंकाळी/,meridiemHour:function(a,b){return 12===a&&(a=0),"रात्री"===b?4>a?a:a+12:"सकाळी"===b?a:"दुपारी"===b?a>=10?a:a+12:"सायंकाळी"===b?a+12:void 0},meridiem:function(a,b,c){return 4>a?"रात्री":10>a?"सकाळी":17>a?"दुपारी":20>a?"सायंकाळी":"रात्री"},week:{dow:0,doy:6}}),nf.defineLocale("ms-my",{months:"Januari_Februari_Mac_April_Mei_Jun_Julai_Ogos_September_Oktober_November_Disember".split("_"),monthsShort:"Jan_Feb_Mac_Apr_Mei_Jun_Jul_Ogs_Sep_Okt_Nov_Dis".split("_"),weekdays:"Ahad_Isnin_Selasa_Rabu_Khamis_Jumaat_Sabtu".split("_"),weekdaysShort:"Ahd_Isn_Sel_Rab_Kha_Jum_Sab".split("_"),weekdaysMin:"Ah_Is_Sl_Rb_Km_Jm_Sb".split("_"),longDateFormat:{LT:"HH.mm",LTS:"LT.ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY [pukul] LT",LLLL:"dddd, D MMMM YYYY [pukul] LT"},meridiemParse:/pagi|tengahari|petang|malam/,meridiemHour:function(a,b){return 12===a&&(a=0),"pagi"===b?a:"tengahari"===b?a>=11?a:a+12:"petang"===b||"malam"===b?a+12:void 0},meridiem:function(a,b,c){return 11>a?"pagi":15>a?"tengahari":19>a?"petang":"malam"},calendar:{sameDay:"[Hari ini pukul] LT",nextDay:"[Esok pukul] LT",nextWeek:"dddd [pukul] LT",lastDay:"[Kelmarin pukul] LT",lastWeek:"dddd [lepas pukul] LT",sameElse:"L"},relativeTime:{future:"dalam %s",past:"%s yang lepas",s:"beberapa saat",m:"seminit",mm:"%d minit",h:"sejam",hh:"%d jam",d:"sehari",dd:"%d hari",M:"sebulan",MM:"%d bulan",y:"setahun",yy:"%d tahun"},week:{dow:1,doy:7}}),{1:"၁",2:"၂",3:"၃",4:"၄",5:"၅",6:"၆",7:"၇",8:"၈",9:"၉",0:"၀"}),Vf={"၁":"1","၂":"2","၃":"3","၄":"4","၅":"5", +"၆":"6","၇":"7","၈":"8","၉":"9","၀":"0"},Wf=(nf.defineLocale("my",{months:"ဇန်နဝါရီ_ဖေဖော်ဝါရီ_မတ်_ဧပြီ_မေ_ဇွန်_ဇူလိုင်_သြဂုတ်_စက်တင်ဘာ_အောက်တိုဘာ_နိုဝင်ဘာ_ဒီဇင်ဘာ".split("_"),monthsShort:"ဇန်_ဖေ_မတ်_ပြီ_မေ_ဇွန်_လိုင်_သြ_စက်_အောက်_နို_ဒီ".split("_"),weekdays:"တနင်္ဂနွေ_တနင်္လာ_အင်္ဂါ_ဗုဒ္ဓဟူး_ကြာသပတေး_သောကြာ_စနေ".split("_"),weekdaysShort:"နွေ_လာ_ဂါ_ဟူး_ကြာ_သော_နေ".split("_"),weekdaysMin:"နွေ_လာ_ဂါ_ဟူး_ကြာ_သော_နေ".split("_"),longDateFormat:{LT:"HH:mm",LTS:"HH:mm:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[ယနေ.] LT [မှာ]",nextDay:"[မနက်ဖြန်] LT [မှာ]",nextWeek:"dddd LT [မှာ]",lastDay:"[မနေ.က] LT [မှာ]",lastWeek:"[ပြီးခဲ့သော] dddd LT [မှာ]",sameElse:"L"},relativeTime:{future:"လာမည့် %s မှာ",past:"လွန်ခဲ့သော %s က",s:"စက္ကန်.အနည်းငယ်",m:"တစ်မိနစ်",mm:"%d မိနစ်",h:"တစ်နာရီ",hh:"%d နာရီ",d:"တစ်ရက်",dd:"%d ရက်",M:"တစ်လ",MM:"%d လ",y:"တစ်နှစ်",yy:"%d နှစ်"},preparse:function(a){return a.replace(/[၁၂၃၄၅၆၇၈၉၀]/g,function(a){return Vf[a]})},postformat:function(a){return a.replace(/\d/g,function(a){return Uf[a]})},week:{dow:1,doy:4}}),nf.defineLocale("nb",{months:"januar_februar_mars_april_mai_juni_juli_august_september_oktober_november_desember".split("_"),monthsShort:"jan_feb_mar_apr_mai_jun_jul_aug_sep_okt_nov_des".split("_"),weekdays:"søndag_mandag_tirsdag_onsdag_torsdag_fredag_lørdag".split("_"),weekdaysShort:"søn_man_tirs_ons_tors_fre_lør".split("_"),weekdaysMin:"sø_ma_ti_on_to_fr_lø".split("_"),longDateFormat:{LT:"H.mm",LTS:"LT.ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY [kl.] LT",LLLL:"dddd D. MMMM YYYY [kl.] LT"},calendar:{sameDay:"[i dag kl.] LT",nextDay:"[i morgen kl.] LT",nextWeek:"dddd [kl.] LT",lastDay:"[i går kl.] LT",lastWeek:"[forrige] dddd [kl.] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"for %s siden",s:"noen sekunder",m:"ett minutt",mm:"%d minutter",h:"en time",hh:"%d timer",d:"en dag",dd:"%d dager",M:"en måned",MM:"%d måneder",y:"ett år",yy:"%d år"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),{1:"१",2:"२",3:"३",4:"४",5:"५",6:"६",7:"७",8:"८",9:"९",0:"०"}),Xf={"१":"1","२":"2","३":"3","४":"4","५":"5","६":"6","७":"7","८":"8","९":"9","०":"0"},Yf=(nf.defineLocale("ne",{months:"जनवरी_फेब्रुवरी_मार्च_अप्रिल_मई_जुन_जुलाई_अगष्ट_सेप्टेम्बर_अक्टोबर_नोभेम्बर_डिसेम्बर".split("_"),monthsShort:"जन._फेब्रु._मार्च_अप्रि._मई_जुन_जुलाई._अग._सेप्ट._अक्टो._नोभे._डिसे.".split("_"),weekdays:"आइतबार_सोमबार_मङ्गलबार_बुधबार_बिहिबार_शुक्रबार_शनिबार".split("_"),weekdaysShort:"आइत._सोम._मङ्गल._बुध._बिहि._शुक्र._शनि.".split("_"),weekdaysMin:"आइ._सो._मङ्_बु._बि._शु._श.".split("_"),longDateFormat:{LT:"Aको h:mm बजे",LTS:"Aको h:mm:ss बजे",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY, LT",LLLL:"dddd, D MMMM YYYY, LT"},preparse:function(a){return a.replace(/[१२३४५६७८९०]/g,function(a){return Xf[a]})},postformat:function(a){return a.replace(/\d/g,function(a){return Wf[a]})},meridiemParse:/राती|बिहान|दिउँसो|बेलुका|साँझ|राती/,meridiemHour:function(a,b){return 12===a&&(a=0),"राती"===b?3>a?a:a+12:"बिहान"===b?a:"दिउँसो"===b?a>=10?a:a+12:"बेलुका"===b||"साँझ"===b?a+12:void 0},meridiem:function(a,b,c){return 3>a?"राती":10>a?"बिहान":15>a?"दिउँसो":18>a?"बेलुका":20>a?"साँझ":"राती"},calendar:{sameDay:"[आज] LT",nextDay:"[भोली] LT",nextWeek:"[आउँदो] dddd[,] LT",lastDay:"[हिजो] LT",lastWeek:"[गएको] dddd[,] LT",sameElse:"L"},relativeTime:{future:"%sमा",past:"%s अगाडी",s:"केही समय",m:"एक मिनेट",mm:"%d मिनेट",h:"एक घण्टा",hh:"%d घण्टा",d:"एक दिन",dd:"%d दिन",M:"एक महिना",MM:"%d महिना",y:"एक बर्ष",yy:"%d बर्ष"},week:{dow:1,doy:7}}),"jan._feb._mrt._apr._mei_jun._jul._aug._sep._okt._nov._dec.".split("_")),Zf="jan_feb_mrt_apr_mei_jun_jul_aug_sep_okt_nov_dec".split("_"),$f=(nf.defineLocale("nl",{months:"januari_februari_maart_april_mei_juni_juli_augustus_september_oktober_november_december".split("_"),monthsShort:function(a,b){return/-MMM-/.test(b)?Zf[a.month()]:Yf[a.month()]},weekdays:"zondag_maandag_dinsdag_woensdag_donderdag_vrijdag_zaterdag".split("_"),weekdaysShort:"zo._ma._di._wo._do._vr._za.".split("_"),weekdaysMin:"Zo_Ma_Di_Wo_Do_Vr_Za".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD-MM-YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[vandaag om] LT",nextDay:"[morgen om] LT",nextWeek:"dddd [om] LT",lastDay:"[gisteren om] LT",lastWeek:"[afgelopen] dddd [om] LT",sameElse:"L"},relativeTime:{future:"over %s",past:"%s geleden",s:"een paar seconden",m:"één minuut",mm:"%d minuten",h:"één uur",hh:"%d uur",d:"één dag",dd:"%d dagen",M:"één maand",MM:"%d maanden",y:"één jaar",yy:"%d jaar"},ordinalParse:/\d{1,2}(ste|de)/,ordinal:function(a){return a+(1===a||8===a||a>=20?"ste":"de")},week:{dow:1,doy:4}}),nf.defineLocale("nn",{months:"januar_februar_mars_april_mai_juni_juli_august_september_oktober_november_desember".split("_"),monthsShort:"jan_feb_mar_apr_mai_jun_jul_aug_sep_okt_nov_des".split("_"),weekdays:"sundag_måndag_tysdag_onsdag_torsdag_fredag_laurdag".split("_"),weekdaysShort:"sun_mån_tys_ons_tor_fre_lau".split("_"),weekdaysMin:"su_må_ty_on_to_fr_lø".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[I dag klokka] LT",nextDay:"[I morgon klokka] LT",nextWeek:"dddd [klokka] LT",lastDay:"[I går klokka] LT",lastWeek:"[Føregåande] dddd [klokka] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"for %s sidan",s:"nokre sekund",m:"eit minutt",mm:"%d minutt",h:"ein time",hh:"%d timar",d:"ein dag",dd:"%d dagar",M:"ein månad",MM:"%d månader",y:"eit år",yy:"%d år"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),"styczeń_luty_marzec_kwiecień_maj_czerwiec_lipiec_sierpień_wrzesień_październik_listopad_grudzień".split("_")),_f="stycznia_lutego_marca_kwietnia_maja_czerwca_lipca_sierpnia_września_października_listopada_grudnia".split("_"),ag=(nf.defineLocale("pl",{months:function(a,b){return""===b?"("+_f[a.month()]+"|"+$f[a.month()]+")":/D MMMM/.test(b)?_f[a.month()]:$f[a.month()]},monthsShort:"sty_lut_mar_kwi_maj_cze_lip_sie_wrz_paź_lis_gru".split("_"),weekdays:"niedziela_poniedziałek_wtorek_środa_czwartek_piątek_sobota".split("_"),weekdaysShort:"nie_pon_wt_śr_czw_pt_sb".split("_"),weekdaysMin:"N_Pn_Wt_Śr_Cz_Pt_So".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Dziś o] LT",nextDay:"[Jutro o] LT",nextWeek:"[W] dddd [o] LT",lastDay:"[Wczoraj o] LT",lastWeek:function(){switch(this.day()){case 0:return"[W zeszłą niedzielę o] LT";case 3:return"[W zeszłą środę o] LT";case 6:return"[W zeszłą sobotę o] LT";default:return"[W zeszły] dddd [o] LT"}},sameElse:"L"},relativeTime:{future:"za %s",past:"%s temu",s:"kilka sekund",m:rd,mm:rd,h:rd,hh:rd,d:"1 dzień",dd:"%d dni",M:"miesiąc",MM:rd,y:"rok",yy:rd},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),nf.defineLocale("pt-br",{months:"Janeiro_Fevereiro_Março_Abril_Maio_Junho_Julho_Agosto_Setembro_Outubro_Novembro_Dezembro".split("_"),monthsShort:"Jan_Fev_Mar_Abr_Mai_Jun_Jul_Ago_Set_Out_Nov_Dez".split("_"),weekdays:"Domingo_Segunda-Feira_Terça-Feira_Quarta-Feira_Quinta-Feira_Sexta-Feira_Sábado".split("_"),weekdaysShort:"Dom_Seg_Ter_Qua_Qui_Sex_Sáb".split("_"),weekdaysMin:"Dom_2ª_3ª_4ª_5ª_6ª_Sáb".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY [às] LT",LLLL:"dddd, D [de] MMMM [de] YYYY [às] LT"},calendar:{sameDay:"[Hoje às] LT",nextDay:"[Amanhã às] LT",nextWeek:"dddd [às] LT",lastDay:"[Ontem às] LT",lastWeek:function(){return 0===this.day()||6===this.day()?"[Último] dddd [às] LT":"[Última] dddd [às] LT"},sameElse:"L"},relativeTime:{future:"em %s",past:"%s atrás",s:"segundos",m:"um minuto",mm:"%d minutos",h:"uma hora",hh:"%d horas",d:"um dia",dd:"%d dias",M:"um mês",MM:"%d meses",y:"um ano",yy:"%d anos"},ordinalParse:/\d{1,2}º/,ordinal:"%dº"}),nf.defineLocale("pt",{months:"Janeiro_Fevereiro_Março_Abril_Maio_Junho_Julho_Agosto_Setembro_Outubro_Novembro_Dezembro".split("_"),monthsShort:"Jan_Fev_Mar_Abr_Mai_Jun_Jul_Ago_Set_Out_Nov_Dez".split("_"),weekdays:"Domingo_Segunda-Feira_Terça-Feira_Quarta-Feira_Quinta-Feira_Sexta-Feira_Sábado".split("_"),weekdaysShort:"Dom_Seg_Ter_Qua_Qui_Sex_Sáb".split("_"),weekdaysMin:"Dom_2ª_3ª_4ª_5ª_6ª_Sáb".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY LT",LLLL:"dddd, D [de] MMMM [de] YYYY LT"},calendar:{sameDay:"[Hoje às] LT",nextDay:"[Amanhã às] LT",nextWeek:"dddd [às] LT",lastDay:"[Ontem às] LT",lastWeek:function(){return 0===this.day()||6===this.day()?"[Último] dddd [às] LT":"[Última] dddd [às] LT"},sameElse:"L"},relativeTime:{future:"em %s",past:"há %s",s:"segundos",m:"um minuto",mm:"%d minutos",h:"uma hora",hh:"%d horas",d:"um dia",dd:"%d dias",M:"um mês",MM:"%d meses",y:"um ano",yy:"%d anos"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4}}),nf.defineLocale("ro",{months:"ianuarie_februarie_martie_aprilie_mai_iunie_iulie_august_septembrie_octombrie_noiembrie_decembrie".split("_"),monthsShort:"ian._febr._mart._apr._mai_iun._iul._aug._sept._oct._nov._dec.".split("_"),weekdays:"duminică_luni_marți_miercuri_joi_vineri_sâmbătă".split("_"),weekdaysShort:"Dum_Lun_Mar_Mie_Joi_Vin_Sâm".split("_"),weekdaysMin:"Du_Lu_Ma_Mi_Jo_Vi_Sâ".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY H:mm",LLLL:"dddd, D MMMM YYYY H:mm"},calendar:{sameDay:"[azi la] LT",nextDay:"[mâine la] LT",nextWeek:"dddd [la] LT",lastDay:"[ieri la] LT",lastWeek:"[fosta] dddd [la] LT",sameElse:"L"},relativeTime:{future:"peste %s",past:"%s în urmă",s:"câteva secunde",m:"un minut",mm:sd,h:"o oră",hh:sd,d:"o zi",dd:sd,M:"o lună",MM:sd,y:"un an",yy:sd},week:{dow:1,doy:7}}),nf.defineLocale("ru",{months:vd,monthsShort:wd,weekdays:xd,weekdaysShort:"вс_пн_вт_ср_чт_пт_сб".split("_"),weekdaysMin:"вс_пн_вт_ср_чт_пт_сб".split("_"),monthsParse:[/^янв/i,/^фев/i,/^мар/i,/^апр/i,/^ма[й|я]/i,/^июн/i,/^июл/i,/^авг/i,/^сен/i,/^окт/i,/^ноя/i,/^дек/i],longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY г.",LLL:"D MMMM YYYY г., LT",LLLL:"dddd, D MMMM YYYY г., LT"},calendar:{sameDay:"[Сегодня в] LT",nextDay:"[Завтра в] LT",lastDay:"[Вчера в] LT",nextWeek:function(){return 2===this.day()?"[Во] dddd [в] LT":"[В] dddd [в] LT"},lastWeek:function(a){if(a.week()===this.week())return 2===this.day()?"[Во] dddd [в] LT":"[В] dddd [в] LT";switch(this.day()){case 0:return"[В прошлое] dddd [в] LT";case 1:case 2:case 4:return"[В прошлый] dddd [в] LT";case 3:case 5:case 6:return"[В прошлую] dddd [в] LT"}},sameElse:"L"},relativeTime:{future:"через %s",past:"%s назад",s:"несколько секунд",m:ud,mm:ud,h:"час",hh:ud,d:"день",dd:ud,M:"месяц",MM:ud,y:"год",yy:ud},meridiemParse:/ночи|утра|дня|вечера/i,isPM:function(a){return/^(дня|вечера)$/.test(a)},meridiem:function(a,b,c){return 4>a?"ночи":12>a?"утра":17>a?"дня":"вечера"},ordinalParse:/\d{1,2}-(й|го|я)/,ordinal:function(a,b){switch(b){case"M":case"d":case"DDD":return a+"-й";case"D":return a+"-го";case"w":case"W":return a+"-я";default:return a}},week:{dow:1,doy:7}}),nf.defineLocale("si",{months:"ජනවාරි_පෙබරවාරි_මාර්තු_අප්‍රේල්_මැයි_ජූනි_ජූලි_අගෝස්තු_සැප්තැම්බර්_ඔක්තෝබර්_නොවැම්බර්_දෙසැම්බර්".split("_"),monthsShort:"ජන_පෙබ_මාර්_අප්_මැයි_ජූනි_ජූලි_අගෝ_සැප්_ඔක්_නොවැ_දෙසැ".split("_"),weekdays:"ඉරිදා_සඳුදා_අඟහරුවාදා_බදාදා_බ්‍රහස්පතින්දා_සිකුරාදා_සෙනසුරාදා".split("_"),weekdaysShort:"ඉරි_සඳු_අඟ_බදා_බ්‍රහ_සිකු_සෙන".split("_"),weekdaysMin:"ඉ_ස_අ_බ_බ්‍ර_සි_සෙ".split("_"),longDateFormat:{LT:"a h:mm",LTS:"a h:mm:ss",L:"YYYY/MM/DD",LL:"YYYY MMMM D",LLL:"YYYY MMMM D, LT",LLLL:"YYYY MMMM D [වැනි] dddd, LTS"},calendar:{sameDay:"[අද] LT[ට]",nextDay:"[හෙට] LT[ට]",nextWeek:"dddd LT[ට]",lastDay:"[ඊයේ] LT[ට]",lastWeek:"[පසුගිය] dddd LT[ට]",sameElse:"L"},relativeTime:{future:"%sකින්",past:"%sකට පෙර",s:"තත්පර කිහිපය",m:"මිනිත්තුව",mm:"මිනිත්තු %d",h:"පැය",hh:"පැය %d",d:"දිනය",dd:"දින %d",M:"මාසය",MM:"මාස %d",y:"වසර",yy:"වසර %d"},ordinalParse:/\d{1,2} වැනි/,ordinal:function(a){return a+" වැනි"},meridiem:function(a,b,c){return a>11?c?"ප.ව.":"පස් වරු":c?"පෙ.ව.":"පෙර වරු"}}),"január_február_marec_apríl_máj_jún_júl_august_september_október_november_december".split("_")),bg="jan_feb_mar_apr_máj_jún_júl_aug_sep_okt_nov_dec".split("_"),cg=(nf.defineLocale("sk",{months:ag,monthsShort:bg,monthsParse:function(a,b){var c,d=[];for(c=0;12>c;c++)d[c]=new RegExp("^"+a[c]+"$|^"+b[c]+"$","i");return d}(ag,bg),weekdays:"nedeľa_pondelok_utorok_streda_štvrtok_piatok_sobota".split("_"),weekdaysShort:"ne_po_ut_st_št_pi_so".split("_"),weekdaysMin:"ne_po_ut_st_št_pi_so".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd D. MMMM YYYY LT"},calendar:{sameDay:"[dnes o] LT",nextDay:"[zajtra o] LT",nextWeek:function(){switch(this.day()){case 0:return"[v nedeľu o] LT";case 1:case 2:return"[v] dddd [o] LT";case 3:return"[v stredu o] LT";case 4:return"[vo štvrtok o] LT";case 5:return"[v piatok o] LT";case 6:return"[v sobotu o] LT"}},lastDay:"[včera o] LT",lastWeek:function(){switch(this.day()){case 0:return"[minulú nedeľu o] LT";case 1:case 2:return"[minulý] dddd [o] LT";case 3:return"[minulú stredu o] LT";case 4:case 5:return"[minulý] dddd [o] LT";case 6:return"[minulú sobotu o] LT"}},sameElse:"L"},relativeTime:{future:"za %s",past:"pred %s",s:zd,m:zd,mm:zd,h:zd,hh:zd,d:zd,dd:zd,M:zd,MM:zd,y:zd,yy:zd},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),nf.defineLocale("sl",{months:"januar_februar_marec_april_maj_junij_julij_avgust_september_oktober_november_december".split("_"),monthsShort:"jan._feb._mar._apr._maj._jun._jul._avg._sep._okt._nov._dec.".split("_"),weekdays:"nedelja_ponedeljek_torek_sreda_četrtek_petek_sobota".split("_"),weekdaysShort:"ned._pon._tor._sre._čet._pet._sob.".split("_"),weekdaysMin:"ne_po_to_sr_če_pe_so".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD. MM. YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[danes ob] LT",nextDay:"[jutri ob] LT",nextWeek:function(){switch(this.day()){case 0:return"[v] [nedeljo] [ob] LT";case 3:return"[v] [sredo] [ob] LT";case 6:return"[v] [soboto] [ob] LT";case 1:case 2:case 4:case 5:return"[v] dddd [ob] LT"}},lastDay:"[včeraj ob] LT",lastWeek:function(){switch(this.day()){case 0:return"[prejšnjo] [nedeljo] [ob] LT";case 3:return"[prejšnjo] [sredo] [ob] LT";case 6:return"[prejšnjo] [soboto] [ob] LT";case 1:case 2:case 4:case 5:return"[prejšnji] dddd [ob] LT"}},sameElse:"L"},relativeTime:{future:"čez %s",past:"pred %s",s:Ad,m:Ad,mm:Ad,h:Ad,hh:Ad,d:Ad,dd:Ad,M:Ad,MM:Ad,y:Ad,yy:Ad},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),nf.defineLocale("sq",{months:"Janar_Shkurt_Mars_Prill_Maj_Qershor_Korrik_Gusht_Shtator_Tetor_Nëntor_Dhjetor".split("_"),monthsShort:"Jan_Shk_Mar_Pri_Maj_Qer_Kor_Gus_Sht_Tet_Nën_Dhj".split("_"),weekdays:"E Diel_E Hënë_E Martë_E Mërkurë_E Enjte_E Premte_E Shtunë".split("_"),weekdaysShort:"Die_Hën_Mar_Mër_Enj_Pre_Sht".split("_"),weekdaysMin:"D_H_Ma_Më_E_P_Sh".split("_"),meridiemParse:/PD|MD/,isPM:function(a){return"M"===a.charAt(0)},meridiem:function(a,b,c){return 12>a?"PD":"MD"},longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Sot në] LT",nextDay:"[Nesër në] LT",nextWeek:"dddd [në] LT",lastDay:"[Dje në] LT",lastWeek:"dddd [e kaluar në] LT",sameElse:"L"},relativeTime:{future:"në %s",past:"%s më parë",s:"disa sekonda",m:"një minutë",mm:"%d minuta",h:"një orë",hh:"%d orë",d:"një ditë",dd:"%d ditë",M:"një muaj",MM:"%d muaj",y:"një vit",yy:"%d vite"},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),{words:{m:["један минут","једне минуте"],mm:["минут","минуте","минута"],h:["један сат","једног сата"],hh:["сат","сата","сати"],dd:["дан","дана","дана"],MM:["месец","месеца","месеци"],yy:["година","године","година"]},correctGrammaticalCase:function(a,b){return 1===a?b[0]:a>=2&&4>=a?b[1]:b[2]},translate:function(a,b,c){var d=cg.words[c];return 1===c.length?b?d[0]:d[1]:a+" "+cg.correctGrammaticalCase(a,d)}}),dg=(nf.defineLocale("sr-cyrl",{months:["јануар","фебруар","март","април","мај","јун","јул","август","септембар","октобар","новембар","децембар"],monthsShort:["јан.","феб.","мар.","апр.","мај","јун","јул","авг.","сеп.","окт.","нов.","дец."],weekdays:["недеља","понедељак","уторак","среда","четвртак","петак","субота"],weekdaysShort:["нед.","пон.","уто.","сре.","чет.","пет.","суб."],weekdaysMin:["не","по","ут","ср","че","пе","су"],longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD. MM. YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[данас у] LT",nextDay:"[сутра у] LT",nextWeek:function(){switch(this.day()){case 0:return"[у] [недељу] [у] LT";case 3:return"[у] [среду] [у] LT";case 6:return"[у] [суботу] [у] LT";case 1:case 2:case 4:case 5:return"[у] dddd [у] LT"}},lastDay:"[јуче у] LT",lastWeek:function(){var a=["[прошле] [недеље] [у] LT","[прошлог] [понедељка] [у] LT","[прошлог] [уторка] [у] LT","[прошле] [среде] [у] LT","[прошлог] [четвртка] [у] LT","[прошлог] [петка] [у] LT","[прошле] [суботе] [у] LT"];return a[this.day()]},sameElse:"L"},relativeTime:{future:"за %s",past:"пре %s",s:"неколико секунди",m:cg.translate,mm:cg.translate,h:cg.translate,hh:cg.translate,d:"дан",dd:cg.translate,M:"месец",MM:cg.translate,y:"годину",yy:cg.translate},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),{words:{m:["jedan minut","jedne minute"],mm:["minut","minute","minuta"],h:["jedan sat","jednog sata"],hh:["sat","sata","sati"],dd:["dan","dana","dana"],MM:["mesec","meseca","meseci"],yy:["godina","godine","godina"]},correctGrammaticalCase:function(a,b){return 1===a?b[0]:a>=2&&4>=a?b[1]:b[2]},translate:function(a,b,c){var d=dg.words[c];return 1===c.length?b?d[0]:d[1]:a+" "+dg.correctGrammaticalCase(a,d)}}),eg=(nf.defineLocale("sr",{months:["januar","februar","mart","april","maj","jun","jul","avgust","septembar","oktobar","novembar","decembar"],monthsShort:["jan.","feb.","mar.","apr.","maj","jun","jul","avg.","sep.","okt.","nov.","dec."],weekdays:["nedelja","ponedeljak","utorak","sreda","četvrtak","petak","subota"],weekdaysShort:["ned.","pon.","uto.","sre.","čet.","pet.","sub."],weekdaysMin:["ne","po","ut","sr","če","pe","su"],longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD. MM. YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[danas u] LT",nextDay:"[sutra u] LT",nextWeek:function(){switch(this.day()){case 0:return"[u] [nedelju] [u] LT";case 3:return"[u] [sredu] [u] LT";case 6:return"[u] [subotu] [u] LT";case 1:case 2:case 4:case 5:return"[u] dddd [u] LT"}},lastDay:"[juče u] LT",lastWeek:function(){var a=["[prošle] [nedelje] [u] LT","[prošlog] [ponedeljka] [u] LT","[prošlog] [utorka] [u] LT","[prošle] [srede] [u] LT","[prošlog] [četvrtka] [u] LT","[prošlog] [petka] [u] LT","[prošle] [subote] [u] LT"];return a[this.day()]},sameElse:"L"},relativeTime:{future:"za %s",past:"pre %s",s:"nekoliko sekundi",m:dg.translate,mm:dg.translate,h:dg.translate,hh:dg.translate,d:"dan",dd:dg.translate,M:"mesec",MM:dg.translate,y:"godinu",yy:dg.translate},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),nf.defineLocale("sv",{months:"januari_februari_mars_april_maj_juni_juli_augusti_september_oktober_november_december".split("_"),monthsShort:"jan_feb_mar_apr_maj_jun_jul_aug_sep_okt_nov_dec".split("_"),weekdays:"söndag_måndag_tisdag_onsdag_torsdag_fredag_lördag".split("_"),weekdaysShort:"sön_mån_tis_ons_tor_fre_lör".split("_"),weekdaysMin:"sö_må_ti_on_to_fr_lö".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"YYYY-MM-DD",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[Idag] LT",nextDay:"[Imorgon] LT",lastDay:"[Igår] LT",nextWeek:"[På] dddd LT",lastWeek:"[I] dddd[s] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"för %s sedan",s:"några sekunder",m:"en minut",mm:"%d minuter",h:"en timme",hh:"%d timmar",d:"en dag",dd:"%d dagar",M:"en månad",MM:"%d månader",y:"ett år",yy:"%d år"},ordinalParse:/\d{1,2}(e|a)/,ordinal:function(a){var b=a%10,c=1===~~(a%100/10)?"e":1===b?"a":2===b?"a":"e";return a+c},week:{dow:1,doy:4}}),nf.defineLocale("ta",{months:"ஜனவரி_பிப்ரவரி_மார்ச்_ஏப்ரல்_மே_ஜூன்_ஜூலை_ஆகஸ்ட்_செப்டெம்பர்_அக்டோபர்_நவம்பர்_டிசம்பர்".split("_"),monthsShort:"ஜனவரி_பிப்ரவரி_மார்ச்_ஏப்ரல்_மே_ஜூன்_ஜூலை_ஆகஸ்ட்_செப்டெம்பர்_அக்டோபர்_நவம்பர்_டிசம்பர்".split("_"),weekdays:"ஞாயிற்றுக்கிழமை_திங்கட்கிழமை_செவ்வாய்கிழமை_புதன்கிழமை_வியாழக்கிழமை_வெள்ளிக்கிழமை_சனிக்கிழமை".split("_"),weekdaysShort:"ஞாயிறு_திங்கள்_செவ்வாய்_புதன்_வியாழன்_வெள்ளி_சனி".split("_"),weekdaysMin:"ஞா_தி_செ_பு_வி_வெ_ச".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY, LT",LLLL:"dddd, D MMMM YYYY, LT"},calendar:{sameDay:"[இன்று] LT",nextDay:"[நாளை] LT",nextWeek:"dddd, LT",lastDay:"[நேற்று] LT",lastWeek:"[கடந்த வாரம்] dddd, LT",sameElse:"L"},relativeTime:{future:"%s இல்",past:"%s முன்",s:"ஒரு சில விநாடிகள்",m:"ஒரு நிமிடம்",mm:"%d நிமிடங்கள்",h:"ஒரு மணி நேரம்",hh:"%d மணி நேரம்",d:"ஒரு நாள்",dd:"%d நாட்கள்",M:"ஒரு மாதம்",MM:"%d மாதங்கள்",y:"ஒரு வருடம்",yy:"%d ஆண்டுகள்"},ordinalParse:/\d{1,2}வது/,ordinal:function(a){return a+"வது"},meridiemParse:/யாமம்|வைகறை|காலை|நண்பகல்|எற்பாடு|மாலை/,meridiem:function(a,b,c){return 2>a?" யாமம்":6>a?" வைகறை":10>a?" காலை":14>a?" நண்பகல்":18>a?" எற்பாடு":22>a?" மாலை":" யாமம்"},meridiemHour:function(a,b){return 12===a&&(a=0),"யாமம்"===b?2>a?a:a+12:"வைகறை"===b||"காலை"===b?a:"நண்பகல்"===b&&a>=10?a:a+12},week:{dow:0,doy:6}}),nf.defineLocale("th",{months:"มกราคม_กุมภาพันธ์_มีนาคม_เมษายน_พฤษภาคม_มิถุนายน_กรกฎาคม_สิงหาคม_กันยายน_ตุลาคม_พฤศจิกายน_ธันวาคม".split("_"),monthsShort:"มกรา_กุมภา_มีนา_เมษา_พฤษภา_มิถุนา_กรกฎา_สิงหา_กันยา_ตุลา_พฤศจิกา_ธันวา".split("_"),weekdays:"อาทิตย์_จันทร์_อังคาร_พุธ_พฤหัสบดี_ศุกร์_เสาร์".split("_"),weekdaysShort:"อาทิตย์_จันทร์_อังคาร_พุธ_พฤหัส_ศุกร์_เสาร์".split("_"),weekdaysMin:"อา._จ._อ._พ._พฤ._ศ._ส.".split("_"),longDateFormat:{LT:"H นาฬิกา m นาที",LTS:"LT s วินาที",L:"YYYY/MM/DD",LL:"D MMMM YYYY",LLL:"D MMMM YYYY เวลา LT",LLLL:"วันddddที่ D MMMM YYYY เวลา LT"},meridiemParse:/ก่อนเที่ยง|หลังเที่ยง/,isPM:function(a){return"หลังเที่ยง"===a},meridiem:function(a,b,c){return 12>a?"ก่อนเที่ยง":"หลังเที่ยง"},calendar:{sameDay:"[วันนี้ เวลา] LT",nextDay:"[พรุ่งนี้ เวลา] LT",nextWeek:"dddd[หน้า เวลา] LT",lastDay:"[เมื่อวานนี้ เวลา] LT",lastWeek:"[วัน]dddd[ที่แล้ว เวลา] LT",sameElse:"L"},relativeTime:{future:"อีก %s",past:"%sที่แล้ว",s:"ไม่กี่วินาที",m:"1 นาที",mm:"%d นาที",h:"1 ชั่วโมง",hh:"%d ชั่วโมง",d:"1 วัน",dd:"%d วัน",M:"1 เดือน",MM:"%d เดือน",y:"1 ปี",yy:"%d ปี"}}),nf.defineLocale("tl-ph",{months:"Enero_Pebrero_Marso_Abril_Mayo_Hunyo_Hulyo_Agosto_Setyembre_Oktubre_Nobyembre_Disyembre".split("_"),monthsShort:"Ene_Peb_Mar_Abr_May_Hun_Hul_Ago_Set_Okt_Nob_Dis".split("_"),weekdays:"Linggo_Lunes_Martes_Miyerkules_Huwebes_Biyernes_Sabado".split("_"),weekdaysShort:"Lin_Lun_Mar_Miy_Huw_Biy_Sab".split("_"),weekdaysMin:"Li_Lu_Ma_Mi_Hu_Bi_Sab".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"MM/D/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY LT",LLLL:"dddd, MMMM DD, YYYY LT"},calendar:{sameDay:"[Ngayon sa] LT",nextDay:"[Bukas sa] LT",nextWeek:"dddd [sa] LT",lastDay:"[Kahapon sa] LT",lastWeek:"dddd [huling linggo] LT",sameElse:"L"},relativeTime:{future:"sa loob ng %s",past:"%s ang nakalipas",s:"ilang segundo",m:"isang minuto",mm:"%d minuto",h:"isang oras",hh:"%d oras",d:"isang araw",dd:"%d araw",M:"isang buwan",MM:"%d buwan",y:"isang taon",yy:"%d taon"},ordinalParse:/\d{1,2}/,ordinal:function(a){return a},week:{dow:1,doy:4}}),{1:"'inci",5:"'inci",8:"'inci",70:"'inci",80:"'inci",2:"'nci",7:"'nci",20:"'nci",50:"'nci",3:"'üncü",4:"'üncü",100:"'üncü",6:"'ncı",9:"'uncu",10:"'uncu",30:"'uncu",60:"'ıncı",90:"'ıncı"}),fg=(nf.defineLocale("tr",{months:"Ocak_Şubat_Mart_Nisan_Mayıs_Haziran_Temmuz_Ağustos_Eylül_Ekim_Kasım_Aralık".split("_"),monthsShort:"Oca_Şub_Mar_Nis_May_Haz_Tem_Ağu_Eyl_Eki_Kas_Ara".split("_"),weekdays:"Pazar_Pazartesi_Salı_Çarşamba_Perşembe_Cuma_Cumartesi".split("_"),weekdaysShort:"Paz_Pts_Sal_Çar_Per_Cum_Cts".split("_"),weekdaysMin:"Pz_Pt_Sa_Ça_Pe_Cu_Ct".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[bugün saat] LT",nextDay:"[yarın saat] LT",nextWeek:"[haftaya] dddd [saat] LT",lastDay:"[dün] LT",lastWeek:"[geçen hafta] dddd [saat] LT",sameElse:"L"},relativeTime:{future:"%s sonra",past:"%s önce",s:"birkaç saniye",m:"bir dakika",mm:"%d dakika",h:"bir saat",hh:"%d saat",d:"bir gün",dd:"%d gün",M:"bir ay",MM:"%d ay",y:"bir yıl",yy:"%d yıl"},ordinalParse:/\d{1,2}'(inci|nci|üncü|ncı|uncu|ıncı)/,ordinal:function(a){if(0===a)return a+"'ıncı";var b=a%10,c=a%100-b,d=a>=100?100:null;return a+(eg[b]||eg[c]||eg[d])},week:{dow:1,doy:7}}),nf.defineLocale("tzm-latn",{months:"innayr_brˤayrˤ_marˤsˤ_ibrir_mayyw_ywnyw_ywlywz_ɣwšt_šwtanbir_ktˤwbrˤ_nwwanbir_dwjnbir".split("_"),monthsShort:"innayr_brˤayrˤ_marˤsˤ_ibrir_mayyw_ywnyw_ywlywz_ɣwšt_šwtanbir_ktˤwbrˤ_nwwanbir_dwjnbir".split("_"),weekdays:"asamas_aynas_asinas_akras_akwas_asimwas_asiḍyas".split("_"),weekdaysShort:"asamas_aynas_asinas_akras_akwas_asimwas_asiḍyas".split("_"),weekdaysMin:"asamas_aynas_asinas_akras_akwas_asimwas_asiḍyas".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[asdkh g] LT",nextDay:"[aska g] LT",nextWeek:"dddd [g] LT",lastDay:"[assant g] LT",lastWeek:"dddd [g] LT",sameElse:"L"},relativeTime:{future:"dadkh s yan %s",past:"yan %s",s:"imik",m:"minuḍ",mm:"%d minuḍ",h:"saɛa",hh:"%d tassaɛin",d:"ass",dd:"%d ossan",M:"ayowr",MM:"%d iyyirn",y:"asgas",yy:"%d isgasn"},week:{dow:6,doy:12}}),nf.defineLocale("tzm",{months:"ⵉⵏⵏⴰⵢⵔ_ⴱⵕⴰⵢⵕ_ⵎⴰⵕⵚ_ⵉⴱⵔⵉⵔ_ⵎⴰⵢⵢⵓ_ⵢⵓⵏⵢⵓ_ⵢⵓⵍⵢⵓⵣ_ⵖⵓⵛⵜ_ⵛⵓⵜⴰⵏⴱⵉⵔ_ⴽⵟⵓⴱⵕ_ⵏⵓⵡⴰⵏⴱⵉⵔ_ⴷⵓⵊⵏⴱⵉⵔ".split("_"),monthsShort:"ⵉⵏⵏⴰⵢⵔ_ⴱⵕⴰⵢⵕ_ⵎⴰⵕⵚ_ⵉⴱⵔⵉⵔ_ⵎⴰⵢⵢⵓ_ⵢⵓⵏⵢⵓ_ⵢⵓⵍⵢⵓⵣ_ⵖⵓⵛⵜ_ⵛⵓⵜⴰⵏⴱⵉⵔ_ⴽⵟⵓⴱⵕ_ⵏⵓⵡⴰⵏⴱⵉⵔ_ⴷⵓⵊⵏⴱⵉⵔ".split("_"),weekdays:"ⴰⵙⴰⵎⴰⵙ_ⴰⵢⵏⴰⵙ_ⴰⵙⵉⵏⴰⵙ_ⴰⴽⵔⴰⵙ_ⴰⴽⵡⴰⵙ_ⴰⵙⵉⵎⵡⴰⵙ_ⴰⵙⵉⴹⵢⴰⵙ".split("_"),weekdaysShort:"ⴰⵙⴰⵎⴰⵙ_ⴰⵢⵏⴰⵙ_ⴰⵙⵉⵏⴰⵙ_ⴰⴽⵔⴰⵙ_ⴰⴽⵡⴰⵙ_ⴰⵙⵉⵎⵡⴰⵙ_ⴰⵙⵉⴹⵢⴰⵙ".split("_"),weekdaysMin:"ⴰⵙⴰⵎⴰⵙ_ⴰⵢⵏⴰⵙ_ⴰⵙⵉⵏⴰⵙ_ⴰⴽⵔⴰⵙ_ⴰⴽⵡⴰⵙ_ⴰⵙⵉⵎⵡⴰⵙ_ⴰⵙⵉⴹⵢⴰⵙ".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[ⴰⵙⴷⵅ ⴴ] LT",nextDay:"[ⴰⵙⴽⴰ ⴴ] LT",nextWeek:"dddd [ⴴ] LT",lastDay:"[ⴰⵚⴰⵏⵜ ⴴ] LT",lastWeek:"dddd [ⴴ] LT",sameElse:"L"},relativeTime:{future:"ⴷⴰⴷⵅ ⵙ ⵢⴰⵏ %s",past:"ⵢⴰⵏ %s",s:"ⵉⵎⵉⴽ",m:"ⵎⵉⵏⵓⴺ",mm:"%d ⵎⵉⵏⵓⴺ",h:"ⵙⴰⵄⴰ",hh:"%d ⵜⴰⵙⵙⴰⵄⵉⵏ",d:"ⴰⵙⵙ",dd:"%d oⵙⵙⴰⵏ",M:"ⴰⵢoⵓⵔ",MM:"%d ⵉⵢⵢⵉⵔⵏ",y:"ⴰⵙⴳⴰⵙ",yy:"%d ⵉⵙⴳⴰⵙⵏ"},week:{dow:6,doy:12}}),nf.defineLocale("uk",{months:Dd,monthsShort:"січ_лют_бер_квіт_трав_черв_лип_серп_вер_жовт_лист_груд".split("_"),weekdays:Ed,weekdaysShort:"нд_пн_вт_ср_чт_пт_сб".split("_"),weekdaysMin:"нд_пн_вт_ср_чт_пт_сб".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY р.",LLL:"D MMMM YYYY р., LT",LLLL:"dddd, D MMMM YYYY р., LT"},calendar:{sameDay:Fd("[Сьогодні "),nextDay:Fd("[Завтра "),lastDay:Fd("[Вчора "),nextWeek:Fd("[У] dddd ["),lastWeek:function(){switch(this.day()){case 0:case 3:case 5:case 6:return Fd("[Минулої] dddd [").call(this);case 1:case 2:case 4:return Fd("[Минулого] dddd [").call(this)}},sameElse:"L"},relativeTime:{future:"за %s",past:"%s тому",s:"декілька секунд",m:Cd,mm:Cd,h:"годину",hh:Cd,d:"день",dd:Cd,M:"місяць",MM:Cd,y:"рік",yy:Cd},meridiemParse:/ночі|ранку|дня|вечора/,isPM:function(a){return/^(дня|вечора)$/.test(a)},meridiem:function(a,b,c){return 4>a?"ночі":12>a?"ранку":17>a?"дня":"вечора"},ordinalParse:/\d{1,2}-(й|го)/,ordinal:function(a,b){switch(b){case"M":case"d":case"DDD":case"w":case"W":return a+"-й";case"D":return a+"-го";default:return a}},week:{dow:1,doy:7}}),nf.defineLocale("uz",{months:"январь_февраль_март_апрель_май_июнь_июль_август_сентябрь_октябрь_ноябрь_декабрь".split("_"),monthsShort:"янв_фев_мар_апр_май_июн_июл_авг_сен_окт_ноя_дек".split("_"),weekdays:"Якшанба_Душанба_Сешанба_Чоршанба_Пайшанба_Жума_Шанба".split("_"),weekdaysShort:"Якш_Душ_Сеш_Чор_Пай_Жум_Шан".split("_"),weekdaysMin:"Як_Ду_Се_Чо_Па_Жу_Ша".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"D MMMM YYYY, dddd LT"},calendar:{sameDay:"[Бугун соат] LT [да]",nextDay:"[Эртага] LT [да]",nextWeek:"dddd [куни соат] LT [да]",lastDay:"[Кеча соат] LT [да]",lastWeek:"[Утган] dddd [куни соат] LT [да]",sameElse:"L"},relativeTime:{future:"Якин %s ичида",past:"Бир неча %s олдин",s:"фурсат",m:"бир дакика",mm:"%d дакика",h:"бир соат",hh:"%d соат",d:"бир кун",dd:"%d кун",M:"бир ой",MM:"%d ой",y:"бир йил",yy:"%d йил"},week:{dow:1,doy:7}}),nf.defineLocale("vi",{months:"tháng 1_tháng 2_tháng 3_tháng 4_tháng 5_tháng 6_tháng 7_tháng 8_tháng 9_tháng 10_tháng 11_tháng 12".split("_"),monthsShort:"Th01_Th02_Th03_Th04_Th05_Th06_Th07_Th08_Th09_Th10_Th11_Th12".split("_"),weekdays:"chủ nhật_thứ hai_thứ ba_thứ tư_thứ năm_thứ sáu_thứ bảy".split("_"),weekdaysShort:"CN_T2_T3_T4_T5_T6_T7".split("_"),weekdaysMin:"CN_T2_T3_T4_T5_T6_T7".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM [năm] YYYY",LLL:"D MMMM [năm] YYYY LT",LLLL:"dddd, D MMMM [năm] YYYY LT",l:"DD/M/YYYY",ll:"D MMM YYYY",lll:"D MMM YYYY LT",llll:"ddd, D MMM YYYY LT"},calendar:{sameDay:"[Hôm nay lúc] LT",nextDay:"[Ngày mai lúc] LT",nextWeek:"dddd [tuần tới lúc] LT",lastDay:"[Hôm qua lúc] LT",lastWeek:"dddd [tuần rồi lúc] LT",sameElse:"L"},relativeTime:{future:"%s tới",past:"%s trước",s:"vài giây",m:"một phút",mm:"%d phút",h:"một giờ",hh:"%d giờ",d:"một ngày",dd:"%d ngày",M:"một tháng",MM:"%d tháng",y:"một năm",yy:"%d năm"},ordinalParse:/\d{1,2}/,ordinal:function(a){return a},week:{dow:1,doy:4}}),nf.defineLocale("zh-cn",{months:"一月_二月_三月_四月_五月_六月_七月_八月_九月_十月_十一月_十二月".split("_"),monthsShort:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),weekdays:"星期日_星期一_星期二_星期三_星期四_星期五_星期六".split("_"),weekdaysShort:"周日_周一_周二_周三_周四_周五_周六".split("_"),weekdaysMin:"日_一_二_三_四_五_六".split("_"),longDateFormat:{LT:"Ah点mm分",LTS:"Ah点m分s秒",L:"YYYY-MM-DD",LL:"YYYY年MMMD日",LLL:"YYYY年MMMD日LT",LLLL:"YYYY年MMMD日ddddLT",l:"YYYY-MM-DD",ll:"YYYY年MMMD日",lll:"YYYY年MMMD日LT",llll:"YYYY年MMMD日ddddLT"},meridiemParse:/凌晨|早上|上午|中午|下午|晚上/,meridiemHour:function(a,b){return 12===a&&(a=0),"凌晨"===b||"早上"===b||"上午"===b?a:"下午"===b||"晚上"===b?a+12:a>=11?a:a+12},meridiem:function(a,b,c){var d=100*a+b;return 600>d?"凌晨":900>d?"早上":1130>d?"上午":1230>d?"中午":1800>d?"下午":"晚上"},calendar:{sameDay:function(){return 0===this.minutes()?"[今天]Ah[点整]":"[今天]LT"},nextDay:function(){return 0===this.minutes()?"[明天]Ah[点整]":"[明天]LT"},lastDay:function(){return 0===this.minutes()?"[昨天]Ah[点整]":"[昨天]LT"},nextWeek:function(){var a,b;return a=nf().startOf("week"),b=this.unix()-a.unix()>=604800?"[下]":"[本]",0===this.minutes()?b+"dddAh点整":b+"dddAh点mm"},lastWeek:function(){var a,b;return a=nf().startOf("week"),b=this.unix()=11?a:a+12:"下午"===b||"晚上"===b?a+12:void 0},meridiem:function(a,b,c){var d=100*a+b; + +return 900>d?"早上":1130>d?"上午":1230>d?"中午":1800>d?"下午":"晚上"},calendar:{sameDay:"[今天]LT",nextDay:"[明天]LT",nextWeek:"[下]ddddLT",lastDay:"[昨天]LT",lastWeek:"[上]ddddLT",sameElse:"L"},ordinalParse:/\d{1,2}(日|月|週)/,ordinal:function(a,b){switch(b){case"d":case"D":case"DDD":return a+"日";case"M":return a+"月";case"w":case"W":return a+"週";default:return a}},relativeTime:{future:"%s內",past:"%s前",s:"幾秒",m:"一分鐘",mm:"%d分鐘",h:"一小時",hh:"%d小時",d:"一天",dd:"%d天",M:"一個月",MM:"%d個月",y:"一年",yy:"%d年"}}),nf);return fg}); \ No newline at end of file diff --git a/ckan/public/base/vendor/resource.config b/ckan/public/base/vendor/resource.config index 875928f1d6e..1abc3d08ab9 100644 --- a/ckan/public/base/vendor/resource.config +++ b/ckan/public/base/vendor/resource.config @@ -32,6 +32,7 @@ reorder = jquery.js vendor = jed.js html5.js + moment.js select2/select2.js select2/select2.css diff --git a/ckan/templates/package/snippets/additional_info.html b/ckan/templates/package/snippets/additional_info.html index f24a3cd8089..a61323eaf27 100644 --- a/ckan/templates/package/snippets/additional_info.html +++ b/ckan/templates/package/snippets/additional_info.html @@ -60,13 +60,22 @@

{{ _('Additional Info') }}

{% if pkg_dict.metadata_modified %} {{ _("Last Updated") }} - {{ h.render_datetime(pkg_dict.metadata_modified, with_hours=True) }} + + + {{ h.render_datetime(pkg_dict.metadata_modified, with_hours=True) }} + + {% endif %} {% if pkg_dict.metadata_created %} {{ _("Created") }} - {{ h.render_datetime(pkg_dict.metadata_created, with_hours=True) }} + + + + {{ h.render_datetime(pkg_dict.metadata_created, with_hours=True) }} + + {% endif %} From a164ac7ff32642f32bf780462727dacb2953a3bb Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 16:45:30 +0200 Subject: [PATCH 071/130] [#2494] Hide dates when JavaScript is active to prevent flickering --- ckan/public/base/css/main.css | 3 +++ ckan/public/base/javascript/main.js | 1 + 2 files changed, 4 insertions(+) diff --git a/ckan/public/base/css/main.css b/ckan/public/base/css/main.css index 1da91d05b82..707b8bf1076 100644 --- a/ckan/public/base/css/main.css +++ b/ckan/public/base/css/main.css @@ -5560,6 +5560,9 @@ a.tag:hover { .js .tab-content.active { display: block; } +.js .datetime { + display: none; +} .box { background-color: #FFF; border: 1px solid #cccccc; diff --git a/ckan/public/base/javascript/main.js b/ckan/public/base/javascript/main.js index 25f8755ca5a..cbdbf4d2fb1 100644 --- a/ckan/public/base/javascript/main.js +++ b/ckan/public/base/javascript/main.js @@ -36,6 +36,7 @@ this.ckan = this.ckan || {}; moment.locale(browserLocale); var date = moment(jQuery(this).data().datetime); jQuery(this).html(date.format("LL, LT ([UTC]Z)")); + jQuery(this).show(); }) // Load the localisations before instantiating the modules. From dabc0c84866eca607d4b0ab22dbd5b570a87c616 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 16:55:52 +0200 Subject: [PATCH 072/130] [#2494] Make sure only 'aware' date objects are compared --- ckan/lib/formatters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py index 2c7b3ccdf90..d14c82bcd32 100644 --- a/ckan/lib/formatters.py +++ b/ckan/lib/formatters.py @@ -106,6 +106,7 @@ def months_between(date1, date2): now = now.replace(tzinfo=datetime_.tzinfo) else: now = now.replace(tzinfo=pytz.utc) + datetime_ = datetime_.replace(tzinfo=pytz.utc) date_diff = now - datetime_ days = date_diff.days if days < 1 and now > datetime_: From 7989b55f41e8446f36cb407016c1ba28c02defbd Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 18:34:57 +0200 Subject: [PATCH 073/130] [#2494] Remove unused imports --- ckan/lib/formatters.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py index d14c82bcd32..d8253e863d8 100644 --- a/ckan/lib/formatters.py +++ b/ckan/lib/formatters.py @@ -1,15 +1,11 @@ import datetime import pytz -import logging -from pylons import config from babel import numbers import ckan.lib.i18n as i18n from ckan.common import _, ungettext -log = logging.getLogger(__name__) - ################################################## # # From c01c954c568e7dba73430c1f64e2ca63453f6416 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 18:40:10 +0200 Subject: [PATCH 074/130] [#2494] Cleanup code - Rename moment to make clear it's moment+locales - Use attribute name for jquery.data() - Fix broken test --- ckan/lib/formatters.py | 3 --- ckan/public/base/javascript/main.js | 2 +- ckan/public/base/vendor/{moment.js => moment-with-locales.js} | 0 ckan/public/base/vendor/resource.config | 2 +- ckan/tests/legacy/lib/test_helpers.py | 2 +- 5 files changed, 3 insertions(+), 6 deletions(-) rename ckan/public/base/vendor/{moment.js => moment-with-locales.js} (100%) diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py index d8253e863d8..bc92a34fdd3 100644 --- a/ckan/lib/formatters.py +++ b/ckan/lib/formatters.py @@ -130,9 +130,6 @@ def months_between(date1, date2): return ungettext('over {years} year ago', 'over {years} years ago', months / 12).format(years=months / 12) - if datetime_.tzinfo is None: - datetime_ = datetime_.replace(tzinfo=pytz.utc) - # actual date details = { 'min': datetime_.minute, diff --git a/ckan/public/base/javascript/main.js b/ckan/public/base/javascript/main.js index cbdbf4d2fb1..3c82d8fd625 100644 --- a/ckan/public/base/javascript/main.js +++ b/ckan/public/base/javascript/main.js @@ -34,7 +34,7 @@ this.ckan = this.ckan || {}; // Convert all datetimes to the users timezone jQuery('.datetime').each(function() { moment.locale(browserLocale); - var date = moment(jQuery(this).data().datetime); + var date = moment(jQuery(this).data('datetime')); jQuery(this).html(date.format("LL, LT ([UTC]Z)")); jQuery(this).show(); }) diff --git a/ckan/public/base/vendor/moment.js b/ckan/public/base/vendor/moment-with-locales.js similarity index 100% rename from ckan/public/base/vendor/moment.js rename to ckan/public/base/vendor/moment-with-locales.js diff --git a/ckan/public/base/vendor/resource.config b/ckan/public/base/vendor/resource.config index 1abc3d08ab9..d7125923a08 100644 --- a/ckan/public/base/vendor/resource.config +++ b/ckan/public/base/vendor/resource.config @@ -32,7 +32,7 @@ reorder = jquery.js vendor = jed.js html5.js - moment.js + moment-with-locales.js select2/select2.js select2/select2.css diff --git a/ckan/tests/legacy/lib/test_helpers.py b/ckan/tests/legacy/lib/test_helpers.py index c6b178bf968..b582b0b7514 100644 --- a/ckan/tests/legacy/lib/test_helpers.py +++ b/ckan/tests/legacy/lib/test_helpers.py @@ -28,7 +28,7 @@ def test_render_datetime(self): def test_render_datetime_with_hours(self): res = h.render_datetime(datetime.datetime(2008, 4, 13, 20, 40, 20, 123456), with_hours=True) - assert_equal(res, 'April 13, 2008, 20:40 (UTC+0)') + assert_equal(res, 'April 13, 2008, 20:40 (UTC)') def test_render_datetime_but_from_string(self): res = h.render_datetime('2008-04-13T20:40:20.123456') From 45ac16ef4856cff17578e2810629db2a5b1ad114 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 19:19:40 +0200 Subject: [PATCH 075/130] [#2494] Only replace valid dates with moment.js --- ckan/public/base/javascript/main.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ckan/public/base/javascript/main.js b/ckan/public/base/javascript/main.js index 3c82d8fd625..745835846a8 100644 --- a/ckan/public/base/javascript/main.js +++ b/ckan/public/base/javascript/main.js @@ -35,7 +35,9 @@ this.ckan = this.ckan || {}; jQuery('.datetime').each(function() { moment.locale(browserLocale); var date = moment(jQuery(this).data('datetime')); - jQuery(this).html(date.format("LL, LT ([UTC]Z)")); + if (date.isValid()) { + jQuery(this).html(date.format("LL, LT ([UTC]Z)")); + } jQuery(this).show(); }) From bdfb3983cf1902d7288f704776cec8245b8a29aa Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 9 Jul 2015 23:31:39 +0200 Subject: [PATCH 076/130] [#2494] Add support for 'server' timezone This special timezone uses the local timezone of the server. In order to do this, the tzlocal module is needed. --- ckan/config/deployment.ini_tmpl | 1 + ckan/lib/helpers.py | 8 +++++++- requirements.in | 1 + requirements.txt | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/ckan/config/deployment.ini_tmpl b/ckan/config/deployment.ini_tmpl index d379392bdea..d4935bdf6f9 100644 --- a/ckan/config/deployment.ini_tmpl +++ b/ckan/config/deployment.ini_tmpl @@ -111,6 +111,7 @@ ckan.favicon = /images/icons/ckan.ico ckan.gravatar_default = identicon ckan.preview.direct = png jpg gif ckan.preview.loadable = html htm rdf+xml owl+xml xml n3 n-triples turtle plain atom csv tsv rss txt json +ckan.timezone = server # package_hide_extras = for_search_index_only #package_edit_return_url = http://another.frontend/dataset/ diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 5bfa530e06b..596b04e545e 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -11,6 +11,7 @@ import re import os import pytz +import tzlocal import urllib import urlparse import pprint @@ -81,6 +82,10 @@ def _datestamp_to_datetime(datetime_): # change output if `ckan.timezone` is available datetime_ = datetime_.replace(tzinfo=pytz.utc) timezone_name = config.get('ckan.timezone', '') + if timezone_name == 'server': + local_tz = tzlocal.get_localzone() + return datetime_.astimezone(local_tz) + try: datetime_ = datetime_.astimezone( pytz.timezone(timezone_name) @@ -91,7 +96,8 @@ def _datestamp_to_datetime(datetime_): 'Timezone `%s` not found. ' 'Please provide a valid timezone setting in `ckan.timezone` ' 'or leave the field empty. All valid values can be found in ' - 'pytz.all_timezones.' % timezone_name + 'pytz.all_timezones. You can use the special value `server` ' + 'to use the local timezone of the server.' % timezone_name ) return datetime_ diff --git a/requirements.in b/requirements.in index 71210901858..d395aa423a2 100644 --- a/requirements.in +++ b/requirements.in @@ -30,3 +30,4 @@ WebOb==1.0.8 zope.interface==4.1.1 unicodecsv>=0.9 pytz==2012j +tzlocal==1.2 diff --git a/requirements.txt b/requirements.txt index ee87f798219..7e3043640a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,3 +42,4 @@ vdm==0.13 wsgiref==0.1.2 zope.interface==4.1.1 pytz==2012j +tzlocal==1.2 From 642cc78344522af9b51ec5a7e95fcdb31395aa4c Mon Sep 17 00:00:00 2001 From: joetsoi Date: Thu, 9 Jul 2015 23:29:23 +0100 Subject: [PATCH 077/130] [#2486] fix package controller tests --- ckan/tests/controllers/test_package.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ckan/tests/controllers/test_package.py b/ckan/tests/controllers/test_package.py index 91fa6fd2ed5..e42f3766fc0 100644 --- a/ckan/tests/controllers/test_package.py +++ b/ckan/tests/controllers/test_package.py @@ -345,8 +345,7 @@ def setup(self): model.repo.rebuild_db() def test_read(self): - user = factories.User() - dataset = factories.Dataset(user=user['name']) + dataset = factories.Dataset() app = helpers._get_test_app() response = app.get(url_for(controller='package', action='read', id=dataset['name'])) @@ -723,7 +722,7 @@ def test_confirm_and_cancel_deleting_a_resource(self): response.mustcontain(message.format(name=resource['name'])) # cancelling sends us back to the resource edit page - form = response.forms['confirm-delete-resource-form'] + form = response.forms['confirm-resource-delete-form'] response = form.submit('cancel') response = response.follow() assert_equal(200, response.status_int) From 67cee127017a2683caaf5f80e647e23e9c88d04d Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Fri, 10 Jul 2015 08:04:06 +0200 Subject: [PATCH 078/130] [#2494] Add snippet for local friendly datetime --- ckan/public/base/css/main.css | 2 +- ckan/public/base/javascript/main.js | 2 +- .../package/snippets/additional_info.html | 8 ++------ .../snippets/local_friendly_datetime.html | 14 ++++++++++++++ 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 ckan/templates/snippets/local_friendly_datetime.html diff --git a/ckan/public/base/css/main.css b/ckan/public/base/css/main.css index 707b8bf1076..c316089c8d8 100644 --- a/ckan/public/base/css/main.css +++ b/ckan/public/base/css/main.css @@ -5560,7 +5560,7 @@ a.tag:hover { .js .tab-content.active { display: block; } -.js .datetime { +.js .automatic-local-datetime { display: none; } .box { diff --git a/ckan/public/base/javascript/main.js b/ckan/public/base/javascript/main.js index 745835846a8..1df0c2802cf 100644 --- a/ckan/public/base/javascript/main.js +++ b/ckan/public/base/javascript/main.js @@ -32,7 +32,7 @@ this.ckan = this.ckan || {}; ckan.LOCALE_ROOT = getRootFromData('localeRoot'); // Convert all datetimes to the users timezone - jQuery('.datetime').each(function() { + jQuery('.automatic-local-datetime').each(function() { moment.locale(browserLocale); var date = moment(jQuery(this).data('datetime')); if (date.isValid()) { diff --git a/ckan/templates/package/snippets/additional_info.html b/ckan/templates/package/snippets/additional_info.html index a61323eaf27..2a31a20f3b0 100644 --- a/ckan/templates/package/snippets/additional_info.html +++ b/ckan/templates/package/snippets/additional_info.html @@ -61,9 +61,7 @@

{{ _('Additional Info') }}

{{ _("Last Updated") }} - - {{ h.render_datetime(pkg_dict.metadata_modified, with_hours=True) }} - + {% snippet 'snippets/local_friendly_datetime.html', datetime_obj=pkg_dict.metadata_modified %} {% endif %} @@ -72,9 +70,7 @@

{{ _('Additional Info') }}

{{ _("Created") }} - - {{ h.render_datetime(pkg_dict.metadata_created, with_hours=True) }} - + {% snippet 'snippets/local_friendly_datetime.html', datetime_obj=pkg_dict.metadata_created %} {% endif %} diff --git a/ckan/templates/snippets/local_friendly_datetime.html b/ckan/templates/snippets/local_friendly_datetime.html new file mode 100644 index 00000000000..345e74f5d94 --- /dev/null +++ b/ckan/templates/snippets/local_friendly_datetime.html @@ -0,0 +1,14 @@ +{# +Displays a datetime that can be converted to a users timezone using JavaScript. +In the data-datetime attribute, the date is rendered in ISO 8601 format. + +datetime_obj - the datetime object to display + +Example: + + {% snippet 'snippets/local_friendly_datetime, datetime=pkg_dict.metadata_created %} + +#} + + {{ h.render_datetime(datetime_obj, with_hours=True) }} + From 2e1287cfc9ce310ab44e827d7d7956d8b9449f81 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Fri, 10 Jul 2015 08:13:11 +0200 Subject: [PATCH 079/130] [#2494] Rename ckan.timezone to ckan.display_timezone --- ckan/config/deployment.ini_tmpl | 2 +- ckan/lib/helpers.py | 13 +++++++------ doc/maintaining/configuration.rst | 10 +++++----- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ckan/config/deployment.ini_tmpl b/ckan/config/deployment.ini_tmpl index d4935bdf6f9..8eb7050591a 100644 --- a/ckan/config/deployment.ini_tmpl +++ b/ckan/config/deployment.ini_tmpl @@ -111,7 +111,7 @@ ckan.favicon = /images/icons/ckan.ico ckan.gravatar_default = identicon ckan.preview.direct = png jpg gif ckan.preview.loadable = html htm rdf+xml owl+xml xml n3 n-triples turtle plain atom csv tsv rss txt json -ckan.timezone = server +ckan.display_timezone = server # package_hide_extras = for_search_index_only #package_edit_return_url = http://another.frontend/dataset/ diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 596b04e545e..073766ceecd 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -79,9 +79,9 @@ def _datestamp_to_datetime(datetime_): return datetime_ # all dates are considered UTC internally, - # change output if `ckan.timezone` is available + # change output if `ckan.display_timezone` is available datetime_ = datetime_.replace(tzinfo=pytz.utc) - timezone_name = config.get('ckan.timezone', '') + timezone_name = config.get('ckan.display_timezone', '') if timezone_name == 'server': local_tz = tzlocal.get_localzone() return datetime_.astimezone(local_tz) @@ -94,10 +94,11 @@ def _datestamp_to_datetime(datetime_): if timezone_name != '': log.warning( 'Timezone `%s` not found. ' - 'Please provide a valid timezone setting in `ckan.timezone` ' - 'or leave the field empty. All valid values can be found in ' - 'pytz.all_timezones. You can use the special value `server` ' - 'to use the local timezone of the server.' % timezone_name + 'Please provide a valid timezone setting in ' + '`ckan.display_timezone` or leave the field empty. All valid ' + 'values can be found in pytz.all_timezones. You can use the ' + 'special value `server` to use the local timezone of the ' + 'server.' % timezone_name ) return datetime_ diff --git a/doc/maintaining/configuration.rst b/doc/maintaining/configuration.rst index 871dc8c9a15..0619b54c274 100644 --- a/doc/maintaining/configuration.rst +++ b/doc/maintaining/configuration.rst @@ -1556,20 +1556,20 @@ Default value: (none) By default, the locales are searched for in the ``ckan/i18n`` directory. Use this option if you want to use another folder. -.. _ckan.timezone: +.. _ckan.display_timezone: -ckan.timezone -^^^^^^^^^^^^^ +ckan.display_timezone +^^^^^^^^^^^^^^^^^^^^^ Example:: - ckan.timezone = Europe/Zurich + ckan.display_timezone = Europe/Zurich Default value: UTC By default, all datetimes are considered to be in the UTC timezone. Use this option to change the displayed dates on the frontend. Internally, the dates are always saved as UTC. This option only changes the way the dates are displayed. -The valid values for this options [can be found at pytz](http://pytz.sourceforge.net/#helpers) (``pytz.all_timezones``) +The valid values for this options [can be found at pytz](http://pytz.sourceforge.net/#helpers) (``pytz.all_timezones``). You can specify the special value `server` to use the timezone settings of the server, that is running CKAN. .. _ckan.root_path: From 4a629c8ccacb0dbe9e035f5da821762d0c5f37d8 Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Fri, 10 Jul 2015 12:30:31 +0100 Subject: [PATCH 080/130] [#2530] Add 403 to server refusal excuses --- ckanext/resourceproxy/controller.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ckanext/resourceproxy/controller.py b/ckanext/resourceproxy/controller.py index ad3ddfea0c2..f6900358799 100644 --- a/ckanext/resourceproxy/controller.py +++ b/ckanext/resourceproxy/controller.py @@ -40,9 +40,10 @@ def proxy_resource(context, data_dict): # first we try a HEAD request which may not be supported did_get = False r = requests.head(url) - # 405 would be the appropriate response here, but 400 with - # the invalid method mentioned in the text is also possible (#2412) - if r.status_code in (400, 405): + # Servers can refuse HEAD requests. 405 is the appropriate response, + # but 400 with the invalid method mentioned in the text, or a 403 + # (forbidden) status is also possible (#2412, #2530) + if r.status_code in (400, 403, 405): r = requests.get(url, stream=True) did_get = True r.raise_for_status() From 420ea88f812f518c1beb7eb52b240ec0fb61c946 Mon Sep 17 00:00:00 2001 From: Bozhidar Bozhanov Date: Wed, 15 Jul 2015 11:19:31 +0300 Subject: [PATCH 081/130] Fixed scripts block name The proper block is "scripts", rather than "script" and the documentation shouldn't be fixed. --- doc/contributing/frontend/template-blocks.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/contributing/frontend/template-blocks.rst b/doc/contributing/frontend/template-blocks.rst index f7bc092587d..d500e9761e8 100644 --- a/doc/contributing/frontend/template-blocks.rst +++ b/doc/contributing/frontend/template-blocks.rst @@ -213,7 +213,7 @@ scripts The scripts block allows you to add additonal scripts to the page. Use the ``super()`` function to load the default scripts before/after your own:: - {% block script %} + {% block scripts %} {{ super() }} {% endblock %} From b628f4bef12406f92c30470f93c82b451045f80e Mon Sep 17 00:00:00 2001 From: amercader Date: Thu, 16 Jul 2015 10:09:18 +0100 Subject: [PATCH 082/130] [#2532] Allow custom dataset types on views create command The dataset_type:dataset filter prevents views from being created when using the views create command on custom dataset types --- ckan/lib/cli.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index c0c6c43e5c8..d6fed92d8f0 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -2418,10 +2418,6 @@ def _search_datasets(self, page=1, view_types=[]): if not search_data_dict.get('q'): search_data_dict['q'] = '*:*' - if ('dataset_type:dataset' not in search_data_dict['fq'] and - 'dataset_type:dataset' not in search_data_dict['fq_list']): - search_data_dict['fq_list'].append('dataset_type:dataset') - query = p.toolkit.get_action('package_search')( {'ignore_capacity_check': True}, search_data_dict) From 56d25db60210d856aee8420485125bdc76a5b7f4 Mon Sep 17 00:00:00 2001 From: amercader Date: Thu, 16 Jul 2015 11:44:42 +0100 Subject: [PATCH 083/130] [#2536] Installation and relese process doc tweaks * Include setting `site_url` on the package install instructions to avoid getting an exception * Improve the release process docs: overview, packaging, announce email, etc --- doc/contributing/release-process.rst | 128 +++++++++++++----- doc/maintaining/configuration.rst | 2 + .../installing/install-from-package.rst | 22 ++- 3 files changed, 117 insertions(+), 35 deletions(-) diff --git a/doc/contributing/release-process.rst b/doc/contributing/release-process.rst index ff83535b07d..ea57e55361d 100644 --- a/doc/contributing/release-process.rst +++ b/doc/contributing/release-process.rst @@ -11,6 +11,31 @@ new CKAN release. An overview of the different kinds of CKAN release, and the process for upgrading a CKAN site to a new version. +---------------- +Process overview +---------------- + +The process of a new release starts with the creation of a new release branch. +A release branch is the one that will be stabilized and eventually become the actual +released version. Release branches are always named ``release-vM.m.p``, after the +:ref:`major, minor and patch versions ` they include. Major and minor versions are +always branched from master. Patch releases are always branched from the most recent tip +of the previous patch release branch. + + :: + + +--+---------------------------------------+-------------> Master + | | + +-----------------> release-v2.4.0 +----------> release-v2.5.0 + | + +---------> release-v2.4.1 + | + +------> release-v2.4.2 + +Once a release branch has been created there is generally a three-four week period until +the actual release. During this period the branch is tested and fixes cherry-picked. The whole +process is described in the following sections. + .. _beta-release: @@ -23,14 +48,14 @@ become stable releases. #. Create a new release branch:: - git checkout -b release-v1.8 + git checkout -b release-v2.5.0 Update ``ckan/__init__.py`` to change the version number to the new version - with a *b* after it, e.g. *1.8b*. + with a *b* after it, e.g. *2.5.0b*. Commit the change and push the new branch to GitHub:: git commit -am "Update version number" - git push origin release-v1.8 + git push origin release-v2.5.0 You will probably need to update the same file on master to increase the version number, in this case ending with an *a* (for alpha). @@ -53,8 +78,26 @@ become stable releases. There will be a final front-end build before the actual release. -#. The beta staging site (http://beta.ckan.org, currently on s084) should be - updated regularly to allow user testing. +#. The beta staging site (http://beta.ckan.org, currently on s084) must be + set to track the latest beta release branch to allow user testing. This site + is updated nightly. + +#. Once a week create a deb package with the latest release branch, using ``betaX`` + iterations. Deb packages are built using Ansible_ scripts located at the + following repo: + + https://github.com/ckan/ckan-packaging + + The repository contains furhter instructions on how to run the scripts, but essentially + you will need access to the packaging server, and then run something like:: + + ansible-playbook package.yml -u your_user -s + + You will be prompted for the CKAN version to package (eg ``2.4.0``), the iteration (eg ``beta1``) + and whether to package the DataPusher (always do it on release packages). + + Packages are created by default on the `/build` folder of the publicly accessible directory of + the packaging server. #. Once the translation freeze is in place (ie no changes to the translatable strings are allowed), strings need to be extracted and uploaded to @@ -96,7 +139,7 @@ become stable releases. e. Edit ``.tx/config``, on line 4 to set the Transifex 'resource' to the new major release name (if different), using dashes instead of dots. - For instance v1.2, v1.2.1 and v1.2.2 all share: ``[ckan.1-2]``. + For instance v2.4.0, v2.4.1 and v2.4.2 all share: ``[ckan.2-4]``. f. Update the ``ckan.po`` files with the new strings from the ``ckan.pot`` file:: @@ -148,6 +191,11 @@ become stable releases. git commit -am " Update translations from Transifex" git push +#. A week before the actual release, send an email to the + `ckan-announce mailing list `_, + so CKAN instance maintainers can be aware of the upcoming releases. List any patch releases + that will be also available. Here's an `example `_ email. + ---------------------- Doing a proper release @@ -156,16 +204,16 @@ Doing a proper release Once the release branch has been thoroughly tested and is stable we can do a release. -1. Run the most thorough tests:: +#. Run the most thorough tests:: nosetests ckan/tests --ckan --ckan-migration --with-pylons=test-core.ini -2. Do a final build of the front-end and commit the changes:: +#. Do a final build of the front-end and commit the changes:: paster front-end-build git commit -am "Rebuild front-end" -3. Update the CHANGELOG.txt with the new version changes: +#. Update the CHANGELOG.txt with the new version changes: * Add the release date next to the version number * Add the following notices at the top of the release, reflecting whether @@ -176,7 +224,12 @@ a release. Note: This version does not require a Solr schema upgrade * Check the issue numbers on the commit messages for information about - the changes. These are some helpful git commands:: + the changes. The following gist has a script that uses the GitHub API to + aid in getting the merged issues between releases: + + https://gist.github.com/amercader/4ec55774b9a625e815bf + + Other helpful commands are:: git branch -a --merged > merged-current.txt git branch -a --merged ckan-1.8.1 > merged-previous.txt @@ -185,23 +238,33 @@ a release. git log --no-merges release-v1.8.1..release-v2.0 git shortlog --no-merges release-v1.8.1..release-v2.0 -4. Check that the docs compile correctly:: +#. Check that the docs compile correctly:: rm build/sphinx -rf python setup.py build_sphinx -5. Remove the beta letter in the version number in ``ckan/__init__.py`` +#. Remove the beta letter in the version number in ``ckan/__init__.py`` (eg 1.1b -> 1.1) and commit the change:: git commit -am "Update version number for release X.Y" -6. Tag the repository with the version number, and make sure to push it to +#. Tag the repository with the version number, and make sure to push it to GitHub afterwards:: git tag -a -m '[release]: Release tag' ckan-X.Y git push --tags -7. Upload the release to PyPI:: +#. Create the final deb package and move it to the root of the + `publicly accessible folder `_ of + the packaging server from the `/build` folder. + + Make sure to rename it so it follows the deb packages name convention:: + + python-ckan_Major.minor_amd64.deb + + Note that we drop any patch version or iteration from the package name. + +#. Upload the release to PyPI:: python setup.py sdist upload @@ -211,7 +274,7 @@ a release. If you make a mistake, you can always remove the release file on PyPI and re-upload it. -8. Enable the new version of the docs on Read the Docs (you will need an admin +#. Enable the new version of the docs on Read the Docs (you will need an admin account): a. Go to the `Read The Docs`_ versions page @@ -221,31 +284,32 @@ a release. b. If it is the latest stable release, set it to be the Default Version and check it is displayed on http://docs.ckan.org. -9. Write a `CKAN Blog post `_ and send an email to +#. Write a `CKAN Blog post `_ and send an email to the mailing list announcing the release, including the relevant bit of changelog. -10. Cherry-pick the i18n changes from the release branch onto master. +#. Cherry-pick the i18n changes from the release branch onto master. - Generally we don't merge or cherry-pick release branches into master, but - the files in ckan/i18n are an exception. These files are only ever changed - on release branches following the :ref:`beta-release` instructions above, - and after a release has been finalized the changes need to be cherry-picked - onto master. + We don't generally merge or cherry-pick release branches into master, but + the files in ckan/i18n are an exception. These files are only ever changed + on release branches following the :ref:`beta-release` instructions above, + and after a release has been finalized the changes need to be cherry-picked + onto master. - To find out what i18n commits there are on the release-v* branch that are - not on master, do:: + To find out what i18n commits there are on the release-v* branch that are + not on master, do:: - git log master..release-v* ckan/i18n + git log master..release-v* ckan/i18n - Then ``checkout`` the master branch, do a ``git status`` and a ``git pull`` - to make sure you have the latest commits on master and no local changes. - Then use ``git cherry-pick`` when on the master branch to cherry-pick these - commits onto master. You should not get any merge conflicts. Run the - ``check-po-files`` command again just to be safe, it should not report any - problems. Run CKAN's tests, again just to be safe. Then do ``git push - origin master``. + Then ``checkout`` the master branch, do a ``git status`` and a ``git pull`` + to make sure you have the latest commits on master and no local changes. + Then use ``git cherry-pick`` when on the master branch to cherry-pick these + commits onto master. You should not get any merge conflicts. Run the + ``check-po-files`` command again just to be safe, it should not report any + problems. Run CKAN's tests, again just to be safe. Then do ``git push + origin master``. .. _Transifex: https://www.transifex.com/projects/p/ckan .. _`Read The Docs`: http://readthedocs.org/dashboard/ckan/versions/ +.. _Ansible: http://ansible.com/ diff --git a/doc/maintaining/configuration.rst b/doc/maintaining/configuration.rst index dab12ee9895..57830ff41c8 100644 --- a/doc/maintaining/configuration.rst +++ b/doc/maintaining/configuration.rst @@ -53,6 +53,8 @@ details on how to this check :doc:`/extensions/remote-config-update`. +.. _config_file: + CKAN configuration file *********************** diff --git a/doc/maintaining/installing/install-from-package.rst b/doc/maintaining/installing/install-from-package.rst index 78d680fc638..c9dd9be0817 100644 --- a/doc/maintaining/installing/install-from-package.rst +++ b/doc/maintaining/installing/install-from-package.rst @@ -35,7 +35,7 @@ CKAN: .. parsed-literal:: - wget \http://packaging.ckan.org/|latest_package_name| + wget \http://packaging.ckan.org/|latest_package_name| .. note:: If ``wget`` is not present, you can install it via:: @@ -87,6 +87,22 @@ CKAN: then edit the :ref:`sqlalchemy.url` option in your |production.ini| file and set the correct password, database and database user. +------------------------------------------------------- +3. Update the configuration and initialize the database +------------------------------------------------------- + +#. Edit the :ref:`config_file` (|production.ini|) to set up the following options: + + site_id + Each CKAN site should have a unique ``site_id``, for example:: + + ckan.site_id = default + + site_url + Provide the site's URL. For example:: + + ckan.site_url = http://demo.ckan.org + #. Initialize your CKAN database by running this command in a terminal:: sudo ckan db init @@ -98,7 +114,7 @@ CKAN: instructions in :doc:`/maintaining/filestore`. --------------------------- -3. Restart Apache and Nginx +4. Restart Apache and Nginx --------------------------- Restart Apache and Nginx by running this command in a terminal:: @@ -107,7 +123,7 @@ Restart Apache and Nginx by running this command in a terminal:: sudo service nginx restart --------------- -4. You're done! +5. You're done! --------------- Open http://localhost in your web browser. You should see the CKAN front From eda33f835414632852068a153330e6308239d471 Mon Sep 17 00:00:00 2001 From: amercader Date: Thu, 16 Jul 2015 12:15:44 +0100 Subject: [PATCH 084/130] [#2387] Fix GeoJSON field option in map view Because the datastore fields were limited to numeric ones (to accommodate the lat/lon option), it was not possible to select a text field for the geojson option. This patch separates the types used on both, while keeping the validation in place and not mixing types between options. --- ckanext/reclineview/plugin.py | 17 +++++++++++++---- .../theme/templates/recline_map_form.html | 6 +++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/ckanext/reclineview/plugin.py b/ckanext/reclineview/plugin.py index 20e204bb794..a0979740ec0 100644 --- a/ckanext/reclineview/plugin.py +++ b/ckanext/reclineview/plugin.py @@ -178,7 +178,9 @@ class ReclineMapView(ReclineViewBase): datastore_fields = [] - datastore_field_types = ['numeric'] + datastore_field_latlon_types = ['numeric'] + + datastore_field_geojson_types = ['text'] def list_map_field_types(self): return [t['value'] for t in self.map_field_types] @@ -213,12 +215,19 @@ def info(self): } def setup_template_variables(self, context, data_dict): - self.datastore_fields = datastore_fields(data_dict['resource'], - self.datastore_field_types) + map_latlon_fields = datastore_fields( + data_dict['resource'], self.datastore_field_latlon_types) + map_geojson_fields = datastore_fields( + data_dict['resource'], self.datastore_field_geojson_types) + + self.datastore_fields = map_latlon_fields + map_geojson_fields + vars = ReclineViewBase.setup_template_variables(self, context, data_dict) vars.update({'map_field_types': self.map_field_types, - 'map_fields': self.datastore_fields}) + 'map_latlon_fields': map_latlon_fields, + 'map_geojson_fields': map_geojson_fields + }) return vars def form_template(self, context, data_dict): diff --git a/ckanext/reclineview/theme/templates/recline_map_form.html b/ckanext/reclineview/theme/templates/recline_map_form.html index 42b4ea5998d..02f1d5944cb 100644 --- a/ckanext/reclineview/theme/templates/recline_map_form.html +++ b/ckanext/reclineview/theme/templates/recline_map_form.html @@ -4,8 +4,8 @@ {{ form.input('limit', id='field-limit', label=_('Number of rows'), placeholder=_('eg: 100'), value=data.limit, error=errors.limit, classes=['control-medium']) }} {{ form.select('map_field_type', label=_('Field type'), options=map_field_types, selected=data.map_field_type, error=errors.map_field_type) }} -{{ form.select('latitude_field', label=_('Latitude field'), options=map_fields, selected=data.latitude_field, error=errors.latitude_field) }} -{{ form.select('longitude_field', label=_('Longitude field'), options=map_fields, selected=data.longitude_field, error=errors.longitude_field) }} -{{ form.select('geojson_field', label=_('GeoJSON field'), options=map_fields, selected=data.geojson_field, error=errors.geojson_field) }} +{{ form.select('latitude_field', label=_('Latitude field'), options=map_latlon_fields, selected=data.latitude_field, error=errors.latitude_field) }} +{{ form.select('longitude_field', label=_('Longitude field'), options=map_latlon_fields, selected=data.longitude_field, error=errors.longitude_field) }} +{{ form.select('geojson_field', label=_('GeoJSON field'), options=map_geojson_fields, selected=data.geojson_field, error=errors.geojson_field) }} {{ form.checkbox('auto_zoom', label=_('Auto zoom to features'), value=True, checked=data.auto_zoom, error=errors.auto_zoom) }} {{ form.checkbox('cluster_markers', label=_('Cluster markers'), value=True, checked=data.cluster_markers, error=errors.cluster_markers) }} From 08d63d0bb911176e2ea9a83b44b6e9f5375d6133 Mon Sep 17 00:00:00 2001 From: David Read Date: Tue, 21 Jul 2015 17:00:56 +0100 Subject: [PATCH 085/130] Fix "paster db init" when celery is configured with a backend other than database. --- ckan/model/__init__.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py index b0147316098..e147207fa8c 100644 --- a/ckan/model/__init__.py +++ b/ckan/model/__init__.py @@ -217,13 +217,18 @@ def init_db(self): try: import ckan.lib.celery_app as celery_app import celery.db.session as celery_session - ## This creates the database tables it is a slight - ## hack to celery. + import celery.backends.database + ## This creates the database tables (if using that backend) + ## It is a slight hack to celery backend = celery_app.celery.backend - celery_result_session = backend.ResultSession() - engine = celery_result_session.bind - celery_session.ResultModelBase.metadata.create_all(engine) + if isinstance(backend, + celery.backends.database.DatabaseBackend): + celery_result_session = backend.ResultSession() + engine = celery_result_session.bind + celery_session.ResultModelBase.metadata.\ + create_all(engine) except ImportError: + # use of celery is optional pass self.init_configuration_data() From 12e9f111b92f5d9c8686e6d589439aa6ccdf0f6a Mon Sep 17 00:00:00 2001 From: Alex Sadleir Date: Wed, 22 Jul 2015 12:04:58 +1000 Subject: [PATCH 086/130] Handle special characters in column names to fix recline view Some special characters/unicode characters are causing recline view sanitization for HTML to fail with a JS error: ``` "bootstrap.js:3 Uncaught Error: Syntax error, unrecognized expression:" ``` Fixes #2490 by catching JS exceptions --- ckanext/reclineview/theme/public/vendor/recline/recline.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ckanext/reclineview/theme/public/vendor/recline/recline.js b/ckanext/reclineview/theme/public/vendor/recline/recline.js index 42be8731bcc..d2d9f7c6d77 100755 --- a/ckanext/reclineview/theme/public/vendor/recline/recline.js +++ b/ckanext/reclineview/theme/public/vendor/recline/recline.js @@ -3222,7 +3222,12 @@ my.SlickGrid = Backbone.View.extend({ } function sanitizeFieldName(name) { - var sanitized = $(name).text(); + var sanitized; + try{ + sanitized = $(name).text(); + } catch(e) { + sanitized = ''; + } return (name !== sanitized && sanitized !== '') ? sanitized : name; } From 2dfc35dd8a8f18bb6d2afe459d3d9fb6cef81ee7 Mon Sep 17 00:00:00 2001 From: David Read Date: Wed, 22 Jul 2015 12:52:44 +0100 Subject: [PATCH 087/130] Copy latest CHANGELOG from release-v2.4.0. --- CHANGELOG.rst | 118 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 104 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 567040d6daa..0b9bb641ffe 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,12 +7,62 @@ Changelog --------- -v2.4 -==== +v2.4.0 2015-07-22 +================= + +Note: This version requires a database upgrade + +Note: This version requires a Solr schema upgrade + +Major: + * CKAN config can now be set from environment variables and via the API (#2429) + +Minor: + * API calls now faster: ``group_show``, ``organization_show``, ``user_show``, + ``package_show``, ``vocabulary_show`` & ``tag_show`` (#1886, #2206, #2207, + #2376) + * Require/validate current password before allowing a password change (#1940) + * Added ``organization_autocomplete`` action (#2125) + * Default authorization no longer allows anyone to create datasets etc (#2164) + * ``organization_list_for_user`` now returns organizations in hierarchy if they + exist for roles set in ``ckan.auth.roles_that_cascade_to_sub_groups`` (#2199) + * Improved accessibility (text based browsers) focused on the page header + (#2258) + * Improved IGroupForm for better customizing groups and organization behaviour + (#2354) + * Admin page can now be extended to have new tabs (#2351) + + +Bug fixes: + * Command line ``paster user`` failed for non-ascii characters (#1244) + * Memory leak fixed in datastore API (#1847) + * Modifying resource didn't update it's last updated timestamp (#1874) + * Datastore didn't update if you uploaded a new file of the same name as the + existing file (#2147) + * Files with really long file were skipped by datapusher (#2057) + * Multi-lingual Solr schema is now updated so it works again (#2161) + * Resource views didn't display when embedded in another site (#2238) + * ``resource_update`` failed if you supplied a revision_id (#2340) + * Recline could not plot GeoJSON on a map (#2387) + * Dataset create form 404 error if you added a resource but left it blank (#2392) + * Editing a resource view for a file that was UTF-8 and had a BOM gave an + error (#2401) + * Email invites had the email address changed to lower-case (#2415) + * Default resource views not created when using a custom dataset schema (#2421, + #2482) + * If the licenses pick-list was customized to remove some, datasets with old + values had them overwritten when edited (#2472) + * Recline views failed on some non-ascii characters (#2490) + * Resource proxy failed if HEAD responds with 403 (#2530) + * Resource views for non-default dataset types couldn't be created (#2532) Changes and deprecations ------------------------ +* The default of allowing anyone to create datasets, groups and organizations + has been changed to False. It is advised to ensure you set all of the + :ref:`config-authorization` options explicitly in your CKAN config. (#2164) + * The ``package_show`` API call does not return the ``tracking_summary``, keys in the dataset or resources by default any more. @@ -23,27 +73,39 @@ Changes and deprecations `new_tests` directory has moved to `tests` and the `new_authz.py` module has been renamed `authz.py`. Code that imports names from the old locations will continue to work in this release but will issue - a deprecation warning. + a deprecation warning. (#1753) -* Add text to account links in header, fixes text based browser support #2258 +* ``group_show`` and ``organization_show`` API calls no longer return the + datasets by default (#2206) -* Add middleware that cleans up the response string after it has been - served, stabilizes memory usage for large requests #1847 + Custom templates or users of this API call will need to pass + ``include_datasets=True`` to include datasets in the response. * The ``vocabulary_show`` and ``tag_show`` API calls no longer returns the ``packages`` key - i.e. datasets that use the vocabulary or tag. However ``tag_show`` now has an ``include_datasets`` option. (#1886) -* `organization_list_for_user` now returns organizations in hierarchy if they - exist for roles set in `ckan.auth.roles_that_cascade_to_sub_groups`. +* Config option ``site_url`` is now required - CKAN will not abort during + start-up if it is not set. (#1976) -* Update license keys to match opendefinition.org #2110 +v2.3.1 2015-07-22 +================= -* The ``group_show`` and ``organization_show`` API calls do not return - ``datasets`` by default any more. +Bug fixes: + * Resource views won't display when embedded in another site (#2238) + * ``resource_update`` failed if you supplied a revision_id (#2340) + * Recline could not plot GeoJSON on a map (#2387) + * Dataset create form 404 error if you added a resource but left it blank (#2392) + * Editing a resource view for a file that was UTF-8 and had a BOM gave an + error (#2401) + * Email invites had the email address changed to lower-case (#2415) + * Default resource views not created when using a custom dataset schema (#2421, + #2482) + * If the licenses pick-list was customized to remove some, datasets with old + values had them overwritten when edited (#2472) + * Recline views failed on some non-ascii characters (#2490) + * Resource views for non-default dataset types couldn't be created (#2532) - Custom templates or users of this API call will need to pass - ``include_datasets=True`` to include datasets in the response. v2.3 2015-03-04 =============== @@ -200,7 +262,6 @@ Bug fixes: * Make resource_create auth work against package_update (#2037) * Fix DataStore permissions check on startup (#1374) * Fix datastore docs link (#2044) - * Fix resource extras getting lost on resource update (#2158) * Clean up field names before rendering the Recline table (#2319) * Don't "normalize" resource URL in recline view (#2324) * Don't assume resource format is there on text preview (#2320) @@ -341,6 +402,17 @@ Troubleshooting: Also see the previous point for other ``who.ini`` changes. +v2.2.3 2015-07-22 +================= + +Bug fixes: + * Allow uppercase emails on user invites (#2415) + * Fix broken boolean validator (#2443) + * Fix auth check in resources_list.html (#2037) + * Key error on resource proxy (#2425) + * Ignore revision_id passed to resources (#2340) + * Add reset for reset_key on successful password change (#2379) + v2.2.2 2015-03-04 ================= @@ -562,6 +634,15 @@ Troubleshooting: leaving the fields empty. Also make sure to restart running processes like harvesters after the update to make sure they use the new code base. +v2.1.5 2015-07-22 +================= + +Bug fixes: + * Fix broken boolean validator (#2443) + * Key error on resource proxy (#2425) + * Ignore revision_id passed to resources (#2340) + * Add reset for reset_key on successful password change (#2379) + v2.1.4 2015-03-04 ================= @@ -720,6 +801,15 @@ Known issues: * Under certain authorization setups the frontend for the groups functionality may not work as expected (See #1176 #1175). +v2.0.7 2015-07-22 +================= + +Bug fixes: + * Fix broken boolean validator (#2443) + * Key error on resource proxy (#2425) + * Ignore revision_id passed to resources (#2340) + * Add reset for reset_key on successful password change (#2379) + v2.0.6 2015-03-04 ================= From 7e797b7a53181feacfda1cfaeceb96eaed3531bc Mon Sep 17 00:00:00 2001 From: amercader Date: Thu, 23 Jul 2015 10:22:59 +0100 Subject: [PATCH 088/130] [#2553] Fix autodetect for tsv resources When you upload or link to a TSV file and don't specify a resource format, the format that you end up with is text/tab-separated-values. This is not recognized by the DataPusher and the resource is not uploaded to the DataStore. This patch adds this alternative representation to the canonical resource format list. --- ckan/config/resource_formats.json | 2 +- ckan/tests/lib/test_helpers.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/ckan/config/resource_formats.json b/ckan/config/resource_formats.json index a83dc8b3e37..0cf6fb92eaa 100644 --- a/ckan/config/resource_formats.json +++ b/ckan/config/resource_formats.json @@ -18,7 +18,7 @@ ["MDB", "Access Database", "application/x-msaccess", []], ["NetCDF", "NetCDF File", "application/netcdf", []], ["ArcGIS Map Service", "ArcGIS Map Service", "ArcGIS Map Service", ["arcgis map service"]], - ["TSV", "Tab Separated Values File", "text/tsv", []], + ["TSV", "Tab Separated Values File", "text/tsv", ["text/tab-separated-values"]], ["WFS", "Web Feature Service", null, []], ["ArcGIS Online Map", "ArcGIS Online Map", "ArcGIS Online Map", ["web map application"]], ["Perl", "Perl Script", "text/x-perl", []], diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index a1175dddc20..69e3f48b97b 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -109,3 +109,12 @@ def test_includes_existing_license(self): eq_(dict(licenses)['some-old-license'], 'some-old-license') # and it is first on the list eq_(licenses[0][0], 'some-old-license') + + +class TestResourceFormat(object): + + def test_autodetect_tsv(self): + + eq_(h.unified_resource_format('tsv'), 'TSV') + + eq_(h.unified_resource_format('text/tab-separated-values'), 'TSV') From 099b571b5062ef77e1f51c5e8103826e29542462 Mon Sep 17 00:00:00 2001 From: amercader Date: Mon, 27 Jul 2015 12:52:13 +0100 Subject: [PATCH 089/130] [#2560] Remove rdf/xml and n3 templates in favour of ckanext-dcat --- CHANGELOG.rst | 11 ++++ ckan/lib/cli.py | 8 ++- ckan/plugins/interfaces.py | 12 ++--- ckan/templates/package/read.n3 | 45 ---------------- ckan/templates/package/read.rdf | 71 -------------------------- ckan/templates/package/read_base.html | 5 -- ckan/tests/controllers/test_package.py | 12 ++--- 7 files changed, 27 insertions(+), 137 deletions(-) delete mode 100644 ckan/templates/package/read.n3 delete mode 100644 ckan/templates/package/read.rdf diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0b9bb641ffe..7eaf4b96421 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,17 @@ Changelog --------- +v2.5.0 XXXX-XX-XX +================= + +Changes and deprecations +------------------------ + +* The old RDF templates to output a dataset in RDF/XML or N3 format have been + removed. These can be now enabled using the ``dcat`` plugin on *ckanext-dcat*: + + https://github.com/ckan/ckanext-dcat#rdf-dcat-endpoints + v2.4.0 2015-07-22 ================= diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index d6fed92d8f0..b7ad921a73e 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -652,7 +652,13 @@ def export_datasets(self, out_folder): url = urlparse.urljoin(fetch_url, url[1:]) + '.rdf' try: fname = os.path.join(out_folder, dd['name']) + ".rdf" - r = urllib2.urlopen(url).read() + try: + r = urllib2.urlopen(url).read() + except urllib2.HTTPError, e: + if e.code == 404: + print ('Please install ckanext-dcat and enable the ' + + '`dcat` plugin to use the RDF serializations') + sys.exit(1) with open(fname, 'wb') as f: f.write(r) except IOError, ioe: diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index d9a6b0ec8c3..eb0ff5da119 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -1056,13 +1056,11 @@ def read_template(self): The path should be relative to the plugin's templates dir, e.g. ``'package/read.html'``. - If the user requests the dataset in a format other than HTML - (CKAN supports returning datasets in RDF/XML or N3 format by appending - .rdf or .n3 to the dataset read URL, see - :doc:`/maintaining/linked-data-and-rdf`) then CKAN will try to render a - template file with the same path as returned by this function, but a - different filename extension, e.g. ``'package/read.rdf'``. If your - extension doesn't have this RDF version of the template file, the user + If the user requests the dataset in a format other than HTML, then + CKAN will try to render a template file with the same path as returned + by this function, but a different filename extension, + e.g. ``'package/read.rdf'``. If your extension (or another one) + does not provide this version of the template file, the user will get a 404 error. :rtype: string diff --git a/ckan/templates/package/read.n3 b/ckan/templates/package/read.n3 deleted file mode 100644 index ca2751e7f65..00000000000 --- a/ckan/templates/package/read.n3 +++ /dev/null @@ -1,45 +0,0 @@ -@prefix : . -@prefix dcat: . -@prefix dct: . -@prefix foaf: . -@prefix owl: . -@prefix rdf: . - -<{{ h.url_for(controller='package',action='read',id=c.pkg_dict['name'], qualified=True)}}> -a dcat:Dataset; - dct:description "{{c.pkg_dict['notes']}}"; - dct:identifier "{{c.pkg_dict['name']}}"; - dct:relation [ - rdf:value ""; - :label "change_note" ], - [ - rdf:value ""; - :label "definition_note" ], - [ - rdf:value ""; - :label "editorial_note" ], - [ - rdf:value ""; - :label "example_note" ], - [ - rdf:value ""; - :label "history_note" ], - [ - rdf:value ""; - :label "scope_note" ], - [ - rdf:value ""; - :label "skos_note" ], - [ - rdf:value ""; - :label "temporal_granularity" ], - [ - rdf:value ""; - :label "type_of_dataset" ], - [ - rdf:value ""; - :label "update_frequency" ]; - dct:title "{{c.pkg_dict['title']}}"; - :label "{{c.pkg_dict['name']}}"; - = ; - foaf:homepage <{{ h.url_for(controller='package',action='read',id=c.pkg_dict['name'], qualified=True)}}> . \ No newline at end of file diff --git a/ckan/templates/package/read.rdf b/ckan/templates/package/read.rdf deleted file mode 100644 index 934c3893aaa..00000000000 --- a/ckan/templates/package/read.rdf +++ /dev/null @@ -1,71 +0,0 @@ - - - - - {{c.pkg_dict['notes']}} - {% for tag_dict in c.pkg_dict['tags'] %} - {{ tag_dict["name"] }} - {% endfor %} - - {{c.pkg_dict['name']}} - - {{c.pkg_dict['name']}} - {{c.pkg_dict['title']}} - {% for rsc_dict in c.pkg_dict['resources'] %} - - - - {% if rsc_dict.get('format')%} - - - {{rsc_dict.get('format')}} - {{rsc_dict.get('format')}} - - - {% endif %} - {% if rsc_dict.get('name')%}{{rsc_dict.get('name')}}{% endif %} - - - {% endfor %} - {% if c.pkg_dict.get('author', None) %} - - - {{ c.pkg_dict['author'] }} - {% if c.pkg_dict.get('maintainer_email', None)%} - - {% endif %} - - - {% endif %} - {% if c.pkg_dict.get('maintainer', None)%} - - - {{ c.pkg_dict['maintainer'] }} - {% if c.pkg_dict.get('maintainer_email', None) %} - - {% endif %} - - - {% endif %} - - {% if c.pkg_dict.get('license_url', None) %} - - {% endif %} - - {% for extra_dict in c.pkg_dict.get('extras',None) %} - - - {{extra_dict.get('key','')}} - {{extra_dict.get('value','')}} - - - {% endfor %} - - diff --git a/ckan/templates/package/read_base.html b/ckan/templates/package/read_base.html index 4fc10392d35..e4a966b090a 100644 --- a/ckan/templates/package/read_base.html +++ b/ckan/templates/package/read_base.html @@ -2,11 +2,6 @@ {% block subtitle %}{{ pkg.title or pkg.name }} - {{ super() }}{% endblock %} -{% block links -%} - {{ super() }} - -{% endblock -%} - {% block head_extras -%} {{ super() }} {% set description = h.markdown_extract(pkg.notes, extract_length=200)|forceescape %} diff --git a/ckan/tests/controllers/test_package.py b/ckan/tests/controllers/test_package.py index 11859e27ea6..6741311d512 100644 --- a/ckan/tests/controllers/test_package.py +++ b/ckan/tests/controllers/test_package.py @@ -353,26 +353,22 @@ def test_read(self): response.mustcontain('Just another test dataset') def test_read_rdf(self): + ''' The RDF outputs now live in ckanext-dcat''' dataset1 = factories.Dataset() offset = url_for(controller='package', action='read', id=dataset1['name']) + ".rdf" app = self._get_test_app() - res = app.get(offset, status=200) - - assert 'dcat' in res, res - assert '{{' not in res, res + app.get(offset, status=404) def test_read_n3(self): + ''' The RDF outputs now live in ckanext-dcat''' dataset1 = factories.Dataset() offset = url_for(controller='package', action='read', id=dataset1['name']) + ".n3" app = self._get_test_app() - res = app.get(offset, status=200) - - assert 'dcat' in res, res - assert '{{' not in res, res + app.get(offset, status=404) class TestPackageDelete(helpers.FunctionalTestBase): From b05e4bf0bdcc5d0693a8de016b314d497e7a579c Mon Sep 17 00:00:00 2001 From: amercader Date: Mon, 27 Jul 2015 12:52:35 +0100 Subject: [PATCH 090/130] [#2560] Remove prehistoric code --- ckan/lib/cli.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index b7ad921a73e..c7831b4e313 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -185,11 +185,9 @@ class ManageDb(CkanCommand): db upgrade [version no.] - Data migrate db version - returns current version of data schema db dump FILE_PATH - dump to a pg_dump file - db dump-rdf DATASET_NAME FILE_PATH db simple-dump-csv FILE_PATH - dump just datasets in CSV format db simple-dump-json FILE_PATH - dump just datasets in JSON format db user-dump-csv FILE_PATH - dump user information to a CSV file - db send-rdf TALIS_STORE USERNAME PASSWORD db load FILE_PATH - load a pg_dump from a file db load-only FILE_PATH - load a pg_dump from a file but don\'t do the schema upgrade or search indexing @@ -244,16 +242,12 @@ def command(self): self.simple_dump_csv() elif cmd == 'simple-dump-json': self.simple_dump_json() - elif cmd == 'dump-rdf': - self.dump_rdf() elif cmd == 'user-dump-csv': self.user_dump_csv() elif cmd == 'create-from-model': model.repo.create_db() if self.verbose: print 'Creating DB: SUCCESS' - elif cmd == 'send-rdf': - self.send_rdf() elif cmd == 'migrate-filestore': self.migrate_filestore() else: @@ -351,23 +345,6 @@ def simple_dump_json(self): dump_file = open(dump_filepath, 'w') dumper.SimpleDumper().dump(dump_file, format='json') - def dump_rdf(self): - if len(self.args) < 3: - print 'Need dataset name and rdf file path' - return - package_name = self.args[1] - rdf_path = self.args[2] - import ckan.model as model - import ckan.lib.rdf as rdf - pkg = model.Package.by_name(unicode(package_name)) - if not pkg: - print 'Dataset name "%s" does not exist' % package_name - return - rdf = rdf.RdfExporter().export_package(pkg) - f = open(rdf_path, 'w') - f.write(rdf) - f.close() - def user_dump_csv(self): if len(self.args) < 2: print 'Need csv file path' @@ -377,17 +354,6 @@ def user_dump_csv(self): dump_file = open(dump_filepath, 'w') dumper.UserDumper().dump(dump_file) - def send_rdf(self): - if len(self.args) < 4: - print 'Need all arguments: {talis-store} {username} {password}' - return - talis_store = self.args[1] - username = self.args[2] - password = self.args[3] - import ckan.lib.talis - talis = ckan.lib.talis.Talis() - return talis.send_rdf(talis_store, username, password) - def migrate_filestore(self): from ckan.model import Session import requests From 4afcd7c8412817c27632601f5e97a9d11bb76db0 Mon Sep 17 00:00:00 2001 From: amercader Date: Mon, 27 Jul 2015 12:53:06 +0100 Subject: [PATCH 091/130] [#2560] Update RDF documentation to point to ckanext-dcat --- doc/maintaining/linked-data-and-rdf.rst | 100 ++++-------------------- 1 file changed, 16 insertions(+), 84 deletions(-) diff --git a/doc/maintaining/linked-data-and-rdf.rst b/doc/maintaining/linked-data-and-rdf.rst index 89dd98b18d6..80676ee9f25 100644 --- a/doc/maintaining/linked-data-and-rdf.rst +++ b/doc/maintaining/linked-data-and-rdf.rst @@ -2,93 +2,25 @@ Linked Data and RDF =================== -CKAN has extensive support for linked data and RDF. In particular, there is -complete and functional mapping of the CKAN dataset schema to linked data -formats. +Linked data and RDF features for CKAN are provided by the ckanext-dcat extension: +https://github.com/ckan/ckanext-dcat -Enabling and Configuring Linked Data Support -============================================ +These features include the RDF serializations of CKAN datasets based on `DCAT`_, that used to be generated +using templates hosted on the main CKAN repo, eg: -In CKAN <= 1.6 please install the RDF extension: https://github.com/okfn/ckanext-rdf +* http://demo.ckan.org/dataset/newcastle-city-council-payments-over-500.xml +* http://demo.ckan.org/dataset/newcastle-city-council-payments-over-500.ttl +* http://demo.ckan.org/dataset/newcastle-city-council-payments-over-500.n3 +* http://demo.ckan.org/dataset/newcastle-city-council-payments-over-500.jsonld -In CKAN >= 1.7, basic RDF support will be available directly in core. +ckanext-dcat offers many more `features `_, +including catalog-wide endpoints and harvesters to import RDF data into CKAN. Please check +its documentation to know more about -Configuration -------------- +As of CKAN 2.5, the RDF templates have been moved out of CKAN core in favour of the ckanext-dcat +customizable `endpoints`_. Note that previous CKAN versions can still use the ckanext-dcat +RDF representations, which will override the old ones served by CKAN core. -When using the built-in RDF support (CKAN >= 1.7) there is no configuration required. By default requests for RDF data will return the RDF generated from the built-in 'packages/read.rdf' template, which can be overridden using the extra-templates directive. - -Accessing Linked Data -===================== - -To access linked data versions just access the :doc:`/api/index` in the usual -way but set the Accept header to the format you would like to be returned. For -example:: - - curl -L -H "Accept: application/rdf+xml" http://thedatahub.org/dataset/gold-prices - curl -L -H "Accept: text/n3" http://thedatahub.org/dataset/gold-prices - -An alternative method of retrieving the data is to add .rdf to the name of the dataset to download:: - - curl -L http://thedatahub.org/dataset/gold-prices.rdf - curl -L http://thedatahub.org/dataset/gold-prices.n3 - - -Schema Mapping -============== - -There are various vocabularies that can be used for describing datasets: - -* Dublin core: these are the most well-known and basic. Dublin core terms includes the class *dct:Dataset*. -* DCAT_ - vocabulary for catalogues of datasets -* VoID_ - vocabulary of interlinked datasets. Specifically designed for describing *rdf* datasets. Perfect except for the fact that it is focused on RDF -* SCOVO_: this is more oriented to statistical datasets but has a *scovo:Dataset* class. - -At the present CKAN uses mostly DCAT and Dublin Core. - -.. _DCAT: http://vocab.deri.ie/dcat -.. _VoID: http://rdfs.org/ns/void -.. _SCOVO: http://sw.joanneum.at/scovo/schema.html - -An example schema might look like:: - - - - - Shark attacks worldwide - sharks - worldwide - - worldwide-shark-attacks - worldwide-shark-attacks - Worldwide Shark Attacks - - - - - - - - - - - - - Ross - - - - - - Ross - - - - - - +.. _DCAT: http://www.w3.org/TR/vocab-dcat/ +.. _endpoints: https://github.com/ckan/ckanext-dcat#rdf-dcat-endpoints From 884040e10b22042dc2a974a5969d0d25eb26a575 Mon Sep 17 00:00:00 2001 From: Laurent Goderre Date: Tue, 28 Jul 2015 10:15:55 -0400 Subject: [PATCH 092/130] Removed the main.debug.css Resolves #2556 --- bin/less | 4 ---- ckan/lib/app_globals.py | 8 ++------ doc/contributing/frontend/index.rst | 5 +---- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/bin/less b/bin/less index 452dcc81e15..f097555657f 100755 --- a/bin/less +++ b/bin/less @@ -15,10 +15,6 @@ function compile(event, filename) { var start = Date.now(), filename = 'main.css'; - if (debug) { - filename = 'main.debug.css'; - } - exec('`npm bin`/lessc ' + __dirname + '/../ckan/public/base/less/main.less > ' + __dirname + '/../ckan/public/base/css/' + filename, function (err, stdout, stderr) { var duration = Date.now() - start; diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index d7b0a67dbce..f7086a73213 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -73,13 +73,9 @@ _CONFIG_CACHE = {} def set_main_css(css_file): - ''' Sets the main_css using debug css if needed. The css_file - must be of the form file.css ''' + ''' Sets the main_css. The css_file must be of the form file.css ''' assert css_file.endswith('.css') - if config.get('debug') and css_file == '/base/css/main.css': - new_css = '/base/css/main.debug.css' - else: - new_css = css_file + new_css = css_file # FIXME we should check the css file exists app_globals.main_css = str(new_css) diff --git a/doc/contributing/frontend/index.rst b/doc/contributing/frontend/index.rst index 50906162a45..501b591e981 100644 --- a/doc/contributing/frontend/index.rst +++ b/doc/contributing/frontend/index.rst @@ -129,10 +129,7 @@ Stylesheets ----------- Because all the stylesheets are using LESS we need to compile them -before beginning development. In production CKAN will look for the -``main.css`` file which is included in the repository. In development -CKAN looks for the file ``main.debug.css`` which you will need to -generate by running: +before beginning development by running: :: From 7873851900678fbb9559c4ea6058a66bab67928b Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 31 Jul 2015 11:53:31 +0100 Subject: [PATCH 093/130] [#1903] Rename migration script to avoid conlfict with a more recent one --- ...77_remove_old_authz_model.py => 078_remove_old_authz_model.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ckan/migration/versions/{077_remove_old_authz_model.py => 078_remove_old_authz_model.py} (100%) diff --git a/ckan/migration/versions/077_remove_old_authz_model.py b/ckan/migration/versions/078_remove_old_authz_model.py similarity index 100% rename from ckan/migration/versions/077_remove_old_authz_model.py rename to ckan/migration/versions/078_remove_old_authz_model.py From 0b318d5ed5fad76c83f8ca3dcd12b70b17298b27 Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 31 Jul 2015 11:58:44 +0100 Subject: [PATCH 094/130] [#1903] Remove unused variables --- ckan/logic/action/create.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index 8e546140995..27f9385d411 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -177,11 +177,9 @@ def package_create(context, data_dict): else: rev.message = _(u'REST API: Create object %s') % data.get("name") - admins = [] if user: user_obj = model.User.by_name(user.decode('utf8')) if user_obj: - admins = [user_obj] data['creator_user_id'] = user_obj.id pkg = model_save.package_dict_save(data, context) @@ -726,11 +724,6 @@ def _group_or_org_create(context, data_dict, is_org=False): group = model_save.group_dict_save(data, context) - if user: - admins = [model.User.by_name(user.decode('utf8'))] - else: - admins = [] - # Needed to let extensions know the group id session.flush() From 4f5925962702bfc98ca52e36ac6a61f3db311665 Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 31 Jul 2015 12:47:22 +0100 Subject: [PATCH 095/130] [#1903] There are no pseudousers any more --- ckan/tests/controllers/test_user.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py index a785dbe9435..e8e3fe8eebf 100644 --- a/ckan/tests/controllers/test_user.py +++ b/ckan/tests/controllers/test_user.py @@ -414,8 +414,7 @@ def test_user_page_lists_users(self): user_response_html = BeautifulSoup(user_response.body) user_list = user_response_html.select('ul.user-list li') - # two pseudo users + the users we've added - assert_equal(len(user_list), 2 + 3) + assert_equal(len(user_list), 3) user_names = [u.text.strip() for u in user_list] assert_true('User One' in user_names) @@ -434,8 +433,7 @@ def test_user_page_doesnot_list_deleted_users(self): user_response_html = BeautifulSoup(user_response.body) user_list = user_response_html.select('ul.user-list li') - # two pseudo users + the users we've added - assert_equal(len(user_list), 2 + 2) + assert_equal(len(user_list), 2) user_names = [u.text.strip() for u in user_list] assert_true('User One' not in user_names) From a7804f9f1db1b6ef8f282a2bb76d69e657f1b222 Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Tue, 4 Aug 2015 17:02:09 +0100 Subject: [PATCH 096/130] [#2557] Fix package count in templates. This happened when datasets stopped being included by default in #2206. --- ckan/templates/group/snippets/info.html | 2 +- ckan/templates/organization/snippets/organization_item.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ckan/templates/group/snippets/info.html b/ckan/templates/group/snippets/info.html index d5ce6b6e0fb..41a87fc30d2 100644 --- a/ckan/templates/group/snippets/info.html +++ b/ckan/templates/group/snippets/info.html @@ -34,7 +34,7 @@

{{ _('Datasets') }}
-
{{ h.SI_number_span(group.packages|length) }}
+
{{ h.SI_number_span(group.package_count) }}
{% endblock %} diff --git a/ckan/templates/organization/snippets/organization_item.html b/ckan/templates/organization/snippets/organization_item.html index 1a9b4513274..4a9f1b6193d 100644 --- a/ckan/templates/organization/snippets/organization_item.html +++ b/ckan/templates/organization/snippets/organization_item.html @@ -27,8 +27,8 @@

{{ organization.display_name }}

{% endif %} {% endblock %} {% block datasets %} - {% if organization.packages %} - {{ ungettext('{num} Dataset', '{num} Datasets', organization.packages).format(num=organization.packages) }} + {% if organization.package_count %} + {{ ungettext('{num} Dataset', '{num} Datasets', organization.package_count).format(num=organization.package_count) }} {% else %} {{ _('0 Datasets') }} {% endif %} From 13b8befcd8a8eed566509179a09f3d31d1a2b60d Mon Sep 17 00:00:00 2001 From: Brook Elgie Date: Tue, 4 Aug 2015 17:03:46 +0100 Subject: [PATCH 097/130] [#2557] Featured grps/orgs should include datasets `include_datasets` now defaults to False (#2206), so need to include it when getting the featured groups and orgs for the index page. --- ckan/lib/helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 6d287f72ad1..200b294286d 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -1921,7 +1921,8 @@ def get_group(id): context = {'ignore_auth': True, 'limits': {'packages': 2}, 'for_view': True} - data_dict = {'id': id} + data_dict = {'id': id, + 'include_datasets': True} try: out = logic.get_action(get_action)(context, data_dict) From c84194cd55134880710b91dc40c50ea77c202b44 Mon Sep 17 00:00:00 2001 From: Ross Jones Date: Thu, 6 Aug 2015 16:51:10 +0100 Subject: [PATCH 098/130] Introduces IDataPusher plugin This new plugin allows users to reject resources for submission, and also to be notified once a datapush is complete. The two methods in the interface are: can_upload: which will abort datapusher_submit calls if they return True. after_upload: Called with the resource ID after the upload into the datastore is complete. --- ckanext/datapusher/interfaces.py | 44 ++++++ ckanext/datapusher/logic/action.py | 12 ++ ckanext/datapusher/plugin.py | 5 +- ckanext/datapusher/tests/test_interfaces.py | 151 ++++++++++++++++++++ setup.py | 1 + 5 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 ckanext/datapusher/interfaces.py create mode 100644 ckanext/datapusher/tests/test_interfaces.py diff --git a/ckanext/datapusher/interfaces.py b/ckanext/datapusher/interfaces.py new file mode 100644 index 00000000000..6521de6f943 --- /dev/null +++ b/ckanext/datapusher/interfaces.py @@ -0,0 +1,44 @@ +from ckan.plugins.interfaces import Interface + + +class IDataPusher(Interface): + """ + The IDataPusher interface allows plugin authors to receive notifications + before and after a resource is submitted to the datapusher service, as + well as determining whether a resource should be submitted in can_upload + + The before_submit function, when implemented + """ + + def can_upload(self, resource_id): + """ This call when implemented can be used to stop the processing of + the datapusher submit function. This method will not be called if + the resource format does not match those defined in the + ckan.datapusher.formats config option or the default formats. + + If this function returns False then processing will be aborted, + whilst returning True will submit the resource to the datapusher + service + + :param resource_id: The ID of the resource that is to be + pushed to the datapusher service. + + Returns ``True`` if the job should be submitted and ``False`` if + the job should be aborted + + :rtype: bool + """ + return True + + def after_upload(self, context, resource_dict, dataset_dict): + """ After a resource has been successfully upload to the datastore + this method will be called with the resource dictionary and the + package dictionary for this resource. + + :param context: The context within which the upload happened + :param resource_dict: The dict represenstaion of the resource that was + successfully uploaded to the datastore + :param dataset_dict: The dict represenstation of the dataset containing + the resource that was uploaded + """ + pass diff --git a/ckanext/datapusher/logic/action.py b/ckanext/datapusher/logic/action.py index 1af23c34e40..d79c616165c 100644 --- a/ckanext/datapusher/logic/action.py +++ b/ckanext/datapusher/logic/action.py @@ -10,6 +10,7 @@ import ckan.logic as logic import ckan.plugins as p import ckanext.datapusher.logic.schema as dpschema +import ckanext.datapusher.interfaces as interfaces log = logging.getLogger(__name__) _get_or_bust = logic.get_or_bust @@ -53,6 +54,14 @@ def datapusher_submit(context, data_dict): user = p.toolkit.get_action('user_show')(context, {'id': context['user']}) + for plugin in p.PluginImplementations(interfaces.IDataPusher): + upload = plugin.can_upload(res_id) + if not upload: + msg = "Plugin {0} rejected resource {1}"\ + .format(plugin.__class__.__name__, res_id) + log.info(msg) + return False + task = { 'entity_id': res_id, 'entity_type': 'resource', @@ -166,6 +175,9 @@ def datapusher_hook(context, data_dict): dataset_dict = p.toolkit.get_action('package_show')( context, {'id': resource_dict['package_id']}) + for plugin in p.PluginImplementations(interfaces.IDataPusher): + plugin.after_upload(context, resource_dict, dataset_dict) + logic.get_action('resource_create_default_resource_views')( context, { diff --git a/ckanext/datapusher/plugin.py b/ckanext/datapusher/plugin.py index f2b9f597114..a3b678f83e6 100644 --- a/ckanext/datapusher/plugin.py +++ b/ckanext/datapusher/plugin.py @@ -97,8 +97,8 @@ def configure(self, config): def notify(self, entity, operation=None): if isinstance(entity, model.Resource): - if (operation == model.domain_object.DomainObjectOperation.new - or not operation): + if (operation == model.domain_object.DomainObjectOperation.new or + not operation): # if operation is None, resource URL has been changed, as # the notify function in IResourceUrlChange only takes # 1 parameter @@ -107,6 +107,7 @@ def notify(self, entity, operation=None): if (entity.format and entity.format.lower() in self.datapusher_formats and entity.url_type != 'datapusher'): + try: p.toolkit.get_action('datapusher_submit')(context, { 'resource_id': entity.id diff --git a/ckanext/datapusher/tests/test_interfaces.py b/ckanext/datapusher/tests/test_interfaces.py new file mode 100644 index 00000000000..dff6ed36ea3 --- /dev/null +++ b/ckanext/datapusher/tests/test_interfaces.py @@ -0,0 +1,151 @@ +import json +import httpretty +import nose +import sys +import datetime + +from nose.tools import raises +from pylons import config +import sqlalchemy.orm as orm +import paste.fixture + +from ckan.tests import helpers, factories +import ckan.plugins as p +import ckan.model as model +import ckan.tests.legacy as tests +import ckan.config.middleware as middleware + +import ckanext.datapusher.interfaces as interfaces +import ckanext.datastore.db as db +from ckanext.datastore.tests.helpers import rebuild_all_dbs + + +# avoid hanging tests https://github.com/gabrielfalcao/HTTPretty/issues/34 +if sys.version_info < (2, 7, 0): + import socket + socket.setdefaulttimeout(1) + + +class FakeDataPusherPlugin(p.SingletonPlugin): + p.implements(p.IConfigurable, inherit=True) + p.implements(interfaces.IDataPusher, inherit=True) + + def configure(self, config): + self.after_upload_calls = 0 + + def can_upload(self, resource_id): + return False + + def after_upload(self, context, resource_dict, package_dict): + self.after_upload_calls += 1 + + +class TestInterace(object): + sysadmin_user = None + normal_user = None + + @classmethod + def setup_class(cls): + wsgiapp = middleware.make_app(config['global_conf'], **config) + cls.app = paste.fixture.TestApp(wsgiapp) + if not tests.is_datastore_supported(): + raise nose.SkipTest("Datastore not supported") + p.load('datastore') + p.load('datapusher') + p.load('test_datapusher_plugin') + + resource = factories.Resource(url_type='datastore') + cls.dataset = factories.Dataset(resources=[resource]) + + cls.sysadmin_user = factories.User(name='testsysadmin', sysadmin=True) + cls.normal_user = factories.User(name='annafan') + engine = db._get_engine( + {'connection_url': config['ckan.datastore.write_url']}) + cls.Session = orm.scoped_session(orm.sessionmaker(bind=engine)) + + @classmethod + def teardown_class(cls): + rebuild_all_dbs(cls.Session) + + p.unload('datastore') + p.unload('datapusher') + p.unload('test_datapusher_plugin') + + @httpretty.activate + @raises(p.toolkit.ObjectNotFound) + def test_send_datapusher_creates_task(self): + httpretty.HTTPretty.register_uri( + httpretty.HTTPretty.POST, + 'http://datapusher.ckan.org/job', + content_type='application/json', + body=json.dumps({'job_id': 'foo', 'job_key': 'bar'})) + + resource = self.dataset['resources'][0] + + context = { + 'ignore_auth': True, + 'user': self.sysadmin_user['name'] + } + + result = p.toolkit.get_action('datapusher_submit')(context, { + 'resource_id': resource['id'] + }) + assert not result + + context.pop('task_status', None) + + # We expect this to raise a NotFound exception + task = p.toolkit.get_action('task_status_show')(context, { + 'entity_id': resource['id'], + 'task_type': 'datapusher', + 'key': 'datapusher' + }) + + def test_after_upload_called(self): + dataset = factories.Dataset() + resource = factories.Resource(package_id=dataset['id']) + + # Push data directly to the DataStore for the resource to be marked as + # `datastore_active=True`, so the grid view can be created + data = { + 'resource_id': resource['id'], + 'fields': [{'id': 'a', 'type': 'text'}, + {'id': 'b', 'type': 'text'}], + 'records': [{'a': '1', 'b': '2'}, ], + 'force': True, + } + helpers.call_action('datastore_create', **data) + + # Create a task for `datapusher_hook` to update + task_dict = { + 'entity_id': resource['id'], + 'entity_type': 'resource', + 'task_type': 'datapusher', + 'key': 'datapusher', + 'value': '{"job_id": "my_id", "job_key":"my_key"}', + 'last_updated': str(datetime.datetime.now()), + 'state': 'pending' + } + helpers.call_action('task_status_update', context={}, **task_dict) + + # Call datapusher_hook with a status of complete to trigger the + # default views creation + params = { + 'status': 'complete', + 'metadata': {'resource_id': resource['id']} + } + helpers.call_action('datapusher_hook', context={}, **params) + + total = sum(plugin.after_upload_calls for plugin + in p.PluginImplementations(interfaces.IDataPusher)) + assert total == 1, total + + params = { + 'status': 'complete', + 'metadata': {'resource_id': resource['id']} + } + helpers.call_action('datapusher_hook', context={}, **params) + + total = sum(plugin.after_upload_calls for plugin + in p.PluginImplementations(interfaces.IDataPusher)) + assert total == 2, total diff --git a/setup.py b/setup.py index b31d07fe134..52db9b1ec66 100644 --- a/setup.py +++ b/setup.py @@ -148,6 +148,7 @@ 'test_json_resource_preview = tests.legacy.ckantestplugins:JsonMockResourcePreviewExtension', 'sample_datastore_plugin = ckanext.datastore.tests.sample_datastore_plugin:SampleDataStorePlugin', 'test_datastore_view = ckan.tests.lib.test_datapreview:MockDatastoreBasedResourceView', + 'test_datapusher_plugin = ckanext.datapusher.tests.test_interfaces:FakeDataPusherPlugin', ], 'babel.extractors': [ 'ckan = ckan.lib.extract:extract_ckan', From 9d3311a315be8d898cfa15243edca447548eb897 Mon Sep 17 00:00:00 2001 From: Ross Jones Date: Wed, 12 Aug 2015 13:07:18 +0100 Subject: [PATCH 099/130] Checks for duplicate column names Before the attempt is made to create the table, checks that there are no duplicate column names in the record sent to the action method. --- ckanext/datastore/db.py | 7 +++++++ ckanext/datastore/tests/test_create.py | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ckanext/datastore/db.py b/ckanext/datastore/db.py index 1121c9e3793..f686d09500f 100644 --- a/ckanext/datastore/db.py +++ b/ckanext/datastore/db.py @@ -316,6 +316,13 @@ def create_table(context, data_dict): }) field['type'] = _guess_type(records[0][field['id']]) + # Check for duplicate fields + unique_fields = set([f['id'] for f in supplied_fields]) + if not len(unique_fields) == len(supplied_fields): + raise ValidationError({ + 'field': ['Duplicate column names are not supported'] + }) + if records: # check record for sanity if not isinstance(records[0], dict): diff --git a/ckanext/datastore/tests/test_create.py b/ckanext/datastore/tests/test_create.py index 49a7f069c6c..b96dca74129 100644 --- a/ckanext/datastore/tests/test_create.py +++ b/ckanext/datastore/tests/test_create.py @@ -1,7 +1,7 @@ import json import nose import sys -from nose.tools import assert_equal +from nose.tools import assert_equal, raises import pylons from pylons import config @@ -138,6 +138,21 @@ def test_create_doesnt_add_more_indexes_when_updating_data(self): current_index_names = self._get_index_names(resource['id']) assert_equal(previous_index_names, current_index_names) + @raises(p.toolkit.ValidationError) + def test_create_duplicate_fields(self): + package = factories.Dataset() + data = { + 'resource': { + 'book': 'crime', + 'author': ['tolstoy', 'dostoevsky'], + 'package_id': package['id'] + }, + 'fields': [{'id': 'book', 'type': 'text'}, + {'id': 'book', 'type': 'text'}], + } + result = helpers.call_action('datastore_create', **data) + + def _has_index_on_field(self, resource_id, field): sql = u""" SELECT From 1cd105796899992372d5c460cc4aca35395ae00a Mon Sep 17 00:00:00 2001 From: amercader Date: Wed, 12 Aug 2015 13:58:37 +0100 Subject: [PATCH 100/130] [#2553] TSV media type is text/tab-separated-values --- ckan/config/resource_formats.json | 2 +- ckan/tests/lib/test_helpers.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ckan/config/resource_formats.json b/ckan/config/resource_formats.json index 0cf6fb92eaa..74452820782 100644 --- a/ckan/config/resource_formats.json +++ b/ckan/config/resource_formats.json @@ -18,7 +18,7 @@ ["MDB", "Access Database", "application/x-msaccess", []], ["NetCDF", "NetCDF File", "application/netcdf", []], ["ArcGIS Map Service", "ArcGIS Map Service", "ArcGIS Map Service", ["arcgis map service"]], - ["TSV", "Tab Separated Values File", "text/tsv", ["text/tab-separated-values"]], + ["TSV", "Tab Separated Values File", "text/tab-separated-values", ["text/tsv"]], ["WFS", "Web Feature Service", null, []], ["ArcGIS Online Map", "ArcGIS Online Map", "ArcGIS Online Map", ["web map application"]], ["Perl", "Perl Script", "text/x-perl", []], diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index 69e3f48b97b..28512e4a2fd 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -118,3 +118,5 @@ def test_autodetect_tsv(self): eq_(h.unified_resource_format('tsv'), 'TSV') eq_(h.unified_resource_format('text/tab-separated-values'), 'TSV') + + eq_(h.unified_resource_format('text/tsv'), 'TSV') From e04fbaf859a587a6bb70d154451f92503f6c5018 Mon Sep 17 00:00:00 2001 From: Alan Tygel Date: Tue, 18 Aug 2015 12:15:25 +0200 Subject: [PATCH 101/130] Update plugin.py The original "packages" sort option was broken ; changed to package_count --- ckanext/example_theme/v08_custom_helper_function/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckanext/example_theme/v08_custom_helper_function/plugin.py b/ckanext/example_theme/v08_custom_helper_function/plugin.py index 5b3707b23b6..3dd3008a941 100644 --- a/ckanext/example_theme/v08_custom_helper_function/plugin.py +++ b/ckanext/example_theme/v08_custom_helper_function/plugin.py @@ -8,7 +8,7 @@ def most_popular_groups(): # Get a list of all the site's groups from CKAN, sorted by number of # datasets. groups = toolkit.get_action('group_list')( - data_dict={'sort': 'packages desc', 'all_fields': True}) + data_dict={'sort': 'package_count desc', 'all_fields': True}) # Truncate the list to the 10 most popular groups only. groups = groups[:10] From 4fd2baf4a2718eded60e560c2930505c00a1cd45 Mon Sep 17 00:00:00 2001 From: amercader Date: Wed, 19 Aug 2015 10:36:27 +0100 Subject: [PATCH 102/130] [#2554] Don't request all extra fields on group_list #2214 replaced the organization/group_list call to group_list_dictize by a organization/group_show call for each group, but didn't pass the include_extras, include_users, etc params set to False, so now on each call of this extra calls are performed by default on all groups. Updated docstrings to include all params --- ckan/logic/action/get.py | 82 +++++++++++++++++++++++------ ckan/tests/logic/action/test_get.py | 13 ++++- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 8e789159299..c44435603ee 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -372,6 +372,8 @@ def _group_or_org_list(context, data_dict, is_org=False): sort = data_dict.get('sort', 'name') q = data_dict.get('q') + all_fields = asbool(data_dict.get('all_fields', None)) + # order_by deprecated in ckan 1.8 # if it is supplied and sort isn't use order_by and raise a warning order_by = data_dict.get('order_by', '') @@ -390,15 +392,7 @@ def _group_or_org_list(context, data_dict, is_org=False): 'package_count', 'title'], total=1) - all_fields = data_dict.get('all_fields', None) - include_extras = all_fields and \ - asbool(data_dict.get('include_extras', False)) - query = model.Session.query(model.Group) - if include_extras: - # this does an eager load of the extras, avoiding an sql query every - # time group_list_dictize accesses a group's extra. - query = query.options(sqlalchemy.orm.joinedload(model.Group._extras)) query = query.filter(model.Group.state == 'active') if groups: query = query.filter(model.Group.name.in_(groups)) @@ -421,6 +415,12 @@ def _group_or_org_list(context, data_dict, is_org=False): group_list = [] for group in groups: data_dict['id'] = group.id + + for key in ('include_extras', 'include_tags', 'include_users', + 'include_groups', 'include_followers'): + if key not in data_dict: + data_dict[key] = False + group_list.append(logic.get_action(action)(context, data_dict)) group_list = sorted(group_list, key=lambda x: x[sort_info[0][0]], @@ -461,6 +461,10 @@ def group_list(context, data_dict): :param include_groups: if all_fields, include the groups the groups are in (optional, default: ``False``). :type include_groups: boolean + :param include_users: if all_fields, include the group users + (optional, default: ``False``). + :type include_users: boolean + :rtype: list of strings @@ -490,15 +494,19 @@ def organization_list(context, data_dict): packages in the `package_count` property. (optional, default: ``False``) :type all_fields: boolean - :param include_extras: if all_fields, include the group extra fields + :param include_extras: if all_fields, include the organization extra fields (optional, default: ``False``) :type include_extras: boolean - :param include_tags: if all_fields, include the group tags + :param include_tags: if all_fields, include the organization tags (optional, default: ``False``) :type include_tags: boolean - :param include_groups: if all_fields, include the groups the groups are in + :param include_groups: if all_fields, include the organizations the + organizations are in (optional, default: ``False``) :type all_fields: boolean + :param include_users: if all_fields, include the organization users + (optional, default: ``False``). + :type include_users: boolean :rtype: list of strings @@ -1160,6 +1168,12 @@ def _group_or_org_show(context, data_dict, is_org=False): include_datasets = asbool(data_dict.get('include_datasets', False)) packages_field = 'datasets' if include_datasets else 'dataset_count' + include_tags = asbool(data_dict.get('include_tags', True)) + include_users = asbool(data_dict.get('include_users', True)) + include_groups = asbool(data_dict.get('include_groups', True)) + include_extras = asbool(data_dict.get('include_extras', True)) + include_followers = asbool(data_dict.get('include_followers', True)) + if group is None: raise NotFound if is_org and not group.is_organization: @@ -1173,7 +1187,11 @@ def _group_or_org_show(context, data_dict, is_org=False): _check_access('group_show', context, data_dict) group_dict = model_dictize.group_dictize(group, context, - packages_field=packages_field) + packages_field=packages_field, + include_tags=include_tags, + include_extras=include_extras, + include_groups=include_groups, + include_users=include_users,) if is_org: plugin_type = plugins.IOrganizationController @@ -1192,9 +1210,12 @@ def _group_or_org_show(context, data_dict, is_org=False): except AttributeError: schema = group_plugin.db_to_form_schema() - group_dict['num_followers'] = logic.get_action('group_follower_count')( - {'model': model, 'session': model.Session}, - {'id': group_dict['id']}) + if include_followers: + group_dict['num_followers'] = logic.get_action('group_follower_count')( + {'model': model, 'session': model.Session}, + {'id': group_dict['id']}) + else: + group_dict['num_followers'] = 0 if schema is None: schema = logic.schema.default_show_group_schema() @@ -1212,6 +1233,21 @@ def group_show(context, data_dict): :param include_datasets: include a list of the group's datasets (optional, default: ``False``) :type id: boolean + :param include_extras: include the group's extra fields + (optional, default: ``True``) + :type id: boolean + :param include_users: include the group's users + (optional, default: ``True``) + :type id: boolean + :param include_groups: include the group's sub groups + (optional, default: ``True``) + :type id: boolean + :param include_tags: include the group's tags + (optional, default: ``True``) + :type id: boolean + :param include_followers: include the group's number of followers + (optional, default: ``True``) + :type id: boolean :rtype: dictionary @@ -1229,6 +1265,22 @@ def organization_show(context, data_dict): :param include_datasets: include a list of the organization's datasets (optional, default: ``False``) :type id: boolean + :param include_extras: include the organization's extra fields + (optional, default: ``True``) + :type id: boolean + :param include_users: include the organization's users + (optional, default: ``True``) + :type id: boolean + :param include_groups: include the organization's sub groups + (optional, default: ``True``) + :type id: boolean + :param include_tags: include the organization's tags + (optional, default: ``True``) + :type id: boolean + :param include_followers: include the organization's number of followers + (optional, default: ``True``) + :type id: boolean + :rtype: dictionary diff --git a/ckan/tests/logic/action/test_get.py b/ckan/tests/logic/action/test_get.py index f722713e279..8d05e7d9a0a 100644 --- a/ckan/tests/logic/action/test_get.py +++ b/ckan/tests/logic/action/test_get.py @@ -128,8 +128,6 @@ def test_group_list_all_fields(self): expected_group = dict(group.items()[:]) for field in ('users', 'tags', 'extras', 'groups'): - if field in group_list[0]: - del group_list[0][field] del expected_group[field] assert group_list[0] == expected_group @@ -149,6 +147,17 @@ def test_group_list_extras_returned(self): eq(group_list[0]['extras'], group['extras']) eq(group_list[0]['extras'][0]['key'], 'key1') + def test_group_list_users_returned(self): + user = factories.User() + group = factories.Group(users=[{'name': user['name'], + 'capacity': 'admin'}]) + + group_list = helpers.call_action('group_list', all_fields=True, + include_users=True) + + eq(group_list[0]['users'], group['users']) + eq(group_list[0]['users'][0]['name'], group['users'][0]['name']) + # NB there is no test_group_list_tags_returned because tags are not in the # group_create schema (yet) From bbaab15883936ad80d93dd6e978f56a3b799a854 Mon Sep 17 00:00:00 2001 From: amercader Date: Wed, 19 Aug 2015 10:57:00 +0100 Subject: [PATCH 103/130] [#2554] Refactor group_list to only query necessary fields Refactor organization/group_list to only query the necessary fields by default (id, name, title, package_count) depending on the required sort. This massively speeds up the default query without all_fields. In part this is because then we no longer need to get all fields in all groups on all cases to do the sorting. There is a minor drawback in that then we can't take private datasets into account when sorting by number of datasets. The actual number displayed will take private datasets into account, as this comes from the dictization, but there might be inconsistencies (note that the "order by datasets option" is not offered by default on the UI) --- ckan/logic/action/get.py | 61 +++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index c44435603ee..e7d5965a9e9 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -392,8 +392,21 @@ def _group_or_org_list(context, data_dict, is_org=False): 'package_count', 'title'], total=1) - query = model.Session.query(model.Group) + if sort_info and sort_info[0][0] == 'package_count': + query = model.Session.query(model.Group.id, + model.Group.name, + sqlalchemy.func.count(model.Group.id)) + + query = query.filter(model.Member.group_id == model.Group.id) \ + .filter(model.Member.table_id == model.Package.id) \ + .filter(model.Member.table_name == 'package') \ + .filter(model.Package.state == 'active') + else: + query = model.Session.query(model.Group.id, + model.Group.name) + query = query.filter(model.Group.state == 'active') + if groups: query = query.filter(model.Group.name.in_(groups)) if q: @@ -407,27 +420,37 @@ def _group_or_org_list(context, data_dict, is_org=False): query = query.filter(model.Group.is_organization == is_org) if not is_org: query = query.filter(model.Group.type == group_type) + if sort_info: + sort_field = sort_info[0][0] + sort_direction = sort_info[0][1] + if sort_field == 'package_count': + query = query.group_by(model.Group.id, model.Group.name) + sort_model_field = sqlalchemy.func.count(model.Group.id) + elif sort_field == 'name': + sort_model_field = model.Group.name + elif sort_field == 'title': + sort_model_field = model.Group.title + + if sort_direction == 'asc': + query = query.order_by(sqlalchemy.asc(sort_model_field)) + else: + query = query.order_by(sqlalchemy.desc(sort_model_field)) groups = query.all() - action = 'organization_show' if is_org else 'group_show' - - group_list = [] - for group in groups: - data_dict['id'] = group.id - - for key in ('include_extras', 'include_tags', 'include_users', - 'include_groups', 'include_followers'): - if key not in data_dict: - data_dict[key] = False - - group_list.append(logic.get_action(action)(context, data_dict)) - - group_list = sorted(group_list, key=lambda x: x[sort_info[0][0]], - reverse=sort_info[0][1] == 'desc') - - if not all_fields: - group_list = [group[ref_group_by] for group in group_list] + if all_fields: + action = 'organization_show' if is_org else 'group_show' + group_list = [] + for group in groups: + data_dict['id'] = group.id + for key in ('include_extras', 'include_tags', 'include_users', + 'include_groups', 'include_followers'): + if key not in data_dict: + data_dict[key] = False + + group_list.append(logic.get_action(action)(context, data_dict)) + else: + group_list = [getattr(group, ref_group_by) for group in groups] return group_list From 9be68909e4225d4593b558bf37d8b8c2cca66231 Mon Sep 17 00:00:00 2001 From: amercader Date: Wed, 19 Aug 2015 11:34:06 +0100 Subject: [PATCH 104/130] [#2554] Add limit/offset support to group_list So on the Organizations and Groups page we just dictize the groups on the page (we need two calls to group_list in the controller, one with all groups to account for the query, ordering, count, etc and one with `all_fields` with just the ones to be displayed on the listing). --- ckan/controllers/group.py | 34 ++++++++++++++++----- ckan/logic/action/get.py | 34 +++++++++++++++++++-- ckan/tests/controllers/test_group.py | 43 +++++++++++++++++++++++++++ ckan/tests/logic/action/test_get.py | 44 ++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 9 deletions(-) diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index 3377f7bb98b..7c5706b361d 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -150,15 +150,15 @@ def add_group_type(cls, group_type): def index(self): group_type = self._guess_group_type() + page = self._get_page_number(request.params) or 1 + items_per_page = 21 + context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'for_view': True, 'with_private': False} q = c.q = request.params.get('q', '') - data_dict = {'all_fields': True, 'q': q, 'type': group_type or 'group'} sort_by = c.sort_by_selected = request.params.get('sort') - if sort_by: - data_dict['sort'] = sort_by try: self._check_access('site_read', context) except NotAuthorized: @@ -170,14 +170,34 @@ def index(self): context['user_id'] = c.userobj.id context['user_is_admin'] = c.userobj.sysadmin - results = self._action('group_list')(context, data_dict) + data_dict_global_results = { + 'all_fields': False, + 'q': q, + 'sort': sort_by, + 'type': group_type or 'group', + } + global_results = self._action('group_list')(context, + data_dict_global_results) + + data_dict_page_results = { + 'all_fields': True, + 'q': q, + 'sort': sort_by, + 'type': group_type or 'group', + 'limit': items_per_page, + 'offset': items_per_page * (page - 1), + } + page_results = self._action('group_list')(context, + data_dict_page_results) c.page = h.Page( - collection=results, - page = self._get_page_number(request.params), + collection=global_results, + page=page, url=h.pager_url, - items_per_page=21 + items_per_page=items_per_page, ) + + c.page.items = page_results return render(self._index_template(group_type), extra_vars={'group_type': group_type}) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index e7d5965a9e9..0dd002a09e7 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -368,8 +368,19 @@ def _group_or_org_list(context, data_dict, is_org=False): groups = data_dict.get('groups') group_type = data_dict.get('type', 'group') ref_group_by = 'id' if api == 2 else 'name' - - sort = data_dict.get('sort', 'name') + pagination_dict = {} + limit = data_dict.get('limit') + if limit: + pagination_dict['limit'] = data_dict['limit'] + offset = data_dict.get('offset') + if offset: + pagination_dict['offset'] = data_dict['offset'] + if pagination_dict: + pagination_dict, errors = _validate( + data_dict, logic.schema.default_pagination_schema(), context) + if errors: + raise ValidationError(errors) + sort = data_dict.get('sort') or 'name' q = data_dict.get('q') all_fields = asbool(data_dict.get('all_fields', None)) @@ -436,6 +447,11 @@ def _group_or_org_list(context, data_dict, is_org=False): else: query = query.order_by(sqlalchemy.desc(sort_model_field)) + if limit: + query = query.limit(limit) + if offset: + query = query.offset(offset) + groups = query.all() if all_fields: @@ -465,6 +481,13 @@ def group_list(context, data_dict): "name asc" string of field name and sort-order. The allowed fields are 'name', 'package_count' and 'title' :type sort: string + :param limit: if given, the list of groups will be broken into pages of + at most ``limit`` groups per page and only one page will be returned + at a time (optional) + :type limit: int + :param offset: when ``limit`` is given, the offset to start + returning groups from + :type offset: int :param groups: a list of names of the groups to return, if given only groups whose names are in this list will be returned (optional) :type groups: list of strings @@ -506,6 +529,13 @@ def organization_list(context, data_dict): "name asc" string of field name and sort-order. The allowed fields are 'name', 'package_count' and 'title' :type sort: string + :param limit: if given, the list of organizations will be broken into pages + of at most ``limit`` organizations per page and only one page will be + returned at a time (optional) + :type limit: int + :param offset: when ``limit`` is given, the offset to start + returning organizations from + :type offset: int :param organizations: a list of names of the groups to return, if given only groups whose names are in this list will be returned (optional) diff --git a/ckan/tests/controllers/test_group.py b/ckan/tests/controllers/test_group.py index 73dfc43da50..a1cc76ff53c 100644 --- a/ckan/tests/controllers/test_group.py +++ b/ckan/tests/controllers/test_group.py @@ -254,3 +254,46 @@ def test_group_follower_list(self): followers_response = app.get(followers_url, extra_environ=env, status=200) assert_true(user_one['display_name'] in followers_response) + + +class TestGroupIndex(helpers.FunctionalTestBase): + + def test_group_index(self): + app = self._get_test_app() + + for i in xrange(1, 25): + _i = '0' + str(i) if i < 10 else i + factories.Group( + name='test-group-{0}'.format(_i), + title='Test Group {0}'.format(_i)) + + url = url_for(controller='group', + action='index') + response = app.get(url) + + for i in xrange(1, 21): + _i = '0' + str(i) if i < 10 else i + assert_in('Test Group {0}'.format(_i), response) + + assert 'Test Group 22' not in response + + url = url_for(controller='group', + action='index', + page=1) + response = app.get(url) + + for i in xrange(1, 21): + _i = '0' + str(i) if i < 10 else i + assert_in('Test Group {0}'.format(_i), response) + + assert 'Test Group 22' not in response + + url = url_for(controller='group', + action='index', + page=2) + response = app.get(url) + + for i in xrange(22, 25): + assert_in('Test Group {0}'.format(i), response) + + assert 'Test Group 21' not in response diff --git a/ckan/tests/logic/action/test_get.py b/ckan/tests/logic/action/test_get.py index 8d05e7d9a0a..629fe7293c1 100644 --- a/ckan/tests/logic/action/test_get.py +++ b/ckan/tests/logic/action/test_get.py @@ -8,6 +8,7 @@ eq = nose.tools.eq_ +assert_raises = nose.tools.assert_raises class TestPackageShow(helpers.FunctionalTestBase): @@ -179,6 +180,49 @@ def test_group_list_groups_returned(self): eq([g['name'] for g in child_group_returned['groups']], [expected_parent_group['name']]) + def test_group_list_limit(self): + + group1 = factories.Group() + group2 = factories.Group() + group3 = factories.Group() + + group_list = helpers.call_action('group_list', limit=1) + + eq(len(group_list), 1) + eq(group_list[0], group1['name']) + + def test_group_list_offset(self): + + group1 = factories.Group() + group2 = factories.Group() + group3 = factories.Group() + + group_list = helpers.call_action('group_list', offset=2) + + eq(len(group_list), 1) + eq(group_list[0], group3['name']) + + def test_group_list_limit_and_offset(self): + + group1 = factories.Group() + group2 = factories.Group() + group3 = factories.Group() + + group_list = helpers.call_action('group_list', offset=1, limit=1) + + eq(len(group_list), 1) + eq(group_list[0], group2['name']) + + def test_group_list_wrong_limit(self): + + assert_raises(logic.ValidationError, helpers.call_action, 'group_list', + limit='a') + + def test_group_list_wrong_offset(self): + + assert_raises(logic.ValidationError, helpers.call_action, 'group_list', + offset='-2') + class TestGroupShow(helpers.FunctionalTestBase): From 8ededef46167e1853b12335e1b198918963e7210 Mon Sep 17 00:00:00 2001 From: amercader Date: Wed, 19 Aug 2015 11:41:02 +0100 Subject: [PATCH 105/130] [#2554] Remove unused code from home controller This was probably used on an old variant of the homepage, but it isn't anymore. It removes the `c.groups` and `c.group_package_stuff` context vars. --- ckan/controllers/home.py | 77 ---------------------------------------- 1 file changed, 77 deletions(-) diff --git a/ckan/controllers/home.py b/ckan/controllers/home.py index 385ac5be736..06549127853 100644 --- a/ckan/controllers/home.py +++ b/ckan/controllers/home.py @@ -74,17 +74,8 @@ def index(self): 'license': _('Licenses'), } - data_dict = {'sort': 'package_count desc', 'all_fields': 1} - # only give the terms to group dictize that are returned in the - # facets as full results take a lot longer - if 'groups' in c.search_facets: - data_dict['groups'] = [ - item['name'] for item in c.search_facets['groups']['items'] - ] - c.groups = logic.get_action('group_list')(context, data_dict) except search.SearchError: c.package_count = 0 - c.groups = [] if c.userobj is not None: msg = None @@ -111,74 +102,6 @@ def index(self): if msg: h.flash_notice(msg, allow_html=True) - # START OF DIRTINESS - def get_group(id): - def _get_group_type(id): - """ - Given the id of a group it determines the type of a group given - a valid id/name for the group. - """ - group = model.Group.get(id) - if not group: - return None - return group.type - - def db_to_form_schema(group_type=None): - from ckan.lib.plugins import lookup_group_plugin - return lookup_group_plugin(group_type).db_to_form_schema() - - group_type = _get_group_type(id.split('@')[0]) - context = {'model': model, 'session': model.Session, - 'ignore_auth': True, - 'user': c.user or c.author, - 'auth_user_obj': c.userobj, - 'schema': db_to_form_schema(group_type=group_type), - 'limits': {'packages': 2}, - 'for_view': True} - data_dict = {'id': id, 'include_datasets': True} - - try: - group_dict = logic.get_action('group_show')(context, data_dict) - except logic.NotFound: - return None - - return {'group_dict': group_dict} - - global dirty_cached_group_stuff - if not dirty_cached_group_stuff: - groups_data = [] - groups = config.get('ckan.featured_groups', '').split() - - for group_name in groups: - group = get_group(group_name) - if group: - groups_data.append(group) - if len(groups_data) == 2: - break - - # c.groups is from the solr query above - if len(groups_data) < 2 and len(c.groups) > 0: - group = get_group(c.groups[0]['name']) - if group: - groups_data.append(group) - if len(groups_data) < 2 and len(c.groups) > 1: - group = get_group(c.groups[1]['name']) - if group: - groups_data.append(group) - # We get all the packages or at least too many so - # limit it to just 2 - for group in groups_data: - group['group_dict']['packages'] = \ - group['group_dict']['packages'][:2] - #now add blanks so we have two - while len(groups_data) < 2: - groups_data.append({'group_dict': {}}) - # cache for later use - dirty_cached_group_stuff = groups_data - - c.group_package_stuff = dirty_cached_group_stuff - - # END OF DIRTINESS return base.render('home/index.html', cache_force=True) def license(self): From d5234c2b0342e401cbe66864488c739d048c08db Mon Sep 17 00:00:00 2001 From: amercader Date: Wed, 19 Aug 2015 11:54:14 +0100 Subject: [PATCH 106/130] [#2571] Add note about further checks --- ckanext/datapusher/interfaces.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ckanext/datapusher/interfaces.py b/ckanext/datapusher/interfaces.py index 6521de6f943..4ecc01bfdea 100644 --- a/ckanext/datapusher/interfaces.py +++ b/ckanext/datapusher/interfaces.py @@ -20,6 +20,11 @@ def can_upload(self, resource_id): whilst returning True will submit the resource to the datapusher service + Note that before reaching this hook there is a prior check on the + resource format, which depends on the value of + the :ref:`ckan.datapusher.formats` configuration option (and requires + the resource to have a format defined). + :param resource_id: The ID of the resource that is to be pushed to the datapusher service. From 5cfc6c6e15cc7eebb26ae13c37874273ae0e4ea9 Mon Sep 17 00:00:00 2001 From: Tyler Kennedy Date: Mon, 24 Aug 2015 14:43:17 -0500 Subject: [PATCH 107/130] Don't use cached values when rendering notes. Currently, a cached field with the rendered markdown is used in the package/read template. This value is set very early in the process (and also results in rendering markdown when it is not needed). Since it is set so early, it bypasses all of the hooks designed to allow these values to be manipulated, such as `before_view`. This breaks extension on core fields that depend on being able to modify these fields, like the multi-lingual support extension ckanext-fluent. --- ckan/templates/package/read.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ckan/templates/package/read.html b/ckan/templates/package/read.html index dd980d16deb..ce918a0e4cb 100644 --- a/ckan/templates/package/read.html +++ b/ckan/templates/package/read.html @@ -23,9 +23,9 @@

{% endblock %}

{% block package_notes %} - {% if c.pkg_notes_formatted %} + {% if pkg.notes %}
- {{ c.pkg_notes_formatted }} + {{ h.render_markdown(pkg.notes) }}
{% endif %} {% endblock %} From ee8d9e9ee2722c30df4898e30cc7ea4ea8705ec6 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Tue, 25 Aug 2015 13:29:48 +0200 Subject: [PATCH 108/130] Use `ckan.site_url` to generate urls of resources --- ckan/lib/dictization/model_dictize.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index 141fc958525..6c1ccd7bd07 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -104,6 +104,15 @@ def extras_list_dictize(extras_list, context): return sorted(result_list, key=lambda x: x["key"]) +def get_site_url_and_protocol(): + site_url = config.get('ckan.site_url', None) + if site_url is not None: + parsed_url = urlparse.urlparse(site_url) + return ( + parsed_url.scheme.encode('utf-8'), + parsed_url.netloc.encode('utf-8') + ) + return (None, None) def resource_dictize(res, context): model = context['model'] @@ -117,11 +126,14 @@ def resource_dictize(res, context): ## in the frontend. Without for_edit the whole qualified url is returned. if resource.get('url_type') == 'upload' and not context.get('for_edit'): cleaned_name = munge.munge_filename(url) + protocol, host = get_site_url_and_protocol() resource['url'] = h.url_for(controller='package', action='resource_download', id=resource['package_id'], resource_id=res.id, filename=cleaned_name, + protocol=protocol, + host=host, qualified=True) elif not urlparse.urlsplit(url).scheme and not context.get('for_edit'): resource['url'] = u'http://' + url.lstrip('/') From 38a985e338c2261711bbba5ab6ef3bf93d593581 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Tue, 25 Aug 2015 13:48:00 +0200 Subject: [PATCH 109/130] Add full domain name in url_for helper --- ckan/lib/dictization/model_dictize.py | 13 ------------- ckan/lib/helpers.py | 19 ++++++++++++++++++- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index 6c1ccd7bd07..75fa42bbaa6 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -104,16 +104,6 @@ def extras_list_dictize(extras_list, context): return sorted(result_list, key=lambda x: x["key"]) -def get_site_url_and_protocol(): - site_url = config.get('ckan.site_url', None) - if site_url is not None: - parsed_url = urlparse.urlparse(site_url) - return ( - parsed_url.scheme.encode('utf-8'), - parsed_url.netloc.encode('utf-8') - ) - return (None, None) - def resource_dictize(res, context): model = context['model'] resource = d.table_dictize(res, context) @@ -126,14 +116,11 @@ def resource_dictize(res, context): ## in the frontend. Without for_edit the whole qualified url is returned. if resource.get('url_type') == 'upload' and not context.get('for_edit'): cleaned_name = munge.munge_filename(url) - protocol, host = get_site_url_and_protocol() resource['url'] = h.url_for(controller='package', action='resource_download', id=resource['package_id'], resource_id=res.id, filename=cleaned_name, - protocol=protocol, - host=host, qualified=True) elif not urlparse.urlsplit(url).scheme and not context.get('for_edit'): resource['url'] = u'http://' + url.lstrip('/') diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 200b294286d..945afc2da98 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -110,6 +110,17 @@ def url(*args, **kw): return _add_i18n_to_url(my_url, locale=locale, **kw) +def get_site_url_and_protocol(): + site_url = config.get('ckan.site_url', None) + if site_url is not None: + parsed_url = urlparse.urlparse(site_url) + return ( + parsed_url.scheme.encode('utf-8'), + parsed_url.netloc.encode('utf-8') + ) + return (None, None) + + def url_for(*args, **kw): '''Return the URL for the given controller, action, id, etc. @@ -139,6 +150,8 @@ def url_for(*args, **kw): raise Exception('api calls must specify the version! e.g. ver=3') # fix ver to include the slash kw['ver'] = '/%s' % ver + if kw.get('qualified', False): + kw['protocol'], kw['host'] = get_site_url_and_protocol() my_url = _routes_default_url_for(*args, **kw) kw['__ckan_no_root'] = no_root return _add_i18n_to_url(my_url, locale=locale, **kw) @@ -222,7 +235,11 @@ def _add_i18n_to_url(url_to_amend, **kw): root = '' if kw.get('qualified', False): # if qualified is given we want the full url ie http://... - root = _routes_default_url_for('/', qualified=True)[:-1] + protocol, host = get_site_url_and_protocol() + root = _routes_default_url_for('/', + qualified=True, + host=host, + protocol=protocol)[:-1] # ckan.root_path is defined when we have none standard language # position in the url root_path = config.get('ckan.root_path', None) From 0fe69bcdd968893a37788f35bf9daaa8e80c1d75 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Tue, 25 Aug 2015 13:49:49 +0200 Subject: [PATCH 110/130] Fix typo --- ckan/lib/dictization/model_dictize.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index 75fa42bbaa6..141fc958525 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -104,6 +104,7 @@ def extras_list_dictize(extras_list, context): return sorted(result_list, key=lambda x: x["key"]) + def resource_dictize(res, context): model = context['model'] resource = d.table_dictize(res, context) From 04fdd1bf3b128a453ab4689f691ee06200d1b33d Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Tue, 25 Aug 2015 15:58:55 +0200 Subject: [PATCH 111/130] Renamed helper to get_site_protocol_and_host and added docstring --- ckan/lib/helpers.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 945afc2da98..afe9f742414 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -110,7 +110,18 @@ def url(*args, **kw): return _add_i18n_to_url(my_url, locale=locale, **kw) -def get_site_url_and_protocol(): +def get_site_protocol_and_host(): + '''Return the protocol and host of the configured `ckan.site_url`. + This is needed to generate valid, full-qualified URLs. + + If `ckan.site_url` is set like this:: + + ckan.site_url = http://example.com + + Then this function would return a tuple `('http', 'example.com')` + If the setting is missing, `(None, None)` is returned instead. + + ''' site_url = config.get('ckan.site_url', None) if site_url is not None: parsed_url = urlparse.urlparse(site_url) @@ -151,7 +162,7 @@ def url_for(*args, **kw): # fix ver to include the slash kw['ver'] = '/%s' % ver if kw.get('qualified', False): - kw['protocol'], kw['host'] = get_site_url_and_protocol() + kw['protocol'], kw['host'] = get_site_protocol_and_host() my_url = _routes_default_url_for(*args, **kw) kw['__ckan_no_root'] = no_root return _add_i18n_to_url(my_url, locale=locale, **kw) @@ -235,7 +246,7 @@ def _add_i18n_to_url(url_to_amend, **kw): root = '' if kw.get('qualified', False): # if qualified is given we want the full url ie http://... - protocol, host = get_site_url_and_protocol() + protocol, host = get_site_protocol_and_host() root = _routes_default_url_for('/', qualified=True, host=host, From da0177a242af360ca1de96ab3ff0c2976a361284 Mon Sep 17 00:00:00 2001 From: amercader Date: Tue, 25 Aug 2015 15:30:51 +0100 Subject: [PATCH 112/130] [#2554] Remove unused 'dirty' var --- ckan/controllers/home.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ckan/controllers/home.py b/ckan/controllers/home.py index 06549127853..83f268d2858 100644 --- a/ckan/controllers/home.py +++ b/ckan/controllers/home.py @@ -12,9 +12,6 @@ CACHE_PARAMETERS = ['__cache', '__no_cache__'] -# horrible hack -dirty_cached_group_stuff = None - class HomeController(base.BaseController): repo = model.repo From eb30e116722fed03a9c4f9ada43945d8546fdc48 Mon Sep 17 00:00:00 2001 From: amercader Date: Tue, 25 Aug 2015 15:39:35 +0100 Subject: [PATCH 113/130] [#2554] Fix ranges in group list tests --- ckan/tests/controllers/test_group.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ckan/tests/controllers/test_group.py b/ckan/tests/controllers/test_group.py index a1cc76ff53c..49bbb6e7473 100644 --- a/ckan/tests/controllers/test_group.py +++ b/ckan/tests/controllers/test_group.py @@ -261,7 +261,7 @@ class TestGroupIndex(helpers.FunctionalTestBase): def test_group_index(self): app = self._get_test_app() - for i in xrange(1, 25): + for i in xrange(1, 26): _i = '0' + str(i) if i < 10 else i factories.Group( name='test-group-{0}'.format(_i), @@ -271,7 +271,7 @@ def test_group_index(self): action='index') response = app.get(url) - for i in xrange(1, 21): + for i in xrange(1, 22): _i = '0' + str(i) if i < 10 else i assert_in('Test Group {0}'.format(_i), response) @@ -282,7 +282,7 @@ def test_group_index(self): page=1) response = app.get(url) - for i in xrange(1, 21): + for i in xrange(1, 22): _i = '0' + str(i) if i < 10 else i assert_in('Test Group {0}'.format(_i), response) @@ -293,7 +293,7 @@ def test_group_index(self): page=2) response = app.get(url) - for i in xrange(22, 25): + for i in xrange(22, 26): assert_in('Test Group {0}'.format(i), response) assert 'Test Group 21' not in response From 0c9d24723d8a2df0bf95b1452781bd5a220cafc2 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Tue, 25 Aug 2015 16:59:03 +0200 Subject: [PATCH 114/130] Add test for full qualified URLs --- ckan/tests/lib/test_helpers.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index 28512e4a2fd..e7c19379880 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -55,6 +55,32 @@ def test_url_for_static_or_external_works_with_protocol_relative_url(self): eq_(h.url_for_static_or_external(url), url) +class TestHelpersUrlFor(object): + + @h.change_config('ckan.site_url', 'http://example.com') + def test_url_for_default(self): + url = '/dataset/my_dataset' + generated_url = h.url_for(controller='package', action='read', id='my_dataset') + eq_(generated_url, url) + + @h.change_config('ckan.site_url', 'http://example.com') + def test_url_for_not_qualified(self): + url = '/dataset/my_dataset' + generated_url = h.url_for(controller='package', + action='read', + id='my_dataset', + qualified=False) + eq_(generated_url, url) + + @h.change_config('ckan.site_url', 'http://example.com') + def test_url_for_qualified(self): + url = 'http://example.com/dataset/my_dataset' + generated_url = h.url_for(controller='package', + action='read', + id='my_dataset', + qualified=True) + eq_(generated_url, url) + class TestHelpersRenderMarkdown(object): def test_render_markdown_allow_html(self): From 549ec2827bf57f4ba3c7bff064811f72ffd53832 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Tue, 25 Aug 2015 18:01:58 +0200 Subject: [PATCH 115/130] Import test helpers for change_config decorator --- ckan/tests/lib/test_helpers.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index e7c19379880..97d084bc58c 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -2,6 +2,7 @@ import ckan.lib.helpers as h import ckan.exceptions +from ckan.tests import helpers eq_ = nose.tools.eq_ CkanUrlException = ckan.exceptions.CkanUrlException @@ -57,13 +58,13 @@ def test_url_for_static_or_external_works_with_protocol_relative_url(self): class TestHelpersUrlFor(object): - @h.change_config('ckan.site_url', 'http://example.com') + @helpers.change_config('ckan.site_url', 'http://example.com') def test_url_for_default(self): url = '/dataset/my_dataset' generated_url = h.url_for(controller='package', action='read', id='my_dataset') eq_(generated_url, url) - @h.change_config('ckan.site_url', 'http://example.com') + @helpers.change_config('ckan.site_url', 'http://example.com') def test_url_for_not_qualified(self): url = '/dataset/my_dataset' generated_url = h.url_for(controller='package', @@ -72,7 +73,7 @@ def test_url_for_not_qualified(self): qualified=False) eq_(generated_url, url) - @h.change_config('ckan.site_url', 'http://example.com') + @helpers.change_config('ckan.site_url', 'http://example.com') def test_url_for_qualified(self): url = 'http://example.com/dataset/my_dataset' generated_url = h.url_for(controller='package', From 0995d4549def866a7caacad8f08bf8e39e1d32dc Mon Sep 17 00:00:00 2001 From: Tyler Kennedy Date: Tue, 25 Aug 2015 11:21:46 -0500 Subject: [PATCH 116/130] Remove pkg_notes_formatted completely. --- ckan/lib/plugins.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ckan/lib/plugins.py b/ckan/lib/plugins.py index abb7ffbeb4c..ff596c9e2c7 100644 --- a/ckan/lib/plugins.py +++ b/ckan/lib/plugins.py @@ -261,8 +261,6 @@ def show_package_schema(self): return ckan.logic.schema.default_show_package_schema() def setup_template_variables(self, context, data_dict): - from ckan.lib.helpers import render_markdown - authz_fn = logic.get_action('group_list_authz') c.groups_authz = authz_fn(context, data_dict) data_dict.update({'available_only': True}) @@ -276,8 +274,8 @@ def setup_template_variables(self, context, data_dict): c.is_sysadmin = ckan.authz.is_sysadmin(c.user) if c.pkg: + # Used by the disqus plugin c.related_count = c.pkg.related_count - c.pkg_notes_formatted = render_markdown(c.pkg.notes) if context.get('revision_id') or context.get('revision_date'): if context.get('revision_id'): From 678e5ff55138a2772072bf9f7709b3f8334048d6 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Tue, 25 Aug 2015 18:32:49 +0200 Subject: [PATCH 117/130] Fix PEP-8 issue --- ckan/tests/lib/test_helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index 97d084bc58c..e5839ea309a 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -82,6 +82,7 @@ def test_url_for_qualified(self): qualified=True) eq_(generated_url, url) + class TestHelpersRenderMarkdown(object): def test_render_markdown_allow_html(self): From 691e05a629c32a838d5a40ffc665f0b2ef99d90b Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Wed, 26 Aug 2015 21:26:06 +0200 Subject: [PATCH 118/130] Add tests for `ckan.root_path` --- ckan/tests/lib/test_helpers.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index e5839ea309a..b7b31937844 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -82,6 +82,27 @@ def test_url_for_qualified(self): qualified=True) eq_(generated_url, url) + @helpers.change_config('ckan.site_url', 'http://example.com') + @helpers.change_config('ckan.root_path', '/my/prefix') + def test_url_for_qualified_with_root_path(self): + url = 'http://example.com/my/prefix/dataset/my_dataset' + generated_url = h.url_for(controller='package', + action='read', + id='my_dataset', + qualified=True) + eq_(generated_url, url) + + @helpers.change_config('ckan.site_url', 'http://example.com') + @helpers.change_config('ckan.root_path', '/my/custom/path/{{LANG}}/foo') + def test_url_for_qualified_with_root_path_and_locale(self): + url = 'http://example.com/my/custom/path/en/foo/dataset/my_dataset' + generated_url = h.url_for(controller='package', + action='read', + id='my_dataset', + qualified=True, + locale='en') + eq_(generated_url, url) + class TestHelpersRenderMarkdown(object): From c3afea6207fb6ce9a494a96ea59a7917c7e1d8a0 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 27 Aug 2015 09:07:48 +0200 Subject: [PATCH 119/130] Create proper locale object --- ckan/tests/lib/test_helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index b7b31937844..cb61df429df 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -1,4 +1,5 @@ import nose +import i18n import ckan.lib.helpers as h import ckan.exceptions @@ -96,11 +97,12 @@ def test_url_for_qualified_with_root_path(self): @helpers.change_config('ckan.root_path', '/my/custom/path/{{LANG}}/foo') def test_url_for_qualified_with_root_path_and_locale(self): url = 'http://example.com/my/custom/path/en/foo/dataset/my_dataset' + locale = i18n.get_locales_dict().get('en') generated_url = h.url_for(controller='package', action='read', id='my_dataset', qualified=True, - locale='en') + locale=locale) eq_(generated_url, url) From 6d0409ed83a573097d01aa31aa2ef34083488d6d Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 27 Aug 2015 09:18:07 +0200 Subject: [PATCH 120/130] Set locale to 'de' to be non-default --- ckan/tests/lib/test_helpers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index cb61df429df..a3899404dd4 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -97,12 +97,11 @@ def test_url_for_qualified_with_root_path(self): @helpers.change_config('ckan.root_path', '/my/custom/path/{{LANG}}/foo') def test_url_for_qualified_with_root_path_and_locale(self): url = 'http://example.com/my/custom/path/en/foo/dataset/my_dataset' - locale = i18n.get_locales_dict().get('en') generated_url = h.url_for(controller='package', action='read', id='my_dataset', qualified=True, - locale=locale) + locale='de') eq_(generated_url, url) From 89336e8ef8b3401d24b08a17a5f58af01d47dd1e Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 27 Aug 2015 09:27:09 +0200 Subject: [PATCH 121/130] Add additional test for locale without root_path --- ckan/tests/lib/test_helpers.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index a3899404dd4..aab6a3bbcce 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -93,10 +93,20 @@ def test_url_for_qualified_with_root_path(self): qualified=True) eq_(generated_url, url) + @helpers.change_config('ckan.site_url', 'http://example.com') + def test_url_for_qualified_with_locale(self): + url = 'http://example.com/de/foo/dataset/my_dataset' + generated_url = h.url_for(controller='package', + action='read', + id='my_dataset', + qualified=True, + locale='de') + eq_(generated_url, url) + @helpers.change_config('ckan.site_url', 'http://example.com') @helpers.change_config('ckan.root_path', '/my/custom/path/{{LANG}}/foo') def test_url_for_qualified_with_root_path_and_locale(self): - url = 'http://example.com/my/custom/path/en/foo/dataset/my_dataset' + url = 'http://example.com/my/custom/path/de/foo/dataset/my_dataset' generated_url = h.url_for(controller='package', action='read', id='my_dataset', From 3a3bcdeca658c38c2dcd978a4244fbfba1025fb1 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 27 Aug 2015 10:28:42 +0200 Subject: [PATCH 122/130] Fix broken test --- ckan/tests/lib/test_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index aab6a3bbcce..9a059fc24c5 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -95,7 +95,7 @@ def test_url_for_qualified_with_root_path(self): @helpers.change_config('ckan.site_url', 'http://example.com') def test_url_for_qualified_with_locale(self): - url = 'http://example.com/de/foo/dataset/my_dataset' + url = 'http://example.com/de/dataset/my_dataset' generated_url = h.url_for(controller='package', action='read', id='my_dataset', From bf106b08b39e3952318ae56db2768f2458532d90 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 27 Aug 2015 11:32:59 +0200 Subject: [PATCH 123/130] Fix broken root_path implementation --- ckan/lib/helpers.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index afe9f742414..a779acbddc7 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -259,15 +259,16 @@ def _add_i18n_to_url(url_to_amend, **kw): # into the ecportal core is done - Toby # we have a special root specified so use that if default_locale: - root = re.sub('/{{LANG}}', '', root_path) + root_path = re.sub('/{{LANG}}', '', root_path) else: - root = re.sub('{{LANG}}', locale, root_path) + root_path = re.sub('{{LANG}}', locale, root_path) # make sure we don't have a trailing / on the root - if root[-1] == '/': - root = root[:-1] - url = url_to_amend[len(re.sub('/{{LANG}}', '', root_path)):] - url = '%s%s' % (root, url) - root = re.sub('/{{LANG}}', '', root_path) + if root_path[-1] == '/': + root_path = root_path[:-1] + # TODO: this seems broken + + url_path = url_to_amend[len(root):] + url = '%s%s%s' % (root, root_path, url_path) else: if default_locale: url = url_to_amend From c0bae5f081159a18c9b44c9bed2aa4a8eb66b261 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 27 Aug 2015 12:00:17 +0200 Subject: [PATCH 124/130] Remove TODO comment --- ckan/lib/helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index a779acbddc7..f8518bba07f 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -265,7 +265,6 @@ def _add_i18n_to_url(url_to_amend, **kw): # make sure we don't have a trailing / on the root if root_path[-1] == '/': root_path = root_path[:-1] - # TODO: this seems broken url_path = url_to_amend[len(root):] url = '%s%s%s' % (root, root_path, url_path) From 4dac0415d5676a0c408daf8aec4266e04cdc8293 Mon Sep 17 00:00:00 2001 From: Stefan Oderbolz Date: Thu, 27 Aug 2015 12:16:20 +0200 Subject: [PATCH 125/130] Add test for relative URL with locale --- ckan/tests/lib/test_helpers.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index 9a059fc24c5..3e2208fc66e 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -65,6 +65,15 @@ def test_url_for_default(self): generated_url = h.url_for(controller='package', action='read', id='my_dataset') eq_(generated_url, url) + @helpers.change_config('ckan.site_url', 'http://example.com') + def test_url_for_with_locale(self): + url = '/de/dataset/my_dataset' + generated_url = h.url_for(controller='package', + action='read', + id='my_dataset', + locale='de') + eq_(generated_url, url) + @helpers.change_config('ckan.site_url', 'http://example.com') def test_url_for_not_qualified(self): url = '/dataset/my_dataset' From f9b39429fc984663585f6dd4b91a64ae37e7c213 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Mon, 27 Jul 2015 15:10:17 -0400 Subject: [PATCH 126/130] format next to format strings, _literal_string helper --- ckanext/datastore/helpers.py | 7 +++++++ ckanext/datastore/plugin.py | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/ckanext/datastore/helpers.py b/ckanext/datastore/helpers.py index 6b6bc122a76..d2441cdc439 100644 --- a/ckanext/datastore/helpers.py +++ b/ckanext/datastore/helpers.py @@ -93,3 +93,10 @@ def _get_table_names_from_plan(plan): log.error('Could not parse query plan') return table_names + + +def literal_string(s): + """ + Return s as a postgres literal string + """ + return u"'" + s.replace(u"'", u"''") + u"'" diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index a7db61dbd52..bfd36654dc2 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -14,6 +14,7 @@ import ckanext.datastore.db as db import ckanext.datastore.interfaces as interfaces import ckanext.datastore.helpers as datastore_helpers +from ckanext.datastore.helpers import literal_string log = logging.getLogger(__name__) @@ -433,8 +434,9 @@ def _where(self, data_dict, fields_types): clause_str = u'_full_text @@ {0}'.format(query_field) clauses.append((clause_str,)) - clause_str = (u'to_tsvector(\'{0}\', cast("{1}" as text)) ' - u'@@ {2}').format(lang, field, query_field) + clause_str = (u'to_tsvector({0}, cast("{1}" as text)) ' + u'@@ {2}').format(literal_string(lang), + field, query_field) clauses.append((clause_str,)) return clauses @@ -503,17 +505,20 @@ def _fts_lang(self, lang=None): def _build_query_and_rank_statements(self, lang, query, plain, field=None): query_alias = self._ts_query_alias(field) rank_alias = self._ts_rank_alias(field) + lang_literal = literal_string(lang) + query_literal = literal_string(query) if plain: - statement = u"plainto_tsquery('{lang}', '{query}') {alias}" + statement = u"plainto_tsquery({lang_literal}, {query_literal}) {query_alias}" else: - statement = u"to_tsquery('{lang}', '{query}') {alias}" + statement = u"to_tsquery({lang_literal}, {query_literal}) {query_alias}" + statement = statement.format(lang_literal=lang_literal, + query_literal=query_literal, query_alias=query_alias) if field is None: rank_field = '_full_text' else: - rank_field = u'to_tsvector(\'{lang}\', cast("{field}" as text))' - rank_field = rank_field.format(lang=lang, field=field) + rank_field = u'to_tsvector({lang_literal}, cast("{field}" as text))' + rank_field = rank_field.format(lang_literal=lang_literal, field=field) rank_statement = u'ts_rank({rank_field}, {query_alias}, 32) AS {alias}' - statement = statement.format(lang=lang, query=query, alias=query_alias) rank_statement = rank_statement.format(rank_field=rank_field, query_alias=query_alias, alias=rank_alias) From e17f1781c2fb65db211baa0b3fbd38d72e112c63 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Mon, 27 Jul 2015 15:59:15 -0400 Subject: [PATCH 127/130] just use unicode in _parse_sort_clause --- ckanext/datastore/plugin.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index bfd36654dc2..2a924f21759 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -1,6 +1,5 @@ import sys import logging -import shlex import re import pylons @@ -352,16 +351,14 @@ def datastore_validate(self, context, data_dict, fields_types): return data_dict def _parse_sort_clause(self, clause, fields_types): - clause = ' '.join(shlex.split(clause.encode('utf-8'))) - clause_match = re.match('^(.+?)( +(asc|desc) *)?$', clause, re.I) + clause_match = re.match(u'^(.+?)( +(asc|desc) *)?$', clause, re.I) if not clause_match: return False field = clause_match.group(1) - sort = (clause_match.group(3) or 'asc').lower() + sort = (clause_match.group(3) or u'asc').lower() - field, sort = unicode(field, 'utf-8'), unicode(sort, 'utf-8') if field not in fields_types: return False From c666976fe93919281a13680bc47a468ca22805e0 Mon Sep 17 00:00:00 2001 From: Ian Ward Date: Mon, 24 Aug 2015 19:36:41 -0400 Subject: [PATCH 128/130] support quoted sort args for backwards compat --- ckanext/datastore/helpers.py | 9 ++++++++- ckanext/datastore/plugin.py | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ckanext/datastore/helpers.py b/ckanext/datastore/helpers.py index d2441cdc439..08007a9d93d 100644 --- a/ckanext/datastore/helpers.py +++ b/ckanext/datastore/helpers.py @@ -99,4 +99,11 @@ def literal_string(s): """ Return s as a postgres literal string """ - return u"'" + s.replace(u"'", u"''") + u"'" + return u"'" + s.replace(u"'", u"''").replace(u'\0', '') + u"'" + + +def identifier(s): + """ + Return s as a quoted postgres identifier + """ + return u'"' + s.replace(u'"', u'""').replace(u'\0', '') + u'"' diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index 2a924f21759..9b3bc40110c 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -357,6 +357,8 @@ def _parse_sort_clause(self, clause, fields_types): return False field = clause_match.group(1) + if field[0] == field[-1] == u'"': + field = field[1:-1] sort = (clause_match.group(3) or u'asc').lower() if field not in fields_types: @@ -460,7 +462,8 @@ def _sort(self, data_dict, fields_types): for clause in clauses: field, sort = self._parse_sort_clause(clause, fields_types) - clause_parsed.append(u'"{0}" {1}'.format(field, sort)) + clause_parsed.append( + u'{0} {1}'.format(datastore_helpers.identifier(field), sort)) return clause_parsed From 88c70458acc903ffe93ca41535ee97cf2ac45515 Mon Sep 17 00:00:00 2001 From: amercader Date: Wed, 2 Sep 2015 13:23:49 +0100 Subject: [PATCH 129/130] Update changelog after 2.4.1 release --- CHANGELOG.rst | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7eaf4b96421..dfd43ab53a3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,27 @@ Changes and deprecations https://github.com/ckan/ckanext-dcat#rdf-dcat-endpoints + +v2.4.1 2015-09-02 +================= + +Note: #2554 fixes a regression where ``group_list`` and ``organization_list`` + where returning extra additional fields by default, causing performance + issues. This is now fixed, so the output for these actions no longer returns + ``users``, ``extras``, etc. + Also, on the homepage template the ``c.groups`` and ``c.group_package_stuff`` + context variables are no longer available. + + +Bug fixes: + +* Fix dataset count in templates and show datasets on featured org/group (#2557) +* Fix autodetect for TSV resources (#2553) +* Improve character escaping in DataStore parameters +* Fix "paster db init" when celery is configured with a non-database backend +* Fix severe performance issues with groups and orgs listings (#2554) + + v2.4.0 2015-07-22 ================= @@ -99,6 +120,16 @@ Changes and deprecations * Config option ``site_url`` is now required - CKAN will not abort during start-up if it is not set. (#1976) + +v2.3.2 2015-09-02 +================= + +Bug fixes: +* Fix autodetect for TSV resources (#2553) +* Improve character escaping in DataStore parameters +* Fix "paster db init" when celery is configured with a non-database backend + + v2.3.1 2015-07-22 ================= From 9e8552dddf728face5b512888c87ed36dd85ae6c Mon Sep 17 00:00:00 2001 From: David Read Date: Thu, 3 Sep 2015 20:21:51 +0100 Subject: [PATCH 130/130] Better to remove the line completely. --- ckan/authz.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ckan/authz.py b/ckan/authz.py index 74cdae77dfd..c95c85f5f1f 100644 --- a/ckan/authz.py +++ b/ckan/authz.py @@ -81,7 +81,6 @@ def _build(self): resolved_auth_function_plugins[name] ) ) - #log.debug('Auth function {0} from plugin {1} was inserted'.format(name, plugin.name)) resolved_auth_function_plugins[name] = plugin.name fetched_auth_functions[name] = auth_function # Use the updated ones in preference to the originals.