Преглед изворни кода

Closes #22620: Add Web Mercator (EPSG:3857) coordinates to sites and devices

Arthur пре 11 часа
родитељ
комит
d241816295

+ 7 - 1
docs/models/dcim/device.md

@@ -71,7 +71,13 @@ If installed in a rack, this field indicates the base rack unit in which the dev
 
 ### Latitude & Longitude
 
-GPS coordinates of the device for geolocation.
+GPS coordinates of the device for geolocation. These are assumed to use the WGS84 datum (as reported by GPS).
+
+### Web Mercator X & Y
+
+!!! info "This field was introduced in NetBox v4.8."
+
+The device's coordinates in the [Web Mercator](https://epsg.io/3857) (EPSG:3857) projection, in meters. These read-only values are calculated from the latitude and longitude. See the [site](./site.md#web-mercator-x-y) documentation for details.
 
 ### Status
 

+ 17 - 1
docs/models/dcim/site.md

@@ -48,4 +48,20 @@ The address to use for deliveries destined for the site.
 
 ### Latitude & Longitude
 
-GPS coordinates of the site for geolocation.
+GPS coordinates of the site for geolocation. These are assumed to use the WGS84 datum (as reported by GPS).
+
+### Web Mercator X & Y
+
+!!! info "This field was introduced in NetBox v4.8."
+
+The site's coordinates in the [Web Mercator](https://epsg.io/3857) (EPSG:3857) projection, in meters. These read-only values are calculated from the latitude and longitude (they are not stored in the database), and are useful for integrating with mapping tools that expect projected X/Y coordinates rather than latitude and longitude. They are calculated as:
+
+```
+X = longitude × (π / 180) × 6378137
+Y = ln(tan(π / 4 + latitude × π / 360)) × 6378137
+```
+
+where `ln` is the natural logarithm. Because Web Mercator is undefined at the poles, latitudes beyond ±85.051129° are clamped to that bound.
+
+!!! note
+    Web Mercator (EPSG:3857) differs from the ellipsoidal World Mercator projection (EPSG:3395). Ensure the target system expects EPSG:3857 coordinates.

+ 7 - 4
netbox/dcim/api/serializers_/devices.py

@@ -108,15 +108,18 @@ class DeviceSerializer(PrimaryModelSerializer):
     device_bay_count = serializers.IntegerField(read_only=True)
     module_bay_count = serializers.IntegerField(read_only=True)
     inventory_item_count = serializers.IntegerField(read_only=True)
+    mercator_x = serializers.FloatField(read_only=True, allow_null=True)
+    mercator_y = serializers.FloatField(read_only=True, allow_null=True)
 
     class Meta:
         model = Device
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
-            'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
-            'status', 'airflow', 'cooling_method', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
-            'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template',
-            'config_context', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
+            'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'mercator_x',
+            'mercator_y', 'parent_device', 'status', 'airflow', 'cooling_method', 'primary_ip', 'primary_ip4',
+            'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'owner',
+            'comments', 'config_template', 'config_context', 'local_context_data', 'tags', 'custom_fields', 'created',
+            'last_updated',
             'console_port_count', 'console_server_port_count', 'power_port_count', 'power_outlet_count',
             'cooling_intake_count', 'cooling_outflow_count', 'interface_count', 'front_port_count', 'rear_port_count',
             'device_bay_count', 'module_bay_count', 'inventory_item_count', 'replicate_components',

+ 5 - 3
netbox/dcim/api/serializers_/sites.py

@@ -48,6 +48,8 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
 
 
 class SiteSerializer(PrimaryModelSerializer):
+    mercator_x = serializers.FloatField(read_only=True, allow_null=True)
+    mercator_y = serializers.FloatField(read_only=True, allow_null=True)
     status = ChoiceField(choices=SiteStatusChoices, required=False)
     region = RegionSerializer(nested=True, required=False, allow_null=True)
     group = SiteGroupSerializer(nested=True, required=False, allow_null=True)
@@ -73,9 +75,9 @@ class SiteSerializer(PrimaryModelSerializer):
         model = Site
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility',
-            'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'owner',
-            'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count',
-            'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
+            'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'mercator_x',
+            'mercator_y', 'owner', 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated',
+            'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
 

+ 10 - 0
netbox/dcim/constants.py

@@ -8,6 +8,16 @@ from .choices import InterfaceTypeChoices
 DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,image/webp'
 
 
+#
+# Sites
+#
+
+# WGS84 semi-major axis (meters), used as the sphere radius for Web Mercator (EPSG:3857)
+WEB_MERCATOR_EARTH_RADIUS = 6378137
+# Latitude bounds of Web Mercator, at which the projection is square
+WEB_MERCATOR_MAX_LATITUDE = 85.051129
+
+
 #
 # Racks
 #

