From e9d3e9438cf5b3bdd691cb8f9f8a27747e84de73 Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Thu, 13 Aug 2026 15:03:55 -0700 Subject: [PATCH 1/3] adding tests for importing layer fixture relationships: companions, attributeInfo, and LookupInfo --- layers/tests/test_fixture_import.py | 225 +++++++++++++++++++++++++++- 1 file changed, 224 insertions(+), 1 deletion(-) diff --git a/layers/tests/test_fixture_import.py b/layers/tests/test_fixture_import.py index 449a85a..bdbdcb3 100644 --- a/layers/tests/test_fixture_import.py +++ b/layers/tests/test_fixture_import.py @@ -4,7 +4,13 @@ from django.test import TestCase from layers.fixture_contract import build_node, build_ref -from layers.models import Layer, MultilayerAssociation +from layers.models import ( + AttributeInfo, + Companionship, + Layer, + LookupInfo, + MultilayerAssociation, +) try: from layers.fixture_import import import_fixture_rows @@ -195,6 +201,223 @@ def test_duplicate_uuid_rows_with_conflicting_fields_raise_error(self): with self.assertRaises(ValueError): import_fixture_rows(fixture_rows, **self._import_kwargs()) + +class LayerFixtureImportPR06Test(TestCase): + """PR06 contract tests for associated model import behavior.""" + + def _require_importer(self): + self.assertIsNotNone( + import_fixture_rows, + "importer API missing: expected layers.fixture_import.import_fixture_rows", + ) + + def _import_kwargs(self): + return { + "dry_run": False, + "associate_all_sites": True, + "missing_ref_policy": "error", + "duplicate_uuid_policy": "error", + } + + def _layer_fields(self, name): + return { + "name": name, + "layer_type": "WMS", + "slug_name": None, + "url": None, + } + + def test_attributeinfo_uuid_match_updates_existing_even_when_source_pk_differs(self): + self._require_importer() + + attribute_uuid = uuid4() + existing_attr = AttributeInfo.objects.create( + uuid=attribute_uuid, + display_name="Original Label", + field_name="old_field", + order=1, + ) + + fixture_rows = [ + build_node( + model="layers.attributeinfo", + source_pk=8801, + uuid_value=attribute_uuid, + fields={ + "display_name": "Updated Label", + "field_name": "new_field", + "order": 7, + }, + relations={}, + ) + ] + + import_fixture_rows(fixture_rows, **self._import_kwargs()) + + existing_attr.refresh_from_db() + self.assertEqual(existing_attr.display_name, "Updated Label") + self.assertEqual(existing_attr.field_name, "new_field") + self.assertEqual(AttributeInfo.objects.filter(uuid=attribute_uuid).count(), 1) + + def test_lookupinfo_source_pk_collision_with_different_uuid_creates_new_record(self): + self._require_importer() + + existing_lookup = LookupInfo.objects.create(value="A", description="existing") + new_uuid = uuid4() + + fixture_rows = [ + build_node( + model="layers.lookupinfo", + source_pk=existing_lookup.pk, + uuid_value=new_uuid, + fields={ + "value": "B", + "description": "imported", + "dashstyle": "solid", + }, + relations={}, + ) + ] + + before_count = LookupInfo.objects.count() + import_fixture_rows(fixture_rows, **self._import_kwargs()) + + self.assertEqual(LookupInfo.objects.count(), before_count + 1) + self.assertTrue(LookupInfo.objects.filter(uuid=new_uuid).exists()) + + def test_second_pass_resolves_layer_attribute_fields_by_uuid(self): + self._require_importer() + + layer_uuid = uuid4() + attr_uuid = uuid4() + + fixture_rows = [ + build_node( + model="layers.attributeinfo", + source_pk=9301, + uuid_value=attr_uuid, + fields={ + "display_name": "Area", + "field_name": "area_sqkm", + "order": 2, + }, + relations={}, + ), + build_node( + model="layers.layer", + source_pk=9302, + uuid_value=layer_uuid, + fields=self._layer_fields("Layer With Attributes"), + relations={ + "attribute_fields": [ + build_ref( + model="layers.attributeinfo", + source_pk=77701, + uuid_value=attr_uuid, + ) + ] + }, + ), + ] + + import_fixture_rows(fixture_rows, **self._import_kwargs()) + + imported_layer = Layer.objects.get(uuid=layer_uuid) + imported_attr = AttributeInfo.objects.get(uuid=attr_uuid) + self.assertEqual(imported_layer.attribute_fields.count(), 1) + self.assertEqual(imported_layer.attribute_fields.first().pk, imported_attr.pk) + + def test_second_pass_resolves_companionship_layer_and_companions_by_uuid(self): + self._require_importer() + + owner_uuid = uuid4() + companion_a_uuid = uuid4() + companion_b_uuid = uuid4() + + fixture_rows = [ + build_node( + model="layers.layer", + source_pk=9401, + uuid_value=owner_uuid, + fields=self._layer_fields("Owner Layer"), + relations={}, + ), + build_node( + model="layers.layer", + source_pk=9402, + uuid_value=companion_a_uuid, + fields=self._layer_fields("Companion A"), + relations={}, + ), + build_node( + model="layers.layer", + source_pk=9403, + uuid_value=companion_b_uuid, + fields=self._layer_fields("Companion B"), + relations={}, + ), + build_node( + model="layers.companionship", + source_pk=9404, + uuid_value=None, + fields={}, + relations={ + "layer": build_ref( + model="layers.layer", + source_pk=55501, + uuid_value=owner_uuid, + ), + "companions": [ + build_ref( + model="layers.layer", + source_pk=55502, + uuid_value=companion_a_uuid, + ), + build_ref( + model="layers.layer", + source_pk=55503, + uuid_value=companion_b_uuid, + ), + ], + }, + ), + ] + + import_fixture_rows(fixture_rows, **self._import_kwargs()) + + imported_owner = Layer.objects.get(uuid=owner_uuid) + companionship = Companionship.objects.get(layer=imported_owner) + companion_uuids = set( + companionship.companions.values_list("uuid", flat=True) + ) + self.assertEqual(companion_uuids, {companion_a_uuid, companion_b_uuid}) + + def test_missing_attribute_relation_uuid_raises_error_under_strict_policy(self): + self._require_importer() + + layer_uuid = uuid4() + missing_attr_uuid = uuid4() + fixture_rows = [ + build_node( + model="layers.layer", + source_pk=9501, + uuid_value=layer_uuid, + fields=self._layer_fields("Layer Missing Attribute Ref"), + relations={ + "attribute_fields": [ + build_ref( + model="layers.attributeinfo", + source_pk=88801, + uuid_value=missing_attr_uuid, + ) + ] + }, + ) + ] + + with self.assertRaises(ValueError): + import_fixture_rows(fixture_rows, **self._import_kwargs()) + def test_missing_relation_uuid_raises_error_under_strict_policy(self): """Raise ValueError if required relations are missing from the fixture.""" self._require_importer() From af56e2f637d8cb86437422469b8f22fdb3b26254 Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Thu, 13 Aug 2026 16:25:46 -0700 Subject: [PATCH 2/3] layer fixture import now supports non-slider references --- layers/fixture_import.py | 116 ++++++++++++++++-- layers/tests/test_fixture_import.py | 179 ++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 13 deletions(-) diff --git a/layers/fixture_import.py b/layers/fixture_import.py index cc47562..06f659e 100644 --- a/layers/fixture_import.py +++ b/layers/fixture_import.py @@ -22,6 +22,16 @@ LAYER_MODEL = "layers.layer" MULTILAYER_ASSOCIATION_MODEL = "layers.multilayerassociation" +ATTRIBUTE_INFO_MODEL = "layers.attributeinfo" +LOOKUP_INFO_MODEL = "layers.lookupinfo" +COMPANIONSHIP_MODEL = "layers.companionship" +SPECIFIC_LAYER_MODELS = { + "layers.layerwms", + "layers.layerarcrest", + "layers.layerxyz", + "layers.layerarcfeatureservice", + "layers.layervector", +} def _model_manager(model_class): @@ -93,6 +103,13 @@ def _resolve_ref_instance(ref_obj, missing_ref_policy): return None +def _resolve_ref_list(ref_list, missing_ref_policy): + resolved = [] + for ref_obj in ref_list or []: + resolved.append(_resolve_ref_instance(ref_obj, missing_ref_policy)) + return resolved + + def import_fixture_rows( rows, dry_run=False, @@ -124,26 +141,47 @@ def import_fixture_rows( association_manager = _model_manager(MultilayerAssociation) def _execute_import(): - # First pass: upsert layer rows by UUID. + # First pass: upsert UUID-keyed rows that do not require relation remaps. for row in rows: - if row.get(NODE_MODEL_KEY) != LAYER_MODEL: + model_label = row.get(NODE_MODEL_KEY) + if model_label not in {LAYER_MODEL, ATTRIBUTE_INFO_MODEL, LOOKUP_INFO_MODEL}: continue - layer_uuid = normalize_uuid(row.get(NODE_UUID_KEY)) - if not layer_uuid: - raise ValueError("Layer row missing UUID") + row_uuid = normalize_uuid(row.get(NODE_UUID_KEY)) + if not row_uuid: + raise ValueError("%s row missing UUID" % model_label) - layer_fields = dict(row.get(NODE_FIELDS_KEY, {})) - layer_obj = layer_manager.filter(uuid=layer_uuid).first() - is_new = layer_obj is None + model_class = apps.get_model(model_label) + model_manager = _model_manager(model_class) + row_fields = dict(row.get(NODE_FIELDS_KEY, {})) + row_obj = model_manager.filter(uuid=row_uuid).first() + is_new = row_obj is None if is_new: - layer_obj = Layer(uuid=layer_uuid) + row_obj = model_class(uuid=row_uuid) + + _apply_fields(row_obj, row_fields) + row_obj.save() + + if model_label == LAYER_MODEL and associate_all_sites: + row_obj.site.set(Site.objects.all()) + + # Second pass: resolve layer m2m attribute refs by UUID. + for row in rows: + if row.get(NODE_MODEL_KEY) != LAYER_MODEL: + continue - _apply_fields(layer_obj, layer_fields) - layer_obj.save() + layer_uuid = normalize_uuid(row.get(NODE_UUID_KEY)) + layer_obj = layer_manager.filter(uuid=layer_uuid).first() + if layer_obj is None: + raise ValueError("Missing layer object for UUID %s" % layer_uuid) - if associate_all_sites: - layer_obj.site.set(Site.objects.all()) + relations = row.get(NODE_RELATIONS_KEY, {}) + if "attribute_fields" in relations: + resolved_attributes = _resolve_ref_list( + relations.get("attribute_fields") or [], + missing_ref_policy, + ) + layer_obj.attribute_fields.set(resolved_attributes) # Second pass: upsert multilayer associations and resolve FKs by UUID refs. for row in rows: @@ -175,6 +213,58 @@ def _execute_import(): assoc_obj.layer = layer_obj assoc_obj.save() + # Second pass: companionship relation rows (non-UUID identity). + for row in rows: + if row.get(NODE_MODEL_KEY) != COMPANIONSHIP_MODEL: + continue + + relations = row.get(NODE_RELATIONS_KEY, {}) + owner_ref = relations.get("layer") + if not owner_ref: + raise ValueError("Missing layer relation for Companionship") + + owner_layer = _resolve_ref_instance(owner_ref, missing_ref_policy) + companion_layers = _resolve_ref_list( + relations.get("companions") or [], + missing_ref_policy, + ) + + Companionship = apps.get_model(COMPANIONSHIP_MODEL) + companionship = Companionship.objects.filter(layer=owner_layer).first() + if companionship is None: + companionship = Companionship(layer=owner_layer) + companionship.save() + + companionship.companions.set(companion_layers) + + # Second pass: specific layer subtype rows keyed by resolved base layer relation. + for row in rows: + model_label = row.get(NODE_MODEL_KEY) + if model_label not in SPECIFIC_LAYER_MODELS: + continue + + relations = row.get(NODE_RELATIONS_KEY, {}) + layer_ref = relations.get("layer") + if not layer_ref: + raise ValueError("Missing layer relation for %s" % model_label) + + layer_obj = _resolve_ref_instance(layer_ref, missing_ref_policy) + model_class = apps.get_model(model_label) + + specific_obj = model_class.objects.filter(layer=layer_obj).first() + if specific_obj is None: + specific_obj = model_class(layer=layer_obj) + + _apply_fields(specific_obj, row.get(NODE_FIELDS_KEY, {})) + specific_obj.save() + + if "lookup_table" in relations and hasattr(specific_obj, "lookup_table"): + resolved_lookup_refs = _resolve_ref_list( + relations.get("lookup_table") or [], + missing_ref_policy, + ) + specific_obj.lookup_table.set(resolved_lookup_refs) + if dry_run: with transaction.atomic(): _execute_import() diff --git a/layers/tests/test_fixture_import.py b/layers/tests/test_fixture_import.py index bdbdcb3..66cfecd 100644 --- a/layers/tests/test_fixture_import.py +++ b/layers/tests/test_fixture_import.py @@ -8,6 +8,11 @@ AttributeInfo, Companionship, Layer, + LayerArcFeatureService, + LayerArcREST, + LayerVector, + LayerWMS, + LayerXYZ, LookupInfo, MultilayerAssociation, ) @@ -418,6 +423,180 @@ def test_missing_attribute_relation_uuid_raises_error_under_strict_policy(self): with self.assertRaises(ValueError): import_fixture_rows(fixture_rows, **self._import_kwargs()) + def test_second_pass_resolves_specific_layer_rows_by_layer_uuid(self): + self._require_importer() + + wms_uuid = uuid4() + arcrest_uuid = uuid4() + xyz_uuid = uuid4() + afs_uuid = uuid4() + vector_uuid = uuid4() + + fixture_rows = [ + build_node( + model="layers.layer", + source_pk=9601, + uuid_value=wms_uuid, + fields={**self._layer_fields("WMS Layer"), "layer_type": "WMS"}, + relations={}, + ), + build_node( + model="layers.layer", + source_pk=9602, + uuid_value=arcrest_uuid, + fields={**self._layer_fields("ArcREST Layer"), "layer_type": "ArcRest"}, + relations={}, + ), + build_node( + model="layers.layer", + source_pk=9603, + uuid_value=xyz_uuid, + fields={**self._layer_fields("XYZ Layer"), "layer_type": "XYZ"}, + relations={}, + ), + build_node( + model="layers.layer", + source_pk=9604, + uuid_value=afs_uuid, + fields={ + **self._layer_fields("ArcFeature Layer"), + "layer_type": "ArcFeatureServer", + }, + relations={}, + ), + build_node( + model="layers.layer", + source_pk=9605, + uuid_value=vector_uuid, + fields={**self._layer_fields("Vector Layer"), "layer_type": "Vector"}, + relations={}, + ), + build_node( + model="layers.layerwms", + source_pk=9701, + uuid_value=None, + fields={"wms_slug": "sample:layer", "wms_version": "1.1.1"}, + relations={ + "layer": build_ref( + model="layers.layer", + source_pk=19901, + uuid_value=wms_uuid, + ) + }, + ), + build_node( + model="layers.layerarcrest", + source_pk=9702, + uuid_value=None, + fields={"arcgis_layers": "0,1"}, + relations={ + "layer": build_ref( + model="layers.layer", + source_pk=19902, + uuid_value=arcrest_uuid, + ) + }, + ), + build_node( + model="layers.layerxyz", + source_pk=9703, + uuid_value=None, + fields={}, + relations={ + "layer": build_ref( + model="layers.layer", + source_pk=19903, + uuid_value=xyz_uuid, + ) + }, + ), + build_node( + model="layers.layerarcfeatureservice", + source_pk=9704, + uuid_value=None, + fields={"arcgis_layers": "2"}, + relations={ + "layer": build_ref( + model="layers.layer", + source_pk=19904, + uuid_value=afs_uuid, + ) + }, + ), + build_node( + model="layers.layervector", + source_pk=9705, + uuid_value=None, + fields={"lookup_field": "kind"}, + relations={ + "layer": build_ref( + model="layers.layer", + source_pk=19905, + uuid_value=vector_uuid, + ) + }, + ), + ] + + import_fixture_rows(fixture_rows, **self._import_kwargs()) + + self.assertTrue(LayerWMS.objects.filter(layer__uuid=wms_uuid).exists()) + self.assertTrue(LayerArcREST.objects.filter(layer__uuid=arcrest_uuid).exists()) + self.assertTrue(LayerXYZ.objects.filter(layer__uuid=xyz_uuid).exists()) + self.assertTrue( + LayerArcFeatureService.objects.filter(layer__uuid=afs_uuid).exists() + ) + self.assertTrue(LayerVector.objects.filter(layer__uuid=vector_uuid).exists()) + + def test_vector_lookup_table_relations_resolve_by_lookup_uuid(self): + self._require_importer() + + vector_uuid = uuid4() + lookup_uuid = uuid4() + + fixture_rows = [ + build_node( + model="layers.layer", + source_pk=9801, + uuid_value=vector_uuid, + fields={**self._layer_fields("Vector + Lookup"), "layer_type": "Vector"}, + relations={}, + ), + build_node( + model="layers.lookupinfo", + source_pk=9802, + uuid_value=lookup_uuid, + fields={"value": "1", "description": "one", "dashstyle": "solid"}, + relations={}, + ), + build_node( + model="layers.layervector", + source_pk=9803, + uuid_value=None, + fields={"lookup_field": "class"}, + relations={ + "layer": build_ref( + model="layers.layer", + source_pk=29901, + uuid_value=vector_uuid, + ), + "lookup_table": [ + build_ref( + model="layers.lookupinfo", + source_pk=29902, + uuid_value=lookup_uuid, + ) + ], + }, + ), + ] + + import_fixture_rows(fixture_rows, **self._import_kwargs()) + + vector_row = LayerVector.objects.get(layer__uuid=vector_uuid) + self.assertEqual(vector_row.lookup_table.count(), 1) + self.assertEqual(vector_row.lookup_table.first().uuid, lookup_uuid) + def test_missing_relation_uuid_raises_error_under_strict_policy(self): """Raise ValueError if required relations are missing from the fixture.""" self._require_importer() From 562244e34c0918af45dc7b3c108d33cf401c66b1 Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Thu, 13 Aug 2026 16:52:45 -0700 Subject: [PATCH 3/3] handle potential edge case where multiple companion records may exist for a single layer during fixture import --- layers/fixture_import.py | 13 +++- layers/tests/test_fixture_import.py | 108 ++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/layers/fixture_import.py b/layers/fixture_import.py index 06f659e..131b44a 100644 --- a/layers/fixture_import.py +++ b/layers/fixture_import.py @@ -230,12 +230,21 @@ def _execute_import(): ) Companionship = apps.get_model(COMPANIONSHIP_MODEL) - companionship = Companionship.objects.filter(layer=owner_layer).first() + companionship = Companionship.objects.filter(layer=owner_layer).order_by("pk").first() if companionship is None: companionship = Companionship(layer=owner_layer) companionship.save() - companionship.companions.set(companion_layers) + existing_companion_ids = set( + companionship.companions.values_list("pk", flat=True) + ) + companions_to_add = [ + companion + for companion in companion_layers + if companion.pk not in existing_companion_ids + ] + if companions_to_add: + companionship.companions.add(*companions_to_add) # Second pass: specific layer subtype rows keyed by resolved base layer relation. for row in rows: diff --git a/layers/tests/test_fixture_import.py b/layers/tests/test_fixture_import.py index 66cfecd..81c68d1 100644 --- a/layers/tests/test_fixture_import.py +++ b/layers/tests/test_fixture_import.py @@ -397,6 +397,114 @@ def test_second_pass_resolves_companionship_layer_and_companions_by_uuid(self): ) self.assertEqual(companion_uuids, {companion_a_uuid, companion_b_uuid}) + def test_multiple_companionship_rows_for_same_owner_merge_into_first_record(self): + self._require_importer() + + owner_uuid = uuid4() + companion_a_uuid = uuid4() + companion_b_uuid = uuid4() + companion_c_uuid = uuid4() + + fixture_rows = [ + build_node( + model="layers.layer", + source_pk=9411, + uuid_value=owner_uuid, + fields=self._layer_fields("Owner Layer"), + relations={}, + ), + build_node( + model="layers.layer", + source_pk=9412, + uuid_value=companion_a_uuid, + fields=self._layer_fields("Companion A"), + relations={}, + ), + build_node( + model="layers.layer", + source_pk=9413, + uuid_value=companion_b_uuid, + fields=self._layer_fields("Companion B"), + relations={}, + ), + build_node( + model="layers.layer", + source_pk=9414, + uuid_value=companion_c_uuid, + fields=self._layer_fields("Companion C"), + relations={}, + ), + build_node( + model="layers.companionship", + source_pk=9415, + uuid_value=None, + fields={}, + relations={ + "layer": build_ref( + model="layers.layer", + source_pk=55601, + uuid_value=owner_uuid, + ), + "companions": [ + build_ref( + model="layers.layer", + source_pk=55602, + uuid_value=companion_a_uuid, + ), + build_ref( + model="layers.layer", + source_pk=55603, + uuid_value=companion_b_uuid, + ), + ], + }, + ), + build_node( + model="layers.companionship", + source_pk=9416, + uuid_value=None, + fields={}, + relations={ + "layer": build_ref( + model="layers.layer", + source_pk=55611, + uuid_value=owner_uuid, + ), + "companions": [ + build_ref( + model="layers.layer", + source_pk=55612, + uuid_value=companion_c_uuid, + ), + ], + }, + ), + ] + + import_fixture_rows(fixture_rows, **self._import_kwargs()) + + imported_owner = Layer.objects.get(uuid=owner_uuid) + companionship_rows = Companionship.objects.filter(layer=imported_owner) + self.assertEqual(companionship_rows.count(), 1) + + companionship = companionship_rows.first() + companion_set = set(companionship.companions.values_list("uuid", flat=True)) + self.assertEqual( + companion_set, + {companion_a_uuid, companion_b_uuid, companion_c_uuid}, + ) + + # Importing the same fixture again should not duplicate identical rows. + import_fixture_rows(fixture_rows, **self._import_kwargs()) + self.assertEqual(Companionship.objects.filter(layer=imported_owner).count(), 1) + companion_set = set( + Companionship.objects.get(layer=imported_owner).companions.values_list("uuid", flat=True) + ) + self.assertEqual( + companion_set, + {companion_a_uuid, companion_b_uuid, companion_c_uuid}, + ) + def test_missing_attribute_relation_uuid_raises_error_under_strict_policy(self): self._require_importer()