From dd9409163b10e7614eedbfd3785aff66bf37cbbe Mon Sep 17 00:00:00 2001 From: Nikita Dumkin Date: Fri, 29 Mar 2024 22:24:18 +0500 Subject: [PATCH 1/3] Tasks 1&2 --- src/bootstrap.py | 2 ++ src/schemas/places.py | 6 +++++ src/transport/handlers/places.py | 46 +++++++++++++++++++++----------- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/bootstrap.py b/src/bootstrap.py index 65327cc..e270b8c 100644 --- a/src/bootstrap.py +++ b/src/bootstrap.py @@ -3,6 +3,7 @@ from exceptions import setup_exception_handlers from routes import metadata_tags, setup_routes from settings import settings +from fastapi_pagination import add_pagination def build_app() -> FastAPI: @@ -19,5 +20,6 @@ def build_app() -> FastAPI: setup_routes(app) setup_exception_handlers(app) + add_pagination(app) return app diff --git a/src/schemas/places.py b/src/schemas/places.py index 19b9037..70057e9 100644 --- a/src/schemas/places.py +++ b/src/schemas/places.py @@ -30,3 +30,9 @@ class PlacesListResponse(ListResponse): """ data: list[Place] + + +class DescriptionRequest(BaseModel): + """Модель для описания""" + + description: str = Field(title="Описание", min_length=2, max_length=255) diff --git a/src/transport/handlers/places.py b/src/transport/handlers/places.py index f19df12..3806464 100644 --- a/src/transport/handlers/places.py +++ b/src/transport/handlers/places.py @@ -1,8 +1,10 @@ -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.places import DescriptionRequest, PlaceResponse, PlaceUpdate from schemas.routes import MetadataTag from services.places_service import PlacesService @@ -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]: """ Получение списка любимых мест. @@ -34,7 +36,7 @@ async def get_list( :return: """ - return PlacesListResponse(data=await places_service.get_places_list(limit=limit)) + return paginate(await places_service.get_places_list(limit=limit)) @router.get( @@ -127,22 +129,36 @@ 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: DescriptionRequest, + places_service: PlacesService = Depends(), +) -> PlaceResponse: """ Создание нового объекта любимого места с автоматическим определением координат. :return: """ - # Пример: - # - # import geocoder - # from geocoder.ipinfo import IpinfoQuery - # - # g: IpinfoQuery = geocoder.ip('me') - # print(g.latlng) + geo_info: IpinfoQuery = geocoder.ip("me") + + if geo_info.latlng: + latitude, longitude = geo_info.latlng + place = Place( + description=description.description, + longitude=longitude, + latitude=latitude, + ) + + if primary_key := await places_service.create_place(place): + return PlaceResponse(data=await places_service.get_place(primary_key)) + + raise ApiHTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Объект не был создан", + ) From 64c652a6827f2616948872c259583d031f669c66 Mon Sep 17 00:00:00 2001 From: Nikita Dumkin Date: Fri, 29 Mar 2024 22:39:31 +0500 Subject: [PATCH 2/3] Task 3&4 --- src/services/places_service.py | 41 +++++- src/tests/functional/v1/test_places.py | 188 +++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 6 deletions(-) diff --git a/src/services/places_service.py b/src/services/places_service.py index 7733f98..dc5f09b 100644 --- a/src/services/places_service.py +++ b/src/services/places_service.py @@ -99,17 +99,46 @@ async def update_place(self, primary_key: int, place: PlaceUpdate) -> Optional[i :return: """ - # при изменении координат – обогащение данных путем получения дополнительной информации от API - # todo + original_place = await self.places_repository.find(primary_key) + only_description_updated = (original_place.latitude == place.latitude + and original_place.longitude == place.longitude) + + if only_description_updated: + updated_place = original_place + updated_place.description = place.description + + else: + updated_place = Place( + latitude=place.latitude, + longitude=place.longitude, + description=place.description, + ) + # при изменении координат – обогащение данных путем получения дополнительной информации от API + if location := await LocationClient().get_location( + latitude=place.latitude, longitude=place.longitude + ): + updated_place.country = location.alpha2code + updated_place.city = location.city + updated_place.locality = location.locality matched_rows = await self.places_repository.update_model( - primary_key, **place.dict(exclude_unset=True) + primary_key, **updated_place.dict(exclude_unset=True) ) await self.session.commit() - # публикация события для попытки импорта информации - # по обновленному объекту любимого места в сервисе Countries Informer - # todo + try: + place_data = CountryCityDTO( + city=updated_place.city, + alpha2code=updated_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/tests/functional/v1/test_places.py b/src/tests/functional/v1/test_places.py index e7f78e7..551734b 100644 --- a/src/tests/functional/v1/test_places.py +++ b/src/tests/functional/v1/test_places.py @@ -84,3 +84,191 @@ 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("session") +class TestPlacesGetMethod: + """ + Тестирование метода получения любимых мест. + """ + + @staticmethod + async def get_endpoint() -> str: + """ + Получение адреса метода API. + :return: + """ + + return "/api/v1/places" + + @pytest.mark.asyncio + @pytest.mark.usefixtures("event_producer_publish") + async def test_method_list(self, client, session): + """ + Тестирование успешного сценария. + :param client: Фикстура клиента для запросов. + :param session: Фикстура сессии для работы с БД. + :return: + """ + + # передаваемые в бд данные + request_body = { + "latitude": 12.3456, + "longitude": 23.4567, + "description": "Описание тестового места", + } + 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 "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 + + # айтемы + assert "items" in response_json + 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"] is None + assert item["city"] is None + assert item["locality"] is None + + +@pytest.mark.usefixtures("session") +class TestPlacesUpdateMethod: + """ + Тестирование метода изменения любимых мест. + """ + + @staticmethod + async def get_endpoint() -> str: + """ + Получение адреса метода API. + :return: + """ + + return "/api/v1/places" + + @pytest.mark.asyncio + @pytest.mark.usefixtures("event_producer_publish") + async def test_method_patch(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", + "country": "AA", + "locality": "Location", + } + # получение id + place_id = await PlacesRepository(session).create_model(request_body) + + patch_request_body = { + "latitude": 12.3456, + "longitude": 12.3456, + "description": "Описание тестового места", + } + + # осуществление запроса + response = await client.patch( + await self.get_endpoint() + f"/{place_id}", 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("session") +class TestPlacesDeleteMethod: + """ + Тестирование метода изменения любимых мест. + """ + + @staticmethod + async def get_endpoint() -> str: + """ + Получение адреса метода API. + :return: + """ + + return "/api/v1/places" + + @pytest.mark.asyncio + @pytest.mark.usefixtures("event_producer_publish") + async def test_method_delete(self, client, session): + """ + Тестирование успешного сценария. + :param client: Фикстура клиента для запросов. + :param session: Фикстура сессии для работы с БД. + :return: + """ + + # создание объекта + request_body = { + "latitude": 12.3456, + "longitude": 23.4567, + "description": "Описание тестового места", + "city": "City", + "country": "AA", + "locality": "Location", + } + # получение id + place_id = await PlacesRepository(session).create_model(request_body) + + # осуществление запроса на удаление + response = await client.delete(await self.get_endpoint() + f"/{place_id}") + + # проверка корректности ответа от сервера + assert response.status_code == status.HTTP_204_NO_CONTENT + # теперь записи не существует + response = await client.get((await self.get_endpoint()) + f"/{place_id}") + assert response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file From 60d10bd704ee693f2341b9d622d1d75509a225ee Mon Sep 17 00:00:00 2001 From: Nikita Dumkin Date: Fri, 29 Mar 2024 23:21:43 +0500 Subject: [PATCH 3/3] Task 5 --- docs/source/index.rst | 194 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/docs/source/index.rst b/docs/source/index.rst index fb16a18..53add07 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -5,12 +5,68 @@ Зависимости =========== +Установите требуемое ПО: +1. Docker для контейнеризации – |link_docker| +.. |link_docker| raw:: html + + Docker Desktop + +2. Для работы с системой контроля версий – |link_git| + +.. |link_git| raw:: html + + Git + +3. IDE для работы с исходным кодом – |link_pycharm| + +.. |link_pycharm| raw:: html + + PyCharm Установка ========= +1. Клонируйте репозиторий проекта в свою рабочую директорию: + + .. code-block:: console + + git clone https://github.com/ctpemho7/python-course-favorite-places.git + +Перед началом использования приложения необходимо его сконфигурировать. + +.. note:: + + Для конфигурации выполните команды, описанные ниже, находясь в корневой директории проекта (на уровне с директорией `src`). + +2. Скопируйте файл настроек `.env.sample`, создав файл `.env`: + .. code-block:: console + + cp .env.sample .env + + Этот файл содержит преднастроенные переменные окружения, значения которых будут общими для всего приложения. + Файл примера (`.env.sample`) содержит набор переменных со значениями по умолчанию. + Созданный файл `.env` можно настроить в зависимости от окружения. + + .. warning:: + + Никогда не добавляйте в систему контроля версий заполненный файл `.env` для предотвращения компрометации информации о конфигурации приложения. + +3. Соберите Docker-контейнер с помощью Docker Compose: + .. code-block:: console + + docker compose build + + Данную команду необходимо выполнять повторно в случае обновления зависимостей в файле `requirements.txt`. + +4. После сборки контейнеров можно их запустить командой: + .. code-block:: console + + docker compose up + + Данная команда запустит собранные контейнеры для приложения и базы данных. + Когда запуск завершится, сервер начнет работать по адресу `http://0.0.0.0:8010`. Использование @@ -20,15 +76,153 @@ Работа с базой данных --------------------- +Для правильной работы приложения необходимо настроить базу данных (создать в ней таблицы). + Для этого нужно применить миграции внутри контейнера приложения. + Данная команда позволит зайти в контейнер приложения: + + .. code-block:: console + + docker compose exec favorite-places-app bash + + Для применения миграций выполните команду: + + .. code-block:: console + + alembic upgrade head + + После выполнения команды в базе данных будут созданы все нужные таблицы. Автоматизация ============= +Проект содержит специальный файл (`Makefile`) для автоматизации выполнения команд: + +1. Сборка Docker-контейнера. +2. Генерация документации. +3. Запуск форматирования кода. +4. Запуск статического анализа кода (выявление ошибок типов и форматирования кода). +5. Запуск автоматических тестов. +6. Запуск всех функций поддержки качества кода (форматирование, линтеры, автотесты). + +Инструкция по запуску этих команд находится в файле `README.md`. Тестирование ============ +Для запуска автоматических тестов выполните команду: + +.. code-block:: console + + make test + +Отчет о тестировании находится в файле `src/htmlcov/index.html`. + + +Документация к исходному коду +============================= + +Клиенты +======= + +Базовый клиент +-------------- +.. automodule:: clients.base.base + :members: + +Geo-клиент +---------- +.. automodule:: clients.geo + :members: + +Описание моделей данных +----------------------- +.. automodule:: clients.schemas + :members: + + +Интеграции +========== + +База данных +----------- +.. automodule:: integrations.db.session + :members: + +Шина событий +------------ +.. automodule:: integrations.events + :members: + +.. automodule:: integrations.events.schemas + :members: + + +Модели +====== + +Миксины +------- + +.. automodule:: models.mixins + :members: + +Модель места +------------ + +.. automodule:: models.places + :members: + +Репозитории +=========== + +Базовый репозиторий +------------------- + +.. automodule:: repositories.base_repository + :members: + +Репозиторий для работы с местом +------------------------------- + +.. automodule:: repositories.places_repository + :members: + + +Настройки проекта +================= +.. automodule:: settings + :members: + + +Схемы запросов и ответов +======================== + +Базовая схема +------------- +.. automodule:: schemas.base + :members: + +Схемы для мест +-------------- +.. automodule:: schemas.places + :members: + +Схемы для маршрутов +------------------- +.. automodule:: schemas.routes + :members: + + +Сервисный слой +============== +.. automodule:: services.places_service + :members: + +Маршруты +======== +.. automodule:: transport.handlers.places + :members: \ No newline at end of file