diff --git a/.github/workflows/hw45-tests.yml b/.github/workflows/hw45-tests.yml new file mode 100644 index 00000000..9f89baf4 --- /dev/null +++ b/.github/workflows/hw45-tests.yml @@ -0,0 +1,62 @@ +name: "HW45 Tests" + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +jobs: + test-hw45: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + strategy: + matrix: + python-version: ["3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: hw45 + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Wait for PostgreSQL starting + run: | + for i in {1..10}; do + pg_isready -h localhost -p 5432 -U postgres && echo "Ready" && break + echo "Still waiting to start db serice..." + sleep 2 + done + + - name: Run tests with check coverage + working-directory: hw45 + env: + PYTHONPATH: ${{ github.workspace }}/hw45 + run: | + export PYTHONPATH=$PYTHONPATH:$(pwd) + python -m coverage run -m pytest + python -m coverage report diff --git a/hw1/README.md b/hw1/README.md index 517057e7..c515cfa9 100644 --- a/hw1/README.md +++ b/hw1/README.md @@ -2,31 +2,34 @@ ## πŸš€ Как Π½Π°Ρ‡Π°Ρ‚ΡŒ Ρ€Π°Π±ΠΎΡ‚Ρƒ - ### 1. УстановитС зависимости + ```bash pip install -r requirements.txt ``` ### 2. Π Π΅Π°Π»ΠΈΠ·ΡƒΠΉΡ‚Π΅ ASGI ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ + ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ„Π°ΠΉΠ» `app.py` ΠΈ Π½Π°ΠΉΠ΄ΠΈΡ‚Π΅ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΡŽ `application` с ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠ΅ΠΌ `TODO`. Π’Π°ΠΌ Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ Ρ€Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Ρ‚ΡŒ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ HTTP запросов Π²Π½ΡƒΡ‚Ρ€ΠΈ этой Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ. КакиС ΠΈΠΌΠ΅Π½Π½ΠΎ запросы Π²Π°ΠΌ Π½ΡƒΠΆΠ½ΠΎ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ? Π’ Π»Π΅ΠΊΡ†ΠΈΠΈ Π΅ΡΡ‚ΡŒ ΠΏΡ€ΠΈΠΌΠ΅Ρ€ матСматичСского API с трСмя Ρ€ΡƒΡ‡ΠΊΠ°ΠΌΠΈ, ΠΈΡ… ΠΈ Π½Π°Π΄ΠΎ ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Ρ‚ΡŒ - ### 3. ЗапуститС сСрвСр для тСстирования + ```bash python app.py ``` Π‘Π΅Ρ€Π²Π΅Ρ€ запустится Π½Π° `http://localhost:8000`. Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΏΡ€ΠΎΡ‚Π΅ΡΡ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ эндпоинты: -- http://localhost:8000/fibonacci/10 -- http://localhost:8000/factorial?n=5 -- http://localhost:8000/mean?numbers=1,2,3 + +- +- +- ### 4. ЗапуститС тСсты локально, Ссли Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ + ```bash pytest test_app.py -v ``` @@ -34,17 +37,20 @@ pytest test_app.py -v ## πŸ“€ Как ΡΠ΄Π°Ρ‚ΡŒ домашнСС Π·Π°Π΄Π°Π½ΠΈΠ΅ ### 1. ЗафиксируйтС измСнСния + ```bash git add app.py git commit -m "Implement ASGI application with fibonacci, factorial, and mean endpoints" ``` ### 2. ΠžΡ‚ΠΏΡ€Π°Π²ΡŒΡ‚Π΅ измСнСния Π² свой Ρ„ΠΎΡ€ΠΊ + ```bash git push origin main ``` ### 3. Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ Pull Request + 1. ΠŸΠ΅Ρ€Π΅ΠΉΠ΄ΠΈΡ‚Π΅ Π½Π° страницу вашСго Ρ„ΠΎΡ€ΠΊΠ° Π½Π° GitHub 2. НаТмитС ΠΊΠ½ΠΎΠΏΠΊΡƒ "Compare & pull request" 3. Π£Π±Π΅Π΄ΠΈΡ‚Π΅ΡΡŒ, Ρ‡Ρ‚ΠΎ PR Π½Π°ΠΏΡ€Π°Π²Π»Π΅Π½ Π² основной Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΉ Π² Π²Π΅Ρ‚ΠΊΡƒ `main` @@ -52,7 +58,10 @@ git push origin main 5. НаТмитС "Create pull request" ### 4. ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ статус тСстов -ПослС создания PR автоматичСски запустятся тСсты. Π’Ρ‹ ΡƒΠ²ΠΈΠ΄ΠΈΡ‚Π΅ статус ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ: + +ПослС создания PR автоматичСски запустятся тСсты. Π’Ρ‹ ΡƒΠ²ΠΈΠ΄ΠΈΡ‚Π΅ +статус ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ: + - βœ… **Checks passing** - всС тСсты ΠΏΡ€ΠΎΡˆΠ»ΠΈ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ - ❌ **Checks failing** - Π΅ΡΡ‚ΡŒ ошибки, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ Π½ΡƒΠΆΠ½ΠΎ ΠΈΡΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ diff --git a/hw1/app.py b/hw1/app.py index 6107b870..02790f49 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,10 +1,15 @@ -from typing import Any, Awaitable, Callable +from http import HTTPStatus +import re +from urllib.parse import parse_qs + +from endpoints import send_error_response, factorial_endpoint, fibonacci_endpoint, mean_endpoint +from utils import Receive, Scope, Send async def application( - scope: dict[str, Any], - receive: Callable[[], Awaitable[dict[str, Any]]], - send: Callable[[dict[str, Any]], Awaitable[None]], + scope: Scope, + receive: Receive, + send: Send, ): """ Args: @@ -12,7 +17,59 @@ async def application( receive: ΠšΠΎΡ€ΡƒΡ‚ΠΈΠ½Π° для получСния сообщСний ΠΎΡ‚ ΠΊΠ»ΠΈΠ΅Π½Ρ‚Π° send: ΠšΠΎΡ€ΡƒΡ‚ΠΈΠ½Π° для ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΈ сообщСний ΠΊΠ»ΠΈΠ΅Π½Ρ‚Ρƒ """ - # TODO: Π’Π°ΡˆΠ° рСализация здСсь + print(f"Beginning connection. Scope: ", scope) + + if scope["type"] == "lifespan": + await handle_lifetime(scope, receive, send) + elif scope["type"] == "http": + await handle_http(scope, receive, send) + + print(f"Ending connection") + + +async def handle_lifetime(scope: Scope, receive: Receive, send: Send): + assert scope["type"] == "lifespan" + + while True: + message = await receive() + print(f"Got message:", message) + + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + break + + +async def handle_http(scope: Scope, receive: Receive, send: Send): + assert scope["type"] == "http" + + if scope["method"] != "GET": + await send_error_response(send, status=HTTPStatus.NOT_FOUND) + elif (m := re.match("/fibonacci/([^/\s]*)", scope["path"])): + await fibonacci_endpoint(scope, receive, send, value=m.group(1)) + elif scope["path"] == "/factorial": + query_string = scope.get("query_string", b"").decode("utf-8") + query_params = parse_qs(query_string).get("n", []) + await factorial_endpoint(scope, receive, send, value=query_params) + elif scope["path"] == "/mean": + body = await read_body(receive) + await mean_endpoint(scope, receive, send, value=body) + else: + await send_error_response(send, status=HTTPStatus.NOT_FOUND) + + +async def read_body(receive: Receive) -> str: + body = b"" + more_body = True + + while more_body: + message = await receive() + body += message.get("body", b"") + more_body = message.get("more_body", False) + + return body.decode("utf-8") + if __name__ == "__main__": import uvicorn diff --git a/hw1/endpoints.py b/hw1/endpoints.py new file mode 100644 index 00000000..e2542d4b --- /dev/null +++ b/hw1/endpoints.py @@ -0,0 +1,64 @@ +from http import HTTPStatus +import json +from utils import Receive, Scope, Send, calculate_fibonacci, calculate_factorial, calculate_mean + + +async def fibonacci_endpoint(scope: Scope, receive: Receive, send: Send, value: str): + if value.startswith('-') and value[1:].isdigit(): + await send_error_response(send, status=HTTPStatus.BAD_REQUEST) + elif value.isdigit(): + response = str(calculate_fibonacci(int(value))) + await send_ok_response(send, response) + else: + await send_error_response(send, status=HTTPStatus.UNPROCESSABLE_ENTITY) + + +async def factorial_endpoint(scope: Scope, receive: Receive, send: Send, value: list): + if len(value) and value[0].startswith('-') and value[0][1:].isdigit(): + await send_error_response(send, status=HTTPStatus.BAD_REQUEST) + elif len(value) and value[0].isdigit(): + response = str(calculate_factorial(int(value[0]))) + await send_ok_response(send, response) + else: + await send_error_response(send, status=HTTPStatus.UNPROCESSABLE_ENTITY) + + +async def mean_endpoint(scope: Scope, receive: Receive, send: Send, value: str): + try: + parsed = json.loads(value) + + if not len(parsed) or not all(isinstance(x, (int, float)) for x in parsed): + await send_error_response(send, status=HTTPStatus.BAD_REQUEST) + else: + response = str(calculate_mean(parsed)) + await send_ok_response(send, response) + except (json.JSONDecodeError, TypeError): + await send_error_response(send, status=HTTPStatus.UNPROCESSABLE_ENTITY) + +async def send_ok_response(send: Send, response: str): + json_response = json.dumps({"result": response}, ensure_ascii=False, indent=2).encode("utf-8") + await send_message(send=send, status=HTTPStatus.OK, body=json_response) + + +async def send_error_response(send: Send, status=HTTPStatus.NOT_FOUND): + await send_message(send=send, status=status, body=b"") + + +async def send_message(send: Send, status: HTTPStatus, body: bytes): + response_message = { + "type": "http.response.start", + "status": status, + "headers": [ + [b"content-type", b"text/plain" if status == HTTPStatus.OK else b"application/json; charset=utf-8"] + ], + } + print("Sending response start:", response_message) + await send(response_message) + + response_message = { + "type": "http.response.body", + "body": body, + "more_body": False, + } + print("Sending response body:", response_message) + await send(response_message) \ No newline at end of file diff --git a/hw1/utils.py b/hw1/utils.py new file mode 100644 index 00000000..844db4c4 --- /dev/null +++ b/hw1/utils.py @@ -0,0 +1,31 @@ +from typing import Any, Awaitable, Callable +from functools import lru_cache + +Scope = dict[str, Any] +Receive = Callable[[], Awaitable[dict[str, Any]]] +Send = Callable[[dict[str, Any]], Awaitable[None]] + + +def calculate_fibonacci(n: int) -> int: + if n <= 1: + return n + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + + +@lru_cache(maxsize=128) +def calculate_factorial(n: int) -> int: + if n < 0: + raise ValueError("Factorial is not defined for negative numbers") + if n == 0 or n == 1: + return 1 + return n * calculate_factorial(n - 1) + + +def calculate_mean(lst: list): + if not lst: + raise ValueError("Cannot calculate mean of empty list") + + return sum(lst) / len(lst) \ No newline at end of file diff --git a/hw2/hw/shop_api/__init__.py b/hw2/hw/shop_api/__init__.py index e69de29b..30ea0a9b 100644 --- a/hw2/hw/shop_api/__init__.py +++ b/hw2/hw/shop_api/__init__.py @@ -0,0 +1,4 @@ +from shop_api.data_storage import DataStorage + + +data_storage = DataStorage("storage") \ No newline at end of file diff --git a/hw2/hw/shop_api/data_storage.py b/hw2/hw/shop_api/data_storage.py new file mode 100644 index 00000000..32b044cc --- /dev/null +++ b/hw2/hw/shop_api/data_storage.py @@ -0,0 +1,127 @@ +import json +import os +from typing import Literal + + +class DataStorage: + def __init__(self, storage_path) -> None: + self.storage_path = os.path.join(os.getcwd(), storage_path) + + self.item_storage_path = os.path.join(self.storage_path, "item_storage.json") + self.cart_storage_path = os.path.join(self.storage_path, "cart_storage.json") + + self.init_storages() + + self.item_storage = self.open_storage(self.item_storage_path) + self.cart_storage = self.open_storage(self.cart_storage_path) + + print("DataStorage init!") + + def init_storages(self): + os.makedirs(self.storage_path, exist_ok=True) + + for st in [self.item_storage_path, self.cart_storage_path]: + if os.path.exists(st): + continue + + with open(st, 'w', encoding='utf-8') as f: + json.dump({}, f) + + def open_storage(self, storage): + with open(storage, 'r', encoding='utf-8') as f: + data = json.load(f) + + return data + + def save_storage(self, storage: Literal["item", "cart"]): + if storage not in ["item", "cart"]: + return + + match storage: + case "item": + with open(self.item_storage_path, "w") as f: + json.dump(self.item_storage, f, indent=4) + case "cart": + with open(self.cart_storage_path, "w") as f: + json.dump(self.cart_storage, f, indent=4) + case _: + print("Not exist storage!") + return + + def create_cart(self): + try: + next_cart_id = max(map(int, self.cart_storage.keys())) + 1 + except ValueError: + next_cart_id = 1 + + self.cart_storage[str(next_cart_id)] = {"id": next_cart_id, "items": {}} + self.save_storage("cart") + + return next_cart_id + + def is_cart_exists(self, cart_id): + return str(cart_id) in self.cart_storage + + def get_cart(self, cart_id): + if str(cart_id) not in self.cart_storage: + return None + + cart = {"id": cart_id, "items": []} + total = 0 + + for item_id, item_count in self.cart_storage[str(cart_id)]["items"].items(): + item = self.get_item(str(item_id)) + print(f"{item=}") + cart["items"].append( + { + "id": item["id"], + "name": item["name"], + "quantity": item_count, + "available": not item["deleted"], + } + ) + total += item_count * item["price"] + + cart["price"] = total + + return cart + + def get_carts(self): + return [self.get_cart(cart_id) for cart_id in self.cart_storage] + + def create_item(self, name: str, price: float): + try: + next_item_id = max(map(int, self.item_storage.keys())) + 1 + except ValueError: + next_item_id = 1 + + item = {"id": next_item_id, "name": name, "price": price, "deleted": False} + self.item_storage[str(next_item_id)] = item + self.save_storage("item") + + return item + + def is_item_exists(self, item_id): + return str(item_id) in self.item_storage + + def get_item(self, item_id): + if str(item_id) not in self.item_storage: + return None + + return self.item_storage[str(item_id)] + + def get_items(self): + return list(self.item_storage.values()) + + def update_item(self, item): + if str(item["id"]) not in self.item_storage: + return + + self.item_storage[str(item["id"])] = item + self.save_storage("item") + + def add_item2storage(self, cart_id, item): + items_in_cart = self.cart_storage[str(cart_id)]["items"].get(str(item["id"]), 0) + 1 + self.cart_storage[str(cart_id)]["items"][str(item["id"])] = items_in_cart + self.save_storage("cart") + \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..ec8ad253 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,25 @@ from fastapi import FastAPI +from fastapi.responses import RedirectResponse + +from shop_api.routers.item import router as item_router +from shop_api.routers.cart import router as cart_router app = FastAPI(title="Shop API") + +app.include_router(item_router) +app.include_router(cart_router) + + +@app.get("/", include_in_schema=False) +async def docs_redirect(): + return RedirectResponse(url="/docs", status_code=301) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + app, + port=7005, + log_level="info", + ) \ No newline at end of file diff --git a/hw2/hw/shop_api/routers/cart.py b/hw2/hw/shop_api/routers/cart.py new file mode 100644 index 00000000..f4667370 --- /dev/null +++ b/hw2/hw/shop_api/routers/cart.py @@ -0,0 +1,76 @@ +from http import HTTPStatus +from typing import Annotated +from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import JSONResponse +from pydantic import NonNegativeInt, PositiveInt + +from shop_api.schemas import Cart + +from shop_api import data_storage + + +router = APIRouter( + prefix="/cart", + tags=["cart"], +) + +@router.post("/") +async def create_cart(): + cart_id = data_storage.create_cart() + return JSONResponse( + {"id": cart_id}, + status_code=HTTPStatus.CREATED, + headers={"location": f"/cart/{cart_id}"} + ) + + +@router.get("/{cart_id}", response_model=Cart) +async def get_cart(cart_id: int): + cart = data_storage.get_cart(cart_id) + + if cart is None: + return HTTPException(HTTPStatus.NOT_FOUND, "Cart not found!") + + return JSONResponse(cart, status_code=HTTPStatus.OK) + + +@router.get("/") +async def get_carts( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeInt, Query()] | None = None, + max_price: Annotated[NonNegativeInt, Query()] | None = None, + min_quantity: Annotated[NonNegativeInt, Query()] | None = None, + max_quantity: Annotated[NonNegativeInt, Query()] | None = None, + ): + if min_price is None: + min_price = 0 + + if max_price is None: + max_price = float("inf") # type: ignore + + if min_quantity is None: + min_quantity = 0 + + if max_quantity is None: + max_quantity = float("inf") # type: ignore + + carts = data_storage.get_carts() + + carts = list(filter(lambda x: min_price <= x["price"] <= max_price, carts)) # type: ignore + + carts = [cart for cart in carts if min_quantity <= sum([it["quantity"] for it in cart["items"]]) <= max_quantity] # type: ignore + carts = carts[offset: offset + limit] + return JSONResponse(carts, status_code=HTTPStatus.OK) + +@router.post("/{cart_id}/add/{item_id}") +async def add_item2cart(cart_id: int, item_id: int): + if not data_storage.is_cart_exists(cart_id): + return HTTPException(HTTPStatus.NOT_FOUND, "Cart not found!") + + item = data_storage.get_item(item_id=item_id) + if item is None: + return HTTPException(HTTPStatus.NOT_FOUND, "Item not found!") + + data_storage.add_item2storage(cart_id, item) + return Response(status_code=HTTPStatus.OK) \ No newline at end of file diff --git a/hw2/hw/shop_api/routers/item.py b/hw2/hw/shop_api/routers/item.py new file mode 100644 index 00000000..599dd904 --- /dev/null +++ b/hw2/hw/shop_api/routers/item.py @@ -0,0 +1,101 @@ +from http import HTTPStatus +from typing import Annotated +from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import JSONResponse +from pydantic import NonNegativeInt, PositiveInt + +from shop_api import data_storage +from shop_api.schemas import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter( + prefix="/item", + tags=["item"], +) + +@router.post("/", response_model=ItemResponse) +async def create_item(item: ItemRequest): + created_item = data_storage.create_item(item.name, item.price) + + return JSONResponse( + created_item, + status_code=HTTPStatus.CREATED + ) + +@router.get("/{item_id}", response_model=ItemResponse) +async def get_item(item_id: int): + item = data_storage.get_item(item_id) + + if item is None or item["deleted"]: + return Response("Item with specified id does not exists or deleted!", status_code=HTTPStatus.NOT_FOUND) + + return JSONResponse(item, status_code=HTTPStatus.OK) + + +@router.get("/") +async def get_items( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeInt, Query()] | None = None, + max_price: Annotated[NonNegativeInt, Query()] | None = None, + show_deleted: bool = False): + items: list = data_storage.get_items() + + if min_price is None: + min_price = 0 + + if max_price is None: + max_price = float("inf") # type: ignore + + items = list(filter(lambda x: (not x["deleted"] if not show_deleted else True) and x["price"] >= min_price and x["price"] <= max_price, items)) + + items = items[offset: offset + limit] + + return JSONResponse(items, status_code=HTTPStatus.OK) + + +@router.put("/{item_id}") +async def update_item(item_id: int, item_params: ItemRequest): + item = data_storage.get_item(item_id=item_id) + + if item is None: + return HTTPException(HTTPStatus.NOT_FOUND, "Item with specified id does not exists!") + + item["name"] = item_params.name + item["price"] = item_params.price + + data_storage.update_item(item) + return JSONResponse(item, status_code=HTTPStatus.OK) + + +@router.patch("/{item_id}") +async def patch_item(item_id: int, item_params: PatchItemRequest): + item = data_storage.get_item(item_id=item_id) + + if item is None: + return HTTPException(HTTPStatus.NOT_FOUND, "Item with specified id does not exists!") + + if item["deleted"]: + return Response("Cannot patch deleted item!", status_code=HTTPStatus.NOT_MODIFIED) + + if item_params.name is not None: + item["name"] = item_params.name + + if item_params.price is not None: + item["price"] = item_params.price + + data_storage.update_item(item) + + return JSONResponse(item, status_code=HTTPStatus.OK) + +@router.delete("/{item_id}") +async def delete_item(item_id: int): + item = data_storage.get_item(item_id) + + if item is None: + return HTTPException(HTTPStatus.NOT_FOUND, "Item with specified id does not exists!") + + item["deleted"] = True + data_storage.update_item(item) + + return Response("Item was deleted", HTTPStatus.OK) \ No newline at end of file diff --git a/hw2/hw/shop_api/schemas.py b/hw2/hw/shop_api/schemas.py new file mode 100644 index 00000000..6c473eaf --- /dev/null +++ b/hw2/hw/shop_api/schemas.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, ConfigDict, Field, conlist + + +class ElementId(BaseModel): + id: int + + +class ItemRequest(BaseModel): + name: str + price: float + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") + + +class ItemResponse(ElementId): + deleted: bool = Field(default=False) + + +class CartItem(ElementId): + name: str + quantity: int + available: bool + + +class Cart(ElementId): + price: float + items: conlist(CartItem) \ No newline at end of file diff --git a/hw3/Dockerfile b/hw3/Dockerfile new file mode 100644 index 00000000..31c83eb5 --- /dev/null +++ b/hw3/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +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_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_service.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw3/README.md b/hw3/README.md new file mode 100644 index 00000000..aad28c54 --- /dev/null +++ b/hw3/README.md @@ -0,0 +1,13 @@ +# Π”Π— + +## ΠΠ°ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ сборку ΠΎΠ±Ρ€Π°Π·ΠΎΠ² Docker ΠΈ ΠΌΠΎΠ½ΠΈΡ‚ΠΎΡ€ΠΈΠ½Π³ с ΠΏΠΎΠΌΠΎΡ‰ΡŒΡŽ Prometheus ΠΈ Grafana + +Π˜Π½Ρ‚Π΅Π³Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Docker с Prometheus ΠΈ Grafana Π² любой ΡƒΠΆΠ΅ написанный Π² Π”Π— сСрвис (ΠΏΠΎ Π°Π½Π°Π»ΠΎΠ³ΠΈΠΈ с Ρ‚Π΅ΠΌ, ΠΊΠ°ΠΊ Π² Ρ€Π΅ΠΏΠ΅) + +По сути, Ссли Π²Ρ‹ Π²Ρ‹ΠΏΠΎΠ»Π½ΠΈΠ»ΠΈ Π²Ρ‚ΠΎΡ€ΡƒΡŽ Π΄ΠΎΠΌΠ°ΡˆΠΊΡƒ, Ρ‚ΠΎ Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ для Π½Π΅Ρ‘ Π½Π°Π΄ΠΎ Π½Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ Dockerfile ΠΈ Π½Π°ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ ΠΌΠΎΠ½ΠΈΡ‚ΠΎΡ€ΠΈΠ½Π³. Если Π²Ρ‚ΠΎΡ€ΡƒΡŽ Π΄ΠΎΠΌΠ°ΡˆΠΊΡƒ Π²Ρ‹ Π½Π΅ Π΄Π΅Π»Π°Π»ΠΈ, Ρ‚ΠΎ ΠΌΠΎΠΆΠ½ΠΎ Π²Π·ΡΡ‚ΡŒ сСрвис ΠΈΠ· [rest_example](../hw2/rest_example/main.py) + +Π‘Π΄Π°Ρ‡Π° Ρ‡Π΅Ρ€Π΅Π· PR, Ρ‚Π°ΠΊ ΠΆΠ΅ Π½ΡƒΠΆΠ½ΠΎ: + +1) Dockerfile для сборки сСрвиса +2) docker-compose.yml для локального разворачивания Π² Docker +3) ΠŸΡ€ΠΈΠ»ΠΎΠΆΠΈΡ‚ΡŒ скрин с ΠΏΠ°Ρ€ΠΎΠΉ Π”Π°ΡˆΠ±ΠΎΡ€Π΄ΠΎΠ² Π² Grafana diff --git a/hw3/docker-compose.yml b/hw3/docker-compose.yml new file mode 100644 index 00000000..427ac2f9 --- /dev/null +++ b/hw3/docker-compose.yml @@ -0,0 +1,30 @@ +version: "3" + +services: + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always diff --git a/hw3/requirements.txt b/hw3/requirements.txt new file mode 100644 index 00000000..8a610665 --- /dev/null +++ b/hw3/requirements.txt @@ -0,0 +1,11 @@ +# ΠžΡΠ½ΠΎΠ²Π½Ρ‹Π΅ зависимости для ASGI прилоТСния +fastapi>=0.117.1 +uvicorn>=0.24.0 +prometheus-fastapi-instrumentator + +# Зависимости для тСстирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + diff --git a/hw3/screenshots/dashboards.png b/hw3/screenshots/dashboards.png new file mode 100644 index 00000000..f245f167 Binary files /dev/null and b/hw3/screenshots/dashboards.png differ diff --git a/hw3/settings/prometheus/prometheus.yml b/hw3/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..ead30d0b --- /dev/null +++ b/hw3/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-service-prometheus + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw3/shop_service/__init__.py b/hw3/shop_service/__init__.py new file mode 100644 index 00000000..6b0c9aef --- /dev/null +++ b/hw3/shop_service/__init__.py @@ -0,0 +1,4 @@ +from shop_service.data_storage import DataStorage + + +data_storage = DataStorage("storage") \ No newline at end of file diff --git a/hw3/shop_service/data_storage.py b/hw3/shop_service/data_storage.py new file mode 100644 index 00000000..32b044cc --- /dev/null +++ b/hw3/shop_service/data_storage.py @@ -0,0 +1,127 @@ +import json +import os +from typing import Literal + + +class DataStorage: + def __init__(self, storage_path) -> None: + self.storage_path = os.path.join(os.getcwd(), storage_path) + + self.item_storage_path = os.path.join(self.storage_path, "item_storage.json") + self.cart_storage_path = os.path.join(self.storage_path, "cart_storage.json") + + self.init_storages() + + self.item_storage = self.open_storage(self.item_storage_path) + self.cart_storage = self.open_storage(self.cart_storage_path) + + print("DataStorage init!") + + def init_storages(self): + os.makedirs(self.storage_path, exist_ok=True) + + for st in [self.item_storage_path, self.cart_storage_path]: + if os.path.exists(st): + continue + + with open(st, 'w', encoding='utf-8') as f: + json.dump({}, f) + + def open_storage(self, storage): + with open(storage, 'r', encoding='utf-8') as f: + data = json.load(f) + + return data + + def save_storage(self, storage: Literal["item", "cart"]): + if storage not in ["item", "cart"]: + return + + match storage: + case "item": + with open(self.item_storage_path, "w") as f: + json.dump(self.item_storage, f, indent=4) + case "cart": + with open(self.cart_storage_path, "w") as f: + json.dump(self.cart_storage, f, indent=4) + case _: + print("Not exist storage!") + return + + def create_cart(self): + try: + next_cart_id = max(map(int, self.cart_storage.keys())) + 1 + except ValueError: + next_cart_id = 1 + + self.cart_storage[str(next_cart_id)] = {"id": next_cart_id, "items": {}} + self.save_storage("cart") + + return next_cart_id + + def is_cart_exists(self, cart_id): + return str(cart_id) in self.cart_storage + + def get_cart(self, cart_id): + if str(cart_id) not in self.cart_storage: + return None + + cart = {"id": cart_id, "items": []} + total = 0 + + for item_id, item_count in self.cart_storage[str(cart_id)]["items"].items(): + item = self.get_item(str(item_id)) + print(f"{item=}") + cart["items"].append( + { + "id": item["id"], + "name": item["name"], + "quantity": item_count, + "available": not item["deleted"], + } + ) + total += item_count * item["price"] + + cart["price"] = total + + return cart + + def get_carts(self): + return [self.get_cart(cart_id) for cart_id in self.cart_storage] + + def create_item(self, name: str, price: float): + try: + next_item_id = max(map(int, self.item_storage.keys())) + 1 + except ValueError: + next_item_id = 1 + + item = {"id": next_item_id, "name": name, "price": price, "deleted": False} + self.item_storage[str(next_item_id)] = item + self.save_storage("item") + + return item + + def is_item_exists(self, item_id): + return str(item_id) in self.item_storage + + def get_item(self, item_id): + if str(item_id) not in self.item_storage: + return None + + return self.item_storage[str(item_id)] + + def get_items(self): + return list(self.item_storage.values()) + + def update_item(self, item): + if str(item["id"]) not in self.item_storage: + return + + self.item_storage[str(item["id"])] = item + self.save_storage("item") + + def add_item2storage(self, cart_id, item): + items_in_cart = self.cart_storage[str(cart_id)]["items"].get(str(item["id"]), 0) + 1 + self.cart_storage[str(cart_id)]["items"][str(item["id"])] = items_in_cart + self.save_storage("cart") + \ No newline at end of file diff --git a/hw3/shop_service/main.py b/hw3/shop_service/main.py new file mode 100644 index 00000000..8013ee61 --- /dev/null +++ b/hw3/shop_service/main.py @@ -0,0 +1,28 @@ +from fastapi import FastAPI +from fastapi.responses import RedirectResponse +from prometheus_fastapi_instrumentator import Instrumentator + +from shop_service.routers.item import router as item_router +from shop_service.routers.cart import router as cart_router + +app = FastAPI(title="Shop API") + +app.include_router(item_router) +app.include_router(cart_router) + +Instrumentator().instrument(app).expose(app) + + +@app.get("/", include_in_schema=False) +async def docs_redirect(): + return RedirectResponse(url="/docs", status_code=301) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + app, + port=7005, + log_level="info", + ) \ No newline at end of file diff --git a/hw3/shop_service/routers/cart.py b/hw3/shop_service/routers/cart.py new file mode 100644 index 00000000..507c78c2 --- /dev/null +++ b/hw3/shop_service/routers/cart.py @@ -0,0 +1,76 @@ +from http import HTTPStatus +from typing import Annotated +from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import JSONResponse +from pydantic import NonNegativeInt, PositiveInt + +from shop_service.schemas import Cart + +from shop_service import data_storage + + +router = APIRouter( + prefix="/cart", + tags=["cart"], +) + +@router.post("/") +async def create_cart(): + cart_id = data_storage.create_cart() + return JSONResponse( + {"id": cart_id}, + status_code=HTTPStatus.CREATED, + headers={"location": f"/cart/{cart_id}"} + ) + + +@router.get("/{cart_id}", response_model=Cart) +async def get_cart(cart_id: int): + cart = data_storage.get_cart(cart_id) + + if cart is None: + return HTTPException(HTTPStatus.NOT_FOUND, "Cart not found!") + + return JSONResponse(cart, status_code=HTTPStatus.OK) + + +@router.get("/") +async def get_carts( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeInt, Query()] | None = None, + max_price: Annotated[NonNegativeInt, Query()] | None = None, + min_quantity: Annotated[NonNegativeInt, Query()] | None = None, + max_quantity: Annotated[NonNegativeInt, Query()] | None = None, + ): + if min_price is None: + min_price = 0 + + if max_price is None: + max_price = float("inf") # type: ignore + + if min_quantity is None: + min_quantity = 0 + + if max_quantity is None: + max_quantity = float("inf") # type: ignore + + carts = data_storage.get_carts() + + carts = list(filter(lambda x: min_price <= x["price"] <= max_price, carts)) # type: ignore + + carts = [cart for cart in carts if min_quantity <= sum([it["quantity"] for it in cart["items"]]) <= max_quantity] # type: ignore + carts = carts[offset: offset + limit] + return JSONResponse(carts, status_code=HTTPStatus.OK) + +@router.post("/{cart_id}/add/{item_id}") +async def add_item2cart(cart_id: int, item_id: int): + if not data_storage.is_cart_exists(cart_id): + return HTTPException(HTTPStatus.NOT_FOUND, "Cart not found!") + + item = data_storage.get_item(item_id=item_id) + if item is None: + return HTTPException(HTTPStatus.NOT_FOUND, "Item not found!") + + data_storage.add_item2storage(cart_id, item) + return Response(status_code=HTTPStatus.OK) \ No newline at end of file diff --git a/hw3/shop_service/routers/item.py b/hw3/shop_service/routers/item.py new file mode 100644 index 00000000..2aa8f487 --- /dev/null +++ b/hw3/shop_service/routers/item.py @@ -0,0 +1,101 @@ +from http import HTTPStatus +from typing import Annotated +from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import JSONResponse +from pydantic import NonNegativeInt, PositiveInt + +from shop_service import data_storage +from shop_service.schemas import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter( + prefix="/item", + tags=["item"], +) + +@router.post("/", response_model=ItemResponse) +async def create_item(item: ItemRequest): + created_item = data_storage.create_item(item.name, item.price) + + return JSONResponse( + created_item, + status_code=HTTPStatus.CREATED + ) + +@router.get("/{item_id}", response_model=ItemResponse) +async def get_item(item_id: int): + item = data_storage.get_item(item_id) + + if item is None or item["deleted"]: + return Response("Item with specified id does not exists or deleted!", status_code=HTTPStatus.NOT_FOUND) + + return JSONResponse(item, status_code=HTTPStatus.OK) + + +@router.get("/") +async def get_items( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeInt, Query()] | None = None, + max_price: Annotated[NonNegativeInt, Query()] | None = None, + show_deleted: bool = False): + items: list = data_storage.get_items() + + if min_price is None: + min_price = 0 + + if max_price is None: + max_price = float("inf") # type: ignore + + items = list(filter(lambda x: (not x["deleted"] if not show_deleted else True) and x["price"] >= min_price and x["price"] <= max_price, items)) + + items = items[offset: offset + limit] + + return JSONResponse(items, status_code=HTTPStatus.OK) + + +@router.put("/{item_id}") +async def update_item(item_id: int, item_params: ItemRequest): + item = data_storage.get_item(item_id=item_id) + + if item is None: + return HTTPException(HTTPStatus.NOT_FOUND, "Item with specified id does not exists!") + + item["name"] = item_params.name + item["price"] = item_params.price + + data_storage.update_item(item) + return JSONResponse(item, status_code=HTTPStatus.OK) + + +@router.patch("/{item_id}") +async def patch_item(item_id: int, item_params: PatchItemRequest): + item = data_storage.get_item(item_id=item_id) + + if item is None: + return HTTPException(HTTPStatus.NOT_FOUND, "Item with specified id does not exists!") + + if item["deleted"]: + return Response("Cannot patch deleted item!", status_code=HTTPStatus.NOT_MODIFIED) + + if item_params.name is not None: + item["name"] = item_params.name + + if item_params.price is not None: + item["price"] = item_params.price + + data_storage.update_item(item) + + return JSONResponse(item, status_code=HTTPStatus.OK) + +@router.delete("/{item_id}") +async def delete_item(item_id: int): + item = data_storage.get_item(item_id) + + if item is None: + return HTTPException(HTTPStatus.NOT_FOUND, "Item with specified id does not exists!") + + item["deleted"] = True + data_storage.update_item(item) + + return Response("Item was deleted", HTTPStatus.OK) \ No newline at end of file diff --git a/hw3/shop_service/schemas.py b/hw3/shop_service/schemas.py new file mode 100644 index 00000000..6c473eaf --- /dev/null +++ b/hw3/shop_service/schemas.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, ConfigDict, Field, conlist + + +class ElementId(BaseModel): + id: int + + +class ItemRequest(BaseModel): + name: str + price: float + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") + + +class ItemResponse(ElementId): + deleted: bool = Field(default=False) + + +class CartItem(ElementId): + name: str + quantity: int + available: bool + + +class Cart(ElementId): + price: float + items: conlist(CartItem) \ No newline at end of file diff --git a/hw45/Dockerfile b/hw45/Dockerfile new file mode 100644 index 00000000..31c83eb5 --- /dev/null +++ b/hw45/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +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_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_service.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw45/README.md b/hw45/README.md new file mode 100644 index 00000000..f0dde387 --- /dev/null +++ b/hw45/README.md @@ -0,0 +1,21 @@ +## Π”Π— 4 + +Π—Π° ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ ΠΏΡƒΠ½ΠΊΡ‚ - 1 Π±Π°Π»Π» + +Π’Π½Π΅Π΄Ρ€ΠΈΡ‚ΡŒ Π²ΠΎ Π²Ρ‚ΠΎΡ€ΡƒΡŽ Π΄ΠΎΠΌΠ°ΡˆΠΊΡƒ Ρ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ Π΄Π°Π½Π½Ρ‹Ρ… Π² Π‘Π”, для этого Π½Π°Π΄ΠΎ: +1) Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π‘Π” Π² docket-compose.yml (Ссли Π‘Π” - это ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹ΠΉ сСрвис, Ссли Ρ…ΠΎΡ‚ΠΈΡ‚Π΅ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ sqlite, Ρ‚ΠΎ ΠΌΠΎΠΆΠ½ΠΎ ΡΠΊΠΈΠΏΠ½ΡƒΡ‚ΡŒ этот шаг) +2) ΠŸΠ΅Ρ€Π΅ΠΏΠΈΡΠ°Ρ‚ΡŒ ΠΊΠΎΠ΄ Π½Π° взаимодСйствиС с вашСй Π‘Π” (Ссли Π²Ρ‹ Π΅Ρ‰Π΅ этого Π½Π΅ сдСлали, Ссли Π²Ρ‹ ΡƒΠΆΠ΅ написали ΠΊΠΎΠ΄ с Π‘Π”, ΠΏΠΎΠ΄Π·Ρ€Π°Π²Π»ΡΡŽ, Π²Π°ΠΌ остался Ρ‚ΠΎΠ»ΡŒΠΊΠΎ 3 ΠΏΡƒΠ½ΠΊΡ‚) +3) Π’ свободной Ρ„ΠΎΡ€ΠΌΠ΅, Π½Π°ΠΏΠΈΡˆΠΈΡ‚Π΅ скрипты, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ ΠΏΡ€ΠΎΡΠΈΠΌΡƒΠ»ΠΈΡ€ΡƒΡŽΡ‚ Ρ€Π°Π·Π½Ρ‹Π΅ "ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌΡ‹" ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ ΠΌΠΎΠ³ΡƒΡ‚ Π²ΠΎΠ·Π½ΠΈΠΊΠ½ΡƒΡ‚ΡŒ Π² транзакциях (dirty read, not-repeatable read, serialize) ΠΈ настраивая ΡƒΡ€ΠΎΠ²Π½Π΅ изоляции ΠΏΠΎΠΊΠ°ΠΆΠΈΡ‚Π΅, Ρ‡Ρ‚ΠΎ ΠΎΠ½ΠΈ Π΄Π΅ΠΉΡΡ‚Π²ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎ Ρ€Π΅ΡˆΠ°ΡŽΡ‚ΡΡ (Ρ‡Π΅Ρ€Π΅Π· SQLAlchemy Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€), Ρ‚ΠΎ Π΅ΡΡ‚ΡŒ: +ΠΏΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ dirty read ΠΏΡ€ΠΈ read uncommited +ΠΏΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ Ρ‡Ρ‚ΠΎ Π½Π΅Ρ‚ dirty read ΠΏΡ€ΠΈ read commited +ΠΏΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ non-repeatable read ΠΏΡ€ΠΈ read commited +ΠΏΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ Ρ‡Ρ‚ΠΎ Π½Π΅Ρ‚ non-repeatable read ΠΏΡ€ΠΈ repeatable read +ΠΏΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ phantom reads ΠΏΡ€ΠΈ repeatable read +ΠΏΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ Ρ‡Ρ‚ΠΎ Π½Π΅Ρ‚ phantom reads ΠΏΡ€ΠΈ serializable +*Π’ΡƒΡ‚ зависит ΠΎΡ‚ Ρ‚ΠΎΠ³ΠΎ ΠΊΠ°ΠΊΡƒΡŽ Π‘Π” Π²Ρ‹ Π²Ρ‹Π±Ρ€Π°Π»ΠΈ, Ρ€Π°Π·Π½Ρ‹Π΅ Π‘Π” ΠΌΠΎΠ³ΡƒΡ‚ ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Ρ‚ΡŒ Ρ€Π°Π·Π½Ρ‹Π΅ ΡƒΡ€ΠΎΠ²Π½ΠΈ изоляции + +# Π”Π— 5 + +1) Π”ΠΎΠ±ΠΈΡ‚ΡŒΡΡ 95% покрытия тСстами вашСй Π²Ρ‚ΠΎΡ€ΠΎΠΉ домашки - 1 Π±Π°Π»Π» + +2) ΠΠ°ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ автозапуск этих тСстов Π² CI, Ссли Π²Ρ‹ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°Π»ΠΈ ΡΡ‚ΠΎΡ€ΠΎΠ½ΡŽΡŽ Π‘Π”, Ρ‚ΠΎ ΠΌΠΎΠΆΠ½ΠΎ ΠΏΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Π²ΠΎΡ‚ [сюда](https://dev.to/kashifsoofi/integration-test-postgres-using-github-actions-3lln), Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ тСсты с Π½Π΅ΠΉ Π² CI. По ΠΈΡ‚ΠΎΠ³Ρƒ Ρƒ вас Π΄ΠΎΠ»ΠΆΠ΅Π½ получится Π·Π΅Π»Π΅Π½Ρ‹ΠΉ ΠΏΠ°ΠΉΠΏΠ»Π°ΠΉΠ½ - оцСниваСтся Π² Π΅Ρ‰Π΅ 2 Π±Π°Π»Π»Π°. diff --git a/hw45/db/cart.py b/hw45/db/cart.py new file mode 100644 index 00000000..f05d22de --- /dev/null +++ b/hw45/db/cart.py @@ -0,0 +1,199 @@ +from typing import List, Optional +from dataclasses import dataclass, field +from abc import ABC, abstractmethod + +from sqlalchemy import Column, ForeignKey, Integer + +from sqlalchemy.orm import Session, relationship + +from db.init import Base +from db.item import ItemMapper, ItemRepositoryInterface + + +# === Π”ΠΎΠΌΠ΅Π½Π½Ρ‹Π΅ ΠΌΠΎΠ΄Π΅Π»ΠΈ (Π±Π΅Π· привязки ΠΊ Π‘Π”) === + +@dataclass +class CartItem: + """ДомСнная модСль ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹""" + id: Optional[int] = None + name: str = "" + quantity: int = 0 + available: bool = True + + +@dataclass +class Cart: + """ДомСнная модСль ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹""" + id: Optional[int] = None + items: List[CartItem] = field(default_factory=list) # Π’Π΅ΠΏΠ΅Ρ€ΡŒ Ρ…Ρ€Π°Π½ΠΈΠΌ просто Ρ‚ΠΎΠ²Π°Ρ€Ρ‹ + price: float = 0 + + +# === SQLAlchemy ΠΌΠΎΠ΄Π΅Π»ΠΈ (для ΠΌΠ°ΠΏΠΈΠ½Π³Π° с Π‘Π”) === + + +class CartOrm(Base): + __tablename__ = 'carts' + + id = Column(Integer, primary_key=True) + orders = relationship("OrdersOrm", back_populates="cart") + + +class OrdersOrm(Base): + __tablename__ = 'orders' + + id = Column(Integer, primary_key=True) + cart_id = Column(Integer, ForeignKey("carts.id", ondelete="CASCADE"), nullable=False) + item_id = Column(Integer, ForeignKey("items.id", ondelete="CASCADE"), nullable=False) + + # Relationships + cart = relationship("CartOrm", back_populates="orders") + item = relationship("ItemOrm") + +# === ΠœΠ°ΠΏΠΏΠ΅Ρ€Ρ‹ (ΠΏΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΌΠ΅ΠΆΠ΄Ρƒ Π΄ΠΎΠΌΠ΅Π½Π½Ρ‹ΠΌΠΈ модСлями ΠΈ ORM) === + +class CartMapper: + """ΠœΠ°ΠΏΠΏΠ΅Ρ€ для прСобразования ΠΌΠ΅ΠΆΠ΄Ρƒ Cart ΠΈ CartOrm""" + + @staticmethod + def to_domain(orm_cart: CartOrm) -> Cart: + """ΠŸΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ ORM ΠΌΠΎΠ΄Π΅Π»ΠΈ Π² Π΄ΠΎΠΌΠ΅Π½Π½ΡƒΡŽ""" + items = [] + tmp = {} + + # КаТдая строка Π² orders прСдставляСт ΠΎΠ΄ΠΈΠ½ экзСмпляр Ρ‚ΠΎΠ²Π°Ρ€Π° + for order in orm_cart.orders: + item = ItemMapper.to_domain(order.item) + if item.id not in tmp: + tmp[item.id] = {"name": item.name, "price": item.price, "quantity": 1, "available": not item.deleted} + else: + tmp[item.id]["quantity"] += 1 + + total_price = 0 + for key, value in tmp.items(): + items.append(CartItem(id=key, name=value["name"], quantity=value["quantity"], available=value["available"])) + total_price += value["price"] * value["quantity"] + + return Cart( + id=orm_cart.id, + items=items, + price=total_price + ) + + @staticmethod + def to_orm( + domain_cart: Cart, + orm_cart: Optional[CartOrm] = None, + ) -> CartOrm: + """ΠŸΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ Π΄ΠΎΠΌΠ΅Π½Π½ΠΎΠΉ ΠΌΠΎΠ΄Π΅Π»ΠΈ Π² ORM""" + if orm_cart is None: + orm_cart = CartOrm() + + # Π›ΠΎΠ³ΠΈΠΊΠ° orders Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Ρ‚ΡŒΡΡ Π² Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΈ + return orm_cart + +# === АбстрактныС интСрфСйсы Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠ΅Π² === + +class CartRepositoryInterface(ABC): + """Π˜Π½Ρ‚Π΅Ρ€Ρ„Π΅ΠΉΡ рСпозитория ΠΊΠΎΡ€Π·ΠΈΠ½""" + + @abstractmethod + def create(self, cart: Cart) -> Cart: + pass + + @abstractmethod + def find_by_id(self, cart_id: int) -> Optional[Cart]: + pass + + @abstractmethod + def update(self, cart: Cart) -> Cart: + pass + + @abstractmethod + def get_all(self) -> List[Cart]: + pass + + +# === ΠšΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½Ρ‹Π΅ Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠ΅Π² === + +class SqlAlchemyCartRepository(CartRepositoryInterface): + """SQLAlchemy рСализация рСпозитория ΠΊΠΎΡ€Π·ΠΈΠ½""" + + def __init__(self, session: Session): + self.session = session + + def create(self, cart: Cart) -> Cart: + orm_cart = CartMapper.to_orm(cart) + self.session.add(orm_cart) + self.session.commit() + return CartMapper.to_domain(orm_cart) + + def find_by_id(self, cart_id: int) -> Optional[Cart]: + orm_cart = self.session.query(CartOrm).filter_by(id=cart_id).first() + return CartMapper.to_domain(orm_cart) if orm_cart else None + + def update(self, cart: Cart) -> Cart: + orm_cart = self.session.query(CartOrm).filter_by(id=cart.id).first() + if not orm_cart: + raise ValueError(f"Cart with id {cart.id} not found") + + # УдаляСм старыС orders + self.session.query(OrdersOrm).filter_by(cart_id=cart.id).delete() + + # ДобавляСм Π½ΠΎΠ²Ρ‹Π΅ orders (ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ Ρ‚ΠΎΠ²Π°Ρ€ ΠΊΠ°ΠΊ ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Π°Ρ строка) + for item in cart.items: + order = OrdersOrm(cart_id=cart.id, item_id=item.id) + self.session.add(order) + + self.session.commit() + return CartMapper.to_domain(orm_cart) + + def get_all(self): + orm_carts = self.session.query(CartOrm).all() + return [CartMapper.to_domain(orm_cart) for orm_cart in orm_carts] + +# === БСрвисы для бизнСс-Π»ΠΎΠ³ΠΈΠΊΠΈ === + +class CartService: + """БСрвис для Ρ€Π°Π±ΠΎΡ‚Ρ‹ с ΠΊΠΎΡ€Π·ΠΈΠ½Π°ΠΌΠΈ""" + + def __init__(self, cart_repo: CartRepositoryInterface, item_repo: ItemRepositoryInterface): + self.cart_repo = cart_repo + self.item_repo = item_repo + + def create_cart(self) -> Cart: + """Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ Π½ΠΎΠ²ΠΎΠΉ ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹""" + cart = Cart() + return self.cart_repo.create(cart) + + def get_cart(self, cart_id: int): + cart = self.cart_repo.find_by_id(cart_id) + + if not cart: + return None + + return cart + + def get_carts(self): + return self.cart_repo.get_all() + + def add_item_to_cart(self, cart_id: int, item_id: int, count: int = 1) -> Cart: + """Π”ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅ Ρ‚ΠΎΠ²Π°Ρ€Π° Π² ΠΊΠΎΡ€Π·ΠΈΠ½Ρƒ""" + if count <= 0: + raise ValueError("Count must be positive") + + # ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ сущСствованиС Ρ‚ΠΎΠ²Π°Ρ€Π° + item = self.item_repo.find_by_id(item_id) + if not item: + raise ValueError(f"Item with id {item_id} not found") + + # ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ ΠΊΠΎΡ€Π·ΠΈΠ½Ρƒ + cart = self.cart_repo.find_by_id(cart_id) + if not cart: + raise ValueError(f"Cart with id {cart_id} not found") + + # ДобавляСм Ρ‚ΠΎΠ²Π°Ρ€ ΡƒΠΊΠ°Π·Π°Π½Π½ΠΎΠ΅ количСство Ρ€Π°Π· (каТдая копия - ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Π°Ρ строка Π² orders) + for _ in range(count): + cart.items.append(item) + + return self.cart_repo.update(cart) \ No newline at end of file diff --git a/hw45/db/init.py b/hw45/db/init.py new file mode 100644 index 00000000..028e1283 --- /dev/null +++ b/hw45/db/init.py @@ -0,0 +1,8 @@ +from sqlalchemy.ext.declarative import declarative_base + + +DATABASE_URL_BASE = "postgresql://postgres:postgres@localhost:5432" # postgres for docker container +DB_NAME = "db" +DATABASE_URL = f"{DATABASE_URL_BASE}/{DB_NAME}" + +Base = declarative_base() \ No newline at end of file diff --git a/hw45/db/item.py b/hw45/db/item.py new file mode 100644 index 00000000..9954956e --- /dev/null +++ b/hw45/db/item.py @@ -0,0 +1,168 @@ +from typing import List, Optional +from dataclasses import dataclass +from abc import ABC, abstractmethod + +from sqlalchemy import Column, Integer, String, Boolean, Numeric +from sqlalchemy.orm import Session +from sqlalchemy.sql import func + +from db.init import Base + + +# === Π”ΠΎΠΌΠ΅Π½Π½Ρ‹Π΅ ΠΌΠΎΠ΄Π΅Π»ΠΈ (Π±Π΅Π· привязки ΠΊ Π‘Π”) === + + +@dataclass +class Item: + """ДомСнная модСль Ρ‚ΠΎΠ²Π°Ρ€Π°""" + + id: Optional[int] = None + name: str = "" + price: float = 0 + deleted: bool = False + + +# === SQLAlchemy ΠΌΠΎΠ΄Π΅Π»ΠΈ (для ΠΌΠ°ΠΏΠΈΠ½Π³Π° с Π‘Π”) === + + +class ItemOrm(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + price = Column(Numeric, nullable=False) + deleted = Column(Boolean, default=func.now()) + + +# === ΠœΠ°ΠΏΠΏΠ΅Ρ€Ρ‹ (ΠΏΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΌΠ΅ΠΆΠ΄Ρƒ Π΄ΠΎΠΌΠ΅Π½Π½Ρ‹ΠΌΠΈ модСлями ΠΈ ORM) === + + +class ItemMapper: + """ΠœΠ°ΠΏΠΏΠ΅Ρ€ для прСобразования ΠΌΠ΅ΠΆΠ΄Ρƒ Item ΠΈ ItemOrm""" + + @staticmethod + def to_domain(orm_item: ItemOrm) -> Item: + """ΠŸΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ ORM ΠΌΠΎΠ΄Π΅Π»ΠΈ Π² Π΄ΠΎΠΌΠ΅Π½Π½ΡƒΡŽ""" + return Item( + id=orm_item.id, + name=orm_item.name, + price=orm_item.price, + deleted=orm_item.deleted, + ) + + @staticmethod + def to_orm( + domain_user: Item, + orm_user: Optional[ItemOrm] = None, + ) -> ItemOrm: + """ΠŸΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ Π΄ΠΎΠΌΠ΅Π½Π½ΠΎΠΉ ΠΌΠΎΠ΄Π΅Π»ΠΈ Π² ORM""" + if orm_user is None: + orm_user = ItemOrm() + + orm_user.name = domain_user.name + orm_user.price = domain_user.price + orm_user.deleted = domain_user.deleted + + return orm_user + + +# === АбстрактныС интСрфСйсы Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠ΅Π² === + + +class ItemRepositoryInterface(ABC): + """Π˜Π½Ρ‚Π΅Ρ€Ρ„Π΅ΠΉΡ рСпозитория Ρ‚ΠΎΠ²Π°Ρ€ΠΎΠ²""" + + @abstractmethod + def create(self, item: Item) -> Item: + pass + + @abstractmethod + def find_by_id(self, item_id: int) -> Optional[Item]: + pass + + @abstractmethod + def get_all(self) -> List[Item]: + pass + + @abstractmethod + def update(self, item: Item) -> Item: + pass + + @abstractmethod + def delete(self, item_id: int) -> None: + pass + + +# === ΠšΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½Ρ‹Π΅ Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠ΅Π² === + + +class SqlAlchemyItemRepository(ItemRepositoryInterface): + """SQLAlchemy рСализация рСпозитория Ρ‚ΠΎΠ²Π°Ρ€ΠΎΠ²""" + + def __init__(self, session: Session): + self.session = session + + # is_commit ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ для возмоТности дСмонстрации Ρ€Π°Π·Π»ΠΈΡ‡Π½Ρ‹Ρ… ΡƒΡ€ΠΎΠ²Π½Π΅ΠΉ изоляции Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΠΉ Π² тСстах + def create(self, item: Item, is_commit = True) -> Item: + orm_item = ItemMapper.to_orm(item) + self.session.add(orm_item) + if is_commit: + self.session.commit() + return ItemMapper.to_domain(orm_item) + + def find_by_id(self, item_id: int) -> Optional[Item]: + orm_item = ( + self.session.query(ItemOrm).filter_by(id=item_id).first() + ) + return ItemMapper.to_domain(orm_item) if orm_item else None + + def get_all(self) -> List[Item]: + orm_items = self.session.query(ItemOrm).all() + return [ItemMapper.to_domain(orm_item) for orm_item in orm_items] + + def update(self, item: Item) -> Item: + orm_item = self.session.query(ItemOrm).filter_by(id=item.id).first() + + ItemMapper.to_orm(item, orm_item) + self.session.commit() + return ItemMapper.to_domain(orm_item) + + def delete(self, item_id: int) -> None: + orm_item = self.session.query(ItemOrm).filter_by(id=item_id).first() + if not orm_item: + raise ValueError(f"Item with id {item_id} not found") + + orm_item.deleted = True + self.session.commit() + + +# === БСрвисы для бизнСс-Π»ΠΎΠ³ΠΈΠΊΠΈ === + + +class ItemService: + """БСрвис для Ρ€Π°Π±ΠΎΡ‚Ρ‹ с Ρ‚ΠΎΠ²Π°Ρ€Π°ΠΌΠΈ""" + + def __init__(self, item_repo: ItemRepositoryInterface): + self.item_repo = item_repo + + def create_item(self, name: str, price: int) -> Item: + """Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ Π½ΠΎΠ²ΠΎΠ³ΠΎ Ρ‚ΠΎΠ²Π°Ρ€Π° с Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΠ΅ΠΉ""" + item = Item(name=name, price=price) + return self.item_repo.create(item) + + def get_items(self) -> List[Item]: + """ΠŸΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ всСх Ρ‚ΠΎΠ²Π°Ρ€ΠΎΠ²""" + return self.item_repo.get_all() + + def get_item(self, item_id: int) -> Item: + """ΠŸΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ Ρ‚ΠΎΠ²Π°Ρ€Π° с ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΎΠΉ сущСствования""" + item = self.item_repo.find_by_id(item_id) + if not item: + return None + return item + + def update_item(self, item: Item) -> Item: + return self.item_repo.update(item) + + def delete_item(self, item_id: int): + self.item_repo.delete(item_id=item_id) \ No newline at end of file diff --git a/hw45/db/main.py b/hw45/db/main.py new file mode 100644 index 00000000..d4b8319a --- /dev/null +++ b/hw45/db/main.py @@ -0,0 +1,45 @@ +from db.cart import CartService, SqlAlchemyCartRepository +from db.item import ItemService, SqlAlchemyItemRepository +from db.utils import create_tables, get_db + +# Create tables +create_tables() + +# Get database session +db = next(get_db()) + +try: + # Initialize repositories + item_repo = SqlAlchemyItemRepository(db) + cart_repo = SqlAlchemyCartRepository(db) + + # Initialize services + item_service = ItemService(item_repo) + cart_service = CartService(cart_repo, item_repo) + + # Example usage + # Create some items + item1 = item_service.create_item("Laptop", 1000) + item2 = item_service.create_item("Mouse", 50) + + print(f"Created items: {item1.name}, {item2.name}") + + # Create a cart + cart = cart_service.create_cart() + print(f"Created cart with ID: {cart.id}") + + # Add items to cart + cart = cart_service.add_item_to_cart(cart.id, item1.id, 2) # 2 laptops + cart = cart_service.add_item_to_cart(cart.id, item2.id, 1) # 1 mouse + + # Get cart total + total = cart_service.get_cart_total(cart.id) + print(f"Cart total: ${total}") + + # Get item count + laptop_count = cart_service.get_item_count(cart.id, item1.id) + print(f"Laptops in cart: {laptop_count}") +except Exception as e: + print(f"Error: {e}") +finally: + db.close() diff --git a/hw45/db/utils.py b/hw45/db/utils.py new file mode 100644 index 00000000..2b20793c --- /dev/null +++ b/hw45/db/utils.py @@ -0,0 +1,69 @@ +from sqlalchemy import text +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from db.init import DATABASE_URL, DATABASE_URL_BASE, DB_NAME, Base + + +def create_database(db_url, db_name): + default_engine = create_engine( + f"{db_url}/postgres", + isolation_level="AUTOCOMMIT", + ) + + with default_engine.connect() as conn: + result = conn.execute(text(f"SELECT 1 FROM pg_database WHERE datname='{db_name}'")) + exists = result.scalar() is not None + + if not exists: + conn.execute(text(f'CREATE DATABASE "{db_name}"')) + print(f"Database '{db_name}' created.") + else: + print(f"Database '{db_name}' already exists.") + + +# Dependency to get database session +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_db_with_specified_isolation(isolation_level="READ COMMITTED"): + engine = create_engine( + DATABASE_URL, + echo=False, # Set to False in production + pool_size=10, + max_overflow=20, + isolation_level=isolation_level, + ) + + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db = SessionLocal() + try: + yield db + finally: + db.close() + + +# Create tables +def create_tables(delete_existing: bool = False): + if delete_existing: + Base.metadata.drop_all(bind=engine) + + create_database(DATABASE_URL_BASE, DB_NAME) + Base.metadata.create_all(bind=engine) + print("Tables created successfully!") + + +engine = create_engine( + DATABASE_URL, + echo=False, # Set to False in production + pool_size=10, + max_overflow=20, + # isolation_level="REPEATABLE READ", +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) \ No newline at end of file diff --git a/hw45/docker-compose.yml b/hw45/docker-compose.yml new file mode 100644 index 00000000..658cd283 --- /dev/null +++ b/hw45/docker-compose.yml @@ -0,0 +1,55 @@ +version: "3" + +services: + postgres: + image: postgres:15 + container_name: postgres + environment: + POSTGRES_DB: db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + stdin_open: true # -i + tty: true # -t + depends_on: + postgres: + condition: service_healthy # Wait for postgres to be healthy + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw45/migrations/init.sql b/hw45/migrations/init.sql new file mode 100644 index 00000000..0db7d55f --- /dev/null +++ b/hw45/migrations/init.sql @@ -0,0 +1,41 @@ +-- Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ схСмы Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… для ΠΏΡ€ΠΈΠΌΠ΅Ρ€ΠΎΠ² +DROP TABLE IF EXISTS carts CASCADE; +DROP TABLE IF EXISTS items CASCADE; +DROP TABLE IF EXISTS orders CASCADE; + +-- Π’Π°Π±Π»ΠΈΡ†Π° ΠΊΠΎΡ€Π·ΠΈΠ½ +CREATE TABLE carts ( + id SERIAL PRIMARY KEY, +); + +-- Π’Π°Π±Π»ΠΈΡ†Π° ΠΏΡ€ΠΎΠ΄ΡƒΠΊΡ‚ΠΎΠ² +CREATE TABLE items ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price DECIMAL(10, 2) NOT NULL CHECK (price >= 0), + deleted BOOLEAN DEFAULT FALSE, +); + +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, +); + +-- -- Вставка тСстовых Π΄Π°Π½Π½Ρ‹Ρ… +-- INSERT INTO users (email, name, age) VALUES +-- ('alice@example.com', 'Alice Johnson', 28), +-- ('bob@example.com', 'Bob Smith', 35), +-- ('charlie@example.com', 'Charlie Brown', 42); + +-- INSERT INTO products (name, price, description, in_stock) VALUES +-- ('Laptop', 999.99, 'High-performance laptop', TRUE), +-- ('Mouse', 29.99, 'Wireless optical mouse', TRUE), +-- ('Keyboard', 79.99, 'Mechanical gaming keyboard', FALSE), +-- ('Monitor', 299.99, '24-inch LCD monitor', TRUE); + +-- INSERT INTO orders (user_id, product_id, quantity, total_price, status) VALUES +-- (1, 1, 1, 999.99, 'delivered'), +-- (1, 2, 2, 59.98, 'shipped'), +-- (2, 3, 1, 79.99, 'processing'), +-- (3, 4, 1, 299.99, 'pending'); diff --git a/hw45/pytest.ini b/hw45/pytest.ini new file mode 100644 index 00000000..e1f2fd07 --- /dev/null +++ b/hw45/pytest.ini @@ -0,0 +1,5 @@ +[tool:pytest] +testpaths = tests +addopts = -v --cov=shop_service --cov-report=term-missing --cov-report=html --cov-report=xml +python_files = test_*.py +python_functions = test_* diff --git a/hw45/requirements.txt b/hw45/requirements.txt new file mode 100644 index 00000000..8d247716 --- /dev/null +++ b/hw45/requirements.txt @@ -0,0 +1,20 @@ +# ΠžΡΠ½ΠΎΠ²Π½Ρ‹Π΅ зависимости для ASGI прилоТСния +fastapi>=0.117.1 +uvicorn>=0.24.0 +prometheus-fastapi-instrumentator + +# Зависимости для тСстирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +pytest-cov +pytest-mock +httpx>=0.27.2 +Faker>=37.8.0 +responses +coverage + +# Sql +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 + diff --git a/hw45/screenshots/dashboards.png b/hw45/screenshots/dashboards.png new file mode 100644 index 00000000..f245f167 Binary files /dev/null and b/hw45/screenshots/dashboards.png differ diff --git a/hw45/settings/prometheus/prometheus.yml b/hw45/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..ead30d0b --- /dev/null +++ b/hw45/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-service-prometheus + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw45/shop_service/main.py b/hw45/shop_service/main.py new file mode 100644 index 00000000..7ac68bce --- /dev/null +++ b/hw45/shop_service/main.py @@ -0,0 +1,15 @@ +from fastapi import FastAPI +from fastapi.responses import RedirectResponse +from prometheus_fastapi_instrumentator import Instrumentator + +from db.utils import create_tables +from shop_service.routers.item import router as item_router +from shop_service.routers.cart import router as cart_router + +create_tables() +app = FastAPI(title="Shop API") + +app.include_router(item_router) +app.include_router(cart_router) + +Instrumentator().instrument(app).expose(app) diff --git a/hw45/shop_service/routers/cart.py b/hw45/shop_service/routers/cart.py new file mode 100644 index 00000000..6c39ed0e --- /dev/null +++ b/hw45/shop_service/routers/cart.py @@ -0,0 +1,82 @@ +from http import HTTPStatus +from typing import Annotated +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi.responses import JSONResponse +from pydantic import NonNegativeInt, PositiveInt +from sqlalchemy.orm import Session + +from db.utils import get_db +from db.cart import CartService, SqlAlchemyCartRepository +from db.item import SqlAlchemyItemRepository +from shop_service.schemas import Cart + + +router = APIRouter( + prefix="/cart", + tags=["cart"], +) + +@router.post("/") +async def create_cart(db: Session = Depends(get_db)): + cart_service = CartService(SqlAlchemyCartRepository(db), SqlAlchemyItemRepository(db)) + + cart = cart_service.create_cart() + return JSONResponse( + {"id": cart.id}, + status_code=HTTPStatus.CREATED, + headers={"location": f"/cart/{cart.id}"} + ) + + +@router.get("/{cart_id}", response_model=Cart, status_code=HTTPStatus.OK) +async def get_cart(cart_id: int, db: Session = Depends(get_db)): + cart_service = CartService(SqlAlchemyCartRepository(db), SqlAlchemyItemRepository(db)) + cart = cart_service.get_cart(cart_id) + + if cart is None: + return Response("Cart not found!", status_code=HTTPStatus.NOT_FOUND) + + return cart + + +@router.get("/", status_code=HTTPStatus.OK) +async def get_carts( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeInt, Query()] | None = None, + max_price: Annotated[NonNegativeInt, Query()] | None = None, + min_quantity: Annotated[NonNegativeInt, Query()] | None = None, + max_quantity: Annotated[NonNegativeInt, Query()] | None = None, + db: Session = Depends(get_db) + ): + if min_price is None: + min_price = 0 + + if max_price is None: + max_price = float("inf") # type: ignore + + if min_quantity is None: + min_quantity = 0 + + if max_quantity is None: + max_quantity = float("inf") # type: ignore + + cart_service = CartService(SqlAlchemyCartRepository(db), SqlAlchemyItemRepository(db)) + carts = cart_service.get_carts() + + carts = list(filter(lambda x: min_price <= x.price <= max_price, carts)) # type: ignore + + carts = [cart for cart in carts if min_quantity <= sum([it.quantity for it in cart.items]) <= max_quantity] # type: ignore + carts = carts[offset: offset + limit] + return carts + +@router.post("/{cart_id}/add/{item_id}") +async def add_item2cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + cart_service = CartService(SqlAlchemyCartRepository(db), SqlAlchemyItemRepository(db)) + + try: + cart_service.add_item_to_cart(cart_id=cart_id, item_id=item_id, count=1) + except ValueError as e: + return Response(str(e), status_code=HTTPStatus.NOT_FOUND) + + return Response(status_code=HTTPStatus.OK) \ No newline at end of file diff --git a/hw45/shop_service/routers/item.py b/hw45/shop_service/routers/item.py new file mode 100644 index 00000000..49a0361c --- /dev/null +++ b/hw45/shop_service/routers/item.py @@ -0,0 +1,106 @@ +from http import HTTPStatus +from typing import Annotated +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from pydantic import NonNegativeInt, PositiveInt + +from sqlalchemy.orm import Session + +from db.utils import get_db +from db.item import ItemService, SqlAlchemyItemRepository +from shop_service.schemas import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter( + prefix="/item", + tags=["item"], +) + +@router.post("/", response_model=ItemResponse, status_code=HTTPStatus.CREATED) +async def create_item(item: ItemRequest, db: Session = Depends(get_db)): + item_service = ItemService(SqlAlchemyItemRepository(db)) + created_item = item_service.create_item(item.name, item.price) + return created_item + +@router.get("/{item_id}", response_model=ItemResponse, status_code=HTTPStatus.OK) +async def get_item(item_id: int, db: Session = Depends(get_db)): + item_service = ItemService(SqlAlchemyItemRepository(db)) + item = item_service.get_item(item_id) + + if item is None or item.deleted: + return Response("Item with specified id does not exists or deleted!", status_code=HTTPStatus.NOT_FOUND) + + return item + + +@router.get("/", status_code=HTTPStatus.OK) +async def get_items( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeInt, Query()] | None = None, + max_price: Annotated[NonNegativeInt, Query()] | None = None, + show_deleted: bool = False, + db: Session = Depends(get_db) + ): + item_service = ItemService(SqlAlchemyItemRepository(db)) + items: list = item_service.get_items() + + if min_price is None: + min_price = 0 + + if max_price is None: + max_price = float("inf") # type: ignore + + items = list(filter(lambda x: (not x.deleted if not show_deleted else True) and x.price >= min_price and x.price <= max_price, items)) + + items = items[offset: offset + limit] + + return items + + +@router.put("/{item_id}", status_code=HTTPStatus.OK) +async def update_item(item_id: int, item_params: ItemRequest, db: Session = Depends(get_db)): + item_service = ItemService(SqlAlchemyItemRepository(db)) + + item = item_service.get_item(item_id=item_id) + + if item is None: + return Response("Item with specified id does not exists!", status_code=HTTPStatus.NOT_FOUND) + + item.name = item_params.name + item.price = item_params.price + + item_service.update_item(item) + return item + + +@router.patch("/{item_id}") +async def patch_item(item_id: int, item_params: PatchItemRequest, db: Session = Depends(get_db)): + item_service = ItemService(SqlAlchemyItemRepository(db)) + item = item_service.get_item(item_id=item_id) + + if item is None: + return Response("Item with specified id does not exists!", status_code=HTTPStatus.NOT_FOUND) + + if item.deleted: + return Response("Cannot patch deleted item!", status_code=HTTPStatus.NOT_MODIFIED) + + if item_params.name is not None: + item.name = item_params.name + + if item_params.price is not None: + item.price = item_params.price + + item_service.update_item(item) + + return item + +@router.delete("/{item_id}", status_code=HTTPStatus.OK) +async def delete_item(item_id: int, db: Session = Depends(get_db)): + item_service = ItemService(SqlAlchemyItemRepository(db)) + + try: + item_service.delete_item(item_id=item_id) + except ValueError: + return HTTPException(HTTPStatus.NOT_FOUND, "Item with specified id does not exists!") + + return Response("Item was deleted") \ No newline at end of file diff --git a/hw45/shop_service/schemas.py b/hw45/shop_service/schemas.py new file mode 100644 index 00000000..e7437b88 --- /dev/null +++ b/hw45/shop_service/schemas.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, ConfigDict, Field, conlist + + +class ElementId(BaseModel): + id: int + + +class ItemRequest(BaseModel): + name: str + price: float = Field(gt=0) + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") + + +class ItemResponse(ElementId, ItemRequest): + deleted: bool = Field(default=False) + + +class CartItem(ElementId): + name: str + quantity: int + available: bool + + +class Cart(ElementId): + price: float + items: conlist(CartItem) \ No newline at end of file diff --git a/hw45/tests/logs/test_dirty_read_simulation.log b/hw45/tests/logs/test_dirty_read_simulation.log new file mode 100644 index 00000000..be929074 --- /dev/null +++ b/hw45/tests/logs/test_dirty_read_simulation.log @@ -0,0 +1,4 @@ +2025-10-25 21:56:11,675 [INFO] Repo1: создаём запись (Device, 1000) Π±Π΅Π· ΠΊΠΎΠΌΠΌΠΈΡ‚Π° +2025-10-25 21:56:11,677 [INFO] Repo2: ΠΏΡ€ΠΎΠ±ΡƒΠ΅ΠΌ Ρ‡ΠΈΡ‚Π°Ρ‚ΡŒ Π΄ΠΎ ΠΊΠΎΠΌΠΌΠΈΡ‚Π° Repo2 +2025-10-25 21:56:11,815 [INFO] Repo2: Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ чтСния = [] +2025-10-25 21:56:11,815 [INFO] Dirty read ΠΏΡ€Π΅Π΄ΠΎΡ‚Π²Ρ€Π°Ρ‰Ρ‘Π½ (Postgres Π½Π΅ Π΄Π°Ρ‘Ρ‚ Ρ‡ΠΈΡ‚Π°Ρ‚ΡŒ Π½Π΅Π·Π°ΠΊΠΎΠΌΠΌΠΈΡ‡Π΅Π½Π½Ρ‹Π΅ Π΄Π°Π½Π½Ρ‹Π΅). diff --git a/hw45/tests/logs/test_non_repeatable_read[READ COMMITTED-False].log b/hw45/tests/logs/test_non_repeatable_read[READ COMMITTED-False].log new file mode 100644 index 00000000..8475bb45 --- /dev/null +++ b/hw45/tests/logs/test_non_repeatable_read[READ COMMITTED-False].log @@ -0,0 +1,5 @@ +2025-10-25 21:56:11,818 [INFO] Repo1: создаём запись (Device, 1000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘ +2025-10-25 21:56:11,860 [INFO] Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π² ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Ρ€Π°Π· 1 item: price=1000 +2025-10-25 21:56:11,889 [INFO] Repo2: ΠΎΠ±Π½ΠΎΠ²ΠΈΠ» Ρ†Π΅Π½Ρƒ Π΄ΠΎ 2000 ΠΈ Π·Π°ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠ» +2025-10-25 21:56:11,890 [INFO] Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π²ΠΎ Π²Ρ‚ΠΎΡ€ΠΎΠΉ Ρ€Π°Π· 1 item: price=2000 +2025-10-25 21:56:11,890 [INFO] Non-repeatable read зафиксирован (Ρ†Π΅Π½Π° измСнилась ΠΌΠ΅ΠΆΠ΄Ρƒ чтСниями). diff --git a/hw45/tests/logs/test_non_repeatable_read[REPEATABLE READ-True].log b/hw45/tests/logs/test_non_repeatable_read[REPEATABLE READ-True].log new file mode 100644 index 00000000..58a8bf9c --- /dev/null +++ b/hw45/tests/logs/test_non_repeatable_read[REPEATABLE READ-True].log @@ -0,0 +1,5 @@ +2025-10-25 21:56:11,893 [INFO] Repo1: создаём запись (Device, 1000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘ +2025-10-25 21:56:11,921 [INFO] Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π² ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Ρ€Π°Π· 2 item: price=1000 +2025-10-25 21:56:11,952 [INFO] Repo2: ΠΎΠ±Π½ΠΎΠ²ΠΈΠ» Ρ†Π΅Π½Ρƒ Π΄ΠΎ 2000 ΠΈ Π·Π°ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠ» +2025-10-25 21:56:11,952 [INFO] Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π²ΠΎ Π²Ρ‚ΠΎΡ€ΠΎΠΉ Ρ€Π°Π· 2 item: price=1000 +2025-10-25 21:56:11,952 [INFO] Non-repeatable read ΠΏΡ€Π΅Π΄ΠΎΡ‚Π²Ρ€Π°Ρ‰Ρ‘Π½ (Postgres snapshot isolation). diff --git a/hw45/tests/logs/test_phantom_read[READ COMMITTED-False].log b/hw45/tests/logs/test_phantom_read[READ COMMITTED-False].log new file mode 100644 index 00000000..d435b3eb --- /dev/null +++ b/hw45/tests/logs/test_phantom_read[READ COMMITTED-False].log @@ -0,0 +1,5 @@ +2025-10-25 21:56:11,954 [INFO] Repo1: создаём запись (Device, 1000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘ +2025-10-25 21:56:11,984 [INFO] Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π² ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Ρ€Π°Π· всС ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚Ρ‹: ID ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ΠΎΠ² - [1, 2, 3] +2025-10-25 21:56:12,013 [INFO] Repo2: создаём запись (TV, 5000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘ +2025-10-25 21:56:12,013 [INFO] Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π²ΠΎ Π²Ρ‚ΠΎΡ€ΠΎΠΉ Ρ€Π°Π· всС ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚Ρ‹: ID ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ΠΎΠ² - [1, 2, 3, 4] +2025-10-25 21:56:12,013 [INFO] Phantom Read зафиксирован (появился Π½ΠΎΠ²Ρ‹ΠΉ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚). diff --git a/hw45/tests/logs/test_phantom_read[REPEATABLE READ-True].log b/hw45/tests/logs/test_phantom_read[REPEATABLE READ-True].log new file mode 100644 index 00000000..58d9387c --- /dev/null +++ b/hw45/tests/logs/test_phantom_read[REPEATABLE READ-True].log @@ -0,0 +1,5 @@ +2025-10-25 21:56:12,016 [INFO] Repo1: создаём запись (Device, 1000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘ +2025-10-25 21:56:12,045 [INFO] Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π² ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Ρ€Π°Π· всС ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚Ρ‹: ID ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ΠΎΠ² - [1, 2, 3, 4, 5] +2025-10-25 21:56:12,075 [INFO] Repo2: создаём запись (TV, 5000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘ +2025-10-25 21:56:12,075 [INFO] Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π²ΠΎ Π²Ρ‚ΠΎΡ€ΠΎΠΉ Ρ€Π°Π· всС ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚Ρ‹: ID ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ΠΎΠ² - [1, 2, 3, 4, 5] +2025-10-25 21:56:12,075 [INFO] Phantom Read ΠΏΡ€Π΅Π΄ΠΎΡ‚Π²Ρ€Π°Ρ‰Ρ‘Π½ Π² случаС SERIALIZABLE ΠΈ Π² Π±ΠΎΠ»ΡŒΡˆΠΈΠ½ΡΡ‚Π²Π΅ случаСв REPEATABLE READ ΠΈΠ·-Π·Π° SNAPSHOT ISOLATION. diff --git a/hw45/tests/logs/test_phantom_read[SERIALIZABLE-True].log b/hw45/tests/logs/test_phantom_read[SERIALIZABLE-True].log new file mode 100644 index 00000000..88c64a1d --- /dev/null +++ b/hw45/tests/logs/test_phantom_read[SERIALIZABLE-True].log @@ -0,0 +1,5 @@ +2025-10-25 21:56:12,078 [INFO] Repo1: создаём запись (Device, 1000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘ +2025-10-25 21:56:12,108 [INFO] Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π² ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Ρ€Π°Π· всС ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚Ρ‹: ID ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ΠΎΠ² - [1, 2, 3, 4, 5, 6, 7] +2025-10-25 21:56:12,136 [INFO] Repo2: создаём запись (TV, 5000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘ +2025-10-25 21:56:12,136 [INFO] Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π²ΠΎ Π²Ρ‚ΠΎΡ€ΠΎΠΉ Ρ€Π°Π· всС ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚Ρ‹: ID ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ΠΎΠ² - [1, 2, 3, 4, 5, 6, 7] +2025-10-25 21:56:12,136 [INFO] Phantom Read ΠΏΡ€Π΅Π΄ΠΎΡ‚Π²Ρ€Π°Ρ‰Ρ‘Π½ Π² случаС SERIALIZABLE ΠΈ Π² Π±ΠΎΠ»ΡŒΡˆΠΈΠ½ΡΡ‚Π²Π΅ случаСв REPEATABLE READ ΠΈΠ·-Π·Π° SNAPSHOT ISOLATION. diff --git a/hw45/tests/test_api.py b/hw45/tests/test_api.py new file mode 100644 index 00000000..3956cf11 --- /dev/null +++ b/hw45/tests/test_api.py @@ -0,0 +1,329 @@ +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +from db.utils import create_tables +from shop_service.main import app + +create_tables() + +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 + + +def test_add_not_exist_item_to_cart( + existing_empty_cart_id: int, +) -> None: + cart_id = existing_empty_cart_id + not_exist_item_id = 3189471973291 + + response = client.post(f"/cart/{cart_id}/add/{not_exist_item_id}") + + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_add_item_to_not_exist_cart( + existing_items: list[int], +) -> None: + not_exist_cart_id = 3189471973291 + item_id = faker.random_element(existing_items) + + response = client.post(f"/cart/{not_exist_cart_id}/add/{item_id}") + + assert response.status_code == HTTPStatus.NOT_FOUND + + +@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 + + +def test_get_not_exist_cart() -> None: + cart_id = 3189471973291 + response = client.get(f"/cart/{cart_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@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 + + +def test_get_not_exist_item() -> None: + item_id = 3189471973291 + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.NOT_FOUND + + +@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 + + +def test_put_not_exist_item() -> None: + item_id = 3189471973291 + response = client.put(f"/item/{item_id}", json={"name": "new name", "price": 9.99}) + + assert response.status_code == HTTPStatus.NOT_FOUND + + +@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) + print(f"{response=}") + 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 \ No newline at end of file diff --git a/hw45/tests/test_db.py b/hw45/tests/test_db.py new file mode 100644 index 00000000..b08bdcee --- /dev/null +++ b/hw45/tests/test_db.py @@ -0,0 +1,136 @@ +# conftest.py +import logging +from pathlib import Path +import pytest + +from db.item import Item, SqlAlchemyItemRepository +from db.utils import create_tables, get_db_with_specified_isolation + +Path("logs").mkdir(exist_ok=True) + + +@pytest.fixture(scope="module", autouse=True) +def setup_database(): + create_tables(delete_existing=True) + yield + + +@pytest.fixture +def setup_logger(request): + """Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ Π»ΠΎΠ³Π³Π΅Ρ€Π° с Ρ„Π°ΠΉΠ»ΠΎΠΌ ΠΏΠΎΠ΄ имя тСста.""" + log_file = Path("logs") / f"{request.node.name}.log" + logger = logging.getLogger(request.node.name) + logger.setLevel(logging.INFO) + + # Π£Π±ΠΈΡ€Π°Π΅ΠΌ старыС Ρ…Π΅Π½Π΄Π»Π΅Ρ€Ρ‹, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π½Π΅ Π΄ΡƒΠ±Π»ΠΈΡ€ΠΎΠ²Π°Π»ΠΈΡΡŒ записи + for h in list(logger.handlers): + logger.removeHandler(h) + + handler = logging.FileHandler(log_file, mode="w", encoding="utf-8") + formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + return logger + + +def test_dirty_read_simulation(setup_logger): + logger = setup_logger + s1 = next(get_db_with_specified_isolation("READ UNCOMMITTED")) + s2 = next(get_db_with_specified_isolation("READ UNCOMMITTED")) + + repo1 = SqlAlchemyItemRepository(s1) + repo2 = SqlAlchemyItemRepository(s2) + + logger.info("Repo1: создаём запись (Device, 1000) Π±Π΅Π· ΠΊΠΎΠΌΠΌΠΈΡ‚Π°") + item = Item(name="Device", price=1000) + repo1.create(item, is_commit=False) + + logger.info("Repo2: ΠΏΡ€ΠΎΠ±ΡƒΠ΅ΠΌ Ρ‡ΠΈΡ‚Π°Ρ‚ΡŒ Π΄ΠΎ ΠΊΠΎΠΌΠΌΠΈΡ‚Π° Repo2") + items = repo2.get_all() + logger.info(f"Repo2: Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ чтСния = {items}") + + s1.rollback() + s2.rollback() + + assert items == [] + logger.info( + "Dirty read ΠΏΡ€Π΅Π΄ΠΎΡ‚Π²Ρ€Π°Ρ‰Ρ‘Π½ (Postgres Π½Π΅ Π΄Π°Ρ‘Ρ‚ Ρ‡ΠΈΡ‚Π°Ρ‚ΡŒ Π½Π΅Π·Π°ΠΊΠΎΠΌΠΌΠΈΡ‡Π΅Π½Π½Ρ‹Π΅ Π΄Π°Π½Π½Ρ‹Π΅)." + ) + + +@pytest.mark.parametrize( + ("isolation_level", "shold_be_equal"), + [ + ("READ COMMITTED", False), + ("REPEATABLE READ", True), + ], +) +def test_non_repeatable_read(setup_logger, isolation_level: str, shold_be_equal: bool): + logger = setup_logger + db1 = next(get_db_with_specified_isolation(isolation_level)) + repo1 = SqlAlchemyItemRepository(db1) + + db2 = next(get_db_with_specified_isolation(isolation_level)) + repo2 = SqlAlchemyItemRepository(db2) + + logger.info("Repo1: создаём запись (Device, 1000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘") + item = repo1.create(Item(name="Device", price=1000), is_commit=True) + + first_read = repo1.find_by_id(item.id) # type: ignore + logger.info(f"Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π² ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Ρ€Π°Π· {item.id} item: price={first_read.price}") # type: ignore + + updated = Item(id=item.id, name="Device", price=2000) + repo2.update(updated) + logger.info("Repo2: ΠΎΠ±Π½ΠΎΠ²ΠΈΠ» Ρ†Π΅Π½Ρƒ Π΄ΠΎ 2000 ΠΈ Π·Π°ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠ»") + + second_read = repo1.find_by_id(item.id) # type: ignore + logger.info(f"Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π²ΠΎ Π²Ρ‚ΠΎΡ€ΠΎΠΉ Ρ€Π°Π· {item.id} item: price={second_read.price}") # type: ignore + + if shold_be_equal: + assert first_read.price == second_read.price + logger.info("Non-repeatable read ΠΏΡ€Π΅Π΄ΠΎΡ‚Π²Ρ€Π°Ρ‰Ρ‘Π½ (Postgres snapshot isolation).") + else: + assert first_read.price != second_read.price + logger.info( + "Non-repeatable read зафиксирован (Ρ†Π΅Π½Π° измСнилась ΠΌΠ΅ΠΆΠ΄Ρƒ чтСниями)." + ) + + +@pytest.mark.parametrize( + ("isolation_level", "shold_be_equal"), + [("READ COMMITTED", False), ("REPEATABLE READ", True), ("SERIALIZABLE", True)], +) +def test_phantom_read(setup_logger, isolation_level: str, shold_be_equal: bool): + logger = setup_logger + db1 = next(get_db_with_specified_isolation(isolation_level)) + repo1 = SqlAlchemyItemRepository(db1) + + db2 = next(get_db_with_specified_isolation(isolation_level)) + repo2 = SqlAlchemyItemRepository(db2) + + logger.info("Repo1: создаём запись (Device, 1000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘") + repo1.create(Item(name="Device", price=1000)) + + first_read = repo1.get_all() + first_item_ids = list(map(lambda x: x.id, first_read)) + logger.info( + f"Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π² ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Ρ€Π°Π· всС ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚Ρ‹: ID ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ΠΎΠ² - {first_item_ids}" + ) + + repo2.create(Item(name="TV", price=5000)) + logger.info("Repo2: создаём запись (TV, 5000) ΠΈ ΠΊΠΎΠΌΠΌΠΈΡ‚ΠΈΠΌ Π΅Ρ‘") + + second_read = repo1.get_all() + second_item_ids = list(map(lambda x: x.id, second_read)) + logger.info( + f"Repo1 Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ Π²ΠΎ Π²Ρ‚ΠΎΡ€ΠΎΠΉ Ρ€Π°Π· всС ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚Ρ‹: ID ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ΠΎΠ² - {second_item_ids}" + ) + + if shold_be_equal: + assert first_item_ids == second_item_ids + logger.info( + "Phantom Read ΠΏΡ€Π΅Π΄ΠΎΡ‚Π²Ρ€Π°Ρ‰Ρ‘Π½ Π² случаС SERIALIZABLE ΠΈ Π² Π±ΠΎΠ»ΡŒΡˆΠΈΠ½ΡΡ‚Π²Π΅ случаСв REPEATABLE READ ΠΈΠ·-Π·Π° SNAPSHOT ISOLATION." + ) + else: + assert first_item_ids != second_item_ids + logger.info("Phantom Read зафиксирован (появился Π½ΠΎΠ²Ρ‹ΠΉ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚).")