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
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
11
backend/app/api/deps.py
Normal file
11
backend/app/api/deps.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from fastapi import Query
|
||||
|
||||
from app.models.enums import ContentLanguage, ContentLength
|
||||
|
||||
|
||||
def lang_param(lang: ContentLanguage = Query(default=ContentLanguage.ru)) -> ContentLanguage:
|
||||
return lang
|
||||
|
||||
|
||||
def length_param(length: ContentLength = Query(default=ContentLength.medium)) -> ContentLength:
|
||||
return length
|
||||
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
34
backend/app/api/routes/cities.py
Normal file
34
backend/app/api/routes/cities.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy import cast, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.city import City
|
||||
from app.schemas.city import CityOut
|
||||
from app.schemas.common import LatLon, LocalizedName
|
||||
|
||||
router = APIRouter(tags=["cities"])
|
||||
|
||||
|
||||
@router.get("/cities", response_model=list[CityOut])
|
||||
def list_cities(db: Session = Depends(get_db)) -> list[CityOut]:
|
||||
stmt = select(
|
||||
City,
|
||||
func.ST_X(cast(City.center_point, Geometry)).label("lon"),
|
||||
func.ST_Y(cast(City.center_point, Geometry)).label("lat"),
|
||||
)
|
||||
rows = db.execute(stmt).all()
|
||||
|
||||
out = []
|
||||
for city, lon, lat in rows:
|
||||
center = LatLon(lat=lat, lon=lon) if lat is not None and lon is not None else None
|
||||
out.append(
|
||||
CityOut(
|
||||
id=city.id,
|
||||
slug=city.slug,
|
||||
name=LocalizedName(ru=city.name_ru, en=city.name_en),
|
||||
center=center,
|
||||
)
|
||||
)
|
||||
return out
|
||||
8
backend/app/api/routes/health.py
Normal file
8
backend/app/api/routes/health.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
67
backend/app/api/routes/nearby.py
Normal file
67
backend/app/api/routes/nearby.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import lang_param, length_param
|
||||
from app.db.session import get_db
|
||||
from app.schemas.common import LatLon
|
||||
from app.schemas.nearby import NearbyPlace, NearbyResponse
|
||||
from app.schemas.place import ContentOut
|
||||
from app.services.nearby_search import find_nearby
|
||||
|
||||
router = APIRouter(tags=["nearby"])
|
||||
|
||||
|
||||
@router.get("/nearby", response_model=NearbyResponse)
|
||||
def nearby(
|
||||
lat: float = Query(),
|
||||
lon: float = Query(),
|
||||
city_slug: str | None = Query(default=None),
|
||||
lang: str = Depends(lang_param),
|
||||
length: str = Depends(length_param),
|
||||
min_results: int = Query(default=5, ge=1, le=50),
|
||||
initial_radius_m: int = Query(default=300, ge=50, le=5000),
|
||||
step_m: int = Query(default=200, ge=50, le=5000),
|
||||
max_radius_m: int = Query(default=5000, ge=100, le=20000),
|
||||
speed: float | None = Query(
|
||||
default=None,
|
||||
description="Reserved for v2: user's speed in m/s. Currently accepted but ignored.",
|
||||
),
|
||||
heading: float | None = Query(
|
||||
default=None,
|
||||
description="Reserved for v2: user's heading in degrees. Currently accepted but ignored.",
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
) -> NearbyResponse:
|
||||
result = find_nearby(
|
||||
db,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
language=lang,
|
||||
length=length,
|
||||
city_slug=city_slug,
|
||||
min_results=min_results,
|
||||
initial_radius_m=initial_radius_m,
|
||||
step_m=step_m,
|
||||
max_radius_m=max_radius_m,
|
||||
)
|
||||
|
||||
places = [
|
||||
NearbyPlace(
|
||||
id=place.id,
|
||||
slug=place.slug,
|
||||
category=place.category,
|
||||
location=LatLon(lat=place_lat, lon=place_lon),
|
||||
address=place.address,
|
||||
district=place.district,
|
||||
built_year=place.built_year,
|
||||
architect_builder=place.architect_builder,
|
||||
architectural_style=place.architectural_style,
|
||||
content=ContentOut(
|
||||
language=content.language, length=content.length, title=content.title, body=content.body
|
||||
),
|
||||
distance_m=round(distance_m, 1),
|
||||
)
|
||||
for place, content, distance_m, place_lon, place_lat in result.rows
|
||||
]
|
||||
|
||||
return NearbyResponse(search_radius_m=result.search_radius_m, count=len(places), places=places)
|
||||
117
backend/app/api/routes/places.py
Normal file
117
backend/app/api/routes/places.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy import and_, cast, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import lang_param, length_param
|
||||
from app.db.session import get_db
|
||||
from app.models.city import City
|
||||
from app.models.enums import PlaceCategory
|
||||
from app.models.place import Place
|
||||
from app.models.place_content import PlaceContent
|
||||
from app.schemas.common import LatLon
|
||||
from app.schemas.place import ContentOut, PlaceDetail, PlaceListItem
|
||||
from app.services.geocoding import search_places
|
||||
|
||||
router = APIRouter(tags=["places"])
|
||||
|
||||
|
||||
@router.get("/places/search", response_model=list[PlaceListItem])
|
||||
def search(
|
||||
q: str = Query(min_length=1),
|
||||
city_slug: str | None = Query(default=None),
|
||||
lang: str = Depends(lang_param),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[PlaceListItem]:
|
||||
rows = search_places(db, q=q, language=lang, city_slug=city_slug)
|
||||
return [
|
||||
PlaceListItem(id=place.id, slug=place.slug, category=place.category, name=content.title, location=LatLon(lat=lat, lon=lon))
|
||||
for place, content, lon, lat in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/places/{place_id}", response_model=PlaceDetail)
|
||||
def get_place(
|
||||
place_id: int,
|
||||
lang: str = Depends(lang_param),
|
||||
length: str = Depends(length_param),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PlaceDetail:
|
||||
stmt = (
|
||||
select(
|
||||
Place,
|
||||
PlaceContent,
|
||||
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 == lang,
|
||||
PlaceContent.length == length,
|
||||
),
|
||||
)
|
||||
.where(Place.id == place_id)
|
||||
)
|
||||
row = db.execute(stmt).first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Place not found")
|
||||
|
||||
place, content, lon, lat = row
|
||||
return PlaceDetail(
|
||||
id=place.id,
|
||||
slug=place.slug,
|
||||
category=place.category,
|
||||
location=LatLon(lat=lat, lon=lon),
|
||||
address=place.address,
|
||||
district=place.district,
|
||||
built_year=place.built_year,
|
||||
architect_builder=place.architect_builder,
|
||||
architectural_style=place.architectural_style,
|
||||
content=ContentOut(language=content.language, length=content.length, title=content.title, body=content.body),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cities/{city_slug}/places", response_model=list[PlaceListItem])
|
||||
def list_city_places(
|
||||
city_slug: str,
|
||||
category: PlaceCategory | None = Query(default=None),
|
||||
district: str | None = Query(default=None),
|
||||
q: str | None = Query(default=None),
|
||||
lang: str = Depends(lang_param),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[PlaceListItem]:
|
||||
city = db.scalar(select(City).where(City.slug == city_slug))
|
||||
if city is None:
|
||||
raise HTTPException(status_code=404, detail="City not found")
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Place,
|
||||
PlaceContent,
|
||||
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 == lang,
|
||||
PlaceContent.length == "short",
|
||||
),
|
||||
)
|
||||
.where(Place.city_id == city.id, Place.is_active.is_(True))
|
||||
)
|
||||
if category is not None:
|
||||
stmt = stmt.where(Place.category == category)
|
||||
if district is not None:
|
||||
stmt = stmt.where(Place.district == district)
|
||||
if q is not None:
|
||||
stmt = stmt.where(PlaceContent.title.ilike(f"%{q}%"))
|
||||
|
||||
rows = db.execute(stmt).all()
|
||||
return [
|
||||
PlaceListItem(id=p.id, slug=p.slug, category=p.category, name=pc.title, location=LatLon(lat=lat, lon=lon))
|
||||
for p, pc, lon, lat in rows
|
||||
]
|
||||
11
backend/app/config.py
Normal file
11
backend/app/config.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
database_url: str = "postgresql+psycopg://guidecity:guidecity@localhost:5432/guidecity"
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
8
backend/app/core/logging.py
Normal file
8
backend/app/core/logging.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import logging
|
||||
|
||||
|
||||
def configure_logging(level: int = logging.INFO) -> None:
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
5
backend/app/db/base.py
Normal file
5
backend/app/db/base.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
17
backend/app/db/session.py
Normal file
17
backend/app/db/session.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
11
backend/app/main.py
Normal file
11
backend/app/main.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from fastapi import FastAPI
|
||||
|
||||
from app.api.routes import cities, health, nearby, places
|
||||
from app.config import settings
|
||||
|
||||
app = FastAPI(title="guideCity API", version="0.1.0")
|
||||
|
||||
app.include_router(health.router, prefix=settings.api_v1_prefix)
|
||||
app.include_router(cities.router, prefix=settings.api_v1_prefix)
|
||||
app.include_router(places.router, prefix=settings.api_v1_prefix)
|
||||
app.include_router(nearby.router, prefix=settings.api_v1_prefix)
|
||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.models.city import City
|
||||
from app.models.place import Place
|
||||
from app.models.place_content import PlaceContent
|
||||
|
||||
__all__ = ["City", "Place", "PlaceContent"]
|
||||
23
backend/app/models/city.py
Normal file
23
backend/app/models/city.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from datetime import datetime
|
||||
|
||||
from geoalchemy2 import Geography
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class City(Base):
|
||||
__tablename__ = "cities"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
name_ru: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
name_en: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
country_code: Mapped[str] = mapped_column(String(2), nullable=False, default="RU")
|
||||
center_point = mapped_column(
|
||||
Geography(geometry_type="POINT", srid=4326, spatial_index=False), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
places: Mapped[list["Place"]] = relationship(back_populates="city", cascade="all, delete-orphan")
|
||||
25
backend/app/models/enums.py
Normal file
25
backend/app/models/enums.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import enum
|
||||
|
||||
|
||||
class PlaceCategory(str, enum.Enum):
|
||||
building = "building"
|
||||
bridge = "bridge"
|
||||
monument = "monument"
|
||||
park = "park"
|
||||
district = "district"
|
||||
skyscraper = "skyscraper"
|
||||
cathedral = "cathedral"
|
||||
museum = "museum"
|
||||
gate = "gate"
|
||||
square = "square"
|
||||
|
||||
|
||||
class ContentLanguage(str, enum.Enum):
|
||||
ru = "ru"
|
||||
en = "en"
|
||||
|
||||
|
||||
class ContentLength(str, enum.Enum):
|
||||
short = "short"
|
||||
medium = "medium"
|
||||
long = "long"
|
||||
38
backend/app/models/place.py
Normal file
38
backend/app/models/place.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from datetime import datetime
|
||||
|
||||
from geoalchemy2 import Geography
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.enums import PlaceCategory
|
||||
|
||||
|
||||
class Place(Base):
|
||||
__tablename__ = "places"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
city_id: Mapped[int] = mapped_column(ForeignKey("cities.id", ondelete="CASCADE"), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
category: Mapped[PlaceCategory] = mapped_column(
|
||||
Enum(PlaceCategory, name="place_category", native_enum=True), nullable=False
|
||||
)
|
||||
location = mapped_column(
|
||||
Geography(geometry_type="POINT", srid=4326, spatial_index=False), nullable=False
|
||||
)
|
||||
address: Mapped[str | None] = mapped_column(String(256))
|
||||
district: Mapped[str | None] = mapped_column(String(128))
|
||||
built_year: Mapped[str | None] = mapped_column(String(32))
|
||||
architect_builder: Mapped[str | None] = mapped_column(String(256))
|
||||
architectural_style: Mapped[str | None] = mapped_column(String(128))
|
||||
notable_people: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
city: Mapped["City"] = relationship(back_populates="places")
|
||||
contents: Mapped[list["PlaceContent"]] = relationship(
|
||||
back_populates="place", cascade="all, delete-orphan"
|
||||
)
|
||||
23
backend/app/models/place_content.py
Normal file
23
backend/app/models/place_content.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from sqlalchemy import Enum, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.enums import ContentLanguage, ContentLength
|
||||
|
||||
|
||||
class PlaceContent(Base):
|
||||
__tablename__ = "place_content"
|
||||
__table_args__ = (UniqueConstraint("place_id", "language", "length", name="uq_place_content_variant"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
place_id: Mapped[int] = mapped_column(ForeignKey("places.id", ondelete="CASCADE"), nullable=False)
|
||||
language: Mapped[ContentLanguage] = mapped_column(
|
||||
Enum(ContentLanguage, name="content_language", native_enum=True), nullable=False
|
||||
)
|
||||
length: Mapped[ContentLength] = mapped_column(
|
||||
Enum(ContentLength, name="content_length", native_enum=True), nullable=False
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
place: Mapped["Place"] = relationship(back_populates="contents")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
10
backend/app/schemas/city.py
Normal file
10
backend/app/schemas/city.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.common import LatLon, LocalizedName
|
||||
|
||||
|
||||
class CityOut(BaseModel):
|
||||
id: int
|
||||
slug: str
|
||||
name: LocalizedName
|
||||
center: LatLon | None = None
|
||||
11
backend/app/schemas/common.py
Normal file
11
backend/app/schemas/common.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LatLon(BaseModel):
|
||||
lat: float
|
||||
lon: float
|
||||
|
||||
|
||||
class LocalizedName(BaseModel):
|
||||
ru: str
|
||||
en: str
|
||||
13
backend/app/schemas/nearby.py
Normal file
13
backend/app/schemas/nearby.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.place import PlaceDetail
|
||||
|
||||
|
||||
class NearbyPlace(PlaceDetail):
|
||||
distance_m: float
|
||||
|
||||
|
||||
class NearbyResponse(BaseModel):
|
||||
search_radius_m: int
|
||||
count: int
|
||||
places: list[NearbyPlace]
|
||||
32
backend/app/schemas/place.py
Normal file
32
backend/app/schemas/place.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
from app.models.enums import ContentLanguage, ContentLength, PlaceCategory
|
||||
from app.schemas.common import LatLon
|
||||
|
||||
|
||||
class ContentOut(BaseModel):
|
||||
language: ContentLanguage
|
||||
length: ContentLength
|
||||
title: str
|
||||
body: str
|
||||
|
||||
|
||||
class PlaceListItem(BaseModel):
|
||||
id: int
|
||||
slug: str
|
||||
category: PlaceCategory
|
||||
name: str
|
||||
location: LatLon
|
||||
|
||||
|
||||
class PlaceDetail(BaseModel):
|
||||
id: int
|
||||
slug: str
|
||||
category: PlaceCategory
|
||||
location: LatLon
|
||||
address: str | None = None
|
||||
district: str | None = None
|
||||
built_year: str | None = None
|
||||
architect_builder: str | None = None
|
||||
architectural_style: str | None = None
|
||||
content: ContentOut
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
47
backend/app/services/geocoding.py
Normal file
47
backend/app/services/geocoding.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Fallback name/address search over our own `places` dataset.
|
||||
|
||||
Used when the user denies location permission and types a place of interest,
|
||||
or when 2GIS's own geocoding API isn't wired into a given client flow yet.
|
||||
"""
|
||||
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy import 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
|
||||
|
||||
|
||||
def search_places(
|
||||
db: Session,
|
||||
*,
|
||||
q: str,
|
||||
language: ContentLanguage,
|
||||
city_slug: str | None = None,
|
||||
limit: int = 20,
|
||||
):
|
||||
stmt = (
|
||||
select(
|
||||
Place,
|
||||
PlaceContent,
|
||||
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 == ContentLength.short,
|
||||
),
|
||||
)
|
||||
.where(Place.is_active.is_(True))
|
||||
.where(PlaceContent.title.ilike(f"%{q}%"))
|
||||
.limit(limit)
|
||||
)
|
||||
if city_slug is not None:
|
||||
stmt = stmt.join(City, City.id == Place.city_id).where(City.slug == city_slug)
|
||||
|
||||
return db.execute(stmt).all()
|
||||
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