diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst index 1d93a17c6b8..8342571ab5c 100644 --- a/docs/en_US/preferences.rst +++ b/docs/en_US/preferences.rst @@ -662,6 +662,12 @@ provider used as a base layer when viewing geometry data. When a custom tile provider is configured, it is selected as the default base layer of the Geometry Viewer. +An administrator may set a system-wide default for these fields via the +``DEFAULT_GEOMETRY_VIEWER_PROVIDER`` setting in :ref:`config_py`. It applies +to any user who has not saved their own values for the fields above; a +user's own preferences, once saved, always take precedence over the +system-wide default. + .. image:: images/preferences_graph_visualiser.png :alt: Preferences sqleditor graph visualiser section :align: center diff --git a/web/config.py b/web/config.py index f39c6085198..6847d928c6a 100644 --- a/web/config.py +++ b/web/config.py @@ -546,6 +546,26 @@ ########################################################################## DATA_RESULT_ROWS_PER_PAGE = 1000 +########################################################################## +# System-wide default for the Geometry Viewer's custom tile provider +# (Query Tool > Data Output > Geometry Viewer). Applies to any user who +# has not saved their own "Custom tile provider ..." preference; a +# per-user preference, once set, always takes precedence over this +# default. Leave "url" empty to keep using the built-in tile layers by +# default (the pre-existing behaviour). +# +# "crs" must be one of "EPSG:3857" (Web Mercator), "EPSG:4326", or +# "EPSG:3395" - the only coordinate reference systems the Geometry +# Viewer's tile layer selector supports. +########################################################################## +DEFAULT_GEOMETRY_VIEWER_PROVIDER = { + "url": "", + "name": "Custom", + "crs": "EPSG:3857", + "attribution": "", + "max_zoom": 18 +} + ########################################################################## # Allow users to display Gravatar image for their username in Server mode ########################################################################## diff --git a/web/pgadmin/tools/sqleditor/tests/test_geometry_viewer_provider_defaults.py b/web/pgadmin/tools/sqleditor/tests/test_geometry_viewer_provider_defaults.py new file mode 100644 index 00000000000..239fefe9a47 --- /dev/null +++ b/web/pgadmin/tools/sqleditor/tests/test_geometry_viewer_provider_defaults.py @@ -0,0 +1,81 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from pgadmin.tools.sqleditor.utils import query_tool_preferences as qtp + + +class GeometryViewerProviderDefaultsTestCase(BaseTestGenerator): + """ + Verify resolve_geometry_viewer_provider_defaults() safely resolves + config.DEFAULT_GEOMETRY_VIEWER_PROVIDER, since config_local.py / + config_distro.py / PGADMIN_CONFIG_* replace that variable wholesale + rather than merging individual keys - a partial, wrong-typed, or + out-of-range administrator override must fall back to the original + hardcoded defaults instead of crashing preference registration. + """ + + _FULL_VALID = { + 'url': 'https://internal.example.com/tiles/{z}/{x}/{y}.png', + 'name': 'Internal', + 'crs': 'EPSG:4326', + 'attribution': 'Internal Corp', + 'max_zoom': 12, + } + + # What every field falls back to when the admin-supplied value for it + # is missing, the wrong type, or out of range. + _ALL_FALLBACK = { + 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', + 'attribution': '', 'max_zoom': 18, + } + + _partial_override_expected = dict(_ALL_FALLBACK) + _partial_override_expected['url'] = \ + 'https://x.example.com/{z}/{x}/{y}.png' + + scenarios = [ + ('Fully valid override is used as-is', + dict(provider=_FULL_VALID, expected=_FULL_VALID)), + + ('Partial override falls back for missing keys', + dict(provider={'url': 'https://x.example.com/{z}/{x}/{y}.png'}, + expected=_partial_override_expected)), + + ('Non-dict override falls back entirely', + dict(provider='oops, not a dict', expected=_ALL_FALLBACK)), + + ('Unsupported CRS falls back to EPSG:3857', + dict(provider={'crs': 'EPSG:9999'}, expected=_ALL_FALLBACK)), + + ('Out-of-range max_zoom falls back to 18', + dict(provider={'max_zoom': 999}, expected=_ALL_FALLBACK)), + + ('Non-integer max_zoom falls back to 18', + dict(provider={'max_zoom': '18'}, expected=_ALL_FALLBACK)), + + ('Boolean max_zoom falls back to 18', + dict(provider={'max_zoom': True}, expected=_ALL_FALLBACK)), + + ('Non-string name/url/attribution fall back individually', + dict(provider={'name': 123, 'url': 456, 'attribution': 789}, + expected=_ALL_FALLBACK)), + + ('Empty dict falls back entirely', + dict(provider={}, expected=_ALL_FALLBACK)), + ] + + def runTest(self): + with patch.object(qtp.config, 'DEFAULT_GEOMETRY_VIEWER_PROVIDER', + self.provider): + result = qtp.resolve_geometry_viewer_provider_defaults() + + self.assertEqual(result, self.expected) diff --git a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py index 65165316145..5b6e8941f24 100644 --- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py +++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py @@ -8,6 +8,7 @@ ########################################################################## """Register preferences for query tool""" +import config from flask_babel import gettext from pgadmin.utils.constants import PREF_LABEL_DISPLAY, \ PREF_LABEL_KEYBOARD_SHORTCUTS, PREF_LABEL_EXPLAIN, PREF_LABEL_OPTIONS, \ @@ -16,8 +17,49 @@ from pgadmin.utils import SHORTCUT_FIELDS as shortcut_fields from config import DATA_RESULT_ROWS_PER_PAGE +GEOMETRY_VIEWER_CRS_CHOICES = ('EPSG:3857', 'EPSG:4326', 'EPSG:3395') + + +def resolve_geometry_viewer_provider_defaults(): + """Resolve config.DEFAULT_GEOMETRY_VIEWER_PROVIDER into safe defaults + for the geometry_viewer preferences. + + config_local.py/config_distro.py/PGADMIN_CONFIG_* replace this single + config.py variable wholesale rather than merging individual keys, so + an administrator overriding only some fields, or setting the wrong + type entirely (e.g. a string instead of a dict), must not crash + preference registration for the rest, and an out-of-range/unsupported + value must not silently reach the frontend unvalidated. + """ + provider = config.DEFAULT_GEOMETRY_VIEWER_PROVIDER + if not isinstance(provider, dict): + provider = {} + + def _str_default(key, fallback): + value = provider.get(key, fallback) + return value if isinstance(value, str) else fallback + + crs = provider.get('crs', 'EPSG:3857') + if crs not in GEOMETRY_VIEWER_CRS_CHOICES: + crs = 'EPSG:3857' + + max_zoom = provider.get('max_zoom', 18) + if not isinstance(max_zoom, int) or isinstance(max_zoom, bool) or \ + not 0 <= max_zoom <= 25: + max_zoom = 18 + + return { + 'url': _str_default('url', ''), + 'name': _str_default('name', 'Custom'), + 'crs': crs, + 'attribution': _str_default('attribution', ''), + 'max_zoom': max_zoom, + } + def register_query_tool_preferences(self): + geometry_viewer_defaults = resolve_geometry_viewer_provider_defaults() + self.explain_verbose = self.preference.register( 'Explain', 'explain_verbose', gettext("Verbose output?"), 'boolean', False, @@ -841,7 +883,8 @@ def register_query_tool_preferences(self): self.custom_tile_url = self.preference.register( 'geometry_viewer', 'custom_tile_url', - gettext("Custom tile provider URL"), 'text', '', + gettext("Custom tile provider URL"), 'text', + geometry_viewer_defaults['url'], category_label=PREF_LABEL_GEOMETRY_VIEWER, control_props={ 'placeholder': 'https://{s}.tile.example.com/{z}/{x}/{y}.png', @@ -853,13 +896,16 @@ def register_query_tool_preferences(self): '.png. The template must contain the {x}, {y} and ' '{z} placeholders, and may contain {s} for ' 'subdomains (a, b, c). Leave empty to disable the ' - 'custom tile provider.'), + 'custom tile provider. Defaults to the ' + 'administrator-configured ' + 'DEFAULT_GEOMETRY_VIEWER_PROVIDER, if any.'), allow_blanks=True ) self.custom_tile_name = self.preference.register( 'geometry_viewer', 'custom_tile_name', - gettext("Custom tile provider name"), 'text', 'Custom', + gettext("Custom tile provider name"), 'text', + geometry_viewer_defaults['name'], category_label=PREF_LABEL_GEOMETRY_VIEWER, help_str=gettext('Display name of the custom tile provider in the ' 'layer selector of the Geometry Viewer.'), @@ -868,7 +914,8 @@ def register_query_tool_preferences(self): self.custom_tile_crs = self.preference.register( 'geometry_viewer', 'custom_tile_crs', - gettext("Custom tile provider CRS"), 'options', 'EPSG:3857', + gettext("Custom tile provider CRS"), 'options', + geometry_viewer_defaults['crs'], category_label=PREF_LABEL_GEOMETRY_VIEWER, options=[{'label': gettext('EPSG:3857 (Web Mercator)'), 'value': 'EPSG:3857'}, @@ -886,7 +933,8 @@ def register_query_tool_preferences(self): self.custom_tile_attribution = self.preference.register( 'geometry_viewer', 'custom_tile_attribution', - gettext("Custom tile provider attribution"), 'text', '', + gettext("Custom tile provider attribution"), 'text', + geometry_viewer_defaults['attribution'], category_label=PREF_LABEL_GEOMETRY_VIEWER, help_str=gettext('Attribution text shown on the map for the custom ' 'tile provider. May contain HTML links.'), @@ -895,7 +943,8 @@ def register_query_tool_preferences(self): self.custom_tile_max_zoom = self.preference.register( 'geometry_viewer', 'custom_tile_max_zoom', - gettext("Custom tile provider max zoom"), 'integer', 18, + gettext("Custom tile provider max zoom"), 'integer', + geometry_viewer_defaults['max_zoom'], min_val=0, max_val=25, category_label=PREF_LABEL_GEOMETRY_VIEWER, help_str=gettext('Maximum zoom level of the custom tile provider.')