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
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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue