From 43c07f0ab767f6a0204990b0a02a0c6b6d43912c Mon Sep 17 00:00:00 2001 From: Vladimir Sverdlov Date: Sun, 4 Oct 2026 22:09:47 +0300 Subject: [PATCH] implement shop api, websocket chat --- hw2/hw/shop_api/api/__init__.py | 0 hw2/hw/shop_api/api/cart/__init__.py | 8 +++ hw2/hw/shop_api/api/cart/contracts.py | 38 +++++++++++ hw2/hw/shop_api/api/cart/routes.py | 63 +++++++++++++++++++ hw2/hw/shop_api/api/chat/__init__.py | 3 + hw2/hw/shop_api/api/chat/routes.py | 28 +++++++++ hw2/hw/shop_api/api/item/__init__.py | 9 +++ hw2/hw/shop_api/api/item/contracts.py | 37 +++++++++++ hw2/hw/shop_api/api/item/routes.py | 59 +++++++++++++++++ hw2/hw/shop_api/main.py | 8 +++ hw2/hw/shop_api/store/__init__.py | 30 +++++++++ hw2/hw/shop_api/store/models.py | 26 ++++++++ hw2/hw/shop_api/store/queries.py | 91 +++++++++++++++++++++++++++ hw2/hw/test_chat.py | 20 ++++++ 14 files changed, 420 insertions(+) create mode 100644 hw2/hw/shop_api/api/__init__.py create mode 100644 hw2/hw/shop_api/api/cart/__init__.py create mode 100644 hw2/hw/shop_api/api/cart/contracts.py create mode 100644 hw2/hw/shop_api/api/cart/routes.py create mode 100644 hw2/hw/shop_api/api/chat/__init__.py create mode 100644 hw2/hw/shop_api/api/chat/routes.py create mode 100644 hw2/hw/shop_api/api/item/__init__.py create mode 100644 hw2/hw/shop_api/api/item/contracts.py create mode 100644 hw2/hw/shop_api/api/item/routes.py create mode 100644 hw2/hw/shop_api/store/__init__.py create mode 100644 hw2/hw/shop_api/store/models.py create mode 100644 hw2/hw/shop_api/store/queries.py create mode 100644 hw2/hw/test_chat.py diff --git a/hw2/hw/shop_api/api/__init__.py b/hw2/hw/shop_api/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hw2/hw/shop_api/api/cart/__init__.py b/hw2/hw/shop_api/api/cart/__init__.py new file mode 100644 index 0000000..a7e82ff --- /dev/null +++ b/hw2/hw/shop_api/api/cart/__init__.py @@ -0,0 +1,8 @@ +from .contracts import CartItemResponse, CartResponse +from .routes import router + +__all__ = [ + 'CartItemResponse', + 'CartResponse', + 'router', +] diff --git a/hw2/hw/shop_api/api/cart/contracts.py b/hw2/hw/shop_api/api/cart/contracts.py new file mode 100644 index 0000000..c18e7e4 --- /dev/null +++ b/hw2/hw/shop_api/api/cart/contracts.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel + +from shop_api import store +from shop_api.store import CartEntity + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartResponse(BaseModel): + id: int + items: list[CartItemResponse] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> 'CartResponse': + items = [] + price = 0.0 + + for item_id, quantity in entity.items.items(): + item = store.get_item(item_id) + if item is None: + continue + items.append( + CartItemResponse( + id=item_id, + name=item.info.name, + quantity=quantity, + available=not item.info.deleted, + ) + ) + price += item.info.price * quantity + + return CartResponse(id=entity.id, items=items, price=price) diff --git a/hw2/hw/shop_api/api/cart/routes.py b/hw2/hw/shop_api/api/cart/routes.py new file mode 100644 index 0000000..f97eb08 --- /dev/null +++ b/hw2/hw/shop_api/api/cart/routes.py @@ -0,0 +1,63 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api import store + +from .contracts import CartResponse + +router = APIRouter(prefix='/cart') + + +@router.post('', status_code=HTTPStatus.CREATED) +async def create_cart(response: Response) -> dict[str, int]: + entity = store.add_cart() + response.headers['location'] = f'/cart/{entity.id}' + return {'id': entity.id} + + +@router.get('') +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat | None, Query()] = None, + max_price: Annotated[NonNegativeFloat | None, Query()] = None, + min_quantity: Annotated[NonNegativeInt | None, Query()] = None, + max_quantity: Annotated[NonNegativeInt | None, Query()] = None, +) -> list[CartResponse]: + result = [] + + for entity in store.get_carts(): + cart = CartResponse.from_entity(entity) + quantity = sum(entity.items.values()) + + 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 + + result.append(cart) + + return result[offset:offset + limit] + + +@router.get('/{cart_id}') +async def get_cart(cart_id: int) -> CartResponse: + entity = store.get_cart(cart_id) + if entity is None: + raise HTTPException(HTTPStatus.NOT_FOUND, 'cart not found') + return CartResponse.from_entity(entity) + + +@router.post('/{cart_id}/add/{item_id}') +async def add_to_cart(cart_id: int, item_id: int) -> CartResponse: + entity = store.add_to_cart(cart_id, item_id) + if entity is None: + raise HTTPException(HTTPStatus.NOT_FOUND, 'cart or item not found') + return CartResponse.from_entity(entity) diff --git a/hw2/hw/shop_api/api/chat/__init__.py b/hw2/hw/shop_api/api/chat/__init__.py new file mode 100644 index 0000000..a6ba164 --- /dev/null +++ b/hw2/hw/shop_api/api/chat/__init__.py @@ -0,0 +1,3 @@ +from .routes import router + +__all__ = ['router'] diff --git a/hw2/hw/shop_api/api/chat/routes.py b/hw2/hw/shop_api/api/chat/routes.py new file mode 100644 index 0000000..a64d1cd --- /dev/null +++ b/hw2/hw/shop_api/api/chat/routes.py @@ -0,0 +1,28 @@ +from uuid import uuid4 + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter() + +chats: dict[str, list[WebSocket]] = {} + + +@router.websocket('/chat/{chat_name}') +async def chat(ws: WebSocket, chat_name: str): + await ws.accept() + username = str(uuid4()) + + if chat_name not in chats: + chats[chat_name] = [] + chats[chat_name].append(ws) + + try: + while True: + message = await ws.receive_text() + text = f'{username} :: {message}' + for client in chats[chat_name]: + await client.send_text(text) + except WebSocketDisconnect: + chats[chat_name].remove(ws) + if not chats[chat_name]: + del chats[chat_name] diff --git a/hw2/hw/shop_api/api/item/__init__.py b/hw2/hw/shop_api/api/item/__init__.py new file mode 100644 index 0000000..97e31c5 --- /dev/null +++ b/hw2/hw/shop_api/api/item/__init__.py @@ -0,0 +1,9 @@ +from .contracts import ItemRequest, ItemResponse, PatchItemRequest +from .routes import router + +__all__ = [ + 'ItemRequest', + 'ItemResponse', + 'PatchItemRequest', + 'router', +] diff --git a/hw2/hw/shop_api/api/item/contracts.py b/hw2/hw/shop_api/api/item/contracts.py new file mode 100644 index 0000000..f87803d --- /dev/null +++ b/hw2/hw/shop_api/api/item/contracts.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel, ConfigDict + +from shop_api.store import ItemEntity, ItemInfo, PatchItemInfo + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> 'ItemResponse': + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str + price: float + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price) + + +class PatchItemRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + + name: str | None = None + price: float | None = None + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) diff --git a/hw2/hw/shop_api/api/item/routes.py b/hw2/hw/shop_api/api/item/routes.py new file mode 100644 index 0000000..002cf83 --- /dev/null +++ b/hw2/hw/shop_api/api/item/routes.py @@ -0,0 +1,59 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api import store + +from .contracts import ItemRequest, ItemResponse, PatchItemRequest + +router = APIRouter(prefix='/item') + + +@router.post('', status_code=HTTPStatus.CREATED) +async def create_item(body: ItemRequest) -> ItemResponse: + entity = store.add_item(body.as_item_info()) + return ItemResponse.from_entity(entity) + + +@router.get('') +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat | None, Query()] = None, + max_price: Annotated[NonNegativeFloat | None, Query()] = None, + show_deleted: Annotated[bool, Query()] = False, +) -> list[ItemResponse]: + entities = store.get_items(offset, limit, min_price, max_price, show_deleted) + return [ItemResponse.from_entity(e) for e in entities] + + +@router.get('/{item_id}') +async def get_item(item_id: int) -> ItemResponse: + entity = store.get_item(item_id) + if entity is None or entity.info.deleted: + raise HTTPException(HTTPStatus.NOT_FOUND, 'item not found') + return ItemResponse.from_entity(entity) + + +@router.put('/{item_id}') +async def put_item(item_id: int, body: ItemRequest) -> ItemResponse: + entity = store.update_item(item_id, body.name, body.price) + if entity is None: + raise HTTPException(HTTPStatus.NOT_FOUND, 'item not found') + return ItemResponse.from_entity(entity) + + +@router.patch('/{item_id}', response_model=None) +async def patch_item(item_id: int, body: PatchItemRequest): + entity = store.patch_item(item_id, body.as_patch_item_info()) + if entity is None: + return Response(status_code=HTTPStatus.NOT_MODIFIED) + return ItemResponse.from_entity(entity) + + +@router.delete('/{item_id}') +async def delete_item(item_id: int) -> Response: + store.delete_item(item_id) + return Response(status_code=HTTPStatus.OK) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c6..17a73b8 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,11 @@ from fastapi import FastAPI +from shop_api.api.cart import router as cart_router +from shop_api.api.chat import router as chat_router +from shop_api.api.item import router as item_router + app = FastAPI(title="Shop API") + +app.include_router(cart_router) +app.include_router(item_router) +app.include_router(chat_router) diff --git a/hw2/hw/shop_api/store/__init__.py b/hw2/hw/shop_api/store/__init__.py new file mode 100644 index 0000000..d73b483 --- /dev/null +++ b/hw2/hw/shop_api/store/__init__.py @@ -0,0 +1,30 @@ +from .models import CartEntity, ItemEntity, ItemInfo, PatchItemInfo +from .queries import ( + add_cart, + add_item, + add_to_cart, + delete_item, + get_cart, + get_carts, + get_item, + get_items, + patch_item, + update_item, +) + +__all__ = [ + 'CartEntity', + 'ItemEntity', + 'ItemInfo', + 'PatchItemInfo', + 'add_cart', + 'add_item', + 'add_to_cart', + 'delete_item', + 'get_cart', + 'get_carts', + 'get_item', + 'get_items', + 'patch_item', + 'update_item', +] diff --git a/hw2/hw/shop_api/store/models.py b/hw2/hw/shop_api/store/models.py new file mode 100644 index 0000000..c2d79e5 --- /dev/null +++ b/hw2/hw/shop_api/store/models.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool = False + + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None + + +@dataclass(slots=True) +class CartEntity: + id: int + items: dict[int, int] = field(default_factory=dict) diff --git a/hw2/hw/shop_api/store/queries.py b/hw2/hw/shop_api/store/queries.py new file mode 100644 index 0000000..a926ba8 --- /dev/null +++ b/hw2/hw/shop_api/store/queries.py @@ -0,0 +1,91 @@ +from typing import Iterable + +from shop_api.store.models import CartEntity, ItemEntity, ItemInfo, PatchItemInfo + +_items = dict[int, ItemInfo]() +_carts = dict[int, dict[int, int]]() +_item_id = 0 +_cart_id = 0 + + +def add_item(info: ItemInfo) -> ItemEntity: + global _item_id + _item_id += 1 + _items[_item_id] = info + return ItemEntity(_item_id, info) + + +def get_item(item_id: int) -> ItemEntity | None: + if item_id not in _items: + return None + return ItemEntity(item_id, _items[item_id]) + + +def get_items( + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + show_deleted: bool = False, +) -> list[ItemEntity]: + result = [] + for item_id, info in _items.items(): + if not show_deleted and info.deleted: + continue + if min_price is not None and info.price < min_price: + continue + if max_price is not None and info.price > max_price: + continue + result.append(ItemEntity(item_id, info)) + return result[offset:offset + limit] + + +def update_item(item_id: int, name: str, price: float) -> ItemEntity | None: + if item_id not in _items: + return None + _items[item_id].name = name + _items[item_id].price = price + return ItemEntity(item_id, _items[item_id]) + + +def patch_item(item_id: int, patch: PatchItemInfo) -> ItemEntity | None: + if item_id not in _items or _items[item_id].deleted: + return None + if patch.name is not None: + _items[item_id].name = patch.name + if patch.price is not None: + _items[item_id].price = patch.price + return ItemEntity(item_id, _items[item_id]) + + +def delete_item(item_id: int) -> None: + if item_id in _items: + _items[item_id].deleted = True + + +def add_cart() -> CartEntity: + global _cart_id + _cart_id += 1 + _carts[_cart_id] = {} + return CartEntity(_cart_id, _carts[_cart_id]) + + +def get_cart(cart_id: int) -> CartEntity | None: + if cart_id not in _carts: + return None + return CartEntity(cart_id, _carts[cart_id]) + + +def get_carts() -> Iterable[CartEntity]: + for cart_id, items in _carts.items(): + yield CartEntity(cart_id, items) + + +def add_to_cart(cart_id: int, item_id: int) -> CartEntity | None: + if cart_id not in _carts or item_id not in _items: + return None + if item_id in _carts[cart_id]: + _carts[cart_id][item_id] += 1 + else: + _carts[cart_id][item_id] = 1 + return CartEntity(cart_id, _carts[cart_id]) diff --git a/hw2/hw/test_chat.py b/hw2/hw/test_chat.py new file mode 100644 index 0000000..85ab41a --- /dev/null +++ b/hw2/hw/test_chat.py @@ -0,0 +1,20 @@ +from fastapi.testclient import TestClient + +from shop_api.main import app + +client = TestClient(app) + + +def test_chat_websocket() -> None: + with client.websocket_connect('/chat/room-1') as ws1: + with client.websocket_connect('/chat/room-1') as ws2: + with client.websocket_connect('/chat/room-2') as ws3: + ws1.send_text('msg room 1') + msg1 = ws1.receive_text() + msg2 = ws2.receive_text() + ws3.send_text('msg room 2') + msg3 = ws3.receive_text() + + assert msg1 == msg2 + assert ' :: msg room 1' in msg1 + assert ' :: msg room 2' in msg3