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
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
POSTGRES_USER=guidecity
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_DB=guidecity
|
||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal 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
40
backend/alembic.ini
Normal 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
48
backend/alembic/env.py
Normal 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()
|
||||
25
backend/alembic/script.py.mako
Normal file
25
backend/alembic/script.py.mako
Normal 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"}
|
||||
115
backend/alembic/versions/0001_init_schema.py
Normal file
115
backend/alembic/versions/0001_init_schema.py
Normal 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
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)
|
||||
3
backend/requirements-dev.txt
Normal file
3
backend/requirements-dev.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-r requirements.txt
|
||||
pytest==8.3.3
|
||||
httpx==0.27.2
|
||||
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal 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
0
backend/seed/__init__.py
Normal file
5
backend/seed/cities.yaml
Normal file
5
backend/seed/cities.yaml
Normal 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
121
backend/seed/seed_loader.py
Normal 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()
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
11
backend/tests/test_health.py
Normal file
11
backend/tests/test_health.py
Normal 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"}
|
||||
25
backend/tests/test_nearby.py
Normal file
25
backend/tests/test_nearby.py
Normal 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)
|
||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
services:
|
||||
db:
|
||||
image: postgis/postgis:16-3.4
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- guidecity_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
build: ./backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
guidecity_pgdata:
|
||||
Loading…
Add table
Add a link
Reference in a new issue