From c48276186a49f04ddaa34e686bd453caee12deed Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Tue, 23 Sep 2025 23:47:36 +0300 Subject: [PATCH 01/14] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 2 deletions(-) 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 From ea6151b04040bc0718d166874d640c9fcc436a92 Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 5 Oct 2025 11:11:46 +0300 Subject: [PATCH 02/14] homework 2 solution --- hw2/hw/shop_api/contracts.py | 68 +++++++++++++ hw2/hw/shop_api/main.py | 5 + hw2/hw/shop_api/models.py | 54 +++++++++++ hw2/hw/shop_api/routes.py | 181 +++++++++++++++++++++++++++++++++++ hw2/hw/shop_api/store.py | 134 ++++++++++++++++++++++++++ 5 files changed, 442 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..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/main.py b/hw2/hw/shop_api/main.py index f60a8c60..acc7dab2 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,8 @@ from fastapi import FastAPI +from hw2.hw.shop_api.routes import cartRouter, itemRouter + app = FastAPI(title="Shop API") + +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..787b4aa0 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,54 @@ +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/routes.py b/hw2/hw/shop_api/routes.py new file mode 100644 index 00000000..90af1bd7 --- /dev/null +++ b/hw2/hw/shop_api/routes.py @@ -0,0 +1,181 @@ +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.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.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.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.get_all_carts() + + if min_price is not None: + carts = [cart for cart in carts if cart.info.price >= min_price] + + if max_price is not None: + carts = [cart for cart in carts if cart.info.price <= max_price] + + if min_quantity is not None: + carts = [cart for cart in carts if cart.info.price >= min_quantity] + + if max_quantity is not None: + carts = [cart for cart in carts if cart.info.price <= max_quantity] + + return [CartResponse.from_entity(entity) for entity in carts[offset:offset + limit]] + + +@cartRouter.post("/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int): + item = store.get_item(item_id) + if item is None: + raise HTTPException(status_code=404, detail="Item not found") + + success = 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.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.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.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.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.patch_item(item_id, info.as_patch_item_info()) + + if isinstance(entity, ItemEntity): + return ItemResponse.from_entity(entity) + else: + match entity: + case store.PatchResult.NotFound: + raise HTTPException(status_code=404, detail="Item not found") + case store.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.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..ef4f8f6f --- /dev/null +++ b/hw2/hw/shop_api/store.py @@ -0,0 +1,134 @@ +from typing import List, Iterable + +from enum import Enum + +from hw2.hw.shop_api.contracts import PutItemRequest +from .models import ( + CartEntity, + CartInfo, + CartItemEntity, + CartItemInfo, + ItemEntity, + ItemInfo, + PatchItemInfo, +) + +_carts = dict[int, CartInfo]() +_items = dict[int, ItemInfo]() + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + +_cart_id_generator: Iterable[int] = int_id_generator() + +_item_id_generator: Iterable[int] = int_id_generator() + +def create_cart() -> int: + id: int = next(_cart_id_generator) + _carts[id] = CartInfo(items=[], price=0) + return id + +def add_item(info: ItemInfo) -> ItemEntity: + _id = next(_item_id_generator) + _items[_id] = info + + return ItemEntity(id = _id, info = info) + +def delete_card(id: int) -> None: + if id in _carts: + del _carts[id] + +def delete_item(id: int) -> None: + if id in _items: + del _items[id] + +def get_cart(id: int) -> CartEntity | None: + if id not in _carts: + return None + + return CartEntity(id=id, info = _carts[id]) + +def get_all_carts() -> List[CartEntity]: + return [CartEntity(id=id, info=info) for id, info in list(_carts.items())] + +def get_item(id: int) -> ItemEntity | None: + if id not in _items: + return None + + return ItemEntity(id=id, info = _items[id]) + +def get_all_items() -> List[ItemEntity]: + return [ItemEntity(id=id, info=info) for id, info in list(_items.items())] + +def add_item_to_cart(cart_id: int, item: ItemEntity) -> bool: + if cart_id not in _carts: + return False + + cart = _carts[cart_id] + + if item.id in [item.id for item in cart.items]: + for cart_item in cart.items: + if cart_item.id == item.id: + cart_item.info.quantity += 1 + break + + new_cart_item = CartItemEntity( + id=item.id, + info = CartItemInfo( + name=item.info.name, + quantity=1, + available=not item.info.deleted + ) + ) + cart.items.append(new_cart_item) + + cart.price = sum(cart_item.info.quantity * _items[cart_item.id].price + for cart_item in cart.items if cart_item.info.available) + + _carts[cart_id] = cart + + return True + +def update_item(item_id: int, item_data: ItemInfo) -> bool: + if item_id not in _items: + return False + _items[item_id] = item_data + return True + +def put_item(item_id: int, request: PutItemRequest) -> ItemEntity | None: + if item_id not in _items: + return None + existing = _items[item_id] + if existing.deleted: + return None + existing.name = request.name + existing.price = request.price + return ItemEntity(item_id, existing) + +class PatchResult(Enum): + NotFound = 0 + NotModified = 1 + Unprocessable = 2 + +def patch_item(item_id: int, patch_info: PatchItemInfo) -> ItemEntity | PatchResult: + if item_id not in _items: + return PatchResult.NotModified + + existing = _items[item_id] + + if existing.deleted: + return PatchResult.NotModified + + if patch_info.name is not None: + existing.name = patch_info.name + + + if patch_info.price is not None and patch_info.price < 0: + return PatchResult.Unprocessable + elif patch_info.price is not None: + existing.price = patch_info.price + + return ItemEntity(id=item_id, info = _items[item_id]) From 9efa10958fe1934c62bf7d02130071d6929df2cc Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 5 Oct 2025 11:13:02 +0300 Subject: [PATCH 03/14] add __init__.py to hw2 module --- hw2/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 hw2/__init__.py diff --git a/hw2/__init__.py b/hw2/__init__.py new file mode 100644 index 00000000..e69de29b From 1a16b709fb312e205f51b5e8ae4185eb85a98d3d Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 12 Oct 2025 16:18:51 +0300 Subject: [PATCH 04/14] graphana and prometheus --- Dockerfile | 23 ++++++++++++++++++ docker-compose.yml | 29 +++++++++++++++++++++++ hw2/hw/requirements.txt | 2 ++ hw2/hw/settings/prometheus/prometheus.yml | 10 ++++++++ hw2/hw/shop_api/main.py | 3 ++- 5 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 hw2/hw/settings/prometheus/prometheus.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..73f8abb4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r hw2/hw/requirements.txt + +FROM base as local + +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..6069538b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./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 diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..65571a2e 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -7,3 +7,5 @@ pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 + +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/main.py b/hw2/hw/shop_api/main.py index acc7dab2..7fff5a0b 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,8 +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) From 4c2a00a1a7f6c3b55d365eb55c112bb271bbd13a Mon Sep 17 00:00:00 2001 From: Andrey Belitsckiy Date: Sun, 26 Oct 2025 15:22:55 +0300 Subject: [PATCH 05/14] hw5-tests.yml --- .github/workflows/main.yml | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..ccf73fbf --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,51 @@ + +name: HW5 Tests + +on: + push: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + pull_request: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + +jobs: + test: + runs-on: ubuntu-latest + + 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 + run: | + python -m pip install --upgrade pip + pip install -r hw2/hw/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 tests/test_shop_api.py --cov=shop_api --cov-report=term -v From 264a9a7c3634b3a1c22929c238896b4839638e36 Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 26 Oct 2025 15:28:45 +0300 Subject: [PATCH 06/14] add postgres to HW2 --- Dockerfile | 5 +- docker-compose.yml | 26 +++ hw2/hw/migrations/init.sql | 22 +++ hw2/hw/requirements.txt | 6 + hw2/hw/shop_api/entities.py | 24 +++ hw2/hw/shop_api/routes.py | 28 +-- hw2/hw/shop_api/store.py | 332 +++++++++++++++++++++++----------- hw2/hw/tests/__init__.py | 0 hw2/hw/tests/test_shop_api.py | 284 +++++++++++++++++++++++++++++ 9 files changed, 608 insertions(+), 119 deletions(-) create mode 100644 hw2/hw/migrations/init.sql create mode 100644 hw2/hw/shop_api/entities.py create mode 100644 hw2/hw/tests/__init__.py create mode 100644 hw2/hw/tests/test_shop_api.py diff --git a/Dockerfile b/Dockerfile index 73f8abb4..ddc26a0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ ARG PYTHONFAULTHANDLER=1 \ PIP_DISABLE_PIP_VERSION_CHECK=on \ PIP_DEFAULT_TIMEOUT=500 -RUN apt-get update && apt-get install -y gcc +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 @@ -20,4 +20,7 @@ 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 index 6069538b..b9ed85cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,28 @@ services: 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 @@ -27,3 +49,7 @@ services: ports: - 9090:9090 restart: always + +volumes: + prometheus-data: + postgres_data: \ No newline at end of file diff --git a/hw2/hw/migrations/init.sql b/hw2/hw/migrations/init.sql new file mode 100644 index 00000000..958c2d37 --- /dev/null +++ b/hw2/hw/migrations/init.sql @@ -0,0 +1,22 @@ +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 REFERENCES carts(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), + UNIQUE(cart_id, item_id) +); diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 65571a2e..7e550d75 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,11 +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/shop_api/entities.py b/hw2/hw/shop_api/entities.py new file mode 100644 index 00000000..8229ba17 --- /dev/null +++ b/hw2/hw/shop_api/entities.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, Numeric +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, nullable=False) + price = Column(Numeric(10, 2), nullable=False) + deleted = Column(Boolean, default=False) + +class Cart(base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, index=True) + +class CartItemAssociation(base): + __tablename__ = "cart_items" + + cart_id = Column(Integer, ForeignKey('carts.id'), primary_key=True) + item_id = Column(Integer, ForeignKey('items.id'), primary_key=True) + quantity = Column(Integer, nullable=False, default=1) diff --git a/hw2/hw/shop_api/routes.py b/hw2/hw/shop_api/routes.py index 90af1bd7..fcddf0c9 100644 --- a/hw2/hw/shop_api/routes.py +++ b/hw2/hw/shop_api/routes.py @@ -17,9 +17,11 @@ cartRouter = APIRouter(prefix="/cart") itemRouter = APIRouter(prefix="/item") +databaseStore = store.DatabaseStore() + @cartRouter.post("/", status_code=HTTPStatus.CREATED) async def create_cart(response: Response): - id = store.create_cart() + id = databaseStore.create_cart() # as REST states one should provide uri to newly created resource in location header response.headers["location"] = f"/cart/{id}" @@ -39,7 +41,7 @@ async def create_cart(response: Response): }, ) async def get_cart(id: int) -> CartResponse: - cart = store.get_cart(id) + cart = databaseStore.get_cart(id) if cart is None: raise HTTPException(status_code=404, detail="Cart not found") return CartResponse.from_entity(cart) @@ -54,7 +56,7 @@ async def get_carts( min_quantity: Optional[int] = Query(None, ge=0), max_quantity: Optional[int] = Query(None, ge=0) ) -> list[CartResponse]: - carts = store.get_all_carts() + carts = databaseStore.get_all_carts() if min_price is not None: carts = [cart for cart in carts if cart.info.price >= min_price] @@ -73,11 +75,11 @@ async def get_carts( @cartRouter.post("/{cart_id}/add/{item_id}") def add_item_to_cart(cart_id: int, item_id: int): - item = store.get_item(item_id) + item = databaseStore.get_item(item_id) if item is None: raise HTTPException(status_code=404, detail="Item not found") - success = store.add_item_to_cart(cart_id, item) + success = databaseStore.add_item_to_cart(cart_id, item) if not success: raise HTTPException(status_code=404, detail="Cart not found") @@ -89,7 +91,7 @@ def add_item_to_cart(cart_id: int, item_id: int): status_code=HTTPStatus.CREATED, ) def post_item(info: ItemRequest, response: Response) -> ItemResponse: - entity = store.add_item(info.as_item_info()) + entity = databaseStore.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}" @@ -109,7 +111,7 @@ def post_item(info: ItemRequest, response: Response) -> ItemResponse: }, ) async def get_item(item_id: int) -> ItemResponse: - item = store.get_item(item_id) + item = databaseStore.get_item(item_id) if item is None: raise HTTPException(status_code=404, detail="Item not found") return ItemResponse.from_entity(item) @@ -123,7 +125,7 @@ async def get_items( max_price: Optional[float] = Query(None, ge=0), show_deleted: Optional[bool] = Query(False), ) -> list[ItemResponse]: - items = store.get_all_items() + items = databaseStore.get_all_items() if not show_deleted: items = [item for item in items if not item.info.deleted] @@ -140,7 +142,7 @@ async def get_items( @itemRouter.put("/{item_id}") def update_item(item_id: int, request: PutItemRequest) -> ItemResponse: - item = store.put_item(item_id=item_id, request=request) + item = databaseStore.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) @@ -161,15 +163,15 @@ def update_item(item_id: int, request: PutItemRequest) -> ItemResponse: } ) async def patch_item(item_id: int, info: PatchItemRequest): - entity = store.patch_item(item_id, info.as_patch_item_info()) + entity = databaseStore.patch_item(item_id, info.as_patch_item_info()) if isinstance(entity, ItemEntity): return ItemResponse.from_entity(entity) else: match entity: - case store.PatchResult.NotFound: + case databaseStore.PatchResult.NotFound: raise HTTPException(status_code=404, detail="Item not found") - case store.PatchResult.NotModified: + case databaseStore.PatchResult.NotModified: return Response(status_code=304) case _: raise HTTPException(status_code=422, detail="Incorrect price") @@ -177,5 +179,5 @@ async def patch_item(item_id: int, info: PatchItemRequest): @itemRouter.delete("/{item_id}") async def delete_item(item_id: int): - store.delete_item(item_id) + databaseStore.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 index ef4f8f6f..3d57710b 100644 --- a/hw2/hw/shop_api/store.py +++ b/hw2/hw/shop_api/store.py @@ -1,6 +1,11 @@ -from typing import List, Iterable +from typing import List from enum import Enum +from sqlalchemy import create_engine, select, delete, and_ +from sqlalchemy.orm import sessionmaker +from contextlib import contextmanager + +from hw2.hw.shop_api.entities import Cart, Item, CartItemAssociation, base from hw2.hw.shop_api.contracts import PutItemRequest from .models import ( @@ -13,122 +18,239 @@ PatchItemInfo, ) -_carts = dict[int, CartInfo]() -_items = dict[int, ItemInfo]() +import os -def int_id_generator() -> Iterable[int]: - i = 0 - while True: - yield i - i += 1 +def get_database_url(): + default_url = "postgresql://shop_user:shop_password@postgres:5432/shop_db" + url = os.getenv('DATABASE_URL', default_url) + + if isinstance(url, bytes): + url = url.decode('latin-1') -_cart_id_generator: Iterable[int] = int_id_generator() + url = url.strip() + + return url -_item_id_generator: Iterable[int] = int_id_generator() +class DatabaseStore: + + def __init__(self, database_url: str = get_database_url()): + 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() -> int: - id: int = next(_cart_id_generator) - _carts[id] = CartInfo(items=[], price=0) - return id + def create_cart(self) -> int: + with self.get_session() as session: + cart = Cart() + session.add(cart) + session.flush() + cart_id = cart.id + return cart_id -def add_item(info: ItemInfo) -> ItemEntity: - _id = next(_item_id_generator) - _items[_id] = info - - return ItemEntity(id = _id, info = info) + def add_item(self, info: ItemInfo) -> ItemEntity: + with self.get_session() as session: + item = Item( + name=info.name, + price=info.price, + deleted=info.deleted + ) + session.add(item) + session.flush() -def delete_card(id: int) -> None: - if id in _carts: - del _carts[id] + return ItemEntity( + id=item.id, + info=ItemInfo( + name=item.name, + price=float(item.price), + deleted=item.deleted + ) + ) -def delete_item(id: int) -> None: - if id in _items: - del _items[id] + def delete_cart(self, id: int) -> None: + with self.get_session() as session: + cart = session.get(Cart, id) + if cart: + session.execute( + delete(CartItemAssociation) + .where(CartItemAssociation.cart_id == id) + ) + session.delete(cart) -def get_cart(id: int) -> CartEntity | None: - if id not in _carts: - return None + def delete_item(self, id: int) -> None: + with self.get_session() as session: + item = session.get(Item, id) + if item: + item.deleted = True - return CartEntity(id=id, info = _carts[id]) + def get_cart(self, id: int) -> CartEntity | None: + with self.get_session() as session: + cart = session.get(Cart, id) + if not cart: + return None + + cart_items = [] + total_price = 0 + + for association in cart.cart_item_associations: + item = association.item + if not item.deleted: + cart_item_entity = CartItemEntity( + id=item.id, + info=CartItemInfo( + name=item.name, + quantity=association.quantity, + available=not item.deleted + ) + ) + cart_items.append(cart_item_entity) + total_price += item.price * association.quantity + + cart_info = CartInfo( + items=cart_items, + price=total_price + ) + + return CartEntity(id=cart.id, info=cart_info) -def get_all_carts() -> List[CartEntity]: - return [CartEntity(id=id, info=info) for id, info in list(_carts.items())] + def get_all_carts(self) -> List[CartEntity]: + with self.get_session() as session: + carts = session.execute(select(Cart)).scalars().all() + result = [] + + for cart in carts: + cart_entity = self.get_cart(cart.id) + if cart_entity: + result.append(cart_entity) + + return result -def get_item(id: int) -> ItemEntity | None: - if id not in _items: - return None + def get_item(self, id: int) -> ItemEntity | None: + with self.get_session() as session: + item = session.get(Item, id) + if not item: + return None + + return ItemEntity( + id=item.id, + info=ItemInfo( + name=item.name, + price=item.price, + deleted=item.deleted + ) + ) - return ItemEntity(id=id, info = _items[id]) + def get_all_items(self) -> List[ItemEntity]: + with self.get_session() as session: + items = session.execute(select(Item)).scalars().all() + return [ + ItemEntity( + id=item.id, + info=ItemInfo( + name=item.name, + price=item.price, + deleted=item.deleted + ) + ) + for item in items + ] -def get_all_items() -> List[ItemEntity]: - return [ItemEntity(id=id, info=info) for id, info in list(_items.items())] + def add_item_to_cart(self, cart_id: int, item: ItemEntity) -> bool: + with self.get_session() as session: + cart = session.get(Cart, cart_id) + if not cart: + return False + + db_item = session.get(Item, item.id) + if not db_item or db_item.deleted: + return False + + existing_association = session.execute( + select(CartItemAssociation) + .where( + and_( + CartItemAssociation.cart_id == cart_id, + CartItemAssociation.item_id == item.id + ) + ) + ).scalar_one_or_none() + + if existing_association: + existing_association.quantity += 1 + else: + association = CartItemAssociation( + cart_id=cart_id, + item_id=item.id, + quantity=1 + ) + session.add(association) + + return True -def add_item_to_cart(cart_id: int, item: ItemEntity) -> bool: - if cart_id not in _carts: - return False - - cart = _carts[cart_id] - - if item.id in [item.id for item in cart.items]: - for cart_item in cart.items: - if cart_item.id == item.id: - cart_item.info.quantity += 1 - break - - new_cart_item = CartItemEntity( - id=item.id, - info = CartItemInfo( - name=item.info.name, - quantity=1, - available=not item.info.deleted + def put_item(self, item_id: int, request: PutItemRequest) -> ItemEntity | None: + with self.get_session() as session: + item = session.get(Item, item_id) + if not item or item.deleted: + return None + + item.name = request.name + item.price = request.price + + return ItemEntity( + id=item.id, + info=ItemInfo( + name=item.name, + price=item.price, + deleted=item.deleted + ) + ) + + class PatchResult(Enum): + NotFound = 0 + NotModified = 1 + Unprocessable = 2 + + def patch_item(self, item_id: int, patch_info: PatchItemInfo) -> ItemEntity | PatchResult: + with self.get_session() as session: + item = session.get(Item, item_id) + if not item: + return self.PatchResult.NotFound + + if item.deleted: + return self.PatchResult.NotModified + + modified = False + + if patch_info.name is not None and patch_info.name != item.name: + item.name = patch_info.name + modified = True + + if patch_info.price is not None: + if patch_info.price < 0: + return self.PatchResult.Unprocessable + if patch_info.price != item.price: + item.price = patch_info.price + modified = True + + if not modified: + return self.PatchResult.NotModified + + return ItemEntity( + id=item.id, + info=ItemInfo( + name=item.name, + price=item.price, + deleted=item.deleted + ) ) - ) - cart.items.append(new_cart_item) - - cart.price = sum(cart_item.info.quantity * _items[cart_item.id].price - for cart_item in cart.items if cart_item.info.available) - - _carts[cart_id] = cart - - return True - -def update_item(item_id: int, item_data: ItemInfo) -> bool: - if item_id not in _items: - return False - _items[item_id] = item_data - return True - -def put_item(item_id: int, request: PutItemRequest) -> ItemEntity | None: - if item_id not in _items: - return None - existing = _items[item_id] - if existing.deleted: - return None - existing.name = request.name - existing.price = request.price - return ItemEntity(item_id, existing) - -class PatchResult(Enum): - NotFound = 0 - NotModified = 1 - Unprocessable = 2 - -def patch_item(item_id: int, patch_info: PatchItemInfo) -> ItemEntity | PatchResult: - if item_id not in _items: - return PatchResult.NotModified - - existing = _items[item_id] - - if existing.deleted: - return PatchResult.NotModified - - if patch_info.name is not None: - existing.name = patch_info.name - - - if patch_info.price is not None and patch_info.price < 0: - return PatchResult.Unprocessable - elif patch_info.price is not None: - existing.price = patch_info.price - - return ItemEntity(id=item_id, info = _items[item_id]) diff --git a/hw2/hw/tests/__init__.py b/hw2/hw/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/tests/test_shop_api.py b/hw2/hw/tests/test_shop_api.py new file mode 100644 index 00000000..6d1e5731 --- /dev/null +++ b/hw2/hw/tests/test_shop_api.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +from hw2.hw.shop_api import main + +client = TestClient(main.app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@pytest.fixture() +def existing_not_empty_cart_id( + existing_empty_cart_id: int, + existing_items: list[int], +) -> int: + for item_id in faker.random_elements(existing_items, unique=False, length=3): + client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + + return existing_empty_cart_id + + +@pytest.fixture() +def existing_item() -> dict[str, Any]: + return client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ).json() + + +@pytest.fixture() +def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: + item_id = existing_item["id"] + client.delete(f"/item/{item_id}") + + existing_item["deleted"] = True + return existing_item + + +def test_post_cart() -> None: + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.parametrize( + ("cart", "not_empty"), + [ + ("existing_empty_cart_id", False), + ("existing_not_empty_cart_id", True), + ], +) +def test_get_cart(request, cart: int, not_empty: bool) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + len_items = len(response_json["items"]) + assert len_items > 0 if not_empty else len_items == 0 + + if not_empty: + price = 0 + + for item in response_json["items"]: + item_id = item["id"] + price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + else: + assert response_json["price"] == 0.0 + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000.0}, HTTPStatus.OK), + ({"max_price": 20.0}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query: dict[str, Any], status_code: int): + response = client.get("/cart", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + quantity = sum(item["quantity"] for cart in data for item in cart["items"]) + + if "min_quantity" in query: + assert quantity >= query["min_quantity"] + + if "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_post_item() -> None: + item = {"name": "test item", "price": 9.99} + response = client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +def test_get_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_item_list(query: dict[str, Any], status_code: int) -> None: + response = client.get("/item", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + if "show_deleted" in query and query["show_deleted"] is False: + assert all(item["deleted"] is False for item in data) + + +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +def test_put_item( + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = client.put(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + new_item = existing_item.copy() + new_item.update(body) + assert response.json() == new_item + + +@pytest.mark.parametrize( + ("item", "body", "status_code"), + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing_item", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing_item", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: + item_data: dict[str, Any] = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + patch_response_body = response.json() + + response = client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +def test_delete_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK \ No newline at end of file From ebd08e1a442ab509b4581f5aa2d705ca36c799c6 Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 26 Oct 2025 15:36:48 +0300 Subject: [PATCH 07/14] fixed main.yml --- .github/workflows/main.yml | 3 + hw2/hw/tests/test_shop_api.py | 584 +++++++++++++++++++--------------- 2 files changed, 330 insertions(+), 257 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ccf73fbf..bd4c1441 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,6 +12,9 @@ on: jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] services: postgres: diff --git a/hw2/hw/tests/test_shop_api.py b/hw2/hw/tests/test_shop_api.py index 6d1e5731..8f5c0f3c 100644 --- a/hw2/hw/tests/test_shop_api.py +++ b/hw2/hw/tests/test_shop_api.py @@ -12,273 +12,343 @@ faker = Faker() -@pytest.fixture() -def existing_empty_cart_id() -> int: - return client.post("/cart").json()["id"] - - -@pytest.fixture(scope="session") -def existing_items() -> list[int]: - items = [ - { - "name": f"Тестовый товар {i}", - "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), - } - for i in range(10) - ] - - return [client.post("/item", json=item).json()["id"] for item in items] - - -@pytest.fixture(scope="session", autouse=True) -def existing_not_empty_carts(existing_items: list[int]) -> list[int]: - carts = [] - - for i in range(20): - cart_id: int = client.post("/cart").json()["id"] - for item_id in faker.random_elements(existing_items, unique=False, length=i): - client.post(f"/cart/{cart_id}/add/{item_id}") - - carts.append(cart_id) - - return carts - - -@pytest.fixture() -def existing_not_empty_cart_id( - existing_empty_cart_id: int, - existing_items: list[int], -) -> int: - for item_id in faker.random_elements(existing_items, unique=False, length=3): - client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") - - return existing_empty_cart_id - - -@pytest.fixture() -def existing_item() -> dict[str, Any]: - return client.post( - "/item", - json={ - "name": f"Тестовый товар {uuid4().hex}", - "price": faker.pyfloat(min_value=10.0, max_value=100.0), - }, - ).json() - - -@pytest.fixture() -def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: - item_id = existing_item["id"] - client.delete(f"/item/{item_id}") - - existing_item["deleted"] = True - return existing_item - - -def test_post_cart() -> None: - response = client.post("/cart") - - assert response.status_code == HTTPStatus.CREATED - assert "location" in response.headers - assert "id" in response.json() - - -@pytest.mark.parametrize( - ("cart", "not_empty"), - [ - ("existing_empty_cart_id", False), - ("existing_not_empty_cart_id", True), - ], -) -def test_get_cart(request, cart: int, not_empty: bool) -> None: - cart_id = request.getfixturevalue(cart) - - response = client.get(f"/cart/{cart_id}") - - assert response.status_code == HTTPStatus.OK - response_json = response.json() - - len_items = len(response_json["items"]) - assert len_items > 0 if not_empty else len_items == 0 - - if not_empty: - price = 0 - - for item in response_json["items"]: - item_id = item["id"] - price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] - - assert response_json["price"] == pytest.approx(price, 1e-8) - else: - assert response_json["price"] == 0.0 - - -@pytest.mark.parametrize( - ("query", "status_code"), - [ +class TestCartEndpoints: + """Тесты для эндпоинтов корзин""" + + @pytest.fixture() + def empty_cart(self) -> dict[str, Any]: + """Создает пустую корзину""" + response = client.post("/cart") + assert response.status_code == HTTPStatus.CREATED + return response.json() + + @pytest.fixture() + def cart_with_items(self, empty_cart: dict[str, Any], sample_items: list[dict]) -> dict[str, Any]: + """Создает корзину с товарами""" + cart_id = empty_cart["id"] + # Добавляем 3 случайных товара + for item in sample_items[:3]: + client.post(f"/cart/{cart_id}/add/{item['id']}") + return empty_cart + + @pytest.fixture(scope="session") + def sample_items(self) -> list[dict]: + """Создает тестовые товары""" + items = [] + for i in range(5): + item_data = { + "name": f"Тестовый товар {i+1}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=100.0), + } + response = client.post("/item", json=item_data) + assert response.status_code == HTTPStatus.CREATED + items.append(response.json()) + return items + + def test_create_cart(self): + """Тест создания корзины""" + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + data = response.json() + assert "id" in data + assert isinstance(data["id"], int) + + @pytest.mark.parametrize("cart_fixture, expected_items_count", [ + ("empty_cart", 0), + ("cart_with_items", 3), + ]) + def test_get_cart(self, request, cart_fixture: str, expected_items_count: int): + """Тест получения корзины""" + cart = request.getfixturevalue(cart_fixture) + cart_id = cart["id"] + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data["items"]) == expected_items_count + assert data["price"] >= 0 # Цена не может быть отрицательной + + # Проверяем расчет общей стоимости + if expected_items_count > 0: + total_price = sum( + item["price"] * item["quantity"] + for item in data["items"] + ) + assert data["price"] == pytest.approx(total_price, 1e-8) + else: + assert data["price"] == 0.0 + + def test_get_nonexistent_cart(self): + """Тест получения несуществующей корзины""" + response = client.get(f"/cart/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.parametrize("params, expected_status", [ + # Валидные параметры ({}, HTTPStatus.OK), - ({"offset": 1, "limit": 2}, HTTPStatus.OK), - ({"min_price": 1000.0}, HTTPStatus.OK), - ({"max_price": 20.0}, HTTPStatus.OK), + ({"offset": 0, "limit": 10}, HTTPStatus.OK), + ({"min_price": 50.0}, HTTPStatus.OK), + ({"max_price": 100.0}, HTTPStatus.OK), ({"min_quantity": 1}, HTTPStatus.OK), - ({"max_quantity": 0}, HTTPStatus.OK), + ({"max_quantity": 5}, HTTPStatus.OK), + + # Невалидные параметры ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), - ], -) -def test_get_cart_list(query: dict[str, Any], status_code: int): - response = client.get("/cart", params=query) - - assert response.status_code == status_code - - if status_code == HTTPStatus.OK: + ]) + def test_get_carts_list(self, params: dict[str, Any], expected_status: int): + """Тест получения списка корзин с фильтрацией""" + response = client.get("/cart", params=params) + + assert response.status_code == expected_status + + if expected_status == HTTPStatus.OK: + data = response.json() + assert isinstance(data, list) + + # Проверяем фильтры + if "min_price" in params: + assert all(cart["price"] >= params["min_price"] for cart in data) + if "max_price" in params: + assert all(cart["price"] <= params["max_price"] for cart in data) + + +class TestItemEndpoints: + """Тесты для эндпоинтов товаров""" + + @pytest.fixture() + def sample_item(self) -> dict[str, Any]: + """Создает тестовый товар""" + item_data = { + "name": f"Тестовый товар {uuid4().hex[:8]}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + } + response = client.post("/item", json=item_data) + assert response.status_code == HTTPStatus.CREATED + return response.json() + + @pytest.fixture() + def deleted_item(self, sample_item: dict[str, Any]) -> dict[str, Any]: + """Создает удаленный товар""" + item_id = sample_item["id"] + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + sample_item["deleted"] = True + return sample_item + + def test_create_item(self): + """Тест создания товара""" + item_data = { + "name": "Новый товар", + "price": 29.99 + } + + response = client.post("/item", json=item_data) + + assert response.status_code == HTTPStatus.CREATED data = response.json() + assert data["name"] == item_data["name"] + assert data["price"] == item_data["price"] + assert "id" in data + assert data["deleted"] is False + + def test_create_item_invalid_data(self): + """Тест создания товара с невалидными данными""" + invalid_items = [ + {"name": "Только имя"}, # Нет цены + {"price": 10.0}, # Нет имени + {"name": "", "price": 10.0}, # Пустое имя + {"name": "Товар", "price": -10.0}, # Отрицательная цена + ] + + for invalid_item in invalid_items: + response = client.post("/item", json=invalid_item) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + def test_get_item(self, sample_item: dict[str, Any]): + """Тест получения товара""" + item_id = sample_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == sample_item + + def test_get_nonexistent_item(self): + """Тест получения несуществующего товара""" + response = client.get(f"/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_get_deleted_item(self, deleted_item: dict[str, Any]): + """Тест получения удаленного товара""" + item_id = deleted_item["id"] + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND - assert isinstance(data, list) - - if "min_price" in query: - assert all(item["price"] >= query["min_price"] for item in data) - - if "max_price" in query: - assert all(item["price"] <= query["max_price"] for item in data) - - quantity = sum(item["quantity"] for cart in data for item in cart["items"]) - - if "min_quantity" in query: - assert quantity >= query["min_quantity"] - - if "max_quantity" in query: - assert quantity <= query["max_quantity"] - - -def test_post_item() -> None: - item = {"name": "test item", "price": 9.99} - response = client.post("/item", json=item) - - assert response.status_code == HTTPStatus.CREATED - - data = response.json() - assert item["price"] == data["price"] - assert item["name"] == data["name"] - - -def test_get_item(existing_item: dict[str, Any]) -> None: - item_id = existing_item["id"] - - response = client.get(f"/item/{item_id}") - - assert response.status_code == HTTPStatus.OK - assert response.json() == existing_item - - -@pytest.mark.parametrize( - ("query", "status_code"), - [ - ({"offset": 2, "limit": 5}, HTTPStatus.OK), - ({"min_price": 5.0}, HTTPStatus.OK), - ({"max_price": 5.0}, HTTPStatus.OK), + @pytest.mark.parametrize("params, expected_status", [ + # Валидные параметры + ({"offset": 0, "limit": 5}, HTTPStatus.OK), + ({"min_price": 20.0}, HTTPStatus.OK), + ({"max_price": 50.0}, HTTPStatus.OK), ({"show_deleted": True}, HTTPStatus.OK), + ({"show_deleted": False}, HTTPStatus.OK), + + # Невалидные параметры ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), - ], -) -def test_get_item_list(query: dict[str, Any], status_code: int) -> None: - response = client.get("/item", params=query) - - assert response.status_code == status_code - - if status_code == HTTPStatus.OK: - data = response.json() - - assert isinstance(data, list) - - if "min_price" in query: - assert all(item["price"] >= query["min_price"] for item in data) - - if "max_price" in query: - assert all(item["price"] <= query["max_price"] for item in data) - - if "show_deleted" in query and query["show_deleted"] is False: - assert all(item["deleted"] is False for item in data) - - -@pytest.mark.parametrize( - ("body", "status_code"), - [ - ({}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"name": "new name", "price": 9.99}, HTTPStatus.OK), - ], -) -def test_put_item( - existing_item: dict[str, Any], - body: dict[str, Any], - status_code: int, -) -> None: - item_id = existing_item["id"] - response = client.put(f"/item/{item_id}", json=body) - - assert response.status_code == status_code - - if status_code == HTTPStatus.OK: - new_item = existing_item.copy() - new_item.update(body) - assert response.json() == new_item - - -@pytest.mark.parametrize( - ("item", "body", "status_code"), - [ - ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), - ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), - ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), - ("existing_item", {}, HTTPStatus.OK), - ("existing_item", {"price": 9.99}, HTTPStatus.OK), - ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), - ( - "existing_item", - {"name": "new name", "price": 9.99, "odd": "value"}, - HTTPStatus.UNPROCESSABLE_ENTITY, - ), - ( - "existing_item", - {"name": "new name", "price": 9.99, "deleted": True}, - HTTPStatus.UNPROCESSABLE_ENTITY, - ), - ], -) -def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: - item_data: dict[str, Any] = request.getfixturevalue(item) - item_id = item_data["id"] - response = client.patch(f"/item/{item_id}", json=body) - - assert response.status_code == status_code - - if status_code == HTTPStatus.OK: - patch_response_body = response.json() - + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ]) + def test_get_items_list(self, params: dict[str, Any], expected_status: int): + """Тест получения списка товаров с фильтрацией""" + response = client.get("/item", params=params) + + assert response.status_code == expected_status + + if expected_status == HTTPStatus.OK: + data = response.json() + assert isinstance(data, list) + + # Проверяем фильтры + if "min_price" in params: + assert all(item["price"] >= params["min_price"] for item in data) + if "max_price" in params: + assert all(item["price"] <= params["max_price"] for item in data) + if params.get("show_deleted") is False: + assert all(item.get("deleted", False) is False for item in data) + + @pytest.mark.parametrize("update_data, expected_status", [ + ({"name": "Новое имя", "price": 99.99}, HTTPStatus.OK), + ({"price": 15.50}, HTTPStatus.UNPROCESSABLE_ENTITY), # Нет имени + ({"name": "Только имя"}, HTTPStatus.UNPROCESSABLE_ENTITY), # Нет цены + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), # Пустые данные + ]) + def test_full_update_item( + self, + sample_item: dict[str, Any], + update_data: dict[str, Any], + expected_status: int + ): + """Тест полного обновления товара (PUT)""" + item_id = sample_item["id"] + + response = client.put(f"/item/{item_id}", json=update_data) + + assert response.status_code == expected_status + + if expected_status == HTTPStatus.OK: + updated_item = response.json() + expected_item = sample_item.copy() + expected_item.update(update_data) + assert updated_item == expected_item + + def test_full_update_deleted_item(self, deleted_item: dict[str, Any]): + """Тест полного обновления удаленного товара""" + item_id = deleted_item["id"] + + response = client.put( + f"/item/{item_id}", + json={"name": "Новое имя", "price": 99.99} + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.parametrize("item_fixture, patch_data, expected_status", [ + # Обновление существующего товара + ("sample_item", {"name": "Частично обновлен"}, HTTPStatus.OK), + ("sample_item", {"price": 77.77}, HTTPStatus.OK), + ("sample_item", {"name": "Полное обновление", "price": 88.88}, HTTPStatus.OK), + + # Попытка обновления удаленного товара + ("deleted_item", {"name": "Новое имя"}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 99.99}, HTTPStatus.NOT_MODIFIED), + + # Невалидные данные + ("sample_item", {"invalid_field": "value"}, HTTPStatus.UNPROCESSABLE_ENTITY), + ("sample_item", {"deleted": True}, HTTPStatus.UNPROCESSABLE_ENTITY), + ]) + def test_partial_update_item( + self, + request, + item_fixture: str, + patch_data: dict[str, Any], + expected_status: int + ): + """Тест частичного обновления товара (PATCH)""" + item_data: dict[str, Any] = request.getfixturevalue(item_fixture) + item_id = item_data["id"] + + response = client.patch(f"/item/{item_id}", json=patch_data) + + assert response.status_code == expected_status + + if expected_status == HTTPStatus.OK: + # Проверяем, что изменения применились + updated_response = client.get(f"/item/{item_id}") + assert updated_response.status_code == HTTPStatus.OK + updated_item = updated_response.json() + + # Проверяем обновленные поля + for key, value in patch_data.items(): + assert updated_item[key] == value + + def test_delete_item(self, sample_item: dict[str, Any]): + """Тест удаления товара""" + item_id = sample_item["id"] + + # Первое удаление + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + # Проверяем, что товар не доступен response = client.get(f"/item/{item_id}") - patched_item = response.json() - - assert patched_item == patch_response_body - - -def test_delete_item(existing_item: dict[str, Any]) -> None: - item_id = existing_item["id"] - - response = client.delete(f"/item/{item_id}") - assert response.status_code == HTTPStatus.OK - - response = client.get(f"/item/{item_id}") - assert response.status_code == HTTPStatus.NOT_FOUND - - response = client.delete(f"/item/{item_id}") - assert response.status_code == HTTPStatus.OK \ No newline at end of file + assert response.status_code == HTTPStatus.NOT_FOUND + + # Повторное удаление (идемпотентность) + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + def test_delete_nonexistent_item(self): + """Тест удаления несуществующего товара""" + response = client.delete(f"/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestCartItemOperations: + """Тесты операций с товарами в корзинах""" + + def test_add_item_to_cart(self, empty_cart: dict[str, Any], sample_item: dict[str, Any]): + """Тест добавления товара в корзину""" + cart_id = empty_cart["id"] + item_id = sample_item["id"] + + response = client.post(f"/cart/{cart_id}/add/{item_id}") + + assert response.status_code == HTTPStatus.OK + + # Проверяем, что товар добавился + cart_response = client.get(f"/cart/{cart_id}") + cart_data = cart_response.json() + + assert any(item["id"] == item_id for item in cart_data["items"]) + assert cart_data["price"] == sample_item["price"] # Один товар + + def test_add_nonexistent_item_to_cart(self, empty_cart: dict[str, Any]): + """Тест добавления несуществующего товара в корзину""" + cart_id = empty_cart["id"] + + response = client.post(f"/cart/{cart_id}/add/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_add_item_to_nonexistent_cart(self, sample_item: dict[str, Any]): + """Тест добавления товара в несуществующую корзину""" + item_id = sample_item["id"] + + response = client.post(f"/cart/999999/add/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND \ No newline at end of file From 36dcdc09a29733f86491d09a17d3d0c7522cc487 Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 26 Oct 2025 15:39:29 +0300 Subject: [PATCH 08/14] fix main.yml again --- .github/workflows/main.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bd4c1441..b7a88c6c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,7 +10,7 @@ on: paths: [ 'hw2/hw/**' ] jobs: - test: + test-hw5: runs-on: ubuntu-latest strategy: matrix: @@ -33,7 +33,7 @@ jobs: steps: - name: Checkout code - - uses: actions/checkout@v4 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 @@ -41,9 +41,10 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies + working-directory: hw2/hw run: | python -m pip install --upgrade pip - pip install -r hw2/hw/requirements.txt + pip install -r requirements.txt - name: Run tests with coverage working-directory: hw2/hw From 8f69a5b9d7aa8ecdf17b89b656ca3b1ebf5a069d Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 26 Oct 2025 15:40:54 +0300 Subject: [PATCH 09/14] removed get_database_url_unnecessary logic --- hw2/hw/shop_api/store.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/hw2/hw/shop_api/store.py b/hw2/hw/shop_api/store.py index 3d57710b..2a0bf388 100644 --- a/hw2/hw/shop_api/store.py +++ b/hw2/hw/shop_api/store.py @@ -24,11 +24,6 @@ def get_database_url(): default_url = "postgresql://shop_user:shop_password@postgres:5432/shop_db" url = os.getenv('DATABASE_URL', default_url) - if isinstance(url, bytes): - url = url.decode('latin-1') - - url = url.strip() - return url class DatabaseStore: From 5f7b4340f59c8abd9730f0fc2f101a537272e194 Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 26 Oct 2025 15:51:51 +0300 Subject: [PATCH 10/14] entities fix --- hw2/hw/shop_api/entities.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/hw2/hw/shop_api/entities.py b/hw2/hw/shop_api/entities.py index 8229ba17..8ca5eb8c 100644 --- a/hw2/hw/shop_api/entities.py +++ b/hw2/hw/shop_api/entities.py @@ -1,8 +1,17 @@ -from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, Numeric +from sqlalchemy import Column, Integer, String, Boolean, Numeric, ForeignKey, Table from sqlalchemy.orm import declarative_base +from sqlalchemy.orm import relationship base = declarative_base() +cart_items = Table( + 'cart_items', + base.metadata, + Column('cart_id', Integer, ForeignKey('carts.id'), primary_key=True), + Column('item_id', Integer, ForeignKey('items.id'), primary_key=True), + Column('quantity', Integer, nullable=False, default=1) +) + class Item(base): __tablename__ = "items" @@ -10,15 +19,24 @@ class Item(base): name = Column(String, nullable=False) price = Column(Numeric(10, 2), nullable=False) deleted = Column(Boolean, default=False) + + items = relationship("Item", secondary=cart_items, back_populates="carts") + carts = relationship("Cart", secondary=cart_items, back_populates="items") class Cart(base): __tablename__ = "carts" id = Column(Integer, primary_key=True, index=True) + + cart_item_associations = relationship("CartItemAssociation", back_populates="cart") class CartItemAssociation(base): __tablename__ = "cart_items" + __table_args__ = {'extend_existing': True} cart_id = Column(Integer, ForeignKey('carts.id'), primary_key=True) item_id = Column(Integer, ForeignKey('items.id'), primary_key=True) quantity = Column(Integer, nullable=False, default=1) + + cart = relationship("Cart", back_populates="cart_item_associations") + item = relationship("Item", back_populates="cart_associations") From 3844519b91248a1141fed1bbb5166e0719201e16 Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 26 Oct 2025 16:00:06 +0300 Subject: [PATCH 11/14] removed table from entities --- hw2/hw/shop_api/entities.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/hw2/hw/shop_api/entities.py b/hw2/hw/shop_api/entities.py index 8ca5eb8c..831ff2e6 100644 --- a/hw2/hw/shop_api/entities.py +++ b/hw2/hw/shop_api/entities.py @@ -1,17 +1,9 @@ -from sqlalchemy import Column, Integer, String, Boolean, Numeric, ForeignKey, Table +from sqlalchemy import Column, Integer, String, Boolean, Numeric, ForeignKey from sqlalchemy.orm import declarative_base from sqlalchemy.orm import relationship base = declarative_base() -cart_items = Table( - 'cart_items', - base.metadata, - Column('cart_id', Integer, ForeignKey('carts.id'), primary_key=True), - Column('item_id', Integer, ForeignKey('items.id'), primary_key=True), - Column('quantity', Integer, nullable=False, default=1) -) - class Item(base): __tablename__ = "items" @@ -20,8 +12,8 @@ class Item(base): price = Column(Numeric(10, 2), nullable=False) deleted = Column(Boolean, default=False) - items = relationship("Item", secondary=cart_items, back_populates="carts") - carts = relationship("Cart", secondary=cart_items, back_populates="items") + items = relationship("Item", back_populates="carts") + carts = relationship("Cart", back_populates="items") class Cart(base): __tablename__ = "carts" From 99ef11f2d62dc0789ef770a93a0259a6da44378e Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 26 Oct 2025 16:16:23 +0300 Subject: [PATCH 12/14] removed 3.13 python --- .github/workflows/main.yml | 2 +- hw2/hw/tests/test_shop_api.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b7a88c6c..d3be015a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12", "3.13"] + python-version: ["3.12"] services: postgres: diff --git a/hw2/hw/tests/test_shop_api.py b/hw2/hw/tests/test_shop_api.py index 8f5c0f3c..3712a3c8 100644 --- a/hw2/hw/tests/test_shop_api.py +++ b/hw2/hw/tests/test_shop_api.py @@ -55,6 +55,7 @@ def test_create_cart(self): assert "id" in data assert isinstance(data["id"], int) + @pytest.mark.parametrize("cart_fixture, expected_items_count", [ ("empty_cart", 0), ("cart_with_items", 3), From 18be252fdbaca4acd442a4e3da0f7e30574a6ad2 Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 26 Oct 2025 16:55:33 +0300 Subject: [PATCH 13/14] removed associations from code --- .github/workflows/{main.yml => hw5-tests.yml} | 0 hw2/hw/shop_api/entities.py | 13 ++------ hw2/hw/shop_api/store.py | 31 +++++++++---------- 3 files changed, 17 insertions(+), 27 deletions(-) rename .github/workflows/{main.yml => hw5-tests.yml} (100%) diff --git a/.github/workflows/main.yml b/.github/workflows/hw5-tests.yml similarity index 100% rename from .github/workflows/main.yml rename to .github/workflows/hw5-tests.yml diff --git a/hw2/hw/shop_api/entities.py b/hw2/hw/shop_api/entities.py index 831ff2e6..c6902e40 100644 --- a/hw2/hw/shop_api/entities.py +++ b/hw2/hw/shop_api/entities.py @@ -1,6 +1,5 @@ from sqlalchemy import Column, Integer, String, Boolean, Numeric, ForeignKey from sqlalchemy.orm import declarative_base -from sqlalchemy.orm import relationship base = declarative_base() @@ -11,24 +10,16 @@ class Item(base): name = Column(String, nullable=False) price = Column(Numeric(10, 2), nullable=False) deleted = Column(Boolean, default=False) - - items = relationship("Item", back_populates="carts") - carts = relationship("Cart", back_populates="items") class Cart(base): __tablename__ = "carts" id = Column(Integer, primary_key=True, index=True) - - cart_item_associations = relationship("CartItemAssociation", back_populates="cart") + class CartItemAssociation(base): __tablename__ = "cart_items" - __table_args__ = {'extend_existing': True} cart_id = Column(Integer, ForeignKey('carts.id'), primary_key=True) item_id = Column(Integer, ForeignKey('items.id'), primary_key=True) - quantity = Column(Integer, nullable=False, default=1) - - cart = relationship("Cart", back_populates="cart_item_associations") - item = relationship("Item", back_populates="cart_associations") + quantity = Column(Integer, nullable=False, default=1) \ No newline at end of file diff --git a/hw2/hw/shop_api/store.py b/hw2/hw/shop_api/store.py index 2a0bf388..ddfab20c 100644 --- a/hw2/hw/shop_api/store.py +++ b/hw2/hw/shop_api/store.py @@ -95,29 +95,28 @@ def get_cart(self, id: int) -> CartEntity | None: if not cart: return None + cart_items_result = session.execute( + select(CartItemAssociation).where(CartItemAssociation.cart_id == id) + ).scalars().all() + cart_items = [] - total_price = 0 - - for association in cart.cart_item_associations: - item = association.item - if not item.deleted: - cart_item_entity = CartItemEntity( - id=item.id, - info=CartItemInfo( - name=item.name, - quantity=association.quantity, - available=not item.deleted - ) + for cart_item_db in cart_items_result: + cart_item_entity = CartItemEntity( + id=cart_item_db.item_id, + info=CartItemInfo( + name=cart_item_db.item_name, + quantity=cart_item_db.quantity, + available=cart_item_db.available ) - cart_items.append(cart_item_entity) - total_price += item.price * association.quantity + ) + cart_items.append(cart_item_entity) cart_info = CartInfo( items=cart_items, - price=total_price + price=float(cart.total_price) ) - return CartEntity(id=cart.id, info=cart_info) + return CartEntity(id=int(cart.id), info=cart_info) def get_all_carts(self) -> List[CartEntity]: with self.get_session() as session: From dc042fb5f21d7d2df38de9aab85687f3ae7df5f9 Mon Sep 17 00:00:00 2001 From: SirDratuti Date: Sun, 26 Oct 2025 16:55:33 +0300 Subject: [PATCH 14/14] fix store and up tests coverage to 96% --- .github/workflows/{main.yml => hw5-tests.yml} | 2 +- hw2/hw/migrations/init.sql | 7 +- hw2/hw/shop_api/entities.py | 33 +- hw2/hw/shop_api/models.py | 1 - hw2/hw/shop_api/patch_result.py | 7 + hw2/hw/shop_api/routes.py | 50 ++- hw2/hw/shop_api/store.py | 318 ++++++++-------- hw2/hw/test_homework2.py | 27 +- hw2/hw/tests/__init__.py | 0 hw2/hw/tests/test_shop_api.py | 355 ------------------ 10 files changed, 223 insertions(+), 577 deletions(-) rename .github/workflows/{main.yml => hw5-tests.yml} (94%) create mode 100644 hw2/hw/shop_api/patch_result.py delete mode 100644 hw2/hw/tests/__init__.py delete mode 100644 hw2/hw/tests/test_shop_api.py diff --git a/.github/workflows/main.yml b/.github/workflows/hw5-tests.yml similarity index 94% rename from .github/workflows/main.yml rename to .github/workflows/hw5-tests.yml index d3be015a..0904c343 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/hw5-tests.yml @@ -52,4 +52,4 @@ jobs: DATABASE_URL: postgresql://shop_user:shop_password@localhost:5432/shop_db PYTHONPATH: ${{ github.workspace }}/hw2/hw run: | - pytest tests/test_shop_api.py --cov=shop_api --cov-report=term -v + pytest test_homework2.py --cov=shop_api --cov-report=term -v diff --git a/hw2/hw/migrations/init.sql b/hw2/hw/migrations/init.sql index 958c2d37..507f5b76 100644 --- a/hw2/hw/migrations/init.sql +++ b/hw2/hw/migrations/init.sql @@ -15,8 +15,7 @@ CREATE TABLE carts ( CREATE TABLE cart_items ( id SERIAL PRIMARY KEY, - cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE, - item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, - quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), - UNIQUE(cart_id, item_id) + cart_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0) ); diff --git a/hw2/hw/shop_api/entities.py b/hw2/hw/shop_api/entities.py index 831ff2e6..17b5044a 100644 --- a/hw2/hw/shop_api/entities.py +++ b/hw2/hw/shop_api/entities.py @@ -1,34 +1,27 @@ -from sqlalchemy import Column, Integer, String, Boolean, Numeric, ForeignKey +from sqlalchemy import Column, Integer, String, Float, Boolean from sqlalchemy.orm import declarative_base -from sqlalchemy.orm import relationship -base = declarative_base() +Base = declarative_base() -class Item(base): +class Item(Base): __tablename__ = "items" id = Column(Integer, primary_key=True, index=True) - name = Column(String, nullable=False) - price = Column(Numeric(10, 2), nullable=False) + name = Column(String(255), nullable=False) + price = Column(Float, nullable=False) deleted = Column(Boolean, default=False) - - items = relationship("Item", back_populates="carts") - carts = relationship("Cart", back_populates="items") -class Cart(base): + +class Cart(Base): __tablename__ = "carts" id = Column(Integer, primary_key=True, index=True) - - cart_item_associations = relationship("CartItemAssociation", back_populates="cart") -class CartItemAssociation(base): + +class CartItem(Base): __tablename__ = "cart_items" - __table_args__ = {'extend_existing': True} - - cart_id = Column(Integer, ForeignKey('carts.id'), primary_key=True) - item_id = Column(Integer, ForeignKey('items.id'), primary_key=True) - quantity = Column(Integer, nullable=False, default=1) - cart = relationship("Cart", back_populates="cart_item_associations") - item = relationship("Item", back_populates="cart_associations") + 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/models.py b/hw2/hw/shop_api/models.py index 787b4aa0..1e61f410 100644 --- a/hw2/hw/shop_api/models.py +++ b/hw2/hw/shop_api/models.py @@ -1,5 +1,4 @@ from dataclasses import dataclass - from typing import List, Optional @dataclass(slots = True) 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 index fcddf0c9..dc4c374c 100644 --- a/hw2/hw/shop_api/routes.py +++ b/hw2/hw/shop_api/routes.py @@ -5,6 +5,7 @@ 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, @@ -17,11 +18,9 @@ cartRouter = APIRouter(prefix="/cart") itemRouter = APIRouter(prefix="/item") -databaseStore = store.DatabaseStore() - @cartRouter.post("/", status_code=HTTPStatus.CREATED) async def create_cart(response: Response): - id = databaseStore.create_cart() + 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}" @@ -41,7 +40,7 @@ async def create_cart(response: Response): }, ) async def get_cart(id: int) -> CartResponse: - cart = databaseStore.get_cart(id) + cart = store.store.get_cart(id) if cart is None: raise HTTPException(status_code=404, detail="Cart not found") return CartResponse.from_entity(cart) @@ -56,30 +55,25 @@ async def get_carts( min_quantity: Optional[int] = Query(None, ge=0), max_quantity: Optional[int] = Query(None, ge=0) ) -> list[CartResponse]: - carts = databaseStore.get_all_carts() - - if min_price is not None: - carts = [cart for cart in carts if cart.info.price >= min_price] - - if max_price is not None: - carts = [cart for cart in carts if cart.info.price <= max_price] - - if min_quantity is not None: - carts = [cart for cart in carts if cart.info.price >= min_quantity] - - if max_quantity is not None: - carts = [cart for cart in carts if cart.info.price <= max_quantity] + 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[offset:offset + limit]] + 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 = databaseStore.get_item(item_id) + item = store.store.get_item(item_id) if item is None: raise HTTPException(status_code=404, detail="Item not found") - success = databaseStore.add_item_to_cart(cart_id, item) + success = store.store.add_item_to_cart(cart_id, item) if not success: raise HTTPException(status_code=404, detail="Cart not found") @@ -91,7 +85,7 @@ def add_item_to_cart(cart_id: int, item_id: int): status_code=HTTPStatus.CREATED, ) def post_item(info: ItemRequest, response: Response) -> ItemResponse: - entity = databaseStore.add_item(info.as_item_info()) + 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}" @@ -111,7 +105,7 @@ def post_item(info: ItemRequest, response: Response) -> ItemResponse: }, ) async def get_item(item_id: int) -> ItemResponse: - item = databaseStore.get_item(item_id) + 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) @@ -125,7 +119,7 @@ async def get_items( max_price: Optional[float] = Query(None, ge=0), show_deleted: Optional[bool] = Query(False), ) -> list[ItemResponse]: - items = databaseStore.get_all_items() + items = store.store.get_all_items() if not show_deleted: items = [item for item in items if not item.info.deleted] @@ -142,7 +136,7 @@ async def get_items( @itemRouter.put("/{item_id}") def update_item(item_id: int, request: PutItemRequest) -> ItemResponse: - item = databaseStore.put_item(item_id=item_id, request=request) + 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) @@ -163,15 +157,15 @@ def update_item(item_id: int, request: PutItemRequest) -> ItemResponse: } ) async def patch_item(item_id: int, info: PatchItemRequest): - entity = databaseStore.patch_item(item_id, info.as_patch_item_info()) + 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 databaseStore.PatchResult.NotFound: + case PatchResult.NotFound: raise HTTPException(status_code=404, detail="Item not found") - case databaseStore.PatchResult.NotModified: + case PatchResult.NotModified: return Response(status_code=304) case _: raise HTTPException(status_code=422, detail="Incorrect price") @@ -179,5 +173,5 @@ async def patch_item(item_id: int, info: PatchItemRequest): @itemRouter.delete("/{item_id}") async def delete_item(item_id: int): - databaseStore.delete_item(item_id) + 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 index 2a0bf388..c34a43fa 100644 --- a/hw2/hw/shop_api/store.py +++ b/hw2/hw/shop_api/store.py @@ -1,11 +1,10 @@ -from typing import List - -from enum import Enum -from sqlalchemy import create_engine, select, delete, and_ +from typing import List, Optional +from sqlalchemy import create_engine, select, delete from sqlalchemy.orm import sessionmaker from contextlib import contextmanager -from hw2.hw.shop_api.entities import Cart, Item, CartItemAssociation, base +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 ( @@ -20,21 +19,16 @@ import os -def get_database_url(): - default_url = "postgresql://shop_user:shop_password@postgres:5432/shop_db" - url = os.getenv('DATABASE_URL', default_url) - - return url - class DatabaseStore: - - def __init__(self, database_url: str = get_database_url()): + 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 + Base.metadata.create_all(bind=self.engine) + + @contextmanager def get_session(self): session = self.SessionLocal() try: @@ -45,207 +39,199 @@ def get_session(self): raise finally: session.close() - + def create_cart(self) -> int: with self.get_session() as session: - cart = Cart() - session.add(cart) + cart_orm = Cart() + session.add(cart_orm) session.flush() - cart_id = cart.id - return cart_id - + return cart_orm.id + def add_item(self, info: ItemInfo) -> ItemEntity: - with self.get_session() as session: + with self.get_session() as session: item = Item( - name=info.name, - price=info.price, - deleted=info.deleted + 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=item.name, + name=str(item.name), price=float(item.price), - deleted=item.deleted + deleted=bool(item.deleted) ) ) - - def delete_cart(self, id: int) -> None: - with self.get_session() as session: - cart = session.get(Cart, id) - if cart: - session.execute( - delete(CartItemAssociation) - .where(CartItemAssociation.cart_id == id) - ) - session.delete(cart) - - def delete_item(self, id: int) -> None: - with self.get_session() as session: - item = session.get(Item, id) - if item: - item.deleted = True - - def get_cart(self, id: int) -> CartEntity | None: + + def get_cart(self, id: int) -> Optional[CartEntity]: with self.get_session() as session: - cart = session.get(Cart, id) - if not cart: + cart_orm =session.query(Cart).filter(Cart.id == id).first() + if not cart_orm: return None - - cart_items = [] - total_price = 0 - - for association in cart.cart_item_associations: - item = association.item - if not item.deleted: - cart_item_entity = CartItemEntity( - id=item.id, - info=CartItemInfo( - name=item.name, - quantity=association.quantity, - available=not item.deleted + + 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 + ) ) ) - cart_items.append(cart_item_entity) - total_price += item.price * association.quantity - - cart_info = CartInfo( - items=cart_items, - price=total_price + 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 + ) ) - - return CartEntity(id=cart.id, info=cart_info) - - def get_all_carts(self) -> List[CartEntity]: + + 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: - carts = session.execute(select(Cart)).scalars().all() - result = [] - - for cart in carts: - cart_entity = self.get_cart(cart.id) - if cart_entity: - result.append(cart_entity) - - return result - - def get_item(self, id: int) -> ItemEntity | None: + 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: + if not item or item.deleted: return None return ItemEntity( - id=item.id, + id=int(item.id), info=ItemInfo( - name=item.name, - price=item.price, - deleted=item.deleted + 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=item.id, + id=int(item.id), info=ItemInfo( - name=item.name, - price=item.price, - deleted=item.deleted + 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 = session.get(Cart, cart_id) - if not cart: + cart_orm = session.query(Cart).filter(Cart.id == cart_id).first() + if not cart_orm: return False - - db_item = session.get(Item, item.id) - if not db_item or db_item.deleted: + + item_orm = session.query(Item).filter(Item.id == item.id).first() + if not item_orm: return False - - existing_association = session.execute( - select(CartItemAssociation) - .where( - and_( - CartItemAssociation.cart_id == cart_id, - CartItemAssociation.item_id == item.id - ) - ) - ).scalar_one_or_none() - - if existing_association: - existing_association.quantity += 1 + + 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: - association = CartItemAssociation( - cart_id=cart_id, - item_id=item.id, - quantity=1 - ) - session.add(association) - - return True + cart_item = CartItem(cart_id=cart_id, item_id=item.id, quantity=1) + session.add(cart_item) - def put_item(self, item_id: int, request: PutItemRequest) -> ItemEntity | None: + return True + + def put_item(self, item_id: int, request: PutItemRequest) -> Optional[ItemEntity]: with self.get_session() as session: - item = session.get(Item, item_id) - if not item or item.deleted: + item_orm = session.query(Item).filter(Item.id == item_id).first() + if not item_orm: return None - - item.name = request.name - item.price = request.price - + item_orm.name = request.name + item_orm.price = request.price + session.commit() + session.refresh(item_orm) return ItemEntity( - id=item.id, - info=ItemInfo( - name=item.name, - price=item.price, - deleted=item.deleted + 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 - class PatchResult(Enum): - NotFound = 0 - NotModified = 1 - Unprocessable = 2 + if item_orm.deleted: + return PatchResult.NotModified - def patch_item(self, item_id: int, patch_info: PatchItemInfo) -> ItemEntity | PatchResult: - with self.get_session() as session: - item = session.get(Item, item_id) - if not item: - return self.PatchResult.NotFound - - if item.deleted: - return self.PatchResult.NotModified - - modified = False - - if patch_info.name is not None and patch_info.name != item.name: - item.name = patch_info.name - modified = True - + if patch_info.name is not None: + item_orm.name = patch_info.name if patch_info.price is not None: - if patch_info.price < 0: - return self.PatchResult.Unprocessable - if patch_info.price != item.price: - item.price = patch_info.price - modified = True - - if not modified: - return self.PatchResult.NotModified - + item_orm.price = patch_info.price + + session.commit() + session.refresh(item_orm) return ItemEntity( - id=item.id, - info=ItemInfo( - name=item.name, - price=item.price, - deleted=item.deleted + 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( diff --git a/hw2/hw/tests/__init__.py b/hw2/hw/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/hw2/hw/tests/test_shop_api.py b/hw2/hw/tests/test_shop_api.py deleted file mode 100644 index 3712a3c8..00000000 --- a/hw2/hw/tests/test_shop_api.py +++ /dev/null @@ -1,355 +0,0 @@ -from http import HTTPStatus -from typing import Any -from uuid import uuid4 - -import pytest -from faker import Faker -from fastapi.testclient import TestClient - -from hw2.hw.shop_api import main - -client = TestClient(main.app) -faker = Faker() - - -class TestCartEndpoints: - """Тесты для эндпоинтов корзин""" - - @pytest.fixture() - def empty_cart(self) -> dict[str, Any]: - """Создает пустую корзину""" - response = client.post("/cart") - assert response.status_code == HTTPStatus.CREATED - return response.json() - - @pytest.fixture() - def cart_with_items(self, empty_cart: dict[str, Any], sample_items: list[dict]) -> dict[str, Any]: - """Создает корзину с товарами""" - cart_id = empty_cart["id"] - # Добавляем 3 случайных товара - for item in sample_items[:3]: - client.post(f"/cart/{cart_id}/add/{item['id']}") - return empty_cart - - @pytest.fixture(scope="session") - def sample_items(self) -> list[dict]: - """Создает тестовые товары""" - items = [] - for i in range(5): - item_data = { - "name": f"Тестовый товар {i+1}", - "price": faker.pyfloat(positive=True, min_value=10.0, max_value=100.0), - } - response = client.post("/item", json=item_data) - assert response.status_code == HTTPStatus.CREATED - items.append(response.json()) - return items - - def test_create_cart(self): - """Тест создания корзины""" - response = client.post("/cart") - - assert response.status_code == HTTPStatus.CREATED - assert "location" in response.headers - data = response.json() - assert "id" in data - assert isinstance(data["id"], int) - - - @pytest.mark.parametrize("cart_fixture, expected_items_count", [ - ("empty_cart", 0), - ("cart_with_items", 3), - ]) - def test_get_cart(self, request, cart_fixture: str, expected_items_count: int): - """Тест получения корзины""" - cart = request.getfixturevalue(cart_fixture) - cart_id = cart["id"] - - response = client.get(f"/cart/{cart_id}") - - assert response.status_code == HTTPStatus.OK - data = response.json() - assert len(data["items"]) == expected_items_count - assert data["price"] >= 0 # Цена не может быть отрицательной - - # Проверяем расчет общей стоимости - if expected_items_count > 0: - total_price = sum( - item["price"] * item["quantity"] - for item in data["items"] - ) - assert data["price"] == pytest.approx(total_price, 1e-8) - else: - assert data["price"] == 0.0 - - def test_get_nonexistent_cart(self): - """Тест получения несуществующей корзины""" - response = client.get(f"/cart/999999") - assert response.status_code == HTTPStatus.NOT_FOUND - - @pytest.mark.parametrize("params, expected_status", [ - # Валидные параметры - ({}, HTTPStatus.OK), - ({"offset": 0, "limit": 10}, HTTPStatus.OK), - ({"min_price": 50.0}, HTTPStatus.OK), - ({"max_price": 100.0}, HTTPStatus.OK), - ({"min_quantity": 1}, HTTPStatus.OK), - ({"max_quantity": 5}, HTTPStatus.OK), - - # Невалидные параметры - ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), - ]) - def test_get_carts_list(self, params: dict[str, Any], expected_status: int): - """Тест получения списка корзин с фильтрацией""" - response = client.get("/cart", params=params) - - assert response.status_code == expected_status - - if expected_status == HTTPStatus.OK: - data = response.json() - assert isinstance(data, list) - - # Проверяем фильтры - if "min_price" in params: - assert all(cart["price"] >= params["min_price"] for cart in data) - if "max_price" in params: - assert all(cart["price"] <= params["max_price"] for cart in data) - - -class TestItemEndpoints: - """Тесты для эндпоинтов товаров""" - - @pytest.fixture() - def sample_item(self) -> dict[str, Any]: - """Создает тестовый товар""" - item_data = { - "name": f"Тестовый товар {uuid4().hex[:8]}", - "price": faker.pyfloat(min_value=10.0, max_value=100.0), - } - response = client.post("/item", json=item_data) - assert response.status_code == HTTPStatus.CREATED - return response.json() - - @pytest.fixture() - def deleted_item(self, sample_item: dict[str, Any]) -> dict[str, Any]: - """Создает удаленный товар""" - item_id = sample_item["id"] - response = client.delete(f"/item/{item_id}") - assert response.status_code == HTTPStatus.OK - sample_item["deleted"] = True - return sample_item - - def test_create_item(self): - """Тест создания товара""" - item_data = { - "name": "Новый товар", - "price": 29.99 - } - - response = client.post("/item", json=item_data) - - assert response.status_code == HTTPStatus.CREATED - data = response.json() - assert data["name"] == item_data["name"] - assert data["price"] == item_data["price"] - assert "id" in data - assert data["deleted"] is False - - def test_create_item_invalid_data(self): - """Тест создания товара с невалидными данными""" - invalid_items = [ - {"name": "Только имя"}, # Нет цены - {"price": 10.0}, # Нет имени - {"name": "", "price": 10.0}, # Пустое имя - {"name": "Товар", "price": -10.0}, # Отрицательная цена - ] - - for invalid_item in invalid_items: - response = client.post("/item", json=invalid_item) - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - - def test_get_item(self, sample_item: dict[str, Any]): - """Тест получения товара""" - item_id = sample_item["id"] - - response = client.get(f"/item/{item_id}") - - assert response.status_code == HTTPStatus.OK - assert response.json() == sample_item - - def test_get_nonexistent_item(self): - """Тест получения несуществующего товара""" - response = client.get(f"/item/999999") - assert response.status_code == HTTPStatus.NOT_FOUND - - def test_get_deleted_item(self, deleted_item: dict[str, Any]): - """Тест получения удаленного товара""" - item_id = deleted_item["id"] - - response = client.get(f"/item/{item_id}") - assert response.status_code == HTTPStatus.NOT_FOUND - - @pytest.mark.parametrize("params, expected_status", [ - # Валидные параметры - ({"offset": 0, "limit": 5}, HTTPStatus.OK), - ({"min_price": 20.0}, HTTPStatus.OK), - ({"max_price": 50.0}, HTTPStatus.OK), - ({"show_deleted": True}, HTTPStatus.OK), - ({"show_deleted": False}, HTTPStatus.OK), - - # Невалидные параметры - ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), - ]) - def test_get_items_list(self, params: dict[str, Any], expected_status: int): - """Тест получения списка товаров с фильтрацией""" - response = client.get("/item", params=params) - - assert response.status_code == expected_status - - if expected_status == HTTPStatus.OK: - data = response.json() - assert isinstance(data, list) - - # Проверяем фильтры - if "min_price" in params: - assert all(item["price"] >= params["min_price"] for item in data) - if "max_price" in params: - assert all(item["price"] <= params["max_price"] for item in data) - if params.get("show_deleted") is False: - assert all(item.get("deleted", False) is False for item in data) - - @pytest.mark.parametrize("update_data, expected_status", [ - ({"name": "Новое имя", "price": 99.99}, HTTPStatus.OK), - ({"price": 15.50}, HTTPStatus.UNPROCESSABLE_ENTITY), # Нет имени - ({"name": "Только имя"}, HTTPStatus.UNPROCESSABLE_ENTITY), # Нет цены - ({}, HTTPStatus.UNPROCESSABLE_ENTITY), # Пустые данные - ]) - def test_full_update_item( - self, - sample_item: dict[str, Any], - update_data: dict[str, Any], - expected_status: int - ): - """Тест полного обновления товара (PUT)""" - item_id = sample_item["id"] - - response = client.put(f"/item/{item_id}", json=update_data) - - assert response.status_code == expected_status - - if expected_status == HTTPStatus.OK: - updated_item = response.json() - expected_item = sample_item.copy() - expected_item.update(update_data) - assert updated_item == expected_item - - def test_full_update_deleted_item(self, deleted_item: dict[str, Any]): - """Тест полного обновления удаленного товара""" - item_id = deleted_item["id"] - - response = client.put( - f"/item/{item_id}", - json={"name": "Новое имя", "price": 99.99} - ) - - assert response.status_code == HTTPStatus.NOT_FOUND - - @pytest.mark.parametrize("item_fixture, patch_data, expected_status", [ - # Обновление существующего товара - ("sample_item", {"name": "Частично обновлен"}, HTTPStatus.OK), - ("sample_item", {"price": 77.77}, HTTPStatus.OK), - ("sample_item", {"name": "Полное обновление", "price": 88.88}, HTTPStatus.OK), - - # Попытка обновления удаленного товара - ("deleted_item", {"name": "Новое имя"}, HTTPStatus.NOT_MODIFIED), - ("deleted_item", {"price": 99.99}, HTTPStatus.NOT_MODIFIED), - - # Невалидные данные - ("sample_item", {"invalid_field": "value"}, HTTPStatus.UNPROCESSABLE_ENTITY), - ("sample_item", {"deleted": True}, HTTPStatus.UNPROCESSABLE_ENTITY), - ]) - def test_partial_update_item( - self, - request, - item_fixture: str, - patch_data: dict[str, Any], - expected_status: int - ): - """Тест частичного обновления товара (PATCH)""" - item_data: dict[str, Any] = request.getfixturevalue(item_fixture) - item_id = item_data["id"] - - response = client.patch(f"/item/{item_id}", json=patch_data) - - assert response.status_code == expected_status - - if expected_status == HTTPStatus.OK: - # Проверяем, что изменения применились - updated_response = client.get(f"/item/{item_id}") - assert updated_response.status_code == HTTPStatus.OK - updated_item = updated_response.json() - - # Проверяем обновленные поля - for key, value in patch_data.items(): - assert updated_item[key] == value - - def test_delete_item(self, sample_item: dict[str, Any]): - """Тест удаления товара""" - item_id = sample_item["id"] - - # Первое удаление - response = client.delete(f"/item/{item_id}") - assert response.status_code == HTTPStatus.OK - - # Проверяем, что товар не доступен - response = client.get(f"/item/{item_id}") - assert response.status_code == HTTPStatus.NOT_FOUND - - # Повторное удаление (идемпотентность) - response = client.delete(f"/item/{item_id}") - assert response.status_code == HTTPStatus.OK - - def test_delete_nonexistent_item(self): - """Тест удаления несуществующего товара""" - response = client.delete(f"/item/999999") - assert response.status_code == HTTPStatus.NOT_FOUND - - -class TestCartItemOperations: - """Тесты операций с товарами в корзинах""" - - def test_add_item_to_cart(self, empty_cart: dict[str, Any], sample_item: dict[str, Any]): - """Тест добавления товара в корзину""" - cart_id = empty_cart["id"] - item_id = sample_item["id"] - - response = client.post(f"/cart/{cart_id}/add/{item_id}") - - assert response.status_code == HTTPStatus.OK - - # Проверяем, что товар добавился - cart_response = client.get(f"/cart/{cart_id}") - cart_data = cart_response.json() - - assert any(item["id"] == item_id for item in cart_data["items"]) - assert cart_data["price"] == sample_item["price"] # Один товар - - def test_add_nonexistent_item_to_cart(self, empty_cart: dict[str, Any]): - """Тест добавления несуществующего товара в корзину""" - cart_id = empty_cart["id"] - - response = client.post(f"/cart/{cart_id}/add/999999") - assert response.status_code == HTTPStatus.NOT_FOUND - - def test_add_item_to_nonexistent_cart(self, sample_item: dict[str, Any]): - """Тест добавления товара в несуществующую корзину""" - item_id = sample_item["id"] - - response = client.post(f"/cart/999999/add/{item_id}") - assert response.status_code == HTTPStatus.NOT_FOUND \ No newline at end of file