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.
Files changed (93) hide show
  1. netbox_pathways/__init__.py +91 -0
  2. netbox_pathways/api/__init__.py +0 -0
  3. netbox_pathways/api/external_geo.py +134 -0
  4. netbox_pathways/api/geo.py +360 -0
  5. netbox_pathways/api/serializers.py +520 -0
  6. netbox_pathways/api/traversal.py +48 -0
  7. netbox_pathways/api/urls.py +46 -0
  8. netbox_pathways/api/views.py +140 -0
  9. netbox_pathways/choices.py +122 -0
  10. netbox_pathways/filterforms.py +381 -0
  11. netbox_pathways/filters.py +622 -0
  12. netbox_pathways/forms.py +1020 -0
  13. netbox_pathways/geo.py +79 -0
  14. netbox_pathways/graph.py +545 -0
  15. netbox_pathways/management/__init__.py +0 -0
  16. netbox_pathways/management/commands/__init__.py +0 -0
  17. netbox_pathways/management/commands/_geodata_worker.py +54 -0
  18. netbox_pathways/management/commands/generate_qgis_project.py +141 -0
  19. netbox_pathways/management/commands/generate_sample_data.py +550 -0
  20. netbox_pathways/management/commands/import_geodata.py +705 -0
  21. netbox_pathways/migrations/0001_initial.py +291 -0
  22. netbox_pathways/migrations/0002_replace_owner_with_tenant.py +54 -0
  23. netbox_pathways/migrations/0003_structure_optional_site_dimensions.py +40 -0
  24. netbox_pathways/migrations/0004_circuit_geometry.py +40 -0
  25. netbox_pathways/migrations/0005_replace_unique_together_with_constraints.py +39 -0
  26. netbox_pathways/migrations/0006_remove_cablesegment_sequence_enter_exit.py +33 -0
  27. netbox_pathways/migrations/0007_cable_routing_redesign.py +36 -0
  28. netbox_pathways/migrations/0008_conduitbank_pathway_subclass.py +108 -0
  29. netbox_pathways/migrations/0009_remove_conduit_unique_position_per_bank_and_more.py +21 -0
  30. netbox_pathways/migrations/0010_structure_status.py +18 -0
  31. netbox_pathways/migrations/0011_rename_name_to_label.py +42 -0
  32. netbox_pathways/migrations/0012_add_filter_field_indexes.py +28 -0
  33. netbox_pathways/migrations/0013_plannedroute.py +45 -0
  34. netbox_pathways/migrations/0014_plannedroute_parent_split.py +19 -0
  35. netbox_pathways/migrations/__init__.py +1 -0
  36. netbox_pathways/models.py +879 -0
  37. netbox_pathways/navigation.py +207 -0
  38. netbox_pathways/registry.py +195 -0
  39. netbox_pathways/route_engine.py +255 -0
  40. netbox_pathways/routing.py +102 -0
  41. netbox_pathways/search.py +126 -0
  42. netbox_pathways/signals.py +24 -0
  43. netbox_pathways/static/netbox_pathways/css/leaflet-theme.css +375 -0
  44. netbox_pathways/static/netbox_pathways/css/pathways-map.css +68 -0
  45. netbox_pathways/static/netbox_pathways/qgis/pathways.qml +34 -0
  46. netbox_pathways/static/netbox_pathways/qgis/structures.qml +38 -0
  47. netbox_pathways/static/netbox_pathways/vendor/MarkerCluster.Default.css +60 -0
  48. netbox_pathways/static/netbox_pathways/vendor/MarkerCluster.css +14 -0
  49. netbox_pathways/static/netbox_pathways/vendor/leaflet.markercluster.js +2 -0
  50. netbox_pathways/tables.py +459 -0
  51. netbox_pathways/template_content.py +337 -0
  52. netbox_pathways/templates/netbox_pathways/aerialspan.html +1 -0
  53. netbox_pathways/templates/netbox_pathways/buttons/apply_route.html +5 -0
  54. netbox_pathways/templates/netbox_pathways/buttons/replan_route.html +5 -0
  55. netbox_pathways/templates/netbox_pathways/buttons/revert_split.html +9 -0
  56. netbox_pathways/templates/netbox_pathways/buttons/split_route.html +5 -0
  57. netbox_pathways/templates/netbox_pathways/buttons/view_in_map.html +5 -0
  58. netbox_pathways/templates/netbox_pathways/cable_route_tab.html +46 -0
  59. netbox_pathways/templates/netbox_pathways/cablesegment.html +32 -0
  60. netbox_pathways/templates/netbox_pathways/conduit.html +1 -0
  61. netbox_pathways/templates/netbox_pathways/conduitbank.html +1 -0
  62. netbox_pathways/templates/netbox_pathways/conduitjunction.html +1 -0
  63. netbox_pathways/templates/netbox_pathways/directburied.html +1 -0
  64. netbox_pathways/templates/netbox_pathways/inc/cable_add_segment_form.html +23 -0
  65. netbox_pathways/templates/netbox_pathways/inc/cable_route_finder_results.html +47 -0
  66. netbox_pathways/templates/netbox_pathways/inc/cable_routing_panel.html +40 -0
  67. netbox_pathways/templates/netbox_pathways/inc/cable_segment_table.html +97 -0
  68. netbox_pathways/templates/netbox_pathways/inc/connected_structures_panel.html +9 -0
  69. netbox_pathways/templates/netbox_pathways/inc/constraint_card.html +22 -0
  70. netbox_pathways/templates/netbox_pathways/inc/geo_map_panel.html +53 -0
  71. netbox_pathways/templates/netbox_pathways/inc/plannedroute_map_panel.html +31 -0
  72. netbox_pathways/templates/netbox_pathways/inc/planner_results.html +80 -0
  73. netbox_pathways/templates/netbox_pathways/innerduct.html +1 -0
  74. netbox_pathways/templates/netbox_pathways/map.html +518 -0
  75. netbox_pathways/templates/netbox_pathways/pathway.html +1 -0
  76. netbox_pathways/templates/netbox_pathways/pathwaylocation.html +1 -0
  77. netbox_pathways/templates/netbox_pathways/plannedroute.html +110 -0
  78. netbox_pathways/templates/netbox_pathways/plannedroute_apply.html +56 -0
  79. netbox_pathways/templates/netbox_pathways/plannedroute_split.html +53 -0
  80. netbox_pathways/templates/netbox_pathways/pullsheet_detail.html +134 -0
  81. netbox_pathways/templates/netbox_pathways/pullsheet_list.html +15 -0
  82. netbox_pathways/templates/netbox_pathways/route_planner.html +713 -0
  83. netbox_pathways/templates/netbox_pathways/sitegeometry.html +1 -0
  84. netbox_pathways/templates/netbox_pathways/structure.html +13 -0
  85. netbox_pathways/templates/netbox_pathways/widgets/map_widget.html +10 -0
  86. netbox_pathways/ui/__init__.py +0 -0
  87. netbox_pathways/ui/panels.py +148 -0
  88. netbox_pathways/urls.py +243 -0
  89. netbox_pathways/views.py +2264 -0
  90. netbox_pathways-0.1.0.dist-info/METADATA +145 -0
  91. netbox_pathways-0.1.0.dist-info/RECORD +93 -0
  92. netbox_pathways-0.1.0.dist-info/WHEEL +5 -0
  93. 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