develop
Сергей Ванюшкин 2024-01-29 02:19:02 +03:00
parent cae407a5f4
commit bab8008ec8
9 changed files with 344 additions and 73 deletions

View File

@ -1,16 +1,11 @@
# fastfood
Fastapi веб приложение реализующее api для общепита.
## Оглавление
- <a href="#description">Описание</a>
- <a href="https://github.com/pi3c/fastfood#зависимости">Зависимости</a>
<a name="description"></a>
## Описание
Данный проект, это результат выполнения практического домашнего задания интенсива от 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.
<a href="https://drive.google.com/drive/folders/13t6fsMO0B6Ls0qYl-uVgAHWOyhFTFv4Z?usp=sharing">Дополнительные материалы</a>
## Возможности
### Спринт 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 строке в файле
<base_dir>/fastfood/crud/menu.py
- 4й пункт ТЗ
см. класс `TestContinuity` в файле
<base_dir>/tests/test_api.py
## Зависимости
Для локальной установки
- postgresql Для работы сервиса необходима установленная СУБД. Должна быть создана база данных и пользователь с правами на нее.
- poetry - Система управления зависимостями в Python.
Остальное добавится автоматически на этапе установки.
<a name="install"></a>
Для запуска в контейнере
- docker
- docker-compose
## Установка
### Docker
Для запуска необходимы установленные приложения docker и docker-compose
Клонируйте репозиторий
> `$ git clone https://git.pi3c.ru/pi3c/fastfood.git`
Перейдите в каталог
> `$ cd fastfood`
Создайте и запустите образы
> `$ docker-compose up -d --build`
После успешного запуска образов документация по API будет доступна по адресу <a href="http://localhost:8000/docs">http://localhost:8000</a>
Для запуска тестов pytest поднимаем контейнер tests
> `$ docker-compose up tests`
### Linux
Установите и настройте postgresql согласно офф. документации. Создайте пользователя и бд.

158
README.md.backup Normal file
View File

@ -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.
<a href="https://drive.google.com/drive/folders/13t6fsMO0B6Ls0qYl-uVgAHWOyhFTFv4Z?usp=sharing">Дополнительные материалы</a>
## Возможности
### Спринт 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 строке в файле
<base_dir>/fastfood/crud/menu.py
- 4й пункт ТЗ
см. класс `TestContinuity` в файле
<base_dir>/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 будет доступна по адресу <a href="http://localhost:8000/docs">http://localhost:8000</a>
Для запуска тестов 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
- Добавить миграции
- Провести рефакторинг, много дублирующего кода
- Много чего другого :)
## Авторы
- Сергей Ванюшкин <pi3c@yandex.ru>
## Лицензия
Распространяется под [MIT лицензией](https://mit-license.org/).

View File

@ -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")

View File

@ -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):

View File

@ -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"

View File

@ -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}")

View File

@ -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

View File

@ -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

56
tests/test_crud.py Normal file
View File

@ -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)