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>
115 lines
4.5 KiB
Python
115 lines
4.5 KiB
Python
"""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.
|