+ 14 - 0
netbox/dcim/graphql/mixins.py

@@ -1,6 +1,7 @@
 from typing import TYPE_CHECKING, Annotated
 
 import strawberry
+import strawberry_django
 
 if TYPE_CHECKING:
     from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType, VirtualCircuitTerminationType
@@ -19,6 +20,7 @@ if TYPE_CHECKING:
 __all__ = (
     'CabledObjectMixin',
     'PathEndpointMixin',
+    'WebMercatorMixin',
 )
 
 
@@ -61,3 +63,15 @@ class PathEndpointMixin:
             strawberry.union("ConnectedEndpointType"),
         ]
     ]
+
+
+@strawberry.type
+class WebMercatorMixin:
+
+    @strawberry_django.field(only=['latitude', 'longitude'])
+    def mercator_x(self) -> float | None:
+        return self.mercator_x
+
+    @strawberry_django.field(only=['latitude', 'longitude'])
+    def mercator_y(self) -> float | None:
+        return self.mercator_y

+ 3 - 3
netbox/dcim/graphql/types.py

@@ -26,7 +26,7 @@ from utilities.querysets import RestrictedPrefetch
 from virtualization.models import Cluster, VMInterface
 
 from .filters import *
-from .mixins import CabledObjectMixin, PathEndpointMixin
+from .mixins import CabledObjectMixin, PathEndpointMixin, WebMercatorMixin
 
 if TYPE_CHECKING:
     from circuits.graphql.types import CircuitTerminationType
@@ -326,7 +326,7 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType):
     filters=DeviceFilter,
     pagination=True
 )
-class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObjectType):
+class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, WebMercatorMixin, PrimaryObjectType):
     console_port_count: BigInt
     console_server_port_count: BigInt
     power_port_count: BigInt
@@ -1134,7 +1134,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, NestedLtreeGroupObjectType):
     filters=SiteFilter,
     pagination=True
 )
-class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObjectType):
+class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, WebMercatorMixin, PrimaryObjectType):
     time_zone: str | None
     region: Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None
     group: Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None

+ 2 - 1
netbox/dcim/models/devices.py

@@ -34,7 +34,7 @@ from utilities.prefetch import get_prefetchable_fields
 from utilities.tracking import TrackingModelMixin
 
 from .device_components import *
