From bab8008ec88c8d6fb9b8e378308a00f22e4bba09 Mon Sep 17 00:00:00 2001 From: pi3c Date: Mon, 29 Jan 2024 02:19:02 +0300 Subject: [PATCH] sync --- README.md | 71 +++++++++++++++--- README.md.backup | 158 +++++++++++++++++++++++++++++++++++++++ fastfood/config.py | 15 ++-- fastfood/cruds/menu.py | 62 ++++----------- fastfood/models.py | 32 +++++++- fastfood/routers/menu.py | 4 +- tests/conftest.py | 8 +- tests/test_api.py | 11 ++- tests/test_crud.py | 56 ++++++++++++++ 9 files changed, 344 insertions(+), 73 deletions(-) create mode 100644 README.md.backup create mode 100644 tests/test_crud.py diff --git a/README.md b/README.md index 442fc99..e327d53 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,11 @@ # fastfood Fastapi веб приложение реализующее api для общепита. -## Оглавление - -- Описание -- Зависимости - - ## Описание -Данный проект, это результат выполнения практического домашнего задания интенсива от YLAB Development. Проект реализован на фреймворке fastapi, с использованием sqlalchemy. В качестве базы данных используется postgresql. +Данный проект, это результат выполнения практических домашних заданий интенсива от YLAB Development. Проект реализован на фреймворке fastapi, с использованием sqlalchemy. В качестве базы данных используется postgresql. -### Техническое задание +## Техническое задание +### Спринт 1 - Создание API Написать проект на FastAPI с использованием PostgreSQL в качестве БД. В проекте следует реализовать REST API по работе с меню ресторана, все CRUD операции. Для проверки задания, к презентаций будет приложена Postman коллекция с тестами. Задание выполнено, если все тесты проходят успешно. Даны 3 сущности: Меню, Подменю, Блюдо. @@ -31,19 +26,77 @@ Fastapi веб приложение реализующее api для общеп В папке ./postman_scripts находятся фалы тестов Postman, для тестирования функционала проекта. +### Спринт 2 - Docker && pytest +В этом домашнем задании надо написать тесты для ранее разработанных ендпоинтов вашего API после Вебинара №1. + +Обернуть программные компоненты в контейнеры. Контейнеры должны запускаться по одной команде “docker-compose up -d” или той которая описана вами в readme.md. + +Образы для Docker: +(API) python:3.10-slim +(DB) postgres:15.1-alpine + +1.Написать CRUD тесты для ранее разработанного API с помощью библиотеки pytest +2.Подготовить отдельный контейнер для запуска тестов. Команду для запуска указать в README.md +3.* Реализовать вывод количества подменю и блюд для Меню через один (сложный) ORM запрос. +4.** Реализовать тестовый сценарий «Проверка кол-ва блюд и подменю в меню» из Postman с помощью pytest +Если FastAPI синхронное - тесты синхронные, Если асинхронное - тесты асинхронные + + +*Оборачиваем приложение в докер. +**CRUD – create/update/retrieve/delete. + +Дополнительные материалы + ## Возможности +### Спринт 1 В проекте реализованы 3 сущности: Menu, SubMenu и Dish. Для каждого них реализованы 4 метода http запросов: GET, POST, PATCH и DELETE c помощью которых можно управлять данными. Для Menu доступен метод GET возвращающий все его SubMenu. Аналогично для SubMenu реализован метод для возврата всех Dish. +### Спринт 2 +- 1й пункт ТЗ +Тесты реализованы в виде 2х классов +`TastBaseCrud` включает 3 подкласса `Menu`, `Submenu`, `Dish` которые реализуют интерфейсы взаимодействия с endpoint'ами реализованных на предыдущем спринте сущностей. Каждый подкласс реализует методы GET(получение всех сущностей), Get(получение конкректной сущности), Post(создание), Patch(обновление), Delete(удаления). Так же в классе реализованы 3 тестовых функции, которые осуществляют тестирование соответствующих endpoint'ов +`TestContinuity` реализует последовательность сценария «Проверка кол-ва блюд и подменю в меню» из Postman + +- 2й пункт ТЗ +Реализованы 3 контейнера(db, app, tests). В db написан блок "проверки здоровья", от которого зависят контейнеры app и test, который гарантирует, что зависимые контейнеры не будут запущены о полной готовности db. + +- 3й пункт ТЗ +см. функцию `get_menu_item` на 28 строке в файле +/fastfood/crud/menu.py + +- 4й пункт ТЗ +см. класс `TestContinuity` в файле +/tests/test_api.py ## Зависимости +Для локальной установки - postgresql Для работы сервиса необходима установленная СУБД. Должна быть создана база данных и пользователь с правами на нее. - poetry - Система управления зависимостями в Python. Остальное добавится автоматически на этапе установки. - +Для запуска в контейнере +- docker +- docker-compose + ## Установка +### Docker +Для запуска необходимы установленные приложения docker и docker-compose +Клонируйте репозиторий +> `$ git clone https://git.pi3c.ru/pi3c/fastfood.git` + +Перейдите в каталог +> `$ cd fastfood` + +Создайте и запустите образы +> `$ docker-compose up -d --build` + +После успешного запуска образов документация по API будет доступна по адресу http://localhost:8000 + +Для запуска тестов pytest поднимаем контейнер tests +> `$ docker-compose up tests` + ### Linux Установите и настройте postgresql согласно офф. документации. Создайте пользователя и бд. diff --git a/README.md.backup b/README.md.backup new file mode 100644 index 0000000..308b3aa --- /dev/null +++ b/README.md.backup @@ -0,0 +1,158 @@ +# fastfood +Fastapi веб приложение реализующее api для общепита. + +## Описание +Данный проект, это результат выполнения практических домашних заданий интенсива от YLAB Development. Проект реализован на фреймворке fastapi, с использованием sqlalchemy. В качестве базы данных используется postgresql. + +## Техническое задание +### Спринт 1 - Создание API +Написать проект на FastAPI с использованием PostgreSQL в качестве БД. В проекте следует реализовать REST API по работе с меню ресторана, все CRUD операции. Для проверки задания, к презентаций будет приложена Postman коллекция с тестами. Задание выполнено, если все тесты проходят успешно. +Даны 3 сущности: Меню, Подменю, Блюдо. + +Зависимости: +- У меню есть подменю, которые к ней привязаны. +- У подменю есть блюда. + +Условия: +- Блюдо не может быть привязано напрямую к меню, минуя подменю. +- Блюдо не может находиться в 2-х подменю одновременно. +- Подменю не может находиться в 2-х меню одновременно. +- Если удалить меню, должны удалиться все подменю и блюда этого меню. +- Если удалить подменю, должны удалиться все блюда этого подменю. +- Цены блюд выводить с округлением до 2 знаков после запятой. +- Во время выдачи списка меню, для каждого меню добавлять кол-во подменю и блюд в этом меню. +- Во время выдачи списка подменю, для каждого подменю добавлять кол-во блюд в этом подменю. +- Во время запуска тестового сценария БД должна быть пуста. + +В папке ./postman_scripts находятся фалы тестов Postman, для тестирования функционала проекта. + +### Спринт 2 - Docker && pytest +В этом домашнем задании надо написать тесты для ранее разработанных ендпоинтов вашего API после Вебинара №1. + +Обернуть программные компоненты в контейнеры. Контейнеры должны запускаться по одной команде “docker-compose up -d” или той которая описана вами в readme.md. + +Образы для Docker: +(API) python:3.10-slim +(DB) postgres:15.1-alpine + +1.Написать CRUD тесты для ранее разработанного API с помощью библиотеки pytest +2.Подготовить отдельный контейнер для запуска тестов. Команду для запуска указать в README.md +3.* Реализовать вывод количества подменю и блюд для Меню через один (сложный) ORM запрос. +4.** Реализовать тестовый сценарий «Проверка кол-ва блюд и подменю в меню» из Postman с помощью pytest +Если FastAPI синхронное - тесты синхронные, Если асинхронное - тесты асинхронные + + +*Оборачиваем приложение в докер. +**CRUD – create/update/retrieve/delete. + +Дополнительные материалы + +## Возможности +### Спринт 1 +В проекте реализованы 3 сущности: Menu, SubMenu и Dish. Для каждого них реализованы 4 метода http запросов: GET, POST, PATCH и DELETE c помощью которых можно управлять данными. +Для Menu доступен метод GET возвращающий все его SubMenu. Аналогично для SubMenu реализован метод для возврата всех Dish. + +### Спринт 2 +- 1й пункт ТЗ +Тесты реализованы в виде 2х классов +`TastBaseCrud` включает 3 подкласса `Menu`, `Submenu`, `Dish` которые реализуют интерфейсы взаимодействия с endpoint'ами реализованных на предыдущем спринте сущностей. Каждый подкласс реализует методы GET(получение всех сущностей), Get(получение конкректной сущности), Post(создание), Patch(обновление), Delete(удаления). Так же в классе реализованы 3 тестовых функции, которые осуществляют тестирование соответствующих endpoint'ов +`TestContinuity` реализует последовательность сценария «Проверка кол-ва блюд и подменю в меню» из Postman + +- 2й пункт ТЗ +Реализованы 3 контейнера(db, app, tests). В db написан блок "проверки здоровья", от которого зависят контейнеры app и test, который гарантирует, что зависимые контейнеры не будут запущены о полной готовности db. + +- 3й пункт ТЗ +см. функцию `get_menu_item` на 28 строке в файле +/fastfood/crud/menu.py + +- 4й пункт ТЗ +см. класс `TestContinuity` в файле +/tests/test_api.py + +## Зависимости +Для локальной установки +- postgresql Для работы сервиса необходима установленная СУБД. Должна быть создана база данных и пользователь с правами на нее. +- poetry - Система управления зависимостями в Python. + +Остальное добавится автоматически на этапе установки. + +Для запуска в контейнере +- docker +- docker-compose + +## Установка +### Docker +Для запуска необходимы установленные приложения docker и docker-compose +Клонируйте репозиторий +> `$ git clone https://git.pi3c.ru/pi3c/fastfood.git` + +Перейдите в каталог +> `$ cd fastfood` + +Создайте и запустите образы +> `$ docker-compose up -d --build` + +После успешного запуска образов документация по API будет доступна по адресу http://localhost:8000 + +Для запуска тестов pytest поднимаем контейнер tests +> `$ docker-compose up tests` + +### Linux +Установите и настройте postgresql согласно офф. документации. Создайте пользователя и бд. + +Установите систему управления зависимостями +> `$ pip[x] install poetry` + +Клонируйте репозиторий +> `$ git clone https://git.pi3c.ru/pi3c/fastfood.git` + +Перейдите в каталог + +> `$ cd fastfood` + +> `$ poetry install --no-root` + +Создастся виртуальное окружение и установятся зависимости + +Файл example.env является образцом файла .env, который необходимо создать перед запуском проекта. +В нем указанны переменные необходимые для подключения к БД. +Созданим файл .env + +>`$ cp ./example.env ./.env` + +Далее отредактируйте .env файл в соответствии с Вашими данными подключения к БД + +## Запуск +Запуск проекта возможен в 2х режимах: +- Запуск в режиме "prod" с ключем --run-server + Подразумевает наличие уже созданных таблиц в базе данных(например с помощью Alembic). Манипуляций со структурой БД не происходит. Данные не удаляются. + +- Запуск в режиме "dev" c ключем --run-test-server + В этом случае при каждом запуске проекта все таблицы с данными удаляются из БД и создаются снова согласно описанных моделей. + + +Для запуска проекта сначала активируем виртуальное окружение + +> `$ poetry shell` + +и запускаем проект в соответстующем режиме + +>`$ python[x] manage.py --ключ` + +вместо этого, так же допускается и другой вариант запуска одной командой без предварительной активации окружения + +>`$ poetry run python[x] manage.py --ключ` + + +## TODO +- Добавить миграции +- Провести рефакторинг, много дублирующего кода +- Много чего другого :) + +## Авторы +- Сергей Ванюшкин + +## Лицензия +Распространяется под [MIT лицензией](https://mit-license.org/). + + diff --git a/fastfood/config.py b/fastfood/config.py index 0f7eefa..d3ac037 100644 --- a/fastfood/config.py +++ b/fastfood/config.py @@ -4,10 +4,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): DB_HOST: str = "localhost" DB_PORT: int = 5432 - DB_USER: str = "postrges" - DB_PASS: str = "postgres" - DB_NAME: str = "postgres" - POSTGRES_DB: str = "fastfod_db" POSTGRES_PASSWORD: str = "postgres" POSTGRES_USER: str = "postgres" @@ -18,8 +14,9 @@ class Settings(BaseSettings): Возвращает строку подключения к БД необходимую для SQLAlchemy """ return ( - f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}" - f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + "postgresql+asyncpg://" + f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.DB_HOST}:{self.DB_PORT}/{self.POSTGRES_DB}" ) @property @@ -28,11 +25,11 @@ class Settings(BaseSettings): Возвращает строку подключения к БД необходимую для SQLAlchemy """ return ( - f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}" - f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}_test" + "postgresql+asyncpg://" + f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.DB_HOST}:{self.DB_PORT}/{self.POSTGRES_DB}_test" ) - model_config = SettingsConfigDict(env_file=".env") diff --git a/fastfood/cruds/menu.py b/fastfood/cruds/menu.py index 5e70d17..604a40e 100644 --- a/fastfood/cruds/menu.py +++ b/fastfood/cruds/menu.py @@ -1,8 +1,8 @@ from uuid import UUID -from sqlalchemy import delete, func, select, update +from sqlalchemy import delete, distinct, func, select, update from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Query, aliased +from sqlalchemy.orm import aliased from fastfood import models, schemas @@ -12,8 +12,8 @@ class MenuCrud: async def get_menus(session: AsyncSession): async with session: query = select(models.Menu) - result = await session.execute(query) - return result.scalars().all() + menus = await session.execute(query) + return menus @staticmethod async def create_menu_item(menu: schemas.MenuBase, session: AsyncSession): @@ -27,55 +27,25 @@ class MenuCrud: @staticmethod async def get_menu_item(menu_id: UUID, session: AsyncSession): async with session: - """ Комментарий для проверяющего - То что было, оставил закоментированным, удалю в следующей части - в pgadmin набросал следующий запрос - WITH subq as ( - SELECT - s.id, - s.title, - s.description, - s.parent_menu, - count(d.id) as dishes_count - FROM submenu s - JOIN dish d ON s.id = d.parent_submenu - GROUP BY s.id - ) - SELECT - m.id, - m.title, - m.description, - count(q.id) AS submenus_count, - SUM(q.dishes_count) AS dishes_count - FROM menu m - JOIN subq q ON m.id = q.parent_menu - GROUP BY m.id - """ m = aliased(models.Menu) s = aliased(models.SubMenu) d = aliased(models.Dish) - query = select(m).where(m.id == menu_id) + query = ( + select( + m, + func.count(distinct(s.id)).label("submenus_count"), + func.count(distinct(d.id)).label("dishes_count") + ) + .join(s, s.parent_menu == m.id, isouter=True) + .join(d, d.parent_submenu == s.id, isouter=True) + .group_by(m.id) + .where(m.id == menu_id) + ) menu = await session.execute(query) menu = menu.scalars().one_or_none() - if menu is None: return None - - submenu_query = select( - func.count(s.id).label("counter") - ).filter(s.parent_menu == menu_id) - counter = await session.execute(submenu_query) - - dish_query = ( - select(func.count(d.id)) - .join(s) - .filter(d.parent_submenu == s.id) - .filter(s.parent_menu == menu_id) - ) - dishes = await session.execute(dish_query) - menu.submenus_count = counter.scalars().one_or_none() - menu.dishes_count = dishes.scalars().one_or_none() return menu @staticmethod @@ -94,7 +64,7 @@ class MenuCrud: await session.commit() qr = select(models.Menu).where(models.Menu.id == menu_id) updated_menu = await session.execute(qr) - return updated_menu.scalars().one() + return updated_menu @staticmethod async def delete_menu_item(menu_id: UUID, session: AsyncSession): diff --git a/fastfood/models.py b/fastfood/models.py index 3c42ee6..d404646 100644 --- a/fastfood/models.py +++ b/fastfood/models.py @@ -1,9 +1,11 @@ import uuid +from copy import deepcopy from typing import Annotated, List, Optional from sqlalchemy import ForeignKey from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy.util import hybridproperty uuidpk = Annotated[ uuid.UUID, @@ -21,6 +23,17 @@ class Base(DeclarativeBase): title: Mapped[str_25] description: Mapped[Optional[str]] + def __eq__(self, other): + classes_match = isinstance(other, self.__class__) + a, b = deepcopy(self.__dict__), deepcopy(other.__dict__) + a.pop('_sa_instance_state', None) + b.pop('_sa_instance_state', None) + attrs_match = (a == b) + return classes_match and attrs_match + + def __ne__(self, other): + return not self.__eq__(other) + class Menu(Base): __tablename__ = "menu" @@ -28,10 +41,21 @@ class Menu(Base): submenus: Mapped[List["SubMenu"]] = relationship( "SubMenu", backref="menu", - lazy="dynamic", + lazy="selectin", cascade="all, delete", ) + @hybridproperty + def submenus_count(self): + return len(self.submenus) + + @hybridproperty + def dishes_count(self): + counter = 0 + for sub in self.submenus: + counter += len(sub.dishes) + return counter + class SubMenu(Base): __tablename__ = "submenu" @@ -42,10 +66,14 @@ class SubMenu(Base): dishes: Mapped[List["Dish"]] = relationship( "Dish", backref="submenu", - lazy="dynamic", + lazy="selectin", cascade="all, delete", ) + @hybridproperty + def dishes_count(self): + return len(self.dishes) + class Dish(Base): __tablename__ = "dish" diff --git a/fastfood/routers/menu.py b/fastfood/routers/menu.py index df5186a..9bbb0ab 100644 --- a/fastfood/routers/menu.py +++ b/fastfood/routers/menu.py @@ -17,7 +17,7 @@ router = APIRouter( @router.get("/", response_model=Optional[List[schemas.Menu]]) async def get_menus(session: AsyncSession = Depends(get_async_session)): result = await crud.get_menus(session=session) - return result + return result.scalars().all() @router.post("/", status_code=201, response_model=schemas.Menu) @@ -54,7 +54,7 @@ async def update_menu( menu=menu, session=session, ) - return result + return result.scalars().one() @router.delete("/{menu_id}") diff --git a/tests/conftest.py b/tests/conftest.py index 60a60f5..9b0b01b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,7 +39,7 @@ async def db_init(): async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) - + async def get_test_session() -> AsyncGenerator[AsyncSession, None]: async with async_session_maker() as session: yield session @@ -58,3 +58,9 @@ async def client(app): app=app, base_url="http://localhost:8000/api/v1/menus", ) as async_client: yield async_client + + +@pytest_asyncio.fixture(scope="session") +async def asession() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker() as session: + yield session diff --git a/tests/test_api.py b/tests/test_api.py index dc3b941..3822f55 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -87,7 +87,8 @@ class TestBaseCrud: async def get(ac, menu, submenu, dish): """Получение блюда по id""" response = await ac.get( - f"/{menu.get('id')}/submenus/{submenu.get('id')}/dishes/{dish.get('id')}", + f"/{menu.get('id')}/submenus/{submenu.get('id')}" + f"/dishes/{dish.get('id')}", ) return response.status_code, response.json() @@ -104,7 +105,8 @@ class TestBaseCrud: async def update(ac, menu, submenu, dish): """Обновление блюда по id""" response = await ac.patch( - f"/{menu.get('id')}/submenus/{submenu.get('id')}/dishes/{dish.get('id')}", + f"/{menu.get('id')}/submenus/{submenu.get('id')}" + f"/dishes/{dish.get('id')}", json=dish, ) return response.status_code, response.json() @@ -113,7 +115,8 @@ class TestBaseCrud: async def delete(ac, menu, submenu, dish): """Удаление блюда по id""" response = await ac.delete( - f"/{menu.get('id')}/submenus/{submenu.get('id')}/dishes/{dish.get('id')}" + f"/{menu.get('id')}/submenus/{submenu.get('id')}" + f"/dishes/{dish.get('id')}" ) return response.status_code @@ -369,7 +372,7 @@ class TestСontinuity: assert rspn["submenus_count"] == 0 assert "dishes_count" in rspn.keys() assert rspn["dishes_count"] == 0 - + # Удаляем меню code = await TestBaseCrud.Menu.delete(client, menu) assert code == 200 diff --git a/tests/test_crud.py b/tests/test_crud.py new file mode 100644 index 0000000..a8afdbf --- /dev/null +++ b/tests/test_crud.py @@ -0,0 +1,56 @@ +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession +from fastfood.cruds.submenu import SubMenuCrud +from fastfood.models import Menu, SubMenu, Dish +from fastfood.cruds.menu import MenuCrud +from fastfood.schemas import Menu as menuschema +from fastfood.schemas import SubMenuRead as submenuschema +from fastfood.schemas import MenuBase as menubaseschema +import pytest + + +@pytest.mark.asyncio +async def test_menu(asession: AsyncSession) -> None: + async with asession: + menu: Menu = Menu(title="SomeMenu", description="SomeDescription") + asession.add(menu) + + await asession.commit() + await asession.refresh(menu) + menu_id: UUID = menu.id + + req_menu: Menu | None = await MenuCrud.get_menu_item(menu_id, asession) + assert menu == req_menu + + req_menus = await MenuCrud.get_menus(asession) + # assert menu == req_menus.first() + + menu.title = "updatedMenu" + await MenuCrud.update_menu_item( + menu.id, menuschema.model_validate(menu), asession + ) + req_menu = await MenuCrud.get_menu_item(menu_id, asession) + assert menu == req_menu + + await MenuCrud.delete_menu_item(menu_id, asession) + req_menus = await MenuCrud.get_menus(asession) + assert req_menus.all() == [] + + +@pytest.mark.asyncio +async def test_submenu(asession: AsyncSession) -> None: + async with asession: + menu: Menu = Menu(title="SomeMenu", description="SomeDescription") + asession.add(menu) + + await asession.commit() + await asession.refresh(menu) + menu_id: UUID = menu.id + submenu: SubMenu = SubMenu( + title="submenu", description="", parent_menu=menu_id, + ) + submenu = await SubMenuCrud.create_submenu_item( + menu_id, menubaseschema.model_validate(submenu), asession, + ) + print(submenu)