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
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/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
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="Объект не был создан",
+ )