From 39ed37490166fd1ce9e0f1251d1cf84719e000ba Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Tue, 10 Nov 2026 10:41:40 +0530 Subject: [PATCH 1/2] OpenConceptLab/ocl_issues#3010 | Expansion records whether version create built it with lock or latest, and a rebuild follows that --- .../migrations/0072_expansion_built_with.py | 18 ++++++++++ core/collections/models.py | 10 ++++-- core/collections/tests/tests.py | 35 ++++++++++++++++++- 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 core/collections/migrations/0072_expansion_built_with.py diff --git a/core/collections/migrations/0072_expansion_built_with.py b/core/collections/migrations/0072_expansion_built_with.py new file mode 100644 index 000000000..350ca1d22 --- /dev/null +++ b/core/collections/migrations/0072_expansion_built_with.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-11-10 05:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collections', '0071_retire_public_edit_access'), + ] + + operations = [ + migrations.AddField( + model_name='expansion', + name='built_with', + field=models.CharField(blank=True, choices=[('lock', 'lock'), ('latest', 'latest')], max_length=10, null=True), + ), + ] diff --git a/core/collections/models.py b/core/collections/models.py index 2d8d092d3..1d707b398 100644 --- a/core/collections/models.py +++ b/core/collections/models.py @@ -450,6 +450,7 @@ def seed_from_head_expansion_in_snapshot(self, is_outermost, lock=True): parameters[ExpansionParameters.INCLUDE_SYSTEM] = system_versions expansion = Expansion( mnemonic=mnemonic, collection_version=self, parameters=parameters, + built_with=VERSION_EXPANSION_LOCK if lock else VERSION_EXPANSION_LATEST, created_by_id=self.created_by_id, updated_by_id=self.created_by_id) expansion.clean() expansion.full_clean() @@ -1540,6 +1541,10 @@ class Meta: evaluated_source_versions = models.ManyToManyField( 'sources.Source', related_name='expansions_evaluated_source_versions_set') unresolved_repo_versions = ArrayField(models.JSONField(), default=list, null=True, blank=True) + # lock or latest when version create built it with that choice, so a rebuild repeats how it was built + built_with = models.CharField( + max_length=10, null=True, blank=True, + choices=[(choice, choice) for choice in (VERSION_EXPANSION_LOCK, VERSION_EXPANSION_LATEST)]) @property def is_head(self): @@ -2128,10 +2133,9 @@ def replace_head_rows(rows, owner, field, klass): def seed_children(self, index=True, force_reevaluate=False): version = self.collection_version - # the expansion version create built is rebuilt as it was built, whichever expansion is now the default: + # one built with lock or latest is rebuilt as it was built, whatever the toggle or default expansion is now: # HEAD rows swapped for resource versions and cascades without a cap - swap_head = force_reevaluate and not version.is_head and self.is_auto_generated and \ - CollectionReference.is_expansion_locked_repo_version_on() + swap_head = force_reevaluate and not version.is_head and bool(self.built_with) previous_concept_ids = set(self.concepts.values_list('id', flat=True)) if swap_head and index else set() previous_mapping_ids = set(self.mappings.values_list('id', flat=True)) if swap_head and index else set() references = version.references diff --git a/core/collections/tests/tests.py b/core/collections/tests/tests.py index c05898449..bdaf14cec 100644 --- a/core/collections/tests/tests.py +++ b/core/collections/tests/tests.py @@ -2361,6 +2361,38 @@ def test_rebuild_keeps_members_past_the_cascade_cap_as_creation_does(self, _): self.assertGreater(expansion.concepts.count(), 1000) self.assertTrue(expansion.concepts.filter(versioned_object_id=grandchild.versioned_object_id).exists()) + @patch('core.common.tasks.export_collection') + def test_rebuild_keeps_members_past_the_cascade_cap_after_the_toggle_is_turned_off(self, _): + grandchild, version = self.head_cascading_past_the_cap() + seed_children_to_new_version('collection', version.id, False, expansion='lock') # pylint: disable=no-value-for-parameter + version.refresh_from_db() + expansion = version.expansion + member_ids = set(expansion.concepts.values_list('id', flat=True)) + + with patch.object(CollectionReference, 'is_expansion_locked_repo_version_on', return_value=False): + expansion.seed_children(index=False, force_reevaluate=True) + + self.assertEqual(set(expansion.concepts.values_list('id', flat=True)), member_ids) + self.assertTrue(expansion.concepts.filter(versioned_object_id=grandchild.versioned_object_id).exists()) + + @patch('core.common.tasks.export_collection') + def test_rebuild_of_an_expansion_built_with_the_toggle_off_stays_capped_once_it_is_on(self, _): + grandchild, version = self.head_cascading_past_the_cap() + with patch.object(CollectionReference, 'is_expansion_locked_repo_version_on', return_value=False): + seed_children_to_new_version('collection', version.id, False) # pylint: disable=no-value-for-parameter + version.refresh_from_db() + expansion = version.expansion + self.assertIsNone(expansion.built_with) + member_ids = set(expansion.concepts.values_list('id', flat=True)) + self.assertFalse(expansion.concepts.filter(versioned_object_id=grandchild.versioned_object_id).exists()) + other = ExpansionFactory(collection_version=version, mnemonic='other') + Collection.objects.filter(id=version.id).update(expansion_uri=other.uri) + expansion = Expansion.objects.get(id=expansion.id) + + expansion.seed_children(index=False, force_reevaluate=True) + + self.assertEqual(set(expansion.concepts.values_list('id', flat=True)), member_ids) + @patch('core.common.tasks.export_collection') def test_rebuild_keeps_members_past_the_cascade_cap_when_another_expansion_is_the_default(self, _): grandchild, version = self.head_cascading_past_the_cap() @@ -2417,12 +2449,13 @@ def test_rebuild_keeps_the_resource_versions_a_head_resolved_version_was_created expansion.seed_children(index=False, force_reevaluate=True) + self.assertEqual(expansion.built_with, choice) self.assertEqual(list(expansion.concepts.all()), [latest]) self.assertEqual(list(collection_v1.references.first().concepts.all()), [latest]) with patch.object(CollectionReference, 'is_expansion_locked_repo_version_on', return_value=False): expansion.seed_children(index=False, force_reevaluate=True) - self.assertEqual(list(expansion.concepts.all()), [concept]) + self.assertEqual(list(expansion.concepts.all()), [latest]) @patch('core.common.tasks.export_collection') def test_seed_children_task_evaluates_references_while_head_expansion_is_processing(self, _): From 6b010c0dea347fd4e05e1f593ddc03a818e7e20b Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Tue, 10 Nov 2026 11:09:21 +0530 Subject: [PATCH 2/2] OpenConceptLab/ocl_issues#3010 | An expansion built with lock or latest evaluates value-set-only references as built, whatever the toggle --- core/collections/models.py | 3 ++- core/collections/tests/tests.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/core/collections/models.py b/core/collections/models.py index 1d707b398..34344ad9d 100644 --- a/core/collections/models.py +++ b/core/collections/models.py @@ -1868,7 +1868,8 @@ def add_references( # pylint: disable=too-many-locals,too-many-statements,too-m should_reevaluate = force_reevaluate or (attempt_reevaluate and not self.is_auto_generated) resolve_by_resource_version = CollectionReference.is_resource_version_repo_resolution_on() - use_any_lock = CollectionReference.is_expansion_locked_repo_version_on() + # one built with lock or latest keeps evaluating as it was built, whatever the toggle is now + use_any_lock = CollectionReference.is_expansion_locked_repo_version_on() or bool(self.built_with) lock_filters = {} if use_any_lock else {'released': True, 'retired': False} should_write_back = use_any_lock and self.is_default resolved_ref_ids = [] diff --git a/core/collections/tests/tests.py b/core/collections/tests/tests.py index bdaf14cec..b9fd32149 100644 --- a/core/collections/tests/tests.py +++ b/core/collections/tests/tests.py @@ -2361,6 +2361,29 @@ def test_rebuild_keeps_members_past_the_cascade_cap_as_creation_does(self, _): self.assertGreater(expansion.concepts.count(), 1000) self.assertTrue(expansion.concepts.filter(versioned_object_id=grandchild.versioned_object_id).exists()) + @patch('core.common.tasks.export_collection') + def test_rebuild_keeps_valueset_only_members_after_the_toggle_is_turned_off(self, _): + valueset = OrganizationCollectionFactory() + _, concept = self.valueset_release(valueset, 'v1') + head = OrganizationCollectionFactory() + expansion = ExpansionFactory(collection_version=head, mnemonic='autoexpand-HEAD') + head.expansion_uri = expansion.uri + head.save() + reference = CollectionReference( + expression=valueset.uri, collection=head, valueset=[valueset.uri], reference_type='concepts') + reference.clean() + reference.save() + version = OrganizationCollectionFactory(organization=head.organization, version='v1', mnemonic=head.mnemonic) + seed_children_to_new_version('collection', version.id, False, expansion='lock') # pylint: disable=no-value-for-parameter + version.refresh_from_db() + expansion = version.expansion + self.assertEqual(list(expansion.concepts.all()), [concept]) + + with patch.object(CollectionReference, 'is_expansion_locked_repo_version_on', return_value=False): + expansion.seed_children(index=False, force_reevaluate=True) + + self.assertEqual(list(expansion.concepts.all()), [concept]) + @patch('core.common.tasks.export_collection') def test_rebuild_keeps_members_past_the_cascade_cap_after_the_toggle_is_turned_off(self, _): grandchild, version = self.head_cascading_past_the_cap()