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.
This commit is contained in:
bctiemann 2026-05-22 06:35:14 -04:00 committed by GitHub
parent 85e5e412bb
commit 2580b321a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 128 additions and 5 deletions

View File

@ -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.
---

View File

@ -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."
)
),
)

View File

@ -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):

View File

@ -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
netbox/netbox/ui/utils.py Normal file
View File

@ -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}'

View File

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