From c7a9fb7075b4a2dad492b9bf8083dbc4eef8b729 Mon Sep 17 00:00:00 2001 From: Gleb Gerlakh Date: Fri, 26 Sep 2025 22:56:32 +0300 Subject: [PATCH 1/8] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..b93d9f48 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,31 @@ from typing import Any, Awaitable, Callable +from math import factorial +import json + + +def fibonacci(n: int): + res = 0 + if n > 0: + f = [0] * (n+1) + f[1] = 1 + for i in range(2, n+1): + f[i] = f[i-1] + f[i-2] + res = f[n] + return res + + +async def send_response(response_status_code: int, response_content_type: bytes, response_body: bytes, + send: Callable[[dict[str, Any]], Awaitable[None]]): + await send( + { + "type": "http.response.start", + "status": response_status_code, + "headers": [ + [b"content-type", response_content_type], + ], + } + ) + await send({"type": "http.response.body", "body": response_body}) async def application( @@ -12,7 +39,58 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope['type'] == 'lifespan': + while True: + message = await receive() + if message['type'] == 'lifespan.startup': + await send({'type': 'lifespan.startup.complete'}) + elif message['type'] == 'lifespan.shutdown': + await send({'type': 'lifespan.shutdown.complete'}) + break + elif scope['type'] == 'http': + path = scope["path"] + if path == "/" or path == "/not_found": + await send_response(404, b"text/plain", b"not found", send) + elif path == "/factorial": + try: + n = int(scope["query_string"].decode().replace("n=", "")) + if n < 0: + await send_response(400, b"text/plain", b"Invalid value for n, must be non-negative", send) + else: + result = factorial(n) + response_body = bytes(json.dumps({"result": result}), encoding="utf-8") + await send_response(200, b"application/json", response_body, send) + except ValueError: + await send_response(422, b"text/plain", b"unprocessible entity", send) + elif path.startswith("/fibonacci/"): + try: + n = int(path.split("/")[2]) + if n < 0: + await send_response(400, b"text/plain", b"Invalid value for n, must be non-negative", send) + else: + result = fibonacci(n) + response_body = bytes(json.dumps({"result": result}), encoding="utf-8") + await send_response(200, b"application/json", response_body, send) + except ValueError: + await send_response(422, b"text/plain", b"unprocessible entity", send) + elif path == "/mean": + body = b'' + event = await receive() + if event['type'] == 'http.request': + body = event.get('body', b'') + inp_arr = json.loads(body.decode()) + if inp_arr == None: + await send_response(422, b"text/plain", b"unprocessible entity", send) + else: + if len(inp_arr) == 0: + await send_response(400, b"text/plain", b"Invalid value for body, must be non-empty array of floats", send) + else: + result = sum(inp_arr) / len(inp_arr) + response_body = bytes(json.dumps({"result": result}), encoding="utf-8") + await send_response(200, b"application/json", response_body, send) + else: + await send_response(422, b"text/plain", b"unprocessible entity", send) + if __name__ == "__main__": import uvicorn From 1c240c0e99ff000e4e8aab08f1e86d6e981003cd Mon Sep 17 00:00:00 2001 From: Gleb Gerlakh Date: Sun, 5 Oct 2025 14:43:03 +0300 Subject: [PATCH 2/8] Add hw2 --- hw2/hw/shop_api/api/__init__.py | 0 hw2/hw/shop_api/api/cart/__init__.py | 7 ++ hw2/hw/shop_api/api/cart/contracts.py | 40 +++++++ hw2/hw/shop_api/api/cart/routes.py | 88 +++++++++++++++ hw2/hw/shop_api/api/item/__init__.py | 9 ++ hw2/hw/shop_api/api/item/contracts.py | 52 +++++++++ hw2/hw/shop_api/api/item/routes.py | 120 ++++++++++++++++++++ hw2/hw/shop_api/main.py | 7 ++ hw2/hw/shop_api/store/__init__.py | 22 ++++ hw2/hw/shop_api/store/models.py | 48 ++++++++ hw2/hw/shop_api/store/queries.py | 151 ++++++++++++++++++++++++++ 11 files changed, 544 insertions(+) create mode 100644 hw2/hw/shop_api/api/__init__.py create mode 100644 hw2/hw/shop_api/api/cart/__init__.py create mode 100644 hw2/hw/shop_api/api/cart/contracts.py create mode 100644 hw2/hw/shop_api/api/cart/routes.py create mode 100644 hw2/hw/shop_api/api/item/__init__.py create mode 100644 hw2/hw/shop_api/api/item/contracts.py create mode 100644 hw2/hw/shop_api/api/item/routes.py create mode 100644 hw2/hw/shop_api/store/__init__.py create mode 100644 hw2/hw/shop_api/store/models.py create mode 100644 hw2/hw/shop_api/store/queries.py diff --git a/hw2/hw/shop_api/api/__init__.py b/hw2/hw/shop_api/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/api/cart/__init__.py b/hw2/hw/shop_api/api/cart/__init__.py new file mode 100644 index 00000000..07405596 --- /dev/null +++ b/hw2/hw/shop_api/api/cart/__init__.py @@ -0,0 +1,7 @@ +from .contracts import CartResponse +from .routes import cart_router + +__all__ = [ + "CartResponse", + "cart_router", +] \ No newline at end of file diff --git a/hw2/hw/shop_api/api/cart/contracts.py b/hw2/hw/shop_api/api/cart/contracts.py new file mode 100644 index 00000000..bee5e60c --- /dev/null +++ b/hw2/hw/shop_api/api/cart/contracts.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict +from typing import Iterable + +from shop_api.store.models import ( + CartItemInfo, + CartEntity, + CartInfo +) + + +# class CartItemResponse(BaseModel): +# id: int +# name: str +# quantity: int +# available: bool + +# @staticmethod +# def from_entity(entity: CartItemEntity) -> CartItemResponse: +# return CartItemResponse( +# id=entity.id, +# name=entity.info.name, +# quantity=entity.info.quantity, +# available=entity.info.available +# ) + + +class CartResponse(BaseModel): + id: int + items: Iterable[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/api/cart/routes.py b/hw2/hw/shop_api/api/cart/routes.py new file mode 100644 index 00000000..64c41cb3 --- /dev/null +++ b/hw2/hw/shop_api/api/cart/routes.py @@ -0,0 +1,88 @@ +from http import HTTPStatus +from typing import Annotated, Optional + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat + +from shop_api import store + +from .contracts import ( + CartResponse +) + +cart_router = APIRouter(prefix="/cart") + + +@cart_router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat | None, Query()] = None, + max_price: Annotated[NonNegativeFloat | None, Query()] = None, + min_quantity: Annotated[NonNegativeInt | None, Query()] = None, + max_quantity: Annotated[NonNegativeInt | None, Query()] = None +) -> list[CartResponse]: + return [CartResponse.from_entity(e) for e in store.get_carts(offset, limit, min_price, max_price, min_quantity, max_quantity)] + + +@cart_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) -> CartResponse: + entity = store.get_cart(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@cart_router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_cart(response: Response) -> CartResponse: + entity = store.add_cart() + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/cart/{entity.id}" + + return CartResponse.from_entity(entity) + + +@cart_router.post( + "/cart/{cart_id}/add/{item_id}", + status_code=HTTPStatus.CREATED, +) +async def post_cart_item(cart_id: int, item_id: int) -> CartResponse: + entity_cart = store.get_cart(cart_id) + + if not entity_cart: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + + entity_item = store.get_item(id) + + if not entity_item: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + ret_entity_cart = store.add_item_to_cart(cart_id=cart_id, item_id=item_id) + + return CartResponse.from_entity(ret_entity_cart) diff --git a/hw2/hw/shop_api/api/item/__init__.py b/hw2/hw/shop_api/api/item/__init__.py new file mode 100644 index 00000000..88f72b72 --- /dev/null +++ b/hw2/hw/shop_api/api/item/__init__.py @@ -0,0 +1,9 @@ +from .contracts import ItemResponse, ItemRequest, PatchItemRequest +from .routes import item_router + +__all__ = [ + "ItemResponse", + "ItemRequest", + "PatchItemRequest", + "item_router", +] \ No newline at end of file diff --git a/hw2/hw/shop_api/api/item/contracts.py b/hw2/hw/shop_api/api/item/contracts.py new file mode 100644 index 00000000..8af2f854 --- /dev/null +++ b/hw2/hw/shop_api/api/item/contracts.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from shop_api.store.models import ( + ItemInfo, + ItemEntity, + 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 PutItemRequest(BaseModel): +# name: str +# price: float + +# 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/api/item/routes.py b/hw2/hw/shop_api/api/item/routes.py new file mode 100644 index 00000000..9d380d6c --- /dev/null +++ b/hw2/hw/shop_api/api/item/routes.py @@ -0,0 +1,120 @@ +from http import HTTPStatus +from typing import Annotated, Optional + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat + +from shop_api import store + +from .contracts import ( + ItemResponse, + ItemRequest, + ItemRequest, + PatchItemRequest +) + +item_router = APIRouter(prefix="/item") + + +@item_router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat | None, Query()] = None, + max_price: Annotated[NonNegativeFloat | None, Query()] = None, + show_deleted: Annotated[bool, Query()] = False +) -> list[ItemResponse]: + return [ItemResponse.from_entity(e) for e in store.get_items(offset, limit, min_price, max_price, show_deleted)] + + +@item_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) -> ItemResponse: + entity = store.get_item(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@item_router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_item(info: ItemRequest, response: Response) -> ItemResponse: + entity = store.add_item(info.as_item_info()) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/item/{entity.id}" + + return ItemResponse.from_entity(entity) + + +@item_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_item(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) + + +@item_router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted pokemon", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify pokemon as one was not found", + }, + } +) +async def put_item( + id: int, + info: ItemRequest +) -> ItemResponse: + entity = store.update_item(id, info) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@item_router.delete("/{id}") +async def delete_item(id: int) -> Response: + + store.delete_item(id) + + return Response("") \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..b71ba716 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,10 @@ from fastapi import FastAPI +from shop_api.api.item import item_router +from shop_api.api.cart import cart_router + app = FastAPI(title="Shop API") + + +app.include_router(item_router) +app.include_router(cart_router) \ No newline at end of file diff --git a/hw2/hw/shop_api/store/__init__.py b/hw2/hw/shop_api/store/__init__.py new file mode 100644 index 00000000..6bdd3f96 --- /dev/null +++ b/hw2/hw/shop_api/store/__init__.py @@ -0,0 +1,22 @@ +from .models import ItemInfo, ItemEntity, PatchItemInfo, CartItemInfo, CartInfo, CartEntity +from .queries import add_item, delete_item, get_item, get_items, update_item, upsert_item, patch_item, get_carts, get_cart, add_cart, add_item_to_cart + +__all__ = [ + "ItemInfo", + "ItemEntity", + "PatchItemInfo", + "CartItemInfo", + "CartInfo", + "CartEntity", + "add_item", + "delete_item", + "get_item", + "get_items", + "update_item", + "upsert_item", + "patch_item", + "get_carts", + "get_cart", + "add_cart", + "add_item_to_cart" +] diff --git a/hw2/hw/shop_api/store/models.py b/hw2/hw/shop_api/store/models.py new file mode 100644 index 00000000..1865d260 --- /dev/null +++ b/hw2/hw/shop_api/store/models.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass +from typing import Iterable, List + + +@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 + + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + + +# @dataclass(slots=True) +# class CartItemEntity: +# id: int +# info: CartItemInfo + + +@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/store/queries.py b/hw2/hw/shop_api/store/queries.py new file mode 100644 index 00000000..f276f445 --- /dev/null +++ b/hw2/hw/shop_api/store/queries.py @@ -0,0 +1,151 @@ +from typing import Iterable + +from shop_api.store.models import ( + ItemInfo, + CartInfo, + PatchItemInfo, + ItemEntity, + CartEntity, + CartInfo, + CartItemInfo +) + + +_carts_data = dict[int, CartInfo]() +_items_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_item(info: ItemInfo) -> ItemEntity: + _id = next(_id_generator) + _items_data[_id] = info + + return ItemEntity(_id, info) + + +def delete_item(id: int) -> None: + if id in _items_data: + del _items_data[id] + + +def get_item(id: int) -> ItemEntity | None: + if id not in _items_data: + return None + + return ItemEntity(id=id, info=_items_data[id]) + + +def get_items( + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + show_deleted: bool = False + ) -> Iterable[ItemEntity]: + curr = 0 + for id, info in _items_data.items(): + if (offset <= curr < offset + limit) and \ + (min_price == None or info.price >= min_price) and \ + (max_price == None or info.price <= max_price) and \ + (not info.deleted or show_deleted): + yield ItemEntity(id, info) + + curr += 1 + + +def update_item(id: int, info: ItemInfo) -> ItemEntity | None: + if id not in _items_data: + return None + + _items_data[id] = info + + return ItemEntity(id=id, info=info) + + +def upsert_item(id: int, info: ItemInfo) -> ItemEntity: + _items_data[id] = info + + return ItemEntity(id=id, info=info) + + +def patch_item(id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if id not in _items_data: + return None + + if patch_info.name is not None: + _items_data[id].name = patch_info.name + + if patch_info.price is not None: + _items_data[id].price = patch_info.price + + return ItemEntity(id=id, info=_items_data[id]) + + +def get_carts( + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + min_quantity: int | None = None, + max_quantity: int | None = None + ) -> Iterable[CartEntity]: + curr = 0 + # returned_carts_cnt = 0 + for id, info in _carts_data.items(): + cart_items_quantity = 0 + for cart_item in info.items: + cart_items_quantity += cart_item.quantity + if (offset <= curr < offset + limit) and \ + (min_price == None or info.price >= min_price) and \ + (max_price == None or info.price <= max_price) and \ + (min_quantity == None or cart_items_quantity >= min_quantity) and \ + (max_quantity == None or cart_items_quantity <= max_quantity): + # returned_carts_cnt += 1 + yield CartEntity(id, info) + + curr += 1 + + # if min_quantity != None and returned_carts_cnt < min_quantity: + # while returned_carts_cnt < min_quantity: + # returned_carts_cnt += 1 + # yield CartEntity(0, CartInfo(items=[], price=0.0)) + + +def get_cart(id: int) -> CartEntity | None: + if id not in _carts_data: + return None + + return CartEntity(id=id, info=_carts_data[id]) + + +def add_cart() -> CartEntity: + _id = next(_id_generator) + info = CartInfo(items=[], price=0.0) + _carts_data[_id] = info + return CartEntity(_id, info) + + +def add_item_to_cart(cart_id: int, item_id: int) -> CartEntity: + cart_entity = get_cart(cart_id) + is_found_item = False + for cart_item in cart_entity.info.items: + if cart_item.id == item_id: + cart_item.info.quantity += 1 + is_found_item = True + break + if not is_found_item: + item_info = _items_data[item_id] + cart_entity.info.items.append(CartItemInfo(id=item_id, name=item_info.name, quantity=1, available=not item_info.deleted)) + # _carts_data[cart_id].items.append(CartItemInfo(id=item_id, name=item_info.name, quantity=1, available=not item_info.deleted)) + + _carts_data[cart_id] = cart_entity.info + return cart_entity \ No newline at end of file From a0ce095c187b08c8a9533c3cb5a0d8297a528d83 Mon Sep 17 00:00:00 2001 From: Gleb Gerlakh Date: Sun, 5 Oct 2025 16:33:23 +0300 Subject: [PATCH 3/8] fix hw2 --- hw2/hw/shop_api/api/cart/routes.py | 12 ++++++------ hw2/hw/shop_api/store/queries.py | 29 ++++++++++++----------------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/hw2/hw/shop_api/api/cart/routes.py b/hw2/hw/shop_api/api/cart/routes.py index 64c41cb3..a3ab56ca 100644 --- a/hw2/hw/shop_api/api/cart/routes.py +++ b/hw2/hw/shop_api/api/cart/routes.py @@ -44,7 +44,7 @@ async def get_cart_by_id(id: int) -> CartResponse: HTTPStatus.NOT_FOUND, f"Request resource /cart/{id} was not found", ) - + return CartResponse.from_entity(entity) @@ -62,7 +62,7 @@ async def post_cart(response: Response) -> CartResponse: @cart_router.post( - "/cart/{cart_id}/add/{item_id}", + "/{cart_id}/add/{item_id}", status_code=HTTPStatus.CREATED, ) async def post_cart_item(cart_id: int, item_id: int) -> CartResponse: @@ -71,18 +71,18 @@ async def post_cart_item(cart_id: int, item_id: int) -> CartResponse: if not entity_cart: raise HTTPException( HTTPStatus.NOT_FOUND, - f"Request resource /cart/{id} was not found", + f"Request resource /cart/{cart_id} was not found", ) - entity_item = store.get_item(id) + entity_item = store.get_item(item_id) if not entity_item: raise HTTPException( HTTPStatus.NOT_FOUND, - f"Request resource /item/{id} was not found", + f"Request resource /item/{item_id} was not found", ) - + ret_entity_cart = store.add_item_to_cart(cart_id=cart_id, item_id=item_id) return CartResponse.from_entity(ret_entity_cart) diff --git a/hw2/hw/shop_api/store/queries.py b/hw2/hw/shop_api/store/queries.py index f276f445..3af8eba9 100644 --- a/hw2/hw/shop_api/store/queries.py +++ b/hw2/hw/shop_api/store/queries.py @@ -22,11 +22,13 @@ def int_id_generator() -> Iterable[int]: i += 1 -_id_generator = int_id_generator() +_item_id_generator = int_id_generator() + +_cart_id_generator = int_id_generator() def add_item(info: ItemInfo) -> ItemEntity: - _id = next(_id_generator) + _id = next(_item_id_generator) _items_data[_id] = info return ItemEntity(_id, info) @@ -99,7 +101,6 @@ def get_carts( max_quantity: int | None = None ) -> Iterable[CartEntity]: curr = 0 - # returned_carts_cnt = 0 for id, info in _carts_data.items(): cart_items_quantity = 0 for cart_item in info.items: @@ -109,15 +110,10 @@ def get_carts( (max_price == None or info.price <= max_price) and \ (min_quantity == None or cart_items_quantity >= min_quantity) and \ (max_quantity == None or cart_items_quantity <= max_quantity): - # returned_carts_cnt += 1 yield CartEntity(id, info) curr += 1 - # if min_quantity != None and returned_carts_cnt < min_quantity: - # while returned_carts_cnt < min_quantity: - # returned_carts_cnt += 1 - # yield CartEntity(0, CartInfo(items=[], price=0.0)) def get_cart(id: int) -> CartEntity | None: @@ -128,24 +124,23 @@ def get_cart(id: int) -> CartEntity | None: def add_cart() -> CartEntity: - _id = next(_id_generator) + _id = next(_cart_id_generator) info = CartInfo(items=[], price=0.0) _carts_data[_id] = info return CartEntity(_id, info) def add_item_to_cart(cart_id: int, item_id: int) -> CartEntity: - cart_entity = get_cart(cart_id) is_found_item = False - for cart_item in cart_entity.info.items: + item_info = _items_data[item_id] + for cart_item in _carts_data[cart_id].items: if cart_item.id == item_id: - cart_item.info.quantity += 1 + cart_item.quantity += 1 is_found_item = True break if not is_found_item: - item_info = _items_data[item_id] - cart_entity.info.items.append(CartItemInfo(id=item_id, name=item_info.name, quantity=1, available=not item_info.deleted)) - # _carts_data[cart_id].items.append(CartItemInfo(id=item_id, name=item_info.name, quantity=1, available=not item_info.deleted)) + _carts_data[cart_id].items.append(CartItemInfo(id=item_id, name=item_info.name, quantity=1, available=not item_info.deleted)) + + _carts_data[cart_id].price += item_info.price - _carts_data[cart_id] = cart_entity.info - return cart_entity \ No newline at end of file + return get_cart(cart_id) \ No newline at end of file From 7f9d66315afda17b7b9606a984ffef6ef3f970bb Mon Sep 17 00:00:00 2001 From: Gleb Gerlakh Date: Sun, 12 Oct 2025 00:52:11 +0300 Subject: [PATCH 4/8] Add hw3 --- hw2/hw/Dockerfile | 20 +++++++++++++++ hw2/hw/docker-compose.yml | 31 +++++++++++++++++++++++ hw2/hw/requirements.txt | 2 +- hw2/hw/settings/prometheus/prometheus.yml | 10 ++++++++ hw2/hw/shop_api/main.py | 2 ++ 5 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 hw2/hw/Dockerfile create mode 100644 hw2/hw/docker-compose.yml create mode 100644 hw2/hw/settings/prometheus/prometheus.yml diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..8da7252c --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +COPY . ./ + + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..290b1a30 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./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 \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..7543cc3b 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,7 +1,7 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 - +prometheus-fastapi-instrumentator # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..275d8544 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: demo-service-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index b71ba716..d7661d9c 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,9 +1,11 @@ from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator from shop_api.api.item import item_router from shop_api.api.cart import cart_router app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) app.include_router(item_router) From 24c5d4a2ea151dd6fd346baf9016943ae2637f5e Mon Sep 17 00:00:00 2001 From: Gleb Gerlakh Date: Sat, 25 Oct 2025 17:24:40 +0300 Subject: [PATCH 5/8] Add hw4 --- hw2/hw/docker-compose.yml | 30 +++- hw2/hw/requirements.txt | 3 + hw2/hw/shop_api/api/cart/contracts.py | 60 ++++---- hw2/hw/shop_api/api/cart/routes.py | 41 ++--- hw2/hw/shop_api/api/item/contracts.py | 49 +++--- hw2/hw/shop_api/api/item/routes.py | 48 +++--- hw2/hw/shop_api/config/__init__.py | 10 ++ hw2/hw/shop_api/main.py | 4 + hw2/hw/shop_api/store/__init__.py | 18 +-- hw2/hw/shop_api/store/database.py | 12 ++ hw2/hw/shop_api/store/dependencies.py | 10 ++ hw2/hw/shop_api/store/models.py | 119 ++++++++++---- hw2/hw/shop_api/store/queries.py | 214 ++++++++++++++------------ 13 files changed, 391 insertions(+), 227 deletions(-) create mode 100644 hw2/hw/shop_api/config/__init__.py create mode 100644 hw2/hw/shop_api/store/database.py create mode 100644 hw2/hw/shop_api/store/dependencies.py diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml index 290b1a30..f24bf44c 100644 --- a/hw2/hw/docker-compose.yml +++ b/hw2/hw/docker-compose.yml @@ -8,8 +8,16 @@ services: dockerfile: ./Dockerfile target: local restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_HOST: postgres + POSTGRES_DB: hw4_db ports: - 8080:8080 + depends_on: + postgres: + condition: service_healthy grafana: image: grafana/grafana:latest @@ -28,4 +36,24 @@ services: - "--web.console.templates=/usr/share/prometheus/consoles" ports: - 9090:9090 - restart: always \ No newline at end of file + restart: always + + postgres: + image: postgres:15 + container_name: hw4_postgres + environment: + POSTGRES_DB: hw4_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 7543cc3b..c9dac984 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -7,3 +7,6 @@ pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 diff --git a/hw2/hw/shop_api/api/cart/contracts.py b/hw2/hw/shop_api/api/cart/contracts.py index bee5e60c..050e9188 100644 --- a/hw2/hw/shop_api/api/cart/contracts.py +++ b/hw2/hw/shop_api/api/cart/contracts.py @@ -1,40 +1,48 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel from typing import Iterable from shop_api.store.models import ( - CartItemInfo, - CartEntity, - CartInfo + Cart, + CartItem ) +class CartMapper: + """Маппер для преобразования между CartResponse и Cart (ORM)""" + + @staticmethod + def to_domain(orm_cart: Cart) -> CartResponse: + """Преобразование ORM модели в доменную""" + return CartResponse( + id=orm_cart.id, + items=[CartItemMapper.to_domain(item) for item in orm_cart.items], + price=orm_cart.price + ) -# class CartItemResponse(BaseModel): -# id: int -# name: str -# quantity: int -# available: bool -# @staticmethod -# def from_entity(entity: CartItemEntity) -> CartItemResponse: -# return CartItemResponse( -# id=entity.id, -# name=entity.info.name, -# quantity=entity.info.quantity, -# available=entity.info.available -# ) +class CartItemMapper: + """Маппер для преобразования между CartItemResponse и CartItem (ORM)""" + + @staticmethod + def to_domain(orm_cart_item: CartItem) -> CartItemResponse: + """Преобразование ORM модели в доменную""" + return CartItemResponse( + id=orm_cart_item.id, + name=orm_cart_item.item.name, + quantity=orm_cart_item.quantity, + available=not orm_cart_item.item.deleted + ) + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool class CartResponse(BaseModel): id: int - items: Iterable[CartItemInfo] + items: Iterable[CartItemResponse] 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/api/cart/routes.py b/hw2/hw/shop_api/api/cart/routes.py index a3ab56ca..d2c4796b 100644 --- a/hw2/hw/shop_api/api/cart/routes.py +++ b/hw2/hw/shop_api/api/cart/routes.py @@ -1,13 +1,15 @@ from http import HTTPStatus from typing import Annotated, Optional -from fastapi import APIRouter, HTTPException, Query, Response +from fastapi import APIRouter, HTTPException, Query, Response, Depends from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat +from sqlalchemy.orm import Session from shop_api import store from .contracts import ( - CartResponse + CartResponse, + CartMapper ) cart_router = APIRouter(prefix="/cart") @@ -20,9 +22,10 @@ async def get_cart_list( min_price: Annotated[NonNegativeFloat | None, Query()] = None, max_price: Annotated[NonNegativeFloat | None, Query()] = None, min_quantity: Annotated[NonNegativeInt | None, Query()] = None, - max_quantity: Annotated[NonNegativeInt | None, Query()] = None + max_quantity: Annotated[NonNegativeInt | None, Query()] = None, + db: Session = Depends(store.get_db) ) -> list[CartResponse]: - return [CartResponse.from_entity(e) for e in store.get_carts(offset, limit, min_price, max_price, min_quantity, max_quantity)] + return [CartMapper.to_domain(orm_cart) for orm_cart in store.get_carts(db, offset, limit, min_price, max_price, min_quantity, max_quantity)] @cart_router.get( @@ -36,53 +39,53 @@ async def get_cart_list( }, }, ) -async def get_cart_by_id(id: int) -> CartResponse: - entity = store.get_cart(id) +async def get_cart_by_id(id: int, db: Session = Depends(store.get_db)) -> CartResponse: + orm_cart = store.get_cart(db, id) - if not entity: + if not orm_cart: raise HTTPException( HTTPStatus.NOT_FOUND, f"Request resource /cart/{id} was not found", ) - return CartResponse.from_entity(entity) + return CartMapper.to_domain(orm_cart) @cart_router.post( "/", status_code=HTTPStatus.CREATED, ) -async def post_cart(response: Response) -> CartResponse: - entity = store.add_cart() +async def post_cart(response: Response, db: Session = Depends(store.get_db)) -> CartResponse: + orm_cart = store.add_cart(db) # as REST states one should provide uri to newly created resource in location header - response.headers["location"] = f"/cart/{entity.id}" + response.headers["location"] = f"/cart/{orm_cart.id}" - return CartResponse.from_entity(entity) + return CartMapper.to_domain(orm_cart) @cart_router.post( "/{cart_id}/add/{item_id}", status_code=HTTPStatus.CREATED, ) -async def post_cart_item(cart_id: int, item_id: int) -> CartResponse: - entity_cart = store.get_cart(cart_id) +async def post_cart_item(cart_id: int, item_id: int, db: Session = Depends(store.get_db)) -> CartResponse: + orm_cart = store.get_cart(db, cart_id) - if not entity_cart: + if not orm_cart: raise HTTPException( HTTPStatus.NOT_FOUND, f"Request resource /cart/{cart_id} was not found", ) - entity_item = store.get_item(item_id) + orm_item = store.get_item(db, item_id) - if not entity_item: + if not orm_item: raise HTTPException( HTTPStatus.NOT_FOUND, f"Request resource /item/{item_id} was not found", ) - ret_entity_cart = store.add_item_to_cart(cart_id=cart_id, item_id=item_id) + ret_orm_cart = store.add_item_to_cart(db, orm_cart, orm_item) - return CartResponse.from_entity(ret_entity_cart) + return CartMapper.to_domain(ret_orm_cart) diff --git a/hw2/hw/shop_api/api/item/contracts.py b/hw2/hw/shop_api/api/item/contracts.py index 8af2f854..beb5094a 100644 --- a/hw2/hw/shop_api/api/item/contracts.py +++ b/hw2/hw/shop_api/api/item/contracts.py @@ -1,45 +1,54 @@ from __future__ import annotations from pydantic import BaseModel, ConfigDict +from typing import Optional from shop_api.store.models import ( - ItemInfo, - ItemEntity, - PatchItemInfo + Item ) + class ItemResponse(BaseModel): id: int name: str price: float deleted: bool + +class ItemMapper: + """Маппер для преобразования между ItemResponse и Item (ORM)""" + @staticmethod - def from_entity(entity: ItemEntity) -> ItemResponse: + def to_domain(orm_item: Item) -> ItemResponse: + """Преобразование ORM модели в доменную""" return ItemResponse( - id=entity.id, - name=entity.info.name, - price=entity.info.price, - deleted=entity.info.deleted, + id=orm_item.id, + name=orm_item.name, + price=orm_item.price, + deleted=orm_item.deleted ) + @staticmethod + def to_orm( + domain_item: ItemRequest, + orm_item: Optional[Item] = None, + ) -> Item: + """Преобразование доменной модели в ORM""" + if orm_item is None: + orm_item = Item() + + orm_item.name = domain_item.name + orm_item.price = domain_item.price + orm_item.deleted = domain_item.deleted + + return orm_item + 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 PutItemRequest(BaseModel): -# name: str -# price: float - -# def as_item_info(self) -> ItemInfo: -# return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) class PatchItemRequest(BaseModel): @@ -48,5 +57,3 @@ class PatchItemRequest(BaseModel): 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/api/item/routes.py b/hw2/hw/shop_api/api/item/routes.py index 9d380d6c..0d07434c 100644 --- a/hw2/hw/shop_api/api/item/routes.py +++ b/hw2/hw/shop_api/api/item/routes.py @@ -1,12 +1,14 @@ from http import HTTPStatus -from typing import Annotated, Optional +from typing import Annotated -from fastapi import APIRouter, HTTPException, Query, Response +from fastapi import APIRouter, HTTPException, Query, Response, Depends from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat +from sqlalchemy.orm import Session from shop_api import store from .contracts import ( + ItemMapper, ItemResponse, ItemRequest, ItemRequest, @@ -22,9 +24,10 @@ async def get_item_list( limit: Annotated[PositiveInt, Query()] = 10, min_price: Annotated[NonNegativeFloat | None, Query()] = None, max_price: Annotated[NonNegativeFloat | None, Query()] = None, - show_deleted: Annotated[bool, Query()] = False + show_deleted: Annotated[bool, Query()] = False, + db: Session = Depends(store.get_db) ) -> list[ItemResponse]: - return [ItemResponse.from_entity(e) for e in store.get_items(offset, limit, min_price, max_price, show_deleted)] + return [ItemMapper.to_domain(orm_item) for orm_item in store.get_items(db, offset, limit, min_price, max_price, show_deleted)] @item_router.get( @@ -38,29 +41,29 @@ async def get_item_list( }, }, ) -async def get_item_by_id(id: int) -> ItemResponse: - entity = store.get_item(id) +async def get_item_by_id(id: int, db: Session = Depends(store.get_db)) -> ItemResponse: + orm_item = store.get_item(db, id) - if not entity: + if not orm_item: raise HTTPException( HTTPStatus.NOT_FOUND, f"Request resource /item/{id} was not found", ) - return ItemResponse.from_entity(entity) + return ItemMapper.to_domain(orm_item) @item_router.post( "/", status_code=HTTPStatus.CREATED, ) -async def post_item(info: ItemRequest, response: Response) -> ItemResponse: - entity = store.add_item(info.as_item_info()) +async def post_item(info: ItemRequest, response: Response, db: Session = Depends(store.get_db)) -> ItemResponse: + orm_item = store.add_item(db, ItemMapper.to_orm(info)) # as REST states one should provide uri to newly created resource in location header - response.headers["location"] = f"/item/{entity.id}" + response.headers["location"] = f"/item/{orm_item.id}" - return ItemResponse.from_entity(entity) + return ItemMapper.to_domain(orm_item) @item_router.patch( @@ -74,16 +77,16 @@ async def post_item(info: ItemRequest, response: Response) -> ItemResponse: }, }, ) -async def patch_item(id: int, info: PatchItemRequest) -> ItemResponse: - entity = store.patch_item(id, info.as_patch_item_info()) +async def patch_item(id: int, info: PatchItemRequest, db: Session = Depends(store.get_db)) -> ItemResponse: + orm_item = store.patch_item(db, id, info.name, info.price) - if entity is None: + if orm_item is None: raise HTTPException( HTTPStatus.NOT_MODIFIED, f"Requested resource /item/{id} was not found", ) - return ItemResponse.from_entity(entity) + return ItemMapper.to_domain(orm_item) @item_router.put( @@ -99,22 +102,23 @@ async def patch_item(id: int, info: PatchItemRequest) -> ItemResponse: ) async def put_item( id: int, - info: ItemRequest + info: ItemRequest, + db: Session = Depends(store.get_db) ) -> ItemResponse: - entity = store.update_item(id, info) + orm_item = store.update_item(db, id, info.name, info.price, info.deleted) - if entity is None: + if orm_item is None: raise HTTPException( HTTPStatus.NOT_MODIFIED, f"Requested resource /item/{id} was not found", ) - return ItemResponse.from_entity(entity) + return ItemMapper.to_domain(orm_item) @item_router.delete("/{id}") -async def delete_item(id: int) -> Response: +async def delete_item(id: int, db: Session = Depends(store.get_db)) -> Response: - store.delete_item(id) + store.delete_item(db, id) return Response("") \ No newline at end of file diff --git a/hw2/hw/shop_api/config/__init__.py b/hw2/hw/shop_api/config/__init__.py new file mode 100644 index 00000000..0e048282 --- /dev/null +++ b/hw2/hw/shop_api/config/__init__.py @@ -0,0 +1,10 @@ +import os + + +POSTGRES_USER = os.environ["POSTGRES_USER"] +POSTGRES_PASSWORD = os.environ["POSTGRES_PASSWORD"] +POSTGRES_HOST = os.environ["POSTGRES_HOST"] +POSTGRES_DB = os.environ["POSTGRES_DB"] + + +SQLALCHEMY_DATABASE_URL = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}/{POSTGRES_DB}" \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index d7661d9c..078c4f47 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -3,6 +3,10 @@ from shop_api.api.item import item_router from shop_api.api.cart import cart_router +from shop_api.store import models +from shop_api.store.database import engine + +models.Base.metadata.create_all(bind=engine) app = FastAPI(title="Shop API") Instrumentator().instrument(app).expose(app) diff --git a/hw2/hw/shop_api/store/__init__.py b/hw2/hw/shop_api/store/__init__.py index 6bdd3f96..f3fa4cd1 100644 --- a/hw2/hw/shop_api/store/__init__.py +++ b/hw2/hw/shop_api/store/__init__.py @@ -1,22 +1,20 @@ -from .models import ItemInfo, ItemEntity, PatchItemInfo, CartItemInfo, CartInfo, CartEntity -from .queries import add_item, delete_item, get_item, get_items, update_item, upsert_item, patch_item, get_carts, get_cart, add_cart, add_item_to_cart +from .models import Item, Cart, CartItem +from .queries import add_item, delete_item, get_item, get_items, update_item, patch_item, get_carts, get_cart, add_cart, add_item_to_cart +from .dependencies import get_db __all__ = [ - "ItemInfo", - "ItemEntity", - "PatchItemInfo", - "CartItemInfo", - "CartInfo", - "CartEntity", + "Item", + "Cart", + "CartItem", "add_item", "delete_item", "get_item", "get_items", "update_item", - "upsert_item", "patch_item", "get_carts", "get_cart", "add_cart", - "add_item_to_cart" + "add_item_to_cart", + "get_db" ] diff --git a/hw2/hw/shop_api/store/database.py b/hw2/hw/shop_api/store/database.py new file mode 100644 index 00000000..8d56e7c6 --- /dev/null +++ b/hw2/hw/shop_api/store/database.py @@ -0,0 +1,12 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from shop_api.config import SQLALCHEMY_DATABASE_URL + + +engine = create_engine( + SQLALCHEMY_DATABASE_URL +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() \ No newline at end of file diff --git a/hw2/hw/shop_api/store/dependencies.py b/hw2/hw/shop_api/store/dependencies.py new file mode 100644 index 00000000..01213bf6 --- /dev/null +++ b/hw2/hw/shop_api/store/dependencies.py @@ -0,0 +1,10 @@ +from .database import SessionLocal + + +# Dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/hw2/hw/shop_api/store/models.py b/hw2/hw/shop_api/store/models.py index 1865d260..f18d35ab 100644 --- a/hw2/hw/shop_api/store/models.py +++ b/hw2/hw/shop_api/store/models.py @@ -1,48 +1,105 @@ -from dataclasses import dataclass -from typing import Iterable, List +from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Float, Boolean +from sqlalchemy.orm import relationship +from .database import Base -@dataclass(slots=True) -class ItemInfo: - name: str - price: float - deleted: bool +class Item(Base): + __tablename__ = "items" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + + # Связь с элементами корзины + cart_items = relationship("CartItem", back_populates="item") -@dataclass(slots=True) -class ItemEntity: - id: int - info: ItemInfo +class Cart(Base): + """Модель корзины""" + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, autoincrement=True) + + # Связь с элементами корзины + items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan") + + @property + def price(self): + """Вычисление общей суммы корзины""" + return sum(cart_item.total_price for cart_item in self.items) + + @property + def quantity(self): + """Вычисление общей суммы корзины""" + return sum(cart_item.quantity for cart_item in self.items) + -@dataclass(slots=True) -class PatchItemInfo: - name: str | None = None - price: float | None = None +class CartItem(Base): + """Промежуточная таблица для связи корзины и товаров с количеством""" + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True, autoincrement=True) + cart_id = Column(Integer, ForeignKey('carts.id'), nullable=False) + item_id = Column(Integer, ForeignKey('items.id'), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + + # Связи + cart = relationship("Cart", back_populates="items") + item = relationship("Item", back_populates="cart_items") + + @property + def total_price(self): + """Общая цена для данного товара в корзине (цена × количество)""" + return self.item.price * self.quantity -@dataclass(slots=True) -class CartItemInfo: - id: int - name: str - quantity: int - available: bool + +# from dataclasses import dataclass +# from typing import Iterable, List + + +# @dataclass(slots=True) +# class ItemInfo: +# name: str +# price: float +# deleted: bool + + +# @dataclass(slots=True) +# class ItemEntity: +# id: int +# info: ItemInfo # @dataclass(slots=True) -# class CartItemEntity: +# class PatchItemInfo: +# name: str | None = None +# price: float | None = None + + +# @dataclass(slots=True) +# class CartItemInfo: # id: int -# info: CartItemInfo +# name: str +# quantity: int +# available: bool + +# # @dataclass(slots=True) +# # class CartItemEntity: +# # id: int +# # info: CartItemInfo -@dataclass(slots=True) -class CartInfo: - items: List[CartItemInfo] - price: float + +# @dataclass(slots=True) +# class CartInfo: +# items: List[CartItemInfo] +# price: float -@dataclass(slots=True) -class CartEntity: - id: int - info: CartInfo +# @dataclass(slots=True) +# class CartEntity: +# id: int +# info: CartInfo diff --git a/hw2/hw/shop_api/store/queries.py b/hw2/hw/shop_api/store/queries.py index 3af8eba9..4dd76b57 100644 --- a/hw2/hw/shop_api/store/queries.py +++ b/hw2/hw/shop_api/store/queries.py @@ -1,146 +1,166 @@ from typing import Iterable +from sqlalchemy.orm import Session from shop_api.store.models import ( - ItemInfo, - CartInfo, - PatchItemInfo, - ItemEntity, - CartEntity, - CartInfo, - CartItemInfo + Item, + Cart, + CartItem ) -_carts_data = dict[int, CartInfo]() -_items_data = dict[int, ItemInfo]() +def add_item(db: Session, orm_item: Item) -> Item: + db.add(orm_item) + db.commit() + db.refresh(orm_item) + return orm_item -def int_id_generator() -> Iterable[int]: - i = 0 - while True: - yield i - i += 1 +def delete_item(db: Session, id: int) -> None: + orm_item = get_item(db, id) + if orm_item != None: + orm_item.deleted = True + db.commit() -_item_id_generator = int_id_generator() - -_cart_id_generator = int_id_generator() - - -def add_item(info: ItemInfo) -> ItemEntity: - _id = next(_item_id_generator) - _items_data[_id] = info - - return ItemEntity(_id, info) - - -def delete_item(id: int) -> None: - if id in _items_data: - del _items_data[id] - - -def get_item(id: int) -> ItemEntity | None: - if id not in _items_data: - return None - - return ItemEntity(id=id, info=_items_data[id]) +def get_item(db: Session, id: int) -> Item | None: + return db.query(Item).filter(Item.id == id).first() def get_items( + db: Session, offset: int = 0, limit: int = 10, min_price: float | None = None, max_price: float | None = None, show_deleted: bool = False - ) -> Iterable[ItemEntity]: - curr = 0 - for id, info in _items_data.items(): - if (offset <= curr < offset + limit) and \ - (min_price == None or info.price >= min_price) and \ - (max_price == None or info.price <= max_price) and \ - (not info.deleted or show_deleted): - yield ItemEntity(id, info) + ) -> Iterable[Item]: + + # Начинаем построение запроса + query = db.query(Item) + + # Фильтр по минимальной цене + if min_price != None: + query = query.filter(Item.price >= min_price) + + # Фильтр по максимальной цене + if max_price != None: + query = query.filter(Item.price <= max_price) + + # Фильтр по удаленным товарам + if not show_deleted: + query = query.filter(Item.deleted == False) + # Если show_deleted=True, показываем все товары включая удаленные + + # Применяем пагинацию + items = query.offset(offset).limit(limit).all() + + return items - curr += 1 +def update_item(db: Session, id: int, name: str, price: float, deleted: bool) -> Item | None: + orm_item = get_item(db, id) -def update_item(id: int, info: ItemInfo) -> ItemEntity | None: - if id not in _items_data: + if orm_item == None: return None - _items_data[id] = info + orm_item.name = name + orm_item.price = price + orm_item.deleted = deleted - return ItemEntity(id=id, info=info) + db.commit() + db.refresh(orm_item) + return orm_item -def upsert_item(id: int, info: ItemInfo) -> ItemEntity: - _items_data[id] = info - return ItemEntity(id=id, info=info) +# def upsert_item(id: int, info: ItemInfo) -> ItemEntity: +# _items_data[id] = info +# return ItemEntity(id=id, info=info) -def patch_item(id: int, patch_info: PatchItemInfo) -> ItemEntity | None: - if id not in _items_data: - return None - if patch_info.name is not None: - _items_data[id].name = patch_info.name +def patch_item(db: Session, id: int, name: str | None, price: float | None) -> Item | None: + orm_item = get_item(db, id) - if patch_info.price is not None: - _items_data[id].price = patch_info.price + if orm_item == None: + return None + + if name is not None: + orm_item.name = name - return ItemEntity(id=id, info=_items_data[id]) + if price is not None: + orm_item.price = price + + db.commit() + db.refresh(orm_item) + return orm_item def get_carts( + db: Session, offset: int = 0, limit: int = 10, min_price: float | None = None, max_price: float | None = None, min_quantity: int | None = None, max_quantity: int | None = None - ) -> Iterable[CartEntity]: - curr = 0 - for id, info in _carts_data.items(): - cart_items_quantity = 0 - for cart_item in info.items: - cart_items_quantity += cart_item.quantity - if (offset <= curr < offset + limit) and \ - (min_price == None or info.price >= min_price) and \ - (max_price == None or info.price <= max_price) and \ - (min_quantity == None or cart_items_quantity >= min_quantity) and \ - (max_quantity == None or cart_items_quantity <= max_quantity): - yield CartEntity(id, info) - - curr += 1 - + ) -> Iterable[Cart]: + # Начинаем построение запроса + query = db.query(Cart) + + # Фильтр по минимальной цене + if min_price != None: + query = query.filter(Cart.price >= min_price) + + # Фильтр по максимальной цене + if max_price != None: + query = query.filter(Cart.price <= max_price) + + # Фильтр по минимальному количеству товаров + if min_quantity != None: + query = query.filter(Cart.quantity >= min_quantity) + + # Фильтр по максимальному количеству товаров + if max_quantity != None: + query = query.filter(Cart.quantity <= max_quantity) + + # Применяем пагинацию + items = query.offset(offset).limit(limit).all() + + return items -def get_cart(id: int) -> CartEntity | None: - if id not in _carts_data: - return None - return CartEntity(id=id, info=_carts_data[id]) +def get_cart(db: Session, id: int) -> Cart | None: + return db.query(Cart).filter(Cart.id == id).first() -def add_cart() -> CartEntity: - _id = next(_cart_id_generator) - info = CartInfo(items=[], price=0.0) - _carts_data[_id] = info - return CartEntity(_id, info) +def add_cart(db: Session) -> Cart: + orm_cart = Cart() + db.add(orm_cart) + db.commit() + db.refresh(orm_cart) + return orm_cart -def add_item_to_cart(cart_id: int, item_id: int) -> CartEntity: - is_found_item = False - item_info = _items_data[item_id] - for cart_item in _carts_data[cart_id].items: - if cart_item.id == item_id: - cart_item.quantity += 1 - is_found_item = True - break - if not is_found_item: - _carts_data[cart_id].items.append(CartItemInfo(id=item_id, name=item_info.name, quantity=1, available=not item_info.deleted)) +def add_item_to_cart(db: Session, cart: Cart, item: Item) -> Cart: - _carts_data[cart_id].price += item_info.price - - return get_cart(cart_id) \ No newline at end of file + # Проверяем, есть ли уже этот товар в корзине + existing_cart_item = db.query(CartItem).filter( + CartItem.cart_id == cart.id, + CartItem.item_id == item.id + ).first() + + if existing_cart_item: + # Если товар уже есть - увеличиваем количество + existing_cart_item.quantity += 1 + cart_item = existing_cart_item + else: + # Если товара нет - создаем новую запись + cart_item = CartItem(cart_id=cart.id, item_id=item.id, quantity=1) + db.add(cart_item) + + db.commit() + db.refresh(cart) + + return cart \ No newline at end of file From 51cd1e59a4f0dfed04403f653f632e1d94aa2cde Mon Sep 17 00:00:00 2001 From: Gleb Gerlakh Date: Sun, 26 Oct 2025 01:42:04 +0300 Subject: [PATCH 6/8] Add hw5 tests --- hw2/hw/docker-compose.yml | 5 +- hw2/hw/requirements.txt | 3 + hw2/hw/shop_api/store/models.py | 60 ++-------------- hw2/hw/shop_api/store/queries.py | 44 ++++++++---- hw2/hw/shop_api/tests/__init__.py | 0 hw2/hw/shop_api/tests/conftest.py | 12 ++++ hw2/hw/shop_api/tests/test_carts.py | 82 +++++++++++++++++++++ hw2/hw/shop_api/tests/test_items.py | 106 ++++++++++++++++++++++++++++ 8 files changed, 238 insertions(+), 74 deletions(-) create mode 100644 hw2/hw/shop_api/tests/__init__.py create mode 100644 hw2/hw/shop_api/tests/conftest.py create mode 100644 hw2/hw/shop_api/tests/test_carts.py create mode 100644 hw2/hw/shop_api/tests/test_items.py diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml index f24bf44c..a8d0b5fe 100644 --- a/hw2/hw/docker-compose.yml +++ b/hw2/hw/docker-compose.yml @@ -48,12 +48,9 @@ services: ports: - "5432:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - ./postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 - -volumes: - postgres_data: \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index c9dac984..48d33d04 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -10,3 +10,6 @@ Faker>=37.8.0 sqlalchemy==2.0.25 psycopg2-binary==2.9.9 python-dotenv==1.0.0 +pytest-cov +pytest-mock +pytest \ No newline at end of file diff --git a/hw2/hw/shop_api/store/models.py b/hw2/hw/shop_api/store/models.py index f18d35ab..c8971a5f 100644 --- a/hw2/hw/shop_api/store/models.py +++ b/hw2/hw/shop_api/store/models.py @@ -29,10 +29,10 @@ def price(self): """Вычисление общей суммы корзины""" return sum(cart_item.total_price for cart_item in self.items) - @property - def quantity(self): - """Вычисление общей суммы корзины""" - return sum(cart_item.quantity for cart_item in self.items) + # @property + # def quantity(self): + # """Вычисление общей суммы корзины""" + # return sum(cart_item.quantity for cart_item in self.items) @@ -51,55 +51,5 @@ class CartItem(Base): @property def total_price(self): - """Общая цена для данного товара в корзине (цена × количество)""" + """Общая цена для данного товара в корзине (цена * количество)""" return self.item.price * self.quantity - - -# from dataclasses import dataclass -# from typing import Iterable, List - - -# @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 - - -# @dataclass(slots=True) -# class CartItemInfo: -# id: int -# name: str -# quantity: int -# available: bool - - -# # @dataclass(slots=True) -# # class CartItemEntity: -# # id: int -# # info: CartItemInfo - - -# @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/store/queries.py b/hw2/hw/shop_api/store/queries.py index 4dd76b57..908d0fa4 100644 --- a/hw2/hw/shop_api/store/queries.py +++ b/hw2/hw/shop_api/store/queries.py @@ -1,4 +1,5 @@ from typing import Iterable +from sqlalchemy import func from sqlalchemy.orm import Session from shop_api.store.models import ( @@ -105,29 +106,42 @@ def get_carts( min_quantity: int | None = None, max_quantity: int | None = None ) -> Iterable[Cart]: - # Начинаем построение запроса + query = db.query(Cart) - # Фильтр по минимальной цене - if min_price != None: - query = query.filter(Cart.price >= min_price) + # Создаем подзапросы для цены и количества + cart_stats_subquery = ( + db.query( + CartItem.cart_id, + func.sum(CartItem.quantity * Item.price).label('total_price'), + func.sum(CartItem.quantity).label('total_quantity') + ) + .join(Item, CartItem.item_id == Item.id) + .group_by(CartItem.cart_id) + .subquery() + ) - # Фильтр по максимальной цене - if max_price != None: - query = query.filter(Cart.price <= max_price) + # Присоединяем подзапрос к основному запросу + query = query.join(cart_stats_subquery, Cart.id == cart_stats_subquery.c.cart_id) + + # Фильтрация по цене + if min_price is not None: + query = query.filter(cart_stats_subquery.c.total_price >= min_price) + + if max_price is not None: + query = query.filter(cart_stats_subquery.c.total_price <= max_price) - # Фильтр по минимальному количеству товаров - if min_quantity != None: - query = query.filter(Cart.quantity >= min_quantity) + # Фильтрация по количеству + if min_quantity is not None: + query = query.filter(cart_stats_subquery.c.total_quantity >= min_quantity) - # Фильтр по максимальному количеству товаров - if max_quantity != None: - query = query.filter(Cart.quantity <= max_quantity) + if max_quantity is not None: + query = query.filter(cart_stats_subquery.c.total_quantity <= max_quantity) # Применяем пагинацию - items = query.offset(offset).limit(limit).all() + carts = query.order_by(Cart.id).offset(offset).limit(limit).all() - return items + return carts diff --git a/hw2/hw/shop_api/tests/__init__.py b/hw2/hw/shop_api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/tests/conftest.py b/hw2/hw/shop_api/tests/conftest.py new file mode 100644 index 00000000..65c0800e --- /dev/null +++ b/hw2/hw/shop_api/tests/conftest.py @@ -0,0 +1,12 @@ +import pytest + + + +@pytest.fixture +def sample_item_data(): + return { + "name": "Test Item", + "price": 100.0, + "deleted": False + } + diff --git a/hw2/hw/shop_api/tests/test_carts.py b/hw2/hw/shop_api/tests/test_carts.py new file mode 100644 index 00000000..1c61082d --- /dev/null +++ b/hw2/hw/shop_api/tests/test_carts.py @@ -0,0 +1,82 @@ +from fastapi.testclient import TestClient +from shop_api.main import app + +client = TestClient(app) + + +def test_cart_root(): + response = client.get("/cart/?min_price=100&max_price=200&min_quantity=1&max_quantity=2") + assert response.status_code == 200 + data = response.json() + assert data == [] + + +def test_create_cart(): + response = client.post("/cart/") + assert response.status_code == 201 + data = response.json() + assert data["id"] is not None + assert data["items"] == [] + assert data["price"] == 0.0 + + +def test_get_cart_by_id(): + cart_response = client.post("/cart/") + assert cart_response.status_code == 201 + cart_id = cart_response.json()["id"] + + # Проверяем корзину + cart_response = client.get(f"/cart/{cart_id}") + cart_data = cart_response.json() + assert cart_response.status_code == 200 + + # Запрос несуществующей корзины + cart_response = client.get("/cart/99999999") + cart_data = cart_response.json() + assert cart_response.status_code == 404 + + +def test_add_item_to_cart(sample_item_data): + # Создаем товар + item_response = client.post("/item/", json=sample_item_data) + item_id = item_response.json()["id"] + + # Создаем корзину + cart_response = client.post("/cart/") + cart_id = cart_response.json()["id"] + + # Добавляем товар в корзину + add_response = client.post(f"/cart/{cart_id}/add/{item_id}") + assert add_response.status_code == 201 + + # Проверяем корзину + cart_response = client.get(f"/cart/{cart_id}") + cart_data = cart_response.json() + + assert len(cart_data["items"]) == 1 + assert cart_data["items"][0]["id"] == item_id + assert cart_data["items"][0]["quantity"] == 1 + assert cart_data["price"] == sample_item_data["price"] + + # Добавляем тот же товар в ту же корзину + add_response = client.post(f"/cart/{cart_id}/add/{item_id}") + assert add_response.status_code == 201 + + # Проверяем корзину + cart_response = client.get(f"/cart/{cart_id}") + cart_data = cart_response.json() + + assert len(cart_data["items"]) == 1 + assert cart_data["items"][0]["id"] == item_id + assert cart_data["items"][0]["quantity"] == 2 + assert cart_data["price"] == sample_item_data["price"] * 2 + + # Запрос несуществующей корзины + cart_response = client.post(f"/cart/9999999999/add/{item_id}") + assert cart_response.status_code == 404 + + + # Запрос несуществующего товара + cart_response = client.post(f"/cart/{cart_id}/add/999999999999") + assert cart_response.status_code == 404 + diff --git a/hw2/hw/shop_api/tests/test_items.py b/hw2/hw/shop_api/tests/test_items.py new file mode 100644 index 00000000..a4d3bfc8 --- /dev/null +++ b/hw2/hw/shop_api/tests/test_items.py @@ -0,0 +1,106 @@ +from fastapi.testclient import TestClient +from shop_api.main import app + +client = TestClient(app) + +def test_item_root(): + response = client.get("/item/") + assert response.status_code == 200 + + +def test_create_item(sample_item_data): + response = client.post("/item/", json=sample_item_data) + assert response.status_code == 201 + data = response.json() + assert data["name"] == sample_item_data["name"] + assert data["price"] == sample_item_data["price"] + assert data["deleted"] is False + assert "id" in data + + +def test_get_item(sample_item_data): + # Сначала создаем товар + create_response = client.post("/item/", json=sample_item_data) + item_id = create_response.json()["id"] + + # Затем получаем его + response = client.get(f"/item/{item_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == item_id + assert data["name"] == sample_item_data["name"] + + # Получаем несуществующий товар + response = client.get("/item/99999999") + assert response.status_code == 404 + + +def test_get_items_with_filters(): + # Создаем несколько товаров + client.post("/item/", json={"name": "Item 1", "price": 50.0, "deleted": False}) + client.post("/item/", json={"name": "Item 2", "price": 150.0, "deleted": False}) + client.post("/item/", json={"name": "Item 3", "price": 200.0, "deleted": False}) + + # Тестируем фильтрацию по цене + response = client.get("/item/?min_price=100&max_price=200") + assert response.status_code == 200 + data = response.json() + assert all(100 <= item["price"] <= 200 for item in data) + + +def test_update_item(sample_item_data): + # Создаем товар + create_response = client.post("/item/", json=sample_item_data) + item_id = create_response.json()["id"] + + # Обновляем товар + update_data = {"name": "Updated Item", "price": 200.0, "deleted": False} + response = client.put(f"/item/{item_id}", json=update_data) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Updated Item" + assert data["price"] == 200.0 + + # Обновляем несуществующий товар + response = client.put("/item/99999999", json=update_data) + assert response.status_code == 304 + + +def test_patch_item(sample_item_data): + # Создаем товар + create_response = client.post("/item/", json=sample_item_data) + item_id = create_response.json()["id"] + + # Обновляем товар + patch_data = {"name": "Patched Item"} + response = client.patch(f"/item/{item_id}", json=patch_data) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Patched Item" + assert data["price"] == 100.0 + + # Обновляем цену + patch_data = {"price": 1.1} + response = client.patch(f"/item/{item_id}", json=patch_data) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Patched Item" + assert data["price"] == 1.1 + + # Обновляем несуществующий товар + response = client.patch("/item/99999999", json=patch_data) + assert response.status_code == 304 + + +def test_delete_item(sample_item_data): + # Создаем товар + create_response = client.post("/item/", json=sample_item_data) + item_id = create_response.json()["id"] + + # Удаляем товар (мягкое удаление) + response = client.delete(f"/item/{item_id}") + assert response.status_code == 200 + + # Проверяем, что товар помечен как удаленный + get_response = client.get(f"/item/{item_id}") + assert get_response.json()["deleted"] is True \ No newline at end of file From e18d1bfbf093992f45f4df7b54ecb9df70b227b4 Mon Sep 17 00:00:00 2001 From: Gleb Gerlakh Date: Sun, 26 Oct 2025 01:53:32 +0300 Subject: [PATCH 7/8] Add hw5 ci tests --- .github/workflows/hw5-tests.yml | 61 +++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/hw5-tests.yml diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 00000000..7cafd637 --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,61 @@ +name: "HW5 Tests" + +# Запускаем тесты при изменении файлов в hw2/hw/ +on: + pull_request: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + push: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + +jobs: + test-hw2: + runs-on: ubuntu-latest + + # Добавляем PostgreSQL как service container + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: hw4_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + + strategy: + matrix: + python-version: ["3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: hw2/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + working-directory: hw2/hw + env: + PYTHONPATH: ${{ github.workspace }}/hw2/hw + POSTGRES_PASSWORD: password + POSTGRES_HOST: localhost + POSTGRES_USER: postgres + POSTGRES_DB: hw4_db + run: | + pytest -vv --cov=shop_api/ --cov-fail-under=99 ./shop_api/tests From ec7675140eccaa35390339af9c47ffd9e07ff66e Mon Sep 17 00:00:00 2001 From: Gleb Gerlakh Date: Sun, 26 Oct 2025 01:55:44 +0300 Subject: [PATCH 8/8] Update .github/workflows/hw5-tests.yml --- .github/workflows/hw5-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml index 7cafd637..125f3582 100644 --- a/.github/workflows/hw5-tests.yml +++ b/.github/workflows/hw5-tests.yml @@ -10,7 +10,7 @@ on: paths: [ 'hw2/hw/**' ] jobs: - test-hw2: + test-hw5: runs-on: ubuntu-latest # Добавляем PostgreSQL как service container @@ -58,4 +58,4 @@ jobs: POSTGRES_USER: postgres POSTGRES_DB: hw4_db run: | - pytest -vv --cov=shop_api/ --cov-fail-under=99 ./shop_api/tests + pytest -vv --cov=shop_api/ --cov-fail-under=100 ./shop_api/tests