diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 00000000..1094edf8 --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,39 @@ +name: "HW5 Tests" + +on: + pull_request: + branches: [ main ] + paths: [ 'hw5_own/**' ] + push: + branches: [ main ] + paths: [ 'hw5_own/**' ] + +jobs: + test-hw2: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: hw5_own + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + working-directory: hw5_own + env: + PYTHONPATH: ${{ github.workspace }}/hw5_own + run: | + pytest test_homework5.py -v + pytest test_websocket_chat.py -v diff --git a/hw1/app.py b/hw1/app.py index 6107b870..be3a76c5 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,100 @@ from typing import Any, Awaitable, Callable +import json # https://docs.python.org/3/library/json.html - это стандартная библиотека в составе python (я не хочу делать алгос по десериализации JSON) + + +# Числовые коды (если нельзя использовать http.HTTPStatus) +OK, BAD_REQUEST, NOT_FOUND, UNPROC = 200, 400, 404, 422 + + +# Специальные исключения +class ENotFound(Exception): ... + + +class EBadRequest(Exception): ... + + +# --- helpers & math ----------------------------------------------------------- +def read_query(qs: bytes) -> dict[str, str]: + """Вместо urllib вот такая заглушка""" + if not qs: + return {} + out: dict[str, str] = {} + s = qs.decode() + for pair in s.split("&"): + if not pair: + continue + k, v = (pair.split("=", 1) + [""])[:2] + out[k] = v + return out + + +def factorial(n: int) -> int: + """Факториал""" + r = 1 + for i in range(2, n + 1): + r *= i + return r + + +def fibonacci(n: int) -> int: + """Фибоначчи""" + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a + + +def mean(nums: list[float]) -> float: + """Среднее""" + return sum(nums) / len(nums) + + +async def read_body(receive: Callable[[], Awaitable[dict[str, Any]]]) -> bytes: + """ + Args: + receive: Корутина для получения сообщений от клиента + """ + body = b"" + while True: + msg = await receive() + if msg["type"] == "http.request": + body += msg.get("body", b"") + if not msg.get("more_body"): + break + else: + break + return body + + +async def respond( + send: Callable[[dict[str, Any]], Awaitable[None]], + status: int, + data: Any | None = None, +): + """ + Args: + send: Словарь с информацией о запросе + status: статус код + data: любая инфа, которую в байтах хотим закинуть + """ + if data is None: + headers = [(b"content-type", b"text/plain"), (b"content-length", b"0")] + await send( + {"type": "http.response.start", "status": status, "headers": headers} + ) + await send({"type": "http.response.body", "body": b""}) + else: + body = json.dumps({"result": data}).encode() + headers = [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ] + await send( + {"type": "http.response.start", "status": status, "headers": headers} + ) + await send({"type": "http.response.body", "body": body}) + async def application( scope: dict[str, Any], @@ -12,8 +107,72 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + # По поводу lifespan: https://www.youtube.com/watch?v=VJ963Z6lsQ4 + if scope["type"] == "lifespan": + while True: + message = await receive() + t = message.get("type") + if t == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif t == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + return + + if scope["type"] != "http": + return + + method, path = scope["method"], scope["path"] + body = await read_body(receive) + + try: + if method != "GET": + raise ENotFound() + + # /factorial?n=... + if path == "/factorial": + n_raw = read_query(scope.get("query_string", b"")).get("n") + if n_raw is None: + raise ValueError("missing n") # -> 422 + n = int(n_raw) # ValueError -> 422 + if n < 0: + raise EBadRequest() # -> 400 + return await respond(send, OK, factorial(n)) + + # /fibonacci/{n} + if path.startswith("/fibonacci/"): + tail = path.split("/", 2)[2] + n = int(tail) # ValueError -> 422 + if n < 0: + raise EBadRequest() # -> 400 + return await respond(send, OK, fibonacci(n)) + + # /mean (GET body = JSON array) + if path == "/mean": + if not body: + raise ValueError("empty body") # -> 422 + data = json.loads(body.decode()) # JSONDecodeError -> 422 + if not isinstance(data, list): + raise ValueError("not a list") # -> 422 + if not data: + raise EBadRequest() # -> 400 + nums = [float(x) for x in data] # ValueError/TypeError -> 422 + return await respond(send, OK, mean(nums)) + + raise ENotFound() + + except ENotFound: + return await respond(send, NOT_FOUND) + except EBadRequest: + return await respond(send, BAD_REQUEST) + except (ValueError, TypeError, json.JSONDecodeError): + return await respond(send, UNPROC) + except Exception: + # На всякий случай — считаем прочие сбои как 400 + return await respond(send, BAD_REQUEST) + if __name__ == "__main__": import uvicorn + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) diff --git a/hw2/hw/requirements_with_websockets.txt b/hw2/hw/requirements_with_websockets.txt new file mode 100644 index 00000000..de0e8590 --- /dev/null +++ b/hw2/hw/requirements_with_websockets.txt @@ -0,0 +1,10 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 +websockets>=15.0.1 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 diff --git a/hw2/hw/shop_api/core/schemas.py b/hw2/hw/shop_api/core/schemas.py new file mode 100644 index 00000000..f7547e01 --- /dev/null +++ b/hw2/hw/shop_api/core/schemas.py @@ -0,0 +1,49 @@ +# from __future__ import annotations +from typing import Optional, Annotated +from pydantic import BaseModel, Field, ConfigDict + +ItemName = Annotated[str, Field(description="Наименование товара", min_length=1)] +ItemId = Annotated[int, Field(description="Идентификатор корзины", ge=0)] +ItemPrice = Annotated[float, Field(description="Цена товара", ge=0)] + +CartId = Annotated[int, Field(description="Идентификатор корзины")] + + +# ---- Item ---- +class ItemOut(BaseModel): + id: ItemId + name: ItemName + price: ItemPrice + deleted: bool = Field(description="Удален ли товар", default=False) + + +class ItemCreate(BaseModel): + name: ItemName + price: ItemPrice + + +class ItemPut(BaseModel): + name: ItemName + price: ItemPrice + + +class ItemPatch(BaseModel): + # Разрешаем частичное обновление ТОЛЬКО name/price; лишние поля → 422 + model_config = ConfigDict(extra="forbid") + + name: Optional[ItemName] = None + price: Optional[ItemPrice] = None + + +# ---- Cart ---- +class CartItemView(BaseModel): + id: ItemId + name: ItemName + quantity: int = Field(description="Количество товара в корзине", ge=0) + available: bool = Field(description="Доступен ли товар") + + +class CartView(BaseModel): + id: int + items: list[CartItemView] + price: float = Field(description="Общая сумма заказа", ge=0.0) diff --git a/hw2/hw/shop_api/core/storage.py b/hw2/hw/shop_api/core/storage.py new file mode 100644 index 00000000..bc3c6cb9 --- /dev/null +++ b/hw2/hw/shop_api/core/storage.py @@ -0,0 +1,142 @@ +from __future__ import annotations +from threading import ( + RLock, +) # стандартная библиотека Python: https://docs.python.org/3/library/threading.html +from typing import Optional +from fastapi import HTTPException, status +from .schemas import ( + ItemOut, + CartItemView, + CartView, + ItemCreate, + ItemPut, + ItemPatch, +) + +# ------------------------- +# In-memory хранилище + блокировки +# ------------------------- +_items_lock = RLock() +_carts_lock = RLock() + +_items: dict[int, ItemOut] = {} +_next_item_id = 1 + +# cart_id -> { item_id -> quantity } +_carts: dict[int, dict[int, int]] = {} +_next_cart_id = 1 + + +def new_item_id() -> int: + global _next_item_id + with _items_lock: + nid = _next_item_id + _next_item_id += 1 + return nid + + +def new_cart_id() -> int: + global _next_cart_id + with _carts_lock: + nid = _next_cart_id + _next_cart_id += 1 + return nid + + +def get_item_or_404(item_id: int) -> ItemOut: + with _items_lock: + item = _items.get(item_id) + if item is None or item.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + return item + + +def get_item_soft(item_id: int) -> Optional[ItemOut]: + with _items_lock: + return _items.get(item_id) + + +def cart_or_404(cart_id: int) -> dict[int, int]: + with _carts_lock: + cart = _carts.get(cart_id) + if cart is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found" + ) + return cart + + +def build_cart_view(cart_id: int) -> CartView: + with _carts_lock: + cart = _carts.get(cart_id, {}) + kv = list(cart.items()) + + items = [] + total = 0.0 + for item_id, qty in kv: + item = get_item_soft(item_id) + name = item.name if item else f"item:{item_id}" + available = bool(item and not item.deleted) + items.append( + CartItemView(id=item_id, name=name, quantity=qty, available=available) + ) + if available: + total += item.price * qty + + return CartView(id=cart_id, items=items, price=total) + + +def create_item(data: ItemCreate) -> ItemOut: + item_id = new_item_id() + item = ItemOut(id=item_id, name=data.name, price=data.price, deleted=False) + with _items_lock: + _items[item_id] = item + return item + + +def put_item(item_id: int, data: ItemPut) -> ItemOut: + with _items_lock: + existing = _items.get(item_id) + if existing is None or existing.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + existing.name = data.name + existing.price = data.price + return existing + + +def patch_item(item_id: int, data: ItemPatch) -> ItemOut: + with _items_lock: + existing = _items.get(item_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + if existing.deleted: + # Пробрасываем семантику 304 на верхний уровень + raise HTTPException( + status_code=status.HTTP_304_NOT_MODIFIED, detail="Item deleted" + ) + + if data.name is not None: + existing.name = data.name + if data.price is not None: + if data.price < 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid price", + ) + existing.price = data.price + + return existing + + +def delete_item(item_id: int) -> dict: + with _items_lock: + existing = _items.get(item_id) + if existing is not None: + existing.deleted = True + return {"ok": True} diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..fdf6ed68 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,16 @@ from fastapi import FastAPI +from shop_api.routers.item import router as item +from shop_api.routers.cart import router as cart +from shop_api.routers.chat import router as chat app = FastAPI(title="Shop API") + +app.include_router(item) +app.include_router(cart) + +app.include_router(chat) + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, port=8001) diff --git a/hw2/hw/shop_api/routers/cart.py b/hw2/hw/shop_api/routers/cart.py new file mode 100644 index 00000000..9e040b6f --- /dev/null +++ b/hw2/hw/shop_api/routers/cart.py @@ -0,0 +1,99 @@ +from typing import List, Optional +from fastapi import APIRouter, Query, Response, status + +from shop_api.core.schemas import CartView +from shop_api.core.storage import ( + _carts, + _carts_lock, + new_cart_id, + cart_or_404, + build_cart_view, + get_item_or_404, +) + +router = APIRouter(prefix="/cart", tags=["cart"]) + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_cart(response: Response): + """ + POST /cart — RPC: создаёт пустую корзину, тело не принимает. + Возвращает 201 и JSON {"id": }, а также заголовок Location: /cart/{id}. + """ + cart_id = new_cart_id() + with _carts_lock: + _carts[cart_id] = {} + response.headers["Location"] = f"/cart/{cart_id}" + return {"id": cart_id} + + +@router.get("/{cart_id}", response_model=CartView) +def get_cart(cart_id: int) -> CartView: + """ + GET /cart/{id} — получить корзину по id. + """ + cart_or_404(cart_id) + return build_cart_view(cart_id) + + +@router.get("", response_model=List[CartView]) +def list_carts( + offset: int = Query(0, ge=0, description="Смещение по списку (offset)"), + limit: int = Query(10, gt=0, description="Лимит количества (limit)"), + min_price: Optional[float] = Query( + None, ge=0.0, description="Мин. сумма корзины (включительно)" + ), + max_price: Optional[float] = Query( + None, ge=0.0, description="Макс. сумма корзины (включительно)" + ), + min_quantity: Optional[int] = Query( + None, ge=0, description="Мин. общее число товаров (включительно)" + ), + max_quantity: Optional[int] = Query( + None, ge=0, description="Макс. общее число товаров (включительно)" + ), +) -> List[CartView]: + """ + GET /cart — список корзин с фильтрами и пагинацией. + + Фильтры: + • min_price/max_price — по суммарной стоимости корзины (включительно); + • min_quantity/max_quantity — по суммарному количеству позиций в корзине (включительно). + Порядок: фильтрация -> offset/limit. + """ + with _carts_lock: + ids = list(_carts.keys()) + + views: List[CartView] = [] + for cid in ids: + v = build_cart_view(cid) + + if min_price is not None and v.price < min_price: + continue + if max_price is not None and v.price > max_price: + continue + + qsum = sum(it.quantity for it in v.items) + if min_quantity is not None and qsum < min_quantity: + continue + if max_quantity is not None and qsum > max_quantity: + continue + + views.append(v) + + return views[offset : offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +def add_to_cart(cart_id: int, item_id: int): + """ + POST /cart/{cart_id}/add/{item_id} — добавить товар в корзину. + Если товар уже есть — увеличивает его количество. + """ + cart = cart_or_404(cart_id) + get_item_or_404(item_id) # проверка на товар + + with _carts_lock: + cart[item_id] = cart.get(item_id, 0) + 1 + + return {"ok": True} diff --git a/hw2/hw/shop_api/routers/chat.py b/hw2/hw/shop_api/routers/chat.py new file mode 100644 index 00000000..6c3e1d5e --- /dev/null +++ b/hw2/hw/shop_api/routers/chat.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +import secrets # стандартная библиотека: https://docs.python.org/3/library/secrets.html +from collections import defaultdict + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter(tags=["chat"]) + + +class RoomManager: + # Решил реализовать в одном файле систему чатов, чтобы проще было проверять + def __init__(self) -> None: + # room -> set of websockets + self.rooms: dict[str, set[WebSocket]] = defaultdict(set) + # websocket -> username + self.usernames: dict[WebSocket, str] = {} + self._lock = asyncio.Lock() + + @staticmethod + def _gen_username() -> str: + # короткий случайный ник + return f"user-{secrets.token_hex(2)}" + + async def connect(self, room: str, ws: WebSocket) -> str: + await ws.accept() + username = self._gen_username() + async with self._lock: + self.rooms[room].add(ws) + self.usernames[ws] = username + await ws.send_text(f"[system] :: your_name = {username}") + return username + + async def disconnect(self, room: str, ws: WebSocket) -> None: + async with self._lock: + self.rooms[room].discard(ws) + self.usernames.pop(ws, None) + if not self.rooms[room]: + self.rooms.pop(room, None) + + async def broadcast( + self, room: str, text: str, sender: WebSocket | None = None + ) -> None: + async with self._lock: + targets = list(self.rooms.get(room, ())) + for ws in targets: + if ws is sender: + continue + try: + await ws.send_text(text) + except Exception: + try: + await self.disconnect(room, ws) + except Exception: + pass + + +manager = RoomManager() + + +@router.websocket("/chat/{chat_name}") +async def chat_ws(websocket: WebSocket, chat_name: str): + """ + Пользователи, подключённые к одному chat_name, получают сообщения друг друга. + Формат сообщения: "{username} :: {message}". + """ + username = await manager.connect(chat_name, websocket) + try: + while True: + # Ждём текст от клиента + msg = await websocket.receive_text() + # Бродкастим другим пользователям в комнате (без эха отправителю) + await manager.broadcast(chat_name, f"{username} :: {msg}", sender=websocket) + except WebSocketDisconnect: + await manager.disconnect(chat_name, websocket) + except Exception: + # Любая иная ошибка закрывает сокет и удаляет из комнаты + await manager.disconnect(chat_name, websocket) + try: + await websocket.close() + except Exception: + pass diff --git a/hw2/hw/shop_api/routers/item.py b/hw2/hw/shop_api/routers/item.py new file mode 100644 index 00000000..acc511f6 --- /dev/null +++ b/hw2/hw/shop_api/routers/item.py @@ -0,0 +1,107 @@ +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Query, Response, status + +from shop_api.core.schemas import ItemOut, ItemCreate, ItemPut, ItemPatch +from shop_api.core.storage import _items, _items_lock, get_item_or_404, new_item_id + +router = APIRouter(prefix="/item", tags=["items"]) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=ItemOut) +def create_item(body: ItemCreate) -> ItemOut: + """ + POST /item - добавление нового товара + """ + item_id = new_item_id() + obj = ItemOut(id=item_id, name=body.name, price=body.price, deleted=False) + with _items_lock: + _items[item_id] = obj + return obj + + +@router.get("/{item_id}", response_model=ItemOut) +def get_item(item_id: int) -> ItemOut: + """ + GET /item/{id} - получение товара по id + """ + return get_item_or_404(item_id) + + +@router.get("", response_model=List[ItemOut]) +def list_items( + offset: int = Query(0, ge=0, description="Смещение (offset)"), + limit: int = Query(10, gt=0, description="Количество (limit)"), + min_price: Optional[float] = Query(None, ge=0, description="Мин. цена"), + max_price: Optional[float] = Query(None, ge=0, description="Макс. цена"), + show_deleted: bool = Query(False, description="Показывать ли удалённые"), +) -> List[ItemOut]: + """ + GET /item - получение списка товаров с фильтрами и пагинацией + """ + with _items_lock: + items = list(_items.values()) + + if not show_deleted: + items = [i for i in items if not i.deleted] + if min_price is not None: + items = [i for i in items if i.price >= min_price] + if max_price is not None: + items = [i for i in items if i.price <= max_price] + + return items[offset : offset + limit] + + +@router.put("/{item_id}", response_model=ItemOut) +def put_item(item_id: int, body: ItemPut) -> ItemOut: + """ + PUT /item/{id} - замена товара по id (создание запрещено) + """ + with _items_lock: + existing = _items.get(item_id) + if existing is None or existing.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + existing.name = body.name + existing.price = body.price + return existing + + +@router.patch("/{item_id}", response_model=ItemOut) +def patch_item(item_id: int, body: ItemPatch): + """ + PATCH /item/{id} - частичное обновление (разрешено менять всё кроме deleted) + Если товар удалён — 304 Not Modified. + """ + with _items_lock: + existing = _items.get(item_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + if existing.deleted: + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + + if body.name is not None: + existing.name = body.name + if body.price is not None: + if body.price < 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid price", + ) + existing.price = body.price + + return existing + + +@router.delete("/{item_id}") +def delete_item(item_id: int): + """ + DELETE /item/{id} - мягкое удаление (deleted=True), идемпотентно + """ + with _items_lock: + existing = _items.get(item_id) + if existing is not None: + existing.deleted = True + return {"ok": True} diff --git a/hw2/hw/test_websocket_chat.py b/hw2/hw/test_websocket_chat.py new file mode 100644 index 00000000..248e7625 --- /dev/null +++ b/hw2/hw/test_websocket_chat.py @@ -0,0 +1,60 @@ +import re +from fastapi.testclient import TestClient + +from shop_api.main import app + +USERNAME_RE = re.compile(r"^\[system\] :: your_name = (user-[0-9a-f]{4})$") + + +def _extract_username(system_msg: str) -> str: + """ + Из приветственного сообщения вида: + "[system] :: your_name = user-ab12" + достаём "user-ab12". + """ + m = USERNAME_RE.match(system_msg) + assert m, f"unexpected system greeting format: {system_msg!r}" + return m.group(1) + + +def test_websocket_broadcast_and_format(): + client = TestClient(app) + + # Подключаем двух пользователей к одной комнате и третьего — к другой + with ( + client.websocket_connect("/chat/room1") as ws1, + client.websocket_connect("/chat/room1") as ws2, + client.websocket_connect("/chat/room2") as ws3, + ): + # Приветственные сообщения с именами + u1 = _extract_username(ws1.receive_text()) + u2 = _extract_username(ws2.receive_text()) + u3 = _extract_username(ws3.receive_text()) + assert u1 != u2 and u1 != u3 and u2 != u3 # имена случайные и разные + + # user1 -> room1 + msg1 = "hello from 1" + ws1.send_text(msg1) + + # Должно прийти ТОЛЬКО второму участнику той же комнаты + got2 = ws2.receive_text() + assert got2 == f"{u1} :: {msg1}" + + # user3 -> room2 (не должен мешать room1) + ws3.send_text("ping from 3") + + # user2 -> room1 + msg2 = "hi from 2" + ws2.send_text(msg2) + + # Должно прийти первому участнику room1 + got1 = ws1.receive_text() + assert got1 == f"{u2} :: {msg2}" + + +def test_websocket_username_greeting_format(): + client = TestClient(app) + with client.websocket_connect("/chat/any-room") as ws: + greet = ws.receive_text() + # Проверяем точный формат приветствия и шаблон username + assert USERNAME_RE.match(greet), f"bad greeting: {greet!r}" diff --git a/hw3_own/Dockerfile b/hw3_own/Dockerfile new file mode 100644 index 00000000..2e62a48a --- /dev/null +++ b/hw3_own/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /api + +RUN apt-get update -y + +COPY requirements_hw3.txt requirements.txt + +RUN pip install -r requirements.txt + +COPY . . + +ENTRYPOINT ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/hw3_own/README.md b/hw3_own/README.md new file mode 100644 index 00000000..2c222cb8 --- /dev/null +++ b/hw3_own/README.md @@ -0,0 +1,25 @@ +# ДЗ 3 + +## Задани - настроить сборку образов Docker и мониторинг с помощью Prometheus и Grafana + +Интегрировать Docker с Prometheus и Grafana в любой уже написанный в ДЗ сервис (по аналогии с тем, как в репе) + +По сути, если вы выполнили вторую домашку, то теперь для неё надо написать Dockerfile и настроить мониторинг. Если вторую домашку вы не делали, то можно взять сервис из [rest_example](../hw2/rest_example/main.py) + +Сдача через PR, так же нужно: + +1) Dockerfile для сборки сервиса +2) docker-compose.yml для локального разворачивания в Docker +3) Приложить скрин с парой Дашбордов в Grafana + +## Комментарий к решению + +1) Визуализации для реализованных дашбордов находятся в директории [docs/](docs/) +2) Для воспроизведения "нагрузки" на систему используются следующие запросы в разных терминалах: +```bash +while true; do curl -s -X POST http://localhost:8080/cart > /dev/null; sleep 0.4; done +``` +и +```bash +while true; do curl -s http://localhost:8080/item > /dev/null; sleep 0.2; done +``` diff --git a/hw3_own/__init__.py b/hw3_own/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3_own/docker-compose.yml b/hw3_own/docker-compose.yml new file mode 100644 index 00000000..e6c0dab1 --- /dev/null +++ b/hw3_own/docker-compose.yml @@ -0,0 +1,34 @@ +services: + server: + build: + context: . + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + volumes: + - ./settings/grafana/provisioning:/etc/grafana/provisioning + depends_on: + - prometheus + + + prometheus: + image: prom/prometheus:latest + volumes: + - ./settings/prometheus:/etc/prometheus + + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + depends_on: + - server diff --git a/hw3_own/docs/dashboard_1.png b/hw3_own/docs/dashboard_1.png new file mode 100644 index 00000000..12b7a4b1 Binary files /dev/null and b/hw3_own/docs/dashboard_1.png differ diff --git a/hw3_own/docs/dashboard_2.png b/hw3_own/docs/dashboard_2.png new file mode 100644 index 00000000..4c249b97 Binary files /dev/null and b/hw3_own/docs/dashboard_2.png differ diff --git a/hw3_own/requirements.txt b/hw3_own/requirements.txt new file mode 100644 index 00000000..207dcf5c --- /dev/null +++ b/hw3_own/requirements.txt @@ -0,0 +1,9 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 diff --git a/hw3_own/requirements_hw3.txt b/hw3_own/requirements_hw3.txt new file mode 100644 index 00000000..1593e6d3 --- /dev/null +++ b/hw3_own/requirements_hw3.txt @@ -0,0 +1,11 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 +websockets>=15.0.1 +prometheus_fastapi_instrumentator>=7.1.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 diff --git a/hw3_own/settings/grafana/provisioning/dashboards/dashboards.yml b/hw3_own/settings/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 00000000..f2f56c65 --- /dev/null +++ b/hw3_own/settings/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,8 @@ +apiVersion: 1 +providers: + - name: "shop-api" + type: file + disableDeletion: false + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/hw3_own/settings/grafana/provisioning/dashboards/shop_api_overview.json b/hw3_own/settings/grafana/provisioning/dashboards/shop_api_overview.json new file mode 100644 index 00000000..9e029a74 --- /dev/null +++ b/hw3_own/settings/grafana/provisioning/dashboards/shop_api_overview.json @@ -0,0 +1,66 @@ +{ + "title": "Shop API — Overview", + "schemaVersion": 39, + "version": 1, + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "type": "timeseries", + "title": "RPS (requests/sec)", + "targets": [ + { + "expr": "sum(rate(http_requests_total[1m]))", + "legendFormat": "rps" + } + ], + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 } + }, + { + "type": "timeseries", + "title": "Error rate (%)", + "targets": [ + { + "expr": "100 * (sum(rate(http_requests_total{status=~\"5..\"}[5m])) / clamp_min(sum(rate(http_requests_total[5m])), 1e-9))", + "legendFormat": "5xx %" + } + ], + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 } + }, + { + "type": "timeseries", + "title": "Latency p95 (seconds)", + "description": "Если лейбл пути называется не 'handler', замените на 'path' в expr.", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95" + } + ], + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 } + }, + { + "type": "stat", + "title": "In-progress requests", + "targets": [ + { + "expr": "sum(http_requests_in_progress)", + "legendFormat": "inflight" + } + ], + "gridPos": { "h": 8, "w": 6, "x": 12, "y": 8 } + }, + { + "type": "bargauge", + "title": "Requests by status (5m rate)", + "options": { "displayMode": "gradient" }, + "targets": [ + { + "expr": "sum by (status) (rate(http_requests_total[5m]))", + "legendFormat": "{{status}}" + } + ], + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 8 } + } + ], + "panelsState": {} +} diff --git a/hw3_own/settings/grafana/provisioning/dashboards/shop_api_routes.json b/hw3_own/settings/grafana/provisioning/dashboards/shop_api_routes.json new file mode 100644 index 00000000..495bdcc5 --- /dev/null +++ b/hw3_own/settings/grafana/provisioning/dashboards/shop_api_routes.json @@ -0,0 +1,51 @@ +{ + "title": "Shop API — Routes", + "schemaVersion": 39, + "version": 1, + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "type": "timeseries", + "title": "RPS by route", + "description": "Если вместо label 'handler' у вас 'path' — замените в expr.", + "targets": [ + { + "expr": "sum by (handler) (rate(http_requests_total[1m]))", + "legendFormat": "{{handler}}" + } + ], + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 0 } + }, + { + "type": "timeseries", + "title": "Latency p95 by route", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, handler) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95 {{handler}}" + } + ], + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 } + }, + { + "type": "timeseries", + "title": "2xx / 4xx / 5xx by route (5m rate)", + "targets": [ + { + "expr": "sum by (handler) (rate(http_requests_total{status=~\"2..\"}[5m]))", + "legendFormat": "2xx {{handler}}" + }, + { + "expr": "sum by (handler) (rate(http_requests_total{status=~\"4..\"}[5m]))", + "legendFormat": "4xx {{handler}}" + }, + { + "expr": "sum by (handler) (rate(http_requests_total{status=~\"5..\"}[5m]))", + "legendFormat": "5xx {{handler}}" + } + ], + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 } + } + ], + "panelsState": {} +} diff --git a/hw3_own/settings/grafana/provisioning/datasources/datasource.yml b/hw3_own/settings/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 00000000..c9f4f3a9 --- /dev/null +++ b/hw3_own/settings/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,8 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/hw3_own/settings/prometheus/prometheus.yml b/hw3_own/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..cb41e3c3 --- /dev/null +++ b/hw3_own/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: service-server + metrics_path: /metrics + static_configs: + - targets: + - server:8080 diff --git a/hw3_own/shop_api/__init__.py b/hw3_own/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3_own/shop_api/core/schemas.py b/hw3_own/shop_api/core/schemas.py new file mode 100644 index 00000000..f7547e01 --- /dev/null +++ b/hw3_own/shop_api/core/schemas.py @@ -0,0 +1,49 @@ +# from __future__ import annotations +from typing import Optional, Annotated +from pydantic import BaseModel, Field, ConfigDict + +ItemName = Annotated[str, Field(description="Наименование товара", min_length=1)] +ItemId = Annotated[int, Field(description="Идентификатор корзины", ge=0)] +ItemPrice = Annotated[float, Field(description="Цена товара", ge=0)] + +CartId = Annotated[int, Field(description="Идентификатор корзины")] + + +# ---- Item ---- +class ItemOut(BaseModel): + id: ItemId + name: ItemName + price: ItemPrice + deleted: bool = Field(description="Удален ли товар", default=False) + + +class ItemCreate(BaseModel): + name: ItemName + price: ItemPrice + + +class ItemPut(BaseModel): + name: ItemName + price: ItemPrice + + +class ItemPatch(BaseModel): + # Разрешаем частичное обновление ТОЛЬКО name/price; лишние поля → 422 + model_config = ConfigDict(extra="forbid") + + name: Optional[ItemName] = None + price: Optional[ItemPrice] = None + + +# ---- Cart ---- +class CartItemView(BaseModel): + id: ItemId + name: ItemName + quantity: int = Field(description="Количество товара в корзине", ge=0) + available: bool = Field(description="Доступен ли товар") + + +class CartView(BaseModel): + id: int + items: list[CartItemView] + price: float = Field(description="Общая сумма заказа", ge=0.0) diff --git a/hw3_own/shop_api/core/storage.py b/hw3_own/shop_api/core/storage.py new file mode 100644 index 00000000..bc3c6cb9 --- /dev/null +++ b/hw3_own/shop_api/core/storage.py @@ -0,0 +1,142 @@ +from __future__ import annotations +from threading import ( + RLock, +) # стандартная библиотека Python: https://docs.python.org/3/library/threading.html +from typing import Optional +from fastapi import HTTPException, status +from .schemas import ( + ItemOut, + CartItemView, + CartView, + ItemCreate, + ItemPut, + ItemPatch, +) + +# ------------------------- +# In-memory хранилище + блокировки +# ------------------------- +_items_lock = RLock() +_carts_lock = RLock() + +_items: dict[int, ItemOut] = {} +_next_item_id = 1 + +# cart_id -> { item_id -> quantity } +_carts: dict[int, dict[int, int]] = {} +_next_cart_id = 1 + + +def new_item_id() -> int: + global _next_item_id + with _items_lock: + nid = _next_item_id + _next_item_id += 1 + return nid + + +def new_cart_id() -> int: + global _next_cart_id + with _carts_lock: + nid = _next_cart_id + _next_cart_id += 1 + return nid + + +def get_item_or_404(item_id: int) -> ItemOut: + with _items_lock: + item = _items.get(item_id) + if item is None or item.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + return item + + +def get_item_soft(item_id: int) -> Optional[ItemOut]: + with _items_lock: + return _items.get(item_id) + + +def cart_or_404(cart_id: int) -> dict[int, int]: + with _carts_lock: + cart = _carts.get(cart_id) + if cart is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found" + ) + return cart + + +def build_cart_view(cart_id: int) -> CartView: + with _carts_lock: + cart = _carts.get(cart_id, {}) + kv = list(cart.items()) + + items = [] + total = 0.0 + for item_id, qty in kv: + item = get_item_soft(item_id) + name = item.name if item else f"item:{item_id}" + available = bool(item and not item.deleted) + items.append( + CartItemView(id=item_id, name=name, quantity=qty, available=available) + ) + if available: + total += item.price * qty + + return CartView(id=cart_id, items=items, price=total) + + +def create_item(data: ItemCreate) -> ItemOut: + item_id = new_item_id() + item = ItemOut(id=item_id, name=data.name, price=data.price, deleted=False) + with _items_lock: + _items[item_id] = item + return item + + +def put_item(item_id: int, data: ItemPut) -> ItemOut: + with _items_lock: + existing = _items.get(item_id) + if existing is None or existing.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + existing.name = data.name + existing.price = data.price + return existing + + +def patch_item(item_id: int, data: ItemPatch) -> ItemOut: + with _items_lock: + existing = _items.get(item_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + if existing.deleted: + # Пробрасываем семантику 304 на верхний уровень + raise HTTPException( + status_code=status.HTTP_304_NOT_MODIFIED, detail="Item deleted" + ) + + if data.name is not None: + existing.name = data.name + if data.price is not None: + if data.price < 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid price", + ) + existing.price = data.price + + return existing + + +def delete_item(item_id: int) -> dict: + with _items_lock: + existing = _items.get(item_id) + if existing is not None: + existing.deleted = True + return {"ok": True} diff --git a/hw3_own/shop_api/main.py b/hw3_own/shop_api/main.py new file mode 100644 index 00000000..788a41a9 --- /dev/null +++ b/hw3_own/shop_api/main.py @@ -0,0 +1,25 @@ +from fastapi import FastAPI +from shop_api.routers.item import router as item +from shop_api.routers.cart import router as cart +from shop_api.routers.chat import router as chat +from prometheus_fastapi_instrumentator import Instrumentator + + +app = FastAPI(title="Shop API") + +app.include_router(item) +app.include_router(cart) + +app.include_router(chat) + +Instrumentator().instrument(app).expose( + app, + endpoint="/metrics", + include_in_schema=False, +) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, port=8001) diff --git a/hw3_own/shop_api/routers/cart.py b/hw3_own/shop_api/routers/cart.py new file mode 100644 index 00000000..9e040b6f --- /dev/null +++ b/hw3_own/shop_api/routers/cart.py @@ -0,0 +1,99 @@ +from typing import List, Optional +from fastapi import APIRouter, Query, Response, status + +from shop_api.core.schemas import CartView +from shop_api.core.storage import ( + _carts, + _carts_lock, + new_cart_id, + cart_or_404, + build_cart_view, + get_item_or_404, +) + +router = APIRouter(prefix="/cart", tags=["cart"]) + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_cart(response: Response): + """ + POST /cart — RPC: создаёт пустую корзину, тело не принимает. + Возвращает 201 и JSON {"id": }, а также заголовок Location: /cart/{id}. + """ + cart_id = new_cart_id() + with _carts_lock: + _carts[cart_id] = {} + response.headers["Location"] = f"/cart/{cart_id}" + return {"id": cart_id} + + +@router.get("/{cart_id}", response_model=CartView) +def get_cart(cart_id: int) -> CartView: + """ + GET /cart/{id} — получить корзину по id. + """ + cart_or_404(cart_id) + return build_cart_view(cart_id) + + +@router.get("", response_model=List[CartView]) +def list_carts( + offset: int = Query(0, ge=0, description="Смещение по списку (offset)"), + limit: int = Query(10, gt=0, description="Лимит количества (limit)"), + min_price: Optional[float] = Query( + None, ge=0.0, description="Мин. сумма корзины (включительно)" + ), + max_price: Optional[float] = Query( + None, ge=0.0, description="Макс. сумма корзины (включительно)" + ), + min_quantity: Optional[int] = Query( + None, ge=0, description="Мин. общее число товаров (включительно)" + ), + max_quantity: Optional[int] = Query( + None, ge=0, description="Макс. общее число товаров (включительно)" + ), +) -> List[CartView]: + """ + GET /cart — список корзин с фильтрами и пагинацией. + + Фильтры: + • min_price/max_price — по суммарной стоимости корзины (включительно); + • min_quantity/max_quantity — по суммарному количеству позиций в корзине (включительно). + Порядок: фильтрация -> offset/limit. + """ + with _carts_lock: + ids = list(_carts.keys()) + + views: List[CartView] = [] + for cid in ids: + v = build_cart_view(cid) + + if min_price is not None and v.price < min_price: + continue + if max_price is not None and v.price > max_price: + continue + + qsum = sum(it.quantity for it in v.items) + if min_quantity is not None and qsum < min_quantity: + continue + if max_quantity is not None and qsum > max_quantity: + continue + + views.append(v) + + return views[offset : offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +def add_to_cart(cart_id: int, item_id: int): + """ + POST /cart/{cart_id}/add/{item_id} — добавить товар в корзину. + Если товар уже есть — увеличивает его количество. + """ + cart = cart_or_404(cart_id) + get_item_or_404(item_id) # проверка на товар + + with _carts_lock: + cart[item_id] = cart.get(item_id, 0) + 1 + + return {"ok": True} diff --git a/hw3_own/shop_api/routers/chat.py b/hw3_own/shop_api/routers/chat.py new file mode 100644 index 00000000..6c3e1d5e --- /dev/null +++ b/hw3_own/shop_api/routers/chat.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +import secrets # стандартная библиотека: https://docs.python.org/3/library/secrets.html +from collections import defaultdict + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter(tags=["chat"]) + + +class RoomManager: + # Решил реализовать в одном файле систему чатов, чтобы проще было проверять + def __init__(self) -> None: + # room -> set of websockets + self.rooms: dict[str, set[WebSocket]] = defaultdict(set) + # websocket -> username + self.usernames: dict[WebSocket, str] = {} + self._lock = asyncio.Lock() + + @staticmethod + def _gen_username() -> str: + # короткий случайный ник + return f"user-{secrets.token_hex(2)}" + + async def connect(self, room: str, ws: WebSocket) -> str: + await ws.accept() + username = self._gen_username() + async with self._lock: + self.rooms[room].add(ws) + self.usernames[ws] = username + await ws.send_text(f"[system] :: your_name = {username}") + return username + + async def disconnect(self, room: str, ws: WebSocket) -> None: + async with self._lock: + self.rooms[room].discard(ws) + self.usernames.pop(ws, None) + if not self.rooms[room]: + self.rooms.pop(room, None) + + async def broadcast( + self, room: str, text: str, sender: WebSocket | None = None + ) -> None: + async with self._lock: + targets = list(self.rooms.get(room, ())) + for ws in targets: + if ws is sender: + continue + try: + await ws.send_text(text) + except Exception: + try: + await self.disconnect(room, ws) + except Exception: + pass + + +manager = RoomManager() + + +@router.websocket("/chat/{chat_name}") +async def chat_ws(websocket: WebSocket, chat_name: str): + """ + Пользователи, подключённые к одному chat_name, получают сообщения друг друга. + Формат сообщения: "{username} :: {message}". + """ + username = await manager.connect(chat_name, websocket) + try: + while True: + # Ждём текст от клиента + msg = await websocket.receive_text() + # Бродкастим другим пользователям в комнате (без эха отправителю) + await manager.broadcast(chat_name, f"{username} :: {msg}", sender=websocket) + except WebSocketDisconnect: + await manager.disconnect(chat_name, websocket) + except Exception: + # Любая иная ошибка закрывает сокет и удаляет из комнаты + await manager.disconnect(chat_name, websocket) + try: + await websocket.close() + except Exception: + pass diff --git a/hw3_own/shop_api/routers/item.py b/hw3_own/shop_api/routers/item.py new file mode 100644 index 00000000..acc511f6 --- /dev/null +++ b/hw3_own/shop_api/routers/item.py @@ -0,0 +1,107 @@ +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Query, Response, status + +from shop_api.core.schemas import ItemOut, ItemCreate, ItemPut, ItemPatch +from shop_api.core.storage import _items, _items_lock, get_item_or_404, new_item_id + +router = APIRouter(prefix="/item", tags=["items"]) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=ItemOut) +def create_item(body: ItemCreate) -> ItemOut: + """ + POST /item - добавление нового товара + """ + item_id = new_item_id() + obj = ItemOut(id=item_id, name=body.name, price=body.price, deleted=False) + with _items_lock: + _items[item_id] = obj + return obj + + +@router.get("/{item_id}", response_model=ItemOut) +def get_item(item_id: int) -> ItemOut: + """ + GET /item/{id} - получение товара по id + """ + return get_item_or_404(item_id) + + +@router.get("", response_model=List[ItemOut]) +def list_items( + offset: int = Query(0, ge=0, description="Смещение (offset)"), + limit: int = Query(10, gt=0, description="Количество (limit)"), + min_price: Optional[float] = Query(None, ge=0, description="Мин. цена"), + max_price: Optional[float] = Query(None, ge=0, description="Макс. цена"), + show_deleted: bool = Query(False, description="Показывать ли удалённые"), +) -> List[ItemOut]: + """ + GET /item - получение списка товаров с фильтрами и пагинацией + """ + with _items_lock: + items = list(_items.values()) + + if not show_deleted: + items = [i for i in items if not i.deleted] + if min_price is not None: + items = [i for i in items if i.price >= min_price] + if max_price is not None: + items = [i for i in items if i.price <= max_price] + + return items[offset : offset + limit] + + +@router.put("/{item_id}", response_model=ItemOut) +def put_item(item_id: int, body: ItemPut) -> ItemOut: + """ + PUT /item/{id} - замена товара по id (создание запрещено) + """ + with _items_lock: + existing = _items.get(item_id) + if existing is None or existing.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + existing.name = body.name + existing.price = body.price + return existing + + +@router.patch("/{item_id}", response_model=ItemOut) +def patch_item(item_id: int, body: ItemPatch): + """ + PATCH /item/{id} - частичное обновление (разрешено менять всё кроме deleted) + Если товар удалён — 304 Not Modified. + """ + with _items_lock: + existing = _items.get(item_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + if existing.deleted: + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + + if body.name is not None: + existing.name = body.name + if body.price is not None: + if body.price < 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid price", + ) + existing.price = body.price + + return existing + + +@router.delete("/{item_id}") +def delete_item(item_id: int): + """ + DELETE /item/{id} - мягкое удаление (deleted=True), идемпотентно + """ + with _items_lock: + existing = _items.get(item_id) + if existing is not None: + existing.deleted = True + return {"ok": True} diff --git a/hw3_own/test_homework2.py b/hw3_own/test_homework2.py new file mode 100644 index 00000000..60a1f36a --- /dev/null +++ b/hw3_own/test_homework2.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +from shop_api.main import app + +client = TestClient(app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@pytest.fixture() +def existing_not_empty_cart_id( + existing_empty_cart_id: int, + existing_items: list[int], +) -> 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}") + + return existing_empty_cart_id + + +@pytest.fixture() +def existing_item() -> dict[str, Any]: + return client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ).json() + + +@pytest.fixture() +def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: + item_id = existing_item["id"] + client.delete(f"/item/{item_id}") + + existing_item["deleted"] = True + return existing_item + + +def test_post_cart() -> None: + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.parametrize( + ("cart", "not_empty"), + [ + ("existing_empty_cart_id", False), + ("existing_not_empty_cart_id", True), + ], +) +def test_get_cart(request, cart: int, not_empty: bool) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + len_items = len(response_json["items"]) + assert len_items > 0 if not_empty else len_items == 0 + + if not_empty: + price = 0 + + for item in response_json["items"]: + item_id = item["id"] + price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + else: + assert response_json["price"] == 0.0 + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000.0}, HTTPStatus.OK), + ({"max_price": 20.0}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query: dict[str, Any], status_code: int): + response = client.get("/cart", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + quantity = sum(item["quantity"] for cart in data for item in cart["items"]) + + if "min_quantity" in query: + assert quantity >= query["min_quantity"] + + if "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_post_item() -> None: + item = {"name": "test item", "price": 9.99} + response = client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +def test_get_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_item_list(query: dict[str, Any], status_code: int) -> None: + response = client.get("/item", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + if "show_deleted" in query and query["show_deleted"] is False: + assert all(item["deleted"] is False for item in data) + + +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +def test_put_item( + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = client.put(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + new_item = existing_item.copy() + new_item.update(body) + assert response.json() == new_item + + +@pytest.mark.parametrize( + ("item", "body", "status_code"), + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing_item", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing_item", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: + item_data: dict[str, Any] = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + patch_response_body = response.json() + + response = client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +def test_delete_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK diff --git a/hw3_own/test_websocket_chat.py b/hw3_own/test_websocket_chat.py new file mode 100644 index 00000000..248e7625 --- /dev/null +++ b/hw3_own/test_websocket_chat.py @@ -0,0 +1,60 @@ +import re +from fastapi.testclient import TestClient + +from shop_api.main import app + +USERNAME_RE = re.compile(r"^\[system\] :: your_name = (user-[0-9a-f]{4})$") + + +def _extract_username(system_msg: str) -> str: + """ + Из приветственного сообщения вида: + "[system] :: your_name = user-ab12" + достаём "user-ab12". + """ + m = USERNAME_RE.match(system_msg) + assert m, f"unexpected system greeting format: {system_msg!r}" + return m.group(1) + + +def test_websocket_broadcast_and_format(): + client = TestClient(app) + + # Подключаем двух пользователей к одной комнате и третьего — к другой + with ( + client.websocket_connect("/chat/room1") as ws1, + client.websocket_connect("/chat/room1") as ws2, + client.websocket_connect("/chat/room2") as ws3, + ): + # Приветственные сообщения с именами + u1 = _extract_username(ws1.receive_text()) + u2 = _extract_username(ws2.receive_text()) + u3 = _extract_username(ws3.receive_text()) + assert u1 != u2 and u1 != u3 and u2 != u3 # имена случайные и разные + + # user1 -> room1 + msg1 = "hello from 1" + ws1.send_text(msg1) + + # Должно прийти ТОЛЬКО второму участнику той же комнаты + got2 = ws2.receive_text() + assert got2 == f"{u1} :: {msg1}" + + # user3 -> room2 (не должен мешать room1) + ws3.send_text("ping from 3") + + # user2 -> room1 + msg2 = "hi from 2" + ws2.send_text(msg2) + + # Должно прийти первому участнику room1 + got1 = ws1.receive_text() + assert got1 == f"{u2} :: {msg2}" + + +def test_websocket_username_greeting_format(): + client = TestClient(app) + with client.websocket_connect("/chat/any-room") as ws: + greet = ws.receive_text() + # Проверяем точный формат приветствия и шаблон username + assert USERNAME_RE.match(greet), f"bad greeting: {greet!r}" diff --git a/hw4_own/.dockignore b/hw4_own/.dockignore new file mode 100644 index 00000000..3d100150 --- /dev/null +++ b/hw4_own/.dockignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.sqlite3 +*.db +.env +.venv/ +.git/ +.gitignore +data/ +tests/.pytest_cache/ diff --git a/hw4_own/Dockerfile b/hw4_own/Dockerfile new file mode 100644 index 00000000..40472257 --- /dev/null +++ b/hw4_own/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /api + +RUN apt-get update -y + +COPY requirements_hw4.txt requirements.txt + +RUN pip install -r requirements.txt + +COPY . /api + +RUN mkdir -p /api/data && chmod -R 0777 /api/data + +ENTRYPOINT ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/hw4_own/README.md b/hw4_own/README.md new file mode 100644 index 00000000..26822ef7 --- /dev/null +++ b/hw4_own/README.md @@ -0,0 +1,15 @@ +## ДЗ + +За каждый пункт - 1 балл + +Внедрить во вторую домашку хранение данных в БД, для этого надо: +1) Добавить БД в docket-compose.yml (если БД - это отдельный сервис, если хотите использовать sqlite, то можно скипнуть этот шаг) +2) Переписать код на взаимодействие с вашей БД (если вы еще этого не сделали, если вы уже написали код с БД, подзравляю, вам остался только 3 пункт) +3) В свободной форме, напишите скрипты, которые просимулируют разные "проблемы" которые могут возникнуть в транзакциях (dirty read, not-repeatable read, serialize) и настраивая уровне изоляции покажите, что они действительно решаются (через SQLAlchemy например), то есть: +показать dirty read при read uncommited +показать что нет dirty read при read commited +показать non-repeatable read при read commited +показать что нет non-repeatable read при repeatable read +показать phantom reads при repeatable read +показать что нет phantom reads при serializable +*Тут зависит от того какую БД вы выбрали, разные БД могут поддерживать разные уровни изоляции \ No newline at end of file diff --git a/hw4_own/__init__.py b/hw4_own/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4_own/data/shop.db b/hw4_own/data/shop.db new file mode 100644 index 00000000..9b9a3802 Binary files /dev/null and b/hw4_own/data/shop.db differ diff --git a/hw4_own/docker-compose.yml b/hw4_own/docker-compose.yml new file mode 100644 index 00000000..f236e2cc --- /dev/null +++ b/hw4_own/docker-compose.yml @@ -0,0 +1,13 @@ +services: + server: + build: + context: . + restart: always + volumes: + - .:/api + - ./data:/api/data + environment: + - DATABASE_URL=sqlite+aiosqlite:////api/data/shop.db + working_dir: /api + ports: + - 8080:8080 diff --git a/hw4_own/requirements_hw4.txt b/hw4_own/requirements_hw4.txt new file mode 100644 index 00000000..b40f6f0d --- /dev/null +++ b/hw4_own/requirements_hw4.txt @@ -0,0 +1,12 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 +websockets>=15.0.1 +SQLAlchemy>=2.0.30 +aiosqlite>=0.20.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 diff --git a/hw4_own/shop_api/__init__.py b/hw4_own/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4_own/shop_api/core/db.py b/hw4_own/shop_api/core/db.py new file mode 100644 index 00000000..11b9f2a9 --- /dev/null +++ b/hw4_own/shop_api/core/db.py @@ -0,0 +1,39 @@ +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./data/shop.db") + + +class Base(DeclarativeBase): + pass + + +def make_engine(url: str = DATABASE_URL) -> AsyncEngine: + # echo=True for debug + return create_async_engine(url, future=True, echo=False) + + +engine: AsyncEngine = make_engine() +async_session: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=engine, expire_on_commit=False +) + + +@asynccontextmanager +async def lifespan_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session() as session: + yield session + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session() as session: + yield session diff --git a/hw4_own/shop_api/core/models.py b/hw4_own/shop_api/core/models.py new file mode 100644 index 00000000..74ecb5f1 --- /dev/null +++ b/hw4_own/shop_api/core/models.py @@ -0,0 +1,44 @@ +from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .db import Base + + +class Item(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + 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) + + cart_links: Mapped[list["CartItem"]] = relationship( + back_populates="item", cascade="all, delete-orphan" + ) + + +class Cart(Base): + __tablename__ = "carts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + items: Mapped[list["CartItem"]] = relationship( + back_populates="cart", cascade="all, delete-orphan" + ) + + +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, autoincrement=True) + cart_id: Mapped[int] = mapped_column( + ForeignKey("carts.id", ondelete="CASCADE"), nullable=False + ) + item_id: Mapped[int] = mapped_column( + ForeignKey("items.id", ondelete="CASCADE"), nullable=False + ) + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + cart: Mapped["Cart"] = relationship(back_populates="items") + item: Mapped["Item"] = relationship(back_populates="cart_links") diff --git a/hw4_own/shop_api/core/schemas.py b/hw4_own/shop_api/core/schemas.py new file mode 100644 index 00000000..f7547e01 --- /dev/null +++ b/hw4_own/shop_api/core/schemas.py @@ -0,0 +1,49 @@ +# from __future__ import annotations +from typing import Optional, Annotated +from pydantic import BaseModel, Field, ConfigDict + +ItemName = Annotated[str, Field(description="Наименование товара", min_length=1)] +ItemId = Annotated[int, Field(description="Идентификатор корзины", ge=0)] +ItemPrice = Annotated[float, Field(description="Цена товара", ge=0)] + +CartId = Annotated[int, Field(description="Идентификатор корзины")] + + +# ---- Item ---- +class ItemOut(BaseModel): + id: ItemId + name: ItemName + price: ItemPrice + deleted: bool = Field(description="Удален ли товар", default=False) + + +class ItemCreate(BaseModel): + name: ItemName + price: ItemPrice + + +class ItemPut(BaseModel): + name: ItemName + price: ItemPrice + + +class ItemPatch(BaseModel): + # Разрешаем частичное обновление ТОЛЬКО name/price; лишние поля → 422 + model_config = ConfigDict(extra="forbid") + + name: Optional[ItemName] = None + price: Optional[ItemPrice] = None + + +# ---- Cart ---- +class CartItemView(BaseModel): + id: ItemId + name: ItemName + quantity: int = Field(description="Количество товара в корзине", ge=0) + available: bool = Field(description="Доступен ли товар") + + +class CartView(BaseModel): + id: int + items: list[CartItemView] + price: float = Field(description="Общая сумма заказа", ge=0.0) diff --git a/hw4_own/shop_api/core/storage.py b/hw4_own/shop_api/core/storage.py new file mode 100644 index 00000000..992bfc94 --- /dev/null +++ b/hw4_own/shop_api/core/storage.py @@ -0,0 +1,137 @@ +from typing import Optional + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from .schemas import ItemOut, CartItemView, CartView, ItemCreate, ItemPut, ItemPatch +from .models import Item, Cart, CartItem + + +# ---- Item helpers ---- +async def create_item(session: AsyncSession, data: ItemCreate) -> ItemOut: + item = Item(name=data.name, price=data.price, deleted=False) + session.add(item) + await session.flush() # populate PK + return ItemOut(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + +async def get_item_or_404(session: AsyncSession, item_id: int) -> Item: + result = await session.execute(select(Item).where(Item.id == item_id)) + item = result.scalar_one_or_none() + if item is None or item.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + return item + + +async def get_item_soft(session: AsyncSession, item_id: int) -> Optional[Item]: + result = await session.execute(select(Item).where(Item.id == item_id)) + return result.scalar_one_or_none() + + +async def put_item(session: AsyncSession, item_id: int, data: ItemPut) -> ItemOut: + item = await get_item_or_404(session, item_id) + item.name = data.name + item.price = data.price + await session.flush() + return ItemOut(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + +async def patch_item(session: AsyncSession, item_id: int, data: ItemPatch) -> ItemOut: + result = await session.execute(select(Item).where(Item.id == item_id)) + item = result.scalar_one_or_none() + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + if item.deleted: + raise HTTPException( + status_code=status.HTTP_304_NOT_MODIFIED, detail="Item deleted" + ) + + if data.name is not None: + item.name = data.name + if data.price is not None: + if data.price < 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid price", + ) + item.price = data.price + + await session.flush() + return ItemOut(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + +async def delete_item(session: AsyncSession, item_id: int) -> dict: + result = await session.execute(select(Item).where(Item.id == item_id)) + item = result.scalar_one_or_none() + if item is not None: + item.deleted = True + await session.flush() + return {"ok": True} + + +# ---- Cart helpers ---- +async def cart_or_404(session: AsyncSession, cart_id: int) -> Cart: + result = await session.execute(select(Cart).where(Cart.id == cart_id)) + cart = result.scalar_one_or_none() + if cart is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found" + ) + return cart + + +async def create_cart(session: AsyncSession) -> int: + cart = Cart() + session.add(cart) + await session.flush() + return cart.id + + +async def add_to_cart(session: AsyncSession, cart_id: int, item_id: int) -> None: + # Check cart and item exist (item must not be deleted) + await cart_or_404(session, cart_id) + await get_item_or_404(session, item_id) + + # Find existing link + result = await session.execute( + select(CartItem).where(CartItem.cart_id == cart_id, CartItem.item_id == item_id) + ) + link = result.scalar_one_or_none() + if link is None: + link = CartItem(cart_id=cart_id, item_id=item_id, quantity=1) + session.add(link) + else: + link.quantity += 1 + await session.flush() + + +async def build_cart_view(session: AsyncSession, cart_id: int) -> CartView: + cart = await cart_or_404(session, cart_id) + + # Load links with joined items + result = await session.execute( + select(CartItem, Item) + .join(Item, CartItem.item_id == Item.id) + .where(CartItem.cart_id == cart.id) + ) + rows = result.all() + + items = [] + total = 0.0 + for link, item in rows: + name = item.name + available = not item.deleted + items.append( + CartItemView( + id=item.id, name=name, quantity=link.quantity, available=available + ) + ) + if available: + total += item.price * link.quantity + + return CartView(id=cart.id, items=items, price=total) diff --git a/hw4_own/shop_api/main.py b/hw4_own/shop_api/main.py new file mode 100644 index 00000000..7d916092 --- /dev/null +++ b/hw4_own/shop_api/main.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from fastapi import FastAPI + +from shop_api.core.db import Base, engine +from shop_api.routers.item import router as item +from shop_api.routers.cart import router as cart +from shop_api.routers.chat import router as chat + +app = FastAPI(title="Shop API (SQLAlchemy + SQLite)") + +@app.on_event("startup") +async def on_startup() -> None: + # Create tables if they don't exist + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +app.include_router(item) +app.include_router(cart) +app.include_router(chat) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, port=8001) diff --git a/hw4_own/shop_api/routers/cart.py b/hw4_own/shop_api/routers/cart.py new file mode 100644 index 00000000..2a45bb34 --- /dev/null +++ b/hw4_own/shop_api/routers/cart.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shop_api.core.schemas import CartView +from shop_api.core.db import get_session +from shop_api.core.models import Cart +from shop_api.core import storage as crud + +router = APIRouter(prefix="/cart", tags=["cart"]) + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_cart(response: Response, session: AsyncSession = Depends(get_session)): + cart_id = await crud.create_cart(session) + await session.commit() + response.headers["Location"] = f"/cart/{cart_id}" + return {"id": cart_id} + + +@router.get("/{cart_id}", response_model=CartView) +async def get_cart(cart_id: int, session: AsyncSession = Depends(get_session)) -> CartView: + return await crud.build_cart_view(session, cart_id) + + +@router.get("", response_model=List[CartView]) +async def list_carts( + offset: int = Query(0, ge=0, description="Смещение по списку (offset)"), + limit: int = Query(10, gt=0, description="Лимит количества (limit)"), + min_price: Optional[float] = Query( + None, ge=0.0, description="Мин. сумма корзины (включительно)" + ), + max_price: Optional[float] = Query( + None, ge=0.0, description="Макс. сумма корзины (включительно)" + ), + min_quantity: Optional[int] = Query( + None, ge=0, description="Мин. общее число товаров (включительно)" + ), + max_quantity: Optional[int] = Query( + None, ge=0, description="Макс. общее число товаров (включительно)" + ), + session: AsyncSession = Depends(get_session), +) -> List[CartView]: + # First gather candidate cart ids + res = await session.execute(select(Cart.id)) + ids = [row[0] for row in res.all()] + + views: List[CartView] = [] + for cid in ids: + v = await crud.build_cart_view(session, cid) + + if min_price is not None and v.price < min_price: + continue + if max_price is not None and v.price > max_price: + continue + + qsum = sum(it.quantity for it in v.items) + if min_quantity is not None and qsum < min_quantity: + continue + if max_quantity is not None and qsum > max_quantity: + continue + + views.append(v) + + return views[offset : offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int, session: AsyncSession = Depends(get_session)): + await crud.add_to_cart(session, cart_id, item_id) + await session.commit() + return {"ok": True} diff --git a/hw4_own/shop_api/routers/chat.py b/hw4_own/shop_api/routers/chat.py new file mode 100644 index 00000000..6c3e1d5e --- /dev/null +++ b/hw4_own/shop_api/routers/chat.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +import secrets # стандартная библиотека: https://docs.python.org/3/library/secrets.html +from collections import defaultdict + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter(tags=["chat"]) + + +class RoomManager: + # Решил реализовать в одном файле систему чатов, чтобы проще было проверять + def __init__(self) -> None: + # room -> set of websockets + self.rooms: dict[str, set[WebSocket]] = defaultdict(set) + # websocket -> username + self.usernames: dict[WebSocket, str] = {} + self._lock = asyncio.Lock() + + @staticmethod + def _gen_username() -> str: + # короткий случайный ник + return f"user-{secrets.token_hex(2)}" + + async def connect(self, room: str, ws: WebSocket) -> str: + await ws.accept() + username = self._gen_username() + async with self._lock: + self.rooms[room].add(ws) + self.usernames[ws] = username + await ws.send_text(f"[system] :: your_name = {username}") + return username + + async def disconnect(self, room: str, ws: WebSocket) -> None: + async with self._lock: + self.rooms[room].discard(ws) + self.usernames.pop(ws, None) + if not self.rooms[room]: + self.rooms.pop(room, None) + + async def broadcast( + self, room: str, text: str, sender: WebSocket | None = None + ) -> None: + async with self._lock: + targets = list(self.rooms.get(room, ())) + for ws in targets: + if ws is sender: + continue + try: + await ws.send_text(text) + except Exception: + try: + await self.disconnect(room, ws) + except Exception: + pass + + +manager = RoomManager() + + +@router.websocket("/chat/{chat_name}") +async def chat_ws(websocket: WebSocket, chat_name: str): + """ + Пользователи, подключённые к одному chat_name, получают сообщения друг друга. + Формат сообщения: "{username} :: {message}". + """ + username = await manager.connect(chat_name, websocket) + try: + while True: + # Ждём текст от клиента + msg = await websocket.receive_text() + # Бродкастим другим пользователям в комнате (без эха отправителю) + await manager.broadcast(chat_name, f"{username} :: {msg}", sender=websocket) + except WebSocketDisconnect: + await manager.disconnect(chat_name, websocket) + except Exception: + # Любая иная ошибка закрывает сокет и удаляет из комнаты + await manager.disconnect(chat_name, websocket) + try: + await websocket.close() + except Exception: + pass diff --git a/hw4_own/shop_api/routers/item.py b/hw4_own/shop_api/routers/item.py new file mode 100644 index 00000000..8e794adb --- /dev/null +++ b/hw4_own/shop_api/routers/item.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy import Select, and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from shop_api.core.schemas import ItemOut, ItemCreate, ItemPut, ItemPatch +from shop_api.core.db import get_session +from shop_api.core.models import Item +from shop_api.core import storage as crud + +router = APIRouter(prefix="/item", tags=["items"]) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=ItemOut) +async def create_item(body: ItemCreate, session: AsyncSession = Depends(get_session)) -> ItemOut: + obj = await crud.create_item(session, body) + await session.commit() + return obj + + +@router.get("/{item_id}", response_model=ItemOut) +async def get_item(item_id: int, session: AsyncSession = Depends(get_session)) -> ItemOut: + item = await crud.get_item_or_404(session, item_id) + return ItemOut(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + +@router.get("", response_model=List[ItemOut]) +async def list_items( + offset: int = Query(0, ge=0, description="Смещение (offset)"), + limit: int = Query(10, gt=0, description="Количество (limit)"), + min_price: Optional[float] = Query(None, ge=0, description="Мин. цена"), + max_price: Optional[float] = Query(None, ge=0, description="Макс. цена"), + show_deleted: bool = Query(False, description="Показывать ли удалённые"), + session: AsyncSession = Depends(get_session), +) -> List[ItemOut]: + stmt: Select = select(Item) + conds = [] + if not show_deleted: + conds.append(Item.deleted.is_(False)) + if min_price is not None: + conds.append(Item.price >= min_price) + if max_price is not None: + conds.append(Item.price <= max_price) + if conds: + stmt = stmt.where(and_(*conds)) + stmt = stmt.offset(offset).limit(limit) + + res = await session.execute(stmt) + items = res.scalars().all() + return [ItemOut(id=i.id, name=i.name, price=i.price, deleted=i.deleted) for i in items] + + +@router.put("/{item_id}", response_model=ItemOut) +async def put_item(item_id: int, body: ItemPut, session: AsyncSession = Depends(get_session)) -> ItemOut: + obj = await crud.put_item(session, item_id, body) + await session.commit() + return obj + + +@router.patch("/{item_id}", response_model=ItemOut) +async def patch_item(item_id: int, body: ItemPatch, session: AsyncSession = Depends(get_session)): + try: + obj = await crud.patch_item(session, item_id, body) + except HTTPException as e: + if e.status_code == status.HTTP_304_NOT_MODIFIED: + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + raise + await session.commit() + return obj + + +@router.delete("/{item_id}") +async def delete_item(item_id: int, session: AsyncSession = Depends(get_session)): + result = await crud.delete_item(session, item_id) + await session.commit() + return result diff --git a/hw5_own/Dockerfile b/hw5_own/Dockerfile new file mode 100644 index 00000000..2e62a48a --- /dev/null +++ b/hw5_own/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /api + +RUN apt-get update -y + +COPY requirements_hw3.txt requirements.txt + +RUN pip install -r requirements.txt + +COPY . . + +ENTRYPOINT ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/hw5_own/README.md b/hw5_own/README.md new file mode 100644 index 00000000..33e79328 --- /dev/null +++ b/hw5_own/README.md @@ -0,0 +1,5 @@ +# ДЗ + +1) Добиться 95% покрытия тестами вашей второй домашки - 1 балл + +2) Настроить автозапуск этих тестов в CI, если вы подключали сторонюю БД, то можно посмотреть вот [сюда](https://dev.to/kashifsoofi/integration-test-postgres-using-github-actions-3lln), чтобы поддержать тесты с ней в CI. По итогу у вас должен получится зеленый пайплайн - оценивается в еще 2 балла. diff --git a/hw5_own/__init__.py b/hw5_own/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5_own/docker-compose.yml b/hw5_own/docker-compose.yml new file mode 100644 index 00000000..e6c0dab1 --- /dev/null +++ b/hw5_own/docker-compose.yml @@ -0,0 +1,34 @@ +services: + server: + build: + context: . + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + volumes: + - ./settings/grafana/provisioning:/etc/grafana/provisioning + depends_on: + - prometheus + + + prometheus: + image: prom/prometheus:latest + volumes: + - ./settings/prometheus:/etc/prometheus + + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + depends_on: + - server diff --git a/hw5_own/docs/dashboard_1.png b/hw5_own/docs/dashboard_1.png new file mode 100644 index 00000000..12b7a4b1 Binary files /dev/null and b/hw5_own/docs/dashboard_1.png differ diff --git a/hw5_own/docs/dashboard_2.png b/hw5_own/docs/dashboard_2.png new file mode 100644 index 00000000..4c249b97 Binary files /dev/null and b/hw5_own/docs/dashboard_2.png differ diff --git a/hw5_own/requirements.txt b/hw5_own/requirements.txt new file mode 100644 index 00000000..1593e6d3 --- /dev/null +++ b/hw5_own/requirements.txt @@ -0,0 +1,11 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 +websockets>=15.0.1 +prometheus_fastapi_instrumentator>=7.1.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 diff --git a/hw5_own/settings/grafana/provisioning/dashboards/dashboards.yml b/hw5_own/settings/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 00000000..f2f56c65 --- /dev/null +++ b/hw5_own/settings/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,8 @@ +apiVersion: 1 +providers: + - name: "shop-api" + type: file + disableDeletion: false + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/hw5_own/settings/grafana/provisioning/dashboards/shop_api_overview.json b/hw5_own/settings/grafana/provisioning/dashboards/shop_api_overview.json new file mode 100644 index 00000000..9e029a74 --- /dev/null +++ b/hw5_own/settings/grafana/provisioning/dashboards/shop_api_overview.json @@ -0,0 +1,66 @@ +{ + "title": "Shop API — Overview", + "schemaVersion": 39, + "version": 1, + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "type": "timeseries", + "title": "RPS (requests/sec)", + "targets": [ + { + "expr": "sum(rate(http_requests_total[1m]))", + "legendFormat": "rps" + } + ], + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 } + }, + { + "type": "timeseries", + "title": "Error rate (%)", + "targets": [ + { + "expr": "100 * (sum(rate(http_requests_total{status=~\"5..\"}[5m])) / clamp_min(sum(rate(http_requests_total[5m])), 1e-9))", + "legendFormat": "5xx %" + } + ], + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 } + }, + { + "type": "timeseries", + "title": "Latency p95 (seconds)", + "description": "Если лейбл пути называется не 'handler', замените на 'path' в expr.", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95" + } + ], + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 } + }, + { + "type": "stat", + "title": "In-progress requests", + "targets": [ + { + "expr": "sum(http_requests_in_progress)", + "legendFormat": "inflight" + } + ], + "gridPos": { "h": 8, "w": 6, "x": 12, "y": 8 } + }, + { + "type": "bargauge", + "title": "Requests by status (5m rate)", + "options": { "displayMode": "gradient" }, + "targets": [ + { + "expr": "sum by (status) (rate(http_requests_total[5m]))", + "legendFormat": "{{status}}" + } + ], + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 8 } + } + ], + "panelsState": {} +} diff --git a/hw5_own/settings/grafana/provisioning/dashboards/shop_api_routes.json b/hw5_own/settings/grafana/provisioning/dashboards/shop_api_routes.json new file mode 100644 index 00000000..495bdcc5 --- /dev/null +++ b/hw5_own/settings/grafana/provisioning/dashboards/shop_api_routes.json @@ -0,0 +1,51 @@ +{ + "title": "Shop API — Routes", + "schemaVersion": 39, + "version": 1, + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "type": "timeseries", + "title": "RPS by route", + "description": "Если вместо label 'handler' у вас 'path' — замените в expr.", + "targets": [ + { + "expr": "sum by (handler) (rate(http_requests_total[1m]))", + "legendFormat": "{{handler}}" + } + ], + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 0 } + }, + { + "type": "timeseries", + "title": "Latency p95 by route", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, handler) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95 {{handler}}" + } + ], + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 } + }, + { + "type": "timeseries", + "title": "2xx / 4xx / 5xx by route (5m rate)", + "targets": [ + { + "expr": "sum by (handler) (rate(http_requests_total{status=~\"2..\"}[5m]))", + "legendFormat": "2xx {{handler}}" + }, + { + "expr": "sum by (handler) (rate(http_requests_total{status=~\"4..\"}[5m]))", + "legendFormat": "4xx {{handler}}" + }, + { + "expr": "sum by (handler) (rate(http_requests_total{status=~\"5..\"}[5m]))", + "legendFormat": "5xx {{handler}}" + } + ], + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 } + } + ], + "panelsState": {} +} diff --git a/hw5_own/settings/grafana/provisioning/datasources/datasource.yml b/hw5_own/settings/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 00000000..c9f4f3a9 --- /dev/null +++ b/hw5_own/settings/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,8 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/hw5_own/settings/prometheus/prometheus.yml b/hw5_own/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..cb41e3c3 --- /dev/null +++ b/hw5_own/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: service-server + metrics_path: /metrics + static_configs: + - targets: + - server:8080 diff --git a/hw5_own/shop_api/__init__.py b/hw5_own/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5_own/shop_api/core/schemas.py b/hw5_own/shop_api/core/schemas.py new file mode 100644 index 00000000..f7547e01 --- /dev/null +++ b/hw5_own/shop_api/core/schemas.py @@ -0,0 +1,49 @@ +# from __future__ import annotations +from typing import Optional, Annotated +from pydantic import BaseModel, Field, ConfigDict + +ItemName = Annotated[str, Field(description="Наименование товара", min_length=1)] +ItemId = Annotated[int, Field(description="Идентификатор корзины", ge=0)] +ItemPrice = Annotated[float, Field(description="Цена товара", ge=0)] + +CartId = Annotated[int, Field(description="Идентификатор корзины")] + + +# ---- Item ---- +class ItemOut(BaseModel): + id: ItemId + name: ItemName + price: ItemPrice + deleted: bool = Field(description="Удален ли товар", default=False) + + +class ItemCreate(BaseModel): + name: ItemName + price: ItemPrice + + +class ItemPut(BaseModel): + name: ItemName + price: ItemPrice + + +class ItemPatch(BaseModel): + # Разрешаем частичное обновление ТОЛЬКО name/price; лишние поля → 422 + model_config = ConfigDict(extra="forbid") + + name: Optional[ItemName] = None + price: Optional[ItemPrice] = None + + +# ---- Cart ---- +class CartItemView(BaseModel): + id: ItemId + name: ItemName + quantity: int = Field(description="Количество товара в корзине", ge=0) + available: bool = Field(description="Доступен ли товар") + + +class CartView(BaseModel): + id: int + items: list[CartItemView] + price: float = Field(description="Общая сумма заказа", ge=0.0) diff --git a/hw5_own/shop_api/core/storage.py b/hw5_own/shop_api/core/storage.py new file mode 100644 index 00000000..bc3c6cb9 --- /dev/null +++ b/hw5_own/shop_api/core/storage.py @@ -0,0 +1,142 @@ +from __future__ import annotations +from threading import ( + RLock, +) # стандартная библиотека Python: https://docs.python.org/3/library/threading.html +from typing import Optional +from fastapi import HTTPException, status +from .schemas import ( + ItemOut, + CartItemView, + CartView, + ItemCreate, + ItemPut, + ItemPatch, +) + +# ------------------------- +# In-memory хранилище + блокировки +# ------------------------- +_items_lock = RLock() +_carts_lock = RLock() + +_items: dict[int, ItemOut] = {} +_next_item_id = 1 + +# cart_id -> { item_id -> quantity } +_carts: dict[int, dict[int, int]] = {} +_next_cart_id = 1 + + +def new_item_id() -> int: + global _next_item_id + with _items_lock: + nid = _next_item_id + _next_item_id += 1 + return nid + + +def new_cart_id() -> int: + global _next_cart_id + with _carts_lock: + nid = _next_cart_id + _next_cart_id += 1 + return nid + + +def get_item_or_404(item_id: int) -> ItemOut: + with _items_lock: + item = _items.get(item_id) + if item is None or item.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + return item + + +def get_item_soft(item_id: int) -> Optional[ItemOut]: + with _items_lock: + return _items.get(item_id) + + +def cart_or_404(cart_id: int) -> dict[int, int]: + with _carts_lock: + cart = _carts.get(cart_id) + if cart is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found" + ) + return cart + + +def build_cart_view(cart_id: int) -> CartView: + with _carts_lock: + cart = _carts.get(cart_id, {}) + kv = list(cart.items()) + + items = [] + total = 0.0 + for item_id, qty in kv: + item = get_item_soft(item_id) + name = item.name if item else f"item:{item_id}" + available = bool(item and not item.deleted) + items.append( + CartItemView(id=item_id, name=name, quantity=qty, available=available) + ) + if available: + total += item.price * qty + + return CartView(id=cart_id, items=items, price=total) + + +def create_item(data: ItemCreate) -> ItemOut: + item_id = new_item_id() + item = ItemOut(id=item_id, name=data.name, price=data.price, deleted=False) + with _items_lock: + _items[item_id] = item + return item + + +def put_item(item_id: int, data: ItemPut) -> ItemOut: + with _items_lock: + existing = _items.get(item_id) + if existing is None or existing.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + existing.name = data.name + existing.price = data.price + return existing + + +def patch_item(item_id: int, data: ItemPatch) -> ItemOut: + with _items_lock: + existing = _items.get(item_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + if existing.deleted: + # Пробрасываем семантику 304 на верхний уровень + raise HTTPException( + status_code=status.HTTP_304_NOT_MODIFIED, detail="Item deleted" + ) + + if data.name is not None: + existing.name = data.name + if data.price is not None: + if data.price < 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid price", + ) + existing.price = data.price + + return existing + + +def delete_item(item_id: int) -> dict: + with _items_lock: + existing = _items.get(item_id) + if existing is not None: + existing.deleted = True + return {"ok": True} diff --git a/hw5_own/shop_api/main.py b/hw5_own/shop_api/main.py new file mode 100644 index 00000000..788a41a9 --- /dev/null +++ b/hw5_own/shop_api/main.py @@ -0,0 +1,25 @@ +from fastapi import FastAPI +from shop_api.routers.item import router as item +from shop_api.routers.cart import router as cart +from shop_api.routers.chat import router as chat +from prometheus_fastapi_instrumentator import Instrumentator + + +app = FastAPI(title="Shop API") + +app.include_router(item) +app.include_router(cart) + +app.include_router(chat) + +Instrumentator().instrument(app).expose( + app, + endpoint="/metrics", + include_in_schema=False, +) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, port=8001) diff --git a/hw5_own/shop_api/routers/cart.py b/hw5_own/shop_api/routers/cart.py new file mode 100644 index 00000000..9e040b6f --- /dev/null +++ b/hw5_own/shop_api/routers/cart.py @@ -0,0 +1,99 @@ +from typing import List, Optional +from fastapi import APIRouter, Query, Response, status + +from shop_api.core.schemas import CartView +from shop_api.core.storage import ( + _carts, + _carts_lock, + new_cart_id, + cart_or_404, + build_cart_view, + get_item_or_404, +) + +router = APIRouter(prefix="/cart", tags=["cart"]) + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_cart(response: Response): + """ + POST /cart — RPC: создаёт пустую корзину, тело не принимает. + Возвращает 201 и JSON {"id": }, а также заголовок Location: /cart/{id}. + """ + cart_id = new_cart_id() + with _carts_lock: + _carts[cart_id] = {} + response.headers["Location"] = f"/cart/{cart_id}" + return {"id": cart_id} + + +@router.get("/{cart_id}", response_model=CartView) +def get_cart(cart_id: int) -> CartView: + """ + GET /cart/{id} — получить корзину по id. + """ + cart_or_404(cart_id) + return build_cart_view(cart_id) + + +@router.get("", response_model=List[CartView]) +def list_carts( + offset: int = Query(0, ge=0, description="Смещение по списку (offset)"), + limit: int = Query(10, gt=0, description="Лимит количества (limit)"), + min_price: Optional[float] = Query( + None, ge=0.0, description="Мин. сумма корзины (включительно)" + ), + max_price: Optional[float] = Query( + None, ge=0.0, description="Макс. сумма корзины (включительно)" + ), + min_quantity: Optional[int] = Query( + None, ge=0, description="Мин. общее число товаров (включительно)" + ), + max_quantity: Optional[int] = Query( + None, ge=0, description="Макс. общее число товаров (включительно)" + ), +) -> List[CartView]: + """ + GET /cart — список корзин с фильтрами и пагинацией. + + Фильтры: + • min_price/max_price — по суммарной стоимости корзины (включительно); + • min_quantity/max_quantity — по суммарному количеству позиций в корзине (включительно). + Порядок: фильтрация -> offset/limit. + """ + with _carts_lock: + ids = list(_carts.keys()) + + views: List[CartView] = [] + for cid in ids: + v = build_cart_view(cid) + + if min_price is not None and v.price < min_price: + continue + if max_price is not None and v.price > max_price: + continue + + qsum = sum(it.quantity for it in v.items) + if min_quantity is not None and qsum < min_quantity: + continue + if max_quantity is not None and qsum > max_quantity: + continue + + views.append(v) + + return views[offset : offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +def add_to_cart(cart_id: int, item_id: int): + """ + POST /cart/{cart_id}/add/{item_id} — добавить товар в корзину. + Если товар уже есть — увеличивает его количество. + """ + cart = cart_or_404(cart_id) + get_item_or_404(item_id) # проверка на товар + + with _carts_lock: + cart[item_id] = cart.get(item_id, 0) + 1 + + return {"ok": True} diff --git a/hw5_own/shop_api/routers/chat.py b/hw5_own/shop_api/routers/chat.py new file mode 100644 index 00000000..6c3e1d5e --- /dev/null +++ b/hw5_own/shop_api/routers/chat.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +import secrets # стандартная библиотека: https://docs.python.org/3/library/secrets.html +from collections import defaultdict + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter(tags=["chat"]) + + +class RoomManager: + # Решил реализовать в одном файле систему чатов, чтобы проще было проверять + def __init__(self) -> None: + # room -> set of websockets + self.rooms: dict[str, set[WebSocket]] = defaultdict(set) + # websocket -> username + self.usernames: dict[WebSocket, str] = {} + self._lock = asyncio.Lock() + + @staticmethod + def _gen_username() -> str: + # короткий случайный ник + return f"user-{secrets.token_hex(2)}" + + async def connect(self, room: str, ws: WebSocket) -> str: + await ws.accept() + username = self._gen_username() + async with self._lock: + self.rooms[room].add(ws) + self.usernames[ws] = username + await ws.send_text(f"[system] :: your_name = {username}") + return username + + async def disconnect(self, room: str, ws: WebSocket) -> None: + async with self._lock: + self.rooms[room].discard(ws) + self.usernames.pop(ws, None) + if not self.rooms[room]: + self.rooms.pop(room, None) + + async def broadcast( + self, room: str, text: str, sender: WebSocket | None = None + ) -> None: + async with self._lock: + targets = list(self.rooms.get(room, ())) + for ws in targets: + if ws is sender: + continue + try: + await ws.send_text(text) + except Exception: + try: + await self.disconnect(room, ws) + except Exception: + pass + + +manager = RoomManager() + + +@router.websocket("/chat/{chat_name}") +async def chat_ws(websocket: WebSocket, chat_name: str): + """ + Пользователи, подключённые к одному chat_name, получают сообщения друг друга. + Формат сообщения: "{username} :: {message}". + """ + username = await manager.connect(chat_name, websocket) + try: + while True: + # Ждём текст от клиента + msg = await websocket.receive_text() + # Бродкастим другим пользователям в комнате (без эха отправителю) + await manager.broadcast(chat_name, f"{username} :: {msg}", sender=websocket) + except WebSocketDisconnect: + await manager.disconnect(chat_name, websocket) + except Exception: + # Любая иная ошибка закрывает сокет и удаляет из комнаты + await manager.disconnect(chat_name, websocket) + try: + await websocket.close() + except Exception: + pass diff --git a/hw5_own/shop_api/routers/item.py b/hw5_own/shop_api/routers/item.py new file mode 100644 index 00000000..acc511f6 --- /dev/null +++ b/hw5_own/shop_api/routers/item.py @@ -0,0 +1,107 @@ +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Query, Response, status + +from shop_api.core.schemas import ItemOut, ItemCreate, ItemPut, ItemPatch +from shop_api.core.storage import _items, _items_lock, get_item_or_404, new_item_id + +router = APIRouter(prefix="/item", tags=["items"]) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=ItemOut) +def create_item(body: ItemCreate) -> ItemOut: + """ + POST /item - добавление нового товара + """ + item_id = new_item_id() + obj = ItemOut(id=item_id, name=body.name, price=body.price, deleted=False) + with _items_lock: + _items[item_id] = obj + return obj + + +@router.get("/{item_id}", response_model=ItemOut) +def get_item(item_id: int) -> ItemOut: + """ + GET /item/{id} - получение товара по id + """ + return get_item_or_404(item_id) + + +@router.get("", response_model=List[ItemOut]) +def list_items( + offset: int = Query(0, ge=0, description="Смещение (offset)"), + limit: int = Query(10, gt=0, description="Количество (limit)"), + min_price: Optional[float] = Query(None, ge=0, description="Мин. цена"), + max_price: Optional[float] = Query(None, ge=0, description="Макс. цена"), + show_deleted: bool = Query(False, description="Показывать ли удалённые"), +) -> List[ItemOut]: + """ + GET /item - получение списка товаров с фильтрами и пагинацией + """ + with _items_lock: + items = list(_items.values()) + + if not show_deleted: + items = [i for i in items if not i.deleted] + if min_price is not None: + items = [i for i in items if i.price >= min_price] + if max_price is not None: + items = [i for i in items if i.price <= max_price] + + return items[offset : offset + limit] + + +@router.put("/{item_id}", response_model=ItemOut) +def put_item(item_id: int, body: ItemPut) -> ItemOut: + """ + PUT /item/{id} - замена товара по id (создание запрещено) + """ + with _items_lock: + existing = _items.get(item_id) + if existing is None or existing.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + existing.name = body.name + existing.price = body.price + return existing + + +@router.patch("/{item_id}", response_model=ItemOut) +def patch_item(item_id: int, body: ItemPatch): + """ + PATCH /item/{id} - частичное обновление (разрешено менять всё кроме deleted) + Если товар удалён — 304 Not Modified. + """ + with _items_lock: + existing = _items.get(item_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + if existing.deleted: + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + + if body.name is not None: + existing.name = body.name + if body.price is not None: + if body.price < 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid price", + ) + existing.price = body.price + + return existing + + +@router.delete("/{item_id}") +def delete_item(item_id: int): + """ + DELETE /item/{id} - мягкое удаление (deleted=True), идемпотентно + """ + with _items_lock: + existing = _items.get(item_id) + if existing is not None: + existing.deleted = True + return {"ok": True} diff --git a/hw5_own/test_homework5.py b/hw5_own/test_homework5.py new file mode 100644 index 00000000..60a1f36a --- /dev/null +++ b/hw5_own/test_homework5.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +from shop_api.main import app + +client = TestClient(app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@pytest.fixture() +def existing_not_empty_cart_id( + existing_empty_cart_id: int, + existing_items: list[int], +) -> 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}") + + return existing_empty_cart_id + + +@pytest.fixture() +def existing_item() -> dict[str, Any]: + return client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ).json() + + +@pytest.fixture() +def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: + item_id = existing_item["id"] + client.delete(f"/item/{item_id}") + + existing_item["deleted"] = True + return existing_item + + +def test_post_cart() -> None: + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.parametrize( + ("cart", "not_empty"), + [ + ("existing_empty_cart_id", False), + ("existing_not_empty_cart_id", True), + ], +) +def test_get_cart(request, cart: int, not_empty: bool) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + len_items = len(response_json["items"]) + assert len_items > 0 if not_empty else len_items == 0 + + if not_empty: + price = 0 + + for item in response_json["items"]: + item_id = item["id"] + price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + else: + assert response_json["price"] == 0.0 + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000.0}, HTTPStatus.OK), + ({"max_price": 20.0}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query: dict[str, Any], status_code: int): + response = client.get("/cart", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + quantity = sum(item["quantity"] for cart in data for item in cart["items"]) + + if "min_quantity" in query: + assert quantity >= query["min_quantity"] + + if "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_post_item() -> None: + item = {"name": "test item", "price": 9.99} + response = client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +def test_get_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_item_list(query: dict[str, Any], status_code: int) -> None: + response = client.get("/item", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + if "show_deleted" in query and query["show_deleted"] is False: + assert all(item["deleted"] is False for item in data) + + +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +def test_put_item( + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = client.put(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + new_item = existing_item.copy() + new_item.update(body) + assert response.json() == new_item + + +@pytest.mark.parametrize( + ("item", "body", "status_code"), + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing_item", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing_item", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: + item_data: dict[str, Any] = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + patch_response_body = response.json() + + response = client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +def test_delete_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK diff --git a/hw5_own/test_websocket_chat.py b/hw5_own/test_websocket_chat.py new file mode 100644 index 00000000..248e7625 --- /dev/null +++ b/hw5_own/test_websocket_chat.py @@ -0,0 +1,60 @@ +import re +from fastapi.testclient import TestClient + +from shop_api.main import app + +USERNAME_RE = re.compile(r"^\[system\] :: your_name = (user-[0-9a-f]{4})$") + + +def _extract_username(system_msg: str) -> str: + """ + Из приветственного сообщения вида: + "[system] :: your_name = user-ab12" + достаём "user-ab12". + """ + m = USERNAME_RE.match(system_msg) + assert m, f"unexpected system greeting format: {system_msg!r}" + return m.group(1) + + +def test_websocket_broadcast_and_format(): + client = TestClient(app) + + # Подключаем двух пользователей к одной комнате и третьего — к другой + with ( + client.websocket_connect("/chat/room1") as ws1, + client.websocket_connect("/chat/room1") as ws2, + client.websocket_connect("/chat/room2") as ws3, + ): + # Приветственные сообщения с именами + u1 = _extract_username(ws1.receive_text()) + u2 = _extract_username(ws2.receive_text()) + u3 = _extract_username(ws3.receive_text()) + assert u1 != u2 and u1 != u3 and u2 != u3 # имена случайные и разные + + # user1 -> room1 + msg1 = "hello from 1" + ws1.send_text(msg1) + + # Должно прийти ТОЛЬКО второму участнику той же комнаты + got2 = ws2.receive_text() + assert got2 == f"{u1} :: {msg1}" + + # user3 -> room2 (не должен мешать room1) + ws3.send_text("ping from 3") + + # user2 -> room1 + msg2 = "hi from 2" + ws2.send_text(msg2) + + # Должно прийти первому участнику room1 + got1 = ws1.receive_text() + assert got1 == f"{u2} :: {msg2}" + + +def test_websocket_username_greeting_format(): + client = TestClient(app) + with client.websocket_connect("/chat/any-room") as ws: + greet = ws.receive_text() + # Проверяем точный формат приветствия и шаблон username + assert USERNAME_RE.match(greet), f"bad greeting: {greet!r}"