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 %}