-from .mixins import RenderConfigMixin
+from .mixins import RenderConfigMixin, WebMercatorMixin
 from .modules import Module
 
 __all__ = (
@@ -550,6 +550,7 @@ class Device(
     ContactsMixin,
     ImageAttachmentsMixin,
     RenderConfigMixin,
+    WebMercatorMixin,
     ConfigContextModel,
     TrackingModelMixin,
     PrimaryModel

+ 28 - 1
netbox/dcim/models/mixins.py

@@ -1,3 +1,4 @@
+import math
 from decimal import Decimal
 
 from django.apps import apps
@@ -8,7 +9,13 @@ from django.db import IntegrityError, models, transaction
 from django.utils.translation import gettext_lazy as _
 
 from dcim.choices import InterfaceTypeChoices
-from dcim.constants import NONCONNECTABLE_IFACE_TYPES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
+from dcim.constants import (
+    NONCONNECTABLE_IFACE_TYPES,
+    VIRTUAL_IFACE_TYPES,
+    WEB_MERCATOR_EARTH_RADIUS,
+    WEB_MERCATOR_MAX_LATITUDE,
+    WIRELESS_IFACE_TYPES,
+)
 from netbox.choices import *
 from utilities.conversion import (
     DIAMETER_TO_MILLIMETERS,
@@ -26,6 +33,7 @@ __all__ = (
     'InterfaceValidationMixin',
     'MaxFlowMixin',
     'RenderConfigMixin',
+    'WebMercatorMixin',
 )
 
 
@@ -500,3 +508,22 @@ class MaxFlowMixin(models.Model):
         # Validate max_flow and max_flow_unit
         if self.max_flow is not None and not self.max_flow_unit:
             raise ValidationError(_("Must specify a unit when setting a maximum flow"))
+
+
+class WebMercatorMixin:
+    """
+    Provides read-only Web Mercator (EPSG:3857) coordinates calculated from a model's latitude and longitude.
+    """
+    @property
+    def mercator_x(self):
+        if self.latitude is None or self.longitude is None:
+            return None
+        return round(math.radians(float(self.longitude)) * WEB_MERCATOR_EARTH_RADIUS, 2)
+
+    @property
+    def mercator_y(self):
+        if self.latitude is None or self.longitude is None:
+            return None
+        # Web Mercator is undefined at the poles; clamp latitude to the projection's bounds
+        latitude = max(-WEB_MERCATOR_MAX_LATITUDE, min(float(self.latitude), WEB_MERCATOR_MAX_LATITUDE))
+        return round(math.log(math.tan(math.pi / 4 + math.radians(latitude) / 2)) * WEB_MERCATOR_EARTH_RADIUS, 2)

+ 3 - 1
netbox/dcim/models/sites.py

@@ -13,6 +13,8 @@ from dcim.constants import *
 from netbox.models import NestedLtreeGroupModel, PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 
+from .mixins import WebMercatorMixin
+
 __all__ = (
     'Location',
     'Region',
@@ -167,7 +169,7 @@ class SiteGroup(ContactsMixin, NestedLtreeGroupModel):
 # Sites
 #
 
-class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
+class Site(ContactsMixin, ImageAttachmentsMixin, WebMercatorMixin, PrimaryModel):
     """
     A Site represents a geographic location within a network; typically a building or campus. The optional facility
     field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).

+ 21 - 0
netbox/dcim/tests/test_api.py

@@ -457,6 +457,27 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase):
         self.assertNotIn('remove_tags', response.data)
         self.assertIn('tags', response.data)
 
+    def test_web_mercator_coordinates(self):
+        """
+        Web Mercator coordinates are calculated from the GPS coordinates and cannot be written.
+        """
+        obj_perm = ObjectPermission(name='Test permission', actions=['change'])
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+        site = Site.objects.first()
+        data = {
+            'latitude': 40.7128,
+            'longitude': -74.006,
+            'mercator_x': 1,
+            'mercator_y': 1,
+        }
+        response = self.client.patch(self._get_detail_url(site), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['mercator_x'], -8238310.24)
+        self.assertEqual(response.data['mercator_y'], 4970071.58)
+
     def test_remove_tags(self):
         """
         Remove tags from an existing object via the remove_tags field.

+ 25 - 0
netbox/dcim/tests/test_models.py

@@ -129,6 +129,31 @@ class MACAddressTestCase(TestCase):
         new_iface.full_clean()  # must not raise
 
 
+class WebMercatorTestCase(TestCase):
+
+    def test_web_mercator_coordinates(self):
+        for model in (Site, Device):
+            with self.subTest(model=model.__name__):
+                obj = model(latitude=Decimal('40.712800'), longitude=Decimal('-74.006000'))
+                self.assertEqual(obj.mercator_x, -8238310.24)
+                self.assertEqual(obj.mercator_y, 4970071.58)
+
+    def test_web_mercator_coordinates_missing_gps(self):
+        for latitude, longitude in ((None, None), (Decimal('10'), None), (None, Decimal('10'))):
+            site = Site(latitude=latitude, longitude=longitude)
+            self.assertIsNone(site.mercator_x)
+            self.assertIsNone(site.mercator_y)
+
+    def test_web_mercator_coordinates_at_poles(self):
+        site = Site(latitude=Decimal('90'), longitude=Decimal('180'))
+        self.assertEqual(site.mercator_x, 20037508.34)
+        self.assertEqual(site.mercator_y, 20037508.63)
+
+        site = Site(latitude=Decimal('-90'), longitude=Decimal('-180'))
+        self.assertEqual(site.mercator_x, -20037508.34)
+        self.assertEqual(site.mercator_y, -20037508.63)
+
+
 class LocationTestCase(TestCase):
 
     def test_change_location_site(self):

+ 4 - 0
netbox/dcim/ui/panels.py

@@ -40,6 +40,8 @@ class SitePanel(panels.ObjectAttributesPanel):
     physical_address = attrs.AddressAttr('physical_address', map_url=True, copy_button=True)
     shipping_address = attrs.AddressAttr('shipping_address', map_url=True, copy_button=True)
     gps_coordinates = attrs.GPSCoordinatesAttr()
+    mercator_x = attrs.NumericAttr('mercator_x', label=_('Web Mercator X'), copy_button=True)
+    mercator_y = attrs.NumericAttr('mercator_y', label=_('Web Mercator Y'), copy_button=True)
 
 
 class LocationPanel(panels.NestedGroupObjectPanel):
@@ -120,6 +122,8 @@ class DevicePanel(panels.ObjectAttributesPanel):
     virtual_chassis = attrs.RelatedObjectAttr('virtual_chassis', linkify=True)
     parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html')
     gps_coordinates = attrs.GPSCoordinatesAttr()
+    mercator_x = attrs.NumericAttr('mercator_x', label=_('Web Mercator X'), copy_button=True)
+    mercator_y = attrs.NumericAttr('mercator_y', label=_('Web Mercator Y'), copy_button=True)
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     description = attrs.TextAttr('description')
     cooling_method = attrs.ChoiceAttr('cooling_method')