diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 00000000..0904c343 --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,55 @@ + +name: HW5 Tests + +on: + push: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + pull_request: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + +jobs: + test-hw5: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: shop_db + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + 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: hw2/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests with coverage + working-directory: hw2/hw + env: + DATABASE_URL: postgresql://shop_user:shop_password@localhost:5432/shop_db + PYTHONPATH: ${{ github.workspace }}/hw2/hw + run: | + pytest test_homework2.py --cov=shop_api --cov-report=term -v diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ddc26a0f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +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 postgresql-client && 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 hw2/hw/requirements.txt + +FROM base as local + +EXPOSE 8080 +EXPOSE 5432 + +CMD ["uvicorn", "hw2.hw.shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..b9ed85cd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + environment: + - DATABASE_URL=postgresql://shop_user:shop_password@postgres:5432/shop_db + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: shop_db + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - .hw2/hw/migrations/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./hw2/hw/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: + prometheus-data: + postgres_data: \ No newline at end of file diff --git a/hw1/app.py b/hw1/app.py index 6107b870..9ee9143a 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,6 +1,8 @@ +import json +import math +from http import HTTPStatus from typing import Any, Awaitable, Callable - async def application( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], @@ -12,7 +14,194 @@ 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"}) + return + + if scope['type'] == 'http': + path = scope['path'].split("/")[1:] + + match path[0]: + case "fibonacci": + await fibonacci( + scope = scope, + send = send, + ) + + case "factorial": + await factorial( + scope = scope, + send = send, + ) + + case "mean": + await mean( + receive=receive, + send = send, + ) + + case _: + await send_response( + send = send, + data = {"error": "Not available"}, + status = HTTPStatus.NOT_FOUND, + ) + +async def fibonacci( + scope: dict[str, Any], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + path = scope['path'].split("/")[1:] + + try: + value = int(path[1]) + except ValueError: + return await send_response( + send = send, + data = { "error": "path parameter is not an integer" }, + status = HTTPStatus.UNPROCESSABLE_ENTITY, + ) + + if value < 0: + return await send_response( + send = send, + data = { "error": "path parameter can't be negative" }, + status = HTTPStatus.BAD_REQUEST, + ) + else: + return await send_response( + send = send, + data = { "result": _fibonacci(value) }, + status = HTTPStatus.OK + ) + +def _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 + +async def factorial( + scope: dict[str, Any], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + query_params: dict[str, Any] = await read_query_params(scope) + + if "n" not in query_params: + return await send_response( + send = send, + data = { "error": "no query param with name \"n\""}, + status = HTTPStatus.UNPROCESSABLE_ENTITY, + ) + + try: + n = int(query_params['n']) + except: + return await send_response( + send = send, + data = { "error": "invalid value of param \"n\""}, + status = HTTPStatus.UNPROCESSABLE_ENTITY, + ) + + try: + return await send_response( + send = send, + data = { "result": math.factorial(n) }, + status = HTTPStatus.OK, + ) + except ValueError: + return await send_response( + send = send, + data = { "error": "value of \"n\" is negative"}, + status = HTTPStatus.BAD_REQUEST, + ) + +async def mean( + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + try: + body = await read_body(receive=receive) + numbers_data = json.loads(body) + except json.JSONDecodeError: + return await send_response( + send = send, + data = {"error": "Invalid JSON"}, + status = HTTPStatus.UNPROCESSABLE_ENTITY, + ) + + if not isinstance(numbers_data, list): + return await send_response( + send = send, + data = { "error": "numbers is not a list"}, + status = HTTPStatus.UNPROCESSABLE_ENTITY, + ) + elif len(numbers_data) == 0: + return await send_response( + send = send, + data = { "error": "numbers is empty"}, + status = HTTPStatus.BAD_REQUEST, + ) + else: + mean = sum(numbers_data) / len(numbers_data) + return await send_response( + send = send, + data = { "result": mean }, + status = HTTPStatus.OK + ) + + + +async def read_body( + receive: Callable[[], Awaitable[dict[str, Any]]], +): + 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') + +async def read_query_params( + scope: dict[str, Any], +) -> dict[str, Any]: + query_string: str | None = scope.get("query_string", b"").decode() + params: dict[str, Any] = {} + if query_string: + for param in query_string.split("&"): + if "=" in param: + key, value = param.split("=", 1) + params[key] = value + + return params + +async def send_response( + send: Callable[[dict[str, Any]], Awaitable[None]], + data: dict[str, Any], + status: HTTPStatus, +): + await send({ + "type": "http.response.start", + "status": status, + "headers": [[b"content-type", b"application/json"]] + }) + await send({ + "type": "http.response.body", + "body": json.dumps(data).encode(), + }) + return + if __name__ == "__main__": import uvicorn diff --git a/hw2/__init__.py b/hw2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/migrations/init.sql b/hw2/hw/migrations/init.sql new file mode 100644 index 00000000..507f5b76 --- /dev/null +++ b/hw2/hw/migrations/init.sql @@ -0,0 +1,21 @@ +DROP TABLE IF EXISTS cart_items CASCADE; +DROP TABLE IF EXISTS carts CASCADE; +DROP TABLE IF EXISTS items CASCADE; + +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 carts ( + id SERIAL PRIMARY KEY +); + +CREATE TABLE cart_items ( + id SERIAL PRIMARY KEY, + cart_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0) +); diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..7e550d75 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,17 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 +pytest-cov +pytest-mock httpx>=0.27.2 Faker>=37.8.0 +responses + +prometheus-fastapi-instrumentator diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..6bdf88e7 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: demo-service-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw2/hw/shop_api/contracts.py b/hw2/hw/shop_api/contracts.py new file mode 100644 index 00000000..d7a66bd1 --- /dev/null +++ b/hw2/hw/shop_api/contracts.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from hw2.hw.shop_api.models import CartEntity, CartItemEntity, ItemEntity, ItemInfo, PatchItemInfo + +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_entity(entity) for entity in entity.info.items], + price = entity.info.price + ) + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + @staticmethod + def from_entity(entity: CartItemEntity) -> CartItemResponse: + return CartItemResponse( + id = entity.id, + name = entity.info.name, + quantity = entity.info.quantity, + available= entity.info.available + ) + +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) + +class PutItemRequest(BaseModel): + name: str + price: float + +class ItemRequest(BaseModel): + name: str + price: float + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name = self.name, price = self.price, deleted=False) + +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 + ) \ No newline at end of file diff --git a/hw2/hw/shop_api/entities.py b/hw2/hw/shop_api/entities.py new file mode 100644 index 00000000..1ae12e50 --- /dev/null +++ b/hw2/hw/shop_api/entities.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class Item(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + + +class Cart(Base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, index=True) + + + +class CartItem(Base): + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, nullable=False) + item_id = Column(Integer, nullable=False) + quantity = Column(Integer, default=1) \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..7fff5a0b 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,9 @@ from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator +from hw2.hw.shop_api.routes import cartRouter, itemRouter app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +app.include_router(cartRouter) +app.include_router(itemRouter) diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..1e61f410 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from typing import List, Optional + +@dataclass(slots = True) +class CartItemInfo: + name: str + quantity: int + available: bool + +@dataclass(slots = True) +class CartItemEntity: + id: int + info: CartItemInfo + +@dataclass(slots = True) +class CartInfo: + items: List[CartItemEntity] + price: float + +@dataclass(slots = True) +class CartEntity: + id: int + info: CartInfo + +@dataclass(slots = True) +class ItemInfo: + name: str + price: float + deleted: bool + +@dataclass(slots = True) +class ItemEntity: + id: int + info: ItemInfo + +@dataclass(slots = True) +class Item: + id: int + name: str + price: float + deleted: bool = False + +@dataclass(slots = True) +class ItemCreate: + name: str + price: float + +@dataclass(slots = True) +class PatchItemInfo: + name: Optional[str] = None + price: Optional[float] = None + + \ No newline at end of file diff --git a/hw2/hw/shop_api/patch_result.py b/hw2/hw/shop_api/patch_result.py new file mode 100644 index 00000000..f0ba4838 --- /dev/null +++ b/hw2/hw/shop_api/patch_result.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class PatchResult(Enum): + NotFound = 0 + NotModified = 1 + Unprocessable = 2 diff --git a/hw2/hw/shop_api/routes.py b/hw2/hw/shop_api/routes.py new file mode 100644 index 00000000..dc4c374c --- /dev/null +++ b/hw2/hw/shop_api/routes.py @@ -0,0 +1,177 @@ +from http import HTTPStatus +from typing import Annotated, Optional + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeInt, PositiveInt + +from hw2.hw.shop_api import store +from hw2.hw.shop_api.patch_result import PatchResult +from hw2.hw.shop_api.contracts import ( + CartResponse, + ItemRequest, + ItemResponse, + PatchItemRequest, + PutItemRequest, +) +from hw2.hw.shop_api.models import ItemEntity + +cartRouter = APIRouter(prefix="/cart") +itemRouter = APIRouter(prefix="/item") + +@cartRouter.post("/", status_code=HTTPStatus.CREATED) +async def create_cart(response: Response): + id = store.store.create_cart() + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/cart/{id}" + + return { "id": id } + + +@cartRouter.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested pokemon", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested pokemon as one was not found", + }, + }, +) +async def get_cart(id: int) -> CartResponse: + cart = store.store.get_cart(id) + if cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + return CartResponse.from_entity(cart) + + +@cartRouter.get("/") +async def get_carts( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0) +) -> list[CartResponse]: + carts = store.store.get_all_carts( + 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 carts] + + +@cartRouter.post("/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int): + item = store.store.get_item(item_id) + if item is None: + raise HTTPException(status_code=404, detail="Item not found") + + success = store.store.add_item_to_cart(cart_id, item) + if not success: + raise HTTPException(status_code=404, detail="Cart not found") + + return {"message": "Item added to cart"} + + +@itemRouter.post( + "/", + status_code=HTTPStatus.CREATED, +) +def post_item(info: ItemRequest, response: Response) -> ItemResponse: + entity = store.store.add_item(info.as_item_info()) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/item/{entity.id}" + + return ItemResponse.from_entity(entity) + + +@itemRouter.get( + "/{item_id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested pokemon", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested pokemon as one was not found", + }, + }, +) +async def get_item(item_id: int) -> ItemResponse: + item = store.store.get_item(item_id) + if item is None: + raise HTTPException(status_code=404, detail="Item not found") + return ItemResponse.from_entity(item) + + +@itemRouter.get("/") +async def get_items( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: Optional[bool] = Query(False), +) -> list[ItemResponse]: + items = store.store.get_all_items() + + if not show_deleted: + items = [item for item in items if not item.info.deleted] + + + if min_price is not None: + items = [item for item in items if item.info.price >= min_price] + + if max_price is not None: + items = [item for item in items if item.info.price <= max_price] + + return [ItemResponse.from_entity(entity) for entity in items[offset:offset + limit]] + + +@itemRouter.put("/{item_id}") +def update_item(item_id: int, request: PutItemRequest) -> ItemResponse: + item = store.store.put_item(item_id=item_id, request=request) + if item is None: + raise HTTPException(status_code=404, detail="Item not found") + return ItemResponse.from_entity(item) + + +@itemRouter.patch( + "/{item_id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + HTTPStatus.UNPROCESSABLE_ENTITY: { + "description": "invalid price", + }, + } +) +async def patch_item(item_id: int, info: PatchItemRequest): + entity = store.store.patch_item(item_id, info.as_patch_item_info()) + + if isinstance(entity, ItemEntity): + return ItemResponse.from_entity(entity) + else: + match entity: + case PatchResult.NotFound: + raise HTTPException(status_code=404, detail="Item not found") + case PatchResult.NotModified: + return Response(status_code=304) + case _: + raise HTTPException(status_code=422, detail="Incorrect price") + + +@itemRouter.delete("/{item_id}") +async def delete_item(item_id: int): + store.store.delete_item(item_id) + return {"message": "Item deleted"} \ 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..f4af3634 --- /dev/null +++ b/hw2/hw/shop_api/store.py @@ -0,0 +1,237 @@ +from typing import List, Optional +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker +from contextlib import contextmanager + +from hw2.hw.shop_api.entities import Cart, Item, CartItem, Base +from hw2.hw.shop_api.patch_result import PatchResult + +from hw2.hw.shop_api.contracts import PutItemRequest +from .models import ( + CartEntity, + CartInfo, + CartItemEntity, + CartItemInfo, + ItemEntity, + ItemInfo, + PatchItemInfo, +) + +import os + +class DatabaseStore: + def __init__(self): + database_url = os.getenv("DATABASE_URL", "postgresql://shop_user:shop_password@localhost:5432/shop_db") + + self.engine = create_engine(database_url) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) + + Base.metadata.create_all(bind=self.engine) + + @contextmanager + def get_session(self): + session = self.SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + def create_cart(self) -> int: + with self.get_session() as session: + cart_orm = Cart() + session.add(cart_orm) + session.flush() + return cart_orm.id + + def add_item(self, info: ItemInfo) -> ItemEntity: + with self.get_session() as session: + item = Item( + name=str(info.name), + price=float(info.price), + deleted=bool(info.deleted) + ) + session.add(item) + session.flush() + + return ItemEntity( + id=item.id, + info=ItemInfo( + name=str(item.name), + price=float(item.price), + deleted=bool(item.deleted) + ) + ) + + def get_cart(self, id: int) -> Optional[CartEntity]: + with self.get_session() as session: + cart_orm =session.query(Cart).filter(Cart.id == id).first() + if not cart_orm: + return None + + cart_items_orm = session.query(CartItem).filter(CartItem.cart_id == id).all() + items: List[CartItem] = [] + total_price = 0.0 + + for cart_item in cart_items_orm: + item_orm = session.query(Item).filter(Item.id == cart_item.item_id).first() + if item_orm: + items.append( + CartItemEntity( + id=item_orm.id, + info = CartItemInfo( + name=item_orm.name, + quantity=cart_item.quantity, + available=not item_orm.deleted + ) + ) + ) + if not item_orm.deleted: + total_price += float(item_orm.price) * cart_item.quantity + + return CartEntity( + id=id, + info = CartInfo( + items=items, + price=total_price + ) + ) + + def get_all_carts( + self, + 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]: + with self.get_session() as session: + cart_ids = session.query(Cart.id).offset(offset).limit(limit).all() + carts: List[CartEntity] = [] + + for (cart_id,) in cart_ids: + cart: CartEntity | None = self.get_cart(cart_id) + if cart: + if min_price is not None and cart.info.price < min_price: + continue + if max_price is not None and cart.info.price > max_price: + continue + + total_quantity = sum(item.info.quantity for item in cart.info.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 + + carts.append(cart) + + return carts + + def get_item(self, id: int) -> Optional[ItemEntity]: + with self.get_session() as session: + item = session.get(Item, id) + if not item or item.deleted: + return None + + return ItemEntity( + id=int(item.id), + info=ItemInfo( + name=str(item.name), + price=float(item.price), + deleted=bool(item.deleted) + ) + ) + + def get_all_items(self) -> List[ItemEntity]: + with self.get_session() as session: + items = session.execute(select(Item)).scalars().all() + return [ + ItemEntity( + id=int(item.id), + info=ItemInfo( + name=str(item.name), + price=float(item.price), + deleted=bool(item.deleted) + ) + ) + for item in items + ] + + def add_item_to_cart(self, cart_id: int, item: ItemEntity) -> bool: + with self.get_session() as session: + cart_orm = session.query(Cart).filter(Cart.id == cart_id).first() + if not cart_orm: + return False + + item_orm = session.query(Item).filter(Item.id == item.id).first() + if not item_orm: + return False + + cart_item = session.query(CartItem).filter( + CartItem.cart_id == cart_id, + CartItem.item_id == item.id + ).first() + + if cart_item: + cart_item.quantity += 1 + else: + cart_item = CartItem(cart_id=cart_id, item_id=item.id, quantity=1) + session.add(cart_item) + + return True + + def put_item(self, item_id: int, request: PutItemRequest) -> Optional[ItemEntity]: + with self.get_session() as session: + item_orm = session.query(Item).filter(Item.id == item_id).first() + if not item_orm: + return None + item_orm.name = request.name + item_orm.price = request.price + session.commit() + session.refresh(item_orm) + return ItemEntity( + id=item_orm.id, + info = ItemInfo( + name=item_orm.name, + price=float(item_orm.price), + deleted=item_orm.deleted + ) + ) + + def patch_item(self, item_id: int, patch_info: PatchItemInfo) -> ItemEntity | PatchResult : + with self.get_session() as session: + item_orm = session.query(Item).filter(Item.id == item_id).first() + if not item_orm: + return PatchResult.NotModified + + if item_orm.deleted: + return PatchResult.NotModified + + if patch_info.name is not None: + item_orm.name = patch_info.name + if patch_info.price is not None: + item_orm.price = patch_info.price + + session.commit() + session.refresh(item_orm) + return ItemEntity( + id=item_orm.id, + info = ItemInfo( + name=item_orm.name, + price=float(item_orm.price), + deleted=item_orm.deleted + ) + ) + + def delete_item(self, item_id: int) -> None: + with self.get_session() as session: + item_orm = session.query(Item).filter(Item.id == item_id).first() + if item_orm: + item_orm.deleted = True + session.commit() + +store = DatabaseStore() \ No newline at end of file diff --git a/hw2/hw/test_homework2.py b/hw2/hw/test_homework2.py index 60a1f36a..1776fe2b 100644 --- a/hw2/hw/test_homework2.py +++ b/hw2/hw/test_homework2.py @@ -6,7 +6,7 @@ from faker import Faker from fastapi.testclient import TestClient -from shop_api.main import app +from hw2.hw.shop_api.main import app client = TestClient(app) faker = Faker() @@ -16,6 +16,10 @@ def existing_empty_cart_id() -> int: return client.post("/cart").json()["id"] +@pytest.fixture() +def non_existing_cart_id() -> int: + return -1 + @pytest.fixture(scope="session") def existing_items() -> list[int]: @@ -83,6 +87,20 @@ def test_post_cart() -> None: assert "id" in response.json() +@pytest.mark.parametrize( + ("cart", "status_code"), + [ + ("existing_empty_cart_id", HTTPStatus.OK), + ("non_existing_cart_id", HTTPStatus.NOT_FOUND), + ], +) +def test_get_cart_status_code(request, cart: int, status_code: HTTPStatus) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == status_code + @pytest.mark.parametrize( ("cart", "not_empty"), [ @@ -112,7 +130,6 @@ def test_get_cart(request, cart: int, not_empty: bool) -> None: else: assert response_json["price"] == 0.0 - @pytest.mark.parametrize( ("query", "status_code"), [ @@ -174,6 +191,12 @@ def test_get_item(existing_item: dict[str, Any]) -> None: assert response.status_code == HTTPStatus.OK assert response.json() == existing_item + +def test_get_non_existing_item() -> None: + item_id = -1 + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND @pytest.mark.parametrize(