From 143b0896282293207a4a8c61f511bd8b8054055a Mon Sep 17 00:00:00 2001 From: Artur Shchekach Date: Mon, 28 Sep 2026 03:53:24 +1000 Subject: [PATCH 1/6] add hw1 --- hw1/app.py | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 1 deletion(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b87..478f748 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,8 @@ +from http import HTTPStatus from typing import Any, Awaitable, Callable +import json +import math +from urllib.parse import parse_qs async def application( @@ -12,7 +16,202 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + path = scope["path"] + method = scope["method"] + + query_string = scope.get("query_string").decode() + query_params = parse_qs(query_string) + + body = b"" + while True: + message = await receive() + if message["type"] == "http.request": + body += message.get("body") + if not message.get("more_body", False): + break + + path_parts = [p for p in path.split("/") if p] + + try: + if path == "/factorial" and method == "GET": + await handle_factorial(query_params, send) + elif len(path_parts) == 2 and path_parts[0] == "fibonacci" and method == "GET": + await handle_fibonacci(path_parts[1], send) + elif path == "/mean" and method == "GET": + await handle_mean(body, send) + else: + await send_response(send, HTTPStatus.NOT_FOUND, {"error": "Not found"}) + except Exception as e: + await send_response(send, HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(e)}) + + +async def send_response( + send: Callable[[dict[str, Any]], Awaitable[None]], + status_code: int, + data: dict[str, Any], +): + """Отправляет JSON ответ""" + response_body = json.dumps(data).encode() + + await send({ + "type": "http.response.start", + "status": status_code, + "headers": [ + [b"content-type", b"application/json"], + [b"content-length", str(len(response_body)).encode()], + ], + }) + + await send({ + "type": "http.response.body", + "body": response_body, + }) + + +async def handle_factorial( + query_params: dict[str, list[str]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + if "n" not in query_params: + await send_response( + send, + HTTPStatus.UNPROCESSABLE_ENTITY, + {"error": "Parameter 'n' is required"}, + ) + return + + value = query_params["n"][0] + + try: + n = int(value) + except (ValueError, TypeError): + await send_response( + send, + HTTPStatus.UNPROCESSABLE_ENTITY, + {"error": "Parameter 'n' must be an integer"}, + ) + return + + if n < 0: + await send_response( + send, + HTTPStatus.BAD_REQUEST, + {"error": "Parameter 'n' must be non-negative"}, + ) + return + + if n > 1000: + await send_response( + send, + HTTPStatus.BAD_REQUEST, + {"error": "Parameter 'n' is too large"}, + ) + return + + result = math.factorial(n) + await send_response(send, HTTPStatus.OK, {"result": result}) + + +async def handle_fibonacci( + n_str: str, + send: Callable[[dict[str, Any]], Awaitable[None]], +): + try: + n = int(n_str) + except (ValueError, TypeError): + await send_response( + send, + HTTPStatus.UNPROCESSABLE_ENTITY, + {"error": "Invalid integer"}, + ) + return + + if n < 0: + await send_response( + send, + HTTPStatus.BAD_REQUEST, + {"error": "Parameter 'n' must be non-negative"}, + ) + return + + if n > 10000: + await send_response( + send, + HTTPStatus.BAD_REQUEST, + {"error": "Parameter 'n' is too large"}, + ) + return + + if n <= 1: + result = n + else: + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + result = b + + await send_response(send, HTTPStatus.OK, {"result": result}) + + +async def handle_mean( + body: bytes, + send: Callable[[dict[str, Any]], Awaitable[None]], +): + if not body: + await send_response( + send, + HTTPStatus.UNPROCESSABLE_ENTITY, + {"error": "Body is required"}, + ) + return + + try: + data = json.loads(body) + except json.JSONDecodeError: + await send_response( + send, + HTTPStatus.UNPROCESSABLE_ENTITY, + {"error": "Invalid JSON"}, + ) + return + + if data is None: + await send_response( + send, + HTTPStatus.UNPROCESSABLE_ENTITY, + {"error": "Body is required"}, + ) + return + + if not isinstance(data, list) or len(data) == 0: + await send_response( + send, + HTTPStatus.BAD_REQUEST, + {"error": "Body must be a non-empty list"}, + ) + return + + for item in data: + if not isinstance(item, (int, float)) or isinstance(item, bool): + await send_response( + send, + HTTPStatus.BAD_REQUEST, + {"error": "All items must be numbers"}, + ) + return + + result = sum(data) / len(data) + await send_response(send, HTTPStatus.OK, {"result": result}) + if __name__ == "__main__": import uvicorn From 85e931d33f8b3946565f29da441d9d50e23090d9 Mon Sep 17 00:00:00 2001 From: Artur Shchekach Date: Sun, 18 Oct 2026 14:33:16 +1000 Subject: [PATCH 2/6] add part of hw2 --- hw2/hw/requirements.txt | 2 + hw2/hw/shop_api/api/endpoints/cart.py | 58 +++++++++++++++++++++ hw2/hw/shop_api/api/endpoints/item.py | 73 +++++++++++++++++++++++++++ hw2/hw/shop_api/api/routers.py | 7 +++ hw2/hw/shop_api/core/config.py | 15 ++++++ hw2/hw/shop_api/core/db.py | 33 ++++++++++++ hw2/hw/shop_api/models/cart.py | 14 +++++ hw2/hw/shop_api/models/item.py | 15 ++++++ hw2/hw/shop_api/schemas/cart.py | 10 ++++ hw2/hw/shop_api/schemas/item.py | 0 hw2/hw/shop_api/services/cart.py | 34 +++++++++++++ hw2/hw/shop_api/services/crud.py | 43 ++++++++++++++++ hw2/hw/shop_api/services/item.py | 34 +++++++++++++ 13 files changed, 338 insertions(+) create mode 100644 hw2/hw/shop_api/api/endpoints/cart.py create mode 100644 hw2/hw/shop_api/api/endpoints/item.py create mode 100644 hw2/hw/shop_api/api/routers.py create mode 100644 hw2/hw/shop_api/core/config.py create mode 100644 hw2/hw/shop_api/core/db.py create mode 100644 hw2/hw/shop_api/models/cart.py create mode 100644 hw2/hw/shop_api/models/item.py create mode 100644 hw2/hw/shop_api/schemas/cart.py create mode 100644 hw2/hw/shop_api/schemas/item.py create mode 100644 hw2/hw/shop_api/services/cart.py create mode 100644 hw2/hw/shop_api/services/crud.py create mode 100644 hw2/hw/shop_api/services/item.py diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5..1673a57 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -7,3 +7,5 @@ pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 +SQLAlchemy==2.0.29 +pydantic-settings==2.2.1 \ No newline at end of file diff --git a/hw2/hw/shop_api/api/endpoints/cart.py b/hw2/hw/shop_api/api/endpoints/cart.py new file mode 100644 index 0000000..16ce725 --- /dev/null +++ b/hw2/hw/shop_api/api/endpoints/cart.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Query, Depends, status, HTTPException +from typing import Annotated +from sqlalchemy.ext.asyncio import AsyncSession + +from shop_api.core.db import get_async_session +from shop_api.schemas.cart import CartListQuery +from shop_api.services.cart import CartService + +router = APIRouter(prefix="/cart", tags=["cart"]) + +SessionDep = Annotated[AsyncSession, Depends(get_async_session)] + +@router.post( + "/", + response_model=CartDB, +) +async def create_new_cart(session: SessionDep): + cart_service = CartService(session) + return await cart_service.create_cart() + + +@router.get("/", response_model=list[CartDB]) +async def get_carts( + session: SessionDep, + query: Annotated[CartListQuery, Query()] +): + cart_service = CartService(session) + return await cart_service.get_carts(query) + +@router.get( + "/{cart_id}", + response_model=CartDB, +) +async def get_cart_by_id(cart_id: int, session: SessionDep): + cart_service = CartService(session) + try: + return await cart_service.get_cart_by_id(cart_id) + except CartNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found" + ) + +@router.post( + "/{cart_id}/add/{item_id}", + response_model=CartDB, +) +async def add_item_cart(cart_id: int, item_id: int, session: SessionDep): + cart_service = CartService(session) + try: + return await cart_service.add_item_to_cart(cart_id, item_id) + except CartNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found" + ) + except ItemNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) \ No newline at end of file diff --git a/hw2/hw/shop_api/api/endpoints/item.py b/hw2/hw/shop_api/api/endpoints/item.py new file mode 100644 index 0000000..94ee203 --- /dev/null +++ b/hw2/hw/shop_api/api/endpoints/item.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter + +from shop_api.services.item import ItemService + +router = APIRouter(prefix="/item", tags=["item"]) + +@router.post( + "/", + response_model=ItemDB, +) +async def create_new_item(obj_in: ItemCreate, session: SessionDep): + item_service = ItemService(session) + return await item_service.create_item(obj_in) + +@router.get("/", response_model=list[ItemDB]) +async def get_items( + session: SessionDep, + query: Annotated[ItemListQuery, Query()] +): + item_service = ItemService(session) + return await item_service.get_items(query) + +@router.get( + "/{item_id}", + response_model=ItemDB, +) +async def get_item_by_id(item_id: int, session: SessionDep): + item_service = ItemService(session) + try: + return await item_service.get_item_by_id(item_id, session) + except ItemNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + +@router.put( + "/{item_id}", + response_model=ItemDB, +) +async def full_update_item_by_id(obj_in: ItemFullUpdate, item_id: int, session: SessionDep): + item_service = ItemService(session) + try: + return await item_service.update_item_by_id(obj_in, item_id) + except ItemNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + +@router.patch( + "/{item_id}", + response_model=ItemDB, +) +async def partially_update_item_by_id(obj_in: ItemPartiallyUpdate, item_id: int, session: SessionDep): + item_service = ItemService(session) + try: + return await item_service.update_item_by_id(obj_in, item_id) + except ItemNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + +@router.delete( + "/{item_id}", + response_model=ItemDB, +) +async def delete_item_by_id(item_id: int, session: SessionDep): + item_service = ItemService(session) + try: + return await item_service.delete_item_by_id(item_id) + except ItemNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) \ No newline at end of file diff --git a/hw2/hw/shop_api/api/routers.py b/hw2/hw/shop_api/api/routers.py new file mode 100644 index 0000000..d115c79 --- /dev/null +++ b/hw2/hw/shop_api/api/routers.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from shop_api.api.endpoints import cart, item + +main_router = APIRouter() +main_router.include_router(cart.router) +main_router.include_router(item.router) diff --git a/hw2/hw/shop_api/core/config.py b/hw2/hw/shop_api/core/config.py new file mode 100644 index 0000000..ef199dc --- /dev/null +++ b/hw2/hw/shop_api/core/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_title: str = "Shop API" + app_description: str = ( + "Приложение для сбора средств для благотворительных" + " проектов связанных с кошками" + ) + database_url: str = "sqlite+aiosqlite:///./shop.db" + + model_config = SettingsConfigDict(env_file=".env") + + +settings = Settings() diff --git a/hw2/hw/shop_api/core/db.py b/hw2/hw/shop_api/core/db.py new file mode 100644 index 0000000..587b5fc --- /dev/null +++ b/hw2/hw/shop_api/core/db.py @@ -0,0 +1,33 @@ +from sqlalchemy import Integer +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + declared_attr, + mapped_column, +) + +from shop_api.core.config import settings + + +class Base(DeclarativeBase): + pass + + +class CommonMixin: + + @declared_attr + def __tablename__(cls): + return cls.__name__.lower() + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + +engine = create_async_engine(settings.database_url) + +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_async_session(): + async with AsyncSessionLocal() as async_session: + yield async_session diff --git a/hw2/hw/shop_api/models/cart.py b/hw2/hw/shop_api/models/cart.py new file mode 100644 index 0000000..b81efc6 --- /dev/null +++ b/hw2/hw/shop_api/models/cart.py @@ -0,0 +1,14 @@ +from sqlalchemy import Float +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from shop_api.core.db import Base, CommonMixin + + +class Cart(CommonMixin, Base): + items: Mapped[list["Item"]] = relationship( + back_populates="cart", + cascade="all, delete-orphan", + ) + price: Mapped[float] = mapped_column( + Float, nullable=False, default=0 + ) diff --git a/hw2/hw/shop_api/models/item.py b/hw2/hw/shop_api/models/item.py new file mode 100644 index 0000000..ae54df4 --- /dev/null +++ b/hw2/hw/shop_api/models/item.py @@ -0,0 +1,15 @@ +from shop_api.core.db import Base, CommonMixin +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Boolean, String, Float + + +class Item(CommonMixin, Base): + name: Mapped[str] = mapped_column( + String(255), nullable=False + ) + price: Mapped[float] = mapped_column( + Float, nullable=False + ) + deleted: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) \ No newline at end of file diff --git a/hw2/hw/shop_api/schemas/cart.py b/hw2/hw/shop_api/schemas/cart.py new file mode 100644 index 0000000..69eaead --- /dev/null +++ b/hw2/hw/shop_api/schemas/cart.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field +from typing import Optional + +class CartListQuery(BaseModel): + offset: int = Field(0, ge=0) + limit: int = Field(10, gt=0) + min_price: Optional[float] = Field(None, ge=0) + max_price: Optional[float] = Field(None, ge=0) + min_quantity: Optional[int] = Field(None, ge=0) + max_quantity: Optional[int] = Field(None, ge=0) \ No newline at end of file diff --git a/hw2/hw/shop_api/schemas/item.py b/hw2/hw/shop_api/schemas/item.py new file mode 100644 index 0000000..e69de29 diff --git a/hw2/hw/shop_api/services/cart.py b/hw2/hw/shop_api/services/cart.py new file mode 100644 index 0000000..0d68c8c --- /dev/null +++ b/hw2/hw/shop_api/services/cart.py @@ -0,0 +1,34 @@ +from shop_api.schemas.cart import CartListQuery +from shop_api.services.item import ItemService +from shop_api.models.cart import Cart +from shop_api.services.crud import CRUDBase +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + + +class CartService: + def __init__(self, session: AsyncSession): + self.session = session + self.crud = CRUDBase(Cart, session) + self.item_service = ItemService(session) + + async def create_cart(self) -> Cart: + return await self.crud.create() + + async def get_carts(self, query: CartListQuery): + db_objs = await self.session.execute( + select(self.model).where(query).order_by(self.model.id) + ) + return list(db_objs.scalars().all()) + + async def get_cart_by_id(self) -> Cart: + cart = await self.crud.get_by_id() + if cart is None: + raise Exception() + return cart + + async def add_item_to_cart(self, cart_id, item_id) -> Cart: + cart = self.get_cart_by_id(cart_id) + item = self.item_service.get_item_by_id(item_id) + cart.item.append(item.id) + cart.price += item.price \ No newline at end of file diff --git a/hw2/hw/shop_api/services/crud.py b/hw2/hw/shop_api/services/crud.py new file mode 100644 index 0000000..973923d --- /dev/null +++ b/hw2/hw/shop_api/services/crud.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shop_api.core.db import Base + + +class CRUDBase: + def __init__(self, model: type[Base], session: AsyncSession): + self.model = model + self.session = session + + async def create(self, obj_in: BaseModel): + new_obj = self.model(**obj_in.model_dump()) + self.session.add(new_obj) + await self.session.flush() + return new_obj + + async def get_by_id(self, obj_id: int): + db_obj = await self.session.execute( + select(self.model).where(self.model.id == obj_id) + ) + return db_obj.scalars().first() + + async def get_all(self): + db_objs = await self.session.execute( + select(self.model).order_by(self.model.id) + ) + return list(db_objs.scalars().all()) + + async def update( + self, db_obj: Base, obj_in: BaseModel + ): + update_data = obj_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_obj, field, value) + await self.session.flush() + return db_obj + + async def remove(self, db_obj: Base): + await self.session.delete(db_obj) + await self.session.flush() + return db_obj diff --git a/hw2/hw/shop_api/services/item.py b/hw2/hw/shop_api/services/item.py new file mode 100644 index 0000000..1a4346d --- /dev/null +++ b/hw2/hw/shop_api/services/item.py @@ -0,0 +1,34 @@ +from shop_api.models.item import Item +from shop_api.services.crud import CRUDBase +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + + +class ItemService: + def __init__(self, session: AsyncSession): + self.session = session + self.crud = CRUDBase(Item, session) + + async def create_item(self, obj_in: ItemCreate) -> Item: + return await self.create_item(obj_in) + + async def get_items(self, query: ItemListQuery) -> list[Item]: + db_objs = await self.session.execute( + select(self.model).where(query).order_by(self.model.id) + ) + return list(db_objs.scalars().all()) + + async def get_item_by_id(self, item_id: int) -> Item: + item = await self.crud.get_by_id(item_id) + if item is None: + raise Exception() + return item + + async def update_item_by_id(self, obj_in: ItemCreate | ItemUpdate, item_id: int): + item = await self.get_item_by_id(item_id) + return await self.crud.update(item, obj_in) + + async def delete_item_by_id(self, item_id: int) -> None: + item = await self.get_item_by_id(item_id) + return await self.crud.remove(item) + \ No newline at end of file From 72df604576d524dd30ea921e9404e1bf44e724a7 Mon Sep 17 00:00:00 2001 From: Artur Shchekach Date: Sun, 18 Oct 2026 23:28:25 +1000 Subject: [PATCH 3/6] change models --- hw2/hw/shop_api/api/endpoints/cart.py | 25 +++++++---------- hw2/hw/shop_api/api/endpoints/item.py | 28 +++++++++---------- hw2/hw/shop_api/api/exceptions.py | 21 +++++++++++++++ hw2/hw/shop_api/core/deps.py | 8 ++++++ hw2/hw/shop_api/models/cart.py | 6 ++--- hw2/hw/shop_api/models/cart_item.py | 17 ++++++++++++ hw2/hw/shop_api/models/item.py | 7 ++++- hw2/hw/shop_api/schemas/cart.py | 22 +++++++++++++-- hw2/hw/shop_api/schemas/item.py | 37 ++++++++++++++++++++++++++ hw2/hw/shop_api/services/cart.py | 17 ++++++------ hw2/hw/shop_api/services/exceptions.py | 6 +++++ hw2/hw/shop_api/services/item.py | 11 ++++---- 12 files changed, 154 insertions(+), 51 deletions(-) create mode 100644 hw2/hw/shop_api/api/exceptions.py create mode 100644 hw2/hw/shop_api/core/deps.py create mode 100644 hw2/hw/shop_api/models/cart_item.py create mode 100644 hw2/hw/shop_api/services/exceptions.py diff --git a/hw2/hw/shop_api/api/endpoints/cart.py b/hw2/hw/shop_api/api/endpoints/cart.py index 16ce725..a8b5143 100644 --- a/hw2/hw/shop_api/api/endpoints/cart.py +++ b/hw2/hw/shop_api/api/endpoints/cart.py @@ -1,15 +1,14 @@ -from fastapi import APIRouter, Query, Depends, status, HTTPException +from fastapi import APIRouter, Query from typing import Annotated -from sqlalchemy.ext.asyncio import AsyncSession -from shop_api.core.db import get_async_session -from shop_api.schemas.cart import CartListQuery +from shop_api.api.exceptions import HTTPItemNotFoundError, HTTPCartNotFoundError +from shop_api.core.deps import SessionDep +from shop_api.schemas.cart import CartListQuery, CartDB from shop_api.services.cart import CartService +from shop_api.services.exceptions import CartNotFoundError, ItemNotFoundError router = APIRouter(prefix="/cart", tags=["cart"]) -SessionDep = Annotated[AsyncSession, Depends(get_async_session)] - @router.post( "/", response_model=CartDB, @@ -36,9 +35,7 @@ async def get_cart_by_id(cart_id: int, session: SessionDep): try: return await cart_service.get_cart_by_id(cart_id) except CartNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found" - ) + raise HTTPCartNotFoundError() @router.post( "/{cart_id}/add/{item_id}", @@ -47,12 +44,8 @@ async def get_cart_by_id(cart_id: int, session: SessionDep): async def add_item_cart(cart_id: int, item_id: int, session: SessionDep): cart_service = CartService(session) try: - return await cart_service.add_item_to_cart(cart_id, item_id) + return await cart_service.add_item_to_cart(cart_id, item_id) except CartNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found" - ) + raise HTTPCartNotFoundError() except ItemNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) \ No newline at end of file + raise HTTPItemNotFoundError() \ No newline at end of file diff --git a/hw2/hw/shop_api/api/endpoints/item.py b/hw2/hw/shop_api/api/endpoints/item.py index 94ee203..4e0070a 100644 --- a/hw2/hw/shop_api/api/endpoints/item.py +++ b/hw2/hw/shop_api/api/endpoints/item.py @@ -1,6 +1,12 @@ -from fastapi import APIRouter +from typing import Annotated +from fastapi import APIRouter, Query + +from shop_api.core.deps import SessionDep +from shop_api.schemas.item import ItemDB, ItemCreate, ItemListQuery, ItemUpdate, ItemReplace +from shop_api.services.exceptions import ItemNotFoundError from shop_api.services.item import ItemService +from shop_api.api.exceptions import HTTPItemNotFoundError router = APIRouter(prefix="/item", tags=["item"]) @@ -29,35 +35,29 @@ async def get_item_by_id(item_id: int, session: SessionDep): try: return await item_service.get_item_by_id(item_id, session) except ItemNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) + raise HTTPItemNotFoundError() @router.put( "/{item_id}", response_model=ItemDB, ) -async def full_update_item_by_id(obj_in: ItemFullUpdate, item_id: int, session: SessionDep): +async def replace_item_by_id(obj_in: ItemReplace, item_id: int, session: SessionDep): item_service = ItemService(session) try: return await item_service.update_item_by_id(obj_in, item_id) except ItemNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) + raise HTTPItemNotFoundError() @router.patch( "/{item_id}", response_model=ItemDB, ) -async def partially_update_item_by_id(obj_in: ItemPartiallyUpdate, item_id: int, session: SessionDep): +async def update_item_by_id(obj_in: ItemUpdate, item_id: int, session: SessionDep): item_service = ItemService(session) try: return await item_service.update_item_by_id(obj_in, item_id) except ItemNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) + raise HTTPItemNotFoundError() @router.delete( "/{item_id}", @@ -68,6 +68,4 @@ async def delete_item_by_id(item_id: int, session: SessionDep): try: return await item_service.delete_item_by_id(item_id) except ItemNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) \ No newline at end of file + raise HTTPItemNotFoundError() diff --git a/hw2/hw/shop_api/api/exceptions.py b/hw2/hw/shop_api/api/exceptions.py new file mode 100644 index 0000000..57e8098 --- /dev/null +++ b/hw2/hw/shop_api/api/exceptions.py @@ -0,0 +1,21 @@ +from fastapi import HTTPException, status + + +class HTTPItemNotFoundError(HTTPException): + '''HTTPException for Item Not Found error.''' + + def __init__(self): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail="Item not found" + ) + + +class HTTPCartNotFoundError(HTTPException): + '''HTTPException for Cart Not Found error.''' + + def __init__(self): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cart not found" + ) diff --git a/hw2/hw/shop_api/core/deps.py b/hw2/hw/shop_api/core/deps.py new file mode 100644 index 0000000..a6d17f0 --- /dev/null +++ b/hw2/hw/shop_api/core/deps.py @@ -0,0 +1,8 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_async_session + +SessionDep = Annotated[AsyncSession, Depends(get_async_session)] diff --git a/hw2/hw/shop_api/models/cart.py b/hw2/hw/shop_api/models/cart.py index b81efc6..abc07c3 100644 --- a/hw2/hw/shop_api/models/cart.py +++ b/hw2/hw/shop_api/models/cart.py @@ -5,10 +5,8 @@ class Cart(CommonMixin, Base): - items: Mapped[list["Item"]] = relationship( + items: Mapped[list["CartItem"]] = relationship( back_populates="cart", cascade="all, delete-orphan", - ) - price: Mapped[float] = mapped_column( - Float, nullable=False, default=0 + lazy="selectin", ) diff --git a/hw2/hw/shop_api/models/cart_item.py b/hw2/hw/shop_api/models/cart_item.py new file mode 100644 index 0000000..d39d17d --- /dev/null +++ b/hw2/hw/shop_api/models/cart_item.py @@ -0,0 +1,17 @@ +from sqlalchemy import ForeignKey, Integer, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from shop_api.core.db import Base + + +class CartItem(Base): + __tablename__ = "cart_items" + __table_args__ = (UniqueConstraint("cart_id", "item_id", name="uq_cart_item"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + cart_id: Mapped[int] = mapped_column(ForeignKey("carts.id", ondelete="CASCADE")) + item_id: Mapped[int] = mapped_column(ForeignKey("items.id", ondelete="CASCADE")) + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + cart: Mapped["Cart"] = relationship(back_populates="items") + item: Mapped["Item"] = relationship(back_populates="cart_links", lazy="joined") \ No newline at end of file diff --git a/hw2/hw/shop_api/models/item.py b/hw2/hw/shop_api/models/item.py index ae54df4..251e6fd 100644 --- a/hw2/hw/shop_api/models/item.py +++ b/hw2/hw/shop_api/models/item.py @@ -1,5 +1,5 @@ from shop_api.core.db import Base, CommonMixin -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy import Boolean, String, Float @@ -12,4 +12,9 @@ class Item(CommonMixin, Base): ) deleted: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False + ) + + cart_links: Mapped[list["CartItem"]] = relationship( + back_populates="item", + cascade="all, delete-orphan", ) \ No newline at end of file diff --git a/hw2/hw/shop_api/schemas/cart.py b/hw2/hw/shop_api/schemas/cart.py index 69eaead..5c881d8 100644 --- a/hw2/hw/shop_api/schemas/cart.py +++ b/hw2/hw/shop_api/schemas/cart.py @@ -1,10 +1,28 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from typing import Optional + class CartListQuery(BaseModel): offset: int = Field(0, ge=0) limit: int = Field(10, gt=0) min_price: Optional[float] = Field(None, ge=0) max_price: Optional[float] = Field(None, ge=0) min_quantity: Optional[int] = Field(None, ge=0) - max_quantity: Optional[int] = Field(None, ge=0) \ No newline at end of file + max_quantity: Optional[int] = Field(None, ge=0) + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + model_config = ConfigDict(from_attributes=True) + + +class CartDB(BaseModel): + id: int + price: float + items: list[CartItem] + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/hw2/hw/shop_api/schemas/item.py b/hw2/hw/shop_api/schemas/item.py index e69de29..83d7aa3 100644 --- a/hw2/hw/shop_api/schemas/item.py +++ b/hw2/hw/shop_api/schemas/item.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import Optional + + +class ItemListQuery(BaseModel): + offset: int = Field(0, ge=0) + limit: int = Field(10, gt=0) + min_price: Optional[float] = Field(None, ge=0) + max_price: Optional[float] = Field(None, ge=0) + show_deleted: Optional[bool] = Field(False, ge=0) + + +class ItemCreate(BaseModel): + name: str = Field(..., min_length=1) + price: float = Field(..., gt=0) + + model_config = ConfigDict(extra="forbid") + + +class ItemReplace(ItemCreate): + pass + + +class ItemUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1) + price: Optional[float] = Field(None, gt=0) + + model_config = ConfigDict(extra="forbid") + + +class ItemDB(BaseModel): + id: int + name: str + price: float + deleted: bool + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/hw2/hw/shop_api/services/cart.py b/hw2/hw/shop_api/services/cart.py index 0d68c8c..338d60f 100644 --- a/hw2/hw/shop_api/services/cart.py +++ b/hw2/hw/shop_api/services/cart.py @@ -3,6 +3,7 @@ from shop_api.models.cart import Cart from shop_api.services.crud import CRUDBase from sqlalchemy.ext.asyncio import AsyncSession +from shop_api.services.exceptions import CartNotFoundError from sqlalchemy import select @@ -17,18 +18,18 @@ async def create_cart(self) -> Cart: async def get_carts(self, query: CartListQuery): db_objs = await self.session.execute( - select(self.model).where(query).order_by(self.model.id) + select(Cart).where(query).order_by(self.model.id) ) return list(db_objs.scalars().all()) - async def get_cart_by_id(self) -> Cart: - cart = await self.crud.get_by_id() + async def get_cart_by_id(self, cart_id: int) -> Cart: + cart = await self.crud.get_by_id(cart_id) if cart is None: - raise Exception() + raise CartNotFoundError(f"Cart with id {cart_id} not found") return cart - async def add_item_to_cart(self, cart_id, item_id) -> Cart: - cart = self.get_cart_by_id(cart_id) - item = self.item_service.get_item_by_id(item_id) - cart.item.append(item.id) + async def add_item_to_cart(self, cart_id: int, item_id: int) -> Cart: + cart = await self.get_cart_by_id(cart_id) + item = await self.item_service.get_item_by_id(item_id) + cart.items.append(item.id) cart.price += item.price \ No newline at end of file diff --git a/hw2/hw/shop_api/services/exceptions.py b/hw2/hw/shop_api/services/exceptions.py new file mode 100644 index 0000000..17a92af --- /dev/null +++ b/hw2/hw/shop_api/services/exceptions.py @@ -0,0 +1,6 @@ +class ItemNotFoundError(Exception): + """Предмет не найден""" + + +class CartNotFoundError(Exception): + """Корзина не найдена""" diff --git a/hw2/hw/shop_api/services/item.py b/hw2/hw/shop_api/services/item.py index 1a4346d..46dd7dc 100644 --- a/hw2/hw/shop_api/services/item.py +++ b/hw2/hw/shop_api/services/item.py @@ -1,4 +1,6 @@ from shop_api.models.item import Item +from shop_api.schemas.item import ItemCreate, ItemListQuery, ItemUpdate, ItemReplace +from shop_api.services.exceptions import ItemNotFoundError from shop_api.services.crud import CRUDBase from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -10,25 +12,24 @@ def __init__(self, session: AsyncSession): self.crud = CRUDBase(Item, session) async def create_item(self, obj_in: ItemCreate) -> Item: - return await self.create_item(obj_in) + return await self.crud.create(obj_in) async def get_items(self, query: ItemListQuery) -> list[Item]: db_objs = await self.session.execute( - select(self.model).where(query).order_by(self.model.id) + select(Item).where(query).order_by(self.model.id) ) return list(db_objs.scalars().all()) async def get_item_by_id(self, item_id: int) -> Item: item = await self.crud.get_by_id(item_id) if item is None: - raise Exception() + raise ItemNotFoundError(f"Item with id {item_id} not found") return item - async def update_item_by_id(self, obj_in: ItemCreate | ItemUpdate, item_id: int): + async def update_item_by_id(self, obj_in: ItemReplace | ItemUpdate, item_id: int): item = await self.get_item_by_id(item_id) return await self.crud.update(item, obj_in) async def delete_item_by_id(self, item_id: int) -> None: item = await self.get_item_by_id(item_id) return await self.crud.remove(item) - \ No newline at end of file From 02fc7745c7772c844c84b3df7665d1dbe5b25a0b Mon Sep 17 00:00:00 2001 From: Artur Shchekach Date: Mon, 19 Oct 2026 06:30:24 +1000 Subject: [PATCH 4/6] add tests and websocket --- hw2/hw/.gitignore | 6 + hw2/hw/alembic.ini | 102 ++++++++++++++ hw2/hw/migrations/README | 1 + hw2/hw/migrations/env.py | 96 +++++++++++++ hw2/hw/migrations/script.py.mako | 24 ++++ .../289598b6d36c_initial_migration.py | 50 +++++++ hw2/hw/pytest.ini | 4 + hw2/hw/requirements.txt | 6 +- hw2/hw/shop_api/api/endpoints/cart.py | 16 ++- hw2/hw/shop_api/api/endpoints/chat.py | 52 ++++++++ hw2/hw/shop_api/api/endpoints/item.py | 14 +- hw2/hw/shop_api/api/routers.py | 3 +- hw2/hw/shop_api/core/base.py | 4 + hw2/hw/shop_api/core/config.py | 3 +- hw2/hw/shop_api/core/db.py | 9 +- hw2/hw/shop_api/core/deps.py | 2 +- hw2/hw/shop_api/main.py | 9 +- hw2/hw/shop_api/models/__init__.py | 3 + hw2/hw/shop_api/models/cart.py | 1 - hw2/hw/shop_api/schemas/cart.py | 4 +- hw2/hw/shop_api/schemas/item.py | 2 +- hw2/hw/shop_api/services/cart.py | 100 ++++++++++++-- hw2/hw/shop_api/services/crud.py | 5 +- hw2/hw/shop_api/services/item.py | 24 +++- hw2/hw/test_homework2.py | 126 ++++++++++++++++-- 25 files changed, 611 insertions(+), 55 deletions(-) create mode 100644 hw2/hw/.gitignore create mode 100644 hw2/hw/alembic.ini create mode 100644 hw2/hw/migrations/README create mode 100644 hw2/hw/migrations/env.py create mode 100644 hw2/hw/migrations/script.py.mako create mode 100644 hw2/hw/migrations/versions/289598b6d36c_initial_migration.py create mode 100644 hw2/hw/pytest.ini create mode 100644 hw2/hw/shop_api/api/endpoints/chat.py create mode 100644 hw2/hw/shop_api/core/base.py create mode 100644 hw2/hw/shop_api/models/__init__.py diff --git a/hw2/hw/.gitignore b/hw2/hw/.gitignore new file mode 100644 index 0000000..110619b --- /dev/null +++ b/hw2/hw/.gitignore @@ -0,0 +1,6 @@ +shop.db +*.db +__pycache__/ +*.pyc +.pytest_cache/ +venv/ \ No newline at end of file diff --git a/hw2/hw/alembic.ini b/hw2/hw/alembic.ini new file mode 100644 index 0000000..f6c8899 --- /dev/null +++ b/hw2/hw/alembic.ini @@ -0,0 +1,102 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/hw2/hw/migrations/README b/hw2/hw/migrations/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/hw2/hw/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/hw2/hw/migrations/env.py b/hw2/hw/migrations/env.py new file mode 100644 index 0000000..33214fe --- /dev/null +++ b/hw2/hw/migrations/env.py @@ -0,0 +1,96 @@ +import asyncio +import os +from logging.config import fileConfig + +from dotenv import load_dotenv +from shop_api.core.config import settings +from sqlalchemy import engine_from_config, pool +from sqlalchemy.ext.asyncio import AsyncEngine + +from alembic import context +from shop_api.core.base import Base + +load_dotenv(".env") +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = AsyncEngine( + engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + future=True, + ) + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) \ No newline at end of file diff --git a/hw2/hw/migrations/script.py.mako b/hw2/hw/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/hw2/hw/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/hw2/hw/migrations/versions/289598b6d36c_initial_migration.py b/hw2/hw/migrations/versions/289598b6d36c_initial_migration.py new file mode 100644 index 0000000..61b28ee --- /dev/null +++ b/hw2/hw/migrations/versions/289598b6d36c_initial_migration.py @@ -0,0 +1,50 @@ +"""Initial migration + +Revision ID: 289598b6d36c +Revises: +Create Date: 2026-10-19 05:47:29.784130 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '289598b6d36c' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('carts', + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('items', + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('deleted', sa.Boolean(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('cart_items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('cart_id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['cart_id'], ['carts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('cart_id', 'item_id', name='uq_cart_item') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('cart_items') + op.drop_table('items') + op.drop_table('carts') + # ### end Alembic commands ### diff --git a/hw2/hw/pytest.ini b/hw2/hw/pytest.ini new file mode 100644 index 0000000..e007f09 --- /dev/null +++ b/hw2/hw/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +asyncio_mode = strict +asyncio_default_test_loop_scope = session +asyncio_default_fixture_loop_scope = session \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 1673a57..1ddcc74 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -8,4 +8,8 @@ pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 SQLAlchemy==2.0.29 -pydantic-settings==2.2.1 \ No newline at end of file +pydantic-settings==2.2.1 +aiosqlite==0.17.0 +alembic==1.7.7 +python-dotenv==1.0.1 +fastapi[standard] \ No newline at end of file diff --git a/hw2/hw/shop_api/api/endpoints/cart.py b/hw2/hw/shop_api/api/endpoints/cart.py index a8b5143..8f8e698 100644 --- a/hw2/hw/shop_api/api/endpoints/cart.py +++ b/hw2/hw/shop_api/api/endpoints/cart.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, status +from fastapi.responses import JSONResponse from typing import Annotated from shop_api.api.exceptions import HTTPItemNotFoundError, HTTPCartNotFoundError @@ -10,15 +11,18 @@ router = APIRouter(prefix="/cart", tags=["cart"]) @router.post( - "/", - response_model=CartDB, + "", ) async def create_new_cart(session: SessionDep): - cart_service = CartService(session) - return await cart_service.create_cart() + cart = await CartService(session).create_cart() + return JSONResponse( + status_code=status.HTTP_201_CREATED, + content={"id": cart.id}, + headers={"Location": f"/cart/{cart.id}"}, + ) -@router.get("/", response_model=list[CartDB]) +@router.get("", response_model=list[CartDB]) async def get_carts( session: SessionDep, query: Annotated[CartListQuery, Query()] diff --git a/hw2/hw/shop_api/api/endpoints/chat.py b/hw2/hw/shop_api/api/endpoints/chat.py new file mode 100644 index 0000000..9ff08cd --- /dev/null +++ b/hw2/hw/shop_api/api/endpoints/chat.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass, field +from uuid import uuid4 + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter(tags=["chat"]) + + +@dataclass(slots=True) +class Broadcaster: + rooms: dict[str, dict[str, WebSocket]] = field( + init=False, default_factory=dict + ) + + async def subscribe(self, chat_name: str, ws: WebSocket) -> str: + await ws.accept() + username = uuid4().hex[:8] + self.rooms.setdefault(chat_name, {})[username] = ws + return username + + def unsubscribe(self, chat_name: str, username: str) -> None: + room = self.rooms.get(chat_name) + if room is None: + return + room.pop(username, None) + if not room: + self.rooms.pop(chat_name, None) + + async def publish( + self, chat_name: str, sender: str, message: str + ) -> None: + for username, ws in list(self.rooms.get(chat_name, {}).items()): + if username == sender: + continue + try: + await ws.send_text(f"{sender} :: {message}") + except Exception: + pass + + +broadcaster = Broadcaster() + + +@router.websocket("/chat/{chat_name}") +async def ws_chat(ws: WebSocket, chat_name: str): + username = await broadcaster.subscribe(chat_name, ws) + try: + while True: + text = await ws.receive_text() + await broadcaster.publish(chat_name, username, text) + except WebSocketDisconnect: + broadcaster.unsubscribe(chat_name, username) \ No newline at end of file diff --git a/hw2/hw/shop_api/api/endpoints/item.py b/hw2/hw/shop_api/api/endpoints/item.py index 4e0070a..6301942 100644 --- a/hw2/hw/shop_api/api/endpoints/item.py +++ b/hw2/hw/shop_api/api/endpoints/item.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Response, status from shop_api.core.deps import SessionDep from shop_api.schemas.item import ItemDB, ItemCreate, ItemListQuery, ItemUpdate, ItemReplace @@ -11,14 +11,15 @@ router = APIRouter(prefix="/item", tags=["item"]) @router.post( - "/", + "", response_model=ItemDB, + status_code=status.HTTP_201_CREATED ) async def create_new_item(obj_in: ItemCreate, session: SessionDep): item_service = ItemService(session) return await item_service.create_item(obj_in) -@router.get("/", response_model=list[ItemDB]) +@router.get("", response_model=list[ItemDB]) async def get_items( session: SessionDep, query: Annotated[ItemListQuery, Query()] @@ -33,7 +34,7 @@ async def get_items( async def get_item_by_id(item_id: int, session: SessionDep): item_service = ItemService(session) try: - return await item_service.get_item_by_id(item_id, session) + return await item_service.get_active_item_by_id(item_id) except ItemNotFoundError: raise HTTPItemNotFoundError() @@ -55,9 +56,12 @@ async def replace_item_by_id(obj_in: ItemReplace, item_id: int, session: Session async def update_item_by_id(obj_in: ItemUpdate, item_id: int, session: SessionDep): item_service = ItemService(session) try: - return await item_service.update_item_by_id(obj_in, item_id) + item = await item_service.get_item_by_id(item_id) except ItemNotFoundError: raise HTTPItemNotFoundError() + if item.deleted: + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + return await item_service.update_item_by_id(obj_in, item_id) @router.delete( "/{item_id}", diff --git a/hw2/hw/shop_api/api/routers.py b/hw2/hw/shop_api/api/routers.py index d115c79..5a49d18 100644 --- a/hw2/hw/shop_api/api/routers.py +++ b/hw2/hw/shop_api/api/routers.py @@ -1,7 +1,8 @@ from fastapi import APIRouter -from shop_api.api.endpoints import cart, item +from shop_api.api.endpoints import cart, item, chat main_router = APIRouter() main_router.include_router(cart.router) main_router.include_router(item.router) +main_router.include_router(chat.router) diff --git a/hw2/hw/shop_api/core/base.py b/hw2/hw/shop_api/core/base.py new file mode 100644 index 0000000..8dd73e2 --- /dev/null +++ b/hw2/hw/shop_api/core/base.py @@ -0,0 +1,4 @@ +"""Импорты класса Base и всех моделей для Alembic.""" + +from shop_api.core.db import Base # noqa +from shop_api.models import Cart, CartItem, Item # noqa \ No newline at end of file diff --git a/hw2/hw/shop_api/core/config.py b/hw2/hw/shop_api/core/config.py index ef199dc..ed6366a 100644 --- a/hw2/hw/shop_api/core/config.py +++ b/hw2/hw/shop_api/core/config.py @@ -4,8 +4,7 @@ class Settings(BaseSettings): app_title: str = "Shop API" app_description: str = ( - "Приложение для сбора средств для благотворительных" - " проектов связанных с кошками" + "Апи магазина" ) database_url: str = "sqlite+aiosqlite:///./shop.db" diff --git a/hw2/hw/shop_api/core/db.py b/hw2/hw/shop_api/core/db.py index 587b5fc..73b7dc6 100644 --- a/hw2/hw/shop_api/core/db.py +++ b/hw2/hw/shop_api/core/db.py @@ -18,7 +18,7 @@ class CommonMixin: @declared_attr def __tablename__(cls): - return cls.__name__.lower() + return cls.__name__.lower() + 's' id: Mapped[int] = mapped_column(Integer, primary_key=True) @@ -30,4 +30,9 @@ def __tablename__(cls): async def get_async_session(): async with AsyncSessionLocal() as async_session: - yield async_session + try: + yield async_session + await async_session.commit() + except Exception: + await async_session.rollback() + raise diff --git a/hw2/hw/shop_api/core/deps.py b/hw2/hw/shop_api/core/deps.py index a6d17f0..5d93482 100644 --- a/hw2/hw/shop_api/core/deps.py +++ b/hw2/hw/shop_api/core/deps.py @@ -3,6 +3,6 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from core.db import get_async_session +from shop_api.core.db import get_async_session SessionDep = Annotated[AsyncSession, Depends(get_async_session)] diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c6..ad29635 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,10 @@ from fastapi import FastAPI -app = FastAPI(title="Shop API") +from shop_api.api.routers import main_router +from shop_api.core.config import settings + +app = FastAPI( + title=settings.app_title, + description=settings.app_description, +) +app.include_router(main_router) \ No newline at end of file diff --git a/hw2/hw/shop_api/models/__init__.py b/hw2/hw/shop_api/models/__init__.py new file mode 100644 index 0000000..0523ca3 --- /dev/null +++ b/hw2/hw/shop_api/models/__init__.py @@ -0,0 +1,3 @@ +from .item import Item # noqa +from .cart import Cart # noqa +from .cart_item import CartItem # noqa \ No newline at end of file diff --git a/hw2/hw/shop_api/models/cart.py b/hw2/hw/shop_api/models/cart.py index abc07c3..4c3e75e 100644 --- a/hw2/hw/shop_api/models/cart.py +++ b/hw2/hw/shop_api/models/cart.py @@ -8,5 +8,4 @@ class Cart(CommonMixin, Base): items: Mapped[list["CartItem"]] = relationship( back_populates="cart", cascade="all, delete-orphan", - lazy="selectin", ) diff --git a/hw2/hw/shop_api/schemas/cart.py b/hw2/hw/shop_api/schemas/cart.py index 5c881d8..2115968 100644 --- a/hw2/hw/shop_api/schemas/cart.py +++ b/hw2/hw/shop_api/schemas/cart.py @@ -11,7 +11,7 @@ class CartListQuery(BaseModel): max_quantity: Optional[int] = Field(None, ge=0) -class CartItem(BaseModel): +class CartItemDB(BaseModel): id: int name: str quantity: int @@ -23,6 +23,6 @@ class CartItem(BaseModel): class CartDB(BaseModel): id: int price: float - items: list[CartItem] + items: list[CartItemDB] model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/hw2/hw/shop_api/schemas/item.py b/hw2/hw/shop_api/schemas/item.py index 83d7aa3..b350a6b 100644 --- a/hw2/hw/shop_api/schemas/item.py +++ b/hw2/hw/shop_api/schemas/item.py @@ -7,7 +7,7 @@ class ItemListQuery(BaseModel): limit: int = Field(10, gt=0) min_price: Optional[float] = Field(None, ge=0) max_price: Optional[float] = Field(None, ge=0) - show_deleted: Optional[bool] = Field(False, ge=0) + show_deleted: bool = False class ItemCreate(BaseModel): diff --git a/hw2/hw/shop_api/services/cart.py b/hw2/hw/shop_api/services/cart.py index 338d60f..76bffcd 100644 --- a/hw2/hw/shop_api/services/cart.py +++ b/hw2/hw/shop_api/services/cart.py @@ -1,10 +1,14 @@ -from shop_api.schemas.cart import CartListQuery +from sqlalchemy.orm import selectinload + +from shop_api.models.cart_item import CartItem +from shop_api.models.item import Item +from shop_api.schemas.cart import CartDB, CartItemDB, CartListQuery from shop_api.services.item import ItemService from shop_api.models.cart import Cart from shop_api.services.crud import CRUDBase from sqlalchemy.ext.asyncio import AsyncSession from shop_api.services.exceptions import CartNotFoundError -from sqlalchemy import select +from sqlalchemy import func, select class CartService: @@ -16,20 +20,94 @@ def __init__(self, session: AsyncSession): async def create_cart(self) -> Cart: return await self.crud.create() - async def get_carts(self, query: CartListQuery): - db_objs = await self.session.execute( - select(Cart).where(query).order_by(self.model.id) + async def get_carts(self, query: CartListQuery) -> list[CartDB]: + total_price = func.coalesce( + func.sum(Item.price * CartItem.quantity), 0.0 + ).label("total_price") + total_quantity = func.coalesce( + func.sum(CartItem.quantity), 0 + ).label("total_quantity") + selection = ( + select(Cart.id.label("cart_id"), total_price, total_quantity) + .outerjoin(CartItem, CartItem.cart_id == Cart.id) + .outerjoin(Item, Item.id == CartItem.item_id) + .group_by(Cart.id) + .order_by(Cart.id) ) - return list(db_objs.scalars().all()) + if query.min_price is not None: + selection = selection.having(total_price >= query.min_price) + if query.max_price is not None: + selection = selection.having(total_price <= query.max_price) + if query.min_quantity is not None: + selection = selection.having(total_quantity >= query.min_quantity) + if query.max_quantity is not None: + selection = selection.having(total_quantity <= query.max_quantity) + selection = selection.limit(query.limit).offset(query.offset) + rows = (await self.session.execute(selection)).all() + if not rows: + return [] + cart_ids = [r.cart_id for r in rows] + price_by_id = {r.cart_id: r.total_price for r in rows} + carts = await self._load_carts_with_items(cart_ids) + return [self._cart_to_dict(c, price_by_id[c.id]) for c in carts] - async def get_cart_by_id(self, cart_id: int) -> Cart: + async def get_cart_by_id(self, cart_id: int) -> CartDB: + cart = await self._check_cart_exist(cart_id) + db_objs = await self._load_carts_with_items([cart.id]) + cart_with_items = db_objs[0] + return self._cart_to_dict(cart_with_items) + + async def _check_cart_exist(self, cart_id) -> Cart: cart = await self.crud.get_by_id(cart_id) if cart is None: raise CartNotFoundError(f"Cart with id {cart_id} not found") return cart - async def add_item_to_cart(self, cart_id: int, item_id: int) -> Cart: - cart = await self.get_cart_by_id(cart_id) + async def _load_carts_with_items(self, cart_ids: list[int]) -> list[Cart]: + db_objs = await self.session.execute( + select(Cart) + .where(Cart.id.in_(cart_ids)) + .options(selectinload(Cart.items).selectinload(CartItem.item)) + .order_by(Cart.id) + ) + return list(db_objs.scalars().all()) + + async def _get_cart_item(self, cart_id: int, item_id: int) -> CartItem | None: + db_obj = await self.session.execute( + select(CartItem).where( + CartItem.cart_id == cart_id, + CartItem.item_id == item_id, + ) + ) + return db_obj.scalars().first() + + async def add_item_to_cart(self, cart_id: int, item_id: int) -> CartDB: + cart = await self._check_cart_exist(cart_id) item = await self.item_service.get_item_by_id(item_id) - cart.items.append(item.id) - cart.price += item.price \ No newline at end of file + cart_item = await self._get_cart_item(cart.id, item.id) + if cart_item is None: + cart_item = CartItem(cart_id=cart.id, item_id=item.id) + self.session.add(cart_item) + else: + cart_item.quantity += 1 + await self.session.flush() + db_objs = await self._load_carts_with_items([cart.id]) + cart_with_items = db_objs[0] + return self._cart_to_dict(cart_with_items) + + def _cart_to_dict(self, cart: Cart, price: float | None = None) -> CartDB: + items_out: list[CartItemDB] = [] + cart_price = price if price is not None else 0.0 + for link in cart.items: + item = link.item + items_out.append( + CartItemDB( + id=item.id, + name=item.name, + quantity=link.quantity, + available=not item.deleted, + ) + ) + if price is None: + cart_price += item.price * link.quantity + return CartDB(id=cart.id, items=items_out, price=cart_price) diff --git a/hw2/hw/shop_api/services/crud.py b/hw2/hw/shop_api/services/crud.py index 973923d..0336030 100644 --- a/hw2/hw/shop_api/services/crud.py +++ b/hw2/hw/shop_api/services/crud.py @@ -10,8 +10,9 @@ def __init__(self, model: type[Base], session: AsyncSession): self.model = model self.session = session - async def create(self, obj_in: BaseModel): - new_obj = self.model(**obj_in.model_dump()) + async def create(self, obj_in: BaseModel | None = None): + data = obj_in.model_dump() if obj_in is not None else {} + new_obj = self.model(**data) self.session.add(new_obj) await self.session.flush() return new_obj diff --git a/hw2/hw/shop_api/services/item.py b/hw2/hw/shop_api/services/item.py index 46dd7dc..e923449 100644 --- a/hw2/hw/shop_api/services/item.py +++ b/hw2/hw/shop_api/services/item.py @@ -15,9 +15,15 @@ async def create_item(self, obj_in: ItemCreate) -> Item: return await self.crud.create(obj_in) async def get_items(self, query: ItemListQuery) -> list[Item]: - db_objs = await self.session.execute( - select(Item).where(query).order_by(self.model.id) - ) + selection = select(Item) + if not query.show_deleted: + selection = selection.where(Item.deleted.is_(False)) + if query.min_price is not None: + selection = selection.where(Item.price >= query.min_price) + if query.max_price is not None: + selection = selection.where(Item.price <= query.max_price) + selection = selection.order_by(Item.id).limit(query.limit).offset(query.offset) + db_objs = await self.session.execute(selection) return list(db_objs.scalars().all()) async def get_item_by_id(self, item_id: int) -> Item: @@ -26,10 +32,18 @@ async def get_item_by_id(self, item_id: int) -> Item: raise ItemNotFoundError(f"Item with id {item_id} not found") return item + async def get_active_item_by_id(self, item_id: int) -> Item: + item = await self.get_item_by_id(item_id) + if item.deleted: + raise ItemNotFoundError(f"Item with id {item_id} not found") + return item + async def update_item_by_id(self, obj_in: ItemReplace | ItemUpdate, item_id: int): item = await self.get_item_by_id(item_id) return await self.crud.update(item, obj_in) - async def delete_item_by_id(self, item_id: int) -> None: + async def delete_item_by_id(self, item_id: int) -> Item: item = await self.get_item_by_id(item_id) - return await self.crud.remove(item) + item.deleted = True + await self.session.flush() + return item diff --git a/hw2/hw/test_homework2.py b/hw2/hw/test_homework2.py index 60a1f36..09091c1 100644 --- a/hw2/hw/test_homework2.py +++ b/hw2/hw/test_homework2.py @@ -1,24 +1,81 @@ +import sys +from pathlib import Path + from http import HTTPStatus from typing import Any from uuid import uuid4 +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + import pytest from faker import Faker from fastapi.testclient import TestClient +sys.path.insert(0, str(Path(__file__).parent)) + from shop_api.main import app +from shop_api.core.db import Base, get_async_session +import shop_api.models + +SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://" + +engine = create_async_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +AsyncTestingSessionLocal = sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + +async def override_db(): + async with AsyncTestingSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + +app.dependency_overrides[get_async_session] = override_db + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest_asyncio.fixture +async def session(): + async with AsyncTestingSessionLocal() as session: + yield session + +@pytest.fixture(scope="session") +def client(init_db): + """TestClient создаётся только после того, как init_db создал таблицы.""" + with TestClient(app) as c: + yield c -client = TestClient(app) faker = Faker() @pytest.fixture() -def existing_empty_cart_id() -> int: +def existing_empty_cart_id(client) -> int: return client.post("/cart").json()["id"] @pytest.fixture(scope="session") -def existing_items() -> list[int]: +def existing_items(client) -> list[int]: items = [ { "name": f"Тестовый товар {i}", @@ -31,7 +88,7 @@ def existing_items() -> list[int]: @pytest.fixture(scope="session", autouse=True) -def existing_not_empty_carts(existing_items: list[int]) -> list[int]: +def existing_not_empty_carts(existing_items: list[int], client) -> list[int]: carts = [] for i in range(20): @@ -48,6 +105,7 @@ def existing_not_empty_carts(existing_items: list[int]) -> list[int]: def existing_not_empty_cart_id( existing_empty_cart_id: int, existing_items: list[int], + client ) -> int: for item_id in faker.random_elements(existing_items, unique=False, length=3): client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") @@ -56,7 +114,7 @@ def existing_not_empty_cart_id( @pytest.fixture() -def existing_item() -> dict[str, Any]: +def existing_item(client) -> dict[str, Any]: return client.post( "/item", json={ @@ -67,7 +125,7 @@ def existing_item() -> dict[str, Any]: @pytest.fixture() -def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: +def deleted_item(existing_item: dict[str, Any], client) -> dict[str, Any]: item_id = existing_item["id"] client.delete(f"/item/{item_id}") @@ -75,7 +133,7 @@ def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: return existing_item -def test_post_cart() -> None: +def test_post_cart(client) -> None: response = client.post("/cart") assert response.status_code == HTTPStatus.CREATED @@ -90,7 +148,7 @@ def test_post_cart() -> None: ("existing_not_empty_cart_id", True), ], ) -def test_get_cart(request, cart: int, not_empty: bool) -> None: +def test_get_cart(request, cart: int, not_empty: bool, client) -> None: cart_id = request.getfixturevalue(cart) response = client.get(f"/cart/{cart_id}") @@ -131,7 +189,7 @@ def test_get_cart(request, cart: int, not_empty: bool) -> None: ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), ], ) -def test_get_cart_list(query: dict[str, Any], status_code: int): +def test_get_cart_list(query: dict[str, Any], status_code: int, client): response = client.get("/cart", params=query) assert response.status_code == status_code @@ -156,7 +214,7 @@ def test_get_cart_list(query: dict[str, Any], status_code: int): assert quantity <= query["max_quantity"] -def test_post_item() -> None: +def test_post_item(client) -> None: item = {"name": "test item", "price": 9.99} response = client.post("/item", json=item) @@ -167,7 +225,7 @@ def test_post_item() -> None: assert item["name"] == data["name"] -def test_get_item(existing_item: dict[str, Any]) -> None: +def test_get_item(existing_item: dict[str, Any], client) -> None: item_id = existing_item["id"] response = client.get(f"/item/{item_id}") @@ -190,7 +248,7 @@ def test_get_item(existing_item: dict[str, Any]) -> None: ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), ], ) -def test_get_item_list(query: dict[str, Any], status_code: int) -> None: +def test_get_item_list(query: dict[str, Any], status_code: int, client) -> None: response = client.get("/item", params=query) assert response.status_code == status_code @@ -222,6 +280,7 @@ def test_put_item( existing_item: dict[str, Any], body: dict[str, Any], status_code: int, + client ) -> None: item_id = existing_item["id"] response = client.put(f"/item/{item_id}", json=body) @@ -255,7 +314,7 @@ def test_put_item( ), ], ) -def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int, client) -> None: item_data: dict[str, Any] = request.getfixturevalue(item) item_id = item_data["id"] response = client.patch(f"/item/{item_id}", json=body) @@ -271,7 +330,7 @@ def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) assert patched_item == patch_response_body -def test_delete_item(existing_item: dict[str, Any]) -> None: +def test_delete_item(existing_item: dict[str, Any], client) -> None: item_id = existing_item["id"] response = client.delete(f"/item/{item_id}") @@ -282,3 +341,42 @@ def test_delete_item(existing_item: dict[str, Any]) -> None: response = client.delete(f"/item/{item_id}") assert response.status_code == HTTPStatus.OK + + +def test_chat_broadcast_within_room(client) -> None: + """Два клиента в одной комнате видят сообщения друг друга.""" + with client.websocket_connect("/chat/room1") as ws1, \ + client.websocket_connect("/chat/room1") as ws2: + ws1.send_text("hello") + data = ws2.receive_text() + assert " :: hello" in data + assert data.split(" :: ")[1] == "hello" + + +def test_chat_sender_does_not_receive_own_message(client) -> None: + with client.websocket_connect("/chat/room2") as ws1, \ + client.websocket_connect("/chat/room2") as ws2: + ws1.send_text("only for ws2") + assert ws2.receive_text().endswith(" :: only for ws2") + ws2.send_text("reply") + data = ws1.receive_text() + assert data.endswith(" :: reply") + assert "only for ws2" not in data + + +def test_chat_isolation_between_rooms(client) -> None: + """Клиенты из разных комнат не получают сообщения друг друга.""" + with client.websocket_connect("/chat/alpha") as ws_alpha, \ + client.websocket_connect("/chat/beta") as ws_beta: + ws_beta.send_text("only beta") + ws_alpha.send_text("only alpha") + with client.websocket_connect("/chat/alpha") as ws_alpha2: + ws_alpha2.send_text("alpha msg") + data = ws_alpha.receive_text() + assert data.endswith(" :: alpha msg") + assert "only beta" not in data + with client.websocket_connect("/chat/beta") as ws_beta2: + ws_beta2.send_text("beta msg") + data = ws_beta.receive_text() + assert data.endswith(" :: beta msg") + assert "only alpha" not in data From 2f4945fc86b70c3c3baba921bb2a50282c688f48 Mon Sep 17 00:00:00 2001 From: Artur Shchekach Date: Mon, 19 Oct 2026 06:33:33 +1000 Subject: [PATCH 5/6] fix deps --- hw2/hw/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 1ddcc74..ba715e3 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -7,7 +7,7 @@ pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 -SQLAlchemy==2.0.29 +SQLAlchemy>=2.0.41 pydantic-settings==2.2.1 aiosqlite==0.17.0 alembic==1.7.7 From bc450a55f68f0caa80bc322370dc58ee8345dfb0 Mon Sep 17 00:00:00 2001 From: Artur Shchekach Date: Mon, 19 Oct 2026 06:35:19 +1000 Subject: [PATCH 6/6] fix deps --- hw2/hw/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index ba715e3..7ce8171 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -7,7 +7,7 @@ pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 -SQLAlchemy>=2.0.41 +sqlalchemy[asyncio]>=2.0.41 pydantic-settings==2.2.1 aiosqlite==0.17.0 alembic==1.7.7