netbox-pathways 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_pathways/__init__.py +91 -0
- netbox_pathways/api/__init__.py +0 -0
- netbox_pathways/api/external_geo.py +134 -0
- netbox_pathways/api/geo.py +360 -0
- netbox_pathways/api/serializers.py +520 -0
- netbox_pathways/api/traversal.py +48 -0
- netbox_pathways/api/urls.py +46 -0
- netbox_pathways/api/views.py +140 -0
- netbox_pathways/choices.py +122 -0
- netbox_pathways/filterforms.py +381 -0
- netbox_pathways/filters.py +622 -0
- netbox_pathways/forms.py +1020 -0
- netbox_pathways/geo.py +79 -0
- netbox_pathways/graph.py +545 -0
- netbox_pathways/management/__init__.py +0 -0
- netbox_pathways/management/commands/__init__.py +0 -0
- netbox_pathways/management/commands/_geodata_worker.py +54 -0
- netbox_pathways/management/commands/generate_qgis_project.py +141 -0
- netbox_pathways/management/commands/generate_sample_data.py +550 -0
- netbox_pathways/management/commands/import_geodata.py +705 -0
- netbox_pathways/migrations/0001_initial.py +291 -0
- netbox_pathways/migrations/0002_replace_owner_with_tenant.py +54 -0
- netbox_pathways/migrations/0003_structure_optional_site_dimensions.py +40 -0
- netbox_pathways/migrations/0004_circuit_geometry.py +40 -0
- netbox_pathways/migrations/0005_replace_unique_together_with_constraints.py +39 -0
- netbox_pathways/migrations/0006_remove_cablesegment_sequence_enter_exit.py +33 -0
- netbox_pathways/migrations/0007_cable_routing_redesign.py +36 -0
- netbox_pathways/migrations/0008_conduitbank_pathway_subclass.py +108 -0
- netbox_pathways/migrations/0009_remove_conduit_unique_position_per_bank_and_more.py +21 -0
- netbox_pathways/migrations/0010_structure_status.py +18 -0
- netbox_pathways/migrations/0011_rename_name_to_label.py +42 -0
- netbox_pathways/migrations/0012_add_filter_field_indexes.py +28 -0
- netbox_pathways/migrations/0013_plannedroute.py +45 -0
- netbox_pathways/migrations/0014_plannedroute_parent_split.py +19 -0
- netbox_pathways/migrations/__init__.py +1 -0
- netbox_pathways/models.py +879 -0
- netbox_pathways/navigation.py +207 -0
- netbox_pathways/registry.py +195 -0
- netbox_pathways/route_engine.py +255 -0
- netbox_pathways/routing.py +102 -0
- netbox_pathways/search.py +126 -0
- netbox_pathways/signals.py +24 -0
- netbox_pathways/static/netbox_pathways/css/leaflet-theme.css +375 -0
- netbox_pathways/static/netbox_pathways/css/pathways-map.css +68 -0
- netbox_pathways/static/netbox_pathways/qgis/pathways.qml +34 -0
- netbox_pathways/static/netbox_pathways/qgis/structures.qml +38 -0
- netbox_pathways/static/netbox_pathways/vendor/MarkerCluster.Default.css +60 -0
- netbox_pathways/static/netbox_pathways/vendor/MarkerCluster.css +14 -0
- netbox_pathways/static/netbox_pathways/vendor/leaflet.markercluster.js +2 -0
- netbox_pathways/tables.py +459 -0
- netbox_pathways/template_content.py +337 -0
- netbox_pathways/templates/netbox_pathways/aerialspan.html +1 -0
- netbox_pathways/templates/netbox_pathways/buttons/apply_route.html +5 -0
- netbox_pathways/templates/netbox_pathways/buttons/replan_route.html +5 -0
- netbox_pathways/templates/netbox_pathways/buttons/revert_split.html +9 -0
- netbox_pathways/templates/netbox_pathways/buttons/split_route.html +5 -0
- netbox_pathways/templates/netbox_pathways/buttons/view_in_map.html +5 -0
- netbox_pathways/templates/netbox_pathways/cable_route_tab.html +46 -0
- netbox_pathways/templates/netbox_pathways/cablesegment.html +32 -0
- netbox_pathways/templates/netbox_pathways/conduit.html +1 -0
- netbox_pathways/templates/netbox_pathways/conduitbank.html +1 -0
- netbox_pathways/templates/netbox_pathways/conduitjunction.html +1 -0
- netbox_pathways/templates/netbox_pathways/directburied.html +1 -0
- netbox_pathways/templates/netbox_pathways/inc/cable_add_segment_form.html +23 -0
- netbox_pathways/templates/netbox_pathways/inc/cable_route_finder_results.html +47 -0
- netbox_pathways/templates/netbox_pathways/inc/cable_routing_panel.html +40 -0
- netbox_pathways/templates/netbox_pathways/inc/cable_segment_table.html +97 -0
- netbox_pathways/templates/netbox_pathways/inc/connected_structures_panel.html +9 -0
- netbox_pathways/templates/netbox_pathways/inc/constraint_card.html +22 -0
- netbox_pathways/templates/netbox_pathways/inc/geo_map_panel.html +53 -0
- netbox_pathways/templates/netbox_pathways/inc/plannedroute_map_panel.html +31 -0
- netbox_pathways/templates/netbox_pathways/inc/planner_results.html +80 -0
- netbox_pathways/templates/netbox_pathways/innerduct.html +1 -0
- netbox_pathways/templates/netbox_pathways/map.html +518 -0
- netbox_pathways/templates/netbox_pathways/pathway.html +1 -0
- netbox_pathways/templates/netbox_pathways/pathwaylocation.html +1 -0
- netbox_pathways/templates/netbox_pathways/plannedroute.html +110 -0
- netbox_pathways/templates/netbox_pathways/plannedroute_apply.html +56 -0
- netbox_pathways/templates/netbox_pathways/plannedroute_split.html +53 -0
- netbox_pathways/templates/netbox_pathways/pullsheet_detail.html +134 -0
- netbox_pathways/templates/netbox_pathways/pullsheet_list.html +15 -0
- netbox_pathways/templates/netbox_pathways/route_planner.html +713 -0
- netbox_pathways/templates/netbox_pathways/sitegeometry.html +1 -0
- netbox_pathways/templates/netbox_pathways/structure.html +13 -0
- netbox_pathways/templates/netbox_pathways/widgets/map_widget.html +10 -0
- netbox_pathways/ui/__init__.py +0 -0
- netbox_pathways/ui/panels.py +148 -0
- netbox_pathways/urls.py +243 -0
- netbox_pathways/views.py +2264 -0
- netbox_pathways-0.1.0.dist-info/METADATA +145 -0
- netbox_pathways-0.1.0.dist-info/RECORD +93 -0
- netbox_pathways-0.1.0.dist-info/WHEEL +5 -0
- netbox_pathways-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from netbox.plugins import PluginConfig
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NetBoxPathwaysConfig(PluginConfig):
|
|
7
|
+
name = "netbox_pathways"
|
|
8
|
+
verbose_name = "NetBox Pathways"
|
|
9
|
+
description = "Physical cable plant infrastructure documentation with GIS capabilities"
|
|
10
|
+
version = __version__
|
|
11
|
+
author = "Jonathan Senecal"
|
|
12
|
+
author_email = "contact@jonathansenecal.com"
|
|
13
|
+
base_url = "pathways"
|
|
14
|
+
required_settings = ["srid"]
|
|
15
|
+
default_settings = {
|
|
16
|
+
"map_center_lat": 45.5017,
|
|
17
|
+
"map_center_lon": -73.5673,
|
|
18
|
+
"map_zoom": 10,
|
|
19
|
+
"map_tiles": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
20
|
+
"map_max_native_zoom": 19,
|
|
21
|
+
"map_attribution": "© OpenStreetMap contributors",
|
|
22
|
+
"map_overlays": [],
|
|
23
|
+
}
|
|
24
|
+
django_apps = [
|
|
25
|
+
"django.contrib.gis",
|
|
26
|
+
"rest_framework_gis",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Populated in ready(), consumed by template_content.py
|
|
30
|
+
_map_config = {}
|
|
31
|
+
|
|
32
|
+
def ready(self):
|
|
33
|
+
from django.conf import settings
|
|
34
|
+
|
|
35
|
+
plugin_cfg = settings.PLUGINS_CONFIG.get("netbox_pathways", {})
|
|
36
|
+
max_zoom = 22
|
|
37
|
+
|
|
38
|
+
base_layers = plugin_cfg.get("map_base_layers")
|
|
39
|
+
if base_layers:
|
|
40
|
+
tiles = []
|
|
41
|
+
for layer in base_layers:
|
|
42
|
+
tile = dict(layer.items())
|
|
43
|
+
tile.setdefault("maxZoom", max_zoom)
|
|
44
|
+
tiles.append(tile)
|
|
45
|
+
else:
|
|
46
|
+
tiles_url = plugin_cfg.get(
|
|
47
|
+
"map_tiles",
|
|
48
|
+
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
49
|
+
)
|
|
50
|
+
max_native = plugin_cfg.get("map_max_native_zoom", 19)
|
|
51
|
+
attribution = plugin_cfg.get(
|
|
52
|
+
"map_attribution",
|
|
53
|
+
"© OpenStreetMap contributors",
|
|
54
|
+
)
|
|
55
|
+
tiles = [
|
|
56
|
+
{
|
|
57
|
+
"name": "Street",
|
|
58
|
+
"url": tiles_url,
|
|
59
|
+
"maxZoom": max_zoom,
|
|
60
|
+
"maxNativeZoom": max_native,
|
|
61
|
+
"attribution": attribution,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "Satellite",
|
|
65
|
+
"url": (
|
|
66
|
+
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
|
67
|
+
),
|
|
68
|
+
"maxZoom": max_zoom,
|
|
69
|
+
"maxNativeZoom": 19,
|
|
70
|
+
"attribution": "Esri World Imagery",
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
NetBoxPathwaysConfig._map_config = {
|
|
75
|
+
"baseLayers": tiles,
|
|
76
|
+
"center": [
|
|
77
|
+
plugin_cfg.get("map_center_lat", 45.5017),
|
|
78
|
+
plugin_cfg.get("map_center_lon", -73.5673),
|
|
79
|
+
],
|
|
80
|
+
"zoom": plugin_cfg.get("map_zoom", 10),
|
|
81
|
+
"minZoom": 1,
|
|
82
|
+
"maxZoom": max_zoom,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
super().ready()
|
|
86
|
+
|
|
87
|
+
# Register signals
|
|
88
|
+
from . import signals # noqa: F401
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
config = NetBoxPathwaysConfig
|
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""GeoJSON endpoint for reference-mode external map layers.
|
|
2
|
+
|
|
3
|
+
Resolves geometry by joining through the FK declared in the layer
|
|
4
|
+
registration, transforms to WGS84, applies bbox filtering, and returns
|
|
5
|
+
a standard GeoJSON FeatureCollection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from django.contrib.gis.db import models as gis_models
|
|
13
|
+
from django.contrib.gis.db.models.functions import Transform
|
|
14
|
+
from django.contrib.gis.geos import Polygon
|
|
15
|
+
from django.db import models as db_models
|
|
16
|
+
from django.http import Http404, JsonResponse
|
|
17
|
+
from rest_framework.permissions import IsAuthenticated
|
|
18
|
+
from rest_framework.views import APIView
|
|
19
|
+
|
|
20
|
+
from netbox_pathways.api.geo import MAX_GEO_RESULTS
|
|
21
|
+
from netbox_pathways.geo import LEAFLET_SRID
|
|
22
|
+
from netbox_pathways.registry import SUPPORTED_GEO_MODELS, registry
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_geo_column(model, geometry_field: str) -> tuple[str, str]:
|
|
28
|
+
"""Return (fk_field__geo_column, target_model_label) for the FK.
|
|
29
|
+
|
|
30
|
+
Raises ValueError if the FK target is not in SUPPORTED_GEO_MODELS.
|
|
31
|
+
"""
|
|
32
|
+
fk = model._meta.get_field(geometry_field)
|
|
33
|
+
target = fk.related_model
|
|
34
|
+
target_label = f"{target._meta.app_label}.{target._meta.model_name}"
|
|
35
|
+
# Case-insensitive lookup to tolerate label casing
|
|
36
|
+
for supported_label, geo_col in SUPPORTED_GEO_MODELS.items():
|
|
37
|
+
if supported_label.lower() == target_label.lower():
|
|
38
|
+
return f"{geometry_field}__{geo_col}", supported_label
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"FK '{geometry_field}' on {model.__name__} points to {target_label}, which is not in SUPPORTED_GEO_MODELS."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_properties(obj, feature_fields: list[str] | None, model) -> dict:
|
|
45
|
+
"""Build GeoJSON properties dict from model instance."""
|
|
46
|
+
props: dict = {"id": obj.pk}
|
|
47
|
+
|
|
48
|
+
if feature_fields is not None:
|
|
49
|
+
fields_to_use = feature_fields
|
|
50
|
+
else:
|
|
51
|
+
# Auto-detect scalar fields + FK display values
|
|
52
|
+
fields_to_use = []
|
|
53
|
+
for f in model._meta.get_fields():
|
|
54
|
+
if not hasattr(f, "column"):
|
|
55
|
+
continue # skip reverse relations, M2M, etc.
|
|
56
|
+
if f.name in ("id", "pk"):
|
|
57
|
+
continue # already handled above
|
|
58
|
+
if isinstance(f, gis_models.GeometryField):
|
|
59
|
+
continue # skip geometry fields
|
|
60
|
+
if isinstance(f, (db_models.BinaryField, db_models.JSONField)):
|
|
61
|
+
continue # skip non-serializable / large fields
|
|
62
|
+
fields_to_use.append(f.name)
|
|
63
|
+
|
|
64
|
+
for fname in fields_to_use:
|
|
65
|
+
val = getattr(obj, fname, None)
|
|
66
|
+
# FK → use __str__ of related object
|
|
67
|
+
if hasattr(val, "pk"):
|
|
68
|
+
props[fname] = str(val)
|
|
69
|
+
elif val is not None:
|
|
70
|
+
props[fname] = val
|
|
71
|
+
else:
|
|
72
|
+
props[fname] = None
|
|
73
|
+
return props
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ExternalLayerGeoView(APIView):
|
|
77
|
+
"""Serve GeoJSON for a reference-mode registered layer."""
|
|
78
|
+
|
|
79
|
+
permission_classes = [IsAuthenticated]
|
|
80
|
+
|
|
81
|
+
def get(self, request, layer_name: str):
|
|
82
|
+
layer_reg = registry.get(layer_name)
|
|
83
|
+
if layer_reg is None or layer_reg.source != "reference":
|
|
84
|
+
raise Http404(f"No reference-mode layer named '{layer_name}'.")
|
|
85
|
+
|
|
86
|
+
qs = layer_reg.queryset(request)
|
|
87
|
+
model = qs.model
|
|
88
|
+
|
|
89
|
+
fk_geo_path, _target_label = _resolve_geo_column(
|
|
90
|
+
model,
|
|
91
|
+
layer_reg.geometry_field,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Annotate with WGS84 geometry
|
|
95
|
+
qs = qs.annotate(
|
|
96
|
+
_geo_4326=Transform(fk_geo_path, LEAFLET_SRID),
|
|
97
|
+
).exclude(_geo_4326__isnull=True)
|
|
98
|
+
|
|
99
|
+
# Bbox filtering
|
|
100
|
+
bbox_str = request.query_params.get("bbox", "")
|
|
101
|
+
if bbox_str:
|
|
102
|
+
try:
|
|
103
|
+
w, s, e, n = (float(x) for x in bbox_str.split(","))
|
|
104
|
+
bbox_poly = Polygon.from_bbox((w, s, e, n))
|
|
105
|
+
bbox_poly.srid = LEAFLET_SRID
|
|
106
|
+
qs = qs.filter(_geo_4326__intersects=bbox_poly)
|
|
107
|
+
except (ValueError, TypeError):
|
|
108
|
+
pass # ignore malformed bbox
|
|
109
|
+
|
|
110
|
+
qs = qs[:MAX_GEO_RESULTS]
|
|
111
|
+
|
|
112
|
+
features = []
|
|
113
|
+
for obj in qs:
|
|
114
|
+
geom = obj._geo_4326
|
|
115
|
+
if geom is None:
|
|
116
|
+
continue
|
|
117
|
+
props = _build_properties(obj, layer_reg.feature_fields, model)
|
|
118
|
+
features.append(
|
|
119
|
+
{
|
|
120
|
+
"type": "Feature",
|
|
121
|
+
"geometry": {
|
|
122
|
+
"type": geom.geom_type,
|
|
123
|
+
"coordinates": geom.coords,
|
|
124
|
+
},
|
|
125
|
+
"properties": props,
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return JsonResponse(
|
|
130
|
+
{
|
|
131
|
+
"type": "FeatureCollection",
|
|
132
|
+
"features": features,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GeoJSON API endpoints for map and GIS client consumption.
|
|
3
|
+
|
|
4
|
+
Geometries are transformed to WGS84 (EPSG:4326) in the database query
|
|
5
|
+
via ST_Transform, avoiding per-row Python transforms.
|
|
6
|
+
|
|
7
|
+
Structures support server-side grid clustering: when a ``zoom`` query
|
|
8
|
+
parameter is present and below CLUSTER_ZOOM_THRESHOLD, the API returns
|
|
9
|
+
cluster centroids + counts instead of individual features, dramatically
|
|
10
|
+
reducing payload size at low zoom levels.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
|
|
15
|
+
from django.contrib.gis.db.models import Collect
|
|
16
|
+
from django.contrib.gis.db.models.functions import Centroid, SnapToGrid, Transform
|
|
17
|
+
from django.contrib.gis.geos import Polygon
|
|
18
|
+
from django.db.models import Count, Max
|
|
19
|
+
from rest_framework import serializers as drf_serializers
|
|
20
|
+
from rest_framework.response import Response
|
|
21
|
+
from rest_framework.viewsets import ReadOnlyModelViewSet
|
|
22
|
+
from rest_framework_gis.fields import GeometryField
|
|
23
|
+
from rest_framework_gis.serializers import GeoFeatureModelSerializer
|
|
24
|
+
|
|
25
|
+
from .. import filters, models
|
|
26
|
+
from ..geo import LEAFLET_SRID, get_srid
|
|
27
|
+
|
|
28
|
+
MAX_GEO_RESULTS = 2000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _grid_size_for_zoom(zoom):
|
|
32
|
+
"""Grid cell size in WGS84 degrees, calibrated to ~50px cluster radius."""
|
|
33
|
+
return 70.0 / (2**zoom)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- GeoJSON Serializers ---
|
|
37
|
+
# geo_field points to an annotated field (already WGS84), declared explicitly
|
|
38
|
+
# so DRF doesn't try to introspect the model for it.
|
|
39
|
+
#
|
|
40
|
+
# NOTE: These serializers intentionally omit ``url`` (get_absolute_url).
|
|
41
|
+
# Calling reverse() per row is extremely expensive (~12 s / 1000 rows) due to
|
|
42
|
+
# lazy URL-pattern population. The map client constructs detail URLs itself
|
|
43
|
+
# from featureType + id, so the field is not needed.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StructureGeoSerializer(GeoFeatureModelSerializer):
|
|
47
|
+
geo_4326 = GeometryField(read_only=True)
|
|
48
|
+
name = drf_serializers.CharField(read_only=True)
|
|
49
|
+
site_name = drf_serializers.SerializerMethodField()
|
|
50
|
+
|
|
51
|
+
class Meta:
|
|
52
|
+
model = models.Structure
|
|
53
|
+
geo_field = "geo_4326"
|
|
54
|
+
fields = ["id", "name", "structure_type", "site_name"]
|
|
55
|
+
|
|
56
|
+
def get_site_name(self, obj):
|
|
57
|
+
return obj.site.name if obj.site_id else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PathwayGeoSerializer(GeoFeatureModelSerializer):
|
|
61
|
+
geo_4326 = GeometryField(read_only=True)
|
|
62
|
+
|
|
63
|
+
class Meta:
|
|
64
|
+
model = models.Pathway
|
|
65
|
+
geo_field = "geo_4326"
|
|
66
|
+
fields = ["id", "label", "pathway_type"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ConduitBankGeoSerializer(GeoFeatureModelSerializer):
|
|
70
|
+
geo_4326 = GeometryField(read_only=True)
|
|
71
|
+
conduit_count = drf_serializers.IntegerField(read_only=True)
|
|
72
|
+
|
|
73
|
+
class Meta:
|
|
74
|
+
model = models.ConduitBank
|
|
75
|
+
geo_field = "geo_4326"
|
|
76
|
+
fields = ["id", "label", "pathway_type", "configuration", "conduit_count"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ConduitGeoSerializer(GeoFeatureModelSerializer):
|
|
80
|
+
geo_4326 = GeometryField(read_only=True)
|
|
81
|
+
|
|
82
|
+
class Meta:
|
|
83
|
+
model = models.Conduit
|
|
84
|
+
geo_field = "geo_4326"
|
|
85
|
+
fields = ["id", "label", "pathway_type"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AerialSpanGeoSerializer(GeoFeatureModelSerializer):
|
|
89
|
+
geo_4326 = GeometryField(read_only=True)
|
|
90
|
+
|
|
91
|
+
class Meta:
|
|
92
|
+
model = models.AerialSpan
|
|
93
|
+
geo_field = "geo_4326"
|
|
94
|
+
fields = ["id", "label", "pathway_type"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DirectBuriedGeoSerializer(GeoFeatureModelSerializer):
|
|
98
|
+
geo_4326 = GeometryField(read_only=True)
|
|
99
|
+
|
|
100
|
+
class Meta:
|
|
101
|
+
model = models.DirectBuried
|
|
102
|
+
geo_field = "geo_4326"
|
|
103
|
+
fields = ["id", "label", "pathway_type"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class CircuitGeoSerializer(GeoFeatureModelSerializer):
|
|
107
|
+
geo_4326 = GeometryField(read_only=True)
|
|
108
|
+
cid = drf_serializers.CharField(source="circuit.cid", read_only=True)
|
|
109
|
+
provider = drf_serializers.CharField(source="circuit.provider.name", read_only=True)
|
|
110
|
+
circuit_type = drf_serializers.CharField(source="circuit.type.name", read_only=True)
|
|
111
|
+
status = drf_serializers.CharField(source="circuit.status", read_only=True)
|
|
112
|
+
|
|
113
|
+
class Meta:
|
|
114
|
+
model = models.CircuitGeometry
|
|
115
|
+
geo_field = "geo_4326"
|
|
116
|
+
fields = ["id", "cid", "provider", "circuit_type", "status", "provider_reference"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- Bbox filtering mixin ---
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class BboxFilterMixin:
|
|
123
|
+
"""
|
|
124
|
+
Filter queryset by bounding box via ``?bbox=west,south,east,north`` (WGS84).
|
|
125
|
+
Annotates a ``geo_4326`` field with the geometry transformed to WGS84.
|
|
126
|
+
|
|
127
|
+
The result cap (MAX_GEO_RESULTS) is applied in ``list()`` rather than
|
|
128
|
+
``get_queryset()`` so that DRF's filter backends (e.g. ``?q=``) can
|
|
129
|
+
still filter the queryset before slicing.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
bbox_geo_field = "location" # native geometry column name
|
|
133
|
+
|
|
134
|
+
def _apply_bbox(self, qs):
|
|
135
|
+
"""Annotate geo_4326 and apply bbox filter (no result cap)."""
|
|
136
|
+
qs = qs.annotate(geo_4326=Transform(self.bbox_geo_field, LEAFLET_SRID))
|
|
137
|
+
|
|
138
|
+
bbox = self.request.query_params.get("bbox")
|
|
139
|
+
if bbox:
|
|
140
|
+
try:
|
|
141
|
+
west, south, east, north = (float(v) for v in bbox.split(","))
|
|
142
|
+
bbox_poly = Polygon.from_bbox((west, south, east, north))
|
|
143
|
+
bbox_poly.srid = LEAFLET_SRID
|
|
144
|
+
if get_srid() != LEAFLET_SRID:
|
|
145
|
+
bbox_poly.transform(get_srid())
|
|
146
|
+
qs = qs.filter(**{f"{self.bbox_geo_field}__intersects": bbox_poly})
|
|
147
|
+
except (ValueError, TypeError):
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
return qs
|
|
151
|
+
|
|
152
|
+
def get_queryset(self):
|
|
153
|
+
qs = super().get_queryset()
|
|
154
|
+
return self._apply_bbox(qs)
|
|
155
|
+
|
|
156
|
+
def _etag_for_queryset(self, queryset):
|
|
157
|
+
"""Lightweight ETag from max(last_updated) + count."""
|
|
158
|
+
agg = queryset.aggregate(t=Max("last_updated"), c=Count("id"))
|
|
159
|
+
raw = f"{agg['t']}:{agg['c']}"
|
|
160
|
+
return hashlib.md5(raw.encode(), usedforsecurity=False).hexdigest()
|
|
161
|
+
|
|
162
|
+
def list(self, request, *args, **kwargs):
|
|
163
|
+
queryset = self.filter_queryset(self.get_queryset())
|
|
164
|
+
|
|
165
|
+
# ETag: cheap aggregate check before expensive serialization
|
|
166
|
+
etag = self._etag_for_queryset(queryset)
|
|
167
|
+
if request.META.get("HTTP_IF_NONE_MATCH") == etag:
|
|
168
|
+
return Response(status=304)
|
|
169
|
+
|
|
170
|
+
# Apply the result cap after ETag check
|
|
171
|
+
serializer = self.get_serializer(queryset[:MAX_GEO_RESULTS], many=True)
|
|
172
|
+
response = Response(serializer.data)
|
|
173
|
+
response["ETag"] = etag
|
|
174
|
+
return response
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- GeoJSON ViewSets (read-only, unpaginated) ---
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class StructureGeoViewSet(BboxFilterMixin, ReadOnlyModelViewSet):
|
|
181
|
+
queryset = (
|
|
182
|
+
models.Structure.objects.select_related("site")
|
|
183
|
+
.only(
|
|
184
|
+
"id",
|
|
185
|
+
"name",
|
|
186
|
+
"structure_type",
|
|
187
|
+
"location",
|
|
188
|
+
"site__name",
|
|
189
|
+
)
|
|
190
|
+
.order_by("pk")
|
|
191
|
+
)
|
|
192
|
+
serializer_class = StructureGeoSerializer
|
|
193
|
+
filterset_class = filters.StructureFilterSet
|
|
194
|
+
bbox_geo_field = "location"
|
|
195
|
+
pagination_class = None
|
|
196
|
+
|
|
197
|
+
def list(self, request, *args, **kwargs):
|
|
198
|
+
zoom = self._parse_zoom()
|
|
199
|
+
if zoom is not None:
|
|
200
|
+
qs = self.filter_queryset(self.get_queryset())
|
|
201
|
+
# ETag check before expensive count/serialize
|
|
202
|
+
etag = self._etag_for_queryset(qs)
|
|
203
|
+
if request.META.get("HTTP_IF_NONE_MATCH") == etag:
|
|
204
|
+
return Response(status=304)
|
|
205
|
+
if qs.count() > MAX_GEO_RESULTS:
|
|
206
|
+
return self._clustered_response(zoom, etag)
|
|
207
|
+
serializer = self.get_serializer(qs[:MAX_GEO_RESULTS], many=True)
|
|
208
|
+
response = Response(serializer.data)
|
|
209
|
+
response["ETag"] = etag
|
|
210
|
+
return response
|
|
211
|
+
return super().list(request, *args, **kwargs)
|
|
212
|
+
|
|
213
|
+
def _parse_zoom(self):
|
|
214
|
+
try:
|
|
215
|
+
return int(self.request.query_params["zoom"])
|
|
216
|
+
except (KeyError, ValueError, TypeError):
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
def _clustered_response(self, zoom, etag=None):
|
|
220
|
+
# Get bbox-filtered queryset WITHOUT the result cap (aggregation reduces rows)
|
|
221
|
+
qs = self._apply_bbox(models.Structure.objects.only("id", "location").order_by())
|
|
222
|
+
|
|
223
|
+
grid_size = _grid_size_for_zoom(zoom)
|
|
224
|
+
geo_expr = Transform("location", LEAFLET_SRID)
|
|
225
|
+
clusters = (
|
|
226
|
+
qs
|
|
227
|
+
# Grid used only for grouping — the actual display point is the
|
|
228
|
+
# centroid of collected geometries, giving organic placement.
|
|
229
|
+
.annotate(grid_cell=SnapToGrid(geo_expr, grid_size))
|
|
230
|
+
.values("grid_cell")
|
|
231
|
+
.annotate(
|
|
232
|
+
count=Count("id"),
|
|
233
|
+
centroid=Centroid(Collect(geo_expr)),
|
|
234
|
+
)
|
|
235
|
+
.order_by()
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
features = []
|
|
239
|
+
total = 0
|
|
240
|
+
for c in clusters:
|
|
241
|
+
pt = c["centroid"]
|
|
242
|
+
if pt is None:
|
|
243
|
+
continue
|
|
244
|
+
count = c["count"]
|
|
245
|
+
total += count
|
|
246
|
+
features.append(
|
|
247
|
+
{
|
|
248
|
+
"type": "Feature",
|
|
249
|
+
"geometry": {
|
|
250
|
+
"type": "Point",
|
|
251
|
+
"coordinates": [pt.x, pt.y],
|
|
252
|
+
},
|
|
253
|
+
"properties": {
|
|
254
|
+
"cluster": True,
|
|
255
|
+
"point_count": count,
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
response = Response(
|
|
261
|
+
{
|
|
262
|
+
"type": "FeatureCollection",
|
|
263
|
+
"features": features,
|
|
264
|
+
"total_count": total,
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
if etag:
|
|
268
|
+
response["ETag"] = etag
|
|
269
|
+
return response
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class PathwayGeoViewSet(BboxFilterMixin, ReadOnlyModelViewSet):
|
|
273
|
+
queryset = models.Pathway.objects.only(
|
|
274
|
+
"id",
|
|
275
|
+
"label",
|
|
276
|
+
"pathway_type",
|
|
277
|
+
"path",
|
|
278
|
+
).order_by("pk")
|
|
279
|
+
serializer_class = PathwayGeoSerializer
|
|
280
|
+
filterset_class = filters.PathwayFilterSet
|
|
281
|
+
bbox_geo_field = "path"
|
|
282
|
+
pagination_class = None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class ConduitBankGeoViewSet(BboxFilterMixin, ReadOnlyModelViewSet):
|
|
286
|
+
queryset = (
|
|
287
|
+
models.ConduitBank.objects.annotate(
|
|
288
|
+
conduit_count=Count("conduits"),
|
|
289
|
+
)
|
|
290
|
+
.only(
|
|
291
|
+
"id",
|
|
292
|
+
"label",
|
|
293
|
+
"pathway_type",
|
|
294
|
+
"path",
|
|
295
|
+
"configuration",
|
|
296
|
+
)
|
|
297
|
+
.order_by("pk")
|
|
298
|
+
)
|
|
299
|
+
serializer_class = ConduitBankGeoSerializer
|
|
300
|
+
filterset_class = filters.ConduitBankFilterSet
|
|
301
|
+
bbox_geo_field = "path"
|
|
302
|
+
pagination_class = None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class ConduitGeoViewSet(BboxFilterMixin, ReadOnlyModelViewSet):
|
|
306
|
+
# Exclude conduits that belong to a bank — those are represented by the bank line
|
|
307
|
+
queryset = (
|
|
308
|
+
models.Conduit.objects.filter(
|
|
309
|
+
conduit_bank__isnull=True,
|
|
310
|
+
)
|
|
311
|
+
.only(
|
|
312
|
+
"id",
|
|
313
|
+
"label",
|
|
314
|
+
"pathway_type",
|
|
315
|
+
"path",
|
|
316
|
+
)
|
|
317
|
+
.order_by("pk")
|
|
318
|
+
)
|
|
319
|
+
serializer_class = ConduitGeoSerializer
|
|
320
|
+
filterset_class = filters.ConduitFilterSet
|
|
321
|
+
bbox_geo_field = "path"
|
|
322
|
+
pagination_class = None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class AerialSpanGeoViewSet(BboxFilterMixin, ReadOnlyModelViewSet):
|
|
326
|
+
queryset = models.AerialSpan.objects.only(
|
|
327
|
+
"id",
|
|
328
|
+
"label",
|
|
329
|
+
"pathway_type",
|
|
330
|
+
"path",
|
|
331
|
+
).order_by("pk")
|
|
332
|
+
serializer_class = AerialSpanGeoSerializer
|
|
333
|
+
filterset_class = filters.AerialSpanFilterSet
|
|
334
|
+
bbox_geo_field = "path"
|
|
335
|
+
pagination_class = None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class DirectBuriedGeoViewSet(BboxFilterMixin, ReadOnlyModelViewSet):
|
|
339
|
+
queryset = models.DirectBuried.objects.only(
|
|
340
|
+
"id",
|
|
341
|
+
"label",
|
|
342
|
+
"pathway_type",
|
|
343
|
+
"path",
|
|
344
|
+
).order_by("pk")
|
|
345
|
+
serializer_class = DirectBuriedGeoSerializer
|
|
346
|
+
filterset_class = filters.DirectBuriedFilterSet
|
|
347
|
+
bbox_geo_field = "path"
|
|
348
|
+
pagination_class = None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class CircuitGeoViewSet(BboxFilterMixin, ReadOnlyModelViewSet):
|
|
352
|
+
queryset = models.CircuitGeometry.objects.select_related(
|
|
353
|
+
"circuit",
|
|
354
|
+
"circuit__provider",
|
|
355
|
+
"circuit__type",
|
|
356
|
+
).order_by("pk")
|
|
357
|
+
serializer_class = CircuitGeoSerializer
|
|
358
|
+
filterset_class = filters.CircuitGeometryFilterSet
|
|
359
|
+
bbox_geo_field = "path"
|
|
360
|
+
pagination_class = None
|