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 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..0bc439f2 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,59 @@ +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: . + dockerfile: Dockerfile + target: local + 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 + 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 + +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 207dcf5c..0eb73123 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,11 @@ # Основные зависимости для ASGI приложения 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 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/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..3d21b578 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,19 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI +# from prometheus_fastapi_instrumentator import Instrumentator + +from .database import init_db +from .routes import router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI(title="Shop API", lifespan=lifespan) +# Instrumentator().instrument(app).expose(app) -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..1859b213 --- /dev/null +++ b/hw2/hw/shop_api/routes.py @@ -0,0 +1,169 @@ +from http import HTTPStatus +from typing import Annotated + +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 ( + CartResponse, + ItemRequest, + ItemResponse, + PatchItemRequest, + PutItemRequest, +) +from .database import get_db + +router = APIRouter() + + +@router.post("/item", status_code=HTTPStatus.CREATED) +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, + 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, + 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, + db: AsyncSession = Depends(get_db) +) -> list[ItemResponse]: + entities = await store.get_items_filtered( + db, + 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, + 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, + 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, + 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 = 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, + db: AsyncSession = Depends(get_db) +) -> Response: + success = await store.delete_item(db, 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, + 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, + db: AsyncSession = Depends(get_db) +) -> CartResponse: + entity = await store.get_cart(db, 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, + db: AsyncSession = Depends(get_db) +) -> list[CartResponse]: + entities = await store.get_carts_filtered( + db, + 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, + 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) diff --git a/hw2/hw/shop_api/store.py b/hw2/hw/shop_api/store.py new file mode 100644 index 00000000..61d39db2 --- /dev/null +++ b/hw2/hw/shop_api/store.py @@ -0,0 +1,264 @@ +from typing import List, Optional + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from .db_models import Cart, CartItemAssociation, Item +from .models import CartEntity, CartItem, ItemEntity, ItemInfo, PatchItemInfo + + +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 + )) + +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=db_item.id, info=ItemInfo( + name=db_item.name, + price=db_item.price, + deleted=db_item.deleted + )) + +async def get_items_filtered( + session: AsyncSession, + offset: int = 0, + limit: int = 10, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + show_deleted: bool = False, +) -> List[ItemEntity]: + query = select(Item) + + 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() + + 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 + )) + +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 + if patch_info.name is not None: + db_item.name = patch_info.name + if patch_info.price is not None: + db_item.price = patch_info.price + if patch_info.deleted is not None: + 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 + )) + +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 + 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) + +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 + 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 + ) + +async def get_carts_filtered( + session: AsyncSession, + offset: int = 0, + limit: int = 10, + 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 + cart_entities.append(CartEntity( + id=db_cart.id, + items=cart_items, + price=db_cart.price + )) + return cart_entities[offset:offset + limit] + + +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 + 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 + 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 + ) + + +async def _recalculate_cart_price(session: AsyncSession, cart: Cart) -> None: + total_price = 0.0 + + 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