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:
vrubelroman 2026-07-09 18:31:29 +00:00
parent 2acc5dfa2e
commit d2ab424e7e
43 changed files with 1058 additions and 0 deletions

0
backend/app/__init__.py Normal file
View file

View file

11
backend/app/api/deps.py Normal file
View 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

View file

View 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

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}

View 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)

View 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
View 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()

View file

View 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",
)

View file

5
backend/app/db/base.py Normal file
View file

@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

17
backend/app/db/session.py Normal file
View 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
View 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)

View 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"]

View 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")

View 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"

View 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"
)

View 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")

View file

View 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

View file

@ -0,0 +1,11 @@
from pydantic import BaseModel
class LatLon(BaseModel):
lat: float
lon: float
class LocalizedName(BaseModel):
ru: str
en: str

View 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]

View 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

View file

View 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()

View 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)