diff --git a/hw2/hw/README.md b/hw2/hw/README.md index 1449e72..754cbd5 100644 --- a/hw2/hw/README.md +++ b/hw2/hw/README.md @@ -93,10 +93,30 @@ Более подробные детали и требования к работе методов смотрите в тестах. Модификация тестов при потребности допускается (но не смысловая). +Команды ниже выполняются из корня репозитория. Установка зависимостей и запуск: + +```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` без тела ответа. +Цена принимает только JSON-числа, без строк и логических значений. +Если добавление товара или изменение его цены/доступности приводит к +переполнению стоимости корзины, операция возвращает `422` без изменения данных. + Чтобы запустить тесты только для этого задания вызовите: ```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 +126,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 +148,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..84bfe8f 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,7 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 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 new file mode 100644 index 0000000..78f55d8 --- /dev/null +++ b/hw2/hw/shop_api/chat.py @@ -0,0 +1,37 @@ +from uuid import uuid4 + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi.websockets import WebSocketState + +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 + 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): + 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..277fee5 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,147 @@ -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] + + +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) + 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: + find_cart(cart_id) + find_item(item_id) + 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) + + +@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()) + return save_item(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)) + return save_item(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..94a1e4e --- /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(strict=True, 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: Price + + +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..c7e748d --- /dev/null +++ b/hw2/hw/shop_api/store.py @@ -0,0 +1,57 @@ +from itertools import count +from math import fsum, isfinite + +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 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( + 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=self.cart_price(quantities), + )