diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fb6fe50 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +POSTGRES_USER=guidecity +POSTGRES_PASSWORD=changeme +POSTGRES_DB=guidecity diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a5ea442 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..bac4e98 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..975f460 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/0001_init_schema.py b/backend/alembic/versions/0001_init_schema.py new file mode 100644 index 0000000..dd813ab --- /dev/null +++ b/backend/alembic/versions/0001_init_schema.py @@ -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. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..eb7217c --- /dev/null +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/cities.py b/backend/app/api/routes/cities.py new file mode 100644 index 0000000..a573996 --- /dev/null +++ b/backend/app/api/routes/cities.py @@ -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 diff --git a/backend/app/api/routes/health.py b/backend/app/api/routes/health.py new file mode 100644 index 0000000..bc7b77f --- /dev/null +++ b/backend/app/api/routes/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/backend/app/api/routes/nearby.py b/backend/app/api/routes/nearby.py new file mode 100644 index 0000000..09b9306 --- /dev/null +++ b/backend/app/api/routes/nearby.py @@ -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) diff --git a/backend/app/api/routes/places.py b/backend/app/api/routes/places.py new file mode 100644 index 0000000..26e545a --- /dev/null +++ b/backend/app/api/routes/places.py @@ -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 + ] diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..09195f7 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..a9dd3e3 --- /dev/null +++ b/backend/app/core/logging.py @@ -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", + ) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..49d0fac --- /dev/null +++ b/backend/app/db/session.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2a6460f --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..1016bb4 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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"] diff --git a/backend/app/models/city.py b/backend/app/models/city.py new file mode 100644 index 0000000..8f07e04 --- /dev/null +++ b/backend/app/models/city.py @@ -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") diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py new file mode 100644 index 0000000..a5b616b --- /dev/null +++ b/backend/app/models/enums.py @@ -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" diff --git a/backend/app/models/place.py b/backend/app/models/place.py new file mode 100644 index 0000000..97435b5 --- /dev/null +++ b/backend/app/models/place.py @@ -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" + ) diff --git a/backend/app/models/place_content.py b/backend/app/models/place_content.py new file mode 100644 index 0000000..51e8e27 --- /dev/null +++ b/backend/app/models/place_content.py @@ -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") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/city.py b/backend/app/schemas/city.py new file mode 100644 index 0000000..5e8224e --- /dev/null +++ b/backend/app/schemas/city.py @@ -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 diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..c15a6b9 --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class LatLon(BaseModel): + lat: float + lon: float + + +class LocalizedName(BaseModel): + ru: str + en: str diff --git a/backend/app/schemas/nearby.py b/backend/app/schemas/nearby.py new file mode 100644 index 0000000..c3b11bf --- /dev/null +++ b/backend/app/schemas/nearby.py @@ -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] diff --git a/backend/app/schemas/place.py b/backend/app/schemas/place.py new file mode 100644 index 0000000..119d5a4 --- /dev/null +++ b/backend/app/schemas/place.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/geocoding.py b/backend/app/services/geocoding.py new file mode 100644 index 0000000..0637f36 --- /dev/null +++ b/backend/app/services/geocoding.py @@ -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() diff --git a/backend/app/services/nearby_search.py b/backend/app/services/nearby_search.py new file mode 100644 index 0000000..94c763f --- /dev/null +++ b/backend/app/services/nearby_search.py @@ -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) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..b918a15 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.3.3 +httpx==0.27.2 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..da7a023 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/seed/__init__.py b/backend/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/seed/cities.yaml b/backend/seed/cities.yaml new file mode 100644 index 0000000..c9edd29 --- /dev/null +++ b/backend/seed/cities.yaml @@ -0,0 +1,5 @@ +cities: + - slug: moscow + name_ru: Москва + name_en: Moscow + center: { lat: 55.7558, lon: 37.6173 } diff --git a/backend/seed/seed_loader.py b/backend/seed/seed_loader.py new file mode 100644 index 0000000..99daeb5 --- /dev/null +++ b/backend/seed/seed_loader.py @@ -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() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..603dedb --- /dev/null +++ b/backend/tests/test_health.py @@ -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"} diff --git a/backend/tests/test_nearby.py b/backend/tests/test_nearby.py new file mode 100644 index 0000000..01b6b85 --- /dev/null +++ b/backend/tests/test_nearby.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..697da63 --- /dev/null +++ b/docker-compose.yml @@ -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: