From 36d3aea1fa93d860bc848192f3c9c79e5e004d69 Mon Sep 17 00:00:00 2001 From: Ashesh Vashi Date: Fri, 24 Jul 2026 22:20:49 +0530 Subject: [PATCH 1/3] feat: add system-wide default for Geometry Viewer custom tile provider Follow-up to #10142. Geometry Viewer's custom tile provider was only configurable per-user, with no way for an administrator to set an organization-wide default (e.g. an internal tile server) that applies to every user out of the box. Add DEFAULT_GEOMETRY_VIEWER_PROVIDER to config.py and source the five custom_tile_* preference defaults from it instead of hardcoded literals. Preference.get() only reads a per-user DB row if the user has explicitly saved one, otherwise falling back to this default - so this is pure override semantics: a user's own saved preference always wins, and there is no second/competing provider entry, so no naming-conflict surface is introduced. --- docs/en_US/preferences.rst | 6 ++++++ web/config.py | 20 ++++++++++++++++++ .../sqleditor/utils/query_tool_preferences.py | 21 ++++++++++++------- 3 files changed, 40 insertions(+), 7 deletions(-) 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/utils/query_tool_preferences.py b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py index 65165316145..909c9654673 100644 --- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py +++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py @@ -14,7 +14,7 @@ PREF_LABEL_CSV_TXT, PREF_LABEL_RESULTS_GRID, \ PREF_LABEL_GRAPH_VISUALISER, PREF_LABEL_GEOMETRY_VIEWER from pgadmin.utils import SHORTCUT_FIELDS as shortcut_fields -from config import DATA_RESULT_ROWS_PER_PAGE +from config import DATA_RESULT_ROWS_PER_PAGE, DEFAULT_GEOMETRY_VIEWER_PROVIDER def register_query_tool_preferences(self): @@ -841,7 +841,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', + DEFAULT_GEOMETRY_VIEWER_PROVIDER['url'], category_label=PREF_LABEL_GEOMETRY_VIEWER, control_props={ 'placeholder': 'https://{s}.tile.example.com/{z}/{x}/{y}.png', @@ -853,13 +854,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', + DEFAULT_GEOMETRY_VIEWER_PROVIDER['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 +872,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', + DEFAULT_GEOMETRY_VIEWER_PROVIDER['crs'], category_label=PREF_LABEL_GEOMETRY_VIEWER, options=[{'label': gettext('EPSG:3857 (Web Mercator)'), 'value': 'EPSG:3857'}, @@ -886,7 +891,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', + DEFAULT_GEOMETRY_VIEWER_PROVIDER['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 +901,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', + DEFAULT_GEOMETRY_VIEWER_PROVIDER['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.') From a4ff44c0102974dab6ea59dd3c55dfa91d1594a4 Mon Sep 17 00:00:00 2001 From: Ashesh Vashi Date: Fri, 24 Jul 2026 22:34:46 +0530 Subject: [PATCH 2/3] fix: guard geometry viewer default config against partial/invalid overrides DEFAULT_GEOMETRY_VIEWER_PROVIDER is replaced wholesale by config_local.py/config_distro.py/PGADMIN_CONFIG_*, not merged key-by-key. Direct dict indexing crashed preference registration on any partial override (KeyError) or non-dict value (AttributeError), and an out-of-range/wrong-type crs or max_zoom would have reached the frontend unvalidated. Move the defaulting logic into resolve_geometry_viewer_provider_defaults(), computed fresh each time register_query_tool_preferences() runs (previously computed once at import time, which also made it unmockable in tests): falls back per-field for missing/wrong-type values, validates crs against the 3 supported choices, and validates max_zoom is a non-bool int in [0, 25]. Adds test_geometry_viewer_provider_defaults.py covering fully valid, partial, non-dict, invalid crs, out-of-range/non-int/bool max_zoom, and non-string field scenarios - run via the project's actual regression harness (not just an ad hoc script). --- .../test_geometry_viewer_provider_defaults.py | 102 ++++++++++++++++++ .../sqleditor/utils/query_tool_preferences.py | 54 ++++++++-- 2 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 web/pgadmin/tools/sqleditor/tests/test_geometry_viewer_provider_defaults.py 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..4d74b5af922 --- /dev/null +++ b/web/pgadmin/tools/sqleditor/tests/test_geometry_viewer_provider_defaults.py @@ -0,0 +1,102 @@ +########################################################################## +# +# 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, + } + + 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={ + 'url': 'https://x.example.com/{z}/{x}/{y}.png', + 'name': 'Custom', 'crs': 'EPSG:3857', + 'attribution': '', 'max_zoom': 18, + })), + + ('Non-dict override falls back entirely', + dict(provider='oops, not a dict', + expected={ + 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', + 'attribution': '', 'max_zoom': 18, + })), + + ('Unsupported CRS falls back to EPSG:3857', + dict(provider={'crs': 'EPSG:9999'}, + expected={ + 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', + 'attribution': '', 'max_zoom': 18, + })), + + ('Out-of-range max_zoom falls back to 18', + dict(provider={'max_zoom': 999}, + expected={ + 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', + 'attribution': '', 'max_zoom': 18, + })), + + ('Non-integer max_zoom falls back to 18', + dict(provider={'max_zoom': '18'}, + expected={ + 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', + 'attribution': '', 'max_zoom': 18, + })), + + ('Boolean max_zoom falls back to 18', + dict(provider={'max_zoom': True}, + expected={ + 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', + 'attribution': '', 'max_zoom': 18, + })), + + ('Non-string name/url/attribution fall back individually', + dict(provider={'name': 123, 'url': 456, 'attribution': 789}, + expected={ + 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', + 'attribution': '', 'max_zoom': 18, + })), + + ('Empty dict falls back entirely', + dict(provider={}, + expected={ + 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', + 'attribution': '', 'max_zoom': 18, + })), + ] + + 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 909c9654673..5b6e8941f24 100644 --- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py +++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py @@ -8,16 +8,58 @@ ########################################################################## """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, \ PREF_LABEL_CSV_TXT, PREF_LABEL_RESULTS_GRID, \ PREF_LABEL_GRAPH_VISUALISER, PREF_LABEL_GEOMETRY_VIEWER from pgadmin.utils import SHORTCUT_FIELDS as shortcut_fields -from config import DATA_RESULT_ROWS_PER_PAGE, DEFAULT_GEOMETRY_VIEWER_PROVIDER +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, @@ -842,7 +884,7 @@ def register_query_tool_preferences(self): self.custom_tile_url = self.preference.register( 'geometry_viewer', 'custom_tile_url', gettext("Custom tile provider URL"), 'text', - DEFAULT_GEOMETRY_VIEWER_PROVIDER['url'], + geometry_viewer_defaults['url'], category_label=PREF_LABEL_GEOMETRY_VIEWER, control_props={ 'placeholder': 'https://{s}.tile.example.com/{z}/{x}/{y}.png', @@ -863,7 +905,7 @@ def register_query_tool_preferences(self): self.custom_tile_name = self.preference.register( 'geometry_viewer', 'custom_tile_name', gettext("Custom tile provider name"), 'text', - DEFAULT_GEOMETRY_VIEWER_PROVIDER['name'], + 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.'), @@ -873,7 +915,7 @@ def register_query_tool_preferences(self): self.custom_tile_crs = self.preference.register( 'geometry_viewer', 'custom_tile_crs', gettext("Custom tile provider CRS"), 'options', - DEFAULT_GEOMETRY_VIEWER_PROVIDER['crs'], + geometry_viewer_defaults['crs'], category_label=PREF_LABEL_GEOMETRY_VIEWER, options=[{'label': gettext('EPSG:3857 (Web Mercator)'), 'value': 'EPSG:3857'}, @@ -892,7 +934,7 @@ def register_query_tool_preferences(self): self.custom_tile_attribution = self.preference.register( 'geometry_viewer', 'custom_tile_attribution', gettext("Custom tile provider attribution"), 'text', - DEFAULT_GEOMETRY_VIEWER_PROVIDER['attribution'], + 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.'), @@ -902,7 +944,7 @@ 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', - DEFAULT_GEOMETRY_VIEWER_PROVIDER['max_zoom'], + 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.') From b7b09ee136e784f7e65d424ea3295dc8d34c009b Mon Sep 17 00:00:00 2001 From: Ashesh Vashi Date: Fri, 24 Jul 2026 22:53:07 +0530 Subject: [PATCH 3/3] fix: E126/E123 pycodestyle failures in geometry viewer defaults test Factor the repeated fallback-defaults dict into _ALL_FALLBACK instead of fighting hanging-indent rules on deeply nested inline literals - CI's pycodestyle run caught E126 (over-indented) on the original nested dicts; a same-style de-indent then hit E123 (closing bracket mismatch), since this repo's .pycodestyle ignore list overrides pycodestyle's implicit defaults and leaves both checks active. --- .../test_geometry_viewer_provider_defaults.py | 61 ++++++------------- 1 file changed, 20 insertions(+), 41 deletions(-) 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 index 4d74b5af922..239fefe9a47 100644 --- a/web/pgadmin/tools/sqleditor/tests/test_geometry_viewer_provider_defaults.py +++ b/web/pgadmin/tools/sqleditor/tests/test_geometry_viewer_provider_defaults.py @@ -31,67 +31,46 @@ class GeometryViewerProviderDefaultsTestCase(BaseTestGenerator): '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={ - 'url': 'https://x.example.com/{z}/{x}/{y}.png', - 'name': 'Custom', 'crs': 'EPSG:3857', - 'attribution': '', 'max_zoom': 18, - })), + 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={ - 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', - 'attribution': '', 'max_zoom': 18, - })), + dict(provider='oops, not a dict', expected=_ALL_FALLBACK)), ('Unsupported CRS falls back to EPSG:3857', - dict(provider={'crs': 'EPSG:9999'}, - expected={ - 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', - 'attribution': '', 'max_zoom': 18, - })), + dict(provider={'crs': 'EPSG:9999'}, expected=_ALL_FALLBACK)), ('Out-of-range max_zoom falls back to 18', - dict(provider={'max_zoom': 999}, - expected={ - 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', - 'attribution': '', 'max_zoom': 18, - })), + dict(provider={'max_zoom': 999}, expected=_ALL_FALLBACK)), ('Non-integer max_zoom falls back to 18', - dict(provider={'max_zoom': '18'}, - expected={ - 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', - 'attribution': '', 'max_zoom': 18, - })), + dict(provider={'max_zoom': '18'}, expected=_ALL_FALLBACK)), ('Boolean max_zoom falls back to 18', - dict(provider={'max_zoom': True}, - expected={ - 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', - 'attribution': '', 'max_zoom': 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={ - 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', - 'attribution': '', 'max_zoom': 18, - })), + expected=_ALL_FALLBACK)), ('Empty dict falls back entirely', - dict(provider={}, - expected={ - 'url': '', 'name': 'Custom', 'crs': 'EPSG:3857', - 'attribution': '', 'max_zoom': 18, - })), + dict(provider={}, expected=_ALL_FALLBACK)), ] def runTest(self):