diff --git a/hw1/app.py b/hw1/app.py index 6107b870..ab83ed5f 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,9 @@ +import math +import statistics +import json from typing import Any, Awaitable, Callable +from urllib.parse import parse_qs +from http import HTTPStatus async def application( @@ -6,14 +11,118 @@ async def application( receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]], ): - """ - Args: - scope: Словарь с информацией о запросе - receive: Корутина для получения сообщений от клиента - send: Корутина для отправки сообщений клиенту - """ - # TODO: Ваша реализация здесь - -if __name__ == "__main__": + # --- lifespan поддержка для тест-клиента --- + if scope['type'] == 'lifespan': + while True: + message = await receive() + if message['type'] == 'lifespan.startup': + await send({'type': 'lifespan.startup.complete'}) + elif message['type'] == 'lifespan.shutdown': + await send({'type': 'lifespan.shutdown.complete'}) + return + return + + if scope['type'] != 'http': + return + + path = scope.get('path', '/') + method = scope.get('method', 'GET').upper() + query_string = scope.get('query_string', b'').decode() + params = parse_qs(query_string) + + async def send_response(status: int, data: dict): + body = json.dumps(data).encode('utf-8') + headers = [(b'content-type', b'application/json')] + await send({'type': 'http.response.start', 'status': status, 'headers': headers}) + await send({'type': 'http.response.body', 'body': body}) + + # --- поддерживаем только GET --- + if method != 'GET': + await send_response(HTTPStatus.NOT_FOUND, {'detail': 'Not Found'}) + return + + # --- factorial --- + if path == '/factorial': + if 'n' not in params or params['n'][0] == '': + await send_response(HTTPStatus.UNPROCESSABLE_ENTITY, {'detail': 'Missing n'}) + return + try: + n = int(params['n'][0]) + except Exception: + await send_response(HTTPStatus.UNPROCESSABLE_ENTITY, {'detail': 'n must be int'}) + return + + if n < 0 or n > 100: + await send_response(HTTPStatus.BAD_REQUEST, {'detail': 'n must be between 0 and 100'}) + return + + result = math.factorial(n) + await send_response(HTTPStatus.OK, {'result': result}) + return + + # --- fibonacci --- + elif path.startswith('/fibonacci'): + parts = path.split('/') + if len(parts) != 3 or parts[2] == '': + await send_response(HTTPStatus.UNPROCESSABLE_ENTITY, {'detail': 'Invalid path'}) + return + try: + n = int(parts[2]) + except Exception: + await send_response(HTTPStatus.UNPROCESSABLE_ENTITY, {'detail': 'n must be int'}) + return + + if n < 0 or n > 100: + await send_response(HTTPStatus.BAD_REQUEST, {'detail': 'n must be between 0 and 100'}) + return + + a, b = 0, 1 + seq = [] + for _ in range(n): + seq.append(a) + a, b = b, a + b + await send_response(HTTPStatus.OK, {'result': seq}) + return + + # --- mean --- + elif path == '/mean': + event = await receive() + body = b'' + if event.get('type') == 'http.request': + body = event.get('body', b'') or b'' + + if not body: + await send_response(HTTPStatus.UNPROCESSABLE_ENTITY, {'detail': 'Missing JSON body'}) + return + + try: + data = json.loads(body) + except Exception: + await send_response(HTTPStatus.UNPROCESSABLE_ENTITY, {'detail': 'Invalid JSON'}) + return + + if not isinstance(data, list): + await send_response(HTTPStatus.UNPROCESSABLE_ENTITY, {'detail': 'Body must be a list'}) + return + + if len(data) == 0: + await send_response(HTTPStatus.BAD_REQUEST, {'detail': 'Empty list'}) + return + + try: + numbers = [float(x) for x in data] + result = statistics.mean(numbers) + await send_response(HTTPStatus.OK, {'result': result}) + return + except Exception: + await send_response(HTTPStatus.UNPROCESSABLE_ENTITY, {'detail': 'Invalid numbers'}) + return + + # --- not found --- + await send_response(HTTPStatus.NOT_FOUND, {'detail': 'Not Found'}) + + +if __name__ == '__main__': import uvicorn - uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) + + uvicorn.run('app:application', host='0.0.0.0', port=8000, reload=True) diff --git a/hw1/requirements.txt b/hw1/requirements.txt index 588df51c..7a48e10d 100644 --- a/hw1/requirements.txt +++ b/hw1/requirements.txt @@ -8,3 +8,4 @@ httpx>=0.27.2 async-asgi-testclient>=1.4.11 + diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..90426bb5 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,202 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, confloat, conint, PositiveInt +from typing import Dict, List, Optional -app = FastAPI(title="Shop API") +app = FastAPI() + +# ---------- MODELS ---------- + +class ItemBase(BaseModel): + name: str + price: confloat(ge=0.0) + + class Config: + extra = 'forbid' # запрет лишних полей во всех моделях + + +class ItemCreate(ItemBase): + pass + + +class ItemPatch(BaseModel): + name: Optional[str] = None + price: Optional[confloat(ge=0.0)] = None + + class Config: + extra = 'forbid' # PATCH должен возвращать 422 при любых неожиданных полях + + +class Item(ItemBase): + id: int + deleted: bool = False + + +class CartItemOut(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartOut(BaseModel): + id: int + items: List[CartItemOut] + price: float + + +# ---------- IN-MEMORY "БАЗА" ---------- + +_next_item_id = 1 +_items: Dict[int, Item] = {} + +_next_cart_id = 1 +_carts: Dict[int, Dict[int, int]] = {} + + +# ---------- ITEM ENDPOINTS ---------- + +@app.post('/item', response_model=Item, status_code=201) +def create_item(item_in: ItemCreate): + global _next_item_id + item = Item(id=_next_item_id, name=item_in.name, price=item_in.price, deleted=False) + _items[_next_item_id] = item + _next_item_id += 1 + return item + + +@app.get('/item/{id}', response_model=Item) +def get_item(id: int): + item = _items.get(id) + if item is None or item.deleted: + raise HTTPException(status_code=404) + return item + + +@app.get('/item', response_model=List[Item]) +def list_items( + offset: conint(ge=0) = 0, + limit: PositiveInt = 10, + min_price: Optional[confloat(ge=0.0)] = None, + max_price: Optional[confloat(ge=0.0)] = None, + show_deleted: bool = False +): + items = [ + item for item in _items.values() + if (show_deleted or not item.deleted) + and (min_price is None or item.price >= min_price) + and (max_price is None or item.price <= max_price) + ] + return sorted(items, key=lambda x: x.id)[offset:offset + limit] + + +@app.put('/item/{id}', response_model=Item) +def replace_item(id: int, item_in: ItemCreate): + if id not in _items: + raise HTTPException(status_code=404) + old_item = _items[id] + new_item = Item(id=id, name=item_in.name, price=item_in.price, deleted=old_item.deleted) + _items[id] = new_item + return new_item + + +@app.patch('/item/{id}', response_model=Item) +def patch_item(id: int, patch: ItemPatch): + item = _items.get(id) + if item is None: + raise HTTPException(status_code=404) + if item.deleted: + return JSONResponse(status_code=304, content=item.model_dump()) + + data = patch.dict(exclude_unset=True) + if not data: + return item + + if 'name' in data: + item.name = data['name'] + if 'price' in data: + item.price = data['price'] + + _items[id] = item + return item + + +@app.delete('/item/{id}') +def delete_item(id: int): + item = _items.get(id) + if item is None: + return {'ok': True} + item.deleted = True + _items[id] = item + return {'ok': True} + + +# ---------- CART ENDPOINTS ---------- + +@app.post('/cart') +def create_cart(): + global _next_cart_id + cid = _next_cart_id + _next_cart_id += 1 + _carts[cid] = {} + response = JSONResponse(status_code=201, content={'id': cid}) + response.headers['Location'] = f'/cart/{cid}' + return response + + +@app.post('/cart/{cart_id}/add/{item_id}') +def add_item_to_cart(cart_id: int, item_id: int): + if cart_id not in _carts: + raise HTTPException(status_code=404) + if item_id not in _items: + raise HTTPException(status_code=404) + + item_map = _carts[cart_id] + item_map[item_id] = item_map.get(item_id, 0) + 1 + return {'ok': True} + + +def _cart_to_response(cart_id: int) -> CartOut: + item_map = _carts.get(cart_id, {}) + items_out = [] + total_price = 0.0 + + for iid, qty in item_map.items(): + item = _items.get(iid) + available = item is not None and not item.deleted + name = item.name if item else '' + if available: + total_price += item.price * qty + items_out.append(CartItemOut(id=iid, name=name, quantity=qty, available=available)) + + return CartOut(id=cart_id, items=items_out, price=total_price) + + +@app.get('/cart/{id}', response_model=CartOut) +def get_cart(id: int): + if id not in _carts: + raise HTTPException(status_code=404) + return _cart_to_response(id) + + +@app.get('/cart', response_model=List[CartOut]) +def list_carts( + offset: conint(ge=0) = 0, + limit: PositiveInt = 10, + min_price: Optional[confloat(ge=0.0)] = None, + max_price: Optional[confloat(ge=0.0)] = None, + min_quantity: Optional[conint(ge=0)] = None, + max_quantity: Optional[conint(ge=0)] = None, +): + carts_out = [] + for cid in sorted(_carts.keys()): + cart_resp = _cart_to_response(cid) + total_quantity = sum(i.quantity for i in cart_resp.items) + if ( + (min_price is None or cart_resp.price >= min_price) + and (max_price is None or cart_resp.price <= max_price) + and (min_quantity is None or total_quantity >= min_quantity) + and (max_quantity is None or total_quantity <= max_quantity) + ): + carts_out.append(cart_resp) + return carts_out[offset:offset + limit] diff --git a/hw3/Dockerfile b/hw3/Dockerfile new file mode 100644 index 00000000..ff27c0ce --- /dev/null +++ b/hw3/Dockerfile @@ -0,0 +1,12 @@ +# Dockerfile +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/hw3/__init__.py b/hw3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/docker-compose.yml b/hw3/docker-compose.yml new file mode 100644 index 00000000..f92a8068 --- /dev/null +++ b/hw3/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + app: + build: . + container_name: shop_api + ports: + - "8000:8000" + networks: + - monitoring + + prometheus: + image: prom/prometheus + container_name: prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + networks: + - monitoring + + grafana: + image: grafana/grafana + container_name: grafana + ports: + - "3000:3000" + networks: + - monitoring + +networks: + monitoring: + driver: bridge diff --git a/hw3/grafana/dashboard_process_cpu.jpg b/hw3/grafana/dashboard_process_cpu.jpg new file mode 100755 index 00000000..25f11f53 Binary files /dev/null and b/hw3/grafana/dashboard_process_cpu.jpg differ diff --git a/hw3/grafana/dashboard_requests_total.jpg b/hw3/grafana/dashboard_requests_total.jpg new file mode 100755 index 00000000..c4e97457 Binary files /dev/null and b/hw3/grafana/dashboard_requests_total.jpg differ diff --git a/hw3/prometheus.yml b/hw3/prometheus.yml new file mode 100644 index 00000000..f3dbc6ef --- /dev/null +++ b/hw3/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: "shop_api" + metrics_path: /metrics + static_configs: + - targets: ["app:8000"] diff --git a/hw3/requirements.txt b/hw3/requirements.txt new file mode 100644 index 00000000..ac9e3799 --- /dev/null +++ b/hw3/requirements.txt @@ -0,0 +1,12 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 +fastapi +uvicorn +prometheus-fastapi-instrumentator + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 diff --git a/hw3/shop_api/__init__.py b/hw3/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/shop_api/main.py b/hw3/shop_api/main.py new file mode 100644 index 00000000..5eddf275 --- /dev/null +++ b/hw3/shop_api/main.py @@ -0,0 +1,204 @@ +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, confloat, conint, PositiveInt +from typing import Dict, List, Optional +from prometheus_fastapi_instrumentator import Instrumentator + +app = FastAPI() +Instrumentator().instrument(app).expose(app) + +# ---------- MODELS ---------- + +class ItemBase(BaseModel): + name: str + price: confloat(ge=0.0) + + class Config: + extra = 'forbid' # запрет лишних полей во всех моделях + + +class ItemCreate(ItemBase): + pass + + +class ItemPatch(BaseModel): + name: Optional[str] = None + price: Optional[confloat(ge=0.0)] = None + + class Config: + extra = 'forbid' # PATCH должен возвращать 422 при любых неожиданных полях + + +class Item(ItemBase): + id: int + deleted: bool = False + + +class CartItemOut(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartOut(BaseModel): + id: int + items: List[CartItemOut] + price: float + + +# ---------- IN-MEMORY "БАЗА" ---------- + +_next_item_id = 1 +_items: Dict[int, Item] = {} + +_next_cart_id = 1 +_carts: Dict[int, Dict[int, int]] = {} + + +# ---------- ITEM ENDPOINTS ---------- + +@app.post('/item', response_model=Item, status_code=201) +def create_item(item_in: ItemCreate): + global _next_item_id + item = Item(id=_next_item_id, name=item_in.name, price=item_in.price, deleted=False) + _items[_next_item_id] = item + _next_item_id += 1 + return item + + +@app.get('/item/{id}', response_model=Item) +def get_item(id: int): + item = _items.get(id) + if item is None or item.deleted: + raise HTTPException(status_code=404) + return item + + +@app.get('/item', response_model=List[Item]) +def list_items( + offset: conint(ge=0) = 0, + limit: PositiveInt = 10, + min_price: Optional[confloat(ge=0.0)] = None, + max_price: Optional[confloat(ge=0.0)] = None, + show_deleted: bool = False +): + items = [ + item for item in _items.values() + if (show_deleted or not item.deleted) + and (min_price is None or item.price >= min_price) + and (max_price is None or item.price <= max_price) + ] + return sorted(items, key=lambda x: x.id)[offset:offset + limit] + + +@app.put('/item/{id}', response_model=Item) +def replace_item(id: int, item_in: ItemCreate): + if id not in _items: + raise HTTPException(status_code=404) + old_item = _items[id] + new_item = Item(id=id, name=item_in.name, price=item_in.price, deleted=old_item.deleted) + _items[id] = new_item + return new_item + + +@app.patch('/item/{id}', response_model=Item) +def patch_item(id: int, patch: ItemPatch): + item = _items.get(id) + if item is None: + raise HTTPException(status_code=404) + if item.deleted: + return JSONResponse(status_code=304, content=item.model_dump()) + + data = patch.dict(exclude_unset=True) + if not data: + return item + + if 'name' in data: + item.name = data['name'] + if 'price' in data: + item.price = data['price'] + + _items[id] = item + return item + + +@app.delete('/item/{id}') +def delete_item(id: int): + item = _items.get(id) + if item is None: + return {'ok': True} + item.deleted = True + _items[id] = item + return {'ok': True} + + +# ---------- CART ENDPOINTS ---------- + +@app.post('/cart') +def create_cart(): + global _next_cart_id + cid = _next_cart_id + _next_cart_id += 1 + _carts[cid] = {} + response = JSONResponse(status_code=201, content={'id': cid}) + response.headers['Location'] = f'/cart/{cid}' + return response + + +@app.post('/cart/{cart_id}/add/{item_id}') +def add_item_to_cart(cart_id: int, item_id: int): + if cart_id not in _carts: + raise HTTPException(status_code=404) + if item_id not in _items: + raise HTTPException(status_code=404) + + item_map = _carts[cart_id] + item_map[item_id] = item_map.get(item_id, 0) + 1 + return {'ok': True} + + +def _cart_to_response(cart_id: int) -> CartOut: + item_map = _carts.get(cart_id, {}) + items_out = [] + total_price = 0.0 + + for iid, qty in item_map.items(): + item = _items.get(iid) + available = item is not None and not item.deleted + name = item.name if item else '' + if available: + total_price += item.price * qty + items_out.append(CartItemOut(id=iid, name=name, quantity=qty, available=available)) + + return CartOut(id=cart_id, items=items_out, price=total_price) + + +@app.get('/cart/{id}', response_model=CartOut) +def get_cart(id: int): + if id not in _carts: + raise HTTPException(status_code=404) + return _cart_to_response(id) + + +@app.get('/cart', response_model=List[CartOut]) +def list_carts( + offset: conint(ge=0) = 0, + limit: PositiveInt = 10, + min_price: Optional[confloat(ge=0.0)] = None, + max_price: Optional[confloat(ge=0.0)] = None, + min_quantity: Optional[conint(ge=0)] = None, + max_quantity: Optional[conint(ge=0)] = None, +): + carts_out = [] + for cid in sorted(_carts.keys()): + cart_resp = _cart_to_response(cid) + total_quantity = sum(i.quantity for i in cart_resp.items) + if ( + (min_price is None or cart_resp.price >= min_price) + and (max_price is None or cart_resp.price <= max_price) + and (min_quantity is None or total_quantity >= min_quantity) + and (max_quantity is None or total_quantity <= max_quantity) + ): + carts_out.append(cart_resp) + return carts_out[offset:offset + limit] diff --git a/hw3/test_homework2.py b/hw3/test_homework2.py new file mode 100644 index 00000000..60a1f36a --- /dev/null +++ b/hw3/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/hw4/Dockerfile b/hw4/Dockerfile new file mode 100644 index 00000000..ac2d8c94 --- /dev/null +++ b/hw4/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +# Устанавливаем Postgres-клиент (для pg_isready) +RUN apt-get update && apt-get install -y --no-install-recommends \ + netcat-traditional postgresql-client && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Устанавливаем зависимости +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем всё приложение +COPY . . + +# Делаем скрипт ожидания исполняемым +RUN chmod +x wait-for-postgres.sh + +# Запускаем сервер через скрипт ожидания +CMD ["./wait-for-postgres.sh", "db", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/hw4/README.md b/hw4/README.md new file mode 100644 index 00000000..ba9f23c8 --- /dev/null +++ b/hw4/README.md @@ -0,0 +1,123 @@ +# ДЗ + +## Задание - REST API (3 балла) + +Реализовать REST + RPC API для выдуманного интернет магазина. + +Тесты завязаны на объект `app = FastAPI(title="Shop API")` из файла [hw2/hw/shop_api/main.py](./shop_api/main.py), поэтому реализовывайте его используя его + +Ресурсы: + +- корзина (cart) + + Пример структуры ресурса: + + ```json + { + "id": 123, // идентификатор корзины + "items": [ // список товаров в корзине + { + "id": 1, // id товара + "name": "Туалетная бумага \"Поцелуй\", рулон", // название + "quantity": 3, // количество товара в корзине + "available": true // доступе ли (не удален ли) товар + }, + { + "id": 535, + "name": "Золотая цепочка \"Abendsonne\"", + "quantity": 1, + "available": false, + }, + ], + "price": 234.4 // общая сумма заказа + } + ``` + +- товар (item) + + Пример структуры ресурса: + + ```json + { + "id": 321, // идентификатор товара + "name": "Молоко \"Буреночка\" 1л.", // наименование товара + "price": 159.99, // цена товара + "deleted": false // удален ли товар, по умолчанию false + } + ``` + +Запросы для реализации: + +- cart + - `POST cart` - создание, работает как RPC, не принимает тело, возвращает + идентификатор + - `GET /cart/{id}` - получение корзины по `id` + - `GET /cart` - получение списка корзин с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `min_quantity` - неотрицательное целое число, минимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `max_quantity` - неотрицательное целое число, максимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `POST /cart/{cart_id}/add/{item_id}` - добавление в корзину с `cart_id` + предмета с `item_id`, если товар уже есть, то увеличивается его количество +- item + - `POST /item` - добавление нового товара + - `GET /item/{id}` - получение товара по `id` + - `GET /item` - получение списка товаров с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена (опционально, + если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена (опционально, + если нет, не учитывает в фильтре) + - `show_deleted` - булевая переменная, показывать ли удаленные товары (по + умолчанию `False`) + - `PUT /item/{id}` - замена товара по `id` (создание запрещено, только замена + существующего) + - `PATCH /item/{id}` - частичное обновление товара по `id` (разрешено менять + все поля, кроме `deleted`) + - `DELETE /item/{id}` - удаление товара по `id` (товар помечается как + удаленный) + +Способ хранение данных на усмотрение. + +Более подробные детали и требования к работе методов смотрите в тестах. +Модификация тестов при потребности допускается (но не смысловая). + +Чтобы запустить тесты только для этого задания вызовите: + +```sh +pytest -vv --showlocals --strict ./hw2/test_homework_2_1.py +``` + +Если получаете ошибку на подобии `No module named 'shop_api'` +Понадобится еще такая команда, после которой можно запускать тесты: + +```sh +export PYTHONPATH=${PWD}/hw2/hw +``` + +## Доп. Задание - WebSocket (+ доп балл) + +Реализовать чат для пользователей в отдельных комнатах (в примере один на всех). + +Пользователи подключаются к чату по WebSocket ручке `/chat/{chat_name}`. +Пользователи, которые ввели один и тот же `chat_name` буду подключены к одному +чату (то есть будут получать сообщения друг от друга). Пользователи не +подключенные к диалогу не будут получать сообщения. + +Сообщение - текст в теле сообщения от клиента. Сервер должен broadcast'ить +сообщения на других пользователей в своем чате. Каждому клиенту сервер +присваивает случайное имя и дополняет каждое сообщение именем пользователя в +начале в следующем виде: `{username} :: {message}`. + +Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 diff --git a/hw4/__init__.py b/hw4/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/db.py b/hw4/db.py new file mode 100644 index 00000000..933c35b0 --- /dev/null +++ b/hw4/db.py @@ -0,0 +1,9 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +import os + +DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql+psycopg2://postgres:postgres@localhost:5432/shop') + +engine = create_engine(DATABASE_URL, echo=True, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) +Base = declarative_base() diff --git a/hw4/docker-compose.yml b/hw4/docker-compose.yml new file mode 100644 index 00000000..4f7af010 --- /dev/null +++ b/hw4/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.9' + +services: + shop_api: + build: . + container_name: shop_api + ports: + - "8000:8000" + depends_on: + - db + environment: + - DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/shop + command: ["./wait-for-postgres.sh", "db", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + + db: + image: postgres:16 + container_name: shop_db + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: shop + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/hw4/main.py b/hw4/main.py new file mode 100644 index 00000000..541b54c6 --- /dev/null +++ b/hw4/main.py @@ -0,0 +1,152 @@ +from fastapi import FastAPI, HTTPException, Depends +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session +from db import Base, engine, SessionLocal +import models +from models import Item, Cart, CartItem +from schemas import ItemCreate, ItemPatch, Item as ItemSchema, CartOut, CartItemOut +from typing import List, Optional +from pydantic import conint, PositiveInt, confloat + +app = FastAPI() + +Base.metadata.create_all(bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +# ---------- ITEM ENDPOINTS ---------- + +@app.post('/item', response_model=ItemSchema, status_code=201) +def create_item(item_in: ItemCreate, db: Session = Depends(get_db)): + item = Item(name=item_in.name, price=item_in.price) + db.add(item) + db.commit() + db.refresh(item) + return item + + +@app.get('/item/{id}', response_model=ItemSchema) +def get_item(id: int, db: Session = Depends(get_db)): + item = db.get(Item, id) + if not item or item.deleted: + raise HTTPException(status_code=404) + return item + + +@app.get('/item', response_model=List[ItemSchema]) +def list_items( + offset: conint(ge=0) = 0, + limit: PositiveInt = 10, + min_price: Optional[confloat(ge=0.0)] = None, + max_price: Optional[confloat(ge=0.0)] = None, + show_deleted: bool = False, + db: Session = Depends(get_db) +): + query = db.query(Item) + if not show_deleted: + query = query.filter(Item.deleted == False) + if min_price is not None: + query = query.filter(Item.price >= min_price) + if max_price is not None: + query = query.filter(Item.price <= max_price) + + return query.offset(offset).limit(limit).all() + + +@app.put('/item/{id}', response_model=ItemSchema) +def replace_item(id: int, item_in: ItemCreate, db: Session = Depends(get_db)): + item = db.get(Item, id) + if not item: + raise HTTPException(status_code=404) + item.name = item_in.name + item.price = item_in.price + db.commit() + db.refresh(item) + return item + + +@app.patch('/item/{id}', response_model=ItemSchema) +def patch_item(id: int, patch: ItemPatch, db: Session = Depends(get_db)): + item = db.get(Item, id) + if not item: + raise HTTPException(status_code=404) + if item.deleted: + return JSONResponse(status_code=304, content=item.__dict__) + + data = patch.dict(exclude_unset=True) + for key, value in data.items(): + setattr(item, key, value) + + db.commit() + db.refresh(item) + return item + + +@app.delete('/item/{id}') +def delete_item(id: int, db: Session = Depends(get_db)): + item = db.get(Item, id) + if not item: + return {'ok': True} + item.deleted = True + db.commit() + return {'ok': True} + + +# ---------- CART ENDPOINTS ---------- + +@app.post('/cart') +def create_cart(db: Session = Depends(get_db)): + cart = Cart() + db.add(cart) + db.commit() + db.refresh(cart) + response = JSONResponse(status_code=201, content={'id': cart.id}) + response.headers['Location'] = f'/cart/{cart.id}' + return response + + +@app.post('/cart/{cart_id}/add/{item_id}') +def add_item_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + cart = db.get(Cart, cart_id) + item = db.get(Item, item_id) + if not cart or not item: + raise HTTPException(status_code=404) + + cart_item = db.query(CartItem).filter_by(cart_id=cart_id, item_id=item_id).first() + if cart_item: + cart_item.quantity += 1 + else: + cart_item = CartItem(cart_id=cart_id, item_id=item_id, quantity=1) + db.add(cart_item) + db.commit() + return {'ok': True} + + +def _cart_to_response(cart: Cart, db: Session) -> CartOut: + items_out = [] + total_price = 0.0 + + for cart_item in cart.items: + item = db.get(Item, cart_item.item_id) + available = item is not None and not item.deleted + name = item.name if item else '' + if available: + total_price += item.price * cart_item.quantity + items_out.append(CartItemOut(id=cart_item.item_id, name=name, quantity=cart_item.quantity, available=available)) + + return CartOut(id=cart.id, items=items_out, price=total_price) + + +@app.get('/cart/{id}', response_model=CartOut) +def get_cart(id: int, db: Session = Depends(get_db)): + cart = db.get(Cart, id) + if not cart: + raise HTTPException(status_code=404) + return _cart_to_response(cart, db) diff --git a/hw4/models.py b/hw4/models.py new file mode 100644 index 00000000..4b889dac --- /dev/null +++ b/hw4/models.py @@ -0,0 +1,29 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey +from sqlalchemy.orm import relationship +from db import Base + +class Item(Base): + __tablename__ = 'items' + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + + +class Cart(Base): + __tablename__ = 'carts' + + id = Column(Integer, primary_key=True, index=True) + items = relationship('CartItem', back_populates='cart', cascade='all, delete-orphan') + + +class CartItem(Base): + __tablename__ = 'cart_items' + + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, ForeignKey('carts.id')) + item_id = Column(Integer, ForeignKey('items.id')) + quantity = Column(Integer, nullable=False, default=1) + + cart = relationship('Cart', back_populates='items') diff --git a/hw4/requirements.txt b/hw4/requirements.txt new file mode 100644 index 00000000..a21276d6 --- /dev/null +++ b/hw4/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +sqlalchemy +psycopg2-binary +pydantic diff --git a/hw4/schemas.py b/hw4/schemas.py new file mode 100644 index 00000000..afe96f12 --- /dev/null +++ b/hw4/schemas.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel, confloat, conint, PositiveInt +from typing import List, Optional + +class ItemBase(BaseModel): + name: str + price: confloat(ge=0.0) + + class Config: + extra = 'forbid' + + +class ItemCreate(ItemBase): + pass + + +class ItemPatch(BaseModel): + name: Optional[str] = None + price: Optional[confloat(ge=0.0)] = None + + class Config: + extra = 'forbid' + + +class Item(ItemBase): + id: int + deleted: bool + + class Config: + orm_mode = True + + +class CartItemOut(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartOut(BaseModel): + id: int + items: List[CartItemOut] + price: float diff --git a/hw4/test_homework2.py b/hw4/test_homework2.py new file mode 100644 index 00000000..3c60aeb0 --- /dev/null +++ b/hw4/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 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/hw4/transactions_demo.py b/hw4/transactions_demo.py new file mode 100644 index 00000000..a7e9164b --- /dev/null +++ b/hw4/transactions_demo.py @@ -0,0 +1,15 @@ +from sqlalchemy import create_engine, text + +# Подключение напрямую к PostgreSQL +engine = create_engine("postgresql+psycopg2://postgres:postgres@localhost:5432/shop", echo=True, future=True) + +# --- Dirty Read (READ UNCOMMITTED) --- +with engine.connect() as conn1, engine.connect() as conn2: + conn1.execute(text("BEGIN")) + conn1.execute(text("UPDATE items SET price = price + 100 WHERE id = 1")) + + conn2.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + result = conn2.execute(text("SELECT price FROM items WHERE id = 1")).scalar() + print("Dirty read price:", result) + + conn1.rollback() diff --git a/hw4/wait-for-postgres.sh b/hw4/wait-for-postgres.sh new file mode 100755 index 00000000..e070d98c --- /dev/null +++ b/hw4/wait-for-postgres.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +host="$1" +shift +cmd="$@" + +until pg_isready -h "$host" -U "postgres" > /dev/null 2>&1; do + echo "⏳ Waiting for Postgres ($host)..." + sleep 1 +done + +echo "✅ Postgres is ready — starting app..." +exec $cmd diff --git a/hw5/README.md b/hw5/README.md new file mode 100644 index 00000000..ba9f23c8 --- /dev/null +++ b/hw5/README.md @@ -0,0 +1,123 @@ +# ДЗ + +## Задание - REST API (3 балла) + +Реализовать REST + RPC API для выдуманного интернет магазина. + +Тесты завязаны на объект `app = FastAPI(title="Shop API")` из файла [hw2/hw/shop_api/main.py](./shop_api/main.py), поэтому реализовывайте его используя его + +Ресурсы: + +- корзина (cart) + + Пример структуры ресурса: + + ```json + { + "id": 123, // идентификатор корзины + "items": [ // список товаров в корзине + { + "id": 1, // id товара + "name": "Туалетная бумага \"Поцелуй\", рулон", // название + "quantity": 3, // количество товара в корзине + "available": true // доступе ли (не удален ли) товар + }, + { + "id": 535, + "name": "Золотая цепочка \"Abendsonne\"", + "quantity": 1, + "available": false, + }, + ], + "price": 234.4 // общая сумма заказа + } + ``` + +- товар (item) + + Пример структуры ресурса: + + ```json + { + "id": 321, // идентификатор товара + "name": "Молоко \"Буреночка\" 1л.", // наименование товара + "price": 159.99, // цена товара + "deleted": false // удален ли товар, по умолчанию false + } + ``` + +Запросы для реализации: + +- cart + - `POST cart` - создание, работает как RPC, не принимает тело, возвращает + идентификатор + - `GET /cart/{id}` - получение корзины по `id` + - `GET /cart` - получение списка корзин с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `min_quantity` - неотрицательное целое число, минимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `max_quantity` - неотрицательное целое число, максимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `POST /cart/{cart_id}/add/{item_id}` - добавление в корзину с `cart_id` + предмета с `item_id`, если товар уже есть, то увеличивается его количество +- item + - `POST /item` - добавление нового товара + - `GET /item/{id}` - получение товара по `id` + - `GET /item` - получение списка товаров с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена (опционально, + если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена (опционально, + если нет, не учитывает в фильтре) + - `show_deleted` - булевая переменная, показывать ли удаленные товары (по + умолчанию `False`) + - `PUT /item/{id}` - замена товара по `id` (создание запрещено, только замена + существующего) + - `PATCH /item/{id}` - частичное обновление товара по `id` (разрешено менять + все поля, кроме `deleted`) + - `DELETE /item/{id}` - удаление товара по `id` (товар помечается как + удаленный) + +Способ хранение данных на усмотрение. + +Более подробные детали и требования к работе методов смотрите в тестах. +Модификация тестов при потребности допускается (но не смысловая). + +Чтобы запустить тесты только для этого задания вызовите: + +```sh +pytest -vv --showlocals --strict ./hw2/test_homework_2_1.py +``` + +Если получаете ошибку на подобии `No module named 'shop_api'` +Понадобится еще такая команда, после которой можно запускать тесты: + +```sh +export PYTHONPATH=${PWD}/hw2/hw +``` + +## Доп. Задание - WebSocket (+ доп балл) + +Реализовать чат для пользователей в отдельных комнатах (в примере один на всех). + +Пользователи подключаются к чату по WebSocket ручке `/chat/{chat_name}`. +Пользователи, которые ввели один и тот же `chat_name` буду подключены к одному +чату (то есть будут получать сообщения друг от друга). Пользователи не +подключенные к диалогу не будут получать сообщения. + +Сообщение - текст в теле сообщения от клиента. Сервер должен broadcast'ить +сообщения на других пользователей в своем чате. Каждому клиенту сервер +присваивает случайное имя и дополняет каждое сообщение именем пользователя в +начале в следующем виде: `{username} :: {message}`. + +Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 diff --git a/hw5/__init__.py b/hw5/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/requirements.txt b/hw5/requirements.txt new file mode 100644 index 00000000..207dcf5c --- /dev/null +++ b/hw5/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/hw5/shop_api/__init__.py b/hw5/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/shop_api/main.py b/hw5/shop_api/main.py new file mode 100644 index 00000000..828eec0a --- /dev/null +++ b/hw5/shop_api/main.py @@ -0,0 +1,199 @@ +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel, confloat, conint, PositiveInt +from typing import Dict, List, Optional + +app = FastAPI() + + +# ---------- MODELS ---------- + +class ItemBase(BaseModel): + name: str + price: confloat(ge=0.0) + + model_config = {'extra': 'forbid'} + + +class ItemCreate(ItemBase): + pass + + +class ItemPatch(BaseModel): + name: Optional[str] = None + price: Optional[confloat(ge=0.0)] = None + + model_config = {'extra': 'forbid'} + + +class Item(ItemBase): + id: int + deleted: bool = False + + +class CartItemOut(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartOut(BaseModel): + id: int + items: List[CartItemOut] + price: float + + +# ---------- IN-MEMORY "БАЗА" ---------- + +_next_item_id = 1 +_items: Dict[int, Item] = {} + +_next_cart_id = 1 +_carts: Dict[int, Dict[int, int]] = {} + + +# ---------- ITEM ENDPOINTS ---------- + +@app.post('/item', response_model=Item, status_code=201) +def create_item(item_in: ItemCreate): + global _next_item_id + item = Item(id=_next_item_id, name=item_in.name, price=item_in.price, deleted=False) + _items[_next_item_id] = item + _next_item_id += 1 + return item + + +@app.get('/item/{id}', response_model=Item) +def get_item(id: int): + item = _items.get(id) + if item is None or item.deleted: + raise HTTPException(status_code=404) + return item + + +@app.get('/item', response_model=List[Item]) +def list_items( + offset: conint(ge=0) = 0, + limit: PositiveInt = 10, + min_price: Optional[confloat(ge=0.0)] = None, + max_price: Optional[confloat(ge=0.0)] = None, + show_deleted: bool = False +): + items = [ + item for item in _items.values() + if (show_deleted or not item.deleted) + and (min_price is None or item.price >= min_price) + and (max_price is None or item.price <= max_price) + ] + return sorted(items, key=lambda x: x.id)[offset:offset + limit] + + +@app.put('/item/{id}', response_model=Item) +def replace_item(id: int, item_in: ItemCreate): + if id not in _items: + raise HTTPException(status_code=404) + old_item = _items[id] + new_item = Item(id=id, name=item_in.name, price=item_in.price, deleted=old_item.deleted) + _items[id] = new_item + return new_item + + +@app.patch('/item/{id}', response_model=Item) +def patch_item(id: int, patch: ItemPatch): + item = _items.get(id) + if item is None: + raise HTTPException(status_code=404) + if item.deleted: + return JSONResponse(status_code=304, content=item.model_dump()) + + data = patch.model_dump(exclude_unset=True) + if not data: + return item + + if 'name' in data: + item.name = data['name'] + if 'price' in data: + item.price = data['price'] + + _items[id] = item + return item + + +@app.delete('/item/{id}') +def delete_item(id: int): + item = _items.get(id) + if item is None: + return {'ok': True} + item.deleted = True + _items[id] = item + return {'ok': True} + + +# ---------- CART ENDPOINTS ---------- + +@app.post('/cart') +def create_cart(): + global _next_cart_id + cid = _next_cart_id + _next_cart_id += 1 + _carts[cid] = {} + response = JSONResponse(status_code=201, content={'id': cid}) + response.headers['Location'] = f'/cart/{cid}' + return response + + +@app.post('/cart/{cart_id}/add/{item_id}') +def add_item_to_cart(cart_id: int, item_id: int): + if cart_id not in _carts or item_id not in _items: + raise HTTPException(status_code=404) + + item_map = _carts[cart_id] + item_map[item_id] = item_map.get(item_id, 0) + 1 + return {'ok': True} + + +def _cart_to_response(cart_id: int) -> CartOut: + item_map = _carts.get(cart_id, {}) + items_out = [] + total_price = 0.0 + + for iid, qty in item_map.items(): + item = _items.get(iid) + available = item is not None and not item.deleted + name = item.name if item else '' + if available: + total_price += item.price * qty + items_out.append(CartItemOut(id=iid, name=name, quantity=qty, available=available)) + + return CartOut(id=cart_id, items=items_out, price=total_price) + + +@app.get('/cart/{id}', response_model=CartOut) +def get_cart(id: int): + if id not in _carts: + raise HTTPException(status_code=404) + return _cart_to_response(id) + + +@app.get('/cart', response_model=List[CartOut]) +def list_carts( + offset: conint(ge=0) = 0, + limit: PositiveInt = 10, + min_price: Optional[confloat(ge=0.0)] = None, + max_price: Optional[confloat(ge=0.0)] = None, + min_quantity: Optional[conint(ge=0)] = None, + max_quantity: Optional[conint(ge=0)] = None, +): + carts_out = [] + for cid in sorted(_carts.keys()): + cart_resp = _cart_to_response(cid) + total_quantity = sum(i.quantity for i in cart_resp.items) + if ( + (min_price is None or cart_resp.price >= min_price) + and (max_price is None or cart_resp.price <= max_price) + and (min_quantity is None or total_quantity >= min_quantity) + and (max_quantity is None or total_quantity <= max_quantity) + ): + carts_out.append(cart_resp) + return carts_out[offset:offset + limit] \ No newline at end of file diff --git a/hw5/shop_api/schemas.py b/hw5/shop_api/schemas.py new file mode 100644 index 00000000..fd5be9ca --- /dev/null +++ b/hw5/shop_api/schemas.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel, Field +from typing import Optional, List + + +# --- Модели товаров --- + +class ItemCreate(BaseModel): + name: str = Field(..., min_length=1) + price: float = Field(..., gt=0) + + +class ItemUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1) + price: Optional[float] = Field(None, gt=0) + available: Optional[bool] = True + + +# --- Модели корзины --- + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartCreate(BaseModel): + items: List[CartItem] = [] + price: float = 0.0 \ No newline at end of file diff --git a/hw5/tests/test_shop_api.py b/hw5/tests/test_shop_api.py new file mode 100644 index 00000000..ea7aaa08 --- /dev/null +++ b/hw5/tests/test_shop_api.py @@ -0,0 +1,238 @@ +from http import HTTPStatus +from typing import Any, Optional +from uuid import uuid4 +import sys +from pathlib import Path + +# Добавляем корень проекта в sys.path +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import pytest +from faker import Faker +from fastapi.testclient import TestClient +from pydantic import BaseModel, ValidationError, Field, ConfigDict + +from shop_api.main import app +from shop_api.schemas import ItemCreate, ItemUpdate, CartCreate + +client = TestClient(app) +faker = Faker() + + +# === Фикстуры === + +@pytest.fixture() +def existing_empty_cart_id() -> int: + response = client.post("/cart") + assert response.status_code == 201 + return response.json()["id"] + + +@pytest.fixture() +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(5) + ] + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture() +def existing_not_empty_cart_id(existing_items: list[int]) -> int: + response = client.post("/cart") + assert response.status_code == 201 + cart_id = response.json()["id"] + for item_id in existing_items[:2]: + r = client.post(f"/cart/{cart_id}/add/{item_id}") + assert r.status_code == 200 + return cart_id + + +@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 + + +# === Тесты Pydantic схем === + +def test_item_create_schema_valid(): + data = {"name": "Valid Item", "price": 10.0} + item = ItemCreate(**data) + assert item.name == "Valid Item" + assert item.price == 10.0 + + +def test_item_create_schema_invalid(): + invalid_data = [ + {}, + {"name": "", "price": -5}, + {"name": "Valid", "price": "string"}, + ] + for data in invalid_data: + with pytest.raises(ValidationError): + ItemCreate(**data) + + +def test_item_update_schema_valid(): + data = {"name": "Updated", "price": 15.0} + item = ItemUpdate(**data) + assert item.name == "Updated" + assert item.price == 15.0 + + +def test_item_update_schema_invalid(): + # Строгая модель для тестов с бизнес-валидацией + class StrictItemUpdate(BaseModel): + model_config = ConfigDict(extra='forbid') # запрещаем лишние поля + + name: Optional[str] = Field(None, min_length=1) + price: Optional[float] = Field(None, gt=0) + available: Optional[bool] = True + + invalid_data = [ + {"price": -10}, # price <= 0 + {"name": ""}, # пустое имя + {"name": "Valid", "price": "text"}, # price не число + {"deleted": "not_bool"}, # лишнее поле + {"odd": "value"}, # лишнее поле + ] + for data in invalid_data: + with pytest.raises(ValidationError): + StrictItemUpdate(**data) + + +def test_cart_create_schema(): + cart = CartCreate() + assert cart is not None + + +# === Эндпоинты === + +def test_post_cart(): + response = client.post("/cart") + assert response.status_code == HTTPStatus.CREATED + assert "id" in response.json() + assert "location" in response.headers + + +def test_get_cart(existing_empty_cart_id, existing_not_empty_cart_id): + # пустая корзина + response = client.get(f"/cart/{existing_empty_cart_id}") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert "items" in data and "price" in data + assert len(data["items"]) == 0 + + # непустая корзина + response = client.get(f"/cart/{existing_not_empty_cart_id}") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data["items"]) > 0 + + +@pytest.mark.parametrize( + "query,status_code", + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000}, HTTPStatus.OK), + ({"max_price": 20}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query, status_code): + response = client.get("/cart", params=query) + assert response.status_code == status_code + + +def test_post_item_endpoint(): + data = {"name": "Test Item", "price": 9.99} + response = client.post("/item", json=data) + assert response.status_code == HTTPStatus.CREATED + assert response.json()["name"] == data["name"] + + +def test_get_item_endpoint(existing_item): + 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( + "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, body, status_code): + 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: + expected = existing_item.copy() + expected.update(body) + assert response.json() == expected + + +@pytest.mark.parametrize( + "item,body,status_code", + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "New", "price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "New", "price": 9.99, "deleted": True}, HTTPStatus.UNPROCESSABLE_ENTITY), + ("existing_item", {"name": "New", "price": 9.99, "odd": "value"}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_patch_item(request, item, body, status_code): + item_data = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + assert response.status_code == status_code + + +def test_delete_item(existing_item): + 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 \ No newline at end of file