Migrate geo backend from Django/Graphene to Express + Apollo Server + arangojs
All checks were successful
Build Docker Image / build (push) Successful in 1m5s
All checks were successful
Build Docker Image / build (push) Successful in 1m5s
Replace Python stack with TypeScript. All 30+ GraphQL queries preserved including phase-based routing (Dijkstra), H3 clustering, K_SHORTEST_PATHS, and external routing services (GraphHopper, OpenRailRouting). Single public endpoint, no auth.
This commit is contained in:
@@ -1 +0,0 @@
|
||||
"""Geo app - logistics graph operations."""
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GeoAppConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'geo_app'
|
||||
@@ -1,49 +0,0 @@
|
||||
"""ArangoDB client singleton."""
|
||||
import logging
|
||||
from arango import ArangoClient
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_db = None
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Get ArangoDB database connection (singleton)."""
|
||||
global _db
|
||||
if _db is None:
|
||||
hosts = settings.ARANGODB_INTERNAL_URL
|
||||
if not hosts.startswith("http"):
|
||||
hosts = f"http://{hosts}"
|
||||
|
||||
client = ArangoClient(hosts=hosts)
|
||||
_db = client.db(
|
||||
settings.ARANGODB_DATABASE,
|
||||
username='root',
|
||||
password=settings.ARANGODB_PASSWORD,
|
||||
)
|
||||
logger.info(
|
||||
"Connected to ArangoDB: %s/%s",
|
||||
hosts,
|
||||
settings.ARANGODB_DATABASE,
|
||||
)
|
||||
return _db
|
||||
|
||||
|
||||
def ensure_graph():
|
||||
"""Ensure named graph exists for K_SHORTEST_PATHS queries."""
|
||||
db = get_db()
|
||||
graph_name = 'optovia_graph'
|
||||
|
||||
if db.has_graph(graph_name):
|
||||
return db.graph(graph_name)
|
||||
|
||||
logger.info("Creating graph: %s", graph_name)
|
||||
return db.create_graph(
|
||||
graph_name,
|
||||
edge_definitions=[{
|
||||
'edge_collection': 'edges',
|
||||
'from_vertex_collections': ['nodes'],
|
||||
'to_vertex_collections': ['nodes'],
|
||||
}],
|
||||
)
|
||||
@@ -1,236 +0,0 @@
|
||||
"""
|
||||
Server-side map clustering using Uber H3 hexagonal grid.
|
||||
|
||||
Maps zoom levels to h3 resolutions and groups nodes by cell.
|
||||
"""
|
||||
import logging
|
||||
import h3
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Map zoom level to h3 resolution
|
||||
# Higher zoom = higher resolution = smaller cells
|
||||
ZOOM_TO_RES = {
|
||||
0: 0, 1: 0, 2: 1, 3: 1, 4: 2, 5: 2,
|
||||
6: 3, 7: 3, 8: 4, 9: 4, 10: 5, 11: 5,
|
||||
12: 6, 13: 7, 14: 8, 15: 9, 16: 10
|
||||
}
|
||||
|
||||
|
||||
def _fetch_nodes(
|
||||
db,
|
||||
west,
|
||||
south,
|
||||
east,
|
||||
north,
|
||||
transport_type=None,
|
||||
node_type=None,
|
||||
product_uuid=None,
|
||||
hub_uuid=None,
|
||||
supplier_uuid=None,
|
||||
):
|
||||
"""Fetch nodes from database for a bounding box.
|
||||
|
||||
Args:
|
||||
db: Database connection
|
||||
west, south, east, north: Bounding box coordinates
|
||||
transport_type: Filter by transport type (auto, rail, sea, air)
|
||||
node_type: Type of nodes to fetch ('logistics', 'offer', 'supplier')
|
||||
"""
|
||||
bind_vars = {
|
||||
'west': west,
|
||||
'south': south,
|
||||
'east': east,
|
||||
'north': north,
|
||||
'product_uuid': product_uuid,
|
||||
'hub_uuid': hub_uuid,
|
||||
'supplier_uuid': supplier_uuid,
|
||||
}
|
||||
|
||||
# Select AQL query based on node_type
|
||||
if node_type == 'offer':
|
||||
aql = """
|
||||
FOR node IN nodes
|
||||
FILTER node.node_type == 'offer'
|
||||
FILTER node.latitude != null AND node.longitude != null
|
||||
FILTER node.latitude >= @south AND node.latitude <= @north
|
||||
FILTER node.longitude >= @west AND node.longitude <= @east
|
||||
FILTER @product_uuid == null OR node.product_uuid == @product_uuid
|
||||
FILTER @supplier_uuid == null OR node.supplier_uuid == @supplier_uuid
|
||||
LET has_hub = @hub_uuid == null ? true : LENGTH(
|
||||
FOR edge IN edges
|
||||
FILTER edge.transport_type == 'offer'
|
||||
FILTER (
|
||||
(edge._from == CONCAT('nodes/', node._key) AND edge._to == CONCAT('nodes/', @hub_uuid)) OR
|
||||
(edge._to == CONCAT('nodes/', node._key) AND edge._from == CONCAT('nodes/', @hub_uuid))
|
||||
)
|
||||
LIMIT 1
|
||||
RETURN 1
|
||||
) > 0
|
||||
FILTER has_hub
|
||||
RETURN node
|
||||
"""
|
||||
elif node_type == 'supplier':
|
||||
# Get suppliers that have offers (aggregate through offers)
|
||||
aql = """
|
||||
FOR offer IN nodes
|
||||
FILTER offer.node_type == 'offer'
|
||||
FILTER offer.supplier_uuid != null
|
||||
FILTER @product_uuid == null OR offer.product_uuid == @product_uuid
|
||||
FILTER @supplier_uuid == null OR offer.supplier_uuid == @supplier_uuid
|
||||
LET has_hub = @hub_uuid == null ? true : LENGTH(
|
||||
FOR edge IN edges
|
||||
FILTER edge.transport_type == 'offer'
|
||||
FILTER (
|
||||
(edge._from == CONCAT('nodes/', offer._key) AND edge._to == CONCAT('nodes/', @hub_uuid)) OR
|
||||
(edge._to == CONCAT('nodes/', offer._key) AND edge._from == CONCAT('nodes/', @hub_uuid))
|
||||
)
|
||||
LIMIT 1
|
||||
RETURN 1
|
||||
) > 0
|
||||
FILTER has_hub
|
||||
LET supplier = DOCUMENT(CONCAT('nodes/', offer.supplier_uuid))
|
||||
FILTER supplier != null
|
||||
FILTER supplier.latitude != null AND supplier.longitude != null
|
||||
FILTER supplier.latitude >= @south AND supplier.latitude <= @north
|
||||
FILTER supplier.longitude >= @west AND supplier.longitude <= @east
|
||||
COLLECT sup_uuid = offer.supplier_uuid INTO offers
|
||||
LET sup = DOCUMENT(CONCAT('nodes/', sup_uuid))
|
||||
RETURN {
|
||||
_key: sup_uuid,
|
||||
name: sup.name,
|
||||
latitude: sup.latitude,
|
||||
longitude: sup.longitude,
|
||||
country: sup.country,
|
||||
country_code: sup.country_code,
|
||||
node_type: 'supplier',
|
||||
offers_count: LENGTH(offers)
|
||||
}
|
||||
"""
|
||||
else: # logistics (default)
|
||||
aql = """
|
||||
FOR node IN nodes
|
||||
FILTER node.node_type == 'logistics' OR node.node_type == null
|
||||
FILTER node.latitude != null AND node.longitude != null
|
||||
FILTER node.latitude >= @south AND node.latitude <= @north
|
||||
FILTER node.longitude >= @west AND node.longitude <= @east
|
||||
FILTER @hub_uuid == null OR node._key == @hub_uuid
|
||||
LET has_offer = (@product_uuid == null AND @supplier_uuid == null) ? true : LENGTH(
|
||||
FOR edge IN edges
|
||||
FILTER edge.transport_type == 'offer'
|
||||
FILTER edge._from == CONCAT('nodes/', node._key) OR edge._to == CONCAT('nodes/', node._key)
|
||||
LET offer_id = edge._from == CONCAT('nodes/', node._key) ? edge._to : edge._from
|
||||
LET offer = DOCUMENT(offer_id)
|
||||
FILTER offer != null AND offer.node_type == 'offer'
|
||||
FILTER @product_uuid == null OR offer.product_uuid == @product_uuid
|
||||
FILTER @supplier_uuid == null OR offer.supplier_uuid == @supplier_uuid
|
||||
LIMIT 1
|
||||
RETURN 1
|
||||
) > 0
|
||||
FILTER has_offer
|
||||
RETURN node
|
||||
"""
|
||||
|
||||
cursor = db.aql.execute(aql, bind_vars=bind_vars)
|
||||
nodes = list(cursor)
|
||||
|
||||
# Filter by transport type if specified (only for logistics nodes)
|
||||
if node_type in (None, 'logistics'):
|
||||
if transport_type:
|
||||
nodes = [
|
||||
n for n in nodes
|
||||
if transport_type in (n.get('transport_types') or [])
|
||||
]
|
||||
else:
|
||||
# Default: only rail/sea hubs
|
||||
nodes = [
|
||||
n for n in nodes
|
||||
if ('rail' in (n.get('transport_types') or [])) or ('sea' in (n.get('transport_types') or []))
|
||||
]
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def get_clustered_nodes(
|
||||
db,
|
||||
west,
|
||||
south,
|
||||
east,
|
||||
north,
|
||||
zoom,
|
||||
transport_type=None,
|
||||
node_type=None,
|
||||
product_uuid=None,
|
||||
hub_uuid=None,
|
||||
supplier_uuid=None,
|
||||
):
|
||||
"""
|
||||
Get clustered nodes for given bounding box and zoom level.
|
||||
|
||||
Uses H3 hexagonal grid to group nearby nodes.
|
||||
|
||||
Args:
|
||||
db: Database connection
|
||||
west, south, east, north: Bounding box coordinates
|
||||
zoom: Map zoom level
|
||||
transport_type: Filter by transport type (for logistics nodes)
|
||||
node_type: Type of nodes ('logistics', 'offer', 'supplier')
|
||||
"""
|
||||
resolution = ZOOM_TO_RES.get(int(zoom), 5)
|
||||
nodes = _fetch_nodes(
|
||||
db,
|
||||
west,
|
||||
south,
|
||||
east,
|
||||
north,
|
||||
transport_type,
|
||||
node_type,
|
||||
product_uuid,
|
||||
hub_uuid,
|
||||
supplier_uuid,
|
||||
)
|
||||
|
||||
if not nodes:
|
||||
return []
|
||||
|
||||
# Group nodes by h3 cell
|
||||
cells = {}
|
||||
for node in nodes:
|
||||
lat = node.get('latitude')
|
||||
lng = node.get('longitude')
|
||||
|
||||
cell = h3.latlng_to_cell(lat, lng, resolution)
|
||||
if cell not in cells:
|
||||
cells[cell] = []
|
||||
cells[cell].append(node)
|
||||
|
||||
# Build results
|
||||
results = []
|
||||
for cell, nodes_in_cell in cells.items():
|
||||
count = len(nodes_in_cell)
|
||||
|
||||
if count == 1:
|
||||
# Single point — return actual node data
|
||||
node = nodes_in_cell[0]
|
||||
results.append({
|
||||
'id': node.get('_key'),
|
||||
'latitude': node.get('latitude'),
|
||||
'longitude': node.get('longitude'),
|
||||
'count': 1,
|
||||
'expansion_zoom': None,
|
||||
'name': node.get('name'),
|
||||
})
|
||||
else:
|
||||
# Cluster — return cell centroid
|
||||
lat, lng = h3.cell_to_latlng(cell)
|
||||
results.append({
|
||||
'id': f"cluster-{cell}",
|
||||
'latitude': lat,
|
||||
'longitude': lng,
|
||||
'count': count,
|
||||
'expansion_zoom': min(zoom + 2, 16),
|
||||
'name': None,
|
||||
})
|
||||
|
||||
logger.info("Returning %d clusters/points for zoom=%d res=%d", len(results), zoom, resolution)
|
||||
return results
|
||||
1846
geo_app/schema.py
1846
geo_app/schema.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user