diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 00000000..26369312 --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,38 @@ +name: "HW5 Tests" + +on: + pull_request: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + push: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + +jobs: + test-hw5: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + + steps: + - name: Check out repository 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: hw2/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + working-directory: hw2/hw + env: + PYTHONPATH: ${{ github.workspace }}/hw2/hw + run: | + pytest tests/test_api.py --cov=shop_api --cov-report=term-missing --cov-fail-under=95 \ No newline at end of file diff --git a/hw1/app.py b/hw1/app.py index 6107b870..3332fe46 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,27 @@ from typing import Any, Awaitable, Callable +import json +import math + + +def fibonacchi(n): + if n < 0: + raise Exception() + prev, ans = 0, 1 + for _ in range(n): + prev, ans = ans, ans + prev + return ans + + +def factorial(n): + if n < 0: + raise Exception() + return math.factorial(n) + + +def mean(arr): + if len(arr) == 0: + raise Exception() + return sum(arr) / len(arr) async def application( @@ -12,7 +35,53 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if 'path' in scope and scope['method'] == 'GET': + splitted_path = scope['path'].split('/') + else: + splitted_path = [None, None] + body = {"result": None} + status = 200 + function = None + try: + if splitted_path[1] == 'fibonacci': + val = splitted_path[2] + assert '.' not in val + param = int(val) + function = fibonacchi + elif splitted_path[1] == 'factorial': + key, val = scope['query_string'].decode('utf-8').split('=') + assert key == 'n' + assert '.' not in val + param = int(val) + function = factorial + elif splitted_path[1] == 'mean': + if scope['query_string'] != b'': + key, val = scope['query_string'].decode('utf-8').split('=') + assert key == 'numbers' + else: + val = await receive() + val = val['body'].decode('utf-8') + assert val[0] == '[' and val[-1] == ']' + val = val[1:-1] + param = [] + if val != '': + for num in val.split(','): + param.append(float(num)) + function = mean + else: + status = 404 + except: + status = 422 + if function is not None: + try: + body = {"result": function(param)} + except: + status = 400 + await send({'type': 'http.response.start', + 'status': status, + 'headers': [[b'content-type', b'application/json']]}) + await send({'type': 'http.response.body', 'body': json.dumps(body).encode('utf-8')}) + if __name__ == "__main__": import uvicorn diff --git a/hw2/__init__.py b/hw2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/chat/__init__.py b/hw2/hw/chat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/chat/client.py b/hw2/hw/chat/client.py new file mode 100644 index 00000000..e929638c --- /dev/null +++ b/hw2/hw/chat/client.py @@ -0,0 +1,35 @@ +import asyncio +import websockets + + +async def receive_messages(websocket): + try: + async for message in websocket: + print(message) + except: + pass + + +async def send_messages(websocket): + loop = asyncio.get_running_loop() + while True: + try: + message = await loop.run_in_executor(None, lambda: input()) + await websocket.send(message) + except: + break + + +async def main(): + chat_name = input("Chat name: ").strip() + + uri = f"ws://127.0.0.1:8000/chat/{chat_name}" + async with websockets.connect(uri) as websocket: + await asyncio.gather( + receive_messages(websocket), + send_messages(websocket) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/hw2/hw/chat/server.py b/hw2/hw/chat/server.py new file mode 100644 index 00000000..2b8de5b6 --- /dev/null +++ b/hw2/hw/chat/server.py @@ -0,0 +1,47 @@ +from collections import defaultdict +from typing import Dict, List + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +import uuid + + +app = FastAPI() + + +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[str, List[WebSocket]] = defaultdict(list) + + async def connect(self, websocket: WebSocket, chat_name: str) -> str: + await websocket.accept() + username = str(uuid.uuid4()) + self.active_connections[chat_name].append(websocket) + return username + + def disconnect(self, websocket: WebSocket, chat_name: str): + self.active_connections[chat_name].remove(websocket) + if chat_name in self.active_connections and len(self.active_connections[chat_name]) == 0: + del self.active_connections[chat_name] + + async def broadcast(self, message: str, chat_name: str, websocket = None): + for connection in self.active_connections[chat_name]: + if connection != websocket: + await connection.send_text(message) + + +manager = ConnectionManager() + + +@app.websocket("/chat/{chat_name}") +async def websocket_endpoint(websocket: WebSocket, chat_name: str): + username = await manager.connect(websocket, chat_name) + + await manager.broadcast(f"--- {username} joined the chat ---", chat_name) + + try: + while True: + data = await websocket.receive_text() + await manager.broadcast(f"{username} :: {data}", chat_name, websocket) + except WebSocketDisconnect: + manager.disconnect(websocket, chat_name) + await manager.broadcast(f"--- {username} left the chat ---", chat_name) \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..45ce7ae4 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -5,5 +5,6 @@ uvicorn>=0.24.0 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 +pytest-cov httpx>=0.27.2 Faker>=37.8.0 diff --git a/hw2/hw/shop_api/api/__init__.py b/hw2/hw/shop_api/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/api/cart/__init__.py b/hw2/hw/shop_api/api/cart/__init__.py new file mode 100644 index 00000000..a81cce79 --- /dev/null +++ b/hw2/hw/shop_api/api/cart/__init__.py @@ -0,0 +1 @@ +from .routes import router \ No newline at end of file diff --git a/hw2/hw/shop_api/api/cart/contracts.py b/hw2/hw/shop_api/api/cart/contracts.py new file mode 100644 index 00000000..cb38b17b --- /dev/null +++ b/hw2/hw/shop_api/api/cart/contracts.py @@ -0,0 +1,33 @@ +from typing import List + +from pydantic import BaseModel + +from hw2.hw.shop_api.store.models import ( + CartEntity, + AddItemInfo +) + + +class ItemCartResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartResponse(BaseModel): + id: int + items: List[ItemCartResponse] + price: float + + @staticmethod + def from_entity(entity: CartEntity): + return CartResponse( + id=entity.id, + items=[ItemCartResponse(id=item_id, + name=info.name, + quantity=info.quantity, + available=info.available) + for item_id, info in entity.info.items.items()], + price=entity.info.price + ) diff --git a/hw2/hw/shop_api/api/cart/routes.py b/hw2/hw/shop_api/api/cart/routes.py new file mode 100644 index 00000000..d53646cb --- /dev/null +++ b/hw2/hw/shop_api/api/cart/routes.py @@ -0,0 +1,78 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Request +from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat + +from hw2.hw.shop_api import store + +from .contracts import CartResponse + +router = APIRouter(prefix="/cart") + + +@router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_cart(_: Request, response: Response) -> CartResponse: + entity = store.add_cart() + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/cart/{entity.id}" + + return CartResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(id: int) -> CartResponse: + entity = store.get_one_cart(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] = 0, + max_price: Annotated[NonNegativeFloat, Query()] = 1e10, + min_quantity: Annotated[NonNegativeInt, Query()] = 0, + max_quantity: Annotated[NonNegativeInt, Query()] = 1e10, +) -> list[CartResponse]: + return [CartResponse.from_entity(e) for e in store.get_many_carts(offset, limit, min_price, max_price, min_quantity, max_quantity)] + + +@router.post( + "/{cart_id}/add/{item_id}", + status_code=HTTPStatus.CREATED, +) +async def post_item_to_cart(cart_id: int, item_id: int, response: Response) -> CartResponse: + entity = store.add_item_to_cart(cart_id, item_id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /{cart_id}/cart/{item_id} was not found", + ) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/cart/{entity.id}" + + return CartResponse.from_entity(entity) diff --git a/hw2/hw/shop_api/api/item/__init__.py b/hw2/hw/shop_api/api/item/__init__.py new file mode 100644 index 00000000..a81cce79 --- /dev/null +++ b/hw2/hw/shop_api/api/item/__init__.py @@ -0,0 +1 @@ +from .routes import router \ No newline at end of file diff --git a/hw2/hw/shop_api/api/item/contracts.py b/hw2/hw/shop_api/api/item/contracts.py new file mode 100644 index 00000000..4faee679 --- /dev/null +++ b/hw2/hw/shop_api/api/item/contracts.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from hw2.hw.shop_api.store.models import ( + ItemInfo, + ItemEntity, + PatchItemInfo +) + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted + ) + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) diff --git a/hw2/hw/shop_api/api/item/routes.py b/hw2/hw/shop_api/api/item/routes.py new file mode 100644 index 00000000..c255df58 --- /dev/null +++ b/hw2/hw/shop_api/api/item/routes.py @@ -0,0 +1,114 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat, StrictBool + +from hw2.hw.shop_api import store + +from .contracts import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter(prefix="/item") + + +@router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_item(info: ItemRequest, response: Response) -> ItemResponse: + entity = store.add_item(info.as_item_info()) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/item/{entity.id}" + + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id(id: int) -> ItemResponse: + entity = store.get_one_item(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] = 0, + max_price: Annotated[NonNegativeFloat, Query()] = 1e10, + show_deleted: Annotated[bool, Query()] = False +) -> list[ItemResponse]: + return [ItemResponse.from_entity(e) for e in store.get_many_items(offset, limit, min_price, max_price, show_deleted)] + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + } +) +async def put_item( + id: int, + info: ItemRequest +) -> ItemResponse: + entity = store.update_item(id, info.as_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item(id: int, info: PatchItemRequest) -> ItemResponse: + entity = store.patch_item(id, info.as_patch_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item(id: int) -> Response: + store.delete_item(id) + return Response("") diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..0a71edf8 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,8 @@ from fastapi import FastAPI +from hw2.hw.shop_api.api import cart, item + app = FastAPI(title="Shop API") + +app.include_router(cart.router) +app.include_router(item.router) diff --git a/hw2/hw/shop_api/store/__init__.py b/hw2/hw/shop_api/store/__init__.py new file mode 100644 index 00000000..448a069f --- /dev/null +++ b/hw2/hw/shop_api/store/__init__.py @@ -0,0 +1,22 @@ +from .models import ItemInfo, ItemEntity, PatchItemInfo, CartInfo, CartEntity, AddItemInfo +from .queries import (add_item, delete_item, get_one_item, get_many_items, update_item, patch_item, + add_cart, get_one_cart, get_many_carts, add_item_to_cart) + +__all__ = [ + "ItemInfo", + "ItemEntity", + "PatchItemInfo", + "CartInfo", + "CartEntity", + "AddItemInfo", + "add_item", + "delete_item", + "get_one_item", + "get_many_items", + "update_item", + "patch_item", + "add_cart", + "get_one_cart", + "get_many_carts", + "add_item_to_cart" +] \ No newline at end of file diff --git a/hw2/hw/shop_api/store/models.py b/hw2/hw/shop_api/store/models.py new file mode 100644 index 00000000..c43cc7a6 --- /dev/null +++ b/hw2/hw/shop_api/store/models.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import Dict + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None + + +@dataclass(slots=True) +class ItemCartInfo: + name: str + quantity: int + available: bool + + +@dataclass(slots=True) +class CartInfo: + items: Dict[int, ItemCartInfo] + price: float + + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo + + +@dataclass(slots=True) +class AddItemInfo: + item_id: int diff --git a/hw2/hw/shop_api/store/queries.py b/hw2/hw/shop_api/store/queries.py new file mode 100644 index 00000000..8bb1ad5f --- /dev/null +++ b/hw2/hw/shop_api/store/queries.py @@ -0,0 +1,134 @@ +from typing import Iterable + +from hw2.hw.shop_api.store.models import * + + +_data_items = dict[int, ItemInfo]() +_data_carts = dict[int, CartInfo]() + + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add_item(info: ItemInfo) -> ItemEntity: + _id = next(_id_generator) + _data_items[_id] = info + + return ItemEntity(_id, info) + + +def delete_item(id: int) -> None: + if id in _data_items: + if _data_items[id].deleted: + return + _data_items[id].deleted = True + else: + return + for cart_id, cart_info in _data_carts.items(): + if id in cart_info.items: + cart_info.items[id].available = False + cart_info.price -= _data_items[id].price * cart_info.items[id].quantity + + +def get_one_item(id: int) -> ItemEntity | None: + if id not in _data_items or _data_items[id].deleted: + return None + + return ItemEntity(id, _data_items[id]) + + +def get_many_items(offset: int = 0, + limit: int = 10, + min_price: float = 0, + max_price: float = 1e10, + show_deleted: bool = False) -> Iterable[ItemEntity]: + curr = 0 + for id, info in _data_items.items(): + if offset <= curr < offset + limit \ + and min_price <= info.price <= max_price \ + and (not info.deleted or show_deleted): + yield ItemEntity(id, info) + + curr += 1 + + +def update_item(id: int, info: ItemInfo) -> ItemEntity | None: + if id not in _data_items: + return None + + old_info = _data_items[id] + _data_items[id] = info + + for cart_id, cart_info in _data_carts.items(): + if id in cart_info.items: + cart_info.items[id].name = _data_items[id].name + cart_info.price -= old_info.price * cart_info.items[id].quantity + cart_info.price += _data_items[id].price * cart_info.items[id].quantity + + return ItemEntity(id, info) + + +def patch_item(id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if id not in _data_items or _data_items[id].deleted: + return None + old_price = _data_items[id].price + if patch_info.name is not None: + _data_items[id].name = patch_info.name + if patch_info.price is not None: + _data_items[id].price = patch_info.price + for cart_id, cart_info in _data_carts.items(): + if id in cart_info.items: + cart_info.items[id].name = _data_items[id].name + cart_info.price -= old_price * cart_info.items[id].quantity + cart_info.price += _data_items[id].price * cart_info.items[id].quantity + return ItemEntity(id, _data_items[id]) + + +def add_cart() -> CartEntity: + _id = next(_id_generator) + _data_carts[_id] = CartInfo({}, 0) + + return CartEntity(_id, _data_carts[_id]) + + +def get_one_cart(id: int) -> CartEntity | None: + if id not in _data_carts: + return None + + return CartEntity(id, _data_carts[id]) + + +def get_many_carts(offset: int = 0, + limit: int = 10, + min_price: float = 0, + max_price: float = 1e10, + min_quantity: int = 0, + max_quantity: int = 1e10) -> Iterable[CartEntity]: + curr = 0 + for id, info in _data_carts.items(): + if offset <= curr < curr + limit \ + and min_price <= info.price <= max_price \ + and min_quantity <= sum([item.quantity for _, item in info.items.items() if item.available]) <= max_quantity: + yield CartEntity(id, info) + + curr += 1 + + +def add_item_to_cart(cart_id: int, item_id: int) -> CartEntity | None: + if cart_id not in _data_carts: + return None + + if item_id in _data_carts[cart_id].items: + _data_carts[cart_id].items[item_id].quantity += 1 + else: + _data_carts[cart_id].items[item_id] = ItemCartInfo(_data_items[item_id].name, 1, True) + _data_carts[cart_id].price += _data_items[item_id].price + return CartEntity(cart_id, _data_carts[cart_id]) diff --git a/hw2/hw/tests/__init__.py b/hw2/hw/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/tests/conftest.py b/hw2/hw/tests/conftest.py new file mode 100644 index 00000000..018a4f22 --- /dev/null +++ b/hw2/hw/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest +from fastapi.testclient import TestClient + +from hw2.hw.shop_api.main import app + +from hw2.hw.shop_api.store import queries as queries_module + + +@pytest.fixture(autouse=True) +def clear_storage_before_each_test(monkeypatch): + fresh_items_db = {} + fresh_carts_db = {} + + monkeypatch.setattr(queries_module, "_data_items", fresh_items_db) + monkeypatch.setattr(queries_module, "_data_carts", fresh_carts_db) + + monkeypatch.setattr(queries_module, "_id_generator", queries_module.int_id_generator()) + + +@pytest.fixture(scope="session") +def client() -> TestClient: + return TestClient(app) diff --git a/hw2/hw/tests/test_api.py b/hw2/hw/tests/test_api.py new file mode 100644 index 00000000..68cdf83b --- /dev/null +++ b/hw2/hw/tests/test_api.py @@ -0,0 +1,207 @@ +from http import HTTPStatus + + +def test_create_item_success(client): + response = client.post("/item/", json={"name": "Test Item", "price": 10.5}) + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data["name"] == "Test Item" + assert data["price"] == 10.5 + assert "id" in data + assert response.headers["location"] == f"/item/{data['id']}" + + +def test_get_item_not_found(client): + response = client.get("/item/999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_get_item_by_id_success(client): + create_response = client.post("/item/", json={"name": "Another Item", "price": 99.9}) + item_id = create_response.json()["id"] + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["id"] == item_id + assert data["name"] == "Another Item" + + +def test_get_items_list(client): + client.post("/item/", json={"name": "Item 1", "price": 10}) + client.post("/item/", json={"name": "Item 2", "price": 20}) + client.post("/item/", json={"name": "Item 3", "price": 30}) + + response = client.get("/item/") + assert response.status_code == HTTPStatus.OK + assert len(response.json()) == 3 + + response = client.get("/item/?min_price=15&max_price=25") + assert len(response.json()) == 1 + assert response.json()[0]["name"] == "Item 2" + + response = client.get("/item/?offset=1&limit=1") + assert len(response.json()) == 1 + assert response.json()[0]["price"] == 20 + + +def test_delete_item(client): + create_response = client.post("/item/", json={"name": "Deletable Item", "price": 5}) + item_id = create_response.json()["id"] + + delete_response = client.delete(f"/item/{item_id}") + assert delete_response.status_code == HTTPStatus.OK + + get_response = client.get(f"/item/{item_id}") + assert get_response.status_code == HTTPStatus.NOT_FOUND + + list_response = client.get("/item/?show_deleted=true") + assert len(list_response.json()) == 1 + deleted_item = list_response.json()[0] + assert deleted_item["id"] == item_id + assert deleted_item["deleted"] is True + + delete_response_2 = client.delete(f"/item/{item_id}") + assert delete_response_2.status_code == HTTPStatus.OK + + +def test_update_item_put(client): + create_response = client.post("/item/", json={"name": "Old Name", "price": 100}) + item_id = create_response.json()["id"] + + response = client.put(f"/item/{item_id}", json={"name": "New Name", "price": 200, "deleted": False}) + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["name"] == "New Name" + assert data["price"] == 200 + + response_not_found = client.put("/item/999", json={"name": "New Name", "price": 200, "deleted": False}) + assert response_not_found.status_code == HTTPStatus.NOT_MODIFIED + + +def test_update_item_patch(client): + create_response = client.post("/item/", json={"name": "Original", "price": 50}) + item_id = create_response.json()["id"] + + response_name = client.patch(f"/item/{item_id}", json={"name": "Patched Name"}) + assert response_name.status_code == HTTPStatus.OK + assert response_name.json()["name"] == "Patched Name" + assert response_name.json()["price"] == 50 + + + response_price = client.patch(f"/item/{item_id}", json={"price": 75.5}) + assert response_price.status_code == HTTPStatus.OK + assert response_price.json()["name"] == "Patched Name" + assert response_price.json()["price"] == 75.5 + + response_not_found = client.patch("/item/999", json={"name": "New Name"}) + assert response_not_found.status_code == HTTPStatus.NOT_MODIFIED + + +def test_create_cart_success(client): + response = client.post("/cart/") + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data["items"] == [] + assert data["price"] == 0.0 + assert "id" in data + assert response.headers["location"] == f"/cart/{data['id']}" + + +def test_get_cart_not_found(client): + response = client.get("/cart/999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_add_item_to_cart(client): + item_resp = client.post("/item/", json={"name": "Burger", "price": 10}) + item_id = item_resp.json()["id"] + cart_resp = client.post("/cart/") + cart_id = cart_resp.json()["id"] + + add_resp = client.post(f"/cart/{cart_id}/add/{item_id}") + assert add_resp.status_code == HTTPStatus.CREATED + cart_data = add_resp.json() + assert len(cart_data["items"]) == 1 + assert cart_data["items"][0]["id"] == item_id + assert cart_data["items"][0]["quantity"] == 1 + assert cart_data["price"] == 10.0 + + add_resp_2 = client.post(f"/cart/{cart_id}/add/{item_id}") + cart_data_2 = add_resp_2.json() + assert len(cart_data_2["items"]) == 1 + assert cart_data_2["items"][0]["quantity"] == 2 + assert cart_data_2["price"] == 20.0 + + add_resp_no_cart = client.post(f"/cart/999/add/{item_id}") + assert add_resp_no_cart.status_code != HTTPStatus.OK + + +def test_get_cart_by_id(client): + cart_resp = client.post("/cart/") + cart_id = cart_resp.json()["id"] + + get_resp = client.get(f"/cart/{cart_id}") + assert get_resp.status_code == HTTPStatus.OK + assert get_resp.json()["id"] == cart_id + + +def test_interaction_delete_item_from_cart(client): + item_resp = client.post("/item/", json={"name": "Fries", "price": 5}) + item_id = item_resp.json()["id"] + cart_resp = client.post("/cart/") + cart_id = cart_resp.json()["id"] + client.post(f"/cart/{cart_id}/add/{item_id}") + + client.delete(f"/item/{item_id}") + + cart_get_resp = client.get(f"/cart/{cart_id}") + cart_data = cart_get_resp.json() + assert cart_data["price"] == 0.0 + assert cart_data["items"][0]["available"] is False + + +def test_interaction_update_item_in_cart(client): + item_resp = client.post("/item/", json={"name": "Item 1", "price": 2}) + item_id = item_resp.json()["id"] + cart_resp = client.post("/cart/") + cart_id = cart_resp.json()["id"] + client.post(f"/cart/{cart_id}/add/{item_id}") + client.post(f"/cart/{cart_id}/add/{item_id}") + + client.put(f"/item/{item_id}", json={"name": "Item 2", "price": 3, "deleted": False}) + cart_get_resp = client.get(f"/cart/{cart_id}") + cart_data = cart_get_resp.json() + assert cart_data["price"] == 6.0 + assert cart_data["items"][0]["name"] == "Item 2" + + client.patch(f"/item/{item_id}", json={"price": 2.5}) + cart_get_resp_2 = client.get(f"/cart/{cart_id}") + cart_data_2 = cart_get_resp_2.json() + assert cart_data_2["price"] == 5.0 + + +def test_get_carts_list(client): + item1_resp = client.post("/item/", json={"name": "A", "price": 10}) + item1_id = item1_resp.json()["id"] + item2_resp = client.post("/item/", json={"name": "B", "price": 50}) + item2_id = item2_resp.json()["id"] + + cart1_resp = client.post("/cart/") + cart1_id = cart1_resp.json()["id"] + client.post(f"/cart/{cart1_id}/add/{item1_id}") + client.post(f"/cart/{cart1_id}/add/{item1_id}") + client.post(f"/cart/{cart1_id}/add/{item1_id}") + + cart2_resp = client.post("/cart/") + cart2_id = cart2_resp.json()["id"] + client.post(f"/cart/{cart2_id}/add/{item1_id}") + client.post(f"/cart/{cart2_id}/add/{item2_id}") + + resp_price = client.get("/cart/?min_price=50") + assert len(resp_price.json()) == 1 + assert resp_price.json()[0]["id"] == cart2_id + + resp_quant = client.get("/cart/?min_quantity=3") + assert len(resp_quant.json()) == 1 + assert resp_quant.json()[0]["id"] == cart1_id diff --git a/hw3/Dockerfile b/hw3/Dockerfile new file mode 100644 index 00000000..62129162 --- /dev/null +++ b/hw3/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install -r requirements.txt + +COPY ./hw2 /app/hw2 + +EXPOSE 8000 + +CMD ["uvicorn", "hw2.rest_example.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/hw3/README.md b/hw3/README.md new file mode 100644 index 00000000..237b9aca --- /dev/null +++ b/hw3/README.md @@ -0,0 +1,15 @@ +# ДЗ 3 + +Я пока не выполнил дз 2, поэтому взял указанный пример. + +Добавленные файлы: + +- *Dockerfile* +- *docker-compose.yml* +- *prometheus.yml* + +Обновил код в файле *hw2/rest_example/main.py* + +Скриншот из Grafana (*pics/grafana.png*) + +![Dashboard](pics/grafana.png) \ No newline at end of file diff --git a/hw3/docker-compose.yml b/hw3/docker-compose.yml new file mode 100644 index 00000000..f9e18d9e --- /dev/null +++ b/hw3/docker-compose.yml @@ -0,0 +1,42 @@ +version: "3" + +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: pokemon_api + ports: + - "8000:8000" + networks: + - monitoring-net + + prometheus: + image: prom/prometheus:latest + container_name: prometheus_server + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + 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" + restart: always + networks: + - monitoring-net + + grafana: + image: grafana/grafana-oss:latest + container_name: grafana_dashboard + ports: + - "3000:3000" + depends_on: + - prometheus + networks: + - monitoring-net + +networks: + monitoring-net: + driver: bridge \ No newline at end of file diff --git a/hw3/hw2/rest_example/api/__init__.py b/hw3/hw2/rest_example/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw2/rest_example/api/pokemon/__init__.py b/hw3/hw2/rest_example/api/pokemon/__init__.py new file mode 100644 index 00000000..ccd625f1 --- /dev/null +++ b/hw3/hw2/rest_example/api/pokemon/__init__.py @@ -0,0 +1,9 @@ +from .contracts import PatchPokemonRequest, PokemonRequest, PokemonResponse +from .routes import router + +__all__ = [ + "PokemonResponse", + "PokemonRequest", + "PatchPokemonRequest", + "router", +] diff --git a/hw3/hw2/rest_example/api/pokemon/contracts.py b/hw3/hw2/rest_example/api/pokemon/contracts.py new file mode 100644 index 00000000..a985b15b --- /dev/null +++ b/hw3/hw2/rest_example/api/pokemon/contracts.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from hw2.rest_example.store.models import ( + PatchPokemonInfo, + PokemonEntity, + PokemonInfo, +) + + +class PokemonResponse(BaseModel): + id: int + name: str + published: bool + + @staticmethod + def from_entity(entity: PokemonEntity) -> PokemonResponse: + return PokemonResponse( + id=entity.id, + name=entity.info.name, + published=entity.info.published, + ) + + +class PokemonRequest(BaseModel): + name: str + published: bool + + def as_pokemon_info(self) -> PokemonInfo: + return PokemonInfo(name=self.name, published=self.published) + + +class PatchPokemonRequest(BaseModel): + name: str | None = None + published: bool | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_pokemon_info(self) -> PatchPokemonInfo: + return PatchPokemonInfo(name=self.name, published=self.published) diff --git a/hw3/hw2/rest_example/api/pokemon/routes.py b/hw3/hw2/rest_example/api/pokemon/routes.py new file mode 100644 index 00000000..ab935c9a --- /dev/null +++ b/hw3/hw2/rest_example/api/pokemon/routes.py @@ -0,0 +1,119 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeInt, PositiveInt + +from hw2.rest_example import store + +from .contracts import ( + PatchPokemonRequest, + PokemonRequest, + PokemonResponse, +) + +router = APIRouter(prefix="/pokemon") + + +@router.get("/") +async def get_pokemon_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, +) -> list[PokemonResponse]: + return [PokemonResponse.from_entity(e) for e in store.get_many(offset, limit)] + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested pokemon", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested pokemon as one was not found", + }, + }, +) +async def get_pokemon_by_id(id: int) -> PokemonResponse: + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_pokemon(info: PokemonRequest, response: Response) -> PokemonResponse: + entity = store.add(info.as_pokemon_info()) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/pokemon/{entity.id}" + + return PokemonResponse.from_entity(entity) + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched pokemon", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify pokemon as one was not found", + }, + }, +) +async def patch_pokemon(id: int, info: PatchPokemonRequest) -> PokemonResponse: + entity = store.patch(id, info.as_patch_pokemon_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted pokemon", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify pokemon as one was not found", + }, + } +) +async def put_pokemon( + id: int, + info: PokemonRequest, + upsert: Annotated[bool, Query()] = False, +) -> PokemonResponse: + entity = ( + store.upsert(id, info.as_pokemon_info()) + if upsert + else store.update(id, info.as_pokemon_info()) + ) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_pokemon(id: int) -> Response: + store.delete(id) + return Response("") diff --git a/hw3/hw2/rest_example/main.py b/hw3/hw2/rest_example/main.py new file mode 100644 index 00000000..90ab1832 --- /dev/null +++ b/hw3/hw2/rest_example/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator + +from hw2.rest_example.api.pokemon import router + +app = FastAPI(title="Pokemon REST API Example") + +app.include_router(router) + +instrumentator = Instrumentator(excluded_handlers=["/metrics"]) + +instrumentator.instrument(app) +instrumentator.expose(app, include_in_schema=True, should_gzip=True) \ No newline at end of file diff --git a/hw3/hw2/rest_example/store/__init__.py b/hw3/hw2/rest_example/store/__init__.py new file mode 100644 index 00000000..cb99d02a --- /dev/null +++ b/hw3/hw2/rest_example/store/__init__.py @@ -0,0 +1,15 @@ +from .models import PatchPokemonInfo, PokemonEntity, PokemonInfo +from .queries import add, delete, get_many, get_one, patch, update, upsert + +__all__ = [ + "PokemonEntity", + "PokemonInfo", + "PatchPokemonInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "upsert", + "patch", +] diff --git a/hw3/hw2/rest_example/store/models.py b/hw3/hw2/rest_example/store/models.py new file mode 100644 index 00000000..95cd40b9 --- /dev/null +++ b/hw3/hw2/rest_example/store/models.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class PokemonInfo: + name: str + published: bool + + +@dataclass(slots=True) +class PokemonEntity: + id: int + info: PokemonInfo + + +@dataclass(slots=True) +class PatchPokemonInfo: + name: str | None = None + published: bool | None = None diff --git a/hw3/hw2/rest_example/store/queries.py b/hw3/hw2/rest_example/store/queries.py new file mode 100644 index 00000000..959492d7 --- /dev/null +++ b/hw3/hw2/rest_example/store/queries.py @@ -0,0 +1,75 @@ +from typing import Iterable + +from hw2.rest_example.store.models import ( + PatchPokemonInfo, + PokemonEntity, + PokemonInfo, +) + +_data = dict[int, PokemonInfo]() + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: PokemonInfo) -> PokemonEntity: + _id = next(_id_generator) + _data[_id] = info + + return PokemonEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> PokemonEntity | None: + if id not in _data: + return None + + return PokemonEntity(id=id, info=_data[id]) + + +def get_many(offset: int = 0, limit: int = 10) -> Iterable[PokemonEntity]: + curr = 0 + for id, info in _data.items(): + if offset <= curr < offset + limit: + yield PokemonEntity(id, info) + + curr += 1 + + +def update(id: int, info: PokemonInfo) -> PokemonEntity | None: + if id not in _data: + return None + + _data[id] = info + + return PokemonEntity(id=id, info=info) + + +def upsert(id: int, info: PokemonInfo) -> PokemonEntity: + _data[id] = info + + return PokemonEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchPokemonInfo) -> PokemonEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.published is not None: + _data[id].published = patch_info.published + + return PokemonEntity(id=id, info=_data[id]) diff --git a/hw3/pics/grafana.png b/hw3/pics/grafana.png new file mode 100644 index 00000000..4f0439b6 Binary files /dev/null and b/hw3/pics/grafana.png differ diff --git a/hw3/prometheus.yml b/hw3/prometheus.yml new file mode 100644 index 00000000..0144a71e --- /dev/null +++ b/hw3/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: "fastapi-service" + metrics_path: /metrics + static_configs: + - targets: ["app:8000"] \ No newline at end of file diff --git a/hw3/requirements.txt b/hw3/requirements.txt new file mode 100644 index 00000000..cedec253 --- /dev/null +++ b/hw3/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.117.1 +uvicorn +prometheus-fastapi-instrumentator \ No newline at end of file diff --git a/hw4/Dockerfile b/hw4/Dockerfile new file mode 100644 index 00000000..7f685a54 --- /dev/null +++ b/hw4/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . \ No newline at end of file diff --git a/hw4/docker-compose.yml b/hw4/docker-compose.yml new file mode 100644 index 00000000..ed0e6c55 --- /dev/null +++ b/hw4/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.8' + +services: + db: + image: postgres:15-alpine + container_name: shop_db + environment: + - POSTGRES_USER=shop_user + - POSTGRES_PASSWORD=shop_password + - POSTGRES_DB=shop_db + volumes: + - postgres_data:/var/lib/postgresql/data/ + ports: + - "5432:5432" + restart: unless-stopped + + api: + build: . + container_name: shop_api + command: uvicorn shop_api.main:app --host 0.0.0.0 --port 8000 --reload + volumes: + - .:/app + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql+psycopg2://shop_user:shop_password@db/shop_db + - PYTHONPATH=/app + depends_on: + - db + restart: unless-stopped + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw4/requirements.txt b/hw4/requirements.txt new file mode 100644 index 00000000..f0be3185 --- /dev/null +++ b/hw4/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +sqlalchemy +psycopg2-binary +pydantic-settings \ No newline at end of file diff --git a/hw4/shop_api/__init__.py b/hw4/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/shop_api/api/__init__.py b/hw4/shop_api/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/shop_api/api/cart/__init__.py b/hw4/shop_api/api/cart/__init__.py new file mode 100644 index 00000000..6773176f --- /dev/null +++ b/hw4/shop_api/api/cart/__init__.py @@ -0,0 +1 @@ +from shop_api.api.cart.routes import router diff --git a/hw4/shop_api/api/cart/contracts.py b/hw4/shop_api/api/cart/contracts.py new file mode 100644 index 00000000..b0bbba83 --- /dev/null +++ b/hw4/shop_api/api/cart/contracts.py @@ -0,0 +1,30 @@ +from typing import List + +from pydantic import BaseModel + +from shop_api.store.models import Cart + + +class ItemCartResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartResponse(BaseModel): + id: int + items: List[ItemCartResponse] + price: float + + @classmethod + def from_orm(cls, cart: Cart): + items = [ + ItemCartResponse( + id=ci.item.id, + name=ci.item.name, + quantity=ci.quantity, + available=not ci.item.deleted + ) for ci in cart.items + ] + return cls(id=cart.id, items=items, price=cart.total_price) diff --git a/hw4/shop_api/api/cart/routes.py b/hw4/shop_api/api/cart/routes.py new file mode 100644 index 00000000..c0b3fa33 --- /dev/null +++ b/hw4/shop_api/api/cart/routes.py @@ -0,0 +1,74 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Request, Depends +from sqlalchemy.orm import Session +from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat + +from shop_api import store +from shop_api.database import get_db + +from shop_api.api.cart.contracts import CartResponse + +router = APIRouter(prefix="/cart") + + +@router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_cart(_: Request, response: Response, db: Session = Depends(get_db)) -> CartResponse: + entity = store.add_cart(db) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/cart/{entity.id}" + + return CartResponse.from_orm(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(id: int, db: Session = Depends(get_db)) -> CartResponse: + entity = store.get_one_cart(db, id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_orm(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] = 0, + max_price: Annotated[NonNegativeFloat, Query()] = 1e10, + min_quantity: Annotated[NonNegativeInt, Query()] = 0, + max_quantity: Annotated[NonNegativeInt, Query()] = 1e10, + db: Session = Depends(get_db) +) -> list[CartResponse]: + return [CartResponse.from_orm(e) for e in store.get_many_carts(db, offset, limit, min_price, max_price, min_quantity, max_quantity)] + + +@router.post( + "/{cart_id}/add/{item_id}", + status_code=HTTPStatus.CREATED, +) +async def post_item_to_cart(cart_id: int, item_id: int, response: Response, db: Session = Depends(get_db)) -> CartResponse: + entity = store.add_item_to_cart(db, cart_id, item_id) + + response.headers["location"] = f"/cart/{entity.id}" + + return CartResponse.from_orm(entity) diff --git a/hw4/shop_api/api/item/__init__.py b/hw4/shop_api/api/item/__init__.py new file mode 100644 index 00000000..5402b999 --- /dev/null +++ b/hw4/shop_api/api/item/__init__.py @@ -0,0 +1 @@ +from shop_api.api.item.routes import router diff --git a/hw4/shop_api/api/item/contracts.py b/hw4/shop_api/api/item/contracts.py new file mode 100644 index 00000000..dfdc1f51 --- /dev/null +++ b/hw4/shop_api/api/item/contracts.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel, ConfigDict + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + class Config: + from_attributes = True + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") diff --git a/hw4/shop_api/api/item/routes.py b/hw4/shop_api/api/item/routes.py new file mode 100644 index 00000000..1bfe2507 --- /dev/null +++ b/hw4/shop_api/api/item/routes.py @@ -0,0 +1,117 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from sqlalchemy.orm import Session +from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat + +from shop_api import store +from shop_api.database import get_db + +from shop_api.api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter(prefix="/item") + + +@router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_item(info: ItemRequest, response: Response, db: Session = Depends(get_db)) -> ItemResponse: + entity = store.add_item(db, info) + + response.headers["location"] = f"/item/{entity.id}" + + return ItemResponse.from_orm(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id(id: int, db: Session = Depends(get_db)) -> ItemResponse: + entity = store.get_one_item(db, id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_orm(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] = 0, + max_price: Annotated[NonNegativeFloat, Query()] = 1e10, + show_deleted: Annotated[bool, Query()] = False, + db: Session = Depends(get_db) +) -> list[ItemResponse]: + return [ItemResponse.from_orm(e) for e in store.get_many_items(db, offset, limit, min_price, max_price, show_deleted)] + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + } +) +async def put_item( + id: int, + info: ItemRequest, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.update_item(db, id, info) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_orm(entity) + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item(id: int, info: PatchItemRequest, db: Session = Depends(get_db)) -> ItemResponse: + entity = store.patch_item(db, id, info) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_orm(entity) + + +@router.delete("/{id}") +async def delete_item(id: int, db: Session = Depends(get_db)) -> Response: + store.delete_item(db, id) + return Response("") diff --git a/hw4/shop_api/database.py b/hw4/shop_api/database.py new file mode 100644 index 00000000..94d0e0e3 --- /dev/null +++ b/hw4/shop_api/database.py @@ -0,0 +1,23 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+psycopg2://shop_user:shop_password@localhost/shop_db") + + +settings = Settings() + +engine = create_engine(settings.DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/hw4/shop_api/main.py b/hw4/shop_api/main.py new file mode 100644 index 00000000..42cdfc6b --- /dev/null +++ b/hw4/shop_api/main.py @@ -0,0 +1,12 @@ +from fastapi import FastAPI +from shop_api.api import cart, item +from shop_api.database import engine +from shop_api.store import models + + +models.Base.metadata.create_all(bind=engine) + +app = FastAPI(title="Shop API") + +app.include_router(cart.router) +app.include_router(item.router) diff --git a/hw4/shop_api/store/__init__.py b/hw4/shop_api/store/__init__.py new file mode 100644 index 00000000..364a90af --- /dev/null +++ b/hw4/shop_api/store/__init__.py @@ -0,0 +1,19 @@ +from shop_api.store.models import Item, CartItem, Cart +from shop_api.store.queries import (add_item, delete_item, get_one_item, get_many_items, update_item, patch_item, + add_cart, get_one_cart, get_many_carts, add_item_to_cart) + +__all__ = [ + "Item", + "CartItem", + "Cart", + "add_item", + "delete_item", + "get_one_item", + "get_many_items", + "update_item", + "patch_item", + "add_cart", + "get_one_cart", + "get_many_carts", + "add_item_to_cart" +] \ No newline at end of file diff --git a/hw4/shop_api/store/models.py b/hw4/shop_api/store/models.py new file mode 100644 index 00000000..12ace7c1 --- /dev/null +++ b/hw4/shop_api/store/models.py @@ -0,0 +1,42 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey +from sqlalchemy.orm import relationship + +from shop_api.database import Base + + +class Item(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True) + price = Column(Float) + deleted = Column(Boolean, default=False, index=True) + + cart_items = relationship("CartItem", back_populates="item") + + +class Cart(Base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, index=True) + + items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan") + + @property + def total_price(self): + if not self.items: + return 0.0 + return sum( + ci.item.price * ci.quantity for ci in self.items if not ci.item.deleted + ) + + +class CartItem(Base): + __tablename__ = "cart_items" + + cart_id = Column(Integer, ForeignKey("carts.id"), primary_key=True) + item_id = Column(Integer, ForeignKey("items.id"), primary_key=True) + quantity = Column(Integer, default=1) + + cart = relationship("Cart", back_populates="items") + item = relationship("Item", back_populates="cart_items") diff --git a/hw4/shop_api/store/queries.py b/hw4/shop_api/store/queries.py new file mode 100644 index 00000000..91426394 --- /dev/null +++ b/hw4/shop_api/store/queries.py @@ -0,0 +1,118 @@ +from typing import List +from sqlalchemy.orm import Session, joinedload + +import shop_api.store.models as db_models +from shop_api.api.item.contracts import ItemRequest, PatchItemRequest + + +def add_item(db: Session, info: ItemRequest) -> db_models.Item: + db_item = db_models.Item(name=info.name, price=info.price, deleted=info.deleted) + db.add(db_item) + db.commit() + db.refresh(db_item) + return db_item + + +def delete_item(db: Session, item_id: int) -> None: + db_item = db.query(db_models.Item).filter(db_models.Item.id == item_id).first() + if db_item: + db_item.deleted = True + db.commit() + + +def get_one_item(db: Session, item_id: int) -> db_models.Item | None: + return db.query(db_models.Item).filter( + db_models.Item.id == item_id, + db_models.Item.deleted == False + ).first() + + +def get_many_items( + db: Session, offset: int, limit: int, min_price: float, max_price: float, show_deleted: bool +) -> List[db_models.Item]: + query = db.query(db_models.Item) + if not show_deleted: + query = query.filter(db_models.Item.deleted == False) + + query = query.filter(db_models.Item.price >= min_price, db_models.Item.price <= max_price) + + return query.offset(offset).limit(limit).all() + + +def update_item(db: Session, item_id: int, info: ItemRequest) -> db_models.Item | None: + db_item = db.query(db_models.Item).filter(db_models.Item.id == item_id).first() + if db_item: + db_item.name = info.name + db_item.price = info.price + db_item.deleted = info.deleted + db.commit() + db.refresh(db_item) + return db_item + + +def patch_item(db: Session, item_id: int, patch_info: PatchItemRequest) -> db_models.Item | None: + db_item = get_one_item(db, item_id) + if db_item: + if patch_info.name is not None: + db_item.name = patch_info.name + if patch_info.price is not None: + db_item.price = patch_info.price + db.commit() + db.refresh(db_item) + return db_item + + +def add_cart(db: Session) -> db_models.Cart: + db_cart = db_models.Cart() + db.add(db_cart) + db.commit() + db.refresh(db_cart) + return db_cart + + +def get_one_cart(db: Session, cart_id: int) -> db_models.Cart | None: + return db.query(db_models.Cart).options( + joinedload(db_models.Cart.items).joinedload(db_models.CartItem.item) + ).filter(db_models.Cart.id == cart_id).first() + + +def get_many_carts( + db: Session, offset: int, limit: int, min_price: float, max_price: float, min_quantity: int, max_quantity: int +) -> List[db_models.Cart]: + all_carts = db.query(db_models.Cart).options( + joinedload(db_models.Cart.items).joinedload(db_models.CartItem.item) + ).offset(offset).limit(limit).all() + + filtered_carts = [] + for cart in all_carts: + total_quantity = sum(ci.quantity for ci in cart.items if not ci.item.deleted) + total_price = cart.total_price + + if (min_price <= total_price <= max_price and + min_quantity <= total_quantity <= max_quantity): + filtered_carts.append(cart) + + return filtered_carts + + +def add_item_to_cart(db: Session, cart_id: int, item_id: int) -> db_models.Cart | None: + cart = get_one_cart(db, cart_id) + item = get_one_item(db, item_id) + + if not cart or not item: + return None + + cart_item = db.query(db_models.CartItem).filter( + db_models.CartItem.cart_id == cart_id, + db_models.CartItem.item_id == item_id + ).first() + + if cart_item: + cart_item.quantity += 1 + else: + new_cart_item = db_models.CartItem(cart_id=cart_id, item_id=item_id, quantity=1) + db.add(new_cart_item) + + db.commit() + db.refresh(cart) + return cart \ No newline at end of file diff --git a/hw4/transaction_scripts/__init__.py b/hw4/transaction_scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/transaction_scripts/db_config.py b/hw4/transaction_scripts/db_config.py new file mode 100644 index 00000000..b56cab1b --- /dev/null +++ b/hw4/transaction_scripts/db_config.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy import Column, Integer, String, Float, Boolean +from sqlalchemy.orm import declarative_base + +DATABASE_URL = "postgresql+psycopg2://shop_user:shop_password@localhost:5432/shop_db" +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +class Item(Base): + __tablename__ = 'items' + id = Column(Integer, primary_key=True) + name = Column(String) + price = Column(Float) + deleted = Column(Boolean, default=False) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/hw4/transaction_scripts/run_dirty_read.py b/hw4/transaction_scripts/run_dirty_read.py new file mode 100644 index 00000000..c1486a75 --- /dev/null +++ b/hw4/transaction_scripts/run_dirty_read.py @@ -0,0 +1,64 @@ +import threading +from db_config import Item, engine, SessionLocal +from setup_db import setup +from sqlalchemy import select, update + + +def transaction_a(item_id, barrier): + connection = engine.connect().execution_options(isolation_level="READ COMMITTED") + try: + with connection.begin() as transaction: + print("Transaction A started. Try set price 99.99") + stmt = ( + update(Item) + .where(Item.id == item_id) + .values(price=55.55) + ) + connection.execute(stmt) + print("Transaction A price updated and flushed") + barrier.wait() + barrier.wait() + stmt = select(Item.price).where(Item.id == item_id) + price = connection.execute(stmt).scalar_one() + print(f"Transaction A has price {price}") + assert price == 55.55 + print("Transaction A do rollback") + transaction.rollback() + finally: + connection.close() + + +def transaction_b(item_id, barrier): + connection = engine.connect().execution_options(isolation_level="READ COMMITTED") + try: + barrier.wait() + with connection.begin(): + print("Transaction B read price") + stmt = select(Item.price).where(Item.id == item_id) + price = connection.execute(stmt).scalar_one() + assert price == 10.0 + print(f"Transaction B price={price:.2f}. No dirty read! Sleep") + barrier.wait() + finally: + connection.close() + + +if __name__ == "__main__": + print("--- Postgresql: no Dirty read. All READ COMMITTED ---") + item_id = setup() + + barrier = threading.Barrier(2, timeout=10) + thread_a = threading.Thread(target=transaction_a, args=(item_id, barrier)) + thread_b = threading.Thread(target=transaction_b, args=(item_id, barrier)) + + thread_a.start() + thread_b.start() + + thread_a.join() + thread_b.join() + + final_session = SessionLocal() + final_item = final_session.query(Item).get(item_id) + print(f"Final price: {final_item.price:.2f}") + assert final_item.price == 10.0 + final_session.close() diff --git a/hw4/transaction_scripts/run_non_repeatable_read.py b/hw4/transaction_scripts/run_non_repeatable_read.py new file mode 100644 index 00000000..3b46937f --- /dev/null +++ b/hw4/transaction_scripts/run_non_repeatable_read.py @@ -0,0 +1,78 @@ +import threading +from db_config import Item, engine +from setup_db import setup +from sqlalchemy import select, update + + +def reader_transaction(item_id, isolation_level, barrier): + connection = engine.connect().execution_options(isolation_level=isolation_level) + try: + with connection.begin(): + print(f"READER ({isolation_level}): Started") + stmt1 = select(Item.price).where(Item.id == item_id) + price1 = connection.execute(stmt1).scalar_one() + print(f"READER ({isolation_level}): First read, price={price1:.2f}") + + barrier.wait() + barrier.wait() + + stmt2 = select(Item.price).where(Item.id == item_id) + price2 = connection.execute(stmt2).scalar_one() + print(f"READER ({isolation_level}): Second read, price={price2:.2f}") + + if isolation_level == 'READ COMMITTED': + assert price1 != price2 + print(f"READER ({isolation_level}): Non-Repeatable Read!") + else: + assert price1 == price2 + print(f"READER ({isolation_level}): ОК") + finally: + connection.close() + + +def writer_transaction(item_id, barrier): + barrier.wait() + connection = engine.connect() + try: + with connection.begin(): + print(f"WRITER: Update price {item_id}") + stmt = ( + update(Item) + .where(Item.id == item_id) + .values(price=55.55) + ) + connection.execute(stmt) + with connection.begin(): + check_stmt = select(Item.price).where(Item.id == item_id) + current_price = connection.execute(check_stmt).scalar_one() + print(f"WRITER: Check price={current_price}") + print("WRITER: Price updated") + finally: + connection.close() + barrier.wait() + + +if __name__ == "__main__": + print("--- Non-Repeatable Read on level READ COMMITTED ---") + item_id = setup() + barrier = threading.Barrier(2, timeout=10) + + reader_thread = threading.Thread(target=reader_transaction, args=(item_id, "READ COMMITTED", barrier)) + writer_thread = threading.Thread(target=writer_transaction, args=(item_id, barrier)) + + reader_thread.start() + writer_thread.start() + reader_thread.join() + writer_thread.join() + + print("--- No Non-Repeatable Read on level REPEATABLE READ ---") + item_id = setup() + barrier = threading.Barrier(2, timeout=10) + + reader_thread = threading.Thread(target=reader_transaction, args=(item_id, "REPEATABLE READ", barrier)) + writer_thread = threading.Thread(target=writer_transaction, args=(item_id, barrier)) + + reader_thread.start() + writer_thread.start() + reader_thread.join() + writer_thread.join() diff --git a/hw4/transaction_scripts/setup_db.py b/hw4/transaction_scripts/setup_db.py new file mode 100644 index 00000000..7d601a07 --- /dev/null +++ b/hw4/transaction_scripts/setup_db.py @@ -0,0 +1,23 @@ +from db_config import SessionLocal, Item +import warnings +warnings.filterwarnings("ignore") +from sqlalchemy import text + + +def setup(): + session = SessionLocal() + try: + session.query(Item).delete(synchronize_session=False) + session.execute(text("ALTER SEQUENCE items_id_seq RESTART WITH 1;")) + + test_item = Item(name="Notebook", price=10.00) + session.add(test_item) + session.commit() + print(f"SETUP: created {test_item.id} with price {test_item.price}") + return test_item.id + finally: + session.close() + + +if __name__ == "__main__": + setup()