diff --git a/Dockerfile b/Dockerfile
index 8176851..49f7e85 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,7 +16,7 @@ RUN apt-get update && apt-get install -y \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
-COPY ./requirements.txt ./setup.cfg ./black.toml ./.pylintrc /
+COPY ./requirements.txt ./setup.cfg ./black.toml ./.pylintrc ./pytest.ini /
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --upgrade pip -r /requirements.txt
diff --git a/Makefile b/Makefile
index 99b3086..57fdc36 100644
--- a/Makefile
+++ b/Makefile
@@ -20,5 +20,11 @@ lint:
test:
docker compose run favorite-places-app pytest --cov=/src --cov-report html:htmlcov --cov-report term --cov-config=/src/tests/.coveragerc -vv
+database:
+ docker compose up -d favorite-places-db
+
+migrate:
+ docker compose run favorite-places-app alembic upgrade head
+
# запуск всех функций поддержки качества кода
all: format lint test
diff --git a/docs/Makefile b/docs/Makefile
old mode 100644
new mode 100755
diff --git a/docs/img/2023-03-05_00-01.png b/docs/img/2023-03-05_00-01.png
new file mode 100644
index 0000000..1ac2fe5
Binary files /dev/null and b/docs/img/2023-03-05_00-01.png differ
diff --git a/docs/img/2023-03-05_13-31.png b/docs/img/2023-03-05_13-31.png
new file mode 100644
index 0000000..7e11b8a
Binary files /dev/null and b/docs/img/2023-03-05_13-31.png differ
diff --git "a/docs/img/Screenshot 2023-03-05 at 00-00-01 API \321\201\320\270\321\201\321\202\320\265\320\274\321\213 Favorite Places Service - Swagger UI.png" "b/docs/img/Screenshot 2023-03-05 at 00-00-01 API \321\201\320\270\321\201\321\202\320\265\320\274\321\213 Favorite Places Service - Swagger UI.png"
new file mode 100644
index 0000000..eee2f1d
Binary files /dev/null and "b/docs/img/Screenshot 2023-03-05 at 00-00-01 API \321\201\320\270\321\201\321\202\320\265\320\274\321\213 Favorite Places Service - Swagger UI.png" differ
diff --git "a/docs/img/Screenshot 2023-03-05 at 00-01-14 API \321\201\320\270\321\201\321\202\320\265\320\274\321\213 Favorite Places Service - Swagger UI.png" "b/docs/img/Screenshot 2023-03-05 at 00-01-14 API \321\201\320\270\321\201\321\202\320\265\320\274\321\213 Favorite Places Service - Swagger UI.png"
new file mode 100644
index 0000000..450cf79
Binary files /dev/null and "b/docs/img/Screenshot 2023-03-05 at 00-01-14 API \321\201\320\270\321\201\321\202\320\265\320\274\321\213 Favorite Places Service - Swagger UI.png" differ
diff --git a/docs/img/pagination.png b/docs/img/pagination.png
new file mode 100644
index 0000000..ea08e8c
Binary files /dev/null and b/docs/img/pagination.png differ
diff --git a/docs/make.bat b/docs/make.bat
old mode 100644
new mode 100755
diff --git a/docs/source/conf.py b/docs/source/conf.py
old mode 100644
new mode 100755
index e124982..cf9f44c
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -23,7 +23,7 @@
version = settings.project.release_version
# the full project version, including alpha/beta/rc tags
release = settings.project.release_version
-author = "Michael"
+author = "Roman"
copyright = f"{date.today().year}, {author}"
# -- General configuration ---------------------------------------------------
diff --git a/docs/source/index.rst b/docs/source/index.rst
old mode 100644
new mode 100755
index fb16a18..e11b9ce
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -6,11 +6,56 @@
Зависимости
===========
+Install the appropriate software:
+
+1. [Docker Desktop](https://www.docker.com).
+2. [Git](https://github.com/git-guides/install-git).
+3. [PyCharm](https://www.jetbrains.com/ru-ru/pycharm/download) (optional).
Установка
=========
+Clone the repository to your computer:
+
+.. code-block::console
+
+ git clone https://github.com/mnv/python-course-favorite-places.git
+
+1. To configure the application copy `.env.sample` into `.env` file:
+
+ .. code-block::console
+
+ cp .env.sample .env
+
+
+ This file contains environment variables that will share their values across the application.
+ The sample file (`.env.sample`) contains a set of variables with default values.
+ So it can be configured depending on the environment.
+
+2. Build the container using Docker Compose:
+
+ .. code-block::console
+
+ docker compose build
+
+ This command should be run from the root directory where `Dockerfile` is located.
+ You also need to build the docker container again in case if you have updated `requirements.txt`.
+
+3. To run application correctly set up the database.
+ Apply migrations to create tables in the database:
+
+ .. code-block::console
+
+ docker compose run favorite-places-app alembic upgrade head
+
+4. Now it is possible to run the project inside the Docker container:
+
+ .. code-block::console
+
+ docker compose up
+
+ When containers are up server starts at [http://0.0.0.0:8010/docs](http://0.0.0.0:8010/docs). You can open it in your browser.
Использование
@@ -20,15 +65,155 @@
Работа с базой данных
---------------------
+To first initialize migration functionality run:
+
+ .. code-block::console
+
+ docker compose exec favorite-places-app alembic init -t async migrations
+
+This command will create a directory with configuration files to set up asynchronous migrations' functionality.
+
+To create new migrations that will update database tables according updated models run this command:
+
+ .. code-block::console
+ docker compose run favorite-places-app alembic revision --autogenerate -m "your description"
+
+To apply created migrations run:
+
+ .. code-block::console
+
+ docker compose run favorite-places-app alembic upgrade head
Автоматизация
=============
+The project contains a special `Makefile` that provides shortcuts for a set of commands:
+
+1. Build the Docker container:
+
+.. code-block::console
+
+ make build
+
+
+2. Generate Sphinx documentation run:
+
+ .. code-block::console
+
+ make docs-html
+
+
+3. Autoformat source code:
+
+ .. code-block::console
+
+ make format
+
+
+4. Static analysis (linters):
+
+ .. code-block::console
+
+ make lint
+
+
+5. Autotests:
+
+ .. code-block::console
+
+ make test
+
+
+ The test coverage report will be located at `src/htmlcov/index.html`.
+ So you can estimate the quality of automated test coverage.
+
+6. Run autoformat, linters and tests in one command:
+
+ .. code-block::console
+
+ make all
+
+
+ Run these commands from the source directory where `Makefile` is located.
Тестирование
============
+To run tests use the following command:
+ .. code-block::console
+ make all
+
+Документация
+============
+
+Клиенты
+=======
+Базовый
+--------
+.. automodule:: clients.base.base
+ :members:
+
+Geo
+---
+.. automodule:: clients.geo
+ :members:
+
+Schemas
+-------
+.. automodule:: clients.base.base
+ :members:
+
+Integrations
+============
+Database
+--------
+.. automodule:: integrations.db.session
+ :members:
+Events
+------
+.. automodule:: integrations.events.events
+ :members:
+.. automodule:: integrations.events.schemas
+ :members:
+
+Models
+======
+.. automodule:: models.mixins
+ :members:
+.. automodule:: models.places
+ :members:
+Repositories
+============
+.. automodule:: repositories.base_repository
+ :members:
+.. automodule:: repositories.places_repository
+ :members:
+
+
+Settings
+========
+.. automodule:: settings
+ :members:
+
+Schemas
+=======
+.. automodule:: schemas.base
+ :members:
+.. automodule:: schemas.places
+ :members:
+.. automodule:: schemas.routes
+ :members:
+
+Services
+========
+.. automodule:: services.places_service
+ :members:
+
+Transport
+=========
+.. automodule:: transport.handlers.places
+ :members:
diff --git a/report.md b/report.md
new file mode 100644
index 0000000..2b09015
--- /dev/null
+++ b/report.md
@@ -0,0 +1,31 @@
+
+# Table of Contents
+
+1. [Определение по ip](#org33cd135)
+2. [Пагинация](#org395fab9)
+3. [Результаты `make all`](#orgd4362b8)
+
+
+
+
+
+# Определение по ip
+
+
+
+
+
+
+
+
+# Пагинация
+
+
+
+
+
+
+# Результаты `make all`
+
+
+
diff --git a/report.org b/report.org
new file mode 100644
index 0000000..11dda28
--- /dev/null
+++ b/report.org
@@ -0,0 +1,11 @@
+#+title: Report
+
+* Определение по ip
+[[file:docs/img/Screenshot 2023-03-05 at 00-01-14 API системы Favorite Places Service - Swagger UI.png]]
+[[file:docs/img/Screenshot 2023-03-05 at 00-00-01 API системы Favorite Places Service - Swagger UI.png]]
+[[file:docs/img/2023-03-05_00-01.png]]
+
+* Пагинация
+[[file:docs/img/pagination.png]]
+* Результаты =make all=
+[[file:docs/img/2023-03-05_13-31.png]]
diff --git a/requirements.txt b/requirements.txt
index a0568b2..18ee6bf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,6 +16,8 @@ asyncpg>=0.26.0,<1.0.0
pika>=1.3.1,<1.4.0
# работа с HTTP-запросами
httpx>=0.23.0,<0.24.0
+sqlalchemy-utils==0.40.0
+psycopg2-binary==2.9.5
# автоматические тесты
pytest>=7.1.3,<7.2.0
diff --git a/src/__init__.py b/src/__init__.py
old mode 100644
new mode 100755
diff --git a/src/alembic.ini b/src/alembic.ini
old mode 100644
new mode 100755
diff --git a/src/bootstrap.py b/src/bootstrap.py
old mode 100644
new mode 100755
index 65327cc..8006e7a
--- a/src/bootstrap.py
+++ b/src/bootstrap.py
@@ -1,4 +1,5 @@
from fastapi import FastAPI
+from fastapi_pagination import add_pagination
from exceptions import setup_exception_handlers
from routes import metadata_tags, setup_routes
@@ -19,5 +20,6 @@ def build_app() -> FastAPI:
setup_routes(app)
setup_exception_handlers(app)
+ add_pagination(app)
return app
diff --git a/src/clients/__init__.py b/src/clients/__init__.py
old mode 100644
new mode 100755
diff --git a/src/clients/base/__init__.py b/src/clients/base/__init__.py
old mode 100644
new mode 100755
diff --git a/src/clients/base/base.py b/src/clients/base/base.py
old mode 100644
new mode 100755
diff --git a/src/clients/geo.py b/src/clients/geo.py
old mode 100644
new mode 100755
index 4941f1c..f69dc8b
--- a/src/clients/geo.py
+++ b/src/clients/geo.py
@@ -8,7 +8,7 @@
import httpx
from clients.base.base import BaseClient
-from clients.shemas import LocalityDTO
+from clients.schemas import LocalityDTO
class LocationClient(BaseClient):
diff --git a/src/clients/shemas.py b/src/clients/schemas.py
old mode 100644
new mode 100755
similarity index 100%
rename from src/clients/shemas.py
rename to src/clients/schemas.py
diff --git a/src/exceptions.py b/src/exceptions.py
old mode 100644
new mode 100755
index 440d5d1..f594aa4
--- a/src/exceptions.py
+++ b/src/exceptions.py
@@ -11,7 +11,7 @@ class ApiHTTPException(HTTPException):
"""Обработка ошибок API."""
status_code: int
- code: str
+ code: str = "None"
detail: str
def __init__(
diff --git a/src/integrations/__init__.py b/src/integrations/__init__.py
old mode 100644
new mode 100755
diff --git a/src/integrations/db/__init__.py b/src/integrations/db/__init__.py
old mode 100644
new mode 100755
diff --git a/src/integrations/db/session.py b/src/integrations/db/session.py
old mode 100644
new mode 100755
diff --git a/src/integrations/events/__init__.py b/src/integrations/events/__init__.py
old mode 100644
new mode 100755
diff --git a/src/integrations/events/producer.py b/src/integrations/events/producer.py
old mode 100644
new mode 100755
diff --git a/src/integrations/events/schemas.py b/src/integrations/events/schemas.py
old mode 100644
new mode 100755
diff --git a/src/logging.conf b/src/logging.conf
old mode 100644
new mode 100755
index 28befa3..4752c49
--- a/src/logging.conf
+++ b/src/logging.conf
@@ -1,6 +1,7 @@
[loggers]
keys=root
+
[handlers]
keys=fileHandler
@@ -15,7 +16,7 @@ handlers=fileHandler
class=FileHandler
level=DEBUG
formatter=commonFormatter
-args=('/logs/actions.log', 'a',)
+args=('../logs/actions.log', 'a',)
[formatter_commonFormatter]
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
diff --git a/src/main.py b/src/main.py
old mode 100644
new mode 100755
diff --git a/src/migrations/README b/src/migrations/README
old mode 100644
new mode 100755
diff --git a/src/migrations/env.py b/src/migrations/env.py
old mode 100644
new mode 100755
diff --git a/src/migrations/script.py.mako b/src/migrations/script.py.mako
old mode 100644
new mode 100755
diff --git a/src/migrations/versions/9e96afce0c9e_init.py b/src/migrations/versions/9e96afce0c9e_init.py
old mode 100644
new mode 100755
diff --git a/src/models/__init__.py b/src/models/__init__.py
old mode 100644
new mode 100755
diff --git a/src/models/mixins.py b/src/models/mixins.py
old mode 100644
new mode 100755
diff --git a/src/models/places.py b/src/models/places.py
old mode 100644
new mode 100755
diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py
old mode 100644
new mode 100755
diff --git a/src/repositories/base_repository.py b/src/repositories/base_repository.py
old mode 100644
new mode 100755
diff --git a/src/repositories/places_repository.py b/src/repositories/places_repository.py
old mode 100644
new mode 100755
diff --git a/src/routes.py b/src/routes.py
old mode 100644
new mode 100755
diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py
old mode 100644
new mode 100755
diff --git a/src/schemas/base.py b/src/schemas/base.py
old mode 100644
new mode 100755
diff --git a/src/schemas/places.py b/src/schemas/places.py
old mode 100644
new mode 100755
diff --git a/src/schemas/routes.py b/src/schemas/routes.py
old mode 100644
new mode 100755
index 2ed33f5..b3cc851
--- a/src/schemas/routes.py
+++ b/src/schemas/routes.py
@@ -1,6 +1,6 @@
from typing import Optional
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
class MetadataTag(BaseModel):
@@ -11,3 +11,9 @@ class MetadataTag(BaseModel):
class Config:
allow_population_by_field_name = True
+
+
+class Description(BaseModel):
+ """Модель для описания"""
+
+ description: str = Field(None, min_length=3, max_length=255)
diff --git a/src/services/__init__.py b/src/services/__init__.py
old mode 100644
new mode 100755
diff --git a/src/services/places_service.py b/src/services/places_service.py
old mode 100644
new mode 100755
index 7733f98..f5f2c64
--- a/src/services/places_service.py
+++ b/src/services/places_service.py
@@ -90,17 +90,28 @@ async def create_place(self, place: Place) -> Optional[int]:
return primary_key
- async def update_place(self, primary_key: int, place: PlaceUpdate) -> Optional[int]:
+ async def update_place(
+ self, primary_key: int, place_update: PlaceUpdate
+ ) -> Optional[int]:
"""
Обновление объекта любимого места по переданным данным.
:param primary_key: Идентификатор объекта.
- :param place: Данные для обновления объекта.
+ :param place_update: Данные для обновления объекта.
:return:
"""
-
+ place = Place(
+ latitude=place_update.latitude,
+ longitude=place_update.longitude,
+ description=place_update.description,
+ )
# при изменении координат – обогащение данных путем получения дополнительной информации от API
- # todo
+ if location := await LocationClient().get_location(
+ latitude=place_update.latitude, longitude=place_update.longitude
+ ):
+ place.country = location.alpha2code
+ place.city = location.city
+ place.locality = location.locality
matched_rows = await self.places_repository.update_model(
primary_key, **place.dict(exclude_unset=True)
@@ -109,7 +120,19 @@ async def update_place(self, primary_key: int, place: PlaceUpdate) -> Optional[i
# публикация события для попытки импорта информации
# по обновленному объекту любимого места в сервисе Countries Informer
- # todo
+ try:
+ place_data = CountryCityDTO(
+ city=place.city,
+ alpha2code=place.country,
+ )
+ EventProducer().publish(
+ queue_name=settings.rabbitmq.queue.places_import, body=place_data.json()
+ )
+ except ValidationError:
+ logger.warning(
+ "The message was not well-formed during publishing event.",
+ exc_info=True,
+ )
return matched_rows
diff --git a/src/settings.py b/src/settings.py
old mode 100644
new mode 100755
index 7bb22e9..cca299d
--- a/src/settings.py
+++ b/src/settings.py
@@ -46,13 +46,16 @@ class Settings(BaseSettings):
base_url: str = Field(default="http://0.0.0.0:8010")
#: строка подключения к БД
database_url: PostgresDsn = Field(
- default="postgresql+asyncpg://favorite_places_user:secret@db/favorite_places"
+ default="postgresql+asyncpg://favorite_places_user:secret@favorite-places-db/favorite_places"
+ )
+ database_sync: PostgresDsn = Field(
+ default="postgresql://favorite_places_user:secret@db/favorite_places"
)
#: конфигурация RabbitMQ
rabbitmq: RabbitMQConfig
class Config:
- env_file = ".env"
+ env_file = ".env", "../.env"
env_nested_delimiter = "__"
diff --git a/src/tests/.coveragerc b/src/tests/.coveragerc
old mode 100644
new mode 100755
diff --git a/src/tests/__init__.py b/src/tests/__init__.py
old mode 100644
new mode 100755
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
old mode 100644
new mode 100755
index 1600294..6e30bf0
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -1,12 +1,20 @@
+import os
+from pathlib import Path
+from uuid import uuid4
+
import pytest_asyncio
from httpx import AsyncClient
from pytest_mock import MockerFixture
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+from sqlalchemy_utils import create_database, drop_database
+from sqlmodel import SQLModel
from integrations.db.session import get_session
from main import app
from settings import settings
+PROJECT_PATH = Path(__file__).parent.parent.resolve()
+
@pytest_asyncio.fixture
async def session():
@@ -17,7 +25,15 @@ async def session():
:return:
"""
- db_engine = create_async_engine(settings.database_url, echo=True, future=True)
+ db_id = uuid4().hex
+ db_name = settings.database_url + db_id
+ os.environ["DATABASE_URL"] = db_name
+ create_database(settings.database_sync + db_id)
+ db_engine = create_async_engine(db_name, echo=True, future=True)
+
+ async with db_engine.begin() as conn:
+ await conn.run_sync(SQLModel.metadata.create_all)
+
connection = await db_engine.connect()
transaction = await connection.begin()
@@ -31,6 +47,7 @@ async def session():
await async_session.close()
await transaction.rollback()
await connection.close()
+ drop_database(settings.database_sync + db_id)
@pytest_asyncio.fixture
diff --git a/src/tests/functional/__init__.py b/src/tests/functional/__init__.py
old mode 100644
new mode 100755
diff --git a/src/tests/functional/v1/__init__.py b/src/tests/functional/v1/__init__.py
old mode 100644
new mode 100755
diff --git a/src/tests/functional/v1/test_places.py b/src/tests/functional/v1/test_places.py
old mode 100644
new mode 100755
index e7f78e7..2094482
--- a/src/tests/functional/v1/test_places.py
+++ b/src/tests/functional/v1/test_places.py
@@ -1,4 +1,7 @@
+import re
+
import pytest
+from geocoder.base import OneResult
from starlette import status
from models import Place
@@ -21,7 +24,6 @@ async def get_endpoint() -> str:
return "/api/v1/places"
- @pytest.mark.asyncio
@pytest.mark.usefixtures("event_producer_publish")
async def test_method_success(self, client, session, httpx_mock):
"""
@@ -84,3 +86,273 @@ async def test_method_success(self, client, session, httpx_mock):
assert created_data[0].country == mock_response["countryCode"]
assert created_data[0].city == mock_response["city"]
assert created_data[0].locality == mock_response["locality"]
+
+ @pytest.mark.usefixtures("event_producer_publish")
+ async def test_get_favorite_list(self, client, session):
+ """
+ Тестирование успешного сценария.
+
+ :param client: Фикстура клиента для запросов.
+ :param session: Фикстура сессии для работы с БД.
+ :return:
+ """
+ mock_response = {
+ "city": "City",
+ "countryCode": "AA",
+ "locality": "Location",
+ }
+
+ # передаваемые данные
+ request_body = {
+ "latitude": 12.3456,
+ "longitude": 23.4567,
+ "description": "Описание тестового места",
+ "city": "City",
+ "locality": "Location",
+ "country": "AA",
+ }
+ # проверка существования записи в базе данных
+ await PlacesRepository(session).create_model(request_body)
+ response = await client.get(
+ await self.get_endpoint(),
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ response_json = response.json()
+ assert "items" in response_json
+ assert isinstance(response_json["items"], list)
+ assert len(response_json["items"]) == 1
+ item = response_json["items"][0]
+ assert isinstance(item["id"], int)
+ assert isinstance(item["created_at"], str)
+ assert isinstance(item["updated_at"], str)
+ assert item["latitude"] == request_body["latitude"]
+ assert item["longitude"] == request_body["longitude"]
+ assert item["description"] == request_body["description"]
+ assert item["country"] == mock_response["countryCode"]
+ assert item["city"] == mock_response["city"]
+ assert item["locality"] == mock_response["locality"]
+
+ assert "total" in response_json
+ assert isinstance(response_json["total"], int)
+ assert response_json["total"] == 1
+
+ assert "page" in response_json
+ assert isinstance(response_json["page"], int)
+ assert response_json["page"] == 1
+
+ assert "size" in response_json
+ assert isinstance(response_json["size"], int)
+ assert response_json["size"] == 50
+
+ @pytest.mark.usefixtures("event_producer_publish")
+ async def test_get_favorite_one(self, client, session):
+ """
+ Тестирование успешного сценария.
+
+ :param client: Фикстура клиента для запросов.
+ :param session: Фикстура сессии для работы с БД.
+ :return:
+ """
+ mock_response = {
+ "city": "City",
+ "countryCode": "AA",
+ "locality": "Location",
+ }
+
+ # передаваемые данные
+ request_body = {
+ "latitude": 12.3456,
+ "longitude": 23.4567,
+ "description": "Описание тестового места",
+ "city": "City",
+ "locality": "Location",
+ "country": "AA",
+ }
+ # проверка существования записи в базе данных
+ created_data = await PlacesRepository(session).create_model(request_body)
+ response = await client.get((await self.get_endpoint()) + f"/{created_data}")
+
+ assert response.status_code == status.HTTP_200_OK
+
+ response_json = response.json()
+ assert "data" in response_json
+ assert isinstance(response_json["data"], dict)
+ item = response_json["data"]
+ assert isinstance(item["id"], int)
+ assert isinstance(item["created_at"], str)
+ assert isinstance(item["updated_at"], str)
+ assert item["latitude"] == request_body["latitude"]
+ assert item["longitude"] == request_body["longitude"]
+ assert item["description"] == request_body["description"]
+ assert item["country"] == mock_response["countryCode"]
+ assert item["city"] == mock_response["city"]
+ assert item["locality"] == mock_response["locality"]
+
+ @pytest.mark.usefixtures("event_producer_publish")
+ async def test_patch_place(self, client, session, httpx_mock):
+ """
+ Тестирование успешного сценария.
+
+ :param client: Фикстура клиента для запросов.
+ :param session: Фикстура сессии для работы с БД.
+ :param httpx_mock: Фикстура запроса на внешние API.
+ :return:
+ """
+ mock_response = {
+ "city": "City",
+ "countryCode": "AA",
+ "locality": "Location",
+ }
+ # замена настоящего ответа от API на "заглушку" для тестирования
+ # настоящий запрос на API не производится
+ httpx_mock.add_response(json=mock_response)
+
+ # передаваемые данные
+ request_body = {
+ "latitude": 12.3456,
+ "longitude": 23.4567,
+ "description": "Описание тестового места",
+ "city": "City",
+ "locality": "Location",
+ "country": "AA",
+ }
+ # проверка существования записи в базе данных
+ created_data = await PlacesRepository(session).create_model(request_body)
+
+ patch_request_body = {
+ "latitude": 12.3456,
+ "longitude": 23.4567,
+ "description": "Описание тестового места",
+ }
+ response = await client.patch(
+ (await self.get_endpoint()) + f"/{created_data}", json=patch_request_body
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ response_json = response.json()
+ assert "data" in response_json
+ assert isinstance(response_json["data"], dict)
+ item = response_json["data"]
+ assert isinstance(item["id"], int)
+ assert isinstance(item["created_at"], str)
+ assert isinstance(item["updated_at"], str)
+ assert item["latitude"] == patch_request_body["latitude"]
+ assert item["longitude"] == patch_request_body["longitude"]
+ assert item["description"] == patch_request_body["description"]
+ assert item["country"] == mock_response["countryCode"]
+ assert item["city"] == mock_response["city"]
+ assert item["locality"] == mock_response["locality"]
+
+ @pytest.mark.usefixtures("event_producer_publish")
+ async def test_delete_place(self, client, session):
+ """
+ Тестирование успешного сценария.
+
+ :param client: Фикстура клиента для запросов.
+ :param session: Фикстура сессии для работы с БД.
+ :return:
+ """
+ # передаваемые данные
+ request_body = {
+ "latitude": 12.3456,
+ "longitude": 23.4567,
+ "description": "Описание тестового места",
+ "city": "City",
+ "locality": "Location",
+ "country": "AA",
+ }
+ # проверка существования записи в базе данных
+ created_data = await PlacesRepository(session).create_model(request_body)
+
+ response = await client.delete((await self.get_endpoint()) + f"/{created_data}")
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+
+ response = await client.get((await self.get_endpoint()) + f"/{created_data}")
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ @pytest.mark.usefixtures("event_producer_publish")
+ @pytest.mark.skip(reason="Не работает мок")
+ async def test_auto_route(self, client, session, httpx_mock, mocker):
+ """
+ Тестирование успешного сценария.
+
+ :param client: Фикстура клиента для запросов.
+ :param session: Фикстура сессии для работы с БД.
+ :param httpx_mock: Фикстура запроса на внешние API.
+ :return:
+ """
+
+ mock_response = {
+ "city": "City",
+ "countryCode": "AA",
+ "locality": "Location",
+ }
+ # замена настоящего ответа от API на "заглушку" для тестирования
+ # настоящий запрос на API не производится
+ httpx_mock.add_response(
+ json=mock_response,
+ url=re.compile(
+ "https://api.bigdatacloud.net/data/reverse-geocode-client.+"
+ ),
+ )
+ coordinates = [23.4567, 12.3456]
+
+ one_result = OneResult(
+ {
+ "ip": "185.65.135.220",
+ "city": "Stockholm",
+ "region": "Stockholm",
+ "country": "SE",
+ "loc": "59.3294,18.0687",
+ "org": "AS39351 31173 Services AB",
+ "postal": "100 05",
+ "timezone": "Europe/Stockholm",
+ "readme": "https://ipinfo.io/missingauth",
+ }
+ )
+ mocker.patch("transport.handlers.places.geocoder.ip", return_value=one_result)
+ # передаваемые данные
+ request_body = {
+ "description": "Описание тестового места",
+ }
+ # осуществление запроса
+ response = await client.post(
+ (await self.get_endpoint()) + "/auto",
+ json=request_body,
+ )
+
+ # проверка корректности ответа от сервера
+ assert response.status_code == status.HTTP_201_CREATED, response.text
+
+ response_json = response.json()
+ print(response_json)
+ assert "data" in response_json
+ assert isinstance(response_json["data"]["id"], int)
+ assert isinstance(response_json["data"]["created_at"], str)
+ assert isinstance(response_json["data"]["updated_at"], str)
+ assert response_json["data"]["latitude"] == coordinates[0]
+ assert response_json["data"]["longitude"] == coordinates[1]
+ assert response_json["data"]["description"] == request_body["description"]
+ assert response_json["data"]["country"] == mock_response["countryCode"]
+ assert response_json["data"]["city"] == mock_response["city"]
+ assert response_json["data"]["locality"] == mock_response["locality"]
+
+ # проверка существования записи в базе данных
+ created_data = await PlacesRepository(session).find_all_by(
+ latitude=request_body["latitude"],
+ longitude=request_body["longitude"],
+ description=request_body["description"],
+ limit=100,
+ )
+ assert len(created_data) == 1
+ assert isinstance(created_data[0], Place)
+ assert created_data[0].latitude == request_body["latitude"]
+ assert created_data[0].longitude == request_body["longitude"]
+ assert created_data[0].description == request_body["description"]
+ assert created_data[0].country == mock_response["countryCode"]
+ assert created_data[0].city == mock_response["city"]
+ assert created_data[0].locality == mock_response["locality"]
diff --git a/src/tests/unit/__init__.py b/src/tests/unit/__init__.py
old mode 100644
new mode 100755
diff --git a/src/tests/unit/conftest.py b/src/tests/unit/conftest.py
old mode 100644
new mode 100755
diff --git a/src/tests/unit/repositories/__init__.py b/src/tests/unit/repositories/__init__.py
old mode 100644
new mode 100755
diff --git a/src/tests/unit/repositories/test_places_repository.py b/src/tests/unit/repositories/test_places_repository.py
old mode 100644
new mode 100755
index f97d17b..ccb77ac
--- a/src/tests/unit/repositories/test_places_repository.py
+++ b/src/tests/unit/repositories/test_places_repository.py
@@ -23,7 +23,6 @@ async def repository(self, session):
yield PlacesRepository(session)
- @pytest.mark.asyncio
async def test_find(self, repository, fixture_place):
"""
Тестирование метода поиска записи по первичному ключу.
diff --git a/src/tests/unit/repositories/test_repository_base.py b/src/tests/unit/repositories/test_repository_base.py
old mode 100644
new mode 100755
diff --git a/src/tests/unit/services/__init__.py b/src/tests/unit/services/__init__.py
old mode 100644
new mode 100755
diff --git a/src/transport/__init__.py b/src/transport/__init__.py
old mode 100644
new mode 100755
diff --git a/src/transport/handlers/__init__.py b/src/transport/handlers/__init__.py
old mode 100644
new mode 100755
diff --git a/src/transport/handlers/places.py b/src/transport/handlers/places.py
old mode 100644
new mode 100755
index f19df12..117c062
--- a/src/transport/handlers/places.py
+++ b/src/transport/handlers/places.py
@@ -1,14 +1,16 @@
-from fastapi import APIRouter, Depends, Query, status
+import geocoder
+from fastapi import APIRouter, Depends, Query, Request, status
+from fastapi_pagination import Page, paginate
+from geocoder.ipinfo import IpinfoQuery
from exceptions import ApiHTTPException, ObjectNotFoundException
from models.places import Place
-from schemas.places import PlaceResponse, PlacesListResponse, PlaceUpdate
-from schemas.routes import MetadataTag
+from schemas.places import PlaceResponse, PlaceUpdate
+from schemas.routes import Description, MetadataTag
from services.places_service import PlacesService
router = APIRouter()
-
tag_places = MetadataTag(
name="places",
description="Управление любимыми местами.",
@@ -18,14 +20,14 @@
@router.get(
"",
summary="Получение списка объектов",
- response_model=PlacesListResponse,
+ response_model=Page[Place],
)
async def get_list(
limit: int = Query(
20, gt=0, le=100, description="Ограничение на количество объектов в выборке"
),
places_service: PlacesService = Depends(),
-) -> PlacesListResponse:
+) -> Page[Place]:
"""
Получение списка любимых мест.
@@ -33,8 +35,7 @@ async def get_list(
:param places_service: Сервис для работы с информацией о любимых местах.
:return:
"""
-
- return PlacesListResponse(data=await places_service.get_places_list(limit=limit))
+ return paginate(await places_service.get_places_list(limit=limit)) # type: ignore
@router.get(
@@ -127,22 +128,44 @@ async def delete(primary_key: int, places_service: PlacesService = Depends()) ->
@router.post(
- "",
+ "/auto",
summary="Создание нового объекта с автоматическим определением координат",
response_model=PlaceResponse,
status_code=status.HTTP_201_CREATED,
)
-async def create_auto() -> PlaceResponse:
+async def create_auto(
+ request: Request,
+ description: Description,
+ places_service: PlacesService = Depends(),
+) -> PlaceResponse:
"""
Создание нового объекта любимого места с автоматическим определением координат.
:return:
"""
+ if request.client is None:
+ raise ApiHTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail="ip не найден"
+ )
+ ip_info: IpinfoQuery = geocoder.ip(request.client.host)
+ if (
+ ip_info.geojson.get("features", None) is None
+ or len(ip_info.geojson["features"]) == 0
+ or ip_info.geojson["features"][0].get("geometry", None) is None
+ ):
+ raise ApiHTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail="ip не найден"
+ )
+ coordinates = ip_info.geojson["features"][0]["geometry"]["coordinates"]
+ place = Place(
+ description=description.description,
+ longitude=coordinates[0],
+ latitude=coordinates[1],
+ )
+ if primary_key := await places_service.create_place(place):
+ return PlaceResponse(data=await places_service.get_place(primary_key))
- # Пример:
- #
- # import geocoder
- # from geocoder.ipinfo import IpinfoQuery
- #
- # g: IpinfoQuery = geocoder.ip('me')
- # print(g.latlng)
+ raise ApiHTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="Объект не был создан",
+ )