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 + +![img](docs/img/Screenshot 2023-03-05 at 00-01-14 API системы Favorite Places Service - Swagger UI.png) +![img](docs/img/Screenshot 2023-03-05 at 00-00-01 API системы Favorite Places Service - Swagger UI.png) +![img](docs/img/2023-03-05_00-01.png) + + + + +# Пагинация + +![img](docs/img/pagination.png) + + + + +# Результаты `make all` + +![img](docs/img/2023-03-05_13-31.png) + 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="Объект не был создан", + )