Compare commits

..

No commits in common. "53aeaef20f97098e35056e18f1ed70e9ef61baef" and "1afb88d746fb489dc2832c7173728e8f87f83231" have entirely different histories.

34 changed files with 389 additions and 1055 deletions

935
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,8 +15,6 @@ uvicorn = "^0.30.6"
sqlalchemy = "^2.0.32"
asyncpg = "^0.29.0"
alembic = "^1.13.2"
pytest-xdist = "^3.6.1"
httpx = "^0.28.1"
[tool.poetry.group.dev.dependencies]
@ -25,19 +23,13 @@ pytest = "^8.3.2"
pytest-asyncio = "^0.23.8"
pytest-flakefinder = "^1.1.0"
pytest-randomly = "^3.15.0"
pytest-cov = "^5.0.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
app = "fastfood_two.__main__:main"
api = "fastfood_two.__main__:main"
[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = [
"src"
]
testpaths = "tests"
addopts = "-n=2 --cov=fastfood_two"

View File

@ -4,7 +4,6 @@ from uuid import UUID
@dataclass(frozen=True)
class AddMenuDTO:
id: UUID | None
title: str
description: str | None
@ -24,8 +23,3 @@ class DeleteMenuDTO:
@dataclass(frozen=True)
class GetMenuByIdDTO:
id: UUID
@dataclass(frozen=True)
class AddSubMenuDTO(AddMenuDTO):
parent_menu: UUID

View File

@ -7,8 +7,3 @@ class MenuDTO:
id: UUID
title: str
description: str | None
@dataclass(frozen=True)
class SubMenuDTO(MenuDTO):
parent_menu: UUID

View File

@ -15,7 +15,7 @@ class AddMenu(Interactor[AddMenuDTO, MenuDTO]):
menu = await self._menu_gateway.insert_menu(
menu=Menu(
id=MenuId(uuid4()) if request.id is None else MenuId(request.id),
id=MenuId(uuid4()),
title=Title(request.title),
description=Description(request.description),
)

View File

@ -1,33 +0,0 @@
from uuid import uuid4
from fastfood_two.application.abstractions.interactor import Interactor
from fastfood_two.application.contracts.requests import AddMenuDTO, AddSubMenuDTO
from fastfood_two.application.contracts.responses import SubMenuDTO
from fastfood_two.domain.submenu.gateway import SubMenuGateway
from fastfood_two.domain.submenu.submenu_entity import (
Description,
SubMenu,
SubMenuId,
Title,
)
class AddMenu(Interactor[AddSubMenuDTO, SubMenuDTO]):
def __init__(self, gateway: SubMenuGateway) -> None:
self._menu_gateway = gateway
async def __call__(self, request: AddSubMenuDTO) -> SubMenuDTO:
menu = await self._menu_gateway.insert_submenu(
menu=SubMenu(
id=SubMenuId(uuid4()) if request.id is None else SubMenuId(request.id),
title=Title(request.title),
description=Description(request.description),
)
)
return SubMenuDTO(
id=menu.id.value,
parent_menu=menu.parent_menu.value,
title=menu.title.value,
description=menu.description.value,
)

View File

@ -1,9 +0,0 @@
from fastfood_two.domain.core.error import DomainError
class SubMenuNotFoundError(DomainError):
pass
class SubMenuDataValidationError(DomainError):
pass

View File

@ -1,30 +0,0 @@
from typing import Protocol
from fastfood_two.domain.submenu.submenu_entity import (
Description,
SubMenu,
SubMenuId,
Title,
)
class SubMenuGateway(Protocol):
async def get_submenu_by_id(self, id: SubMenuId) -> SubMenu | None:
raise NotImplementedError
async def insert_submenu(self, menu: SubMenu) -> SubMenu:
raise NotImplementedError
async def get_all_submenus_by_menu_id(self) -> list[SubMenu]:
raise NotImplementedError
async def update_submenu(
self,
id: SubMenuId,
title: Title | None,
description: Description | None,
) -> SubMenu:
raise NotImplementedError
async def delete_submenu(self, id: SubMenuId) -> None:
raise NotImplementedError

View File

@ -1,34 +0,0 @@
from dataclasses import dataclass
from uuid import UUID
from fastfood_two.domain.core.entity import DomainEntity
from fastfood_two.domain.core.value_obj import DomainValueObject
@dataclass(frozen=True)
class SubMenuId(DomainValueObject):
value: UUID
@dataclass(frozen=True)
class Title(DomainValueObject):
value: str
@dataclass(frozen=True)
class Description(DomainValueObject):
value: str | None
@dataclass
class SubMenu(DomainEntity[SubMenuId]):
title: Title
description: Description
@staticmethod
def create(id: UUID, title: str, description: str | None) -> "Menu":
return SubMenu(
id=SubMenuId(id),
title=Title(title),
description=Description(description),
)

View File

@ -12,18 +12,18 @@ class MenuGatewayImpl:
self._session = session
async def get_all_menus(self) -> list[Menu]:
query = text("SELECT * FROM menus;")
query = text("SELECT * FROM menu;")
menus = await self._session.execute(query)
return [db_entity_to_domain(menu._tuple()) for menu in menus]
return [db_entity_to_domain(menu.tuple()) for menu in menus]
async def get_menu_by_id(self, id: MenuId) -> Menu | None:
query = text("SELECT * FROM menus WHERE id = :id;")
query = text("SELECT * FROM menu WHERE id = :id;")
menu = (await self._session.execute(query, {"id": id.value})).tuples().first()
return db_entity_to_domain(menu) if menu else None
async def insert_menu(self, menu: Menu) -> Menu:
query = text("INSERT INTO menus (id, title, description) VALUES (:id, :title, :description);")
query = text("INSERT INTO menu (id, title, description) VALUES (:id, :title, :description);")
await self._session.execute(
query,
{
@ -37,7 +37,7 @@ class MenuGatewayImpl:
async def update_menu(self, id: MenuId, title: Title | None, description: Description | None) -> Menu:
query = text(
"UPDATE menus SET {}{} {} WHERE id = :id RETURNING *;".format(
"UPDATE menu SET {}{} {} WHERE id = :id RETURNING *;".format(
"title = :title " if title is not None else "",
"," if all([title is not None, description is not None]) else "",
"description = :description" if description is not None else "",
@ -52,9 +52,9 @@ class MenuGatewayImpl:
},
)
await self._session.commit()
return db_entity_to_domain(menu.one()._tuple())
return db_entity_to_domain(menu.one().tuple())
async def delete_menu(self, id: MenuId) -> None:
query = text("DELETE FROM menus WHERE id = :id;")
query = text("DELETE FROM menu WHERE id = :id;")
await self._session.execute(query, {"id": id.value})
await self._session.commit()

View File

@ -8,11 +8,11 @@ from .common_attrs import str_25, uuidpk
class Dish(Base):
__tablename__ = "dishes"
__tablename__ = "dish"
id: Mapped[uuidpk]
title: Mapped[str_25]
description: Mapped[str | None]
price: Mapped[float]
parent_submenu: Mapped[uuid.UUID] = mapped_column(ForeignKey("menus.id", ondelete="CASCADE"))
parent_submenu: Mapped[uuid.UUID] = mapped_column(ForeignKey("menu.id", ondelete="CASCADE"))

View File

@ -8,19 +8,15 @@ from .common_attrs import str_25, uuidpk
class SQLAMenu(Base):
__tablename__ = "menus"
id: Mapped[uuidpk] = mapped_column(primary_key=True, unique=True)
id: Mapped[uuidpk]
title: Mapped[str_25]
description: Mapped[str | None]
parent: Mapped["SQLAMenu"] = mapped_column(ForeignKey("menus.id", ondelete="CASCADE"), nullable=True)
parent: Mapped[uuidpk | None] = mapped_column(ForeignKey("menus.id", ondelete="CASCADE"), nullable=True)
submenus: Mapped[list["SQLAMenu"]] = relationship(
"SQLAMenu", backref="menus", lazy="selectin", cascade="all, delete", remote_side=id
"SQLAMenu",
backref="menus",
lazy="selectin",
cascade="all, delete",
)
def to_json(self):
return {
"id": str(self.id),
"title": self.title,
"description": self.description,
}

View File

@ -1,12 +0,0 @@
from fastapi import Request
from fastapi.responses import JSONResponse
from fastfood_two.domain.menu.error import MenuDataValidationError, MenuNotFoundError
async def handle_menu_not_found_error(request: Request, error: MenuNotFoundError) -> JSONResponse:
return JSONResponse(status_code=404, content={"message": error.message})
async def handle_menu_data_validation_error(request: Request, error: MenuDataValidationError) -> JSONResponse:
return JSONResponse(status_code=422, content={"message": error.message})

View File

@ -1,11 +1,5 @@
from fastapi import FastAPI
from fastfood_two.domain.menu.error import MenuDataValidationError, MenuNotFoundError
from fastfood_two.presentation.fastapi_backend.depends.errors import (
handle_menu_data_validation_error,
handle_menu_not_found_error,
)
def init_errorhandlers(app: FastAPI) -> None:
"""Initialize FastAPI error handlers.
@ -13,5 +7,4 @@ def init_errorhandlers(app: FastAPI) -> None:
:param app: FastAPI application
:type app: FastAPI
"""
app.add_exception_handler(MenuNotFoundError, handle_menu_not_found_error)
app.add_exception_handler(MenuDataValidationError, handle_menu_data_validation_error)
pass

View File

@ -26,6 +26,10 @@ async def app_lifespan(app: FastAPI) -> AsyncGenerator:
logger.info("Application lifespan started")
init_dependencies(app)
init_errorhandlers(app)
init_routers(app)
yield
logger.info("Application lifespan stopped")
@ -44,8 +48,4 @@ def app_factory() -> FastAPI:
lifespan=app_lifespan,
)
init_dependencies(app)
init_errorhandlers(app)
init_routers(app)
return app

View File

@ -1,10 +1,7 @@
from uuid import UUID
from pydantic import BaseModel
class AddMenuPDModel(BaseModel):
id: UUID | None = None
title: str
description: str | None

View File

@ -56,8 +56,6 @@ async def add_menu(
Parameters
----------
Optional id: UUID
id of the menu
title: str
title of the menu
description: str
@ -68,7 +66,7 @@ async def add_menu(
MenuDTO
created menu
"""
menus = await usecase(request=AddMenuDTO(id=request.id, title=request.title, description=request.description))
menus = await usecase(request=AddMenuDTO(title=request.title, description=request.description))
return menus

View File

@ -1,99 +0,0 @@
import logging
import os
import xdist
from pytest import Session, fixture, hookimpl
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from fastfood_two.application.config import Config
from tests.utils import db_creator, db_deleter, get_test_settings
def pytest_configure(config):
worker_id = os.environ.get("PYTEST_XDIST_WORKER")
if worker_id is not None:
logging.basicConfig(
format=config.getini("log_file_format"),
filename=f"tests_{worker_id}.log",
level=config.getini("log_file_level"),
)
@hookimpl(trylast=True)
def pytest_sessionstart(session: Session) -> None:
"""Hook запуска приложения в отдельном процессе."""
worker_id = xdist.get_xdist_worker_id(session)
import asyncio
db_name = f"fastfood_two_test_db_{worker_id}"
loop = asyncio.get_event_loop_policy().new_event_loop()
loop.run_until_complete(db_creator(name=db_name))
loop.close()
@hookimpl(trylast=True)
def pytest_sessionfinish(session: Session) -> None:
"""Hook остановки приложения."""
worker_id = xdist.get_xdist_worker_id(session)
import asyncio
db_name = f"fastfood_two_test_db_{worker_id}"
loop = asyncio.get_event_loop_policy().new_event_loop()
loop.run_until_complete(db_deleter(name=db_name))
loop.close()
@fixture(scope="session")
def app_settings() -> Config:
worker_id = os.environ.get("PYTEST_XDIST_WORKER")
if worker_id == None:
worker_id = "master"
settings = get_test_settings(db_name=f"fastfood_two_test_db_{worker_id}")
return settings
@fixture
def get_loop():
import asyncio
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@fixture
def test_engine(app_settings):
return create_async_engine(url=app_settings.db.url)
@fixture
def test_sessionmaker(test_engine):
return async_sessionmaker(test_engine, expire_on_commit=False)
@fixture
async def test_session(test_sessionmaker):
async with test_sessionmaker() as session:
try:
yield session
finally:
pass
@fixture
def app(app_settings, test_engine, test_sessionmaker, test_session):
from fastfood_two.presentation.fastapi_backend.main import app_factory
app = app_factory()
app.dependency_overrides[Config] = lambda: app_settings
app.dependency_overrides[AsyncEngine] = lambda: test_engine
app.dependency_overrides[async_sessionmaker[AsyncSession]] = lambda: test_sessionmaker
app.dependency_overrides[AsyncSession] = lambda: test_session
return app

View File

@ -1,74 +0,0 @@
from uuid import uuid4
from _pytest.fixtures import SubRequest
from pytest import fixture
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from fastfood_two.infrastructure.pg_storage.models import SQLAMenu
def generate_str():
return str(uuid4())
@fixture
async def menu(request: SubRequest, test_session: AsyncSession):
if request.param is not None:
id = uuid4()
title = "test_menu"
description = None
match len(request.param):
case 2:
need_create, need_delete = request.param
if not isinstance(need_create, bool):
raise ValueError
if not isinstance(need_delete, bool):
raise ValueError
case 3:
need_create, need_delete, title = request.param
if not isinstance(need_create, bool):
raise ValueError
if not isinstance(need_delete, bool):
raise ValueError
if not isinstance(title, str | bool):
raise ValueError
if isinstance(title, bool):
title = "test_menu" if not title else generate_str
case 4:
need_create, need_delete, title, description = request.param
if not isinstance(need_create, bool):
raise ValueError
if not isinstance(need_delete, bool):
raise ValueError
if not isinstance(title, str | bool):
raise ValueError
if not isinstance(description, str | bool | None):
raise ValueError
if isinstance(title, bool):
title = "test_menu" if not title else generate_str
if isinstance(description, bool):
description = "test_desc" if not description else generate_str
case _:
raise ValueError
menu = SQLAMenu(id=id, title=title, description=description)
if need_create is True:
test_session.add(menu)
await test_session.commit()
yield menu
if need_delete is True:
stmt = delete(SQLAMenu).where(SQLAMenu.id == menu.id)
await test_session.execute(stmt)
await test_session.commit()
else:
raise ValueError

View File

@ -1,65 +0,0 @@
from pytest import mark
@mark.parametrize(
"menu, is_created",
[((False, False), False), ((True, True), True)],
ids=["Get empty menu list", "Get non-empty menu list"],
indirect=["menu"],
)
async def test_get_menu_list(client, menu, is_created: bool):
response = await client.get("menu/")
assert response.status_code == 200
if is_created:
expected = [menu.to_json()]
else:
expected = []
assert response.json() == expected
@mark.parametrize(
"menu",
[(False, True, False, False), (False, True, False, None)],
ids=["Post menu with description", "Post menu without description"],
indirect=True,
)
async def test_post_menu_add(client, menu):
response = await client.post("menu/", json=menu.to_json())
assert response.status_code == 200
assert response.json() == menu.to_json()
@mark.parametrize(
"menu, json",
[
((True, True), {"title": "updated_title"}),
((True, True), {"title": "updated_title", "description": "updated_description"}),
((True, True), {"title": "updated_title", "description": None}),
],
ids=[
"Update menu title",
"Update menu title and description",
"Update menu title and description to None",
],
indirect=["menu"],
)
async def test_update_menu(client, menu, json):
response = await client.patch(f"menu/{menu.id}", json=json)
assert response.status_code == 200
assert response.json() == {
"id": str(menu.id),
"title": json.get("title"),
"description": json.get("description", None),
}
@mark.parametrize(
"menu",
[(True, False)],
ids=["Delete menu"],
indirect=True,
)
async def test_delete_menu(client, menu):
response = await client.delete(f"menu/{menu.id}")
assert response.status_code == 204

View File

@ -1,13 +0,0 @@
from httpx import ASGITransport, AsyncClient
from pytest import fixture
@fixture
async def client(app):
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://127.0.0.1:8000/api/v1/",
) as async_client:
yield async_client

View File

@ -0,0 +1,6 @@
import pytest
@pytest.mark.asyncio
async def test_coreapp():
assert 1 == 1

View File

@ -1,3 +0,0 @@
class TestMenuDomain:
def test_entity(self):
assert 1 == 1

View File

@ -1,50 +0,0 @@
import contextlib
import os
from functools import lru_cache
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError
from sqlalchemy.ext.asyncio import create_async_engine
from fastfood_two.application.config import Config
from fastfood_two.infrastructure.pg_storage.config import PostgresConfig
from fastfood_two.infrastructure.pg_storage.models import Base
async def db_creator(name: str) -> None:
sql = f"create database {name} with owner test_user;"
async with create_async_engine(
url="postgresql+asyncpg://pi3c:test_password@localhost:5432/test_db",
isolation_level="AUTOCOMMIT",
).begin() as conn:
with contextlib.suppress(ProgrammingError):
await conn.execute(text(sql))
async with create_async_engine(
url=f"postgresql+asyncpg://test_user:test_password@localhost:5432/{name}",
).begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async def db_deleter(name: str) -> None:
sql = f"DROP DATABASE {name} WITH (FORCE);"
async with create_async_engine(
url="postgresql+asyncpg://pi3c:test_password@localhost:5432/test_db",
isolation_level="AUTOCOMMIT",
).begin() as conn:
await conn.execute(text(sql))
@lru_cache()
def get_test_settings(db_name: str) -> Config:
"""Возвращает настройки приложения"""
return Config(
db=PostgresConfig(
user=os.getenv("DB_USER", "test_user"),
password=os.getenv("DB_PASS", "test_password"),
host=os.getenv("DB_HOST", "localhost"),
port=int(os.getenv("DB_PORT", 5432)),
dbname=db_name,
),
)