diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 00000000..bc0d5000 --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,39 @@ +name: "HW5 Tests" + +# Запускаем тесты при изменении файлов в hw5/ +on: + pull_request: + branches: [ main ] + paths: [ 'hw5/**' ] + push: + branches: [ main ] + paths: [ 'hw5/**' ] + +jobs: + test-hw5: + 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/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) 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("") 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 00000000..2032c364 Binary files /dev/null and b/hw3/hw/grafana.png differ 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 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..4bd04901 --- /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.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/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) 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 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..1e854329 --- /dev/null +++ b/hw5/README.md @@ -0,0 +1,43 @@ +# ДЗ + +1) Добиться 95% покрытия тестами вашей второй домашки - 1 балл + +2) Настроить автозапуск этих тестов в CI, если вы подключали сторонюю БД, то можно посмотреть вот [сюда](https://dev.to/kashifsoofi/integration-test-postgres-using-github-actions-3lln), чтобы поддержать тесты с ней в CI. По итогу у вас должен получится зеленый пайплайн - оценивается в еще 2 балла. + +## Полезные команды + +Установка переменной окружения (для Windows PowerShell): + +```powershell +$env:PYTHONPATH = "$PWD" +``` + +Запуск тестов с покрытием: + +```bash +pytest --cov=shop_api/routers +``` + +## Текущее покрытие тестами + +``` +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% +``` 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