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