From 15c5a81b288aa305d1071a615d03a0b96b58a09d Mon Sep 17 00:00:00 2001 From: cosm0oo Date: Sun, 4 Oct 2026 23:14:16 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BB=20API=20=D0=BC=D0=B0=D0=B3=D0=B0?= =?UTF-8?q?=D0=B7=D0=B8=D0=BD=D0=B0=20=D0=B8=20=D1=87=D0=B0=D1=82=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B4=D0=B7=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hw2/hw/README.md | 32 ++++++++- hw2/hw/requirements.txt | 2 +- hw2/hw/shop_api/chat.py | 30 +++++++++ hw2/hw/shop_api/main.py | 133 +++++++++++++++++++++++++++++++++++++- hw2/hw/shop_api/models.py | 49 ++++++++++++++ hw2/hw/shop_api/store.py | 32 +++++++++ 6 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 hw2/hw/shop_api/chat.py create mode 100644 hw2/hw/shop_api/models.py create mode 100644 hw2/hw/shop_api/store.py diff --git a/hw2/hw/README.md b/hw2/hw/README.md index 1449e72..e8fac72 100644 --- a/hw2/hw/README.md +++ b/hw2/hw/README.md @@ -93,10 +93,27 @@ Более подробные детали и требования к работе методов смотрите в тестах. Модификация тестов при потребности допускается (но не смысловая). +Команды ниже выполняются из корня репозитория. Установка зависимостей и запуск: + +```sh +python -m pip install -r hw2/hw/requirements.txt +python -m uvicorn shop_api.main:app --app-dir hw2/hw --reload +``` + +Документация API доступна на http://127.0.0.1:8000/docs. +Товары и корзины хранятся в памяти одного процесса и сбрасываются при перезапуске. +В корзине используются актуальные названия и цены товаров. Удалённые товары +остаются в составе корзины с `available: false`, но не входят в её стоимость. +Фильтры количества учитывают все единицы товаров, включая недоступные. +Фильтрация выполняется до пагинации; границы цены и количества включаются. +`PUT` полностью заменяет существующий товар, в том числе поле `deleted`; +`PATCH` изменяет только переданные `name` и `price`, запрещая `null` и лишние поля. +Для удалённого товара `PATCH` возвращает `304` без тела ответа. + Чтобы запустить тесты только для этого задания вызовите: ```sh -pytest -vv --showlocals --strict ./hw2/test_homework2.py +python -m pytest -vv --showlocals --strict-markers ./hw2/hw/test_homework2.py ``` Если получаете ошибку на подобии `No module named 'shop_api'` @@ -106,6 +123,13 @@ pytest -vv --showlocals --strict ./hw2/test_homework2.py export PYTHONPATH=${PWD}/hw2/hw ``` +В PowerShell: + +```powershell +$env:PYTHONPATH = "$PWD/hw2/hw" +python -m pytest hw2/hw/test_homework2.py -v +``` + ## Доп. Задание - WebSocket (+ доп балл) Реализовать чат для пользователей в отдельных комнатах (в примере один на всех). @@ -121,3 +145,9 @@ export PYTHONPATH=${PWD}/hw2/hw начале в следующем виде: `{username} :: {message}`. Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 + +Дополнительное задание реализовано в `shop_api/chat.py`. Адрес подключения: +`ws://127.0.0.1:8000/chat/{chat_name}`. Отправитель не получает своё сообщение +обратно; пользователи других комнат также его не получают. После отключения +последнего участника комната удаляется. Чаты, как и магазин, работают в одном +процессе; запускать сервер следует с одним worker. diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5..ef5f121 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,6 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 -uvicorn>=0.24.0 +uvicorn[standard]>=0.24.0 # Зависимости для тестирования pytest>=7.4.0 diff --git a/hw2/hw/shop_api/chat.py b/hw2/hw/shop_api/chat.py new file mode 100644 index 0000000..5765558 --- /dev/null +++ b/hw2/hw/shop_api/chat.py @@ -0,0 +1,30 @@ +from uuid import uuid4 + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter() +rooms: dict[str, set[WebSocket]] = {} + + +@router.websocket("/chat/{chat_name}") +async def chat(websocket: WebSocket, chat_name: str) -> None: + await websocket.accept() + username = f"user-{uuid4().hex}" + room = rooms.setdefault(chat_name, set()) + room.add(websocket) + try: + while True: + message = await websocket.receive_text() + for recipient in tuple(room): + if recipient is websocket: + continue + try: + await recipient.send_text(f"{username} :: {message}") + except (WebSocketDisconnect, OSError, RuntimeError): + room.discard(recipient) + except WebSocketDisconnect: + pass + finally: + room.discard(websocket) + if not room and rooms.get(chat_name) is room: + del rooms[chat_name] diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c6..b8508bf 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,134 @@ -from fastapi import FastAPI +from http import HTTPStatus +from typing import Annotated + +from fastapi import FastAPI, HTTPException, Query, Response +from pydantic import NonNegativeInt, PositiveInt + +from .chat import router as chat_router +from .models import Cart, CartCreated, Item, ItemData, ItemPatch +from .store import Store app = FastAPI(title="Shop API") +app.include_router(chat_router) +store = Store() + +PriceFilter = Annotated[float | None, Query(ge=0, allow_inf_nan=False)] +Offset = Annotated[NonNegativeInt, Query()] +Limit = Annotated[PositiveInt, Query()] +QuantityFilter = Annotated[NonNegativeInt | None, Query()] + + +def find_item(item_id: int, include_deleted: bool = False) -> Item: + item = store.items.get(item_id) + if item is None or (item.deleted and not include_deleted): + raise HTTPException(HTTPStatus.NOT_FOUND, "Item not found") + return item + + +def find_cart(cart_id: int) -> dict[int, int]: + if cart_id not in store.carts: + raise HTTPException(HTTPStatus.NOT_FOUND, "Cart not found") + return store.carts[cart_id] + + +@app.post("/cart", status_code=HTTPStatus.CREATED) +async def create_cart(response: Response) -> CartCreated: + cart_id = next(store.cart_ids) + store.carts[cart_id] = {} + response.headers["Location"] = f"/cart/{cart_id}" + return CartCreated(id=cart_id) + + +@app.get("/cart/{cart_id}") +async def get_cart(cart_id: int) -> Cart: + find_cart(cart_id) + return store.get_cart(cart_id) + + +@app.get("/cart") +async def list_carts( + offset: Offset = 0, + limit: Limit = 10, + min_price: PriceFilter = None, + max_price: PriceFilter = None, + min_quantity: QuantityFilter = None, + max_quantity: QuantityFilter = None, +) -> list[Cart]: + carts = [] + for cart_id in store.carts: + cart = store.get_cart(cart_id) + quantity = sum(item.quantity for item in cart.items) + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + if min_quantity is not None and quantity < min_quantity: + continue + if max_quantity is not None and quantity > max_quantity: + continue + carts.append(cart) + return carts[offset : offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int) -> Cart: + cart = find_cart(cart_id) + find_item(item_id) + cart[item_id] = cart.get(item_id, 0) + 1 + return store.get_cart(cart_id) + + +@app.post("/item", status_code=HTTPStatus.CREATED) +async def create_item(data: ItemData, response: Response) -> Item: + item = Item(id=next(store.item_ids), **data.model_dump()) + store.items[item.id] = item + response.headers["Location"] = f"/item/{item.id}" + return item + + +@app.get("/item/{item_id}") +async def get_item(item_id: int) -> Item: + return find_item(item_id) + + +@app.get("/item") +async def list_items( + offset: Offset = 0, + limit: Limit = 10, + min_price: PriceFilter = None, + max_price: PriceFilter = None, + show_deleted: bool = False, +) -> list[Item]: + items = [ + item + for item in store.items.values() + if (show_deleted or not item.deleted) + and (min_price is None or item.price >= min_price) + and (max_price is None or item.price <= max_price) + ] + return items[offset : offset + limit] + + +@app.put("/item/{item_id}") +async def replace_item(item_id: int, data: ItemData) -> Item: + find_item(item_id, include_deleted=True) + item = Item(id=item_id, **data.model_dump()) + store.items[item_id] = item + return item + + +@app.patch("/item/{item_id}", response_model=Item) +async def patch_item(item_id: int, data: ItemPatch) -> Item | Response: + item = find_item(item_id, include_deleted=True) + if item.deleted: + return Response(status_code=HTTPStatus.NOT_MODIFIED) + updated = item.model_copy(update=data.model_dump(exclude_unset=True)) + store.items[item_id] = updated + return updated + + +@app.delete("/item/{item_id}") +async def delete_item(item_id: int) -> Response: + item = find_item(item_id, include_deleted=True) + item.deleted = True + return Response(status_code=HTTPStatus.OK) diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 0000000..e27bd72 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,49 @@ +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +Price = Annotated[float, Field(ge=0, allow_inf_nan=False)] +Name = Annotated[str, Field(min_length=1)] + + +class ItemData(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Name + price: Price + deleted: bool = False + + +class Item(ItemData): + id: int + + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Name | None = None + price: Price | None = None + + @field_validator("name", "price") + @classmethod + def reject_null(cls, value: str | float | None) -> str | float: + if value is None: + raise ValueError("The field must not be null") + return value + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: list[CartItem] + price: float + + +class CartCreated(BaseModel): + id: int diff --git a/hw2/hw/shop_api/store.py b/hw2/hw/shop_api/store.py new file mode 100644 index 0000000..52c4f63 --- /dev/null +++ b/hw2/hw/shop_api/store.py @@ -0,0 +1,32 @@ +from itertools import count +from math import fsum + +from .models import Cart, CartItem, Item + + +class Store: + def __init__(self): + self.items: dict[int, Item] = {} + self.carts: dict[int, dict[int, int]] = {} + self.item_ids = count(1) + self.cart_ids = count(1) + + def get_cart(self, cart_id: int) -> Cart: + quantities = self.carts[cart_id] + return Cart( + id=cart_id, + items=[ + CartItem( + id=item_id, + name=self.items[item_id].name, + quantity=quantity, + available=not self.items[item_id].deleted, + ) + for item_id, quantity in quantities.items() + ], + price=fsum( + self.items[item_id].price * quantity + for item_id, quantity in quantities.items() + if not self.items[item_id].deleted + ), + ) From 19253f563b32188147236aeaf5ad81660330e754 Mon Sep 17 00:00:00 2001 From: cosm0oo Date: Sun, 4 Oct 2026 23:27:43 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20=D1=86=D0=B5=D0=BD=20=D0=B8=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D1=83=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B2=20=D0=B4=D0=B7=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hw2/hw/README.md | 3 +++ hw2/hw/requirements.txt | 3 ++- hw2/hw/shop_api/chat.py | 9 ++++++++- hw2/hw/shop_api/main.py | 25 +++++++++++++++++++------ hw2/hw/shop_api/models.py | 4 ++-- hw2/hw/shop_api/store.py | 37 +++++++++++++++++++++++++++++++------ 6 files changed, 65 insertions(+), 16 deletions(-) diff --git a/hw2/hw/README.md b/hw2/hw/README.md index e8fac72..754cbd5 100644 --- a/hw2/hw/README.md +++ b/hw2/hw/README.md @@ -109,6 +109,9 @@ python -m uvicorn shop_api.main:app --app-dir hw2/hw --reload `PUT` полностью заменяет существующий товар, в том числе поле `deleted`; `PATCH` изменяет только переданные `name` и `price`, запрещая `null` и лишние поля. Для удалённого товара `PATCH` возвращает `304` без тела ответа. +Цена принимает только JSON-числа, без строк и логических значений. +Если добавление товара или изменение его цены/доступности приводит к +переполнению стоимости корзины, операция возвращает `422` без изменения данных. Чтобы запустить тесты только для этого задания вызовите: diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index ef5f121..84bfe8f 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,7 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 -uvicorn[standard]>=0.24.0 +uvicorn>=0.24.0 +websockets>=14.0 # Зависимости для тестирования pytest>=7.4.0 diff --git a/hw2/hw/shop_api/chat.py b/hw2/hw/shop_api/chat.py index 5765558..78f55d8 100644 --- a/hw2/hw/shop_api/chat.py +++ b/hw2/hw/shop_api/chat.py @@ -1,6 +1,7 @@ from uuid import uuid4 from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi.websockets import WebSocketState router = APIRouter() rooms: dict[str, set[WebSocket]] = {} @@ -18,9 +19,15 @@ async def chat(websocket: WebSocket, chat_name: str) -> None: for recipient in tuple(room): if recipient is websocket: continue + if ( + recipient.application_state != WebSocketState.CONNECTED + or recipient.client_state != WebSocketState.CONNECTED + ): + room.discard(recipient) + continue try: await recipient.send_text(f"{username} :: {message}") - except (WebSocketDisconnect, OSError, RuntimeError): + except (WebSocketDisconnect, OSError): room.discard(recipient) except WebSocketDisconnect: pass diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index b8508bf..277fee5 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -31,6 +31,16 @@ def find_cart(cart_id: int) -> dict[int, int]: return store.carts[cart_id] +def save_item(item: Item) -> Item: + try: + store.update_item(item) + except OverflowError as error: + raise HTTPException( + HTTPStatus.UNPROCESSABLE_ENTITY, "Cart price is too large" + ) from error + return item + + @app.post("/cart", status_code=HTTPStatus.CREATED) async def create_cart(response: Response) -> CartCreated: cart_id = next(store.cart_ids) @@ -72,9 +82,14 @@ async def list_carts( @app.post("/cart/{cart_id}/add/{item_id}") async def add_to_cart(cart_id: int, item_id: int) -> Cart: - cart = find_cart(cart_id) + find_cart(cart_id) find_item(item_id) - cart[item_id] = cart.get(item_id, 0) + 1 + try: + store.add_to_cart(cart_id, item_id) + except OverflowError as error: + raise HTTPException( + HTTPStatus.UNPROCESSABLE_ENTITY, "Cart price is too large" + ) from error return store.get_cart(cart_id) @@ -113,8 +128,7 @@ async def list_items( async def replace_item(item_id: int, data: ItemData) -> Item: find_item(item_id, include_deleted=True) item = Item(id=item_id, **data.model_dump()) - store.items[item_id] = item - return item + return save_item(item) @app.patch("/item/{item_id}", response_model=Item) @@ -123,8 +137,7 @@ async def patch_item(item_id: int, data: ItemPatch) -> Item | Response: if item.deleted: return Response(status_code=HTTPStatus.NOT_MODIFIED) updated = item.model_copy(update=data.model_dump(exclude_unset=True)) - store.items[item_id] = updated - return updated + return save_item(updated) @app.delete("/item/{item_id}") diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py index e27bd72..94a1e4e 100644 --- a/hw2/hw/shop_api/models.py +++ b/hw2/hw/shop_api/models.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -Price = Annotated[float, Field(ge=0, allow_inf_nan=False)] +Price = Annotated[float, Field(strict=True, ge=0, allow_inf_nan=False)] Name = Annotated[str, Field(min_length=1)] @@ -42,7 +42,7 @@ class CartItem(BaseModel): class Cart(BaseModel): id: int items: list[CartItem] - price: float + price: Price class CartCreated(BaseModel): diff --git a/hw2/hw/shop_api/store.py b/hw2/hw/shop_api/store.py index 52c4f63..c7e748d 100644 --- a/hw2/hw/shop_api/store.py +++ b/hw2/hw/shop_api/store.py @@ -1,5 +1,5 @@ from itertools import count -from math import fsum +from math import fsum, isfinite from .models import Cart, CartItem, Item @@ -11,6 +11,35 @@ def __init__(self): self.item_ids = count(1) self.cart_ids = count(1) + def cart_price( + self, quantities: dict[int, int], replacement: Item | None = None + ) -> float: + prices = [] + for item_id, quantity in quantities.items(): + item = ( + replacement + if replacement is not None and replacement.id == item_id + else self.items[item_id] + ) + if not item.deleted: + prices.append(item.price * quantity) + price = fsum(prices) + if not isfinite(price): + raise OverflowError("Cart price is too large") + return price + + def add_to_cart(self, cart_id: int, item_id: int) -> None: + quantities = self.carts[cart_id].copy() + quantities[item_id] = quantities.get(item_id, 0) + 1 + self.cart_price(quantities) + self.carts[cart_id] = quantities + + def update_item(self, item: Item) -> None: + for quantities in self.carts.values(): + if item.id in quantities: + self.cart_price(quantities, replacement=item) + self.items[item.id] = item + def get_cart(self, cart_id: int) -> Cart: quantities = self.carts[cart_id] return Cart( @@ -24,9 +53,5 @@ def get_cart(self, cart_id: int) -> Cart: ) for item_id, quantity in quantities.items() ], - price=fsum( - self.items[item_id].price * quantity - for item_id, quantity in quantities.items() - if not self.items[item_id].deleted - ), + price=self.cart_price(quantities), )