From 42ae8fa466f474cd8ff5741f62652173fbaf527e Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Thu, 30 Apr 2026 14:52:03 -0700 Subject: [PATCH 01/10] support and replace data_manager's get_portal_catalog_map view --- layers/models.py | 22 +++ layers/tests/test_models.py | 302 +++++++++++++++++++++++++++++++++++- layers/urls.py | 1 + layers/views.py | 13 ++ 4 files changed, 337 insertions(+), 1 deletion(-) diff --git a/layers/models.py b/layers/models.py index 3dc2192..ee5cecd 100644 --- a/layers/models.py +++ b/layers/models.py @@ -455,6 +455,28 @@ def orders(self): content_type = ContentType.objects.get_for_model(self.__class__) return ChildOrder.objects.filter(object_id=self.id, content_type=content_type) + # This property gets an array of all of the Layer records that are direct children of this Theme + # This is particularly used by the 'get_portal_catalog_map' view to identify 'visualizable' layers + # in the catalog. + @property + def layers(self): + content_type = ContentType.objects.get_for_model(Layer) + child_orders = ChildOrder.objects.filter(parent_theme=self, content_type=content_type).order_by('order', 'id') + layers = [child_order.content_object for child_order in child_orders] + return layers + + # This property gets an array of all of the Layer records that are decendats of this Theme + # This is particularly used by the 'get_portal_catalog_map' view to identify 'visualizable' layers in the catalog. + @property + def all_layers(self): + content_type = ContentType.objects.get_for_model(Theme) + child_theme_orders = ChildOrder.objects.filter(parent_theme=self, content_type=content_type).order_by('order', 'id') + layers = self.layers + for child_order in child_theme_orders: + if child_order.content_object.is_visible: + layers = layers + child_order.content_object.all_layers + return list(set(layers)) + # return dict formatted for use in bootstrap-3-typeahead 'layer search' widget in 'visualize' # overrides ChildType method to include any 'sublayer' information. def get_search_object(self, site_id, parent_theme): diff --git a/layers/tests/test_models.py b/layers/tests/test_models.py index 4406b65..baa52a0 100644 --- a/layers/tests/test_models.py +++ b/layers/tests/test_models.py @@ -1,7 +1,9 @@ -from django.test import TestCase +from django.test import TestCase, RequestFactory, override_settings from layers.models import Theme, Layer, MultilayerAssociation, MultilayerDimension, MultilayerDimensionValue, Companionship, LayerWMS, LayerArcREST, LayerArcFeatureService, LayerVector, LayerXYZ, ChildOrder from layers.serializers import ThemeSerializer, LayerWMSSerializer, CompanionLayerSerializer, LayerArcRESTSerializer, LayerArcFeatureServiceSerializer, LayerXYZSerializer, LayerVectorSerializer, SubThemeSerializer, ChildOrderSerializer +from layers.views import get_portal_catalog_map from collections.abc import Collection +import json from django.contrib.sites.models import Site from django.contrib.contenttypes.models import ContentType # request to get data from live site, mung it and make it into v2 @@ -864,3 +866,301 @@ def test_multilayer_related_attributes(self): self.assertEqual([], january_data["dimensions"]) self.assertEqual({}, january_data["associated_multilayers"]) + +class ThemeLayersPropertyTest(TestCase): + """Tests for Theme.layers and Theme.all_layers model properties.""" + + def setUp(self): + self.site = Site.objects.get(pk=1) + + self.top_theme = Theme.objects.create( + name="Top Theme", + is_top_theme=True, + is_visible=True, + ) + self.top_theme.site.add(self.site) + + self.sub_theme = Theme.objects.create( + name="Sub Theme", + is_visible=True, + ) + self.sub_theme.site.add(self.site) + + self.hidden_sub_theme = Theme.objects.create( + name="Hidden Sub Theme", + is_visible=False, + ) + self.hidden_sub_theme.site.add(self.site) + + self.direct_layer = Layer.objects.create( + name="Direct Layer", + layer_type="WMS", + catalog_name="direct-catalog", + ) + self.direct_layer.site.add(self.site) + + self.nested_layer = Layer.objects.create( + name="Nested Layer", + layer_type="WMS", + catalog_name="nested-catalog", + ) + self.nested_layer.site.add(self.site) + + self.hidden_nested_layer = Layer.objects.create( + name="Hidden Nested Layer", + layer_type="WMS", + catalog_name="hidden-nested-catalog", + ) + self.hidden_nested_layer.site.add(self.site) + + # top_theme -> direct_layer (direct child) + ChildOrder.objects.create( + parent_theme=self.top_theme, + content_object=self.direct_layer, + order=1, + ) + # top_theme -> sub_theme -> nested_layer + ChildOrder.objects.create( + parent_theme=self.top_theme, + content_object=self.sub_theme, + order=2, + ) + ChildOrder.objects.create( + parent_theme=self.sub_theme, + content_object=self.nested_layer, + order=1, + ) + # top_theme -> hidden_sub_theme -> hidden_nested_layer + ChildOrder.objects.create( + parent_theme=self.top_theme, + content_object=self.hidden_sub_theme, + order=3, + ) + ChildOrder.objects.create( + parent_theme=self.hidden_sub_theme, + content_object=self.hidden_nested_layer, + order=1, + ) + + def test_layers_returns_only_direct_layer_children(self): + result = self.top_theme.layers + self.assertIn(self.direct_layer, result) + self.assertNotIn(self.nested_layer, result) + self.assertNotIn(self.hidden_nested_layer, result) + self.assertNotIn(self.sub_theme, result) + + def test_layers_returns_correct_count(self): + # Only direct_layer is a direct Layer child of top_theme + self.assertEqual(len(self.top_theme.layers), 1) + + def test_layers_on_sub_theme(self): + result = self.sub_theme.layers + self.assertIn(self.nested_layer, result) + self.assertEqual(len(result), 1) + + def test_layers_empty_when_no_direct_layer_children(self): + theme_no_layers = Theme.objects.create(name="No Layers Theme") + theme_no_layers.site.add(self.site) + ChildOrder.objects.create( + parent_theme=theme_no_layers, + content_object=self.sub_theme, + order=1, + ) + self.assertEqual(theme_no_layers.layers, []) + + def test_all_layers_includes_direct_and_nested(self): + result = self.top_theme.all_layers + self.assertIn(self.direct_layer, result) + self.assertIn(self.nested_layer, result) + + def test_all_layers_excludes_layers_under_hidden_sub_theme(self): + # hidden_sub_theme has is_visible=False so its layers must not appear + result = self.top_theme.all_layers + self.assertNotIn(self.hidden_nested_layer, result) + + def test_all_layers_contains_no_theme_objects(self): + result = self.top_theme.all_layers + for item in result: + self.assertIsInstance(item, Layer) + + def test_all_layers_returns_unique_layers(self): + # Add nested_layer as a direct child too to create a duplicate + ChildOrder.objects.create( + parent_theme=self.top_theme, + content_object=self.nested_layer, + order=4, + ) + result = self.top_theme.all_layers + self.assertEqual(len(result), len(set(result))) + + def test_all_layers_on_leaf_theme_equals_layers(self): + # sub_theme has no sub-themes, so all_layers == layers + self.assertEqual( + set(self.sub_theme.all_layers), + set(self.sub_theme.layers), + ) + + +class GetPortalCatalogMapViewTest(TestCase): + """Tests for the get_portal_catalog_map view.""" + + def setUp(self): + self.site = Site.objects.get(pk=1) + self.factory = RequestFactory() + + self.top_theme = Theme.objects.create( + name="Catalog Top Theme", + is_top_theme=True, + is_visible=True, + ) + self.top_theme.site.add(self.site) + + self.sub_theme = Theme.objects.create( + name="Catalog Sub Theme", + is_visible=True, + ) + self.sub_theme.site.add(self.site) + + self.layer_with_catalog_name = Layer.objects.create( + name="Mapped Layer", + layer_type="WMS", + catalog_name="my-catalog-record", + ) + self.layer_with_catalog_name.site.add(self.site) + + self.layer_no_catalog_name = Layer.objects.create( + name="Unmapped Layer", + layer_type="WMS", + catalog_name=None, + ) + self.layer_no_catalog_name.site.add(self.site) + + self.layer_empty_catalog_name = Layer.objects.create( + name="Empty Catalog Layer", + layer_type="WMS", + catalog_name="", + ) + self.layer_empty_catalog_name.site.add(self.site) + + self.nested_layer = Layer.objects.create( + name="Nested Catalog Layer", + layer_type="WMS", + catalog_name="nested-catalog-record", + ) + self.nested_layer.site.add(self.site) + + ChildOrder.objects.create( + parent_theme=self.top_theme, + content_object=self.layer_with_catalog_name, + order=1, + ) + ChildOrder.objects.create( + parent_theme=self.top_theme, + content_object=self.layer_no_catalog_name, + order=2, + ) + ChildOrder.objects.create( + parent_theme=self.top_theme, + content_object=self.layer_empty_catalog_name, + order=3, + ) + ChildOrder.objects.create( + parent_theme=self.top_theme, + content_object=self.sub_theme, + order=4, + ) + ChildOrder.objects.create( + parent_theme=self.sub_theme, + content_object=self.nested_layer, + order=1, + ) + + def _get(self): + request = self.factory.get("/layers/get_portal_catalog_map") + return get_portal_catalog_map(request) + + @override_settings(CATALOG_TECHNOLOGY="GeoPortal2") + def test_returns_200(self): + response = self._get() + self.assertEqual(response.status_code, 200) + + @override_settings(CATALOG_TECHNOLOGY="GeoPortal2") + def test_maps_catalog_name_to_layer_pk(self): + response = self._get() + data = json.loads(response.content) + self.assertIn("my-catalog-record", data) + self.assertEqual(data["my-catalog-record"], self.layer_with_catalog_name.pk) + + @override_settings(CATALOG_TECHNOLOGY="GeoPortal2") + def test_includes_nested_layer_under_visible_sub_theme(self): + response = self._get() + data = json.loads(response.content) + self.assertIn("nested-catalog-record", data) + self.assertEqual(data["nested-catalog-record"], self.nested_layer.pk) + + @override_settings(CATALOG_TECHNOLOGY="GeoPortal2") + def test_excludes_layers_without_catalog_name(self): + response = self._get() + data = json.loads(response.content) + # layer_no_catalog_name has catalog_name=None + for key in data: + self.assertNotEqual(data[key], self.layer_no_catalog_name.pk) + + @override_settings(CATALOG_TECHNOLOGY="GeoPortal2") + def test_excludes_layers_with_empty_catalog_name(self): + response = self._get() + data = json.loads(response.content) + self.assertNotIn("", data) + + @override_settings(CATALOG_TECHNOLOGY="GeoPortal2") + def test_excludes_layers_from_invisible_top_theme(self): + invisible_theme = Theme.objects.create( + name="Invisible Top Theme", + is_top_theme=True, + is_visible=False, + ) + invisible_theme.site.add(self.site) + hidden_layer = Layer.objects.create( + name="Hidden Layer", + layer_type="WMS", + catalog_name="should-not-appear", + ) + hidden_layer.site.add(self.site) + ChildOrder.objects.create( + parent_theme=invisible_theme, + content_object=hidden_layer, + order=1, + ) + response = self._get() + data = json.loads(response.content) + self.assertNotIn("should-not-appear", data) + + @override_settings(CATALOG_TECHNOLOGY="GeoPortal2") + def test_excludes_layers_from_non_top_level_theme(self): + orphan_theme = Theme.objects.create( + name="Orphan Theme", + is_top_theme=False, + is_visible=True, + ) + orphan_theme.site.add(self.site) + orphan_layer = Layer.objects.create( + name="Orphan Layer", + layer_type="WMS", + catalog_name="orphan-catalog-record", + ) + orphan_layer.site.add(self.site) + ChildOrder.objects.create( + parent_theme=orphan_theme, + content_object=orphan_layer, + order=1, + ) + response = self._get() + data = json.loads(response.content) + self.assertNotIn("orphan-catalog-record", data) + + @override_settings(CATALOG_TECHNOLOGY="NotGeoPortal2") + def test_returns_empty_dict_when_catalog_technology_not_geoportal2(self): + response = self._get() + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.content), {}) + diff --git a/layers/urls.py b/layers/urls.py index 4dfc2af..7019cdc 100644 --- a/layers/urls.py +++ b/layers/urls.py @@ -18,6 +18,7 @@ re_path(r'^get_layer_catalog_content/(?P\w+)/(?P\d+)/(?P\d+)/?$', views.get_layer_catalog_content), path('get_layer_catalog_content///', views.get_layer_catalog_content), re_path(r'^get_catalog_records/', views.get_catalog_records), + re_path(r'^get_portal_catalog_map', views.get_portal_catalog_map), # This allows the catalog page to link to 'visualize' layers re_path(r'^migration/layer_status/', views.layer_status), re_path(r'^migration/layer_details/', views.migration_layer_details), re_path(r'^picker/', views.get_picker), diff --git a/layers/views.py b/layers/views.py index 899e442..9ff8aac 100644 --- a/layers/views.py +++ b/layers/views.py @@ -584,6 +584,19 @@ def get_catalog_records(request): return JsonResponse(data) +# This allows the catalog page to link to 'visualize' layers, which requires knowing the layerID that corresponds to a +# given catalog record. Only layers with a catalog_name field will be included in this mapping. +def get_portal_catalog_map(request): + data = {} + if settings.CATALOG_TECHNOLOGY == "GeoPortal2": + viable_layers = [] + for top_theme in Theme.objects.filter(is_top_theme=True).filter(is_visible=True): + viable_layers += [x for x in top_theme.all_layers if x.catalog_name not in ["", None]] + viable_layers = list(set(viable_layers)) + for layer in viable_layers: + data[layer.catalog_name] = layer.pk + return JsonResponse(data) + def get_sublayers_data(parent_theme): sublayers_data = [] sub_child_orders = ChildOrder.objects.filter(parent_theme=parent_theme).order_by('order') From 7c611c053318dc67cacdcb16d3a9c9264589da2e Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Fri, 1 May 2026 16:36:33 -0700 Subject: [PATCH 02/10] Only include visible layers in get_portal_catalog_map Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- layers/models.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/layers/models.py b/layers/models.py index ee5cecd..df2bebb 100644 --- a/layers/models.py +++ b/layers/models.py @@ -462,7 +462,11 @@ def orders(self): def layers(self): content_type = ContentType.objects.get_for_model(Layer) child_orders = ChildOrder.objects.filter(parent_theme=self, content_type=content_type).order_by('order', 'id') - layers = [child_order.content_object for child_order in child_orders] + layers = [ + child_order.content_object + for child_order in child_orders + if child_order.content_object.is_visible + ] return layers # This property gets an array of all of the Layer records that are decendats of this Theme From 9c13039d07e23416dc2eb000285c417bb86868b1 Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Fri, 1 May 2026 16:42:25 -0700 Subject: [PATCH 03/10] Clean up logic to build response in get_portal_catalog_map View. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- layers/views.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/layers/views.py b/layers/views.py index 9ff8aac..e71bd89 100644 --- a/layers/views.py +++ b/layers/views.py @@ -589,12 +589,15 @@ def get_catalog_records(request): def get_portal_catalog_map(request): data = {} if settings.CATALOG_TECHNOLOGY == "GeoPortal2": - viable_layers = [] + seen_pks = set() for top_theme in Theme.objects.filter(is_top_theme=True).filter(is_visible=True): - viable_layers += [x for x in top_theme.all_layers if x.catalog_name not in ["", None]] - viable_layers = list(set(viable_layers)) - for layer in viable_layers: - data[layer.catalog_name] = layer.pk + for layer in top_theme.all_layers: + if layer.catalog_name in ["", None]: + continue + if layer.pk in seen_pks: + continue + seen_pks.add(layer.pk) + data[layer.catalog_name] = layer.pk return JsonResponse(data) def get_sublayers_data(parent_theme): From f6e3ff6a0a246dccd34383941a4a216c2233fcbf Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Fri, 1 May 2026 16:44:28 -0700 Subject: [PATCH 04/10] clean up testing to avoid set() with unhashable types Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- layers/tests/test_models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/layers/tests/test_models.py b/layers/tests/test_models.py index baa52a0..8466162 100644 --- a/layers/tests/test_models.py +++ b/layers/tests/test_models.py @@ -991,13 +991,13 @@ def test_all_layers_returns_unique_layers(self): order=4, ) result = self.top_theme.all_layers - self.assertEqual(len(result), len(set(result))) + self.assertEqual(len(result), len(set(layer.pk for layer in result))) def test_all_layers_on_leaf_theme_equals_layers(self): # sub_theme has no sub-themes, so all_layers == layers self.assertEqual( - set(self.sub_theme.all_layers), - set(self.sub_theme.layers), + set(layer.pk for layer in self.sub_theme.all_layers), + set(layer.pk for layer in self.sub_theme.layers), ) From aeb3a75999ff56cc52e020cd3d89e20f119aec69 Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Fri, 1 May 2026 16:45:46 -0700 Subject: [PATCH 05/10] clean up Theme.all_layers to prevent unhashable types in set() Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- layers/models.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/layers/models.py b/layers/models.py index df2bebb..41db7f0 100644 --- a/layers/models.py +++ b/layers/models.py @@ -479,8 +479,14 @@ def all_layers(self): for child_order in child_theme_orders: if child_order.content_object.is_visible: layers = layers + child_order.content_object.all_layers - return list(set(layers)) + unique_layers = [] + seen_layer_pks = set() + for layer in layers: + if layer.pk not in seen_layer_pks: + seen_layer_pks.add(layer.pk) + unique_layers.append(layer) + return unique_layers # return dict formatted for use in bootstrap-3-typeahead 'layer search' widget in 'visualize' # overrides ChildType method to include any 'sublayer' information. def get_search_object(self, site_id, parent_theme): From 0080ffa7e1e327d06ab704ceeafa27f467da7b21 Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Fri, 1 May 2026 16:49:43 -0700 Subject: [PATCH 06/10] minor typo --- layers/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/layers/models.py b/layers/models.py index 41db7f0..3d8a81c 100644 --- a/layers/models.py +++ b/layers/models.py @@ -469,7 +469,7 @@ def layers(self): ] return layers - # This property gets an array of all of the Layer records that are decendats of this Theme + # This property gets an array of all of the Layer records that are descendants of this Theme # This is particularly used by the 'get_portal_catalog_map' view to identify 'visualizable' layers in the catalog. @property def all_layers(self): From 41c259093d7a5c72a993b501649b53585b773034 Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Fri, 1 May 2026 17:28:17 -0700 Subject: [PATCH 07/10] make GeoPortal overrides work on Layer form in mp-layers --- layers/static/admin/js/layer_admin.js | 16 +++++++++------- layers/static/layers/js/catalog/GeoPortal2.js | 11 +++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/layers/static/admin/js/layer_admin.js b/layers/static/admin/js/layer_admin.js index 46e4e24..c2e76a8 100644 --- a/layers/static/admin/js/layer_admin.js +++ b/layers/static/admin/js/layer_admin.js @@ -22,13 +22,6 @@ document.addEventListener("DOMContentLoaded", function() { }); - const layerTypeField = document.querySelector("#id_layer_type"); - if (layerTypeField) { - layerTypeField.addEventListener("change", updateInlines); - updateInlines(); - assign_field_values_from_source_technology(); - } - assign_field_values_from_source_technology = function() { if ($('#id_layer_type').val() == "ArcRest" || $('#id_layer_type').val() == "ArcFeatureServer") { var url = $('#id_url').val(); @@ -91,5 +84,14 @@ document.addEventListener("DOMContentLoaded", function() { } } } + + const layerTypeField = document.querySelector("#id_layer_type"); + if (layerTypeField) { + layerTypeField.addEventListener("change", updateInlines); + updateInlines(); + assign_field_values_from_source_technology(); + } + + assign_field_values_from_source_technology() }); \ No newline at end of file diff --git a/layers/static/layers/js/catalog/GeoPortal2.js b/layers/static/layers/js/catalog/GeoPortal2.js index 71ad81d..ff8631a 100644 --- a/layers/static/layers/js/catalog/GeoPortal2.js +++ b/layers/static/layers/js/catalog/GeoPortal2.js @@ -4,6 +4,12 @@ // //////////////////////////////////////////////////////////////////////////////// +const union = function (array1, array2) { + var hash = {}, union_arr = []; + $.each($.merge($.merge([], array1), array2), function (index, value) { hash[value] = value; }); + $.each(hash, function (key, value) { union_arr.push(key); }); + return union_arr; +} var populate_fields_from_catalog = function (catalog_record_data, record_id) { if (record_id == null || record_id == "null") { @@ -25,6 +31,11 @@ var populate_fields_from_catalog = function (catalog_record_data, record_id) { record_json = data._source; record_json.id = data._id; aggregate_catalog_record_values(record_json); + }, + error: function (error) { + console.error("Error retrieving catalog record: " + error); + // AdminLayerForm.replace_all_select2_with_input(); + hide_spinner(); } }); } From 28618916769c978bac12332b5599de264b1f1754 Mon Sep 17 00:00:00 2001 From: Ryan Hodges Date: Fri, 1 May 2026 17:29:02 -0700 Subject: [PATCH 08/10] remove unnecessary inclusion of knockout in admin forms --- layers/templates/admin/layers/Layer/change_form.html | 1 - layers/templates/admin/layers/Theme/change_form.html | 1 - 2 files changed, 2 deletions(-) diff --git a/layers/templates/admin/layers/Layer/change_form.html b/layers/templates/admin/layers/Layer/change_form.html index 3e58d8a..7217fb9 100644 --- a/layers/templates/admin/layers/Layer/change_form.html +++ b/layers/templates/admin/layers/Layer/change_form.html @@ -11,7 +11,6 @@ - -