diff --git a/hw1/app.py b/hw1/app.py index 6107b870..24ad9f25 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,33 @@ +from http import HTTPStatus +import json +from utils.utils import ( + check_request_valid, + create_message, + create_start_message +) +from utils.math_functions import ( + factorial, + fibonacci, + mean, + validate_list, + validate_number +) from typing import Any, Awaitable, Callable +import urllib + + +async def read_body( + receive: Callable[[], Awaitable[dict[str, Any]]] +) -> str: + body = b"" + while True: + message = await receive() + body += message.get("body", b"") + if not message.get("more_body", False): + break + return body + async def application( scope: dict[str, Any], @@ -12,7 +40,93 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + + if scope['type'] == 'http': + method = scope["method"] + path = scope["path"] + + if not check_request_valid(method, path): + status_code = HTTPStatus.NOT_FOUND.value + body = { + "result": HTTPStatus.NOT_FOUND.phrase + } + + input_body = await read_body(receive) + + if path == "/factorial": + query_string = scope["query_string"] + parsed_query = urllib.parse.parse_qs(query_string.decode("utf-8")) + if parsed_query.get("n"): + n = parsed_query["n"][0] + else: + n = None + + validation_result = validate_number(n) + if validation_result: + status_code, data = validation_result + else: + data = factorial(n) + status_code = HTTPStatus.OK.value + + body = { + "result": data + } + + elif "/fibonacci" in path: + n = path.split("/")[-1] + + validation_result = validate_number(n) + if validation_result: + status_code, data = validation_result + else: + data = fibonacci(n) + status_code = HTTPStatus.OK.value + + body = { + "result": data + } + + elif path == "/mean": + input_body = input_body.decode() + try: + json_data = json.loads(input_body) + except json.decoder.JSONDecodeError: + json_data = [] + + validation_result = validate_list(json_data) + + if validation_result: + status_code, data = validation_result + else: + data = mean(json_data) + status_code = HTTPStatus.OK.value + + body = { + "result": data + } + + await send( + create_start_message( + status_code=status_code + ) + ) + + await send( + create_message( + body=body + ) + ) + + elif scope["type"] == "lifespan": + while True: + message = await receive() + t = message.get("type") + if t == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif t == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + if __name__ == "__main__": import uvicorn diff --git a/hw1/utils/math_functions.py b/hw1/utils/math_functions.py new file mode 100644 index 00000000..53c1dc49 --- /dev/null +++ b/hw1/utils/math_functions.py @@ -0,0 +1,55 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +from utils.utils import is_digit + + +def validate_number( + n: str +) -> Optional[tuple[int, str]]: + if not n or not is_digit(n): + return HTTPStatus.UNPROCESSABLE_ENTITY.value, HTTPStatus.UNPROCESSABLE_ENTITY.phrase + + n = int(n) + + if n < 0: + return HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.phrase + + +def validate_list( + l: Any +) -> Optional[tuple[int, str]]: + if isinstance(l, list) and l == []: + return HTTPStatus.BAD_REQUEST.value, HTTPStatus.BAD_REQUEST.phrase + + if not l: + return HTTPStatus.UNPROCESSABLE_ENTITY.value, HTTPStatus.UNPROCESSABLE_ENTITY.phrase + + +def factorial( + n: str +) -> int: + n = int(n) + + factorial = 1 + for i in range(1, n + 1): + factorial *= i + + return factorial + + +def fibonacci( + n: str +) -> int: + n = int(n) + + if n <= 1: + return n + else: + return fibonacci(n - 1) + fibonacci(n - 2) + + +def mean( + nums: list[Union[int, float]] +) -> Union[int, float]: + return sum(nums)/len(nums) diff --git a/hw1/utils/utils.py b/hw1/utils/utils.py new file mode 100644 index 00000000..dabf7225 --- /dev/null +++ b/hw1/utils/utils.py @@ -0,0 +1,38 @@ +import json + + +def is_digit( + str_num: str +) -> bool: + if str_num.startswith("-"): + str_num = str_num[1:] + + return str_num.isdigit() + + +def check_request_valid( + method: str, + path: str +) -> bool: + return method == "GET" and path in ["/factorial", "/fibonacci", "/mean"] + + +def create_start_message( + status_code: int = 200 +) -> dict: + return { + 'type': 'http.response.start', + 'status': status_code, + 'headers': [ + (b'content-type', b'text/plain'), + ] + } + + +def create_message( + body: str +) -> dict: + return { + 'type': 'http.response.body', + 'body': json.dumps(body).encode("utf-8"), + } diff --git a/hw2/.github/workflows/test.yml b/hw2/.github/workflows/test.yml new file mode 100644 index 00000000..fa1beac6 --- /dev/null +++ b/hw2/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: HW2 tests + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + +jobs: + build-and-test: + name: Build and Test + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-mock pytest-cov + + - name: Run tests + run: | + pytest \ No newline at end of file diff --git a/hw2/hw/shop_api/handlers/cart.py b/hw2/hw/shop_api/handlers/cart.py new file mode 100644 index 00000000..727c31fc --- /dev/null +++ b/hw2/hw/shop_api/handlers/cart.py @@ -0,0 +1,108 @@ +from http import HTTPStatus +from typing import Annotated, List +import uuid + +from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import JSONResponse +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt + +from shop_api.models.cart import CartOutSchema +from shop_api import local_data + + +router = APIRouter(prefix="/cart") + + +@router.post( + "", + response_model=CartOutSchema, + status_code=HTTPStatus.CREATED +) +async def add_cart(response: Response): + cart_id = str(uuid.uuid4()) + cart_data = {"id": cart_id} + + local_data.add_single_cart(cart_data=cart_data) + + response.headers["Location"] = f"/cart/{cart_id}" + + return cart_data + + +@router.get( + "/{cart_id}", + response_model=CartOutSchema, + status_code=HTTPStatus.OK +) +async def get_cart_by_id(cart_id: str): + cart_data = local_data.get_single_cart(cart_id=cart_id) + + return cart_data + + +@router.get( + "", + response_model=List[CartOutSchema], + status_code=HTTPStatus.OK +) +async def get_all_carts( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] = None, + max_price: Annotated[NonNegativeFloat, Query()] = None, + min_quantity: Annotated[NonNegativeInt, Query()] = None, + max_quantity: Annotated[NonNegativeInt, Query()] = None, +): + all_carts = local_data.get_all_carts() + + filtered_carts: List[CartOutSchema] = [] + for cart in all_carts: + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + if min_quantity is not None and sum([item.quantity for item in cart.items]) < min_quantity: + continue + if max_quantity is not None and sum([item.quantity for item in cart.items]) > max_quantity: + continue + + filtered_carts.append(cart) + + filtered_carts = filtered_carts[offset: offset + limit] + + return filtered_carts + + +@router.post( + "/{cart_id}/add/{item_id}", + response_model=CartOutSchema, + status_code=HTTPStatus.OK +) +async def add_item_to_cart( + cart_id: str, + item_id: str +): + cart = local_data.get_single_cart(cart_id=cart_id) + + if cart is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart with {cart_id=!r} wasn't found", + ) + + item_ids = local_data.get_all_item_ids_for_cart( + cart_id=cart_id + ) + + if item_id in item_ids: + for item in cart.items: + if item_id == item.id: + item.quantity += 1 + else: + cart.items.append( + local_data.get_single_item( + item_id=item_id + ) + ) + + return cart diff --git a/hw2/hw/shop_api/handlers/item.py b/hw2/hw/shop_api/handlers/item.py new file mode 100644 index 00000000..8f16b6b4 --- /dev/null +++ b/hw2/hw/shop_api/handlers/item.py @@ -0,0 +1,158 @@ +from http import HTTPStatus +from typing import Annotated, List +import uuid + +from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import JSONResponse +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt + +from shop_api.models.cart import CartOutSchema +from shop_api import local_data +from shop_api.models.item import ItemPatchSchema, ItemSchema, ItemCreateSchema + + +router = APIRouter(prefix="/item") + + +@router.post( + "", + response_model=ItemSchema, + status_code=HTTPStatus.CREATED, +) +async def add_item( + response: Response, + item: ItemCreateSchema +): + item_id = str(uuid.uuid4()) + item_data = { + "id": item_id, + **item.model_dump() + } + + local_data.add_single_item( + item_id=item_id, + item_data=item_data + ) + + response.headers["Location"] = f"/item/{item_id}" + + return item_data + + +@router.get( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def get_item_by_id(item_id: str): + item_data = local_data.get_single_item(item_id=item_id) + if item_data.deleted: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with {item_id=!r} was deleted" + ) + return item_data + + +@router.get( + "", + response_model=List[ItemSchema], + status_code=HTTPStatus.OK +) +async def get_all_items( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] = None, + max_price: Annotated[NonNegativeFloat, Query()] = None, + show_deleted: Annotated[bool, Query()] = False, +): + all_items = local_data.get_all_items() + + filtered_items: List[ItemSchema] = [] + for item in all_items: + if min_price and item.price < min_price: + continue + if max_price and item.price > max_price: + continue + if not show_deleted and item.deleted: + continue + + filtered_items.append(item) + + filtered_items = filtered_items[offset: offset + limit] + + return filtered_items + + +@router.put( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def change_item( + item_id: str, + item: ItemSchema +): + if local_data.get_single_item(item_id=item_id) is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with {item_id=!r} is not found" + ) + + local_data.add_single_item( + item_id=item_id, + item_data=item + ) + + return item + + +@router.patch( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def change_item_fields( + item_id: str, + item: ItemPatchSchema +): + old_item = local_data.get_single_item(item_id=item_id) + if old_item is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with {item_id=!r} is not found" + ) + + if old_item.deleted: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Item with {item_id=!r} has been deleted" + ) + + update_data = item.model_dump(exclude_unset=True) + updated_item = old_item.model_copy(update=update_data) + + local_data.add_single_item( + item_id=item_id, + item_data=updated_item + ) + + return updated_item + + +@router.delete( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def delete_item(item_id: str): + item_data = local_data.get_single_item(item_id=item_id) + if item_data is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with {item_id=!r} is not found" + ) + + local_data.delete_item(item_id=item_id) + return item_data + \ No newline at end of file diff --git a/hw2/hw/shop_api/local_data.py b/hw2/hw/shop_api/local_data.py new file mode 100644 index 00000000..4ba9ea0c --- /dev/null +++ b/hw2/hw/shop_api/local_data.py @@ -0,0 +1,59 @@ +from pydantic import validate_call +from typing import Dict, List, Set, Union +from shop_api.models.cart import CartOutSchema +from shop_api.models.item import ItemSchema + + +_local_data_carts: Dict[str, CartOutSchema] = {} +_local_data_items: Dict[str, ItemSchema] = {} + + +@validate_call +def add_single_cart(cart_data: CartOutSchema) -> None: + cart_id = cart_data.id + _local_data_carts[cart_id] = cart_data + + +def get_single_cart( + cart_id: str +) -> Union[CartOutSchema, None]: + if cart_id not in _local_data_carts: + return None + + return _local_data_carts[cart_id] + + +@validate_call +def get_all_carts() -> List[CartOutSchema]: + return list(_local_data_carts.values()) + + +@validate_call +def add_single_item( + item_id: str, + item_data: ItemSchema +) -> None: + item_data.id = item_id + _local_data_items[item_id] = item_data + + +@validate_call +def get_single_item(item_id: str) -> Union[ItemSchema, None]: + if item_id not in _local_data_items: + return None + + return _local_data_items[item_id] + + +@validate_call +def get_all_items() -> List[ItemSchema]: + return list(_local_data_items.values()) + + +def get_all_item_ids_for_cart(cart_id: str) -> Set[str]: + return set(list(_local_data_carts[cart_id].model_fields.keys())) + + +def delete_item(item_id: str) -> None: + item = _local_data_items.get(item_id) + item.deleted = True diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..7b21b4df 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,9 @@ from fastapi import FastAPI +from shop_api.handlers.cart import router as cart_router +from shop_api.handlers.item import router as item_router + app = FastAPI(title="Shop API") + +app.include_router(cart_router) +app.include_router(item_router) diff --git a/hw2/hw/shop_api/models/cart.py b/hw2/hw/shop_api/models/cart.py new file mode 100644 index 00000000..b2e932b0 --- /dev/null +++ b/hw2/hw/shop_api/models/cart.py @@ -0,0 +1,14 @@ +from typing import List +from pydantic import BaseModel, Field, computed_field + +from shop_api.models.item import ItemSchema + + +class CartOutSchema(BaseModel): + id: str + items: List[ItemSchema] = Field(default_factory=list) + + @computed_field + @property + def price(self) -> float: + return sum(item.price * item.quantity for item in self.items) diff --git a/hw2/hw/shop_api/models/item.py b/hw2/hw/shop_api/models/item.py new file mode 100644 index 00000000..a708bc1a --- /dev/null +++ b/hw2/hw/shop_api/models/item.py @@ -0,0 +1,23 @@ +import uuid +from pydantic import BaseModel, ConfigDict + + +class ItemSchema(BaseModel): + id: str = str(uuid.uuid4()) + name: str + price: float + deleted: bool = False + quantity: int = 1 + + +class ItemCreateSchema(BaseModel): + name: str = "" + price: float = 0.0 + + +class ItemPatchSchema(BaseModel): + name: str = "" + price: float = 0.0 + quantity: int = 1 + + model_config = ConfigDict(extra='forbid') diff --git a/hw2/hw/tests/handlers/test_cart.py b/hw2/hw/tests/handlers/test_cart.py new file mode 100644 index 00000000..ca27b9d5 --- /dev/null +++ b/hw2/hw/tests/handlers/test_cart.py @@ -0,0 +1,204 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from http import HTTPStatus + +from shop_api.handlers.cart import router +from shop_api.models.item import ItemSchema +from shop_api.models.cart import CartOutSchema + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router) + with TestClient(app) as test_client: + yield test_client + +@pytest.fixture +def mock_data(): + item1 = ItemSchema(name="Test Item", id="item-1", quantity=1, price=10.0) + item2 = ItemSchema(name="Test Item", id="item-2", quantity=2, price=25.0) + + cart1_item = item1.model_copy() + cart1 = CartOutSchema(id="cart-123", items=[cart1_item]) + + cart2 = CartOutSchema( + id="cart-456", + items=[ + item1.model_copy(update={"quantity": 3}), + item2.model_copy() + ] + ) + + cart3 = CartOutSchema(id="cart-789", items=[]) + + return { + "item1": item1, + "item2": item2, + "cart1": cart1, + "cart2": cart2, + "cart3": cart3, + "all_carts": [cart1, cart2, cart3] + } + + +def test_add_cart(client: TestClient, mocker): + test_uuid = "new-cart-uuid" + mocker.patch( + "shop_api.handlers.cart.uuid.uuid4", + return_value=test_uuid + ) + mock_add_db = mocker.patch("shop_api.handlers.cart.local_data.add_single_cart") + + response = client.post("/cart") + assert response.status_code == HTTPStatus.CREATED + + expected_data = {"id": test_uuid} + assert response.json()["id"] == test_uuid + assert response.headers["Location"] == f"/cart/{test_uuid}" + + mock_add_db.assert_called_once_with(cart_data=expected_data) + + +def test_get_cart_by_id(client: TestClient, mocker, mock_data): + test_cart = mock_data["cart1"] + + mocker.patch( + "shop_api.handlers.cart.local_data.get_single_cart", + return_value=test_cart + ) + + response = client.get(f"/cart/{test_cart.id}") + assert response.status_code == HTTPStatus.OK + assert response.json() == test_cart.model_dump() + + +def test_add_item_to_cart_new_item(client: TestClient, mocker, mock_data): + cart = mock_data["cart3"] + item_to_add = mock_data["item1"] + + mocker.patch("shop_api.handlers.cart.local_data.get_single_cart", return_value=cart) + + mocker.patch( + "shop_api.handlers.cart.local_data.get_all_item_ids_for_cart", + return_value=[] + ) + + mocker.patch( + "shop_api.handlers.cart.local_data.get_single_item", + return_value=item_to_add + ) + + response = client.post(f"/cart/{cart.id}/add/{item_to_add.id}") + + assert response.status_code == HTTPStatus.OK + + response_data = response.json() + assert len(response_data["items"]) == 1 + assert response_data["items"][0]["id"] == item_to_add.id + assert response_data["items"][0]["quantity"] == 1 + + +def test_add_item_to_cart_existing_item(client: TestClient, mocker, mock_data): + cart = mock_data["cart1"] + item_id_to_add = "item-1" + + mocker.patch("shop_api.handlers.cart.local_data.get_single_cart", return_value=cart) + + mocker.patch( + "shop_api.handlers.cart.local_data.get_all_item_ids_for_cart", + return_value=[item_id_to_add] + ) + + response = client.post(f"/cart/{cart.id}/add/{item_id_to_add}") + assert response.status_code == HTTPStatus.OK + + response_data = response.json() + assert len(response_data["items"]) == 1 + assert response_data["items"][0]["id"] == item_id_to_add + assert response_data["items"][0]["quantity"] == 2 + + +def test_add_item_to_cart_cart_not_found(client: TestClient, mocker): + mocker.patch("shop_api.handlers.cart.local_data.get_single_cart", return_value=None) + + response = client.post("/cart/fake-cart-id/add/fake-item-id") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert "wasn't found" in response.json()["detail"] + + +@pytest.mark.parametrize( + "params, expected_ids", + [ + ( + {"offset": 0, "limit": 2}, + ["cart-123", "cart-456"] + ), + ( + {"offset": 1, "limit": 2}, + ["cart-456", "cart-789"] + ), + ( + {"offset": 10, "limit": 10}, + [] + ), + ( + {"min_price": 50.0}, + ["cart-456"] + ), + ( + {"max_price": 50.0}, + ["cart-123", "cart-789"] + ), + ( + {"min_price": 5.0, "max_price": 20.0}, + ["cart-123"] + ), + ( + {"min_quantity": 2}, + ["cart-456"] + ), + ( + {"max_quantity": 2}, + ["cart-123", "cart-789"] + ), + ( + {"min_quantity": 1, "max_quantity": 1}, + ["cart-123"] + ), + ( + {"min_price": 5.0, "min_quantity": 2}, + ["cart-456"] + ), + ( + {"max_price": 90.0, "max_quantity": 1}, + ["cart-123", "cart-789"] + ), + ( + {"min_price": 1000.0}, + [] + ), + ( + {}, + ["cart-123", "cart-456", "cart-789"] + ), + ], +) +def test_get_all_carts_filtering( + client: TestClient, mocker, mock_data, params, expected_ids +): + mocker.patch( + "shop_api.handlers.cart.local_data.get_all_carts", + return_value=mock_data["all_carts"] + ) + + response = client.get("/cart", params=params) + + assert response.status_code == HTTPStatus.OK + + response_data = response.json() + response_ids = [cart["id"] for cart in response_data] + + assert response_ids == expected_ids \ No newline at end of file diff --git a/hw2/hw/tests/handlers/test_item.py b/hw2/hw/tests/handlers/test_item.py new file mode 100644 index 00000000..7a2f7f6d --- /dev/null +++ b/hw2/hw/tests/handlers/test_item.py @@ -0,0 +1,186 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from http import HTTPStatus + +from shop_api.handlers.item import router +from shop_api.models.item import ItemSchema + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router) + with TestClient(app) as test_client: + yield test_client + +@pytest.fixture +def mock_data(): + item1 = ItemSchema(id="item-1", name="Apple", price=10.0, quantity=5) + item2 = ItemSchema(id="item-2", name="Banana", price=100.0, quantity=1) + item3_deleted = ItemSchema(id="item-3", name="Cherry", price=50.0, quantity=10, deleted=True) + return { + "item1": item1, + "item2": item2, + "item3_deleted": item3_deleted, + "all_items": [item1, item2, item3_deleted] + } + +def test_add_item(client: TestClient, mocker): + test_uuid = "new-item-uuid" + + item_create_data = {"name": "Test Item", "price": 9.99, "quantity": 1} + + mocker.patch("shop_api.handlers.item.uuid.uuid4", return_value=test_uuid) + mock_add_db = mocker.patch("shop_api.handlers.item.local_data.add_single_item") + + response = client.post("/item", json=item_create_data) + + assert response.status_code == HTTPStatus.CREATED + assert response.headers["Location"] == f"/item/{test_uuid}" + + data_sent_to_db = { + "id": test_uuid, + "name": "Test Item", + "price": 9.99 + } + + data_returned_to_client = { + "id": test_uuid, + "name": "Test Item", + "price": 9.99, + "quantity": 1, + "deleted": False + } + + assert response.json() == data_returned_to_client + + mock_add_db.assert_called_once_with( + item_id=test_uuid, + item_data=data_sent_to_db + ) + +def test_get_item_by_id_success(client: TestClient, mocker, mock_data): + test_item = mock_data["item1"] + mocker.patch("shop_api.handlers.item.local_data.get_single_item", return_value=test_item) + + response = client.get(f"/item/{test_item.id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == test_item.model_dump() + +def test_get_item_by_id_deleted(client: TestClient, mocker, mock_data): + deleted_item = mock_data["item3_deleted"] + mocker.patch("shop_api.handlers.item.local_data.get_single_item", return_value=deleted_item) + + response = client.get(f"/item/{deleted_item.id}") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert "was deleted" in response.json()["detail"] + +@pytest.mark.parametrize( + "params, expected_ids", + [ + ({}, ["item-1", "item-2"]), + ({"show_deleted": True}, ["item-1", "item-2", "item-3"]), + ({"limit": 1}, ["item-1"]), + ({"offset": 1}, ["item-2"]), + ({"offset": 1, "limit": 1, "show_deleted": True}, ["item-2"]), + ({"min_price": 20.0}, ["item-2"]), + ({"min_price": 20.0, "show_deleted": False}, ["item-2"]), + ({"max_price": 20.0}, ["item-1"]), + ({"min_price": 5.0, "max_price": 60.0, "show_deleted": True}, ["item-1", "item-3"]), + ({"min_price": 1000.0}, []), + ], +) +def test_get_all_items(client: TestClient, mocker, mock_data, params, expected_ids): + mocker.patch("shop_api.handlers.item.local_data.get_all_items", return_value=mock_data["all_items"]) + + response = client.get("/item", params=params) + + assert response.status_code == HTTPStatus.OK + response_ids = [item["id"] for item in response.json()] + assert response_ids == expected_ids + +def test_change_item(client: TestClient, mocker, mock_data): + existing_item = mock_data["item1"] + + updated_item_data = { + "id": existing_item.id, + "name": "New Apple", + "price": 15.0, + "quantity": 2, + "deleted": False + } + + mocker.patch("shop_api.handlers.item.local_data.get_single_item", return_value=existing_item) + mock_add_db = mocker.patch("shop_api.handlers.item.local_data.add_single_item") + + response = client.put(f"/item/{existing_item.id}", json=updated_item_data) + + assert response.status_code == HTTPStatus.OK + assert response.json() == updated_item_data + + updated_model = ItemSchema(**updated_item_data) + mock_add_db.assert_called_once_with( + item_id=existing_item.id, + item_data=updated_model + ) + +def test_change_item_not_found(client: TestClient, mocker): + mocker.patch("shop_api.handlers.item.local_data.get_single_item", return_value=None) + + invalid_data = {"id": "fake-id", "name": "Fake", "price": 1, "quantity": 1, "deleted": False} + response = client.put("/item/fake-id", json=invalid_data) + + assert response.status_code == HTTPStatus.NOT_FOUND + +def test_change_item_fields(client: TestClient, mocker, mock_data): + old_item = mock_data["item1"].model_copy() + patch_data = {"name": "Gala Apple", "quantity": 50} + + mocker.patch("shop_api.handlers.item.local_data.get_single_item", return_value=old_item) + mock_add_db = mocker.patch("shop_api.handlers.item.local_data.add_single_item") + + response = client.patch(f"/item/{old_item.id}", json=patch_data) + + assert response.status_code == HTTPStatus.OK + + expected_item = old_item.model_copy(update=patch_data) + assert response.json() == expected_item.model_dump() + + mock_add_db.assert_called_once_with( + item_id=old_item.id, + item_data=expected_item + ) + +def test_change_item_fields_not_found(client: TestClient, mocker): + mocker.patch("shop_api.handlers.item.local_data.get_single_item", return_value=None) + response = client.patch("/item/fake-id", json={"name": "test"}) + assert response.status_code == HTTPStatus.NOT_FOUND + +def test_change_item_fields_deleted(client: TestClient, mocker, mock_data): + deleted_item = mock_data["item3_deleted"] + mocker.patch("shop_api.handlers.item.local_data.get_single_item", return_value=deleted_item) + + response = client.patch(f"/item/{deleted_item.id}", json={"name": "test"}) + + assert response.status_code == HTTPStatus.NOT_MODIFIED + +def test_delete_item(client: TestClient, mocker, mock_data): + item_to_delete = mock_data["item1"] + + mocker.patch("shop_api.handlers.item.local_data.get_single_item", return_value=item_to_delete) + mock_delete_db = mocker.patch("shop_api.handlers.item.local_data.delete_item") + + response = client.delete(f"/item/{item_to_delete.id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == item_to_delete.model_dump() + + mock_delete_db.assert_called_once_with(item_id=item_to_delete.id) + +def test_delete_item_not_found(client: TestClient, mocker): + mocker.patch("shop_api.handlers.item.local_data.get_single_item", return_value=None) + response = client.delete("/item/fake-id") + assert response.status_code == HTTPStatus.NOT_FOUND \ No newline at end of file diff --git a/hw2/pytest.ini b/hw2/pytest.ini new file mode 100644 index 00000000..171cd2de --- /dev/null +++ b/hw2/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = hw \ No newline at end of file diff --git a/hw3/Dockerfile b/hw3/Dockerfile new file mode 100644 index 00000000..43812477 --- /dev/null +++ b/hw3/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r 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/hw3/README.md b/hw3/README.md new file mode 100644 index 00000000..aad28c54 --- /dev/null +++ b/hw3/README.md @@ -0,0 +1,13 @@ +# ДЗ + +## Настроить сборку образов Docker и мониторинг с помощью Prometheus и Grafana + +Интегрировать Docker с Prometheus и Grafana в любой уже написанный в ДЗ сервис (по аналогии с тем, как в репе) + +По сути, если вы выполнили вторую домашку, то теперь для неё надо написать Dockerfile и настроить мониторинг. Если вторую домашку вы не делали, то можно взять сервис из [rest_example](../hw2/rest_example/main.py) + +Сдача через PR, так же нужно: + +1) Dockerfile для сборки сервиса +2) docker-compose.yml для локального разворачивания в Docker +3) Приложить скрин с парой Дашбордов в Grafana diff --git a/hw3/docker-compose.yml b/hw3/docker-compose.yml new file mode 100644 index 00000000..a1d7be69 --- /dev/null +++ b/hw3/docker-compose.yml @@ -0,0 +1,32 @@ + +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 diff --git a/hw3/grafana_examples/image.png b/hw3/grafana_examples/image.png new file mode 100644 index 00000000..18e0adf3 Binary files /dev/null and b/hw3/grafana_examples/image.png differ diff --git a/hw3/requirements.txt b/hw3/requirements.txt new file mode 100644 index 00000000..bb6b4134 --- /dev/null +++ b/hw3/requirements.txt @@ -0,0 +1,3 @@ +fastapi +uvicorn +prometheus-fastapi-instrumentator \ No newline at end of file diff --git a/hw3/settings/prometheus/prometheus.yml b/hw3/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..6bdf88e7 --- /dev/null +++ b/hw3/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: demo-service-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw3/shop_api/__init__.py b/hw3/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/shop_api/handlers/cart.py b/hw3/shop_api/handlers/cart.py new file mode 100644 index 00000000..727c31fc --- /dev/null +++ b/hw3/shop_api/handlers/cart.py @@ -0,0 +1,108 @@ +from http import HTTPStatus +from typing import Annotated, List +import uuid + +from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import JSONResponse +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt + +from shop_api.models.cart import CartOutSchema +from shop_api import local_data + + +router = APIRouter(prefix="/cart") + + +@router.post( + "", + response_model=CartOutSchema, + status_code=HTTPStatus.CREATED +) +async def add_cart(response: Response): + cart_id = str(uuid.uuid4()) + cart_data = {"id": cart_id} + + local_data.add_single_cart(cart_data=cart_data) + + response.headers["Location"] = f"/cart/{cart_id}" + + return cart_data + + +@router.get( + "/{cart_id}", + response_model=CartOutSchema, + status_code=HTTPStatus.OK +) +async def get_cart_by_id(cart_id: str): + cart_data = local_data.get_single_cart(cart_id=cart_id) + + return cart_data + + +@router.get( + "", + response_model=List[CartOutSchema], + status_code=HTTPStatus.OK +) +async def get_all_carts( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] = None, + max_price: Annotated[NonNegativeFloat, Query()] = None, + min_quantity: Annotated[NonNegativeInt, Query()] = None, + max_quantity: Annotated[NonNegativeInt, Query()] = None, +): + all_carts = local_data.get_all_carts() + + filtered_carts: List[CartOutSchema] = [] + for cart in all_carts: + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + if min_quantity is not None and sum([item.quantity for item in cart.items]) < min_quantity: + continue + if max_quantity is not None and sum([item.quantity for item in cart.items]) > max_quantity: + continue + + filtered_carts.append(cart) + + filtered_carts = filtered_carts[offset: offset + limit] + + return filtered_carts + + +@router.post( + "/{cart_id}/add/{item_id}", + response_model=CartOutSchema, + status_code=HTTPStatus.OK +) +async def add_item_to_cart( + cart_id: str, + item_id: str +): + cart = local_data.get_single_cart(cart_id=cart_id) + + if cart is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart with {cart_id=!r} wasn't found", + ) + + item_ids = local_data.get_all_item_ids_for_cart( + cart_id=cart_id + ) + + if item_id in item_ids: + for item in cart.items: + if item_id == item.id: + item.quantity += 1 + else: + cart.items.append( + local_data.get_single_item( + item_id=item_id + ) + ) + + return cart diff --git a/hw3/shop_api/handlers/item.py b/hw3/shop_api/handlers/item.py new file mode 100644 index 00000000..8f16b6b4 --- /dev/null +++ b/hw3/shop_api/handlers/item.py @@ -0,0 +1,158 @@ +from http import HTTPStatus +from typing import Annotated, List +import uuid + +from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import JSONResponse +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt + +from shop_api.models.cart import CartOutSchema +from shop_api import local_data +from shop_api.models.item import ItemPatchSchema, ItemSchema, ItemCreateSchema + + +router = APIRouter(prefix="/item") + + +@router.post( + "", + response_model=ItemSchema, + status_code=HTTPStatus.CREATED, +) +async def add_item( + response: Response, + item: ItemCreateSchema +): + item_id = str(uuid.uuid4()) + item_data = { + "id": item_id, + **item.model_dump() + } + + local_data.add_single_item( + item_id=item_id, + item_data=item_data + ) + + response.headers["Location"] = f"/item/{item_id}" + + return item_data + + +@router.get( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def get_item_by_id(item_id: str): + item_data = local_data.get_single_item(item_id=item_id) + if item_data.deleted: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with {item_id=!r} was deleted" + ) + return item_data + + +@router.get( + "", + response_model=List[ItemSchema], + status_code=HTTPStatus.OK +) +async def get_all_items( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] = None, + max_price: Annotated[NonNegativeFloat, Query()] = None, + show_deleted: Annotated[bool, Query()] = False, +): + all_items = local_data.get_all_items() + + filtered_items: List[ItemSchema] = [] + for item in all_items: + if min_price and item.price < min_price: + continue + if max_price and item.price > max_price: + continue + if not show_deleted and item.deleted: + continue + + filtered_items.append(item) + + filtered_items = filtered_items[offset: offset + limit] + + return filtered_items + + +@router.put( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def change_item( + item_id: str, + item: ItemSchema +): + if local_data.get_single_item(item_id=item_id) is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with {item_id=!r} is not found" + ) + + local_data.add_single_item( + item_id=item_id, + item_data=item + ) + + return item + + +@router.patch( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def change_item_fields( + item_id: str, + item: ItemPatchSchema +): + old_item = local_data.get_single_item(item_id=item_id) + if old_item is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with {item_id=!r} is not found" + ) + + if old_item.deleted: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Item with {item_id=!r} has been deleted" + ) + + update_data = item.model_dump(exclude_unset=True) + updated_item = old_item.model_copy(update=update_data) + + local_data.add_single_item( + item_id=item_id, + item_data=updated_item + ) + + return updated_item + + +@router.delete( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def delete_item(item_id: str): + item_data = local_data.get_single_item(item_id=item_id) + if item_data is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item with {item_id=!r} is not found" + ) + + local_data.delete_item(item_id=item_id) + return item_data + \ No newline at end of file diff --git a/hw3/shop_api/local_data.py b/hw3/shop_api/local_data.py new file mode 100644 index 00000000..4ba9ea0c --- /dev/null +++ b/hw3/shop_api/local_data.py @@ -0,0 +1,59 @@ +from pydantic import validate_call +from typing import Dict, List, Set, Union +from shop_api.models.cart import CartOutSchema +from shop_api.models.item import ItemSchema + + +_local_data_carts: Dict[str, CartOutSchema] = {} +_local_data_items: Dict[str, ItemSchema] = {} + + +@validate_call +def add_single_cart(cart_data: CartOutSchema) -> None: + cart_id = cart_data.id + _local_data_carts[cart_id] = cart_data + + +def get_single_cart( + cart_id: str +) -> Union[CartOutSchema, None]: + if cart_id not in _local_data_carts: + return None + + return _local_data_carts[cart_id] + + +@validate_call +def get_all_carts() -> List[CartOutSchema]: + return list(_local_data_carts.values()) + + +@validate_call +def add_single_item( + item_id: str, + item_data: ItemSchema +) -> None: + item_data.id = item_id + _local_data_items[item_id] = item_data + + +@validate_call +def get_single_item(item_id: str) -> Union[ItemSchema, None]: + if item_id not in _local_data_items: + return None + + return _local_data_items[item_id] + + +@validate_call +def get_all_items() -> List[ItemSchema]: + return list(_local_data_items.values()) + + +def get_all_item_ids_for_cart(cart_id: str) -> Set[str]: + return set(list(_local_data_carts[cart_id].model_fields.keys())) + + +def delete_item(item_id: str) -> None: + item = _local_data_items.get(item_id) + item.deleted = True diff --git a/hw3/shop_api/main.py b/hw3/shop_api/main.py new file mode 100644 index 00000000..06998ecf --- /dev/null +++ b/hw3/shop_api/main.py @@ -0,0 +1,12 @@ +from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator + +from shop_api.handlers.cart import router as cart_router +from shop_api.handlers.item import router as item_router + + +app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +app.include_router(cart_router) +app.include_router(item_router) diff --git a/hw3/shop_api/models/cart.py b/hw3/shop_api/models/cart.py new file mode 100644 index 00000000..b2e932b0 --- /dev/null +++ b/hw3/shop_api/models/cart.py @@ -0,0 +1,14 @@ +from typing import List +from pydantic import BaseModel, Field, computed_field + +from shop_api.models.item import ItemSchema + + +class CartOutSchema(BaseModel): + id: str + items: List[ItemSchema] = Field(default_factory=list) + + @computed_field + @property + def price(self) -> float: + return sum(item.price * item.quantity for item in self.items) diff --git a/hw3/shop_api/models/item.py b/hw3/shop_api/models/item.py new file mode 100644 index 00000000..a708bc1a --- /dev/null +++ b/hw3/shop_api/models/item.py @@ -0,0 +1,23 @@ +import uuid +from pydantic import BaseModel, ConfigDict + + +class ItemSchema(BaseModel): + id: str = str(uuid.uuid4()) + name: str + price: float + deleted: bool = False + quantity: int = 1 + + +class ItemCreateSchema(BaseModel): + name: str = "" + price: float = 0.0 + + +class ItemPatchSchema(BaseModel): + name: str = "" + price: float = 0.0 + quantity: int = 1 + + model_config = ConfigDict(extra='forbid') diff --git a/hw4/Dockerfile b/hw4/Dockerfile new file mode 100644 index 00000000..43812477 --- /dev/null +++ b/hw4/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r 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/hw4/docker-compose.yml b/hw4/docker-compose.yml new file mode 100644 index 00000000..98c5f8d5 --- /dev/null +++ b/hw4/docker-compose.yml @@ -0,0 +1,43 @@ + +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + environment: + DATABASE_HOST: postgres + DATABASE_PORT: 5432 + DATABASE_USER: postgres + DATABASE_PASSWORD: password + DATABASE_NAME: hw4_db + depends_on: + postgres: + condition: service_healthy + + 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 + - ./migrations:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/hw4/requirements.txt b/hw4/requirements.txt new file mode 100644 index 00000000..83ab7e76 --- /dev/null +++ b/hw4/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +sqlalchemy +asyncpg +pydantic-settings \ No newline at end of file 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/db/config.py b/hw4/shop_api/db/config.py new file mode 100644 index 00000000..7969d4a0 --- /dev/null +++ b/hw4/shop_api/db/config.py @@ -0,0 +1,14 @@ +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + DATABASE_HOST: str + DATABASE_PORT: int + DATABASE_USER: str + DATABASE_PASSWORD: str + DATABASE_NAME: str + + @property + def DATABASE_URL(self) -> str: + return f"postgresql+asyncpg://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}" + +settings = Settings() diff --git a/hw4/shop_api/db/session.py b/hw4/shop_api/db/session.py new file mode 100644 index 00000000..d19e303a --- /dev/null +++ b/hw4/shop_api/db/session.py @@ -0,0 +1,27 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker, declarative_base +from .config import settings + + +engine = create_async_engine(settings.DATABASE_URL, echo=True, future=True) + + +SessionLocal = sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +Base = declarative_base() + + +async def get_db() -> AsyncSession: + async with SessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/hw4/shop_api/handlers/cart.py b/hw4/shop_api/handlers/cart.py new file mode 100644 index 00000000..fe15c1c0 --- /dev/null +++ b/hw4/shop_api/handlers/cart.py @@ -0,0 +1,168 @@ +from http import HTTPStatus +from typing import Annotated, List +import uuid + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload + +from shop_api.db.session import get_db +from shop_api.models.cart import Cart as CartModel, CartOutSchema +from shop_api.models.item import Item as ItemModel +from shop_api.models.cart_item import CartItem as CartItemModel + + +router = APIRouter(prefix="/cart") + + +@router.post( + "", + response_model=CartOutSchema, + status_code=HTTPStatus.CREATED +) +async def add_cart( + response: Response, + db: AsyncSession = Depends(get_db) +): + db_cart = CartModel() + + db.add(db_cart) + await db.commit() + await db.refresh(db_cart) + + response.headers["Location"] = f"/cart/{db_cart.id}" + + return db_cart + + +@router.get( + "/{cart_id}", + response_model=CartOutSchema, + status_code=HTTPStatus.OK +) +async def get_cart_by_id( + cart_id: str, + db: AsyncSession = Depends(get_db) +): + try: + cart_uuid = uuid.UUID(cart_id) + except ValueError: + raise HTTPException(HTTPStatus.NOT_FOUND, f"Invalid cart_id format") + + query = ( + select(CartModel) + .where(CartModel.id == cart_uuid) + .options( + selectinload(CartModel.cart_items) + .selectinload(CartItemModel.item) + ) + ) + result = await db.execute(query) + db_cart = result.scalar_one_or_none() + + if db_cart is None: + raise HTTPException(HTTPStatus.NOT_FOUND, f"Cart with {cart_id=} wasn't found") + + return db_cart + + +@router.get( + "", + response_model=List[CartOutSchema], + status_code=HTTPStatus.OK +) +async def get_all_carts( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] = None, + max_price: Annotated[NonNegativeFloat, Query()] = None, + min_quantity: Annotated[NonNegativeInt, Query()] = None, + max_quantity: Annotated[NonNegativeInt, Query()] = None, + db: AsyncSession = Depends(get_db) +): + query = ( + select(CartModel) + .options( + selectinload(CartModel.cart_items) + .selectinload(CartItemModel.item) + ) + ) + result = await db.execute(query) + all_carts_db = result.scalars().unique().all() + + all_cart_schemas = [CartOutSchema.model_validate(cart) for cart in all_carts_db] + + filtered_carts: List[CartOutSchema] = [] + for cart in all_cart_schemas: + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + + total_quantity = sum([cart_item.quantity for cart_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 + + filtered_carts.append(cart) + + return filtered_carts[offset: offset + limit] + + +@router.post( + "/{cart_id}/add/{item_id}", + response_model=CartOutSchema, + status_code=HTTPStatus.OK +) +async def add_item_to_cart( + cart_id: str, + item_id: str, + db: AsyncSession = Depends(get_db) +): + try: + cart_uuid = uuid.UUID(cart_id) + item_uuid = uuid.UUID(item_id) + except ValueError: + raise HTTPException(HTTPStatus.NOT_FOUND, "Invalid cart_id or item_id format") + + item_query = select(ItemModel).where( + ItemModel.id == item_uuid, + ItemModel.deleted == False + ) + db_item = (await db.execute(item_query)).scalar_one_or_none() + + if db_item is None: + raise HTTPException(HTTPStatus.NOT_FOUND, f"Item (product) with {item_id=} wasn't found or was deleted") + + cart_query = ( + select(CartModel) + .where(CartModel.id == cart_uuid) + .options(selectinload(CartModel.cart_items)) + ) + db_cart = (await db.execute(cart_query)).scalar_one_or_none() + + if db_cart is None: + raise HTTPException(HTTPStatus.NOT_FOUND, f"Cart with {cart_id=} wasn't found") + + existing_cart_item: CartItemModel | None = None + for cart_item in db_cart.cart_items: + if cart_item.item_id == item_uuid: + existing_cart_item = cart_item + break + + if existing_cart_item: + existing_cart_item.quantity += 1 + else: + new_cart_item = CartItemModel( + cart_id=cart_uuid, + item_id=item_uuid, + quantity=1 + ) + db.add(new_cart_item) + + await db.commit() + + return await get_cart_by_id(cart_id, db) diff --git a/hw4/shop_api/handlers/item.py b/hw4/shop_api/handlers/item.py new file mode 100644 index 00000000..43edf58c --- /dev/null +++ b/hw4/shop_api/handlers/item.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Annotated, List +import uuid + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from shop_api.db.session import get_db + + +from shop_api.models.item import ( + Item as ItemModel, + ItemSchema, + ItemCreateSchema, + ItemPatchSchema +) + +router = APIRouter(prefix="/item") + + +@router.post( + "", + response_model=ItemSchema, + status_code=HTTPStatus.CREATED, +) +async def add_item( + response: Response, + item: ItemCreateSchema, + db: AsyncSession = Depends(get_db) +): + db_item = ItemModel(**item.model_dump()) + + db.add(db_item) + await db.commit() + await db.refresh(db_item) + + response.headers["Location"] = f"/item/{db_item.id}" + return db_item + + +@router.get( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def get_item_by_id( + item_id: str, + db: AsyncSession = Depends(get_db) +): + try: + item_uuid = uuid.UUID(item_id) + except ValueError: + raise HTTPException(HTTPStatus.NOT_FOUND, "Invalid item_id format") + + query = select(ItemModel).where(ItemModel.id == item_uuid) + result = await db.execute(query) + db_item = result.scalar_one_or_none() + + if db_item is None: + raise HTTPException(HTTPStatus.NOT_FOUND, f"Item with {item_id=} is not found") + + if db_item.deleted: + raise HTTPException(HTTPStatus.NOT_FOUND, f"Item with {item_id=} was deleted") + + return db_item + + +@router.get( + "", + response_model=List[ItemSchema], + status_code=HTTPStatus.OK +) +async def get_all_items( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] = None, + max_price: Annotated[NonNegativeFloat, Query()] = None, + show_deleted: Annotated[bool, Query()] = False, + db: AsyncSession = Depends(get_db) +): + query = select(ItemModel) + + if min_price: + query = query.where(ItemModel.price >= min_price) + if max_price: + query = query.where(ItemModel.price <= max_price) + if not show_deleted: + query = query.where(ItemModel.deleted == False) + + query = query.offset(offset).limit(limit) + + result = await db.execute(query) + all_items = result.scalars().all() + + return all_items + + +@router.put( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def change_item( + item_id: str, + item: ItemSchema, + db: AsyncSession = Depends(get_db) +): + db_item = await get_item_by_id(item_id, db) + + db_item.name = item.name + db_item.price = item.price + db_item.deleted = item.deleted + + await db.commit() + await db.refresh(db_item) + + return db_item + + +@router.patch( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def change_item_fields( + item_id: str, + item: ItemPatchSchema, + db: AsyncSession = Depends(get_db) +): + db_item = (await db.execute( + select(ItemModel).where(ItemModel.id == uuid.UUID(item_id)) + )).scalar_one_or_none() + + if db_item is None: + raise HTTPException(HTTPStatus.NOT_FOUND, f"Item with {item_id=} is not found") + + if db_item.deleted: + raise HTTPException(HTTPStatus.NOT_MODIFIED, f"Item with {item_id=} has been deleted") + + update_data = item.model_dump(exclude_unset=True) + + for key, value in update_data.items(): + setattr(db_item, key, value) + + await db.commit() + await db.refresh(db_item) + + return db_item + + +@router.delete( + "/{item_id}", + response_model=ItemSchema, + status_code=HTTPStatus.OK +) +async def delete_item( + item_id: str, + db: AsyncSession = Depends(get_db) +): + db_item = (await db.execute( + select(ItemModel).where(ItemModel.id == uuid.UUID(item_id)) + )).scalar_one_or_none() + + if db_item is None: + raise HTTPException(HTTPStatus.NOT_FOUND, f"Item with {item_id=} is not found") + + db_item.deleted = True + await db.commit() + await db.refresh(db_item) + + return db_item diff --git a/hw4/shop_api/local_data.py b/hw4/shop_api/local_data.py new file mode 100644 index 00000000..4ba9ea0c --- /dev/null +++ b/hw4/shop_api/local_data.py @@ -0,0 +1,59 @@ +from pydantic import validate_call +from typing import Dict, List, Set, Union +from shop_api.models.cart import CartOutSchema +from shop_api.models.item import ItemSchema + + +_local_data_carts: Dict[str, CartOutSchema] = {} +_local_data_items: Dict[str, ItemSchema] = {} + + +@validate_call +def add_single_cart(cart_data: CartOutSchema) -> None: + cart_id = cart_data.id + _local_data_carts[cart_id] = cart_data + + +def get_single_cart( + cart_id: str +) -> Union[CartOutSchema, None]: + if cart_id not in _local_data_carts: + return None + + return _local_data_carts[cart_id] + + +@validate_call +def get_all_carts() -> List[CartOutSchema]: + return list(_local_data_carts.values()) + + +@validate_call +def add_single_item( + item_id: str, + item_data: ItemSchema +) -> None: + item_data.id = item_id + _local_data_items[item_id] = item_data + + +@validate_call +def get_single_item(item_id: str) -> Union[ItemSchema, None]: + if item_id not in _local_data_items: + return None + + return _local_data_items[item_id] + + +@validate_call +def get_all_items() -> List[ItemSchema]: + return list(_local_data_items.values()) + + +def get_all_item_ids_for_cart(cart_id: str) -> Set[str]: + return set(list(_local_data_carts[cart_id].model_fields.keys())) + + +def delete_item(item_id: str) -> None: + item = _local_data_items.get(item_id) + item.deleted = True diff --git a/hw4/shop_api/main.py b/hw4/shop_api/main.py new file mode 100644 index 00000000..d2cc6207 --- /dev/null +++ b/hw4/shop_api/main.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI +from shop_api.db.session import engine, Base + +from shop_api.handlers.cart import router as cart_router +from shop_api.handlers.item import router as item_router +from shop_api.models.cart import Cart +from shop_api.models.item import Item + + +app = FastAPI(title="Shop API") + +@app.on_event("startup") +async def startup_event(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +app.include_router(cart_router) +app.include_router(item_router) diff --git a/hw4/shop_api/models/cart.py b/hw4/shop_api/models/cart.py new file mode 100644 index 00000000..260b9b44 --- /dev/null +++ b/hw4/shop_api/models/cart.py @@ -0,0 +1,33 @@ +import uuid +from typing import List +from pydantic import BaseModel, Field, computed_field + +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import relationship +from shop_api.db.session import Base +from shop_api.models.cart_item import CartItemSchema + + +class Cart(Base): + __tablename__ = "carts" + id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + cart_items = relationship( + "CartItem", + back_populates="cart", + cascade="all, delete-orphan" + ) + + +class CartOutSchema(BaseModel): + id: uuid.UUID + items: List[CartItemSchema] = Field(default_factory=list) + + @computed_field + @property + def price(self) -> float: + return sum(cart_item.item.price * cart_item.quantity for cart_item in self.items) + + class Config: + from_attributes = True diff --git a/hw4/shop_api/models/cart_item.py b/hw4/shop_api/models/cart_item.py new file mode 100644 index 00000000..012a572d --- /dev/null +++ b/hw4/shop_api/models/cart_item.py @@ -0,0 +1,30 @@ +import uuid +from pydantic import BaseModel +from sqlalchemy import Column, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import relationship + +from shop_api.db.session import Base +from shop_api.models.item import ItemSchema + + +class CartItem(Base): + __tablename__ = "cart_items" + + id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + quantity = Column(Integer, default=1, nullable=False) + + cart_id = Column(PG_UUID(as_uuid=True), ForeignKey("carts.id"), nullable=False) + item_id = Column(PG_UUID(as_uuid=True), ForeignKey("items.id"), nullable=False) + + cart = relationship("Cart", back_populates="cart_items") + item = relationship("Item") + + +class CartItemSchema(BaseModel): + id: uuid.UUID + quantity: int + item: ItemSchema + + class Config: + from_attributes = True diff --git a/hw4/shop_api/models/item.py b/hw4/shop_api/models/item.py new file mode 100644 index 00000000..af362cec --- /dev/null +++ b/hw4/shop_api/models/item.py @@ -0,0 +1,36 @@ +import uuid +from pydantic import BaseModel, ConfigDict + +from sqlalchemy import Column, String, Float, Boolean +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from shop_api.db.session import Base + + +class Item(Base): + __tablename__ = "items" + + id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String, nullable=False, index=True) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False, nullable=False, index=True) + + +class ItemSchema(BaseModel): + id: uuid.UUID + name: str + price: float + deleted: bool = False + + class Config: + from_attributes = True + + +class ItemCreateSchema(BaseModel): + name: str = "" + price: float = 0.0 + +class ItemPatchSchema(BaseModel): + name: str = "" + price: float = 0.0 + + model_config = ConfigDict(extra='forbid') \ No newline at end of file diff --git a/hw4/shop_api/models/product.py b/hw4/shop_api/models/product.py new file mode 100644 index 00000000..7a156b2a --- /dev/null +++ b/hw4/shop_api/models/product.py @@ -0,0 +1,27 @@ +import uuid +from sqlalchemy import Column, String, Float, Boolean +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from shop_api.db.session import Base +from pydantic import BaseModel + + +class Product(Base): + __tablename__ = "products" + id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String, nullable=False, index=True) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + + +class ProductSchema(BaseModel): + id: str + name: str + price: float + + class Config: + from_attributes = True + + +class ProductCreateSchema(BaseModel): + name: str + price: float