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:
parent
85e5e412bb
commit
2580b321a3
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
),
|
||||
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue