Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion hw2/hw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`
Expand All @@ -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 (+ доп балл)

Реализовать чат для пользователей в отдельных комнатах (в примере один на всех).
Expand All @@ -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.
1 change: 1 addition & 0 deletions hw2/hw/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Основные зависимости для ASGI приложения
fastapi>=0.117.1
uvicorn>=0.24.0
websockets>=14.0

# Зависимости для тестирования
pytest>=7.4.0
Expand Down
37 changes: 37 additions & 0 deletions hw2/hw/shop_api/chat.py
Original file line number Diff line number Diff line change
@@ -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]
146 changes: 145 additions & 1 deletion hw2/hw/shop_api/main.py
Original file line number Diff line number Diff line change
@@ -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)
49 changes: 49 additions & 0 deletions hw2/hw/shop_api/models.py
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions hw2/hw/shop_api/store.py
Original file line number Diff line number Diff line change
@@ -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),
)
Loading