Add FastAPI backend with PostGIS-backed nearby search
FastAPI service backed by PostgreSQL+PostGIS: cities/places/place_content schema (normalized per-language, per-length content for easy future re-ingestion), Alembic migration, an iterative-radius-expansion nearby search endpoint, a name-search fallback for denied-location flows, and a YAML seed loader CLI. Verified end-to-end against a live Docker Postgres+PostGIS instance (migration, city seed, place/content CRUD, and nearby radius expansion all confirmed working via curl). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2acc5dfa2e
commit
d2ab424e7e
43 changed files with 1058 additions and 0 deletions
82
backend/app/services/nearby_search.py
Normal file
82
backend/app/services/nearby_search.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Nearby-place search with iterative radius expansion.
|
||||
|
||||
Sorts candidate places by distance ascending and grows the search radius by
|
||||
``step_m`` until at least ``min_results`` places are found (or ``max_radius_m``
|
||||
is hit, as a safety cap against runaway loops in sparsely-covered areas).
|
||||
|
||||
Reserved for a future v2 extension: ``speed``/``heading`` inputs to bias
|
||||
results toward the user's direction of travel and to pick a shorter content
|
||||
``length`` automatically when moving fast through a dense cluster of places.
|
||||
Not implemented in this iteration — callers should not pass them yet.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy import Row, and_, cast, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.city import City
|
||||
from app.models.enums import ContentLanguage, ContentLength
|
||||
from app.models.place import Place
|
||||
from app.models.place_content import PlaceContent
|
||||
|
||||
|
||||
@dataclass
|
||||
class NearbyResult:
|
||||
search_radius_m: int
|
||||
rows: list[Row]
|
||||
|
||||
|
||||
def find_nearby(
|
||||
db: Session,
|
||||
*,
|
||||
lat: float,
|
||||
lon: float,
|
||||
language: ContentLanguage,
|
||||
length: ContentLength,
|
||||
city_slug: str | None = None,
|
||||
min_results: int = 5,
|
||||
initial_radius_m: int = 300,
|
||||
step_m: int = 200,
|
||||
max_radius_m: int = 5000,
|
||||
) -> NearbyResult:
|
||||
city_id: int | None = None
|
||||
if city_slug is not None:
|
||||
city_id = db.scalar(select(City.id).where(City.slug == city_slug))
|
||||
|
||||
origin = func.ST_GeogFromText(f"SRID=4326;POINT({lon} {lat})")
|
||||
distance_expr = func.ST_Distance(Place.location, origin)
|
||||
|
||||
base_stmt = (
|
||||
select(
|
||||
Place,
|
||||
PlaceContent,
|
||||
distance_expr.label("distance_m"),
|
||||
func.ST_X(cast(Place.location, Geometry)).label("lon"),
|
||||
func.ST_Y(cast(Place.location, Geometry)).label("lat"),
|
||||
)
|
||||
.join(
|
||||
PlaceContent,
|
||||
and_(
|
||||
PlaceContent.place_id == Place.id,
|
||||
PlaceContent.language == language,
|
||||
PlaceContent.length == length,
|
||||
),
|
||||
)
|
||||
.where(Place.is_active.is_(True))
|
||||
.order_by(distance_expr)
|
||||
)
|
||||
if city_id is not None:
|
||||
base_stmt = base_stmt.where(Place.city_id == city_id)
|
||||
|
||||
radius = initial_radius_m
|
||||
rows: list[Row] = []
|
||||
while True:
|
||||
stmt = base_stmt.where(func.ST_DWithin(Place.location, origin, radius))
|
||||
rows = db.execute(stmt).all()
|
||||
if len(rows) >= min_results or radius >= max_radius_m:
|
||||
break
|
||||
radius += step_m
|
||||
|
||||
return NearbyResult(search_radius_m=radius, rows=rows)
|
||||
Loading…
Add table
Add a link
Reference in a new issue