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

16
backend/Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

40
backend/alembic.ini Normal file
View file

@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

48
backend/alembic/env.py Normal file
View file

@ -0,0 +1,48 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.config import settings
from app.db.base import Base
from app.models import city, place, place_content # noqa: F401 (register models with Base.metadata)
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,115 @@
"""init schema
Revision ID: 0001
Revises:
Create Date: 2026-07-09
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from geoalchemy2 import Geography
from sqlalchemy.dialects import postgresql
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
bind = op.get_bind()
place_category = postgresql.ENUM(
"building",
"bridge",
"monument",
"park",
"district",
"skyscraper",
"cathedral",
"museum",
"gate",
"square",
name="place_category",
create_type=False,
)
content_language = postgresql.ENUM("ru", "en", name="content_language", create_type=False)
content_length = postgresql.ENUM("short", "medium", "long", name="content_length", create_type=False)
place_category.create(bind, checkfirst=True)
content_language.create(bind, checkfirst=True)
content_length.create(bind, checkfirst=True)
op.create_table(
"cities",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("slug", sa.String(64), nullable=False, unique=True),
sa.Column("name_ru", sa.String(128), nullable=False),
sa.Column("name_en", sa.String(128), nullable=False),
sa.Column("country_code", sa.String(2), nullable=False, server_default="RU"),
sa.Column(
"center_point",
Geography(geometry_type="POINT", srid=4326, spatial_index=False),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"places",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("city_id", sa.Integer(), sa.ForeignKey("cities.id", ondelete="CASCADE"), nullable=False),
sa.Column("slug", sa.String(128), nullable=False, unique=True),
sa.Column("category", place_category, nullable=False),
sa.Column(
"location",
Geography(geometry_type="POINT", srid=4326, spatial_index=False),
nullable=False,
),
sa.Column("address", sa.String(256), nullable=True),
sa.Column("district", sa.String(128), nullable=True),
sa.Column("built_year", sa.String(32), nullable=True),
sa.Column("architect_builder", sa.String(256), nullable=True),
sa.Column("architectural_style", sa.String(128), nullable=True),
sa.Column("notable_people", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("idx_places_location_gist", "places", ["location"], postgresql_using="gist")
op.create_index("idx_places_city_id", "places", ["city_id"])
op.create_table(
"place_content",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("place_id", sa.Integer(), sa.ForeignKey("places.id", ondelete="CASCADE"), nullable=False),
sa.Column("language", content_language, nullable=False),
sa.Column("length", content_length, nullable=False),
sa.Column("title", sa.String(256), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.UniqueConstraint("place_id", "language", "length", name="uq_place_content_variant"),
)
op.create_index("idx_place_content_lookup", "place_content", ["place_id", "language", "length"])
def downgrade() -> None:
op.drop_index("idx_place_content_lookup", table_name="place_content")
op.drop_table("place_content")
op.drop_index("idx_places_city_id", table_name="places")
op.drop_index("idx_places_location_gist", table_name="places")
op.drop_table("places")
op.drop_table("cities")
bind = op.get_bind()
postgresql.ENUM(name="content_length").drop(bind, checkfirst=True)
postgresql.ENUM(name="content_language").drop(bind, checkfirst=True)
postgresql.ENUM(name="place_category").drop(bind, checkfirst=True)
# Deliberately not dropping the postgis extension: the postgis/postgis base
# image pre-installs postgis_topology/postgis_tiger_geocoder which depend on
# it, and dropping a shared extension here is out of this migration's scope.

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)

View file

@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.3
httpx==0.27.2

9
backend/requirements.txt Normal file
View file

@ -0,0 +1,9 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
psycopg[binary]==3.2.2
geoalchemy2==0.15.2
alembic==1.13.2
pydantic==2.9.2
pydantic-settings==2.5.2
pyyaml==6.0.2

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

5
backend/seed/cities.yaml Normal file
View file

@ -0,0 +1,5 @@
cities:
- slug: moscow
name_ru: Москва
name_en: Moscow
center: { lat: 55.7558, lon: 37.6173 }

121
backend/seed/seed_loader.py Normal file
View file

@ -0,0 +1,121 @@
"""CLI to load seed YAML content into the database, upserting by slug.
Usage:
python -m seed.seed_loader --cities seed/cities.yaml
python -m seed.seed_loader --file seed/moscow_red_square.yaml
"""
import argparse
from pathlib import Path
import yaml
from app.db.session import SessionLocal
from app.models.city import City
from app.models.place import Place
from app.models.place_content import PlaceContent
def load_cities(path: Path) -> None:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
db = SessionLocal()
try:
for c in data["cities"]:
city = db.query(City).filter(City.slug == c["slug"]).one_or_none()
if city is None:
city = City(slug=c["slug"], name_ru=c["name_ru"], name_en=c["name_en"])
db.add(city)
else:
city.name_ru = c["name_ru"]
city.name_en = c["name_en"]
center = c.get("center")
if center is not None:
city.center_point = f"SRID=4326;POINT({center['lon']} {center['lat']})"
db.flush()
db.commit()
print(f"Loaded {len(data['cities'])} cities from {path.name}")
finally:
db.close()
def _upsert_place(db, city_id: int, place_data: dict) -> Place:
place = db.query(Place).filter(Place.slug == place_data["slug"]).one_or_none()
if place is None:
place = Place(city_id=city_id, slug=place_data["slug"], category=place_data["category"])
db.add(place)
else:
place.category = place_data["category"]
place.city_id = city_id
place.address = place_data.get("address")
place.district = place_data.get("district")
place.built_year = place_data.get("built_year")
place.architect_builder = place_data.get("architect_builder")
place.architectural_style = place_data.get("architectural_style")
place.notable_people = place_data.get("notable_people")
loc = place_data["location"]
place.location = f"SRID=4326;POINT({loc['lon']} {loc['lat']})"
db.flush()
return place
def _upsert_content(db, place_id: int, language: str, length: str, title: str, body: str) -> None:
content = (
db.query(PlaceContent)
.filter(
PlaceContent.place_id == place_id,
PlaceContent.language == language,
PlaceContent.length == length,
)
.one_or_none()
)
if content is None:
db.add(PlaceContent(place_id=place_id, language=language, length=length, title=title, body=body))
else:
content.title = title
content.body = body
def load_places(path: Path) -> None:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
city_slug = data["city"]
db = SessionLocal()
try:
city = db.query(City).filter(City.slug == city_slug).one_or_none()
if city is None:
raise SystemExit(
f"City '{city_slug}' not found. Run `--cities seed/cities.yaml` first."
)
for place_data in data["places"]:
place = _upsert_place(db, city.id, place_data)
titles = place_data.get("title", {})
for language, lengths in place_data["content"].items():
title = titles.get(language, place_data["slug"])
for length, body in lengths.items():
_upsert_content(db, place.id, language, length, title, body.strip())
db.commit()
print(f"Loaded {len(data['places'])} places from {path.name}")
finally:
db.close()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--cities", help="Path to a cities YAML file")
group.add_argument("--file", help="Path to a places YAML file")
args = parser.parse_args()
if args.cities:
load_cities(Path(args.cities))
else:
load_places(Path(args.file))
if __name__ == "__main__":
main()

View file

View file

@ -0,0 +1,11 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health() -> None:
response = client.get("/api/v1/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

View file

@ -0,0 +1,25 @@
"""Integration test — assumes the DB has been migrated and seeded
(see README: alembic upgrade head + seed_loader for all 3 place files)
before running `docker compose exec api pytest`.
"""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_nearby_red_square_returns_at_least_five_places() -> None:
response = client.get(
"/api/v1/nearby",
params={"lat": 55.7539, "lon": 37.6208, "city_slug": "moscow", "lang": "ru", "length": "short"},
)
assert response.status_code == 200
body = response.json()
assert body["count"] >= 5
assert len(body["places"]) == body["count"]
distances = [p["distance_m"] for p in body["places"]]
assert distances == sorted(distances)