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

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.