From 2d9eaa4548fe5fcf0066ba54e42d99a4468e90c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=BB=D0=B8=D0=BD=D0=BE=D0=B2=D0=B0=20=D0=9C=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D1=8F?= <99964993+MuraBlinova@users.noreply.github.com> Date: Sun, 28 Sep 2025 12:01:50 +0300 Subject: [PATCH 1/4] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..d5a9d71c 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,130 @@ from typing import Any, Awaitable, Callable +import json + +#_________Отправка HTTP ответа_________ +async def send_response(send, status_code, data): + await send({ + "type": "http.response.start", + "status": status_code, + "headers": [[b"content-type", b"application/json"]] + }) + + await send({ + "type": "http.response.body", + "body": json.dumps(data).encode() + }) + + +#_________Вычисление n-го числа Фибоначчи_________ +def fibonacci(n): + if n <= 1: + return n + a, b = 0, 1 + for _ in range(n-1): + a, b = b, a + b + return b + +#_________Вычисление n-го факториала________ +def factorial(n): + if n < 0: + raise ValueError("n must be non-negative") + result = 1 + for i in range(n): + result *= i + return result + +#_________Вычисление среднего значения списка_________ +def mean(json_data): + if not isinstance(json_data, list): + raise ValueError("json must be a list") + if not all(isinstance(item, (int, float)) for item in json_data): + raise ValueError("json must contain only numbers") + return sum(json_data) / len(json_data) + + +#_________Обработка /fibonacci/{n}_________ +async def handle_fibonacci(send, path): + try: + n_str = path.split("/fibonacci/")[1] + n = int(n_str) + + if n < 0: + await send_response(send, 400, {"error": "Bad Request"}) + return + + result = fibonacci(n) + await send_response(send, 200, {"result": result}) + + except ValueError: + await send_response(send, 422, {"error": "Unprocessable Entity"}) + except Exception: + await send_response(send, 422, {"error": "Unprocessable Entity"}) + + +#_________Обработка /factorial?n={n}_________ +async def handle_factorial(send, scope): + try: + query_string = scope.get("query_string", b"").decode() + + if not query_string: + await send_response(send, 422, {"error": "Unprocessable Entity"}) + return + + if "=" in query_string: + key, value = query_string.split("=", 1) + else: + await send_response(send, 422, {"error": "Unprocessable Entity"}) + + if key != "n": + await send_response(send, 422, {"error": "Unprocessable Entity"}) + return + + if not value: + await send_response(send, 422, {"error": "Unprocessable Entity"}) + return + + n = int(value) + + if n < 0: + await send_response(send, 400, {"error": "Bad Request"}) + return + + result = factorial(n) + await send_response(send, 200, {"result": result}) + + except ValueError: + await send_response(send, 422, {"error": "Unprocessable Entity"}) + except Exception: + await send_response(send, 422, {"error": "Unprocessable Entity"}) + + +#_________Обработка /mean_________ +async def handle_mean(send, receive): + try: + message = await receive() + body = message.get("body", b"") + + if not body: + await send_response(send, 422, {"error": "Unprocessable Entity"}) + return + + data = json.loads(body.decode()) + + if not isinstance(data, list): + await send_response(send, 422, {"error": "Unprocessable Entity"}) + return + + if len(data) == 0: + await send_response(send, 400, {"error": "Bad Request"}) + return + + result = mean(data) + await send_response(send, 200, {"result": result}) + + except json.JSONDecodeError: + await send_response(send, 422, {"error": "Unprocessable Entity"}) + except Exception: + await send_response(send, 422, {"error": "Unprocessable Entity"}) async def application( @@ -12,7 +138,29 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + break + return + + method = scope["method"] + path = scope["path"] + + if path.startswith("/fibonacci/"): + await handle_fibonacci(send, path) + elif path.startswith("/factorial"): + await handle_factorial(send, scope) + elif path.startswith("/mean"): + await handle_mean(send, receive) + else: + await send_response(send, 404, {"error": "Not Found"}) + if __name__ == "__main__": import uvicorn From 946372e29319d9b8379ea6c0614d52644bd1b3b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=BB=D0=B8=D0=BD=D0=BE=D0=B2=D0=B0=20=D0=9C=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D1=8F?= <99964993+MuraBlinova@users.noreply.github.com> Date: Sun, 5 Oct 2025 21:35:03 +0300 Subject: [PATCH 2/4] HW2 --- hw2/hw/shop_api/contracts.py | 97 +++++++++++++++++++ hw2/hw/shop_api/main.py | 2 + hw2/hw/shop_api/models.py | 36 +++++++ hw2/hw/shop_api/routes.py | 136 ++++++++++++++++++++++++++ hw2/hw/shop_api/store.py | 181 +++++++++++++++++++++++++++++++++++ 5 files changed, 452 insertions(+) create mode 100644 hw2/hw/shop_api/contracts.py create mode 100644 hw2/hw/shop_api/models.py create mode 100644 hw2/hw/shop_api/routes.py create mode 100644 hw2/hw/shop_api/store.py diff --git a/hw2/hw/shop_api/contracts.py b/hw2/hw/shop_api/contracts.py new file mode 100644 index 00000000..f2a05c9c --- /dev/null +++ b/hw2/hw/shop_api/contracts.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +# from hw2.hw.shop_api.models import ( +from .models import ( + CartEntity, + CartItem, + ItemEntity, + ItemInfo, + 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 + + def as_item_info(self) -> ItemInfo: + return ItemInfo( + name=self.name, + price=self.price, + deleted=False, + ) + + +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, + deleted=None, + ) + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + @staticmethod + def from_cart_item(cart_item: CartItem) -> CartItemResponse: + return CartItemResponse( + id=cart_item.id, + name=cart_item.name, + quantity=cart_item.quantity, + available=cart_item.available, + ) + + +class CartResponse(BaseModel): + id: int + items: list[CartItemResponse] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + return CartResponse( + id=entity.id, + items=[CartItemResponse.from_cart_item(item) for item in entity.items], + price=entity.price, + ) + +class PutItemRequest(BaseModel): + name: str + price: float + + model_config = ConfigDict(extra="forbid") + + def as_item_info(self) -> ItemInfo: + return ItemInfo( + name=self.name, + price=self.price, + deleted=False, + ) \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..354799c5 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,5 @@ from fastapi import FastAPI +from .routes import router app = FastAPI(title="Shop API") +app.include_router(router) diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..571277a9 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + + +@dataclass(slots=True) +class CartItem: + id: int + quantity: int + available: bool + name: str + + +@dataclass(slots=True) +class CartEntity: + id: int + items: list[CartItem] + price: float + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None + deleted: bool | None = None diff --git a/hw2/hw/shop_api/routes.py b/hw2/hw/shop_api/routes.py new file mode 100644 index 00000000..cdbb60e3 --- /dev/null +++ b/hw2/hw/shop_api/routes.py @@ -0,0 +1,136 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import Field, NonNegativeInt, PositiveInt + +from . import store +from .contracts import ( + CartResponse, + ItemRequest, + ItemResponse, + PatchItemRequest, + PutItemRequest, +) + +router = APIRouter() + + +@router.post("/item", status_code=HTTPStatus.CREATED) +async def create_item(request: ItemRequest, response: Response) -> ItemResponse: + entity = store.add_item(request.as_item_info()) + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get("/item/{item_id}") +async def get_item(item_id: int) -> ItemResponse: + entity = store.get_item(item_id) + if not entity or entity.info.deleted: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with id {item_id} not found" + ) + return ItemResponse.from_entity(entity) + + +@router.get("/item") +async def get_items( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[float | None, Query(ge=0)] = None, + max_price: Annotated[float | None, Query(ge=0)] = None, + show_deleted: Annotated[bool, Query()] = False, +) -> list[ItemResponse]: + entities = store.get_items_filtered( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + show_deleted=show_deleted, + ) + return [ItemResponse.from_entity(entity) for entity in entities] + + +@router.put("/item/{item_id}") +async def update_item(item_id: int, request: PutItemRequest) -> ItemResponse: + entity = store.update_item(item_id, request.as_item_info()) + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with id {item_id} not found" + ) + return ItemResponse.from_entity(entity) + + +@router.patch("/item/{item_id}") +async def patch_item(item_id: int, request: PatchItemRequest) -> ItemResponse: + entity = store.get_item(item_id) + if not entity or entity.info.deleted: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Item with id {item_id} not found" + ) + + updated_entity = store.patch_item(item_id, request.as_patch_item_info()) + return ItemResponse.from_entity(updated_entity) + + +@router.delete("/item/{item_id}") +async def delete_item(item_id: int) -> Response: + success = store.delete_item(item_id) + if not success: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with id {item_id} not found" + ) + return Response(status_code=HTTPStatus.OK) + + +@router.post("/cart", status_code=HTTPStatus.CREATED) +async def create_cart(response: Response) -> dict[str, int]: + entity = store.add_cart() + response.headers["location"] = f"/cart/{entity.id}" + return {"id": entity.id} + + +@router.get("/cart/{cart_id}") +async def get_cart(cart_id: int) -> CartResponse: + entity = store.get_cart(cart_id) + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart with id {cart_id} not found" + ) + return CartResponse.from_entity(entity) + + +@router.get("/cart") +async def get_carts( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[float | None, Query(ge=0)] = None, + max_price: Annotated[float | None, Query(ge=0)] = None, + min_quantity: Annotated[int | None, Query(ge=0)] = None, + max_quantity: Annotated[int | None, Query(ge=0)] = None, +) -> list[CartResponse]: + entities = store.get_carts_filtered( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + min_quantity=min_quantity, + max_quantity=max_quantity, + ) + return [CartResponse.from_entity(entity) for entity in entities] + + +@router.post("/cart/{cart_id}/add/{item_id}") +async def add_item_to_cart(cart_id: int, item_id: int) -> CartResponse: + entity = store.add_item_to_cart(cart_id, item_id) + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart with id {cart_id} or item with id {item_id} not found" + ) + return CartResponse.from_entity(entity) \ No newline at end of file diff --git a/hw2/hw/shop_api/store.py b/hw2/hw/shop_api/store.py new file mode 100644 index 00000000..594a3be7 --- /dev/null +++ b/hw2/hw/shop_api/store.py @@ -0,0 +1,181 @@ +from typing import Iterable + +from .models import ( + CartEntity, + CartItem, + ItemEntity, + ItemInfo, + PatchItemInfo, +) + +_items = dict[int, ItemInfo]() +_carts = dict[int, CartEntity]() + +_item_counter = 0 +_cart_counter = 0 + + +def _get_next_item_id() -> int: + global _item_counter + _item_counter += 1 + return _item_counter + + +def _get_next_cart_id() -> int: + global _cart_counter + _cart_counter += 1 + return _cart_counter + + +def add_item(info: ItemInfo) -> ItemEntity: + item_id = _get_next_item_id() + _items[item_id] = info + return ItemEntity(item_id, info) + + +def get_item(item_id: int) -> ItemEntity | None: + if item_id not in _items: + return None + return ItemEntity(id=item_id, info=_items[item_id]) + + +def get_items_filtered( + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + show_deleted: bool = False, +) -> list[ItemEntity]: + result = [] + curr = 0 + for item_id, info in _items.items(): + if not show_deleted and info.deleted: + continue + + if min_price is not None and info.price < min_price: + continue + if max_price is not None and info.price > max_price: + continue + + if offset <= curr < offset + limit: + result.append(ItemEntity(item_id, info)) + + curr += 1 + + return result + + +def update_item(item_id: int, info: ItemInfo) -> ItemEntity | None: + if item_id not in _items: + return None + + _items[item_id] = info + return ItemEntity(id=item_id, info=info) + + +def patch_item(item_id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if item_id not in _items: + return None + + info = _items[item_id] + + if patch_info.name is not None: + info.name = patch_info.name + if patch_info.price is not None: + info.price = patch_info.price + if patch_info.deleted is not None: + info.deleted = patch_info.deleted + + return ItemEntity(id=item_id, info=info) + + +def delete_item(item_id: int) -> bool: + if item_id not in _items: + return False + + _items[item_id].deleted = True + return True + + +def add_cart() -> CartEntity: + cart_id = _get_next_cart_id() + cart = CartEntity(id=cart_id, items=[], price=0.0) + _carts[cart_id] = cart + return cart + + +def get_cart(cart_id: int) -> CartEntity | None: + if cart_id not in _carts: + return None + return _carts[cart_id] + + +def get_carts_filtered( + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + min_quantity: int | None = None, + max_quantity: int | None = None, +) -> list[CartEntity]: + result = [] + curr = 0 + for cart_id, cart in _carts.items(): + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + + total_quantity = sum(item.quantity for item in cart.items) + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + if offset <= curr < offset + limit: + result.append(cart) + + curr += 1 + + return result + + +def add_item_to_cart(cart_id: int, item_id: int) -> CartEntity | None: + if cart_id not in _carts: + return None + + item_entity = get_item(item_id) + if not item_entity or item_entity.info.deleted: + return None + + cart = _carts[cart_id] + + for cart_item in cart.items: + if cart_item.id == item_id: + cart_item.quantity += 1 + cart.price += item_entity.info.price + return cart + + new_cart_item = CartItem( + id=item_id, + name=item_entity.info.name, + quantity=1, + available=True, + ) + cart.items.append(new_cart_item) + cart.price += item_entity.info.price + + return cart + + +def _recalculate_cart_price(cart: CartEntity) -> None: + total_price = 0.0 + for cart_item in cart.items: + item_entity = get_item(cart_item.id) + if item_entity and not item_entity.info.deleted: + total_price += item_entity.info.price * cart_item.quantity + cart_item.available = True + else: + cart_item.available = False + + cart.price = total_price \ No newline at end of file From c6511c146c1b6a24bddee1c272f26cadb477f9b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=BB=D0=B8=D0=BD=D0=BE=D0=B2=D0=B0=20=D0=9C=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D1=8F?= <99964993+MuraBlinova@users.noreply.github.com> Date: Sun, 12 Oct 2025 22:34:56 +0300 Subject: [PATCH 3/4] HW3 --- hw2/hw/Dockerfile | 25 +++++++++++++++++ hw2/hw/docker-compose.yml | 34 +++++++++++++++++++++++ hw2/hw/requirements.txt | 1 + hw2/hw/settings/prometheus/prometheus.yml | 10 +++++++ hw2/hw/shop_api/main.py | 4 +++ 5 files changed, 74 insertions(+) create mode 100644 hw2/hw/Dockerfile create mode 100644 hw2/hw/docker-compose.yml create mode 100644 hw2/hw/settings/prometheus/prometheus.yml diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..a76eb2e1 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,25 @@ +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 +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . + +EXPOSE 8000 + +FROM base as local + +ENV PYTHONPATH=/app:$PYTHONPATH + +CMD ["uvicorn", "shop_api.main:app", "--port", "8000", "--host", "0.0.0.0"] diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..d1eb358c --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,34 @@ +version: "3" + +services: + shop_api: + build: + context: . + dockerfile: Dockerfile + target: local + restart: always + ports: + - 8000:8000 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + depends_on: + - prometheus + + 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 + depends_on: + - shop_api diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..10e03314 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,7 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-fastapi-instrumentator>=6.0.0 # Зависимости для тестирования pytest>=7.4.0 diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..005bba4e --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-service + metrics_path: /metrics + static_configs: + - targets: + - shop_api:8000 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 354799c5..3f104724 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,5 +1,9 @@ from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator from .routes import router + app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + app.include_router(router) From dcc3993cec70f780379da110e3e2e7abe8d6f128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=BB=D0=B8=D0=BD=D0=BE=D0=B2=D0=B0=20=D0=9C=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D1=8F?= <99964993+MuraBlinova@users.noreply.github.com> Date: Sun, 26 Oct 2025 22:29:36 +0300 Subject: [PATCH 4/4] hw4 --- hw2/hw/docker-compose.yml | 25 +++ hw2/hw/isolation_demos.py | 384 ++++++++++++++++++++++++++++++++++++++ hw2/hw/requirements.txt | 4 + hw2/hw/shop_api/main.py | 16 +- hw2/hw/shop_api/routes.py | 77 +++++--- hw2/hw/shop_api/store.py | 363 +++++++++++++++++++++-------------- 6 files changed, 704 insertions(+), 165 deletions(-) create mode 100644 hw2/hw/isolation_demos.py diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml index d1eb358c..0bc439f2 100644 --- a/hw2/hw/docker-compose.yml +++ b/hw2/hw/docker-compose.yml @@ -1,6 +1,23 @@ version: "3" services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + POSTGRES_DB: shop_db + ports: + - 5445:5432 + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U shop_user -d shop_db"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + shop_api: build: context: . @@ -9,6 +26,11 @@ services: restart: always ports: - 8000:8000 + environment: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@postgres:5445/shop_db + depends_on: + postgres: + condition: service_healthy grafana: image: grafana/grafana:latest @@ -32,3 +54,6 @@ services: restart: always depends_on: - shop_api + +volumes: + postgres_data: diff --git a/hw2/hw/isolation_demos.py b/hw2/hw/isolation_demos.py new file mode 100644 index 00000000..8e02bec8 --- /dev/null +++ b/hw2/hw/isolation_demos.py @@ -0,0 +1,384 @@ +import asyncio +import os +from typing import Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from shop_api.database import Base +from shop_api import db_models + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://shop_user:shop_password@localhost:5445/shop_db" +) + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def ensure_tables(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def setup_test_data(): + await ensure_tables() + async with async_session_maker() as session: + await session.execute(text("TRUNCATE TABLE cart_items, carts, items RESTART IDENTITY CASCADE")) + await session.execute(text( + "INSERT INTO items (name, price, deleted) VALUES ('Test Item', 100.0, false)" + )) + await session.commit() + print("+ Тестовые данные созданы\n") + + +async def demo_dirty_read_uncommitted(): + """ + ДЕМОНСТРАЦИЯ 1: Попытка Dirty Read при READ UNCOMMITTED + B PostgreSQL READ UNCOMMITTED работает как READ COMMITTED, + поэтому dirty reads НЕ происходят даже на этом уровне. + """ + print("=" * 60) + print("ДЕМОНСТРАЦИЯ 1: READ UNCOMMITTED - Попытка Dirty Read") + print("=" * 60) + + await setup_test_data() + + async def transaction_1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + + print("T1: Начало транзакции (READ UNCOMMITTED)") + print("T1: Обновление цены товара с 100.0 на 999.0") + await session.execute(text("UPDATE items SET price = 999.0 WHERE id = 1")) + + print("T1: Ожидание 2 секунды") + await asyncio.sleep(2) + + print("T1: Откат транзакции (ROLLBACK)") + await session.rollback() + print("T1: Завершено\n") + + async def transaction_2(): + await asyncio.sleep(0.5) + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + + print("T2: Начало транзакции (READ UNCOMMITTED)") + result = await session.execute(text("SELECT price FROM items WHERE id = 1")) + price = result.scalar() + print(f"T2: Чтение цены товара: {price}") + + if price == 100.0: + print("T2: + Dirty Read НЕ произошел! Видны только закоммиченные данные (100.0)") + else: + print(f"T2: - Dirty Read произошел! Видны незакоммиченные данные ({price})") + + await session.commit() + print("T2: Завершено\n") + + await asyncio.gather(transaction_1(), transaction_2()) + print("-" * 50 + "\n") + + +async def demo_no_dirty_read_committed(): + """ + ДЕМОНСТРАЦИЯ 2: Отсутствие Dirty Read при READ COMMITTED + При уровне READ COMMITTED dirty reads не происходят. + """ + print("=" * 60) + print("ДЕМОНСТРАЦИЯ 2: READ COMMITTED - Отсутствие Dirty Read") + print("=" * 60) + + await setup_test_data() + + async def transaction_1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + print("T1: Начало транзакции (READ COMMITTED)") + print("T1: Обновление цены товара с 100.0 на 999.0") + await session.execute(text("UPDATE items SET price = 999.0 WHERE id = 1")) + + print("T1: Ожидание 2 секунды") + await asyncio.sleep(2) + + print("T1: Откат транзакции (ROLLBACK)") + await session.rollback() + print("T1: Завершено\n") + + async def transaction_2(): + await asyncio.sleep(0.5) + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + print("T2: Начало транзакции (READ COMMITTED)") + result = await session.execute(text("SELECT price FROM items WHERE id = 1")) + price = result.scalar() + print(f"T2: Чтение цены товара: {price}") + + if price == 100.0: + print("T2: + Dirty Read НЕ произошел! Видны только закоммиченные данные (100.0)") + else: + print(f"T2: - Dirty Read произошел! Видны незакоммиченные данные ({price})") + + await session.commit() + print("T2: Завершено\n") + + await asyncio.gather(transaction_1(), transaction_2()) + print("-" * 50 + "\n") + + +async def demo_non_repeatable_read_committed(): + """ + ДЕМОНСТРАЦИЯ 3: Non-Repeatable Read при READ COMMITTED + При READ COMMITTED одна транзакция может видеть изменения другой. + """ + print("=" * 60) + print("ДЕМОНСТРАЦИЯ 3: READ COMMITTED - Non-Repeatable Read") + print("=" * 60) + + await setup_test_data() + + async def transaction_1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + print("T1: Начало транзакции (READ COMMITTED)") + result = await session.execute(text("SELECT price FROM items WHERE id = 1")) + price1 = result.scalar() + print(f"T1: Первое чтение цены: {price1}") + + print("T1: Ожидание изменения данных T2") + await asyncio.sleep(2) + + result = await session.execute(text("SELECT price FROM items WHERE id = 1")) + price2 = result.scalar() + print(f"T1: Второе чтение цены: {price2}") + + if price1 != price2: + print(f"T1: + Non-Repeatable Read произошел! ({price1} -> {price2})") + else: + print("T1: - Non-Repeatable Read НЕ произошел") + + await session.commit() + print("T1: Завершено\n") + + async def transaction_2(): + await asyncio.sleep(0.5) + async with async_session_maker() as session: + print("T2: Начало транзакции") + print("T2: Изменение цены товара с 100.0 на 200.0") + await session.execute(text("UPDATE items SET price = 200.0 WHERE id = 1")) + await session.commit() + print("T2: Изменение закоммичено") + print("T2: Завершено\n") + + await asyncio.gather(transaction_1(), transaction_2()) + print("-" * 50 + "\n") + + +async def demo_no_non_repeatable_read_repeatable(): + """ + ДЕМОНСТРАЦИЯ 4: Отсутствие Non-Repeatable Read при REPEATABLE READ + При REPEATABLE READ транзакция видит снимок данных на момент начала. + """ + print("=" * 60) + print("ДЕМОНСТРАЦИЯ 4: REPEATABLE READ - Отсутствие Non-Repeatable Read") + print("=" * 60) + + await setup_test_data() + + async def transaction_1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + + print("T1: Начало транзакции (REPEATABLE READ)") + result = await session.execute(text("SELECT price FROM items WHERE id = 1")) + price1 = result.scalar() + print(f"T1: Первое чтение цены: {price1}") + + print("T1: Ожидание изменения данных T2") + await asyncio.sleep(2) + + result = await session.execute(text("SELECT price FROM items WHERE id = 1")) + price2 = result.scalar() + print(f"T1: Второе чтение цены: {price2}") + + if price1 == price2: + print(f"T1: + Non-Repeatable Read НЕ произошел! Виден стабильный снимок ({price1})") + else: + print(f"T1: - Non-Repeatable Read произошел ({price1} -> {price2})") + + await session.commit() + print("T1: Завершено\n") + + async def transaction_2(): + await asyncio.sleep(0.5) + async with async_session_maker() as session: + print("T2: Начало транзакции") + print("T2: Изменение цены товара с 100.0 на 200.0") + await session.execute(text("UPDATE items SET price = 200.0 WHERE id = 1")) + await session.commit() + print("T2: Изменение закоммичено") + print("T2: Завершено\n") + + await asyncio.gather(transaction_1(), transaction_2()) + print("-" * 50 + "\n") + + +async def demo_phantom_read_repeatable(): + """ + ДЕМОНСТРАЦИЯ 5: Phantom Reads при REPEATABLE READ + B PostgreSQL REPEATABLE READ предотвращает phantom reads + """ + print("=" * 60) + print("ДЕМОНСТРАЦИЯ 5: REPEATABLE READ - Phantom Reads") + print("=" * 60) + + await setup_test_data() + + async def transaction_1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + + print("T1: Начало транзакции (REPEATABLE READ)") + result = await session.execute(text("SELECT COUNT(*) FROM items WHERE deleted = false")) + count1 = result.scalar() + print(f"T1: Первое чтение количества товаров: {count1}") + + print("T1: Ожидание пока T2 добавит новый товар") + await asyncio.sleep(2) + + result = await session.execute(text("SELECT COUNT(*) FROM items WHERE deleted = false")) + count2 = result.scalar() + print(f"T1: Второе чтение количества товаров: {count2}") + + if count1 == count2: + print(f"T1: + Phantom Read НЕ произошел! Количество стабильно ({count1})") + else: + print(f"T1: - Phantom Read произошел! ({count1} -> {count2})") + + await session.commit() + print("T1: Завершено\n") + + async def transaction_2(): + await asyncio.sleep(0.5) + async with async_session_maker() as session: + print("T2: Начало транзакции") + print("T2: Добавление нового товара") + await session.execute(text( + "INSERT INTO items (name, price, deleted) VALUES ('New Item', 150.0, false)" + )) + await session.commit() + print("T2: Новый товар добавлен и закоммичен") + print("T2: Завершено\n") + + await asyncio.gather(transaction_1(), transaction_2()) + print("-" * 50 + "\n") + + +async def demo_no_phantom_read_serializable(): + """ + ДЕМОНСТРАЦИЯ 6: Отсутствие Phantom Reads при SERIALIZABLE + При SERIALIZABLE транзакции полностью изолированы. + """ + print("=" * 60) + print("ДЕМОНСТРАЦИЯ 6: SERIALIZABLE - Отсутствие Phantom Reads") + print("=" * 60) + + await setup_test_data() + + async def transaction_1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + + print("T1: Начало транзакции (SERIALIZABLE)") + result = await session.execute(text("SELECT COUNT(*) FROM items WHERE deleted = false")) + count1 = result.scalar() + print(f"T1: Первое чтение количества товаров: {count1}") + + print("T1: Ожидание попытки добавления нового товара T2") + await asyncio.sleep(2) + + result = await session.execute(text("SELECT COUNT(*) FROM items WHERE deleted = false")) + count2 = result.scalar() + print(f"T1: Второе чтение количества товаров: {count2}") + + if count1 == count2: + print(f"T1: + Phantom Read НЕ произошел! Количество стабильно ({count1})") + else: + print(f"T1: - Phantom Read произошел! ({count1} -> {count2})") + + await session.commit() + print("T1: Завершено\n") + + async def transaction_2(): + await asyncio.sleep(0.5) + async with async_session_maker() as session: + try: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + print("T2: Начало транзакции (SERIALIZABLE)") + print("T2: Попытка добавить новый товар") + await session.execute(text( + "INSERT INTO items (name, price, deleted) VALUES ('New Item', 150.0, false)" + )) + await session.commit() + print("T2: Товар успешно добавлен") + print("T2: Завершено\n") + except Exception as e: + print(f"T2: + Транзакция не смогла завершиться из-за конфликта сериализации") + print(f"T2: Ошибка: {type(e).__name__}") + await session.rollback() + print("T2: Завершено с откатом\n") + + await asyncio.gather(transaction_1(), transaction_2()) + print("-" * 50 + "\n") + + +async def main(): + print("\n") + print("=" * 50) + print("=" * 50) + print("ДЕМОНСТРАЦИЯ УРОВНЕЙ ИЗОЛЯЦИИ ТРАНЗАКЦИЙ") + print("=" * 50) + print("=" * 50) + print("\n") + + try: + await demo_dirty_read_uncommitted() + await asyncio.sleep(1) + + await demo_no_dirty_read_committed() + await asyncio.sleep(1) + + await demo_non_repeatable_read_committed() + await asyncio.sleep(1) + + await demo_no_non_repeatable_read_repeatable() + await asyncio.sleep(1) + + await demo_phantom_read_repeatable() + await asyncio.sleep(1) + + await demo_no_phantom_read_serializable() + + print("\n") + print("=" * 50) + print("=" * 50) + print("ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА") + print("=" * 50) + print("=" * 50) + print("\n") + + except Exception as e: + print(f"\n!!! Ошибка: {e}") + print("Убедитесь, что PostgreSQL запущен и доступен по адресу:") + print(f" {DATABASE_URL}") + raise + finally: + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 10e03314..0eb73123 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -3,6 +3,10 @@ fastapi>=0.117.1 uvicorn>=0.24.0 prometheus-fastapi-instrumentator>=6.0.0 +# Зависимости для работы с БД +sqlalchemy>=2.0.0 +asyncpg>=0.29.0 + # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 3f104724..3d21b578 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,9 +1,19 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI -from prometheus_fastapi_instrumentator import Instrumentator +# from prometheus_fastapi_instrumentator import Instrumentator +from .database import init_db from .routes import router -app = FastAPI(title="Shop API") -Instrumentator().instrument(app).expose(app) + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI(title="Shop API", lifespan=lifespan) +# Instrumentator().instrument(app).expose(app) app.include_router(router) diff --git a/hw2/hw/shop_api/routes.py b/hw2/hw/shop_api/routes.py index cdbb60e3..1859b213 100644 --- a/hw2/hw/shop_api/routes.py +++ b/hw2/hw/shop_api/routes.py @@ -1,8 +1,9 @@ from http import HTTPStatus from typing import Annotated -from fastapi import APIRouter, HTTPException, Query, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import Field, NonNegativeInt, PositiveInt +from sqlalchemy.ext.asyncio import AsyncSession from . import store from .contracts import ( @@ -12,20 +13,28 @@ PatchItemRequest, PutItemRequest, ) +from .database import get_db router = APIRouter() @router.post("/item", status_code=HTTPStatus.CREATED) -async def create_item(request: ItemRequest, response: Response) -> ItemResponse: - entity = store.add_item(request.as_item_info()) +async def create_item( + request: ItemRequest, + response: Response, + db: AsyncSession = Depends(get_db) +) -> ItemResponse: + entity = await store.add_item(db, request.as_item_info()) response.headers["location"] = f"/item/{entity.id}" return ItemResponse.from_entity(entity) @router.get("/item/{item_id}") -async def get_item(item_id: int) -> ItemResponse: - entity = store.get_item(item_id) +async def get_item( + item_id: int, + db: AsyncSession = Depends(get_db) +) -> ItemResponse: + entity = await store.get_item(db, item_id) if not entity or entity.info.deleted: raise HTTPException( HTTPStatus.NOT_FOUND, @@ -41,8 +50,10 @@ async def get_items( min_price: Annotated[float | None, Query(ge=0)] = None, max_price: Annotated[float | None, Query(ge=0)] = None, show_deleted: Annotated[bool, Query()] = False, + db: AsyncSession = Depends(get_db) ) -> list[ItemResponse]: - entities = store.get_items_filtered( + entities = await store.get_items_filtered( + db, offset=offset, limit=limit, min_price=min_price, @@ -53,8 +64,12 @@ async def get_items( @router.put("/item/{item_id}") -async def update_item(item_id: int, request: PutItemRequest) -> ItemResponse: - entity = store.update_item(item_id, request.as_item_info()) +async def update_item( + item_id: int, + request: PutItemRequest, + db: AsyncSession = Depends(get_db) +) -> ItemResponse: + entity = await store.update_item(db, item_id, request.as_item_info()) if not entity: raise HTTPException( HTTPStatus.NOT_FOUND, @@ -64,21 +79,27 @@ async def update_item(item_id: int, request: PutItemRequest) -> ItemResponse: @router.patch("/item/{item_id}") -async def patch_item(item_id: int, request: PatchItemRequest) -> ItemResponse: - entity = store.get_item(item_id) +async def patch_item( + item_id: int, + request: PatchItemRequest, + db: AsyncSession = Depends(get_db) +) -> ItemResponse: + entity = await store.get_item(db, item_id) if not entity or entity.info.deleted: raise HTTPException( HTTPStatus.NOT_MODIFIED, f"Item with id {item_id} not found" ) - - updated_entity = store.patch_item(item_id, request.as_patch_item_info()) + updated_entity = await store.patch_item(db, item_id, request.as_patch_item_info()) return ItemResponse.from_entity(updated_entity) @router.delete("/item/{item_id}") -async def delete_item(item_id: int) -> Response: - success = store.delete_item(item_id) +async def delete_item( + item_id: int, + db: AsyncSession = Depends(get_db) +) -> Response: + success = await store.delete_item(db, item_id) if not success: raise HTTPException( HTTPStatus.NOT_FOUND, @@ -88,15 +109,21 @@ async def delete_item(item_id: int) -> Response: @router.post("/cart", status_code=HTTPStatus.CREATED) -async def create_cart(response: Response) -> dict[str, int]: - entity = store.add_cart() +async def create_cart( + response: Response, + db: AsyncSession = Depends(get_db) +) -> dict[str, int]: + entity = await store.add_cart(db) response.headers["location"] = f"/cart/{entity.id}" return {"id": entity.id} @router.get("/cart/{cart_id}") -async def get_cart(cart_id: int) -> CartResponse: - entity = store.get_cart(cart_id) +async def get_cart( + cart_id: int, + db: AsyncSession = Depends(get_db) +) -> CartResponse: + entity = await store.get_cart(db, cart_id) if not entity: raise HTTPException( HTTPStatus.NOT_FOUND, @@ -113,8 +140,10 @@ async def get_carts( max_price: Annotated[float | None, Query(ge=0)] = None, min_quantity: Annotated[int | None, Query(ge=0)] = None, max_quantity: Annotated[int | None, Query(ge=0)] = None, + db: AsyncSession = Depends(get_db) ) -> list[CartResponse]: - entities = store.get_carts_filtered( + entities = await store.get_carts_filtered( + db, offset=offset, limit=limit, min_price=min_price, @@ -126,11 +155,15 @@ async def get_carts( @router.post("/cart/{cart_id}/add/{item_id}") -async def add_item_to_cart(cart_id: int, item_id: int) -> CartResponse: - entity = store.add_item_to_cart(cart_id, item_id) +async def add_item_to_cart( + cart_id: int, + item_id: int, + db: AsyncSession = Depends(get_db) +) -> CartResponse: + entity = await store.add_item_to_cart(db, cart_id, item_id) if not entity: raise HTTPException( HTTPStatus.NOT_FOUND, f"Cart with id {cart_id} or item with id {item_id} not found" ) - return CartResponse.from_entity(entity) \ No newline at end of file + return CartResponse.from_entity(entity) diff --git a/hw2/hw/shop_api/store.py b/hw2/hw/shop_api/store.py index 594a3be7..61d39db2 100644 --- a/hw2/hw/shop_api/store.py +++ b/hw2/hw/shop_api/store.py @@ -1,181 +1,264 @@ -from typing import Iterable +from typing import List, Optional -from .models import ( - CartEntity, - CartItem, - ItemEntity, - ItemInfo, - PatchItemInfo, -) +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload -_items = dict[int, ItemInfo]() -_carts = dict[int, CartEntity]() +from .db_models import Cart, CartItemAssociation, Item +from .models import CartEntity, CartItem, ItemEntity, ItemInfo, PatchItemInfo -_item_counter = 0 -_cart_counter = 0 +async def add_item(session: AsyncSession, info: ItemInfo) -> ItemEntity: + db_item = Item(name=info.name, price=info.price, deleted=info.deleted) + session.add(db_item) + await session.commit() + await session.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo( + name=db_item.name, + price=db_item.price, + deleted=db_item.deleted + )) -def _get_next_item_id() -> int: - global _item_counter - _item_counter += 1 - return _item_counter - - -def _get_next_cart_id() -> int: - global _cart_counter - _cart_counter += 1 - return _cart_counter - - -def add_item(info: ItemInfo) -> ItemEntity: - item_id = _get_next_item_id() - _items[item_id] = info - return ItemEntity(item_id, info) - - -def get_item(item_id: int) -> ItemEntity | None: - if item_id not in _items: +async def get_item(session: AsyncSession, item_id: int) -> Optional[ItemEntity]: + result = await session.execute(select(Item).where(Item.id == item_id)) + db_item = result.scalar_one_or_none() + if not db_item: return None - return ItemEntity(id=item_id, info=_items[item_id]) + return ItemEntity(id=db_item.id, info=ItemInfo( + name=db_item.name, + price=db_item.price, + deleted=db_item.deleted + )) - -def get_items_filtered( +async def get_items_filtered( + session: AsyncSession, offset: int = 0, limit: int = 10, - min_price: float | None = None, - max_price: float | None = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, show_deleted: bool = False, -) -> list[ItemEntity]: - result = [] - curr = 0 - for item_id, info in _items.items(): - if not show_deleted and info.deleted: - continue - - if min_price is not None and info.price < min_price: - continue - if max_price is not None and info.price > max_price: - continue - - if offset <= curr < offset + limit: - result.append(ItemEntity(item_id, info)) - - curr += 1 - - return result +) -> List[ItemEntity]: + query = select(Item) - -def update_item(item_id: int, info: ItemInfo) -> ItemEntity | None: - if item_id not in _items: - return None + if not show_deleted: + query = query.where(Item.deleted == False) + + if min_price is not None: + query = query.where(Item.price >= min_price) + if max_price is not None: + query = query.where(Item.price <= max_price) + + query = query.offset(offset).limit(limit) + + result = await session.execute(query) + db_items = result.scalars().all() - _items[item_id] = info - return ItemEntity(id=item_id, info=info) + return [ + ItemEntity(id=db_item.id, info=ItemInfo( + name=db_item.name, + price=db_item.price, + deleted=db_item.deleted + )) + for db_item in db_items + ] +async def update_item( + session: AsyncSession, + item_id: int, + info: ItemInfo +) -> Optional[ItemEntity]: + result = await session.execute(select(Item).where(Item.id == item_id)) + db_item = result.scalar_one_or_none() + if not db_item: + return None + db_item.name = info.name + db_item.price = info.price + db_item.deleted = info.deleted + await session.commit() + await session.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo( + name=db_item.name, + price=db_item.price, + deleted=db_item.deleted + )) -def patch_item(item_id: int, patch_info: PatchItemInfo) -> ItemEntity | None: - if item_id not in _items: +async def patch_item( + session: AsyncSession, + item_id: int, + patch_info: PatchItemInfo +) -> Optional[ItemEntity]: + result = await session.execute(select(Item).where(Item.id == item_id)) + db_item = result.scalar_one_or_none() + if not db_item: return None - - info = _items[item_id] - if patch_info.name is not None: - info.name = patch_info.name + db_item.name = patch_info.name if patch_info.price is not None: - info.price = patch_info.price + db_item.price = patch_info.price if patch_info.deleted is not None: - info.deleted = patch_info.deleted - - return ItemEntity(id=item_id, info=info) - + db_item.deleted = patch_info.deleted + await session.commit() + await session.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo( + name=db_item.name, + price=db_item.price, + deleted=db_item.deleted + )) -def delete_item(item_id: int) -> bool: - if item_id not in _items: +async def delete_item(session: AsyncSession, item_id: int) -> bool: + result = await session.execute(select(Item).where(Item.id == item_id)) + db_item = result.scalar_one_or_none() + if not db_item: return False - - _items[item_id].deleted = True + db_item.deleted = True + result = await session.execute( + select(Cart) + .where(Cart.id.in_( + select(CartItemAssociation.cart_id).where(CartItemAssociation.item_id == item_id) + )) + .options(selectinload(Cart.cart_items).selectinload(CartItemAssociation.item)) + ) + affected_carts = result.scalars().all() + for cart in affected_carts: + await _recalculate_cart_price(session, cart) + await session.commit() return True +async def add_cart(session: AsyncSession) -> CartEntity: + db_cart = Cart(price=0.0) + session.add(db_cart) + await session.commit() + await session.refresh(db_cart) + return CartEntity(id=db_cart.id, items=[], price=0.0) -def add_cart() -> CartEntity: - cart_id = _get_next_cart_id() - cart = CartEntity(id=cart_id, items=[], price=0.0) - _carts[cart_id] = cart - return cart - - -def get_cart(cart_id: int) -> CartEntity | None: - if cart_id not in _carts: +async def get_cart(session: AsyncSession, cart_id: int) -> Optional[CartEntity]: + result = await session.execute( + select(Cart) + .where(Cart.id == cart_id) + .options(selectinload(Cart.cart_items).selectinload(CartItemAssociation.item)) + ) + db_cart = result.scalar_one_or_none() + if not db_cart: return None - return _carts[cart_id] - + cart_items = [] + for assoc in db_cart.cart_items: + cart_items.append(CartItem( + id=assoc.item_id, + name=assoc.item.name, + quantity=assoc.quantity, + available=not assoc.item.deleted + )) + return CartEntity( + id=db_cart.id, + items=cart_items, + price=db_cart.price + ) -def get_carts_filtered( +async def get_carts_filtered( + session: AsyncSession, offset: int = 0, limit: int = 10, - min_price: float | None = None, - max_price: float | None = None, - min_quantity: int | None = None, - max_quantity: int | None = None, -) -> list[CartEntity]: - result = [] - curr = 0 - for cart_id, cart in _carts.items(): - if min_price is not None and cart.price < min_price: - continue - if max_price is not None and cart.price > max_price: - continue - - total_quantity = sum(item.quantity for item in cart.items) + min_price: Optional[float] = None, + max_price: Optional[float] = None, + min_quantity: Optional[int] = None, + max_quantity: Optional[int] = None, +) -> List[CartEntity]: + query = select(Cart).options( + selectinload(Cart.cart_items).selectinload(CartItemAssociation.item) + ) + if min_price is not None: + query = query.where(Cart.price >= min_price) + if max_price is not None: + query = query.where(Cart.price <= max_price) + result = await session.execute(query) + db_carts = result.scalars().all() + cart_entities = [] + for db_cart in db_carts: + cart_items = [] + total_quantity = 0 + for assoc in db_cart.cart_items: + cart_items.append(CartItem( + id=assoc.item_id, + name=assoc.item.name, + quantity=assoc.quantity, + available=not assoc.item.deleted + )) + total_quantity += assoc.quantity if min_quantity is not None and total_quantity < min_quantity: continue if max_quantity is not None and total_quantity > max_quantity: continue - - if offset <= curr < offset + limit: - result.append(cart) - - curr += 1 - - return result + cart_entities.append(CartEntity( + id=db_cart.id, + items=cart_items, + price=db_cart.price + )) + return cart_entities[offset:offset + limit] -def add_item_to_cart(cart_id: int, item_id: int) -> CartEntity | None: - if cart_id not in _carts: +async def add_item_to_cart( + session: AsyncSession, + cart_id: int, + item_id: int +) -> Optional[CartEntity]: + result = await session.execute( + select(Cart) + .where(Cart.id == cart_id) + .options(selectinload(Cart.cart_items).selectinload(CartItemAssociation.item)) + ) + db_cart = result.scalar_one_or_none() + if not db_cart: return None - - item_entity = get_item(item_id) - if not item_entity or item_entity.info.deleted: + result = await session.execute(select(Item).where(Item.id == item_id)) + db_item = result.scalar_one_or_none() + if not db_item or db_item.deleted: return None - - cart = _carts[cart_id] - - for cart_item in cart.items: - if cart_item.id == item_id: - cart_item.quantity += 1 - cart.price += item_entity.info.price - return cart - - new_cart_item = CartItem( - id=item_id, - name=item_entity.info.name, - quantity=1, - available=True, + existing_assoc = None + for assoc in db_cart.cart_items: + if assoc.item_id == item_id: + existing_assoc = assoc + break + if existing_assoc: + existing_assoc.quantity += 1 + else: + new_assoc = CartItemAssociation( + cart_id=cart_id, + item_id=item_id, + quantity=1, + item=db_item + ) + session.add(new_assoc) + db_cart.cart_items.append(new_assoc) + await _recalculate_cart_price(session, db_cart) + await session.commit() + result = await session.execute( + select(Cart) + .where(Cart.id == cart_id) + .options(selectinload(Cart.cart_items).selectinload(CartItemAssociation.item)) + ) + db_cart = result.scalar_one_or_none() + if not db_cart: + return None + cart_items = [] + for assoc in db_cart.cart_items: + cart_items.append(CartItem( + id=assoc.item_id, + name=assoc.item.name, + quantity=assoc.quantity, + available=not assoc.item.deleted + )) + return CartEntity( + id=db_cart.id, + items=cart_items, + price=db_cart.price ) - cart.items.append(new_cart_item) - cart.price += item_entity.info.price - - return cart -def _recalculate_cart_price(cart: CartEntity) -> None: +async def _recalculate_cart_price(session: AsyncSession, cart: Cart) -> None: total_price = 0.0 - for cart_item in cart.items: - item_entity = get_item(cart_item.id) - if item_entity and not item_entity.info.deleted: - total_price += item_entity.info.price * cart_item.quantity - cart_item.available = True - else: - cart_item.available = False + + for assoc in cart.cart_items: + if not assoc.item.deleted: + total_price += assoc.item.price * assoc.quantity cart.price = total_price \ No newline at end of file