From 17d892c521c74bb71c29ef9ad11d1a9903803b59 Mon Sep 17 00:00:00 2001 From: jsa <jsa@edx.org> Date: Mon, 8 Dec 2014 10:11:39 -0500 Subject: [PATCH] make block.get_parent() work. Co-Authored-By: Christina Roberts <christina@edx.org> Co-Authored-By: Daniel Friedman <dfriedman@edx.org> Co-Authored-By: Don Mitchell <dmitchell@edx.org> --- cms/djangoapps/contentstore/views/item.py | 3 ++- cms/djangoapps/contentstore/views/tests/test_item.py | 52 ++++++++++++++++++++++++++++++++++++++++++++-------- common/lib/xmodule/xmodule/modulestore/modulestore_settings.py | 4 ++++ common/lib/xmodule/xmodule/modulestore/mongo/base.py | 123 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ common/lib/xmodule/xmodule/modulestore/mongo/draft.py | 3 +++ common/lib/xmodule/xmodule/modulestore/tests/factories.py | 31 +++++++++++++------------------ common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py | 36 +++++++++++++----------------------- common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py | 10 ++++++---- common/lib/xmodule/xmodule/modulestore/tests/test_publish.py | 28 ++++++++++++---------------- common/lib/xmodule/xmodule/modulestore/tests/test_xml.py | 2 +- common/lib/xmodule/xmodule/modulestore/xml.py | 51 ++++++--------------------------------------------- common/lib/xmodule/xmodule/modulestore/xml_importer.py | 13 +++++++------ common/lib/xmodule/xmodule/tests/test_conditional.py | 1 - common/lib/xmodule/xmodule/tests/test_course_module.py | 2 -- common/lib/xmodule/xmodule/tests/test_import.py | 2 -- common/lib/xmodule/xmodule/x_module.py | 3 ++- lms/djangoapps/courseware/tests/test_lti_integration.py | 4 ++-- lms/djangoapps/courseware/tests/test_submitting_problems.py | 16 ++++++++-------- lms/djangoapps/instructor/tests/test_tools.py | 3 ++- lms/lib/xblock/test/test_mixin.py | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 20 files changed, 333 insertions(+), 161 deletions(-) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index e390811..bdb5a8d 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -143,7 +143,8 @@ def xblock_handler(request, usage_key_string): # right now can't combine output of this w/ output of _get_module_info, but worthy goal return JsonResponse(CourseGradingModel.get_section_grader_type(usage_key)) # TODO: pass fields to _get_module_info and only return those - rsp = _get_module_info(_get_xblock(usage_key, request.user)) + with modulestore().bulk_operations(usage_key.course_key): + rsp = _get_module_info(_get_xblock(usage_key, request.user)) return JsonResponse(rsp) else: return HttpResponse(status=406) diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index c322233..d7f8d16 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -116,9 +116,20 @@ class GetItemTest(ItemTest): return resp @ddt.data( - (1, 21, 23, 35, 37), - (2, 22, 24, 38, 39), - (3, 23, 25, 41, 41), + # chapter explanation: + # 1-3. get course, chapter, chapter's children, + # 4-7. chapter's published grandchildren, chapter's draft grandchildren, published & then draft greatgrand + # 8 compute chapter's parent + # 9 get chapter's parent + # 10-16. run queries 2-8 again + # 17-19. compute seq, vert, and problem's parents (odd since it's going down; so, it knows) + # 20-22. get course 3 times + # 23. get chapter + # 24. compute chapter's parent (course) + # 25. compute course's parent (None) + (1, 20, 20, 26, 26), + (2, 21, 21, 29, 28), + (3, 22, 22, 32, 30), ) @ddt.unpack def test_get_query_count(self, branching_factor, chapter_queries, section_queries, unit_queries, problem_queries): @@ -411,21 +422,46 @@ class TestDuplicateItem(ItemTest): except for location and display name. """ def duplicate_and_verify(source_usage_key, parent_usage_key): + """ Duplicates the source, parenting to supplied parent. Then does equality check. """ usage_key = self._duplicate_item(parent_usage_key, source_usage_key) - self.assertTrue(check_equality(source_usage_key, usage_key), "Duplicated item differs from original") + self.assertTrue( + check_equality(source_usage_key, usage_key, parent_usage_key), + "Duplicated item differs from original" + ) - def check_equality(source_usage_key, duplicate_usage_key): + def check_equality(source_usage_key, duplicate_usage_key, parent_usage_key=None): + """ + Gets source and duplicated items from the modulestore using supplied usage keys. + Then verifies that they represent equivalent items (modulo parents and other + known things that may differ). + """ original_item = self.get_item_from_modulestore(source_usage_key) duplicated_item = self.get_item_from_modulestore(duplicate_usage_key) self.assertNotEqual( - original_item.location, - duplicated_item.location, + unicode(original_item.location), + unicode(duplicated_item.location), "Location of duplicate should be different from original" ) - # Set the location and display name to be the same so we can make sure the rest of the duplicate is equal. + + # Parent will only be equal for root of duplicated structure, in the case + # where an item is duplicated in-place. + if parent_usage_key and unicode(original_item.parent) == unicode(parent_usage_key): + self.assertEqual( + unicode(parent_usage_key), unicode(duplicated_item.parent), + "Parent of duplicate should equal parent of source for root xblock when duplicated in-place" + ) + else: + self.assertNotEqual( + unicode(original_item.parent), unicode(duplicated_item.parent), + "Parent duplicate should be different from source" + ) + + # Set the location, display name, and parent to be the same so we can make sure the rest of the + # duplicate is equal. duplicated_item.location = original_item.location duplicated_item.display_name = original_item.display_name + duplicated_item.parent = original_item.parent # Children will also be duplicated, so for the purposes of testing equality, we will set # the children to the original after recursively checking the children. diff --git a/common/lib/xmodule/xmodule/modulestore/modulestore_settings.py b/common/lib/xmodule/xmodule/modulestore/modulestore_settings.py index ee1a7fc..0b29f3e 100644 --- a/common/lib/xmodule/xmodule/modulestore/modulestore_settings.py +++ b/common/lib/xmodule/xmodule/modulestore/modulestore_settings.py @@ -98,6 +98,7 @@ def update_module_store_settings( module_store_options=None, xml_store_options=None, default_store=None, + mappings=None, ): """ Updates the settings for each store defined in the given module_store_setting settings @@ -123,6 +124,9 @@ def update_module_store_settings( return raise Exception("Could not find setting for requested default store: {}".format(default_store)) + if mappings and 'mappings' in module_store_setting['default']['OPTIONS']: + module_store_setting['default']['OPTIONS']['mappings'] = mappings + def get_mixed_stores(mixed_setting): """ diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py index 51227e4..a858fb8 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py @@ -29,7 +29,7 @@ from contracts import contract, new_contract from importlib import import_module from opaque_keys.edx.keys import UsageKey, CourseKey, AssetKey -from opaque_keys.edx.locations import Location +from opaque_keys.edx.locations import Location, BlockUsageLocator from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locator import CourseLocator, LibraryLocator @@ -56,6 +56,7 @@ new_contract('CourseKey', CourseKey) new_contract('AssetKey', AssetKey) new_contract('AssetMetadata', AssetMetadata) new_contract('long', long) +new_contract('BlockUsageLocator', BlockUsageLocator) # sort order that returns DRAFT items first SORT_REVISION_FAVOR_DRAFT = ('_id.revision', pymongo.DESCENDING) @@ -93,12 +94,13 @@ class MongoKeyValueStore(InheritanceKeyValueStore): A KeyValueStore that maps keyed data access to one of the 3 data areas known to the MongoModuleStore (data, children, and metadata) """ - def __init__(self, data, children, metadata): + def __init__(self, data, parent, children, metadata): super(MongoKeyValueStore, self).__init__() if not isinstance(data, dict): self._data = {'data': data} else: self._data = data + self._parent = parent self._children = children self._metadata = metadata @@ -106,7 +108,7 @@ class MongoKeyValueStore(InheritanceKeyValueStore): if key.scope == Scope.children: return self._children elif key.scope == Scope.parent: - return None + return self._parent elif key.scope == Scope.settings: return self._metadata[key.field_name] elif key.scope == Scope.content: @@ -219,15 +221,35 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): self._convert_reference_to_key(childloc) for childloc in definition.get('children', []) ] + + parent = None + if self.cached_metadata is not None: + # fish the parent out of here if it's available + parent_url = self.cached_metadata.get(unicode(location), {}).get('parent', {}).get( + ModuleStoreEnum.Branch.published_only if location.revision is None + else ModuleStoreEnum.Branch.draft_preferred + ) + if parent_url: + parent = BlockUsageLocator.from_string(parent_url) + if not parent and category != 'course': + # try looking it up just-in-time (but not if we're working with a root node (course). + parent = self.modulestore.get_parent_location( + as_published(location), + ModuleStoreEnum.RevisionOption.published_only if location.revision is None + else ModuleStoreEnum.RevisionOption.draft_preferred + ) + data = definition.get('data', {}) if isinstance(data, basestring): data = {'data': data} + mixed_class = self.mixologist.mix(class_) if data: # empty or None means no work data = self._convert_reference_fields_to_keys(mixed_class, location.course_key, data) metadata = self._convert_reference_fields_to_keys(mixed_class, location.course_key, metadata) kvs = MongoKeyValueStore( data, + parent, children, metadata, ) @@ -439,6 +461,32 @@ class MongoBulkOpsMixin(BulkOperationsMixin): ) +class ParentLocationCache(dict): + """ + Dict-based object augmented with a more cache-like interface, for internal use. + """ + # pylint: disable=missing-docstring + + @contract(key=unicode) + def has(self, key): + return key in self + + @contract(key=unicode, value="BlockUsageLocator | None") + def set(self, key, value): + self[key] = value + + @contract(key=unicode) + def delete(self, key): + if key in self: + del self[key] + + @contract(value="BlockUsageLocator") + def delete_by_value(self, value): + keys_to_delete = [k for k, v in self.iteritems() if v == value] + for key in keys_to_delete: + del self[key] + + class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, MongoBulkOpsMixin): """ A Mongodb backed ModuleStore @@ -572,6 +620,16 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo return location.replace(revision=MongoRevisionKey.draft) return location.replace(revision=MongoRevisionKey.published) + def _get_parent_cache(self, branch): + """ + Provides a reference to one of the two branch-specific + ParentLocationCaches associated with the current request (if any). + """ + if self.request_cache is not None: + return self.request_cache.data.setdefault('parent-location-{}'.format(branch), ParentLocationCache()) + else: + return ParentLocationCache() + def _compute_metadata_inheritance_tree(self, course_id): ''' TODO (cdodge) This method can be deleted when the 'split module store' work has been completed @@ -640,7 +698,13 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo _compute_inherited_metadata(child) else: # this is likely a leaf node, so let's record what metadata we need to inherit - metadata_to_inherit[child] = my_metadata + metadata_to_inherit[child] = my_metadata.copy() + # WARNING: 'parent' is not part of inherited metadata, but + # we're piggybacking on this recursive traversal to grab + # and cache the child's parent, as a performance optimization. + # The 'parent' key will be popped out of the dictionary during + # CachingDescriptorSystem.load_item + metadata_to_inherit[child].setdefault('parent', {})[self.get_branch_setting()] = url if root is not None: _compute_inherited_metadata(root) @@ -735,12 +799,18 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo data = {} to_process = list(items) course_key = self.fill_in_run(course_key) + parent_cache = self._get_parent_cache(self.get_branch_setting()) + while to_process and depth is None or depth >= 0: children = [] for item in to_process: self._clean_item_data(item) - children.extend(item.get('definition', {}).get('children', [])) - data[Location._from_deprecated_son(item['location'], course_key.run)] = item + item_location = Location._from_deprecated_son(item['location'], course_key.run) + item_children = item.get('definition', {}).get('children', []) + children.extend(item_children) + for item_child in item_children: + parent_cache.set(item_child, item_location) + data[item_location] = item if depth == 0: break @@ -1245,6 +1315,13 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo if xblock.has_children: children = self._serialize_scope(xblock, Scope.children) payload.update({'definition.children': children['children']}) + + # Remove all old pointers to me, then add my current children back + parent_cache = self._get_parent_cache(self.get_branch_setting()) + parent_cache.delete_by_value(xblock.location) + for child in xblock.children: + parent_cache.set(unicode(child), xblock.location) + self._update_single_item(xblock.scope_ids.usage_id, payload, allow_not_found=allow_not_found) # update subtree edited info for ancestors @@ -1339,6 +1416,10 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo assert revision == ModuleStoreEnum.RevisionOption.published_only \ or revision == ModuleStoreEnum.RevisionOption.draft_preferred + parent_cache = self._get_parent_cache(self.get_branch_setting()) + if parent_cache.has(unicode(location)): + return parent_cache.get(unicode(location)) + # create a query with tag, org, course, and the children field set to the given location query = self._course_key_to_son(location.course_key) query['definition.children'] = unicode(location) @@ -1347,30 +1428,35 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo if revision == ModuleStoreEnum.RevisionOption.published_only: query['_id.revision'] = MongoRevisionKey.published - # query the collection, sorting by DRAFT first - parents = self.collection.find(query, {'_id': True}, sort=[SORT_REVISION_FAVOR_DRAFT]) + def cache_and_return(parent_loc): # pylint:disable=missing-docstring + parent_cache.set(unicode(location), parent_loc) + return parent_loc - if parents.count() == 0: + # query the collection, sorting by DRAFT first + parents = list( + self.collection.find(query, {'_id': True}, sort=[SORT_REVISION_FAVOR_DRAFT]) + ) + if len(parents) == 0: # no parents were found - return None + return cache_and_return(None) if revision == ModuleStoreEnum.RevisionOption.published_only: - if parents.count() > 1: + if len(parents) > 1: non_orphan_parents = self._get_non_orphan_parents(location, parents, revision) if len(non_orphan_parents) == 0: # no actual parent found - return None + return cache_and_return(None) if len(non_orphan_parents) > 1: # should never have multiple PUBLISHED parents raise ReferentialIntegrityError( - u"{} parents claim {}".format(parents.count(), location) + u"{} parents claim {}".format(len(parents), location) ) else: - return non_orphan_parents[0] + return cache_and_return(non_orphan_parents[0].replace(run=location.course_key.run)) else: # return the single PUBLISHED parent - return Location._from_deprecated_son(parents[0]['_id'], location.course_key.run) + return cache_and_return(Location._from_deprecated_son(parents[0]['_id'], location.course_key.run)) else: # there could be 2 different parents if # (1) the draft item was moved or @@ -1386,11 +1472,11 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo # since we sorted by SORT_REVISION_FAVOR_DRAFT, the 0'th parent is the one we want if published_parents > 1: non_orphan_parents = self._get_non_orphan_parents(location, all_parents, revision) - return non_orphan_parents[0] + return cache_and_return(non_orphan_parents[0].replace(run=location.course_key.run)) found_id = all_parents[0]['_id'] # don't disclose revision outside modulestore - return Location._from_deprecated_son(found_id, location.course_key.run) + return cache_and_return(Location._from_deprecated_son(found_id, location.course_key.run)) def get_parent_location(self, location, revision=ModuleStoreEnum.RevisionOption.published_only, **kwargs): ''' @@ -1409,7 +1495,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo ''' parent = self._get_raw_parent_location(location, revision) if parent: - return as_published(parent) + return parent return None def get_modulestore_type(self, course_key=None): @@ -1463,6 +1549,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo """ kvs = MongoKeyValueStore( definition_data, + None, [], metadata, ) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py index 93a5eac..d603287 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py @@ -643,6 +643,9 @@ class DraftModuleStore(MongoModuleStore): Raises: ItemNotFoundError: if any of the draft subtree nodes aren't found + + Returns: + The newly published xblock """ # NOTE: cannot easily use self._breadth_first b/c need to get pub'd and draft as pairs # (could do it by having 2 breadth first scans, the first to just get all published children diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index d0229cc..fb70429 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -210,26 +210,18 @@ class ItemFactory(XModuleFactory): # replace the display name with an optional parameter passed in from the caller if display_name is not None: metadata['display_name'] = display_name - runtime = parent.runtime if parent else None - store.create_item( + + module = store.create_child( user_id, - location.course_key, + parent.location, location.block_type, block_id=location.block_id, metadata=metadata, definition_data=data, - runtime=runtime + runtime=parent.runtime, + fields=kwargs, ) - module = store.get_item(location) - - for attr, val in kwargs.items(): - setattr(module, attr, val) - # Save the attributes we just set - module.save() - - store.update_item(module, user_id) - # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so # if we add one then we need to also add it to the policy information (i.e. metadata) # we should remove this once we can break this reference from the course to static tabs @@ -248,12 +240,15 @@ class ItemFactory(XModuleFactory): parent.children.append(location) store.update_item(parent, user_id) if publish_item: - store.publish(parent.location, user_id) + published_parent = store.publish(parent.location, user_id) + # module is last child of parent + return published_parent.get_children()[-1] + else: + return store.get_item(location) elif publish_item: - store.publish(location, user_id) - - # return the published item - return store.get_item(location) + return store.publish(location, user_id) + else: + return module @contextmanager diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index a8712f4..5c0e23e 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -358,12 +358,12 @@ class TestMixedModuleStore(CourseComparisonTest): self.store.has_item(self.fake_location, revision=ModuleStoreEnum.RevisionOption.draft_preferred) # draft queries: - # problem: find draft item, find all items pertinent to inheritance computation + # problem: find draft item, find all items pertinent to inheritance computation, find parent # non-existent problem: find draft, find published # split: # problem: active_versions, structure # non-existent problem: ditto - @ddt.data(('draft', [2, 2], 0), ('split', [2, 2], 0)) + @ddt.data(('draft', [3, 2], 0), ('split', [2, 2], 0)) @ddt.unpack def test_get_item(self, default_ms, max_find, max_send): self.initdb(default_ms) @@ -388,10 +388,10 @@ class TestMixedModuleStore(CourseComparisonTest): self.store.get_item(self.fake_location, revision=ModuleStoreEnum.RevisionOption.draft_preferred) # Draft: - # wildcard query, 6! load pertinent items for inheritance calls, course root fetch (why) + # wildcard query, 6! load pertinent items for inheritance calls, load parents, course root fetch (why) # Split: # active_versions (with regex), structure, and spurious active_versions refetch - @ddt.data(('draft', 8, 0), ('split', 3, 0)) + @ddt.data(('draft', 14, 0), ('split', 3, 0)) @ddt.unpack def test_get_items(self, default_ms, max_find, max_send): self.initdb(default_ms) @@ -405,7 +405,6 @@ class TestMixedModuleStore(CourseComparisonTest): course_locn = self.course_locations[self.MONGO_COURSEID] with check_mongo_calls(max_find, max_send): - # NOTE: use get_course if you just want the course. get_items is expensive modules = self.store.get_items(course_locn.course_key, qualifiers={'category': 'problem'}) self.assertEqual(len(modules), 6) @@ -416,12 +415,11 @@ class TestMixedModuleStore(CourseComparisonTest): revision=ModuleStoreEnum.RevisionOption.draft_preferred ) - # draft: get draft, count parents, get parents, count & get grandparents, count & get greatgrand, - # count & get next ancestor (chapter's parent), count non-existent next ancestor, get inheritance + # draft: get draft, get ancestors up to course (2-6), compute inheritance # sends: update problem and then each ancestor up to course (edit info) # split: active_versions, definitions (calculator field), structures # 2 sends to update index & structure (note, it would also be definition if a content field changed) - @ddt.data(('draft', 11, 5), ('split', 3, 2)) + @ddt.data(('draft', 7, 5), ('split', 3, 2)) @ddt.unpack def test_update_item(self, default_ms, max_find, max_send): """ @@ -886,9 +884,9 @@ class TestMixedModuleStore(CourseComparisonTest): # notice this doesn't test getting a public item via draft_preferred which draft would have 2 hits (split # still only 2) - # Draft: count via definition.children query, then fetch via that query + # Draft: get_parent # Split: active_versions, structure - @ddt.data(('draft', 2, 0), ('split', 2, 0)) + @ddt.data(('draft', 1, 0), ('split', 2, 0)) @ddt.unpack def test_get_parent_locations(self, default_ms, max_find, max_send): """ @@ -1022,20 +1020,12 @@ class TestMixedModuleStore(CourseComparisonTest): # Draft: # Problem path: # 1. Get problem - # 2-3. count matches definition.children called 2x? - # 4. get parent via definition.children query - # 5-7. 2 counts and 1 get grandparent via definition.children - # 8-10. ditto for great-grandparent - # 11-13. ditto for next ancestor - # 14. fail count query looking for parent of course (unnecessary) - # 15. get course record direct query (not via definition.children) (already fetched in 13) - # 16. get items for inheritance computation - # 17. get vertical (parent of problem) - # 18. get items for inheritance computation (why? caching should handle) - # 19-20. get vertical_x1b (? why? this is the only ref in trace) & items for inheritance computation - # Chapter path: get chapter, count parents 2x, get parents, count non-existent grandparents + # 2-6. get parent and rest of ancestors up to course + # 7-8. get sequential, compute inheritance + # 8-9. get vertical, compute inheritance + # 10-11. get other vertical_x1b (why?) and compute inheritance # Split: active_versions & structure - @ddt.data(('draft', [20, 5], 0), ('split', [2, 2], 0)) + @ddt.data(('draft', [12, 3], 0), ('split', [2, 2], 0)) @ddt.unpack def test_path_to_location(self, default_ms, num_finds, num_sends): """ diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index a70fb7d..737e0c8 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -717,15 +717,16 @@ class TestMongoKeyValueStore(object): def setUp(self): self.data = {'foo': 'foo_value'} self.course_id = SlashSeparatedCourseKey('org', 'course', 'run') + self.parent = self.course_id.make_usage_key('parent', 'p') self.children = [self.course_id.make_usage_key('child', 'a'), self.course_id.make_usage_key('child', 'b')] self.metadata = {'meta': 'meta_val'} - self.kvs = MongoKeyValueStore(self.data, self.children, self.metadata) + self.kvs = MongoKeyValueStore(self.data, self.parent, self.children, self.metadata) def test_read(self): assert_equals(self.data['foo'], self.kvs.get(KeyValueStore.Key(Scope.content, None, None, 'foo'))) + assert_equals(self.parent, self.kvs.get(KeyValueStore.Key(Scope.parent, None, None, 'parent'))) assert_equals(self.children, self.kvs.get(KeyValueStore.Key(Scope.children, None, None, 'children'))) assert_equals(self.metadata['meta'], self.kvs.get(KeyValueStore.Key(Scope.settings, None, None, 'meta'))) - assert_equals(None, self.kvs.get(KeyValueStore.Key(Scope.parent, None, None, 'parent'))) def test_read_invalid_scope(self): for scope in (Scope.preferences, Scope.user_info, Scope.user_state): @@ -735,7 +736,7 @@ class TestMongoKeyValueStore(object): assert_false(self.kvs.has(key)) def test_read_non_dict_data(self): - self.kvs = MongoKeyValueStore('xml_data', self.children, self.metadata) + self.kvs = MongoKeyValueStore('xml_data', self.parent, self.children, self.metadata) assert_equals('xml_data', self.kvs.get(KeyValueStore.Key(Scope.content, None, None, 'data'))) def _check_write(self, key, value): @@ -746,9 +747,10 @@ class TestMongoKeyValueStore(object): yield (self._check_write, KeyValueStore.Key(Scope.content, None, None, 'foo'), 'new_data') yield (self._check_write, KeyValueStore.Key(Scope.children, None, None, 'children'), []) yield (self._check_write, KeyValueStore.Key(Scope.settings, None, None, 'meta'), 'new_settings') + # write Scope.parent raises InvalidScope, which is covered in test_write_invalid_scope def test_write_non_dict_data(self): - self.kvs = MongoKeyValueStore('xml_data', self.children, self.metadata) + self.kvs = MongoKeyValueStore('xml_data', self.parent, self.children, self.metadata) self._check_write(KeyValueStore.Key(Scope.content, None, None, 'data'), 'new_data') def test_write_invalid_scope(self): diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_publish.py b/common/lib/xmodule/xmodule/modulestore/tests/test_publish.py index b067a8b..0484eb8 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_publish.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_publish.py @@ -47,14 +47,10 @@ class TestPublish(SplitWMongoCourseBoostrapper): # For each (4) item created # - try to find draft # - try to find non-draft - # - retrieve draft of new parent - # - get last error - # - load parent - # - load inheritable data - # - load parent - # - load ancestors + # - compute what is parent + # - load draft parent again & compute its parent chain up to course # count for updates increased to 16 b/c of edit_info updating - with check_mongo_calls(40, 16): + with check_mongo_calls(36, 16): self._create_item('html', 'Html1', "<p>Goodbye</p>", {'display_name': 'Parented Html'}, 'vertical', 'Vert1', split=False) self._create_item( 'discussion', 'Discussion1', @@ -96,22 +92,22 @@ class TestPublish(SplitWMongoCourseBoostrapper): item = self.draft_mongo.get_item(vert_location, 2) # Finds: # 1 get draft vert, - # 2-10 for each child: (3 children x 3 queries each) - # get draft and then published child + # 2 compute parent + # 3-14 for each child: (3 children x 4 queries each) + # get draft, compute parent, and then published child # compute inheritance - # 11 get published vert - # 12-15 get each ancestor (count then get): (2 x 2), - # 16 then fail count of course parent (1) - # 17 compute inheritance - # 18-19 get draft and published vert + # 15 get published vert + # 16-18 get ancestor chain + # 19 compute inheritance + # 20-22 get draft and published vert, compute parent # Sends: # delete the subtree of drafts (1 call), # update the published version of each node in subtree (4 calls), # update the ancestors up to course (2 calls) if mongo_uses_error_check(self.draft_mongo): - max_find = 20 + max_find = 23 else: - max_find = 19 + max_find = 22 with check_mongo_calls(max_find, 7): self.draft_mongo.publish(item.location, self.user_id) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py index 0c518a1..c72e91a 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py @@ -31,7 +31,7 @@ class TestXMLModuleStore(unittest.TestCase): Test around the XML modulestore """ def test_xml_modulestore_type(self): - store = XMLModuleStore(DATA_DIR, course_dirs=['toy', 'simple']) + store = XMLModuleStore(DATA_DIR, course_dirs=[]) self.assertEqual(store.get_modulestore_type(), ModuleStoreEnum.Type.xml) def test_unicode_chars_in_xml_content(self): diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index a836597..18c8ea4 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -53,7 +53,7 @@ def clean_out_mako_templating(xml_string): class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): def __init__(self, xmlstore, course_id, course_dir, - error_tracker, parent_tracker, + error_tracker, load_error_modules=True, **kwargs): """ A class that handles loading from xml. Does some munging to ensure that @@ -209,7 +209,8 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): if descriptor.has_children: for child in descriptor.get_children(): - parent_tracker.add_parent(child.scope_ids.usage_id, descriptor.scope_ids.usage_id) + child.parent = descriptor.location + child.save() # After setting up the descriptor, save any changes that we have # made to attributes on the descriptor to the underlying KeyValueStore. @@ -278,41 +279,6 @@ class CourseLocationManager(OpaqueKeyReader, AsideKeyGenerator): return usage_id -class ParentTracker(object): - """A simple class to factor out the logic for tracking location parent pointers.""" - def __init__(self): - """ - Init - """ - # location -> parent. Not using defaultdict because we care about the empty case. - self._parents = dict() - - def add_parent(self, child, parent): - """ - Add a parent of child location to the set of parents. Duplicate calls have no effect. - - child and parent must be :class:`.Location` instances. - """ - self._parents[child] = parent - - def is_known(self, child): - """ - returns True iff child has some parents. - """ - return child in self._parents - - def make_known(self, location): - """Tell the parent tracker about an object, without registering any - parents for it. Used for the top level course descriptor locations.""" - self._parents.setdefault(location, None) - - def parent(self, child): - """ - Return the parent of this child. If not is_known(child), will throw a KeyError - """ - return self._parents[child] - - class XMLModuleStore(ModuleStoreReadBase): """ An XML backed ModuleStore @@ -352,8 +318,6 @@ class XMLModuleStore(ModuleStoreReadBase): class_ = getattr(import_module(module_path), class_name) self.default_class = class_ - self.parent_trackers = defaultdict(ParentTracker) - # All field data will be stored in an inheriting field data. self.field_data = inheriting_field_data(kvs=DictKeyValueStore()) @@ -400,7 +364,7 @@ class XMLModuleStore(ModuleStoreReadBase): else: self.courses[course_dir] = course_descriptor self._course_errors[course_descriptor.id] = errorlog - self.parent_trackers[course_descriptor.id].make_known(course_descriptor.scope_ids.usage_id) + course_descriptor.parent = None def __unicode__(self): ''' @@ -512,7 +476,6 @@ class XMLModuleStore(ModuleStoreReadBase): course_id=course_id, course_dir=course_dir, error_tracker=tracker, - parent_tracker=self.parent_trackers[course_id], load_error_modules=self.load_error_modules, get_policy=get_policy, mixins=self.xblock_mixins, @@ -756,10 +719,8 @@ class XMLModuleStore(ModuleStoreReadBase): '''Find the location that is the parent of this location in this course. Needed for path_to_location(). ''' - if not self.parent_trackers[location.course_key].is_known(location): - raise ItemNotFoundError("{0} not in {1}".format(location, location.course_key)) - - return self.parent_trackers[location.course_key].parent(location) + block = self.get_item(location, 0) + return block.parent def get_modulestore_type(self, course_key=None): """ diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 4b946e4..e697c61 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -28,7 +28,7 @@ import json import re from lxml import etree -from .xml import XMLModuleStore, ImportSystem, ParentTracker +from .xml import XMLModuleStore, ImportSystem from xblock.runtime import KvsFieldData, DictKeyValueStore from xmodule.x_module import XModuleDescriptor from opaque_keys.edx.keys import UsageKey @@ -479,11 +479,13 @@ def _import_module_and_update_references( fields = {} for field_name, field in module.fields.iteritems(): - if field.is_set_on(module): - if field.scope == Scope.parent: - continue + if field.scope != Scope.parent and field.is_set_on(module): if isinstance(field, Reference): - fields[field_name] = _convert_reference_fields_to_new_namespace(field.read_from(module)) + value = field.read_from(module) + if value is None: + fields[field_name] = None + else: + fields[field_name] = _convert_reference_fields_to_new_namespace(field.read_from(module)) elif isinstance(field, ReferenceList): references = field.read_from(module) fields[field_name] = [_convert_reference_fields_to_new_namespace(reference) for reference in references] @@ -548,7 +550,6 @@ def _import_course_draft( course_id=source_course_id, course_dir=draft_course_dir, error_tracker=errorlog.tracker, - parent_tracker=ParentTracker(), load_error_modules=False, mixins=xml_module_store.xblock_mixins, field_data=KvsFieldData(kvs=DictKeyValueStore()), diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index effdf27..00f8600 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -30,7 +30,6 @@ class DummySystem(ImportSystem): course_id=SlashSeparatedCourseKey(ORG, COURSE, 'test_run'), course_dir='test_dir', error_tracker=Mock(), - parent_tracker=Mock(), load_error_modules=load_error_modules, ) diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index 5083c79..30313c6 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -36,14 +36,12 @@ class DummySystem(ImportSystem): course_id = SlashSeparatedCourseKey(ORG, COURSE, 'test_run') course_dir = "test_dir" error_tracker = Mock() - parent_tracker = Mock() super(DummySystem, self).__init__( xmlstore=xmlstore, course_id=course_id, course_dir=course_dir, error_tracker=error_tracker, - parent_tracker=parent_tracker, load_error_modules=load_error_modules, field_data=KvsFieldData(DictKeyValueStore()), ) diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 079b73f..3a8af12 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -39,14 +39,12 @@ class DummySystem(ImportSystem): course_id = SlashSeparatedCourseKey(ORG, COURSE, 'test_run') course_dir = "test_dir" error_tracker = Mock() - parent_tracker = Mock() super(DummySystem, self).__init__( xmlstore=xmlstore, course_id=course_id, course_dir=course_dir, error_tracker=error_tracker, - parent_tracker=parent_tracker, load_error_modules=load_error_modules, mixins=(InheritanceMixin, XModuleMixin), field_data=KvsFieldData(DictKeyValueStore()), diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 770d642..892e0a9 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -918,7 +918,8 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock): # =============================== BUILTIN METHODS ========================== def __eq__(self, other): - return (self.scope_ids == other.scope_ids and + return (hasattr(other, 'scope_ids') and + self.scope_ids == other.scope_ids and self.fields.keys() == other.fields.keys() and all(getattr(self, field.name) == getattr(other, field.name) for field in self.fields.values())) diff --git a/lms/djangoapps/courseware/tests/test_lti_integration.py b/lms/djangoapps/courseware/tests/test_lti_integration.py index 2c7da15..8c72a18 100644 --- a/lms/djangoapps/courseware/tests/test_lti_integration.py +++ b/lms/djangoapps/courseware/tests/test_lti_integration.py @@ -163,7 +163,7 @@ class TestLTIModuleListing(ModuleStoreTestCase): parent_location=self.section2.location, display_name="lti draft", category="lti", - location=self.course.id.make_usage_key('lti', 'lti_published'), + location=self.course.id.make_usage_key('lti', 'lti_draft'), publish_item=False, ) @@ -199,7 +199,7 @@ class TestLTIModuleListing(ModuleStoreTestCase): "lti_1_1_result_service_xml_endpoint": self.expected_handler_url('grade_handler'), "lti_2_0_result_service_json_endpoint": self.expected_handler_url('lti_2_0_result_rest_handler') + "/user/{anon_user_id}", - "display_name": self.lti_draft.display_name + "display_name": self.lti_published.display_name, } self.assertEqual([expected], json.loads(response.content)) diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index 3a7ddd8..0953391 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -1159,11 +1159,11 @@ class TestConditionalContent(TestSubmittingProblems): vertical_0, vertical_1 = self.split_setup(user_partition_group) # Group 0 will have 2 problems in the section, worth a total of 4 points. - self.add_dropdown_to_section(vertical_0.location, 'H2P1', 1).location.html_id() - self.add_dropdown_to_section(vertical_0.location, 'H2P2', 3).location.html_id() + self.add_dropdown_to_section(vertical_0.location, 'H2P1_GROUP0', 1).location.html_id() + self.add_dropdown_to_section(vertical_0.location, 'H2P2_GROUP0', 3).location.html_id() # Group 1 will have 1 problem in the section, worth a total of 1 point. - self.add_dropdown_to_section(vertical_1.location, 'H2P1', 1).location.html_id() + self.add_dropdown_to_section(vertical_1.location, 'H2P1_GROUP1', 1).location.html_id() # Submit answers for problem in Section 1, which is visible to all students. self.submit_question_answer('H1P1', {'2_1': 'Correct', '2_2': 'Incorrect'}) @@ -1175,8 +1175,8 @@ class TestConditionalContent(TestSubmittingProblems): """ self.split_different_problems_setup(self.user_partition_group_0) - self.submit_question_answer('H2P1', {'2_1': 'Correct'}) - self.submit_question_answer('H2P2', {'2_1': 'Correct', '2_2': 'Incorrect', '2_3': 'Correct'}) + self.submit_question_answer('H2P1_GROUP0', {'2_1': 'Correct'}) + self.submit_question_answer('H2P2_GROUP0', {'2_1': 'Correct', '2_2': 'Incorrect', '2_3': 'Correct'}) self.assertEqual(self.score_for_hw('homework1'), [1.0]) self.assertEqual(self.score_for_hw('homework2'), [1.0, 2.0]) @@ -1194,7 +1194,7 @@ class TestConditionalContent(TestSubmittingProblems): """ self.split_different_problems_setup(self.user_partition_group_1) - self.submit_question_answer('H2P1', {'2_1': 'Correct'}) + self.submit_question_answer('H2P1_GROUP1', {'2_1': 'Correct'}) self.assertEqual(self.score_for_hw('homework1'), [1.0]) self.assertEqual(self.score_for_hw('homework2'), [1.0]) @@ -1219,7 +1219,7 @@ class TestConditionalContent(TestSubmittingProblems): [_, vertical_1] = self.split_setup(user_partition_group) # Group 1 will have 1 problem in the section, worth a total of 1 point. - self.add_dropdown_to_section(vertical_1.location, 'H2P1', 1).location.html_id() + self.add_dropdown_to_section(vertical_1.location, 'H2P1_GROUP1', 1).location.html_id() self.submit_question_answer('H1P1', {'2_1': 'Correct'}) @@ -1244,7 +1244,7 @@ class TestConditionalContent(TestSubmittingProblems): """ self.split_one_group_no_problems_setup(self.user_partition_group_1) - self.submit_question_answer('H2P1', {'2_1': 'Correct'}) + self.submit_question_answer('H2P1_GROUP1', {'2_1': 'Correct'}) self.assertEqual(self.score_for_hw('homework1'), [1.0]) self.assertEqual(self.score_for_hw('homework2'), [1.0]) diff --git a/lms/djangoapps/instructor/tests/test_tools.py b/lms/djangoapps/instructor/tests/test_tools.py index 6c6c96e..a879592 100644 --- a/lms/djangoapps/instructor/tests/test_tools.py +++ b/lms/djangoapps/instructor/tests/test_tools.py @@ -119,7 +119,8 @@ class TestFindUnit(ModuleStoreTestCase): Test finding a nested unit. """ url = self.homework.location.to_deprecated_string() - self.assertEqual(tools.find_unit(self.course, url), self.homework) + found_unit = tools.find_unit(self.course, url) + self.assertEqual(found_unit.location, self.homework.location) def test_find_unit_notfound(self): """ diff --git a/lms/lib/xblock/test/test_mixin.py b/lms/lib/xblock/test/test_mixin.py index d97666b..329404c 100644 --- a/lms/lib/xblock/test/test_mixin.py +++ b/lms/lib/xblock/test/test_mixin.py @@ -1,8 +1,12 @@ """ Tests of the LMS XBlock Mixin """ +import ddt +from django.conf import settings from xblock.validation import ValidationMessage +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.modulestore_settings import update_module_store_settings from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.partitions.partitions import Group, UserPartition @@ -13,9 +17,11 @@ class LmsXBlockMixinTestCase(ModuleStoreTestCase): Base class for XBlock mixin tests cases. A simple course with a single user partition is created in setUp for all subclasses to use. """ - - def setUp(self): - super(LmsXBlockMixinTestCase, self).setUp() + def build_course(self): + """ + Build up a course tree with a UserPartition. + """ + # pylint: disable=attribute-defined-outside-init self.user_partition = UserPartition( 0, 'first_partition', @@ -31,13 +37,16 @@ class LmsXBlockMixinTestCase(ModuleStoreTestCase): self.section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') self.subsection = ItemFactory.create(parent=self.section, category='sequential', display_name='Test Subsection') self.vertical = ItemFactory.create(parent=self.subsection, category='vertical', display_name='Test Unit') - self.video = ItemFactory.create(parent=self.subsection, category='video', display_name='Test Video') + self.video = ItemFactory.create(parent=self.vertical, category='video', display_name='Test Video 1') class XBlockValidationTest(LmsXBlockMixinTestCase): """ Unit tests for XBlock validation """ + def setUp(self): + super(XBlockValidationTest, self).setUp() + self.build_course() def verify_validation_message(self, message, expected_message, expected_message_type): """ @@ -92,6 +101,9 @@ class XBlockGroupAccessTest(LmsXBlockMixinTestCase): """ Unit tests for XBlock group access. """ + def setUp(self): + super(XBlockGroupAccessTest, self).setUp() + self.build_course() def test_is_visible_to_group(self): """ @@ -143,3 +155,90 @@ class OpenAssessmentBlockMixinTestCase(ModuleStoreTestCase): Test has_score is true for ora2 problems. """ self.assertTrue(self.open_assessment.has_score) + + +@ddt.ddt +class XBlockGetParentTest(LmsXBlockMixinTestCase): + """ + Test that XBlock.get_parent returns correct results with each modulestore + backend. + """ + def _pre_setup(self): + # load the one xml course into the xml store + update_module_store_settings( + settings.MODULESTORE, + mappings={'edX/toy/2012_Fall': ModuleStoreEnum.Type.xml}, + xml_store_options={ + 'data_dir': settings.COMMON_TEST_DATA_ROOT # where toy course lives + }, + ) + super(XBlockGetParentTest, self)._pre_setup() + + @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.xml) + def test_parents(self, modulestore_type): + with self.store.default_store(modulestore_type): + + # setting up our own local course tree here, since it needs to be + # created with the correct modulestore type. + + if modulestore_type == 'xml': + course_key = self.store.make_course_key('edX', 'toy', '2012_Fall') + else: + course_key = self.create_toy_course('edX', 'toy', '2012_Fall_copy') + course = self.store.get_course(course_key) + + self.assertIsNone(course.get_parent()) + + def recurse(parent): + """ + Descend the course tree and ensure the result of get_parent() + is the expected one. + """ + visited = [] + for child in parent.get_children(): + self.assertEqual(parent.location, child.get_parent().location) + visited.append(child) + visited += recurse(child) + return visited + + visited = recurse(course) + self.assertEqual(len(visited), 28) + + @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) + def test_parents_draft_content(self, modulestore_type): + # move the video to the new vertical + with self.store.default_store(modulestore_type): + self.build_course() + new_vertical = ItemFactory.create(parent=self.subsection, category='vertical', display_name='New Test Unit') + child_to_move_location = self.video.location.for_branch(None) + new_parent_location = new_vertical.location.for_branch(None) + old_parent_location = self.vertical.location.for_branch(None) + + with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred): + self.assertIsNone(self.course.get_parent()) + + with self.store.bulk_operations(self.course.id): + user_id = ModuleStoreEnum.UserID.test + + old_parent = self.store.get_item(old_parent_location) + old_parent.children.remove(child_to_move_location) + self.store.update_item(old_parent, user_id) + + new_parent = self.store.get_item(new_parent_location) + new_parent.children.append(child_to_move_location) + self.store.update_item(new_parent, user_id) + + # re-fetch video from draft store + video = self.store.get_item(child_to_move_location) + + self.assertEqual( + new_parent_location, + video.get_parent().location + ) + with self.store.branch_setting(ModuleStoreEnum.Branch.published_only): + # re-fetch video from published store + video = self.store.get_item(child_to_move_location) + self.assertEqual( + old_parent_location, + video.get_parent().location.for_branch(None) + ) -- libgit2 0.26.0