"""BGP route-collector location resolver. Collector positions are stored in the ``bgp_collector_locations`` database table. The old JSON registry is now only a seed payload used during database initialization, not a runtime resolver or candidate source. """ from __future__ import annotations import json from pathlib import Path from typing import Any, Iterator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.bgp_collector_location import BGPCollectorLocation from app.services.location import ( LocationCandidate, LocationPipeline, LocationQuery, NominatimResolver, ResolutionResult, ResolverOutput, SourceCoordinatesResolver, build_default_nominatim_geocoder, coerce_str, normalize_text, ) SEED_PATH = ( Path(__file__).resolve().parents[1] / "data" / "seeds" / "ripe_ris_collector_locations_seed.json" ) # ── Geocoder (kept at module level for monkeypatching + cache_clear) ── _geocode_online = build_default_nominatim_geocoder() # ── In-process compatibility cache ────────────────────────────────── RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {} def _collector_record_to_dict(record: BGPCollectorLocation) -> dict[str, Any]: return record.to_location_dict() def set_bgp_collector_location_cache( locations: dict[str, dict[str, Any]], ) -> None: """Replace the legacy compatibility cache in-place.""" RIPE_RIS_COLLECTOR_COORDS.clear() RIPE_RIS_COLLECTOR_COORDS.update( {coerce_str(key): dict(value) for key, value in locations.items()} ) async def refresh_bgp_collector_location_cache( session: AsyncSession, ) -> dict[str, dict[str, Any]]: result = await session.execute(select(BGPCollectorLocation)) records = result.scalars().all() cache = { record.collector_id: _collector_record_to_dict(record) for record in records if record.collector_id } set_bgp_collector_location_cache(cache) return cache def _load_seed_payload() -> dict[str, Any]: with SEED_PATH.open("r", encoding="utf-8") as handle: return json.load(handle) def _seed_entry_to_record_kwargs(entry: dict[str, Any], collector_id: str) -> dict[str, Any]: return { "collector_id": collector_id, "operator": entry.get("operator") or "RIPE NCC", "site": entry.get("site"), "city": entry.get("city"), "country": entry.get("country"), "latitude": entry.get("latitude"), "longitude": entry.get("longitude"), "precision": entry.get("precision") or "city", "confidence": entry.get("confidence"), "source": "legacy_seed", "source_url": None, "source_note": entry.get("source_note") or "Seeded from legacy RIPE RIS collector coordinates", "raw_payload": entry, "needs_confirmation": True, "verification_status": "unverified", "verified_at": None, } async def seed_default_bgp_collector_locations(session: AsyncSession) -> None: """Seed default RIPE RIS collector locations without overwriting users.""" payload = _load_seed_payload() for entry in payload.get("locations", []): aliases = entry.get("aliases") or [] collector_ids = [ coerce_str(alias) for alias in aliases if coerce_str(alias).startswith("rrc") ] if not collector_ids: continue collector_id = collector_ids[0] existing = await session.scalar( select(BGPCollectorLocation).where( BGPCollectorLocation.collector_id == collector_id ) ) if existing: continue session.add( BGPCollectorLocation( **_seed_entry_to_record_kwargs(entry, collector_id) ) ) await session.commit() await refresh_bgp_collector_location_cache(session) def get_bgp_collector_location_dict(collector_name: str) -> dict[str, Any]: """Return the current cached collector location dict, or ``{}`` if unknown.""" return dict(RIPE_RIS_COLLECTOR_COORDS.get(coerce_str(collector_name), {})) def iter_known_collector_names() -> Iterator[str]: """Yield every collector technical name (rrcXX) known in the cache.""" return iter(sorted(RIPE_RIS_COLLECTOR_COORDS.keys())) # ── Pipeline construction ────────────────────────────────────────── class StoredCollectorLocationResolver: """Resolve a collector through the DB-backed compatibility cache.""" name = "stored_collector_location" def resolve(self, query: LocationQuery) -> ResolverOutput: collector = coerce_str(query.name) if not collector: for alias in query.aliases: collector = coerce_str(alias) if collector: break if not collector: return ResolverOutput() location = get_bgp_collector_location_dict(collector) if not location: return ResolverOutput() latitude = location.get("latitude") longitude = location.get("longitude") if latitude in (None, 0.0) or longitude in (None, 0.0): return ResolverOutput() return ResolverOutput( candidates=( LocationCandidate( latitude=float(latitude), longitude=float(longitude), display_name=location.get("matched_location_name") or collector, precision=location.get("precision") or "city", confidence=float(location.get("confidence") or 0.85), query=f"stored_collector_location::{collector}", source=location.get("source") or self.name, source_note=location.get("source_note"), matched_fields=("collector",), needs_confirmation=bool(location.get("needs_confirmation")), city=location.get("city"), region=None, country=location.get("country"), matched_location_name=( location.get("matched_location_name") or collector ), location_verified_at=location.get("verified_at"), suggested_registry_entry=None, ), ) ) def _bgp_collector_query_plan( query: LocationQuery, ) -> list[tuple[str, tuple[str, ...]]]: """Build the Nominatim query plan for a BGP collector.""" extra = query.extra or {} site = str(extra.get("site") or "") operator = str(extra.get("operator") or "") city = query.city or "" country = query.country or "" plan: list[tuple[str, tuple[str, ...]]] = [] def add(parts: list[tuple[str, str]]) -> None: non_empty = [(field, value) for field, value in parts if value] if not non_empty: return seen: set[str] = set() cleaned: list[str] = [] fields: list[str] = [] for field, value in non_empty: key = normalize_text(value) if not key or key in seen: continue seen.add(key) cleaned.append(value) fields.append(field) if not cleaned: return composed = ", ".join(cleaned) if not any(composed == existing for existing, _ in plan): plan.append((composed, tuple(fields))) add([("site", site), ("city", city), ("country", country)]) add([("site", site), ("country", country)]) add([("operator", operator), ("city", city), ("country", country)]) add([("city", city), ("country", country)]) return plan BGP_COLLECTOR_PIPELINE = LocationPipeline( [ SourceCoordinatesResolver(), StoredCollectorLocationResolver(), ], failure_reason=( "Could not resolve BGP collector to renderable coordinates from" " source coordinates or stored collector location." ), ) BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline( [ SourceCoordinatesResolver(), NominatimResolver( query_plan_builder=_bgp_collector_query_plan, # Late-binding so tests can monkeypatch ``_geocode_online``. geocoder=lambda q: _geocode_online(q), ), ], failure_reason=( "Could not resolve BGP collector to renderable coordinates from" " source coordinates or online geocoding." ), ) # ── Public API ───────────────────────────────────────────────────── def resolve_bgp_collector_location( collector_name: str, *, city: str | None = None, country: str | None = None, site: str | None = None, operator: str | None = None, ) -> ResolutionResult: """Resolve a BGP collector to its best-known stored location.""" stored = get_bgp_collector_location_dict(collector_name) name = coerce_str(collector_name) or None query = LocationQuery( name=name, aliases=tuple(filter(None, (collector_name,))), city=coerce_str(city or stored.get("city")) or None, country=coerce_str(country or stored.get("country")) or None, extra={ "site": coerce_str(site or stored.get("site")), "operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC", }, ) return BGP_COLLECTOR_PIPELINE.resolve_best(query) def collect_bgp_collector_location_candidates( *, collector: str | None = None, city: str | None = None, country: str | None = None, site: str | None = None, operator: str | None = None, ) -> tuple[list[LocationCandidate], list[str]]: query = build_bgp_collector_location_query( collector=collector, city=city, country=country, site=site, operator=operator, ) return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query) def build_bgp_collector_location_query( *, collector: str | None = None, city: str | None = None, country: str | None = None, site: str | None = None, operator: str | None = None, ) -> LocationQuery: stored = get_bgp_collector_location_dict(collector or "") name = coerce_str(collector) or None return LocationQuery( name=name, aliases=tuple(filter(None, (collector,))), city=coerce_str(city or stored.get("city")) or None, country=coerce_str(country or stored.get("country")) or None, extra={ "site": coerce_str(site or stored.get("site")), "operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC", "collector": coerce_str(collector), }, )