diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 650833129..5acb097f8 100644 --- a/docs/configuration/miscellaneous.md +++ b/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. --- diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 0c9975941..69d62033b 100644 --- a/netbox/netbox/config/parameters.py +++ b/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." + ) ), ) diff --git a/netbox/netbox/tests/test_ui.py b/netbox/netbox/tests/test_ui.py index 6727e532a..a86277c5c 100644 --- a/netbox/netbox/tests/test_ui.py +++ b/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): diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index d4179daad..95bf0a8a4 100644 --- a/netbox/netbox/ui/attrs.py +++ b/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, }) diff --git a/netbox/netbox/ui/utils.py b/netbox/netbox/ui/utils.py new file mode 100644 index 000000000..ade132865 --- /dev/null +++ b/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}' diff --git a/netbox/templates/ui/attrs/gps_coordinates.html b/netbox/templates/ui/attrs/gps_coordinates.html index 8e72f08bb..2c77dc6de 100644 --- a/netbox/templates/ui/attrs/gps_coordinates.html +++ b/netbox/templates/ui/attrs/gps_coordinates.html @@ -2,7 +2,7 @@ {% load l10n %} {{ latitude }}, {{ longitude }} {% if map_url %} - + {% trans "Map" %} {% endif %}