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/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
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue