From e8fe6797922b20729e53b3a9953f3109883fa3ff Mon Sep 17 00:00:00 2001 From: SaylesMand Date: Mon, 22 Sep 2025 15:12:18 +0300 Subject: [PATCH 1/9] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..1e477051 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,3 +1,7 @@ +import math +import json +from http import HTTPStatus +from urllib import parse from typing import Any, Awaitable, Callable @@ -12,8 +16,162 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] != "http": + await send_response( + send=send, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + data={"error": "UnsupportedType", "message": "This application only supports HTTP"}, + ) + return + + method = scope["method"] + path = scope["path"] + + if method != "GET": + await send_response( + send=send, + status=HTTPStatus.NOT_FOUND, + data={"error": "NotFound", "message": f"Path {path} not found for method {method}"}, + ) + return + + if path == "/factorial": + query_params = parse.parse_qs(scope["query_string"].decode()) + n_str = query_params.get("n", [None])[0] + + if n_str is None or n_str == "": + await send_response( + send=send, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + data={"error": "InvalidParameter", "message": "Query parameter 'n' is required"}, + ) + return + try: + n = int(n_str) + except (ValueError, TypeError): + await send_response( + send=send, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + data={"error": "InvalidParameter", "message": "'n' must be a valid integer"}, + ) + return + + if n < 0: + await send_response( + send=send, + status=HTTPStatus.BAD_REQUEST, + data={"error": "InvalidValue", "message": "'n' must be non-negative"}, + ) + return + + value = math.factorial(n) + await send_response(send=send, status=HTTPStatus.OK, data={"result": value}) + return + + elif path.startswith("/fibonacci/"): + path_parts = path.strip("/").split("/") + + if len(path_parts) != 2: + await send_response( + send=send, status=HTTPStatus.NOT_FOUND, data={"error": "NotFound", "message": "Invalid path format"} + ) + return + + try: + n = int(path_parts[1]) + except ValueError: + await send_response( + send=send, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + data={"error": "InvalidParameter", "message": "Path parameter must be an integer"}, + ) + return + + if n < 0: + await send_response( + send=send, + status=HTTPStatus.BAD_REQUEST, + data={"error": "InvalidValue", "message": "'n' must be non-negative"}, + ) + return + + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + + await send_response(send=send, status=HTTPStatus.OK, data={"result": a}) + return + + elif path == "/mean": + event = await receive() + body = event.get("body", b"") + if not body: + await send_response( + send=send, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + data={"error": "InvalidParameter", "message": "Request body cannot be empty"}, + ) + return + + try: + data = json.loads(body) + except json.JSONDecodeError: + await send_response( + send, + HTTPStatus.UNPROCESSABLE_ENTITY, + {"error": "InvalidJSON", "message": "Could not decode request body"}, + ) + return + + if not isinstance(data, list): + await send_response( + send, + HTTPStatus.UNPROCESSABLE_ENTITY, + {"error": "InvalidFormat", "message": "Request body must be a JSON array"}, + ) + return + + if not data: + await send_response( + send, + HTTPStatus.BAD_REQUEST, + {"error": "InvalidValue", "message": "Input array cannot be empty"}, + ) + return + + if not all(isinstance(x, (int, float)) for x in data): + await send_response( + send=send, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + data={"error": "InvalidParameter", "message": "All elements in the array must be numbers"}, + ) + return + + value = sum(data) / len(data) + await send_response(send=send, status=HTTPStatus.OK, data={"result": value}) + return + + await send_response( + send=send, + status=HTTPStatus.NOT_FOUND, + data={"error": "NotFound", "message": f"Path {path} not found"}, + ) + return + + +async def send_response(send, status: HTTPStatus, data: dict): + body = json.dumps(data).encode("utf-8") + await send( + { + "type": "http.response.start", + "status": status.value, + "headers": [(b"content-type", b"application/json; charset=utf-8")], + } + ) + await send({"type": "http.response.body", "body": body}) + if __name__ == "__main__": import uvicorn + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) From 6738a8bb77d26b595bcbbcef01390db28d4d10da Mon Sep 17 00:00:00 2001 From: SaylesMand Date: Tue, 30 Sep 2025 14:42:05 +0300 Subject: [PATCH 2/9] implemented REST+RPC API --- hw2/hw/shop_api/cart/contracts.py | 38 +++++++++ hw2/hw/shop_api/cart/store/__init__.py | 13 +++ hw2/hw/shop_api/cart/store/models.py | 21 +++++ hw2/hw/shop_api/cart/store/queries.py | 89 ++++++++++++++++++++ hw2/hw/shop_api/item/contracts.py | 40 +++++++++ hw2/hw/shop_api/item/store/__init__.py | 14 ++++ hw2/hw/shop_api/item/store/models.py | 20 +++++ hw2/hw/shop_api/item/store/queries.py | 80 ++++++++++++++++++ hw2/hw/shop_api/main.py | 11 +++ hw2/hw/shop_api/routers/cart.py | 72 ++++++++++++++++ hw2/hw/shop_api/routers/item.py | 112 +++++++++++++++++++++++++ 11 files changed, 510 insertions(+) create mode 100644 hw2/hw/shop_api/cart/contracts.py create mode 100644 hw2/hw/shop_api/cart/store/__init__.py create mode 100644 hw2/hw/shop_api/cart/store/models.py create mode 100644 hw2/hw/shop_api/cart/store/queries.py create mode 100644 hw2/hw/shop_api/item/contracts.py create mode 100644 hw2/hw/shop_api/item/store/__init__.py create mode 100644 hw2/hw/shop_api/item/store/models.py create mode 100644 hw2/hw/shop_api/item/store/queries.py create mode 100644 hw2/hw/shop_api/routers/cart.py create mode 100644 hw2/hw/shop_api/routers/item.py diff --git a/hw2/hw/shop_api/cart/contracts.py b/hw2/hw/shop_api/cart/contracts.py new file mode 100644 index 00000000..ae1c2509 --- /dev/null +++ b/hw2/hw/shop_api/cart/contracts.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from typing import Any + +from pydantic import BaseModel + +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +# class CartItemRequest(BaseModel): +# items: list[CartItemInfo] +# price: float + +# def as_cart_info(self) -> CartInfo: +# items = [CartItemInfo(**item.dict()) for item in self.items] +# # price = +# return CartInfo(items=items, price=self.price) + + + +class CartResponse(BaseModel): + id: int + items: list[CartItemInfo] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + return CartResponse( + id=entity.id, + items=entity.info.items, + price=entity.info.price, + ) \ No newline at end of file diff --git a/hw2/hw/shop_api/cart/store/__init__.py b/hw2/hw/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..e14c47c2 --- /dev/null +++ b/hw2/hw/shop_api/cart/store/__init__.py @@ -0,0 +1,13 @@ +from .models import CartItemInfo, CartEntity, CartInfo +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartEntity", + "CartInfo", + "CartItemInfo", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw2/hw/shop_api/cart/store/models.py b/hw2/hw/shop_api/cart/store/models.py new file mode 100644 index 00000000..edc7bdda --- /dev/null +++ b/hw2/hw/shop_api/cart/store/models.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo diff --git a/hw2/hw/shop_api/cart/store/queries.py b/hw2/hw/shop_api/cart/store/queries.py new file mode 100644 index 00000000..36a14d32 --- /dev/null +++ b/hw2/hw/shop_api/cart/store/queries.py @@ -0,0 +1,89 @@ +from logging import info +from typing import Iterable + +from shop_api.item.store.models import ItemEntity +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +_data: dict[int, CartInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def create() -> CartEntity: + _id = next(_id_generator) + info = CartInfo(items=[], price=0.0) + _data[_id] = info + return CartEntity(id=_id, info=info) + + +def add(cart_id: int, item_entity: ItemEntity) -> CartEntity: + cart_info = _data[cart_id] + for ci in cart_info.items: + if ci.id == item_entity.id: + ci.quantity += 1 + ci.available = not item_entity.info.deleted + break + else: + cart_info.items.append( + CartItemInfo( + id=item_entity.id, + name=item_entity.info.name, + quantity=1, + available=not item_entity.info.deleted, + ) + ) + cart_info.price += item_entity.info.price + _data[cart_id] = cart_info + + return CartEntity(id=cart_id, info=cart_info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> CartEntity | None: + if id not in _data: + return None + + return CartEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + min_quantity: int = None, + max_quantity: int = None, +) -> Iterable[CartEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + sum_quantity = 0 + for item in info.items: + sum_quantity += item.quantity + + if min_quantity is not None and min_quantity > sum_quantity: + continue + if max_quantity is not None and max_quantity < sum_quantity: + continue + + if offset <= curr < offset + limit: + yield CartEntity(id, info) + + curr += 1 diff --git a/hw2/hw/shop_api/item/contracts.py b/hw2/hw/shop_api/item/contracts.py new file mode 100644 index 00000000..6d516d31 --- /dev/null +++ b/hw2/hw/shop_api/item/contracts.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + + +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) diff --git a/hw2/hw/shop_api/item/store/__init__.py b/hw2/hw/shop_api/item/store/__init__.py new file mode 100644 index 00000000..bf69aa56 --- /dev/null +++ b/hw2/hw/shop_api/item/store/__init__.py @@ -0,0 +1,14 @@ +from .models import PatchItemInfo, ItemEntity, ItemInfo +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "ItemEntity", + "ItemInfo", + "PatchItemInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw2/hw/shop_api/item/store/models.py b/hw2/hw/shop_api/item/store/models.py new file mode 100644 index 00000000..22bb1735 --- /dev/null +++ b/hw2/hw/shop_api/item/store/models.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None \ No newline at end of file diff --git a/hw2/hw/shop_api/item/store/queries.py b/hw2/hw/shop_api/item/store/queries.py new file mode 100644 index 00000000..eff87679 --- /dev/null +++ b/hw2/hw/shop_api/item/store/queries.py @@ -0,0 +1,80 @@ +from typing import Iterable + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +_data: dict[int, ItemInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: ItemInfo) -> ItemEntity: + _id = next(_id_generator) + _data[_id] = info + + return ItemEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> ItemEntity | None: + if id not in _data: + return None + + return ItemEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + show_deleted: bool = False, +) -> Iterable[ItemEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + if not show_deleted and info.deleted: + continue + + if offset <= curr < offset + limit: + yield ItemEntity(id, info) + + curr += 1 + + +def update(id: int, info: ItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + _data[id] = info + + return ItemEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.price is not None: + _data[id].price = patch_info.price + + return ItemEntity(id=id, info=_data[id]) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..e65f791c 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,14 @@ from fastapi import FastAPI +from shop_api.routers.cart import router as cart +from shop_api.routers.item import router as item + app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, port=8001) diff --git a/hw2/hw/shop_api/routers/cart.py b/hw2/hw/shop_api/routers/cart.py new file mode 100644 index 00000000..d510513c --- /dev/null +++ b/hw2/hw/shop_api/routers/cart.py @@ -0,0 +1,72 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse + +import shop_api +import shop_api.item +import shop_api.item.store + + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart(response: Response) -> CartResponse: + entity = store.create() + + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, +): + return [ + CartResponse.from_entity(e) + for e in store.get_many( + offset, limit, min_price, max_price, min_quantity, max_quantity + ) + ] + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int): + item_entity = shop_api.item.store.get_one(item_id) + + entity = store.add(cart_id, item_entity) + + return CartResponse.from_entity(entity) \ No newline at end of file diff --git a/hw2/hw/shop_api/routers/item.py b/hw2/hw/shop_api/routers/item.py new file mode 100644 index 00000000..d95d39d2 --- /dev/null +++ b/hw2/hw/shop_api/routers/item.py @@ -0,0 +1,112 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter(prefix="/item") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item(info: ItemRequest, response: Response) -> ItemResponse: + entity = store.add(info.as_item_info()) + + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] | None = None, +): + return [ + ItemResponse.from_entity(e) + for e in store.get_many(offset, limit, min_price, max_price, show_deleted) + ] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item(id: int, info: PatchItemRequest) -> ItemResponse: + entity = store.patch(id, info.as_patch_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, +) -> ItemResponse: + entity = store.update(id, info.as_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item(id: int) -> Response: + store.delete(id) + return Response("") From 66ec05621c8cf246601f0c70e41b5e460bd18a3b Mon Sep 17 00:00:00 2001 From: SaylesMand Date: Tue, 7 Oct 2025 12:39:30 +0300 Subject: [PATCH 3/9] Added docker and monitoring (Prometheus and Grafana) --- hw3/hw/.dockerignore | 2 + hw3/hw/Dockerfile | 12 + hw3/hw/README.md | 17 ++ hw3/hw/__init__.py | 0 hw3/hw/docker-compose.yml | 30 +++ hw3/hw/grafana.png | Bin 0 -> 96896 bytes hw3/hw/requirements.txt | 11 + hw3/hw/settings/prometheus/prometheus.yml | 10 + hw3/hw/shop_api/__init__.py | 0 hw3/hw/shop_api/cart/contracts.py | 38 +++ hw3/hw/shop_api/cart/store/__init__.py | 13 + hw3/hw/shop_api/cart/store/models.py | 21 ++ hw3/hw/shop_api/cart/store/queries.py | 89 +++++++ hw3/hw/shop_api/item/contracts.py | 40 +++ hw3/hw/shop_api/item/store/__init__.py | 14 ++ hw3/hw/shop_api/item/store/models.py | 20 ++ hw3/hw/shop_api/item/store/queries.py | 80 ++++++ hw3/hw/shop_api/main.py | 13 + hw3/hw/shop_api/routers/cart.py | 72 ++++++ hw3/hw/shop_api/routers/item.py | 112 +++++++++ hw3/hw/test_homework2.py | 284 ++++++++++++++++++++++ 21 files changed, 878 insertions(+) create mode 100644 hw3/hw/.dockerignore create mode 100644 hw3/hw/Dockerfile create mode 100644 hw3/hw/README.md create mode 100644 hw3/hw/__init__.py create mode 100644 hw3/hw/docker-compose.yml create mode 100644 hw3/hw/grafana.png create mode 100644 hw3/hw/requirements.txt create mode 100644 hw3/hw/settings/prometheus/prometheus.yml create mode 100644 hw3/hw/shop_api/__init__.py create mode 100644 hw3/hw/shop_api/cart/contracts.py create mode 100644 hw3/hw/shop_api/cart/store/__init__.py create mode 100644 hw3/hw/shop_api/cart/store/models.py create mode 100644 hw3/hw/shop_api/cart/store/queries.py create mode 100644 hw3/hw/shop_api/item/contracts.py create mode 100644 hw3/hw/shop_api/item/store/__init__.py create mode 100644 hw3/hw/shop_api/item/store/models.py create mode 100644 hw3/hw/shop_api/item/store/queries.py create mode 100644 hw3/hw/shop_api/main.py create mode 100644 hw3/hw/shop_api/routers/cart.py create mode 100644 hw3/hw/shop_api/routers/item.py create mode 100644 hw3/hw/test_homework2.py diff --git a/hw3/hw/.dockerignore b/hw3/hw/.dockerignore new file mode 100644 index 00000000..de793787 --- /dev/null +++ b/hw3/hw/.dockerignore @@ -0,0 +1,2 @@ +.github +.venv \ No newline at end of file diff --git a/hw3/hw/Dockerfile b/hw3/hw/Dockerfile new file mode 100644 index 00000000..65c0f7ad --- /dev/null +++ b/hw3/hw/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR /app +COPY . . + +RUN pip install -r requirements.txt + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] + diff --git a/hw3/hw/README.md b/hw3/hw/README.md new file mode 100644 index 00000000..088c6d6c --- /dev/null +++ b/hw3/hw/README.md @@ -0,0 +1,17 @@ +# ДЗ + +## Настроить сборку образов Docker и мониторинг с помощью Prometheus и Grafana + +Интегрировать Docker с Prometheus и Grafana в любой уже написанный в ДЗ сервис (по аналогии с тем, как в репе) + +По сути, если вы выполнили вторую домашку, то теперь для неё надо написать Dockerfile и настроить мониторинг. Если вторую домашку вы не делали, то можно взять сервис из [rest_example](../hw2/rest_example/main.py) + +Сдача через PR, так же нужно: + +1) Dockerfile для сборки сервиса +2) docker-compose.yml для локального разворачивания в Docker +3) Приложить скрин с парой Дашбордов в Grafana + + +## Решение +В [grafana.png](grafana.png) приведена визуализация реализованного дашборда (график time-series). \ No newline at end of file diff --git a/hw3/hw/__init__.py b/hw3/hw/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw/docker-compose.yml b/hw3/hw/docker-compose.yml new file mode 100644 index 00000000..b990f773 --- /dev/null +++ b/hw3/hw/docker-compose.yml @@ -0,0 +1,30 @@ +services: + local: + build: + context: . + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + depends_on: + - prometheus + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + depends_on: + - local diff --git a/hw3/hw/grafana.png b/hw3/hw/grafana.png new file mode 100644 index 0000000000000000000000000000000000000000..2032c3646997c8211ff0d97ed6700ff520e04873 GIT binary patch literal 96896 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

sSjmnzAxzvp{aG;{7nAN<63#j2d zV`{SrYCkjT-&j-pi(NvYD_s)EeuuUtG92pC?y&2nUXdC(u>Vf=-O2PJ<(Mtmr3Mg$ zqLR$LV^B@5EY%G#{;*_4J|sk1WU>V?BWMdfohS2%5&1KORqR8}>0F`kb4x>Ki z=mJCN;q(kn`=Gg-L8rs09ry- zx(X>H+A~lI;a-^veZlVQ^DuoCzlkDcxGy6;sFjE7_CFCvsc4QUjr3@_w$Pg0*OpqQ zT2f@lg_R#(hGEcN!HfH!cus8P^Rht4P5=0HEZyt9y4HARiCv&g*errw{fX% z?J!Ao2c>V>eYidUV4b)@0O5Rw3ioLjA6nYwq8Qdf|1{)Z1|2y7IXtlD z+PlGL8lqfoUIKiJuwPOyX>uwA97K=vwMixM6y!f_ngOW=>e@CcoEl z>1&^5MY!TyVt6Ln9TAIE(hdMCa;PL5|1x3|$^|u*C349(%Kg*T~W-HYftg6dsI?2EV7hVSE2<=5ZSNf!|ffT)ie{IOy`k8Ix%;I|Vir-i*J2wU8^qOf3j0O%}*_MT|R@^gpAMGEl3|@q=U@3-v!@{ zTjfX*C)(|?A+q(LT;!$A*5w0Nck9o#CSq$kD=18vww9-v_)(=`0D(H zlWUbDoeVfDgfneI?z31)3CYuh`)x^%EGNM^YWj-(YCvG*LdP8|yZ8ki!veZFs|iTE9ZHLA9kG_QKD00rlv+dA1`aV2c=`5%*@eYQ za6Q@i&sikI?{CZ^onUzFbl?lNuwWJC%BG2=SCJ55px*4aPU!H897faxFF~=X2<^MD z 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

=sX)l4k^p2PK^UXI=7uq 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 Yk$2SlAmszi>_@K~-n>#};1vD80O9nQ4FCWD literal 0 HcmV?d00001 diff --git a/hw3/hw/requirements.txt b/hw3/hw/requirements.txt new file mode 100644 index 00000000..e680b29a --- /dev/null +++ b/hw3/hw/requirements.txt @@ -0,0 +1,11 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + +prometheus_fastapi_instrumentator \ No newline at end of file diff --git a/hw3/hw/settings/prometheus/prometheus.yml b/hw3/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..7fa1951b --- /dev/null +++ b/hw3/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw3/hw/shop_api/__init__.py b/hw3/hw/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw/shop_api/cart/contracts.py b/hw3/hw/shop_api/cart/contracts.py new file mode 100644 index 00000000..ae1c2509 --- /dev/null +++ b/hw3/hw/shop_api/cart/contracts.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from typing import Any + +from pydantic import BaseModel + +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +# class CartItemRequest(BaseModel): +# items: list[CartItemInfo] +# price: float + +# def as_cart_info(self) -> CartInfo: +# items = [CartItemInfo(**item.dict()) for item in self.items] +# # price = +# return CartInfo(items=items, price=self.price) + + + +class CartResponse(BaseModel): + id: int + items: list[CartItemInfo] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + return CartResponse( + id=entity.id, + items=entity.info.items, + price=entity.info.price, + ) \ No newline at end of file diff --git a/hw3/hw/shop_api/cart/store/__init__.py b/hw3/hw/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..e14c47c2 --- /dev/null +++ b/hw3/hw/shop_api/cart/store/__init__.py @@ -0,0 +1,13 @@ +from .models import CartItemInfo, CartEntity, CartInfo +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartEntity", + "CartInfo", + "CartItemInfo", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw3/hw/shop_api/cart/store/models.py b/hw3/hw/shop_api/cart/store/models.py new file mode 100644 index 00000000..edc7bdda --- /dev/null +++ b/hw3/hw/shop_api/cart/store/models.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo diff --git a/hw3/hw/shop_api/cart/store/queries.py b/hw3/hw/shop_api/cart/store/queries.py new file mode 100644 index 00000000..36a14d32 --- /dev/null +++ b/hw3/hw/shop_api/cart/store/queries.py @@ -0,0 +1,89 @@ +from logging import info +from typing import Iterable + +from shop_api.item.store.models import ItemEntity +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +_data: dict[int, CartInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def create() -> CartEntity: + _id = next(_id_generator) + info = CartInfo(items=[], price=0.0) + _data[_id] = info + return CartEntity(id=_id, info=info) + + +def add(cart_id: int, item_entity: ItemEntity) -> CartEntity: + cart_info = _data[cart_id] + for ci in cart_info.items: + if ci.id == item_entity.id: + ci.quantity += 1 + ci.available = not item_entity.info.deleted + break + else: + cart_info.items.append( + CartItemInfo( + id=item_entity.id, + name=item_entity.info.name, + quantity=1, + available=not item_entity.info.deleted, + ) + ) + cart_info.price += item_entity.info.price + _data[cart_id] = cart_info + + return CartEntity(id=cart_id, info=cart_info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> CartEntity | None: + if id not in _data: + return None + + return CartEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + min_quantity: int = None, + max_quantity: int = None, +) -> Iterable[CartEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + sum_quantity = 0 + for item in info.items: + sum_quantity += item.quantity + + if min_quantity is not None and min_quantity > sum_quantity: + continue + if max_quantity is not None and max_quantity < sum_quantity: + continue + + if offset <= curr < offset + limit: + yield CartEntity(id, info) + + curr += 1 diff --git a/hw3/hw/shop_api/item/contracts.py b/hw3/hw/shop_api/item/contracts.py new file mode 100644 index 00000000..6d516d31 --- /dev/null +++ b/hw3/hw/shop_api/item/contracts.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + + +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) diff --git a/hw3/hw/shop_api/item/store/__init__.py b/hw3/hw/shop_api/item/store/__init__.py new file mode 100644 index 00000000..bf69aa56 --- /dev/null +++ b/hw3/hw/shop_api/item/store/__init__.py @@ -0,0 +1,14 @@ +from .models import PatchItemInfo, ItemEntity, ItemInfo +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "ItemEntity", + "ItemInfo", + "PatchItemInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw3/hw/shop_api/item/store/models.py b/hw3/hw/shop_api/item/store/models.py new file mode 100644 index 00000000..22bb1735 --- /dev/null +++ b/hw3/hw/shop_api/item/store/models.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None \ No newline at end of file diff --git a/hw3/hw/shop_api/item/store/queries.py b/hw3/hw/shop_api/item/store/queries.py new file mode 100644 index 00000000..eff87679 --- /dev/null +++ b/hw3/hw/shop_api/item/store/queries.py @@ -0,0 +1,80 @@ +from typing import Iterable + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +_data: dict[int, ItemInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: ItemInfo) -> ItemEntity: + _id = next(_id_generator) + _data[_id] = info + + return ItemEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> ItemEntity | None: + if id not in _data: + return None + + return ItemEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + show_deleted: bool = False, +) -> Iterable[ItemEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + if not show_deleted and info.deleted: + continue + + if offset <= curr < offset + limit: + yield ItemEntity(id, info) + + curr += 1 + + +def update(id: int, info: ItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + _data[id] = info + + return ItemEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.price is not None: + _data[id].price = patch_info.price + + return ItemEntity(id=id, info=_data[id]) diff --git a/hw3/hw/shop_api/main.py b/hw3/hw/shop_api/main.py new file mode 100644 index 00000000..037615ba --- /dev/null +++ b/hw3/hw/shop_api/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from shop_api.routers.cart import router as cart +from shop_api.routers.item import router as item + +from prometheus_fastapi_instrumentator import Instrumentator + +app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +Instrumentator().instrument(app).expose(app, endpoint="/metrics") diff --git a/hw3/hw/shop_api/routers/cart.py b/hw3/hw/shop_api/routers/cart.py new file mode 100644 index 00000000..d510513c --- /dev/null +++ b/hw3/hw/shop_api/routers/cart.py @@ -0,0 +1,72 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse + +import shop_api +import shop_api.item +import shop_api.item.store + + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart(response: Response) -> CartResponse: + entity = store.create() + + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, +): + return [ + CartResponse.from_entity(e) + for e in store.get_many( + offset, limit, min_price, max_price, min_quantity, max_quantity + ) + ] + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int): + item_entity = shop_api.item.store.get_one(item_id) + + entity = store.add(cart_id, item_entity) + + return CartResponse.from_entity(entity) \ No newline at end of file diff --git a/hw3/hw/shop_api/routers/item.py b/hw3/hw/shop_api/routers/item.py new file mode 100644 index 00000000..d95d39d2 --- /dev/null +++ b/hw3/hw/shop_api/routers/item.py @@ -0,0 +1,112 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter(prefix="/item") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item(info: ItemRequest, response: Response) -> ItemResponse: + entity = store.add(info.as_item_info()) + + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] | None = None, +): + return [ + ItemResponse.from_entity(e) + for e in store.get_many(offset, limit, min_price, max_price, show_deleted) + ] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item(id: int, info: PatchItemRequest) -> ItemResponse: + entity = store.patch(id, info.as_patch_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, +) -> ItemResponse: + entity = store.update(id, info.as_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item(id: int) -> Response: + store.delete(id) + return Response("") diff --git a/hw3/hw/test_homework2.py b/hw3/hw/test_homework2.py new file mode 100644 index 00000000..60a1f36a --- /dev/null +++ b/hw3/hw/test_homework2.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 shop_api.main import app + +client = TestClient(app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@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 From 0fceb8676132b5667aaa2cbd81928aaa01ac359b Mon Sep 17 00:00:00 2001 From: SaylesMand Date: Thu, 23 Oct 2025 00:16:49 +0300 Subject: [PATCH 4/9] integrate PostgreSQL with SQLAlchemy ORM --- hw4/.dockerignore | 2 + hw4/Dockerfile | 12 +++ hw4/README.md | 15 +++ hw4/__init__.py | 0 hw4/docker-compose.yml | 54 +++++++++++ hw4/requirements.txt | 13 +++ hw4/settings/prometheus/prometheus.yml | 10 ++ hw4/shop_api/__init__.py | 0 hw4/shop_api/cart/contracts.py | 31 +++++++ hw4/shop_api/cart/store/__init__.py | 12 +++ hw4/shop_api/cart/store/models.py | 29 ++++++ hw4/shop_api/cart/store/queries.py | 119 ++++++++++++++++++++++++ hw4/shop_api/cart/store/schemas.py | 18 ++++ hw4/shop_api/db.py | 23 +++++ hw4/shop_api/item/contracts.py | 38 ++++++++ hw4/shop_api/item/store/__init__.py | 12 +++ hw4/shop_api/item/store/models.py | 15 +++ hw4/shop_api/item/store/queries.py | 84 +++++++++++++++++ hw4/shop_api/item/store/schemas.py | 27 ++++++ hw4/shop_api/main.py | 16 ++++ hw4/shop_api/routers/cart.py | 80 ++++++++++++++++ hw4/shop_api/routers/item.py | 121 +++++++++++++++++++++++++ 22 files changed, 731 insertions(+) create mode 100644 hw4/.dockerignore create mode 100644 hw4/Dockerfile create mode 100644 hw4/README.md create mode 100644 hw4/__init__.py create mode 100644 hw4/docker-compose.yml create mode 100644 hw4/requirements.txt create mode 100644 hw4/settings/prometheus/prometheus.yml create mode 100644 hw4/shop_api/__init__.py create mode 100644 hw4/shop_api/cart/contracts.py create mode 100644 hw4/shop_api/cart/store/__init__.py create mode 100644 hw4/shop_api/cart/store/models.py create mode 100644 hw4/shop_api/cart/store/queries.py create mode 100644 hw4/shop_api/cart/store/schemas.py create mode 100644 hw4/shop_api/db.py create mode 100644 hw4/shop_api/item/contracts.py create mode 100644 hw4/shop_api/item/store/__init__.py create mode 100644 hw4/shop_api/item/store/models.py create mode 100644 hw4/shop_api/item/store/queries.py create mode 100644 hw4/shop_api/item/store/schemas.py create mode 100644 hw4/shop_api/main.py create mode 100644 hw4/shop_api/routers/cart.py create mode 100644 hw4/shop_api/routers/item.py diff --git a/hw4/.dockerignore b/hw4/.dockerignore new file mode 100644 index 00000000..de793787 --- /dev/null +++ b/hw4/.dockerignore @@ -0,0 +1,2 @@ +.github +.venv \ No newline at end of file diff --git a/hw4/Dockerfile b/hw4/Dockerfile new file mode 100644 index 00000000..65c0f7ad --- /dev/null +++ b/hw4/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR /app +COPY . . + +RUN pip install -r requirements.txt + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] + diff --git a/hw4/README.md b/hw4/README.md new file mode 100644 index 00000000..26822ef7 --- /dev/null +++ b/hw4/README.md @@ -0,0 +1,15 @@ +## ДЗ + +За каждый пункт - 1 балл + +Внедрить во вторую домашку хранение данных в БД, для этого надо: +1) Добавить БД в docket-compose.yml (если БД - это отдельный сервис, если хотите использовать sqlite, то можно скипнуть этот шаг) +2) Переписать код на взаимодействие с вашей БД (если вы еще этого не сделали, если вы уже написали код с БД, подзравляю, вам остался только 3 пункт) +3) В свободной форме, напишите скрипты, которые просимулируют разные "проблемы" которые могут возникнуть в транзакциях (dirty read, not-repeatable read, serialize) и настраивая уровне изоляции покажите, что они действительно решаются (через SQLAlchemy например), то есть: +показать dirty read при read uncommited +показать что нет dirty read при read commited +показать non-repeatable read при read commited +показать что нет non-repeatable read при repeatable read +показать phantom reads при repeatable read +показать что нет phantom reads при serializable +*Тут зависит от того какую БД вы выбрали, разные БД могут поддерживать разные уровни изоляции \ No newline at end of file diff --git a/hw4/__init__.py b/hw4/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/docker-compose.yml b/hw4/docker-compose.yml new file mode 100644 index 00000000..da5236ec --- /dev/null +++ b/hw4/docker-compose.yml @@ -0,0 +1,54 @@ +services: + postgres: + image: postgres:15 + container_name: postgres + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: db + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + local: + build: + context: . + restart: always + ports: + - 8080:8080 + environment: + - DATABASE_URL=postgresql://postgres:password@postgres:5432/db + depends_on: + - postgres + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + depends_on: + - prometheus + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + depends_on: + - local + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw4/requirements.txt b/hw4/requirements.txt new file mode 100644 index 00000000..c40f5daa --- /dev/null +++ b/hw4/requirements.txt @@ -0,0 +1,13 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + +prometheus_fastapi_instrumentator +sqlalchemy +psycopg2-binary \ No newline at end of file diff --git a/hw4/settings/prometheus/prometheus.yml b/hw4/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..7fa1951b --- /dev/null +++ b/hw4/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw4/shop_api/__init__.py b/hw4/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/shop_api/cart/contracts.py b/hw4/shop_api/cart/contracts.py new file mode 100644 index 00000000..d44416a1 --- /dev/null +++ b/hw4/shop_api/cart/contracts.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from typing import List + +from shop_api.cart.store.schemas import CartEntity + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + +class CartResponse(BaseModel): + id: int + items: List[CartItemResponse] + price: float + + @staticmethod + def from_entity(entity: "CartEntity") -> "CartResponse": + return CartResponse( + id=entity.id, + items=[ + CartItemResponse( + id=item.id, + name=item.name, + quantity=item.quantity, + available=item.available, + ) + for item in entity.items + ], + price=entity.price, + ) \ No newline at end of file diff --git a/hw4/shop_api/cart/store/__init__.py b/hw4/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..d5283b5a --- /dev/null +++ b/hw4/shop_api/cart/store/__init__.py @@ -0,0 +1,12 @@ +from .models import CartItem, Cart +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartItem", + "Cart", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw4/shop_api/cart/store/models.py b/hw4/shop_api/cart/store/models.py new file mode 100644 index 00000000..eb033c4b --- /dev/null +++ b/hw4/shop_api/cart/store/models.py @@ -0,0 +1,29 @@ +from sqlalchemy import Column, Integer, Boolean, Float, ForeignKey, String +from sqlalchemy.orm import relationship + +from shop_api.db import Base + + +class CartItem(Base): + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, ForeignKey("carts.id")) + item_id = Column(Integer, ForeignKey("items.id")) + name = Column(String(255)) + quantity = Column(Integer, default=1) + available = Column(Boolean, default=True) + + cart = relationship("Cart", back_populates="items") + item = relationship("Item", back_populates="cart_items") + + +class Cart(Base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, index=True) + price = Column(Float, default=0.0) + + items = relationship( + "CartItem", back_populates="cart", cascade="all, delete-orphan" + ) diff --git a/hw4/shop_api/cart/store/queries.py b/hw4/shop_api/cart/store/queries.py new file mode 100644 index 00000000..b989ffae --- /dev/null +++ b/hw4/shop_api/cart/store/queries.py @@ -0,0 +1,119 @@ +from typing import Iterable, Optional +from sqlalchemy.orm import Session + +from shop_api.cart.store.schemas import CartEntity, CartItemEntity +from shop_api.item.store.schemas import ItemEntity +from shop_api.cart.store.models import Cart, CartItem + + +def create(db: Session) -> CartEntity: + db_cart = Cart() + db.add(db_cart) + db.commit() + db.refresh(db_cart) + return CartEntity(id=db_cart.id, price=db_cart.price, items=[]) + + +def add(db: Session, cart_id: int, item_entity: ItemEntity) -> Optional[CartEntity]: + cart = db.query(Cart).filter(Cart.id == cart_id).first() + if not cart: + return None + + cart_item = ( + db.query(CartItem) + .filter(CartItem.cart_id == cart_id, CartItem.item_id == item_entity.id) + .first() + ) + + if cart_item: + cart_item.quantity += 1 + cart_item.available = not item_entity.info.deleted + else: + cart_item = CartItem( + cart_id=cart_id, + item_id=item_entity.id, + name=item_entity.info.name, + quantity=1, + available=not item_entity.info.deleted, + ) + db.add(cart_item) + + cart.price += item_entity.info.price + db.commit() + db.refresh(cart) + + cart_items = [ + CartItemEntity( + id=item.id, + name=item.name, + quantity=item.quantity, + available=item.available + ) + for item in cart.items + ] + return CartEntity(id=cart.id, price=cart.price, items=cart_items) + + +def delete(db: Session, id: int) -> bool: + db_cart = db.query(Cart).filter(Cart.id == id).first() + if db_cart: + db.delete(db_cart) + db.commit() + return True + return False + + +def get_one(db: Session, id: int) -> Optional[CartEntity]: + db_cart = db.query(Cart).filter(Cart.id == id).first() + if db_cart: + cart_items = [ + CartItemEntity( + id=item.id, + name=item.name, + quantity=item.quantity, + available=item.available + ) + for item in db_cart.items + ] + return CartEntity(id=db_cart.id, price=db_cart.price, items=cart_items) + return None + + +def get_many( + db: Session, + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + min_quantity: int = None, + max_quantity: int = None, +) -> Iterable[CartEntity]: + query = db.query(Cart) + if min_price is not None: + query = query.filter(Cart.price >= min_price) + if max_price is not None: + query = query.filter(Cart.price <= max_price) + + carts = query.offset(offset).limit(limit).all() + + result = [] + for cart in carts: + total_quantity = sum(item.quantity for item in cart.items) + + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + cart_items = [ + CartItemEntity( + id=item.id, + name=item.name, + quantity=item.quantity, + available=item.available + ) + for item in cart.items + ] + result.append(CartEntity(id=cart.id, price=cart.price, items=cart_items)) + + return result \ No newline at end of file diff --git a/hw4/shop_api/cart/store/schemas.py b/hw4/shop_api/cart/store/schemas.py new file mode 100644 index 00000000..82005ec5 --- /dev/null +++ b/hw4/shop_api/cart/store/schemas.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from typing import List + + +class CartEntity(BaseModel): + id: int + price: float + items: List["CartItemEntity"] + + +class CartItemEntity(BaseModel): + id: int + name: str + quantity: int + available: bool + + class Config: + from_attributes = True diff --git a/hw4/shop_api/db.py b/hw4/shop_api/db.py new file mode 100644 index 00000000..63bd9a13 --- /dev/null +++ b/hw4/shop_api/db.py @@ -0,0 +1,23 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +DATABASE_URL = os.getenv( + "DATABASE_URL", "postgresql://postgres:password@localhost:5432/shop_db" # <-- Это используется, если переменная НЕ установлена +) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +from shop_api.cart.store.models import Cart, CartItem +from shop_api.item.store.models import Item + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/hw4/shop_api/item/contracts.py b/hw4/shop_api/item/contracts.py new file mode 100644 index 00000000..7984a031 --- /dev/null +++ b/hw4/shop_api/item/contracts.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, ConfigDict + +from shop_api.item.store.schemas import ItemEntity, ItemInfo, PatchItemInfo + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: "ItemEntity") -> "ItemResponse": + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + + +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) diff --git a/hw4/shop_api/item/store/__init__.py b/hw4/shop_api/item/store/__init__.py new file mode 100644 index 00000000..e5ae556c --- /dev/null +++ b/hw4/shop_api/item/store/__init__.py @@ -0,0 +1,12 @@ +from .models import Item +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "Item", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw4/shop_api/item/store/models.py b/hw4/shop_api/item/store/models.py new file mode 100644 index 00000000..a7f28627 --- /dev/null +++ b/hw4/shop_api/item/store/models.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, Boolean, Float, String +from sqlalchemy.orm import relationship + +from shop_api.db import Base + + +class Item(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + + cart_items = relationship("CartItem", back_populates="item") diff --git a/hw4/shop_api/item/store/queries.py b/hw4/shop_api/item/store/queries.py new file mode 100644 index 00000000..f3ed3460 --- /dev/null +++ b/hw4/shop_api/item/store/queries.py @@ -0,0 +1,84 @@ +from typing import Iterable, Optional + +from shop_api.item.store.schemas import ItemEntity, ItemInfo, PatchItemInfo +from shop_api.item.store.models import Item +from sqlalchemy.orm import Session + + +def add(db: Session, info: ItemInfo) -> ItemEntity: + db_item = Item(name=info.name, price=info.price, deleted=info.deleted) + db.add(db_item) + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo.model_validate(db_item)) + + +def delete(db: Session, id: int) -> bool: + db_item = db.query(Item).filter(Item.id == id).first() + if db_item: + db.delete(db_item) + db.commit() + return True + return False + + +def get_one(db: Session, id: int) -> Optional[ItemEntity]: + db_item = db.query(Item).filter(Item.id == id).first() + if db_item: + return ItemEntity(id=db_item.id, info=ItemInfo.model_validate(db_item)) + return None + + +def get_many( + db: Session, + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + show_deleted: bool = False, +) -> Iterable[ItemEntity]: + query = db.query(Item) + + if min_price is not None: + query = query.filter(Item.price >= min_price) + if max_price is not None: + query = query.filter(Item.price <= max_price) + if not show_deleted: + query = query.filter(Item.deleted.is_(False)) + + + items = query.offset(offset).limit(limit).all() + + return [ + ItemEntity(id=item.id, info=ItemInfo.model_validate(item)) + for item in items + ] + + + +def update(db: Session, id: int, info: ItemInfo) -> Optional[ItemEntity]: + db_item = db.query(Item).filter(Item.id == id).first() + if db_item: + db_item.name = info.name + db_item.price = info.price + db_item.deleted = info.deleted + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo.from_orm(db_item)) + return None + + +def patch(db: Session, id: int, patch_info: PatchItemInfo) -> Optional[ItemEntity]: + db_item = db.query(Item).filter(Item.id == id).first() + if not db_item: + return None + + if patch_info.name is not None: + db_item.name = patch_info.name + if patch_info.price is not None: + db_item.price = patch_info.price + + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo.from_orm(db_item)) + diff --git a/hw4/shop_api/item/store/schemas.py b/hw4/shop_api/item/store/schemas.py new file mode 100644 index 00000000..f81d90d2 --- /dev/null +++ b/hw4/shop_api/item/store/schemas.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel +from typing import Optional + + +class ItemInfo(BaseModel): + name: str + price: float + deleted: bool = False + + class Config: + from_attributes = True + + +class PatchItemInfo(BaseModel): + name: Optional[str] = None + price: Optional[float] = None + + class Config: + from_attributes = True + + +class ItemEntity(BaseModel): + id: int + info: ItemInfo + + class Config: + from_attributes = True \ No newline at end of file diff --git a/hw4/shop_api/main.py b/hw4/shop_api/main.py new file mode 100644 index 00000000..5ced21e8 --- /dev/null +++ b/hw4/shop_api/main.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI + +from shop_api.routers.cart import router as cart +from shop_api.routers.item import router as item + +from prometheus_fastapi_instrumentator import Instrumentator + +from .db import Base, engine + +app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +Instrumentator().instrument(app).expose(app, endpoint="/metrics") +Base.metadata.create_all(bind=engine) diff --git a/hw4/shop_api/routers/cart.py b/hw4/shop_api/routers/cart.py new file mode 100644 index 00000000..58268b4e --- /dev/null +++ b/hw4/shop_api/routers/cart.py @@ -0,0 +1,80 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.db import get_db +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse + +import shop_api +import shop_api.item +import shop_api.item.store + +from sqlalchemy.orm import Session + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart(response: Response, db: Session = Depends(get_db)) -> CartResponse: + entity = store.create(db) + + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{cart_id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(cart_id: int, db: Session = Depends(get_db)): + entity = store.get_one(db, cart_id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{cart_id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + db: Session = Depends(get_db), + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, +): + entities = store.get_many( + db=db, + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + min_quantity=min_quantity, + max_quantity=max_quantity, + ) + return [CartResponse.from_entity(e) for e in entities] + + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + item_entity = shop_api.item.store.get_one(db, item_id) + + entity = store.add(db, cart_id, item_entity) + + return CartResponse.from_entity(entity) diff --git a/hw4/shop_api/routers/item.py b/hw4/shop_api/routers/item.py new file mode 100644 index 00000000..1c79dbca --- /dev/null +++ b/hw4/shop_api/routers/item.py @@ -0,0 +1,121 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from sqlalchemy.orm import Session + +from shop_api.db import get_db +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest + +router = APIRouter(prefix="/item", tags=["Item"]) + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item( + info: ItemRequest, + response: Response, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.add(db, info.as_item_info()) + response.headers["Location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: {"description": "Успешно найден товар"}, + HTTPStatus.NOT_FOUND: {"description": "Товар не найден"}, + }, +) +async def get_item_by_id(id: int, db: Session = Depends(get_db)) -> ItemResponse: + entity = store.get_one(db, id) + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + db: Session = Depends(get_db), + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] = False, +): + entities = store.get_many( + db=db, + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + show_deleted=show_deleted, + ) + return [ItemResponse.from_entity(e) for e in entities] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: {"description": "Успешно обновлён товар (PATCH)"}, + HTTPStatus.NOT_FOUND: {"description": "Товар не найден"}, + }, +) +async def patch_item( + id: int, + info: PatchItemRequest, + db: Session = Depends(get_db), +) -> ItemResponse: + entity = store.patch(db, id, info.as_patch_item_info()) + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: {"description": "Успешно обновлён товар (PUT)"}, + HTTPStatus.NOT_FOUND: {"description": "Товар не найден"}, + }, +) +async def put_item( + id: int, + info: ItemRequest, + db: Session = Depends(get_db), +) -> ItemResponse: + entity = store.update(db, id, info.as_item_info()) + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + return ItemResponse.from_entity(entity) + + +@router.delete( + "/{id}", + status_code=HTTPStatus.NO_CONTENT, + responses={ + HTTPStatus.NO_CONTENT: {"description": "Товар успешно удалён"}, + HTTPStatus.NOT_FOUND: {"description": "Товар не найден"}, + }, +) +async def delete_item(id: int, db: Session = Depends(get_db)) -> Response: + deleted = store.delete(db, id) + if not deleted: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + return Response(status_code=HTTPStatus.NO_CONTENT) From a6d6937232837d96b84a05f82c740bb2480e6233 Mon Sep 17 00:00:00 2001 From: SaylesMand Date: Thu, 23 Oct 2025 12:20:07 +0300 Subject: [PATCH 5/9] added transaction simulation --- hw4/transactions/non_repeatable.py | 76 ++++++++++++++++++++++++++++++ hw4/transactions/phantom_read.py | 76 ++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 hw4/transactions/non_repeatable.py create mode 100644 hw4/transactions/phantom_read.py diff --git a/hw4/transactions/non_repeatable.py b/hw4/transactions/non_repeatable.py new file mode 100644 index 00000000..24ca4ad9 --- /dev/null +++ b/hw4/transactions/non_repeatable.py @@ -0,0 +1,76 @@ +import threading +import time +from sqlalchemy import create_engine, Column, Integer, Float, select, update +from sqlalchemy.orm import declarative_base, Session + +engine = create_engine( + "postgresql://postgres:password@localhost:5432/shop_db", + isolation_level="REPEATABLE READ", # READ COMMITTED | REPEATABLE READ + future=True, +) +Base = declarative_base() + + +class Account(Base): + __tablename__ = "accounts" + id = Column(Integer, primary_key=True) + balance = Column(Float, nullable=False) + + def __repr__(self): + return f"" + + +Base.metadata.create_all(engine) + +with Session(engine) as s: + s.query(Account).delete() + s.add_all([Account(id=1, balance=100.0), Account(id=2, balance=200.0)]) + s.commit() + + +def t1(): + with Session(engine) as s: + print("T1: start") + row1 = s.execute(select(Account).where(Account.id == 1)).first() + print("T1: first read:", row1) + time.sleep(2) + + s.expire_all() + + row2 = s.execute(select(Account).where(Account.id == 1)).first() + print("T1: second read:", row2) + + +def t2(): + with Session(engine) as s: + time.sleep(1) + print("T2: updating...") + s.execute(update(Account).where(Account.id == 1).values(balance=500)) + s.commit() + print("T2: committed") + + +th1 = threading.Thread(target=t1) +th2 = threading.Thread(target=t2) +th1.start() +th2.start() +th1.join() +th2.join() + +""" +# Non-repeatable read при READ COMMITTED + +T1: start +T1: first read: (,) +T2: updating... +T2: committed +T1: second read: (,) + +# Non-repeatable read при REPEATABLE READ + +T1: start +T1: first read: (,) +T2: updating... +T2: committed +T1: second read: (,) +""" \ No newline at end of file diff --git a/hw4/transactions/phantom_read.py b/hw4/transactions/phantom_read.py new file mode 100644 index 00000000..3768c9c5 --- /dev/null +++ b/hw4/transactions/phantom_read.py @@ -0,0 +1,76 @@ +import threading +import time +from sqlalchemy import create_engine, Column, Integer, Float, insert, select +from sqlalchemy.orm import declarative_base, Session + +engine = create_engine( + "postgresql://postgres:password@localhost:5432/shop_db", + isolation_level="SERIALIZABLE", # REPEATABLE READ | SERIALIZABLE + future=True, +) +Base = declarative_base() + + +class Account(Base): + __tablename__ = "accounts" + id = Column(Integer, primary_key=True) + balance = Column(Float, nullable=False) + + def __repr__(self): + return f"" + + +Base.metadata.create_all(engine) + +with Session(engine) as s: + s.query(Account).delete() + s.add_all([Account(id=1, balance=100.0), Account(id=2, balance=200.0)]) + s.commit() + + +def t1(): + with Session(engine) as s: + print("T1: start") + users1 = s.execute(select(Account)).all() + print("T1: first read:", users1) + time.sleep(2) + users2 = s.execute(select(Account)).all() + print("T1: second read:", users2) + s.commit() # comment if REPEATABLE READ + +def t2(): + with Session(engine) as s: + time.sleep(1) + print("T2: inserting new row") + s.execute(insert(Account).values(id=3, balance=300)) + try: + s.commit() + print("T2: committed") + except Exception as e: + print("T2: serialization failed:", e) + + +th1 = threading.Thread(target=t1) +th2 = threading.Thread(target=t2) +th1.start() +th2.start() +th1.join() +th2.join() + +""" +# Phantom read при REPEATABLE READ + +T1: start +T1: first read: [(,), (,)] +T2: inserting new row +T2: committed +T1: second read: [(,), (,)] + +# Phantom read при SERIALIZABLE + +T1: start +T1: first read: [(,), (,)] +T2: inserting new row +T2: committed +T1: second read: [(,), (,)] +""" \ No newline at end of file From db2560b408f6a371deb59586d39d42872a272fb4 Mon Sep 17 00:00:00 2001 From: SaylesMand Date: Fri, 24 Oct 2025 13:15:29 +0300 Subject: [PATCH 6/9] covered the code with tests and configured CI --- .github/workflows/hw5-tests.yml | 39 ++++++++ .gitignore | 3 + hw4/shop_api/item/store/queries.py | 4 +- hw5/.dockerignore | 2 + hw5/Dockerfile | 12 +++ hw5/README.md | 35 +++++++ hw5/__init__.py | 0 hw5/docker-compose.yml | 54 +++++++++++ hw5/requirements.txt | 15 +++ hw5/settings/prometheus/prometheus.yml | 10 ++ hw5/shop_api/__init__.py | 0 hw5/shop_api/cart/contracts.py | 31 +++++++ hw5/shop_api/cart/store/__init__.py | 12 +++ hw5/shop_api/cart/store/models.py | 29 ++++++ hw5/shop_api/cart/store/queries.py | 119 ++++++++++++++++++++++++ hw5/shop_api/cart/store/schemas.py | 18 ++++ hw5/shop_api/db.py | 20 ++++ hw5/shop_api/item/contracts.py | 38 ++++++++ hw5/shop_api/item/store/__init__.py | 12 +++ hw5/shop_api/item/store/models.py | 15 +++ hw5/shop_api/item/store/queries.py | 84 +++++++++++++++++ hw5/shop_api/item/store/schemas.py | 27 ++++++ hw5/shop_api/main.py | 17 ++++ hw5/shop_api/routers/cart.py | 80 ++++++++++++++++ hw5/shop_api/routers/item.py | 121 +++++++++++++++++++++++++ hw5/tests/cart/test_cart_contracts.py | 0 hw5/tests/cart/test_cart_models.py | 18 ++++ hw5/tests/cart/test_cart_queries.py | 96 ++++++++++++++++++++ hw5/tests/cart/test_cart_router.py | 90 ++++++++++++++++++ hw5/tests/cart/test_cart_schemas.py | 0 hw5/tests/conftest.py | 54 +++++++++++ hw5/tests/item/test_item_contracts.py | 20 ++++ hw5/tests/item/test_item_models.py | 11 +++ hw5/tests/item/test_item_queries.py | 47 ++++++++++ hw5/tests/item/test_item_router.py | 120 ++++++++++++++++++++++++ hw5/tests/item/test_item_schemas.py | 18 ++++ hw5/tests/test_db.py | 18 ++++ hw5/tests/test_main.py | 4 + hw5/transactions/non_repeatable.py | 76 ++++++++++++++++ hw5/transactions/phantom_read.py | 76 ++++++++++++++++ 40 files changed, 1443 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/hw5-tests.yml create mode 100644 hw5/.dockerignore create mode 100644 hw5/Dockerfile create mode 100644 hw5/README.md create mode 100644 hw5/__init__.py create mode 100644 hw5/docker-compose.yml create mode 100644 hw5/requirements.txt create mode 100644 hw5/settings/prometheus/prometheus.yml create mode 100644 hw5/shop_api/__init__.py create mode 100644 hw5/shop_api/cart/contracts.py create mode 100644 hw5/shop_api/cart/store/__init__.py create mode 100644 hw5/shop_api/cart/store/models.py create mode 100644 hw5/shop_api/cart/store/queries.py create mode 100644 hw5/shop_api/cart/store/schemas.py create mode 100644 hw5/shop_api/db.py create mode 100644 hw5/shop_api/item/contracts.py create mode 100644 hw5/shop_api/item/store/__init__.py create mode 100644 hw5/shop_api/item/store/models.py create mode 100644 hw5/shop_api/item/store/queries.py create mode 100644 hw5/shop_api/item/store/schemas.py create mode 100644 hw5/shop_api/main.py create mode 100644 hw5/shop_api/routers/cart.py create mode 100644 hw5/shop_api/routers/item.py create mode 100644 hw5/tests/cart/test_cart_contracts.py create mode 100644 hw5/tests/cart/test_cart_models.py create mode 100644 hw5/tests/cart/test_cart_queries.py create mode 100644 hw5/tests/cart/test_cart_router.py create mode 100644 hw5/tests/cart/test_cart_schemas.py create mode 100644 hw5/tests/conftest.py create mode 100644 hw5/tests/item/test_item_contracts.py create mode 100644 hw5/tests/item/test_item_models.py create mode 100644 hw5/tests/item/test_item_queries.py create mode 100644 hw5/tests/item/test_item_router.py create mode 100644 hw5/tests/item/test_item_schemas.py create mode 100644 hw5/tests/test_db.py create mode 100644 hw5/tests/test_main.py create mode 100644 hw5/transactions/non_repeatable.py create mode 100644 hw5/transactions/phantom_read.py diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 00000000..e92f18ed --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,39 @@ +name: "HW2 Tests" + +# Запускаем тесты при изменении файлов в hw5/ +on: + pull_request: + branches: [ main ] + paths: [ 'hw5/**' ] + push: + branches: [ main ] + paths: [ 'hw5/**' ] + +jobs: + test-hw2: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: /hw5 + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + working-directory: /hw5 + env: + PYTHONPATH: ${{ github.workspace }}/hw5 + run: | + pytest tests -v diff --git a/.gitignore b/.gitignore index 852216e6..b28499a1 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ dmypy.json # macOS .DS_Store + +#other +test.db \ No newline at end of file diff --git a/hw4/shop_api/item/store/queries.py b/hw4/shop_api/item/store/queries.py index f3ed3460..4bd04901 100644 --- a/hw4/shop_api/item/store/queries.py +++ b/hw4/shop_api/item/store/queries.py @@ -64,7 +64,7 @@ def update(db: Session, id: int, info: ItemInfo) -> Optional[ItemEntity]: db_item.deleted = info.deleted db.commit() db.refresh(db_item) - return ItemEntity(id=db_item.id, info=ItemInfo.from_orm(db_item)) + return ItemEntity(id=db_item.id, info=ItemInfo.model_validate(db_item)) return None @@ -80,5 +80,5 @@ def patch(db: Session, id: int, patch_info: PatchItemInfo) -> Optional[ItemEntit db.commit() db.refresh(db_item) - return ItemEntity(id=db_item.id, info=ItemInfo.from_orm(db_item)) + return ItemEntity(id=db_item.id, info=ItemInfo.model_validate(db_item)) diff --git a/hw5/.dockerignore b/hw5/.dockerignore new file mode 100644 index 00000000..de793787 --- /dev/null +++ b/hw5/.dockerignore @@ -0,0 +1,2 @@ +.github +.venv \ No newline at end of file diff --git a/hw5/Dockerfile b/hw5/Dockerfile new file mode 100644 index 00000000..65c0f7ad --- /dev/null +++ b/hw5/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR /app +COPY . . + +RUN pip install -r requirements.txt + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] + diff --git a/hw5/README.md b/hw5/README.md new file mode 100644 index 00000000..c8f84818 --- /dev/null +++ b/hw5/README.md @@ -0,0 +1,35 @@ +# ДЗ + +1) Добиться 95% покрытия тестами вашей второй домашки - 1 балл + +2) Настроить автозапуск этих тестов в CI, если вы подключали сторонюю БД, то можно посмотреть вот [сюда](https://dev.to/kashifsoofi/integration-test-postgres-using-github-actions-3lln), чтобы поддержать тесты с ней в CI. По итогу у вас должен получится зеленый пайплайн - оценивается в еще 2 балла. + + +```bash +$env:PYTHONPATH = "$PWD" +``` + +```bash +pytest --cov=shop_api/routers +``` + +## tests coverage +Name Stmts Miss Cover +----------------------------------------------------- +shop_api\__init__.py 0 0 100% +shop_api\cart\contracts.py 15 0 100% +shop_api\cart\store\__init__.py 3 0 100% +shop_api\cart\store\models.py 18 0 100% +shop_api\cart\store\queries.py 56 0 100% +shop_api\cart\store\schemas.py 13 0 100% +shop_api\db.py 13 0 100% +shop_api\item\contracts.py 22 0 100% +shop_api\item\store\__init__.py 3 0 100% +shop_api\item\store\models.py 10 0 100% +shop_api\item\store\queries.py 53 1 98% +shop_api\item\store\schemas.py 18 0 100% +shop_api\main.py 11 1 91% +shop_api\routers\cart.py 32 0 100% +shop_api\routers\item.py 42 0 100% +----------------------------------------------------- +TOTAL 309 2 99% \ No newline at end of file diff --git a/hw5/__init__.py b/hw5/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/docker-compose.yml b/hw5/docker-compose.yml new file mode 100644 index 00000000..da5236ec --- /dev/null +++ b/hw5/docker-compose.yml @@ -0,0 +1,54 @@ +services: + postgres: + image: postgres:15 + container_name: postgres + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: db + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + local: + build: + context: . + restart: always + ports: + - 8080:8080 + environment: + - DATABASE_URL=postgresql://postgres:password@postgres:5432/db + depends_on: + - postgres + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + depends_on: + - prometheus + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + depends_on: + - local + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw5/requirements.txt b/hw5/requirements.txt new file mode 100644 index 00000000..65fce71c --- /dev/null +++ b/hw5/requirements.txt @@ -0,0 +1,15 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + +prometheus_fastapi_instrumentator +sqlalchemy +psycopg2-binary +pytest-cov +pytest-asyncio \ No newline at end of file diff --git a/hw5/settings/prometheus/prometheus.yml b/hw5/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..7fa1951b --- /dev/null +++ b/hw5/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw5/shop_api/__init__.py b/hw5/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/shop_api/cart/contracts.py b/hw5/shop_api/cart/contracts.py new file mode 100644 index 00000000..d44416a1 --- /dev/null +++ b/hw5/shop_api/cart/contracts.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from typing import List + +from shop_api.cart.store.schemas import CartEntity + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + +class CartResponse(BaseModel): + id: int + items: List[CartItemResponse] + price: float + + @staticmethod + def from_entity(entity: "CartEntity") -> "CartResponse": + return CartResponse( + id=entity.id, + items=[ + CartItemResponse( + id=item.id, + name=item.name, + quantity=item.quantity, + available=item.available, + ) + for item in entity.items + ], + price=entity.price, + ) \ No newline at end of file diff --git a/hw5/shop_api/cart/store/__init__.py b/hw5/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..d5283b5a --- /dev/null +++ b/hw5/shop_api/cart/store/__init__.py @@ -0,0 +1,12 @@ +from .models import CartItem, Cart +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartItem", + "Cart", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw5/shop_api/cart/store/models.py b/hw5/shop_api/cart/store/models.py new file mode 100644 index 00000000..eb033c4b --- /dev/null +++ b/hw5/shop_api/cart/store/models.py @@ -0,0 +1,29 @@ +from sqlalchemy import Column, Integer, Boolean, Float, ForeignKey, String +from sqlalchemy.orm import relationship + +from shop_api.db import Base + + +class CartItem(Base): + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, ForeignKey("carts.id")) + item_id = Column(Integer, ForeignKey("items.id")) + name = Column(String(255)) + quantity = Column(Integer, default=1) + available = Column(Boolean, default=True) + + cart = relationship("Cart", back_populates="items") + item = relationship("Item", back_populates="cart_items") + + +class Cart(Base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, index=True) + price = Column(Float, default=0.0) + + items = relationship( + "CartItem", back_populates="cart", cascade="all, delete-orphan" + ) diff --git a/hw5/shop_api/cart/store/queries.py b/hw5/shop_api/cart/store/queries.py new file mode 100644 index 00000000..b989ffae --- /dev/null +++ b/hw5/shop_api/cart/store/queries.py @@ -0,0 +1,119 @@ +from typing import Iterable, Optional +from sqlalchemy.orm import Session + +from shop_api.cart.store.schemas import CartEntity, CartItemEntity +from shop_api.item.store.schemas import ItemEntity +from shop_api.cart.store.models import Cart, CartItem + + +def create(db: Session) -> CartEntity: + db_cart = Cart() + db.add(db_cart) + db.commit() + db.refresh(db_cart) + return CartEntity(id=db_cart.id, price=db_cart.price, items=[]) + + +def add(db: Session, cart_id: int, item_entity: ItemEntity) -> Optional[CartEntity]: + cart = db.query(Cart).filter(Cart.id == cart_id).first() + if not cart: + return None + + cart_item = ( + db.query(CartItem) + .filter(CartItem.cart_id == cart_id, CartItem.item_id == item_entity.id) + .first() + ) + + if cart_item: + cart_item.quantity += 1 + cart_item.available = not item_entity.info.deleted + else: + cart_item = CartItem( + cart_id=cart_id, + item_id=item_entity.id, + name=item_entity.info.name, + quantity=1, + available=not item_entity.info.deleted, + ) + db.add(cart_item) + + cart.price += item_entity.info.price + db.commit() + db.refresh(cart) + + cart_items = [ + CartItemEntity( + id=item.id, + name=item.name, + quantity=item.quantity, + available=item.available + ) + for item in cart.items + ] + return CartEntity(id=cart.id, price=cart.price, items=cart_items) + + +def delete(db: Session, id: int) -> bool: + db_cart = db.query(Cart).filter(Cart.id == id).first() + if db_cart: + db.delete(db_cart) + db.commit() + return True + return False + + +def get_one(db: Session, id: int) -> Optional[CartEntity]: + db_cart = db.query(Cart).filter(Cart.id == id).first() + if db_cart: + cart_items = [ + CartItemEntity( + id=item.id, + name=item.name, + quantity=item.quantity, + available=item.available + ) + for item in db_cart.items + ] + return CartEntity(id=db_cart.id, price=db_cart.price, items=cart_items) + return None + + +def get_many( + db: Session, + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + min_quantity: int = None, + max_quantity: int = None, +) -> Iterable[CartEntity]: + query = db.query(Cart) + if min_price is not None: + query = query.filter(Cart.price >= min_price) + if max_price is not None: + query = query.filter(Cart.price <= max_price) + + carts = query.offset(offset).limit(limit).all() + + result = [] + for cart in carts: + total_quantity = sum(item.quantity for item in cart.items) + + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + cart_items = [ + CartItemEntity( + id=item.id, + name=item.name, + quantity=item.quantity, + available=item.available + ) + for item in cart.items + ] + result.append(CartEntity(id=cart.id, price=cart.price, items=cart_items)) + + return result \ No newline at end of file diff --git a/hw5/shop_api/cart/store/schemas.py b/hw5/shop_api/cart/store/schemas.py new file mode 100644 index 00000000..82005ec5 --- /dev/null +++ b/hw5/shop_api/cart/store/schemas.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from typing import List + + +class CartEntity(BaseModel): + id: int + price: float + items: List["CartItemEntity"] + + +class CartItemEntity(BaseModel): + id: int + name: str + quantity: int + available: bool + + class Config: + from_attributes = True diff --git a/hw5/shop_api/db.py b/hw5/shop_api/db.py new file mode 100644 index 00000000..891314d4 --- /dev/null +++ b/hw5/shop_api/db.py @@ -0,0 +1,20 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +DATABASE_URL = os.getenv( + "DATABASE_URL", "postgresql://postgres:password@localhost:5432/shop_db" +) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/hw5/shop_api/item/contracts.py b/hw5/shop_api/item/contracts.py new file mode 100644 index 00000000..7984a031 --- /dev/null +++ b/hw5/shop_api/item/contracts.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, ConfigDict + +from shop_api.item.store.schemas import ItemEntity, ItemInfo, PatchItemInfo + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: "ItemEntity") -> "ItemResponse": + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + + +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) diff --git a/hw5/shop_api/item/store/__init__.py b/hw5/shop_api/item/store/__init__.py new file mode 100644 index 00000000..e5ae556c --- /dev/null +++ b/hw5/shop_api/item/store/__init__.py @@ -0,0 +1,12 @@ +from .models import Item +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "Item", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw5/shop_api/item/store/models.py b/hw5/shop_api/item/store/models.py new file mode 100644 index 00000000..156b8c0a --- /dev/null +++ b/hw5/shop_api/item/store/models.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, Boolean, Float, String +from sqlalchemy.orm import relationship + +from shop_api.db import Base + + +class Item(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + + cart_items = relationship("CartItem", back_populates="item") diff --git a/hw5/shop_api/item/store/queries.py b/hw5/shop_api/item/store/queries.py new file mode 100644 index 00000000..4bd04901 --- /dev/null +++ b/hw5/shop_api/item/store/queries.py @@ -0,0 +1,84 @@ +from typing import Iterable, Optional + +from shop_api.item.store.schemas import ItemEntity, ItemInfo, PatchItemInfo +from shop_api.item.store.models import Item +from sqlalchemy.orm import Session + + +def add(db: Session, info: ItemInfo) -> ItemEntity: + db_item = Item(name=info.name, price=info.price, deleted=info.deleted) + db.add(db_item) + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo.model_validate(db_item)) + + +def delete(db: Session, id: int) -> bool: + db_item = db.query(Item).filter(Item.id == id).first() + if db_item: + db.delete(db_item) + db.commit() + return True + return False + + +def get_one(db: Session, id: int) -> Optional[ItemEntity]: + db_item = db.query(Item).filter(Item.id == id).first() + if db_item: + return ItemEntity(id=db_item.id, info=ItemInfo.model_validate(db_item)) + return None + + +def get_many( + db: Session, + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + show_deleted: bool = False, +) -> Iterable[ItemEntity]: + query = db.query(Item) + + if min_price is not None: + query = query.filter(Item.price >= min_price) + if max_price is not None: + query = query.filter(Item.price <= max_price) + if not show_deleted: + query = query.filter(Item.deleted.is_(False)) + + + items = query.offset(offset).limit(limit).all() + + return [ + ItemEntity(id=item.id, info=ItemInfo.model_validate(item)) + for item in items + ] + + + +def update(db: Session, id: int, info: ItemInfo) -> Optional[ItemEntity]: + db_item = db.query(Item).filter(Item.id == id).first() + if db_item: + db_item.name = info.name + db_item.price = info.price + db_item.deleted = info.deleted + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo.model_validate(db_item)) + return None + + +def patch(db: Session, id: int, patch_info: PatchItemInfo) -> Optional[ItemEntity]: + db_item = db.query(Item).filter(Item.id == id).first() + if not db_item: + return None + + if patch_info.name is not None: + db_item.name = patch_info.name + if patch_info.price is not None: + db_item.price = patch_info.price + + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo.model_validate(db_item)) + diff --git a/hw5/shop_api/item/store/schemas.py b/hw5/shop_api/item/store/schemas.py new file mode 100644 index 00000000..f81d90d2 --- /dev/null +++ b/hw5/shop_api/item/store/schemas.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel +from typing import Optional + + +class ItemInfo(BaseModel): + name: str + price: float + deleted: bool = False + + class Config: + from_attributes = True + + +class PatchItemInfo(BaseModel): + name: Optional[str] = None + price: Optional[float] = None + + class Config: + from_attributes = True + + +class ItemEntity(BaseModel): + id: int + info: ItemInfo + + class Config: + from_attributes = True \ No newline at end of file diff --git a/hw5/shop_api/main.py b/hw5/shop_api/main.py new file mode 100644 index 00000000..1ae66bfc --- /dev/null +++ b/hw5/shop_api/main.py @@ -0,0 +1,17 @@ +from fastapi import FastAPI + +from shop_api.routers.cart import router as cart +from shop_api.routers.item import router as item + +from prometheus_fastapi_instrumentator import Instrumentator + +from .db import Base, engine + +app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +Instrumentator().instrument(app).expose(app, endpoint="/metrics") +if __name__ == "__main__": + Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/hw5/shop_api/routers/cart.py b/hw5/shop_api/routers/cart.py new file mode 100644 index 00000000..58268b4e --- /dev/null +++ b/hw5/shop_api/routers/cart.py @@ -0,0 +1,80 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.db import get_db +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse + +import shop_api +import shop_api.item +import shop_api.item.store + +from sqlalchemy.orm import Session + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart(response: Response, db: Session = Depends(get_db)) -> CartResponse: + entity = store.create(db) + + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{cart_id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(cart_id: int, db: Session = Depends(get_db)): + entity = store.get_one(db, cart_id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{cart_id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + db: Session = Depends(get_db), + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, +): + entities = store.get_many( + db=db, + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + min_quantity=min_quantity, + max_quantity=max_quantity, + ) + return [CartResponse.from_entity(e) for e in entities] + + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + item_entity = shop_api.item.store.get_one(db, item_id) + + entity = store.add(db, cart_id, item_entity) + + return CartResponse.from_entity(entity) diff --git a/hw5/shop_api/routers/item.py b/hw5/shop_api/routers/item.py new file mode 100644 index 00000000..1c79dbca --- /dev/null +++ b/hw5/shop_api/routers/item.py @@ -0,0 +1,121 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from sqlalchemy.orm import Session + +from shop_api.db import get_db +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest + +router = APIRouter(prefix="/item", tags=["Item"]) + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item( + info: ItemRequest, + response: Response, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.add(db, info.as_item_info()) + response.headers["Location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: {"description": "Успешно найден товар"}, + HTTPStatus.NOT_FOUND: {"description": "Товар не найден"}, + }, +) +async def get_item_by_id(id: int, db: Session = Depends(get_db)) -> ItemResponse: + entity = store.get_one(db, id) + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + db: Session = Depends(get_db), + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] = False, +): + entities = store.get_many( + db=db, + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + show_deleted=show_deleted, + ) + return [ItemResponse.from_entity(e) for e in entities] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: {"description": "Успешно обновлён товар (PATCH)"}, + HTTPStatus.NOT_FOUND: {"description": "Товар не найден"}, + }, +) +async def patch_item( + id: int, + info: PatchItemRequest, + db: Session = Depends(get_db), +) -> ItemResponse: + entity = store.patch(db, id, info.as_patch_item_info()) + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: {"description": "Успешно обновлён товар (PUT)"}, + HTTPStatus.NOT_FOUND: {"description": "Товар не найден"}, + }, +) +async def put_item( + id: int, + info: ItemRequest, + db: Session = Depends(get_db), +) -> ItemResponse: + entity = store.update(db, id, info.as_item_info()) + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + return ItemResponse.from_entity(entity) + + +@router.delete( + "/{id}", + status_code=HTTPStatus.NO_CONTENT, + responses={ + HTTPStatus.NO_CONTENT: {"description": "Товар успешно удалён"}, + HTTPStatus.NOT_FOUND: {"description": "Товар не найден"}, + }, +) +async def delete_item(id: int, db: Session = Depends(get_db)) -> Response: + deleted = store.delete(db, id) + if not deleted: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + return Response(status_code=HTTPStatus.NO_CONTENT) diff --git a/hw5/tests/cart/test_cart_contracts.py b/hw5/tests/cart/test_cart_contracts.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/tests/cart/test_cart_models.py b/hw5/tests/cart/test_cart_models.py new file mode 100644 index 00000000..058cde50 --- /dev/null +++ b/hw5/tests/cart/test_cart_models.py @@ -0,0 +1,18 @@ +from shop_api.cart.store.models import Cart, CartItem +from shop_api.item.store.models import Item + + +def test_cart_and_cartitem_relationship(session): + cart = Cart(price=0.0) + item = Item(name="Pen", price=2.5) + session.add_all([cart, item]) + session.commit() + + cart_item = CartItem(cart_id=cart.id, item_id=item.id, name="Pen", quantity=3) + session.add(cart_item) + session.commit() + + loaded_cart = session.query(Cart).first() + assert len(loaded_cart.items) == 1 + assert loaded_cart.items[0].quantity == 3 + assert loaded_cart.items[0].item.name == "Pen" diff --git a/hw5/tests/cart/test_cart_queries.py b/hw5/tests/cart/test_cart_queries.py new file mode 100644 index 00000000..9dd68fe4 --- /dev/null +++ b/hw5/tests/cart/test_cart_queries.py @@ -0,0 +1,96 @@ +import pytest +from shop_api.cart.store import queries as cart_queries +from shop_api.item.store import queries as item_queries +from shop_api.item.store.schemas import ItemInfo, ItemEntity + + +@pytest.fixture +def sample_item(session): + info = ItemInfo(name="Book", price=10.0) + item = item_queries.add(session, info) + return item + + +def test_create_cart(session): + cart = cart_queries.create(session) + assert cart.id > 0 + assert cart.price == 0.0 + assert cart.items == [] + + +def test_add_item(session, sample_item): + cart = cart_queries.create(session) + updated = cart_queries.add(session, cart.id, sample_item) + assert updated.price == 10.0 + assert updated.items[0].quantity == 1 + + +def test_add_same_item_increments_quantity(session, sample_item): + cart = cart_queries.create(session) + cart_queries.add(session, cart.id, sample_item) + updated = cart_queries.add(session, cart.id, sample_item) + assert updated.items[0].quantity == 2 + assert updated.price == 20.0 + + +def test_add_deleted_item_unavailable(session): + + cart = cart_queries.create(session) + deleted_item = ItemEntity(id=1, info=ItemInfo(name="Old", price=5, deleted=True)) + result = cart_queries.add(session, cart.id, deleted_item) + assert result.items[0].available is False + + +def test_get_one(session, sample_item): + cart = cart_queries.create(session) + cart_queries.add(session, cart.id, sample_item) + found = cart_queries.get_one(session, cart.id) + assert found.id == cart.id + assert found.price == 10.0 + + +def test_get_many_with_filters(session, sample_item): + c1 = cart_queries.create(session) + c2 = cart_queries.create(session) + cart_queries.add(session, c1.id, sample_item) + cart_queries.add(session, c2.id, sample_item) + cart_queries.add(session, c2.id, sample_item) + result = list(cart_queries.get_many(session, min_price=15)) + assert len(result) == 1 + assert result[0].id == c2.id + + +def test_delete_cart(session): + cart = cart_queries.create(session) + assert cart_queries.delete(session, cart.id) + assert not cart_queries.get_one(session, cart.id) + + +def test_add_to_nonexistent_cart_returns_none(session, sample_item): + # cart_id не существует + result = cart_queries.add(session, 999, sample_item) + assert result is None + + +def test_delete_nonexistent_cart_returns_false(session): + # Корзины с таким ID нет + result = cart_queries.delete(session, 999) + assert result is False + + +def test_get_many_with_quantity_filters(session, sample_item): + c1 = cart_queries.create(session) + c2 = cart_queries.create(session) + # в первой корзине 1 товар + cart_queries.add(session, c1.id, sample_item) + # во второй — 3 + cart_queries.add(session, c2.id, sample_item) + cart_queries.add(session, c2.id, sample_item) + cart_queries.add(session, c2.id, sample_item) + + result_min = list(cart_queries.get_many(session, min_quantity=2)) + result_max = list(cart_queries.get_many(session, max_quantity=2)) + + # Проверяем, что фильтры работают + assert all(sum(i.quantity for i in c.items) >= 2 for c in result_min) + assert all(sum(i.quantity for i in c.items) <= 2 for c in result_max) diff --git a/hw5/tests/cart/test_cart_router.py b/hw5/tests/cart/test_cart_router.py new file mode 100644 index 00000000..cfaab79b --- /dev/null +++ b/hw5/tests/cart/test_cart_router.py @@ -0,0 +1,90 @@ +from http import HTTPStatus +from typing import Any +import pytest +from shop_api.item.store.schemas import ItemInfo +from fastapi.testclient import TestClient +from shop_api.item.store import queries as item_queries + + +@pytest.fixture +def sample_item(session): + info = ItemInfo(name="TestItem", price=50.0) + return item_queries.add(session, info) + + +def test_post_cart(client): + response = client.post("/cart/") + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert data["price"] == 0 + assert data["items"] == [] + + +def test_get_cart_by_id(client): + # Сначала создаем корзину + post = client.post("/cart/") + cart_id = post.json()["id"] + + response = client.get(f"/cart/{cart_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == cart_id + + +@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), + ({"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(client: TestClient, 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 "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_get_cart_not_found(client): + response = client.get("/cart/9999") + assert response.status_code == 404 + + +def test_add_item_to_cart(client, sample_item): + post_cart = client.post("/cart/") + cart_id = post_cart.json()["id"] + item_id = sample_item.id + + response = client.post(f"/cart/{cart_id}/add/{item_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == cart_id + assert len(data["items"]) == 1 + assert data["items"][0]["name"] == "TestItem" + assert data["items"][0]["quantity"] == 1 diff --git a/hw5/tests/cart/test_cart_schemas.py b/hw5/tests/cart/test_cart_schemas.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/tests/conftest.py b/hw5/tests/conftest.py new file mode 100644 index 00000000..58d62fa6 --- /dev/null +++ b/hw5/tests/conftest.py @@ -0,0 +1,54 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from fastapi.testclient import TestClient + +from shop_api.db import Base, get_db +from shop_api.main import app +from shop_api.cart.store.models import Cart, CartItem +from shop_api.item.store.models import Item + + +engine = create_engine("sqlite:///./test.db", connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function", autouse=True) +def clean_db(): + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + yield + + +@pytest.fixture(scope="session", autouse=True) +def setup_database(): + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture(scope="function") +def session(): + """Создаём новую сессию для каждого теста""" + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +@pytest.fixture(scope="function") +def client(session): + """Создаёт TestClient с переопределённой зависимостью get_db""" + + def override_get_db(): + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() diff --git a/hw5/tests/item/test_item_contracts.py b/hw5/tests/item/test_item_contracts.py new file mode 100644 index 00000000..4e3057d5 --- /dev/null +++ b/hw5/tests/item/test_item_contracts.py @@ -0,0 +1,20 @@ +from shop_api.item.contracts import ItemResponse, ItemRequest, PatchItemRequest +from shop_api.item.store.schemas import ItemEntity, ItemInfo + + +def test_itemresponse_from_entity(): + entity = ItemEntity(id=1, info=ItemInfo(name="Marker", price=1.5)) + resp = ItemResponse.from_entity(entity) + assert resp.name == "Marker" + + +def test_itemrequest_to_info(): + req = ItemRequest(name="Book", price=12.0, deleted=False) + info = req.as_item_info() + assert info.name == "Book" + + +def test_patchitemrequest_to_patch_info(): + req = PatchItemRequest(name="Pen", price=5.0) + patch = req.as_patch_item_info() + assert patch.price == 5.0 diff --git a/hw5/tests/item/test_item_models.py b/hw5/tests/item/test_item_models.py new file mode 100644 index 00000000..1cdb8a35 --- /dev/null +++ b/hw5/tests/item/test_item_models.py @@ -0,0 +1,11 @@ +from shop_api.item.store.models import Item + + +def test_item_model_fields(session): + item = Item(name="Marker", price=1.5) + session.add(item) + session.commit() + result = session.query(Item).first() + assert result.name == "Marker" + assert result.price == 1.5 + assert result.deleted is False diff --git a/hw5/tests/item/test_item_queries.py b/hw5/tests/item/test_item_queries.py new file mode 100644 index 00000000..f827a668 --- /dev/null +++ b/hw5/tests/item/test_item_queries.py @@ -0,0 +1,47 @@ +import pytest +from shop_api.item.store import queries +from shop_api.item.store.schemas import ItemInfo, PatchItemInfo + + +@pytest.fixture +def sample_item(session): + return queries.add(session, ItemInfo(name="Pencil", price=2.0)) + + +def test_add_item(session): + item = queries.add(session, ItemInfo(name="Book", price=10.0)) + assert item.id > 0 + assert item.info.name == "Book" + + +def test_get_one(session, sample_item): + found = queries.get_one(session, sample_item.id) + assert found.info.name == "Pencil" + + +def test_get_many(session): + queries.add(session, ItemInfo(name="Pen", price=3.0)) + queries.add(session, ItemInfo(name="Notebook", price=7.0)) + items = list(queries.get_many(session, min_price=5)) + assert len(items) == 1 + assert items[0].info.name == "Notebook" + + +def test_update_item(session, sample_item): + updated_info = ItemInfo(name="Pen", price=4.5) + updated = queries.update(session, sample_item.id, updated_info) + assert updated.info.price == 4.5 + + +def test_patch_item(session, sample_item): + patched = queries.patch(session, sample_item.id, PatchItemInfo(price=5.0)) + assert patched.info.price == 5.0 + + +def test_delete_item(session, sample_item): + assert queries.delete(session, sample_item.id) + assert queries.get_one(session, sample_item.id) is None + + +def test_delete_nonexistent(session): + assert queries.delete(session, 999) is False diff --git a/hw5/tests/item/test_item_router.py b/hw5/tests/item/test_item_router.py new file mode 100644 index 00000000..1af860e3 --- /dev/null +++ b/hw5/tests/item/test_item_router.py @@ -0,0 +1,120 @@ +from http import HTTPStatus +from typing import Any + +from fastapi.testclient import TestClient +import pytest + + +def test_post_item(client): + payload = {"name": "Book", "price": 20.5, "deleted": False} + response = client.post("/item/", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["name"] == payload["name"] + assert data["price"] == payload["price"] + assert data["deleted"] == payload["deleted"] + + +def test_get_item_by_id(client): + payload = {"name": "Book2", "price": 15.0} + post = client.post("/item/", json=payload) + item_id = post.json()["id"] + + response = client.get(f"/item/{item_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == item_id + + +def test_get_item_not_found(client): + response = client.get("/item/9999") + assert response.status_code == 404 + + +@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(client: TestClient, 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) + + +def test_patch_item(client): + payload = {"name": "Book3", "price": 10.0} + post = client.post("/item/", json=payload) + item_id = post.json()["id"] + + patch_payload = {"price": 12.0} + response = client.patch(f"/item/{item_id}", json=patch_payload) + assert response.status_code == 200 + data = response.json() + assert data["price"] == 12.0 + + +def test_put_item(client): + payload = {"name": "Book4", "price": 5.0} + post = client.post("/item/", json=payload) + item_id = post.json()["id"] + + put_payload = {"name": "Book4_updated", "price": 6.0, "deleted": False} + response = client.put(f"/item/{item_id}", json=put_payload) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Book4_updated" + assert data["price"] == 6.0 + + +def test_delete_item(client): + payload = {"name": "Book5", "price": 8.0} + post = client.post("/item/", json=payload) + item_id = post.json()["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == 204 + + response2 = client.get(f"/item/{item_id}") + assert response2.status_code == 404 + + +def test_patch_item_not_found(client): + response = client.patch("/item/9999", json={"price": 50}) + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + +def test_put_item_not_found(client): + response = client.put("/item/9999", json={"name": "Nope", "price": 99.9, "deleted": False}) + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + +def test_delete_item_not_found(client): + response = client.delete("/item/9999") + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() diff --git a/hw5/tests/item/test_item_schemas.py b/hw5/tests/item/test_item_schemas.py new file mode 100644 index 00000000..41b91086 --- /dev/null +++ b/hw5/tests/item/test_item_schemas.py @@ -0,0 +1,18 @@ +from shop_api.item.store.schemas import ItemInfo, ItemEntity, PatchItemInfo + + +def test_iteminfo_model(): + info = ItemInfo(name="Book", price=9.9) + assert not info.deleted + assert info.name == "Book" + + +def test_patchiteminfo_model(): + patch = PatchItemInfo(price=4.4) + assert patch.price == 4.4 + + +def test_itementity_model(): + info = ItemInfo(name="Pencil", price=2.0) + entity = ItemEntity(id=1, info=info) + assert entity.info.name == "Pencil" diff --git a/hw5/tests/test_db.py b/hw5/tests/test_db.py new file mode 100644 index 00000000..fa850ddb --- /dev/null +++ b/hw5/tests/test_db.py @@ -0,0 +1,18 @@ +from shop_api import db + +def test_engine_and_session_creation(): + assert str(db.engine.url).startswith("postgresql") or str(db.engine.url).startswith("sqlite") + + session = db.SessionLocal() + assert session.is_active + session.close() + + +def test_get_db_generator_closes_session(): + generator = db.get_db() + session = next(generator) + assert session.is_active + try: + next(generator) + except StopIteration: + pass diff --git a/hw5/tests/test_main.py b/hw5/tests/test_main.py new file mode 100644 index 00000000..6f223ef4 --- /dev/null +++ b/hw5/tests/test_main.py @@ -0,0 +1,4 @@ +from shop_api.main import app + +def test_app_importable(): + assert app.title \ No newline at end of file diff --git a/hw5/transactions/non_repeatable.py b/hw5/transactions/non_repeatable.py new file mode 100644 index 00000000..24ca4ad9 --- /dev/null +++ b/hw5/transactions/non_repeatable.py @@ -0,0 +1,76 @@ +import threading +import time +from sqlalchemy import create_engine, Column, Integer, Float, select, update +from sqlalchemy.orm import declarative_base, Session + +engine = create_engine( + "postgresql://postgres:password@localhost:5432/shop_db", + isolation_level="REPEATABLE READ", # READ COMMITTED | REPEATABLE READ + future=True, +) +Base = declarative_base() + + +class Account(Base): + __tablename__ = "accounts" + id = Column(Integer, primary_key=True) + balance = Column(Float, nullable=False) + + def __repr__(self): + return f"" + + +Base.metadata.create_all(engine) + +with Session(engine) as s: + s.query(Account).delete() + s.add_all([Account(id=1, balance=100.0), Account(id=2, balance=200.0)]) + s.commit() + + +def t1(): + with Session(engine) as s: + print("T1: start") + row1 = s.execute(select(Account).where(Account.id == 1)).first() + print("T1: first read:", row1) + time.sleep(2) + + s.expire_all() + + row2 = s.execute(select(Account).where(Account.id == 1)).first() + print("T1: second read:", row2) + + +def t2(): + with Session(engine) as s: + time.sleep(1) + print("T2: updating...") + s.execute(update(Account).where(Account.id == 1).values(balance=500)) + s.commit() + print("T2: committed") + + +th1 = threading.Thread(target=t1) +th2 = threading.Thread(target=t2) +th1.start() +th2.start() +th1.join() +th2.join() + +""" +# Non-repeatable read при READ COMMITTED + +T1: start +T1: first read: (,) +T2: updating... +T2: committed +T1: second read: (,) + +# Non-repeatable read при REPEATABLE READ + +T1: start +T1: first read: (,) +T2: updating... +T2: committed +T1: second read: (,) +""" \ No newline at end of file diff --git a/hw5/transactions/phantom_read.py b/hw5/transactions/phantom_read.py new file mode 100644 index 00000000..3768c9c5 --- /dev/null +++ b/hw5/transactions/phantom_read.py @@ -0,0 +1,76 @@ +import threading +import time +from sqlalchemy import create_engine, Column, Integer, Float, insert, select +from sqlalchemy.orm import declarative_base, Session + +engine = create_engine( + "postgresql://postgres:password@localhost:5432/shop_db", + isolation_level="SERIALIZABLE", # REPEATABLE READ | SERIALIZABLE + future=True, +) +Base = declarative_base() + + +class Account(Base): + __tablename__ = "accounts" + id = Column(Integer, primary_key=True) + balance = Column(Float, nullable=False) + + def __repr__(self): + return f"" + + +Base.metadata.create_all(engine) + +with Session(engine) as s: + s.query(Account).delete() + s.add_all([Account(id=1, balance=100.0), Account(id=2, balance=200.0)]) + s.commit() + + +def t1(): + with Session(engine) as s: + print("T1: start") + users1 = s.execute(select(Account)).all() + print("T1: first read:", users1) + time.sleep(2) + users2 = s.execute(select(Account)).all() + print("T1: second read:", users2) + s.commit() # comment if REPEATABLE READ + +def t2(): + with Session(engine) as s: + time.sleep(1) + print("T2: inserting new row") + s.execute(insert(Account).values(id=3, balance=300)) + try: + s.commit() + print("T2: committed") + except Exception as e: + print("T2: serialization failed:", e) + + +th1 = threading.Thread(target=t1) +th2 = threading.Thread(target=t2) +th1.start() +th2.start() +th1.join() +th2.join() + +""" +# Phantom read при REPEATABLE READ + +T1: start +T1: first read: [(,), (,)] +T2: inserting new row +T2: committed +T1: second read: [(,), (,)] + +# Phantom read при SERIALIZABLE + +T1: start +T1: first read: [(,), (,)] +T2: inserting new row +T2: committed +T1: second read: [(,), (,)] +""" \ No newline at end of file From 2777963ceafbd5778a58ad17c53e5b240c00bb3b Mon Sep 17 00:00:00 2001 From: Daniil Matvienko <106984108+SaylesMand@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:18:23 +0300 Subject: [PATCH 7/9] Update README.md --- hw5/README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/hw5/README.md b/hw5/README.md index c8f84818..1e854329 100644 --- a/hw5/README.md +++ b/hw5/README.md @@ -4,16 +4,23 @@ 2) Настроить автозапуск этих тестов в CI, если вы подключали сторонюю БД, то можно посмотреть вот [сюда](https://dev.to/kashifsoofi/integration-test-postgres-using-github-actions-3lln), чтобы поддержать тесты с ней в CI. По итогу у вас должен получится зеленый пайплайн - оценивается в еще 2 балла. +## Полезные команды -```bash +Установка переменной окружения (для Windows PowerShell): + +```powershell $env:PYTHONPATH = "$PWD" ``` +Запуск тестов с покрытием: + ```bash pytest --cov=shop_api/routers ``` -## tests coverage +## Текущее покрытие тестами + +``` Name Stmts Miss Cover ----------------------------------------------------- shop_api\__init__.py 0 0 100% @@ -32,4 +39,5 @@ shop_api\main.py 11 1 91% shop_api\routers\cart.py 32 0 100% shop_api\routers\item.py 42 0 100% ----------------------------------------------------- -TOTAL 309 2 99% \ No newline at end of file +TOTAL 309 2 99% +``` From fd3917da3d9146d2155d55d36f5a578718079f99 Mon Sep 17 00:00:00 2001 From: SaylesMand Date: Fri, 24 Oct 2025 13:19:43 +0300 Subject: [PATCH 8/9] fixing hw5-tests.yml --- .github/workflows/hw5-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml index e92f18ed..39753f41 100644 --- a/.github/workflows/hw5-tests.yml +++ b/.github/workflows/hw5-tests.yml @@ -1,4 +1,4 @@ -name: "HW2 Tests" +name: "HW5 Tests" # Запускаем тесты при изменении файлов в hw5/ on: @@ -10,7 +10,7 @@ on: paths: [ 'hw5/**' ] jobs: - test-hw2: + test-hw5: runs-on: ubuntu-latest strategy: matrix: From 4b86e3bbbb71cfe647a445c1c7fd3ceaacacd01c Mon Sep 17 00:00:00 2001 From: SaylesMand Date: Fri, 24 Oct 2025 13:28:39 +0300 Subject: [PATCH 9/9] fixing hw5-tests.yml --- .github/workflows/hw5-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml index 39753f41..bc0d5000 100644 --- a/.github/workflows/hw5-tests.yml +++ b/.github/workflows/hw5-tests.yml @@ -26,13 +26,13 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies - working-directory: /hw5 + working-directory: hw5 run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Run tests - working-directory: /hw5 + working-directory: hw5 env: PYTHONPATH: ${{ github.workspace }}/hw5 run: |