Просмотр исходного кода

Closes #19460: Support {lat}/{lon} placeholders in MAPS_URL (#22243)

Add support for literal `{lat}` and `{lon}` placeholders in `MAPS_URL`
when rendering GPS coordinate links. Existing configurations continue to
work by falling back to appending `lat,lon` when no coordinate placeholders
are present.

Move map URL handling into shared UI helpers so `GPSCoordinatesAttr` and
`AddressAttr` use consistent placeholder detection. When `MAPS_URL` contains
coordinate placeholders, suppress address-based map links to avoid rendering
invalid URLs.

Add tests for placeholder replacement, decimal coordinate values, fallback
behavior, and address link suppression. Also document the address link behavior
in the `MAPS_URL` configuration description.
bctiemann 10 часов назад
Родитель
Сommit
2580b321a3

+ 15 - 1
docs/configuration/miscellaneous.md

@@ -190,7 +190,21 @@ Setting this to `True` will display a "maintenance mode" banner at the top of ev
 
 Default: `https://maps.google.com/?q=` (Google Maps)
 
-This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. Set this to `None` to disable the "map it" button within the UI.
+This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. Set this to `None` to disable the "map it" button within the UI.
+
+**For street addresses**, the URL must accept a free-form address string appended directly to it.
+
+**For GPS coordinates**, two formats are supported:
+
+* **Simple prefix** (default behavior): The latitude and longitude are appended as a comma-separated pair. For example, `https://maps.google.com/?q=` produces `https://maps.google.com/?q=48.858,2.294`.
+* **Coordinate placeholders**: Include `{lat}` and/or `{lon}` anywhere in the URL. Only these two literal placeholders are supported. For example:
+
+```
+MAPS_URL = "https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}"
+```
+
+!!! note
+    When `MAPS_URL` contains `{lat}` or `{lon}` placeholders, the "map it" button will only appear on pages with GPS coordinates — address-based map links will be suppressed, since the coordinate-format URL cannot be used with a plain address string.
 
 ---
 

+ 5 - 1
netbox/netbox/config/parameters.py

@@ -229,7 +229,11 @@ PARAMS = (
         name='MAPS_URL',
         label=_('Maps URL'),
         default='https://maps.google.com/?q=',
-        description=_("Base URL for mapping geographic locations")
+        description=_(
+            "URL for mapping geographic locations. For GPS coordinates, include {lat} and/or {lon} placeholders, "
+            "or omit them to append coordinates as a comma-separated pair. "
+            "Note: when {lat} or {lon} placeholders are present, address-based map links will be disabled."
+        )
     ),
 
 )

+ 74 - 0
netbox/netbox/tests/test_ui.py

@@ -1,3 +1,4 @@
+from decimal import Decimal
 from types import SimpleNamespace
 
 from django.test import RequestFactory, TestCase
@@ -15,6 +16,7 @@ from dcim.choices import InterfaceTypeChoices
 from dcim.models import Interface, Site
 from netbox.ui import attrs
 from netbox.ui.panels import ObjectsTablePanel
+from netbox.ui.utils import build_coords_url
 from users.models import ObjectPermission, User
 from utilities.testing import create_test_device
 from vpn.choices import (
@@ -439,6 +441,78 @@ class GPSCoordinatesAttrTestCase(TestCase):
         obj = SimpleNamespace(latitude=None, longitude=None)
         self.assertEqual(attr.render(obj, {'name': 'coordinates'}), attr.placeholder)
 
+    def test_build_coords_url_legacy_prefix(self):
+        url = build_coords_url('https://maps.google.com/?q=', 48.858, 2.294)
+        self.assertEqual(url, 'https://maps.google.com/?q=48.858,2.294')
+
+    def test_build_coords_url_lat_lon_placeholders(self):
+        url = build_coords_url(
+            'https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}',
+            48.858,
+            2.294,
+        )
+        self.assertEqual(url, 'https://www.openstreetmap.org/?mlat=48.858&mlon=2.294#map=16/48.858/2.294')
+
+    def test_build_coords_url_lat_placeholder_only(self):
+        url = build_coords_url('https://example.com/?lat={lat}', 48.858, 2.294)
+        self.assertEqual(url, 'https://example.com/?lat=48.858')
+
+    def test_build_coords_url_lon_placeholder_only(self):
+        url = build_coords_url('https://example.com/?lon={lon}', 48.858, 2.294)
+        self.assertEqual(url, 'https://example.com/?lon=2.294')
+
+    def test_build_coords_url_unknown_placeholder_falls_back_to_legacy(self):
+        # URL with only an unknown placeholder (no {lat}/{lon}) → legacy append
+        url = build_coords_url('https://example.com/?q={unknown}', 48.858, 2.294)
+        self.assertEqual(url, 'https://example.com/?q={unknown}48.858,2.294')
+
+    def test_build_coords_url_known_and_unknown_placeholder(self):
+        # {lat} is substituted; unknown key is left as a literal placeholder
+        url = build_coords_url(
+            'https://example.com/?lat={lat}&layer={layer}', 48.858, 2.294
+        )
+        self.assertEqual(url, 'https://example.com/?lat=48.858&layer={layer}')
+
+    def test_build_coords_url_decimal_values_no_locale_separator(self):
+        # Decimal field values must format with '.' as the decimal separator regardless of locale;
+        # a locale-style comma separator would produce e.g. '48,858258' and break the URL
+        url = build_coords_url(
+            'https://maps.google.com/?q=',
+            Decimal('48.858258'),
+            Decimal('2.294498'),
+        )
+        self.assertEqual(url, 'https://maps.google.com/?q=48.858258,2.294498')
+
+    def test_build_coords_url_decimal_with_placeholders_no_locale_separator(self):
+        url = build_coords_url(
+            'https://www.openstreetmap.org/?mlat={lat}&mlon={lon}',
+            Decimal('48.858258'),
+            Decimal('2.294498'),
+        )
+        self.assertEqual(url, 'https://www.openstreetmap.org/?mlat=48.858258&mlon=2.294498')
+
+
+class AddressAttrTestCase(TestCase):
+
+    def test_plain_prefix_map_url_is_passed_through(self):
+        attr = attrs.AddressAttr('address', map_url='https://maps.google.com/?q=')
+        obj = SimpleNamespace(address='1 Main St')
+        context = attr.get_context(obj, 'address', '1 Main St', {})
+        self.assertEqual(context['map_url'], 'https://maps.google.com/?q=')
+
+    def test_gps_format_map_url_is_suppressed_for_addresses(self):
+        # A GPS-format URL cannot render address links; map_url should be None
+        attr = attrs.AddressAttr('address', map_url='https://maps.example.com/?mlat={lat}&mlon={lon}')
+        obj = SimpleNamespace(address='1 Main St')
+        context = attr.get_context(obj, 'address', '1 Main St', {})
+        self.assertIsNone(context['map_url'])
+
+    def test_no_map_url(self):
+        attr = attrs.AddressAttr('address', map_url=False)
+        obj = SimpleNamespace(address='1 Main St')
+        context = attr.get_context(obj, 'address', '1 Main St', {})
+        self.assertIsNone(context['map_url'])
+
 
 class DateTimeAttrTestCase(TestCase):
 

+ 10 - 2
netbox/netbox/ui/attrs.py

@@ -3,6 +3,7 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
 from netbox.config import get_config
+from netbox.ui.utils import build_coords_url, is_coordinate_map_url
 from utilities.data import resolve_attr_path
 
 __all__ = (
@@ -472,8 +473,12 @@ class AddressAttr(MapURLMixin, ObjectAttribute):
         self._map_url = map_url
 
     def get_context(self, obj, attr, value, context):
+        map_url = self.map_url
+        # A coordinate-format MAPS_URL (containing {lat}/{lon}) cannot be used for address rendering
+        if map_url and is_coordinate_map_url(map_url):
+            map_url = None
         return {
-            'map_url': self.map_url,
+            'map_url': map_url,
         }
 
 
@@ -500,11 +505,14 @@ class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute):
         longitude = resolve_attr_path(obj, self.longitude_attr)
         if latitude is None or longitude is None:
             return self.placeholder
+        map_url = self.map_url
+        if map_url:
+            map_url = build_coords_url(map_url, latitude, longitude)
         return render_to_string(self.template_name, {
             'name': context['name'],
             'latitude': latitude,
             'longitude': longitude,
-            'map_url': self.map_url,
+            'map_url': map_url,
         })
 
 

+ 23 - 0
netbox/netbox/ui/utils.py

@@ -0,0 +1,23 @@
+__all__ = (
+    'build_coords_url',
+    'is_coordinate_map_url',
+)
+
+
+def is_coordinate_map_url(url):
+    """Return True if the URL contains GPS coordinate placeholders ({lat} or {lon})."""
+    return '{lat}' in url or '{lon}' in url
+
+
+def build_coords_url(map_url, latitude, longitude):
+    """
+    Build a GPS map URL from a base URL and coordinate values.
+
+    If the URL contains {lat} or {lon} placeholders they are substituted directly;
+    otherwise the coordinates are appended as a comma-separated pair.
+    """
+    lat_str = str(latitude)
+    lon_str = str(longitude)
+    if is_coordinate_map_url(map_url):
+        return map_url.replace('{lat}', lat_str).replace('{lon}', lon_str)
+    return f'{map_url}{lat_str},{lon_str}'

+ 1 - 1
netbox/templates/ui/attrs/gps_coordinates.html

@@ -2,7 +2,7 @@
 {% load l10n %}
 <span>{{ latitude }}, {{ longitude }}</span>
 {% if map_url %}
-  <a href="{{ map_url }}{{ latitude|unlocalize }},{{ longitude|unlocalize }}" target="_blank" class="btn btn-primary btn-sm print-none">
+  <a href="{{ map_url }}" target="_blank" class="btn btn-primary btn-sm print-none">
     <i class="mdi mdi-map-marker"></i> {% trans "Map" %}
   </a>
 {% endif %}