diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml new file mode 100644 index 00000000..ca938657 --- /dev/null +++ b/.github/workflows/h5-tests.yml @@ -0,0 +1,69 @@ +name: "HW5 Tests" + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-hw5: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: test_shop_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install dependencies + working-directory: hw5/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + + - name: Wait for PostgreSQL + run: | + for i in {1..30}; do + pg_isready -h localhost -p 5432 -U postgres && echo "Postgres ready" && break + echo "Waiting for PostgreSQL..." + sleep 2 + done + + - name: Run tests with coverage + working-directory: hw5/hw + env: + DATABASE_URL: postgresql://postgres:password@localhost:5432/test_shop_db + run: | + export PYTHONPATH=$PYTHONPATH:$(pwd) + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: hw5/hw/coverage.xml + flags: unittests + fail_ci_if_error: false + + - name: Check coverage threshold + working-directory: hw5/hw + run: | + python -m coverage report --fail-under=95 diff --git a/hw1/app.py b/hw1/app.py index 6107b870..3b2ff143 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,8 @@ +import math +import json +from http import HTTPStatus from typing import Any, Awaitable, Callable +from urllib.parse import parse_qs async def application( @@ -12,8 +16,193 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] != "http": + await send_error(send, HTTPStatus.BAD_REQUEST, "Only HTTP supported") + return + + path = scope["path"] + method = scope["method"] + + # Обработка различных маршрутов + if method == "GET" and path == "/factorial": + await handle_factorial(scope, receive, send) + elif method == "GET" and path.startswith("/fibonacci/"): + await handle_fibonacci(scope, receive, send) + elif method in ["GET", "POST"] and path == "/mean": + await handle_mean(scope, receive, send) # Supporte GET et POST + else: + await send_error(send, HTTPStatus.NOT_FOUND, "Endpoint not found") + + +async def handle_factorial(scope, receive, send): + """Обработка GET /factorial?n=5""" + query_params = parse_query_string(scope["query_string"]) + n_str = query_params.get("n", [""])[0] + + # Cas limite: paramètre manquant + if not n_str: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Missing parameter 'n'") + return + + try: + n = int(n_str) + except ValueError: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Parameter 'n' must be an integer") + return + + # Cas limite: nombre négatif + if n < 0: + await send_error(send, HTTPStatus.BAD_REQUEST, "Invalid value for n, must be non-negative") + return + + # Cas limite: nombre trop grand + if n > 1000: + await send_error(send, HTTPStatus.BAD_REQUEST, "Value too large for n") + return + + result = math.factorial(n) + await send_json_response(send, {"result": result}) + + +async def handle_fibonacci(scope, receive, send): + """Обработка GET /fibonacci/5""" + path_parts = scope["path"].split("/") + + # Cas limite: format d'URL incorrect + if len(path_parts) < 3: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Invalid URL format") + return + + try: + n = int(path_parts[2]) + except ValueError: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Path parameter must be an integer") + return + + # Cas limite: nombre négatif + if n < 0: + await send_error(send, HTTPStatus.BAD_REQUEST, "Invalid value for n, must be non-negative") + return + + # Cas limite: nombre trop grand + if n > 1000: + await send_error(send, HTTPStatus.BAD_REQUEST, "Value too large for n") + return + + # Расчет Fibonacci + if n == 0: + result = 0 + elif n == 1: + result = 1 + else: + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + result = b + + await send_json_response(send, {"result": result}) + + +async def handle_mean(scope, receive, send): + """Обработка /mean - supporte GET et POST avec différents formats""" + # Essayer de lire le JSON depuis le body (pour les tests GET avec JSON) + body = await read_request_body(receive) + + has_json_body = False + numbers = [] + + if body.strip(): + try: + data = json.loads(body) + if isinstance(data, list): + numbers = [float(x) for x in data] + has_json_body = True + except (ValueError, TypeError, json.JSONDecodeError): + pass # Ignorer et essayer avec query parameter + + # Si pas de JSON body valide, essayer avec query parameter + if not has_json_body: + query_params = parse_query_string(scope["query_string"]) + numbers_str = query_params.get("numbers", [""])[0] + + if not numbers_str: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Missing parameter 'numbers' or JSON body") + return + + try: + numbers = [float(x.strip()) for x in numbers_str.split(",") if x.strip()] + except ValueError: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Parameter 'numbers' must be comma-separated floats") + return + + # Validation commune + if len(numbers) == 0: + await send_error(send, HTTPStatus.BAD_REQUEST, "Numbers array must not be empty") + return + + # Vérifier les valeurs non numériques + if any(math.isnan(x) or math.isinf(x) for x in numbers): + await send_error(send, HTTPStatus.BAD_REQUEST, "Numbers must be finite values") + return + + # Calcul du résultat + result = sum(numbers) / len(numbers) + await send_json_response(send, {"result": result}) + + +async def read_request_body(receive) -> str: + """Чтение полного тела запроса""" + body = b"" + more_body = True + while more_body: + message = await receive() + body += message.get("body", b"") + more_body = message.get("more_body", False) + return body.decode() + + +async def send_json_response(send, data: dict): + """Отправка JSON ответа""" + response_body = json.dumps(data).encode() + + await send({ + "type": "http.response.start", + "status": HTTPStatus.OK.value, + "headers": [ + [b"content-type", b"application/json"], + ], + }) + + await send({ + "type": "http.response.body", + "body": response_body, + }) + + +async def send_error(send, status: HTTPStatus, message: str): + """Отправка HTTP ошибки""" + error_data = {"error": message} + response_body = json.dumps(error_data).encode() + + await send({ + "type": "http.response.start", + "status": status.value, + "headers": [ + [b"content-type", b"application/json"], + ], + }) + + await send({ + "type": "http.response.body", + "body": response_body, + }) + + +def parse_query_string(query_string: bytes) -> dict: + """Парсинг query string параметров""" + return parse_qs(query_string.decode()) + if __name__ == "__main__": import uvicorn - uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/hw2/hw/python b/hw2/hw/python new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/cart/contracts.py b/hw2/hw/shop_api/cart/contracts.py new file mode 100644 index 00000000..ae1c2509 --- /dev/null +++ b/hw2/hw/shop_api/cart/contracts.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from typing import Any + +from pydantic import BaseModel + +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +# class CartItemRequest(BaseModel): +# items: list[CartItemInfo] +# price: float + +# def as_cart_info(self) -> CartInfo: +# items = [CartItemInfo(**item.dict()) for item in self.items] +# # price = +# return CartInfo(items=items, price=self.price) + + + +class CartResponse(BaseModel): + id: int + items: list[CartItemInfo] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + return CartResponse( + id=entity.id, + items=entity.info.items, + price=entity.info.price, + ) \ No newline at end of file diff --git a/hw2/hw/shop_api/cart/routers.py b/hw2/hw/shop_api/cart/routers.py new file mode 100644 index 00000000..d510513c --- /dev/null +++ b/hw2/hw/shop_api/cart/routers.py @@ -0,0 +1,72 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse + +import shop_api +import shop_api.item +import shop_api.item.store + + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart(response: Response) -> CartResponse: + entity = store.create() + + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, +): + return [ + CartResponse.from_entity(e) + for e in store.get_many( + offset, limit, min_price, max_price, min_quantity, max_quantity + ) + ] + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int): + item_entity = shop_api.item.store.get_one(item_id) + + entity = store.add(cart_id, item_entity) + + return CartResponse.from_entity(entity) \ No newline at end of file diff --git a/hw2/hw/shop_api/cart/store/__init__.py b/hw2/hw/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..e14c47c2 --- /dev/null +++ b/hw2/hw/shop_api/cart/store/__init__.py @@ -0,0 +1,13 @@ +from .models import CartItemInfo, CartEntity, CartInfo +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartEntity", + "CartInfo", + "CartItemInfo", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw2/hw/shop_api/cart/store/models.py b/hw2/hw/shop_api/cart/store/models.py new file mode 100644 index 00000000..edc7bdda --- /dev/null +++ b/hw2/hw/shop_api/cart/store/models.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo diff --git a/hw2/hw/shop_api/cart/store/queries.py b/hw2/hw/shop_api/cart/store/queries.py new file mode 100644 index 00000000..36a14d32 --- /dev/null +++ b/hw2/hw/shop_api/cart/store/queries.py @@ -0,0 +1,89 @@ +from logging import info +from typing import Iterable + +from shop_api.item.store.models import ItemEntity +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +_data: dict[int, CartInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def create() -> CartEntity: + _id = next(_id_generator) + info = CartInfo(items=[], price=0.0) + _data[_id] = info + return CartEntity(id=_id, info=info) + + +def add(cart_id: int, item_entity: ItemEntity) -> CartEntity: + cart_info = _data[cart_id] + for ci in cart_info.items: + if ci.id == item_entity.id: + ci.quantity += 1 + ci.available = not item_entity.info.deleted + break + else: + cart_info.items.append( + CartItemInfo( + id=item_entity.id, + name=item_entity.info.name, + quantity=1, + available=not item_entity.info.deleted, + ) + ) + cart_info.price += item_entity.info.price + _data[cart_id] = cart_info + + return CartEntity(id=cart_id, info=cart_info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> CartEntity | None: + if id not in _data: + return None + + return CartEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + min_quantity: int = None, + max_quantity: int = None, +) -> Iterable[CartEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + sum_quantity = 0 + for item in info.items: + sum_quantity += item.quantity + + if min_quantity is not None and min_quantity > sum_quantity: + continue + if max_quantity is not None and max_quantity < sum_quantity: + continue + + if offset <= curr < offset + limit: + yield CartEntity(id, info) + + curr += 1 diff --git a/hw2/hw/shop_api/item/contracts.py b/hw2/hw/shop_api/item/contracts.py new file mode 100644 index 00000000..6d516d31 --- /dev/null +++ b/hw2/hw/shop_api/item/contracts.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) diff --git a/hw2/hw/shop_api/item/routers.py b/hw2/hw/shop_api/item/routers.py new file mode 100644 index 00000000..d95d39d2 --- /dev/null +++ b/hw2/hw/shop_api/item/routers.py @@ -0,0 +1,112 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter(prefix="/item") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item(info: ItemRequest, response: Response) -> ItemResponse: + entity = store.add(info.as_item_info()) + + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] | None = None, +): + return [ + ItemResponse.from_entity(e) + for e in store.get_many(offset, limit, min_price, max_price, show_deleted) + ] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item(id: int, info: PatchItemRequest) -> ItemResponse: + entity = store.patch(id, info.as_patch_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, +) -> ItemResponse: + entity = store.update(id, info.as_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item(id: int) -> Response: + store.delete(id) + return Response("") diff --git a/hw2/hw/shop_api/item/store/__init__.py b/hw2/hw/shop_api/item/store/__init__.py new file mode 100644 index 00000000..bf69aa56 --- /dev/null +++ b/hw2/hw/shop_api/item/store/__init__.py @@ -0,0 +1,14 @@ +from .models import PatchItemInfo, ItemEntity, ItemInfo +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "ItemEntity", + "ItemInfo", + "PatchItemInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw2/hw/shop_api/item/store/models.py b/hw2/hw/shop_api/item/store/models.py new file mode 100644 index 00000000..22bb1735 --- /dev/null +++ b/hw2/hw/shop_api/item/store/models.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None \ No newline at end of file diff --git a/hw2/hw/shop_api/item/store/queries.py b/hw2/hw/shop_api/item/store/queries.py new file mode 100644 index 00000000..a8d506c8 --- /dev/null +++ b/hw2/hw/shop_api/item/store/queries.py @@ -0,0 +1,79 @@ +from typing import Iterable + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +_data: dict[int, ItemInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + +_id_generator = int_id_generator() + + +def add(info: ItemInfo) -> ItemEntity: + _id = next(_id_generator) + _data[_id] = info + + return ItemEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> ItemEntity | None: + if id not in _data: + return None + + return ItemEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + show_deleted: bool = False, +) -> Iterable[ItemEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + if not show_deleted and info.deleted: + continue + + if offset <= curr < offset + limit: + yield ItemEntity(id, info) + + curr += 1 + + +def update(id: int, info: ItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + _data[id] = info + + return ItemEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.price is not None: + _data[id].price = patch_info.price + + return ItemEntity(id=id, info=_data[id]) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..e9d946d9 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,14 @@ from fastapi import FastAPI +from shop_api.cart.routers import router as cart +from shop_api.item.routers import router as item + app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +@app.get("/") +async def root(): + return {"message": "API Shop is running"} + diff --git a/hw2/hw/shop_api/uvicorn b/hw2/hw/shop_api/uvicorn new file mode 100644 index 00000000..e69de29b diff --git a/hw3/grpc_example/README.md b/hw3/grpc_example/README.md new file mode 100644 index 00000000..b62bdbd2 --- /dev/null +++ b/hw3/grpc_example/README.md @@ -0,0 +1,26 @@ +# gRPC Example + +Идейно изначально мы описываем .proto файл + +Затем командой генерим код (для этого нам понадобится библиотечка из requirements.txt), который будет за нас отправлять и принимать такие сообщения: + +```sh +python3 -m grpc_tools.protoc \ + --proto_path=./hw2/grpc_example/proto/ \ + --python_out=./hw2/grpc_example \ + --grpc_python_out=./hw2/grpc_example \ + --pyi_out=./hw2/grpc_example \ + ping.proto +``` + +Затем используя сгенерированный код можно написать свой клиент или сервер, тут уже нвписаны примеры в example_service.py и example_client.py + +Чтобы запустить сервис и клиент можно использовать следующие команды: + +```sh +python3 -m hw2.grpc_example.example_service +``` + +```sh +python3 -m hw2.grpc_example.example_client +``` diff --git a/hw3/grpc_example/__init__.py b/hw3/grpc_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/grpc_example/example_client.py b/hw3/grpc_example/example_client.py new file mode 100644 index 00000000..2d520d8c --- /dev/null +++ b/hw3/grpc_example/example_client.py @@ -0,0 +1,25 @@ +import grpc + +import hw2.grpc_example.ping_pb2 as pb2 +import hw2.grpc_example.ping_pb2_grpc as pb2_grpc + + +def message_from_input_generator(): + while True: + message = input() + + if not message: + return + + yield pb2.PingRequest(message=message) + + +if __name__ == "__main__": + with grpc.insecure_channel("localhost:50051") as channel: + stub = pb2_grpc.ExampleStub(channel) + + response = stub.Ping(pb2.PingRequest(message="message lol")) + print(response) + + for response in stub.PingStream(message_from_input_generator()): + print(response) diff --git a/hw3/grpc_example/example_service.py b/hw3/grpc_example/example_service.py new file mode 100644 index 00000000..8eaa9207 --- /dev/null +++ b/hw3/grpc_example/example_service.py @@ -0,0 +1,25 @@ +from concurrent import futures +from typing import Iterable + +import grpc + +import hw2.grpc_example.ping_pb2 as pb2 +import hw2.grpc_example.ping_pb2_grpc as pb2_grpc + + +class ExampleService(pb2_grpc.ExampleServicer): + def Ping(self, request: pb2.PingRequest, context): + return pb2.PongResponse(message=request.message) + + def PingStream(self, request_iterator: Iterable[pb2.PingRequest], context): + for message in request_iterator: + yield pb2.PongResponse(message=message.message) + + +if __name__ == "__main__": + print("running server") + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + pb2_grpc.add_ExampleServicer_to_server(ExampleService(), server) + server.add_insecure_port("[::]:50051") + server.start() + server.wait_for_termination() diff --git a/hw3/grpc_example/proto/ping.proto b/hw3/grpc_example/proto/ping.proto new file mode 100644 index 00000000..7c09b204 --- /dev/null +++ b/hw3/grpc_example/proto/ping.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package example; + +service Example { + rpc Ping(PingRequest) returns (PongResponse); + rpc PingStream(stream PingRequest) returns (stream PongResponse); +} + +message PingRequest { + string message = 1; +} + +message PongResponse { + string message = 1; +} \ No newline at end of file diff --git a/hw3/grpc_example/requirements.txt b/hw3/grpc_example/requirements.txt new file mode 100644 index 00000000..420e91c3 --- /dev/null +++ b/hw3/grpc_example/requirements.txt @@ -0,0 +1 @@ +grpcio-tools>=1.75.0 diff --git a/hw3/hw/Dockerfile b/hw3/hw/Dockerfile new file mode 100644 index 00000000..b498d04e --- /dev/null +++ b/hw3/hw/Dockerfile @@ -0,0 +1,24 @@ +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 +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/hw/Grafana-total-request.PNG b/hw3/hw/Grafana-total-request.PNG new file mode 100644 index 00000000..1a37d7da Binary files /dev/null and b/hw3/hw/Grafana-total-request.PNG differ diff --git a/hw3/hw/README.md b/hw3/hw/README.md new file mode 100644 index 00000000..ba9f23c8 --- /dev/null +++ b/hw3/hw/README.md @@ -0,0 +1,123 @@ +# ДЗ + +## Задание - REST API (3 балла) + +Реализовать REST + RPC API для выдуманного интернет магазина. + +Тесты завязаны на объект `app = FastAPI(title="Shop API")` из файла [hw2/hw/shop_api/main.py](./shop_api/main.py), поэтому реализовывайте его используя его + +Ресурсы: + +- корзина (cart) + + Пример структуры ресурса: + + ```json + { + "id": 123, // идентификатор корзины + "items": [ // список товаров в корзине + { + "id": 1, // id товара + "name": "Туалетная бумага \"Поцелуй\", рулон", // название + "quantity": 3, // количество товара в корзине + "available": true // доступе ли (не удален ли) товар + }, + { + "id": 535, + "name": "Золотая цепочка \"Abendsonne\"", + "quantity": 1, + "available": false, + }, + ], + "price": 234.4 // общая сумма заказа + } + ``` + +- товар (item) + + Пример структуры ресурса: + + ```json + { + "id": 321, // идентификатор товара + "name": "Молоко \"Буреночка\" 1л.", // наименование товара + "price": 159.99, // цена товара + "deleted": false // удален ли товар, по умолчанию false + } + ``` + +Запросы для реализации: + +- cart + - `POST cart` - создание, работает как RPC, не принимает тело, возвращает + идентификатор + - `GET /cart/{id}` - получение корзины по `id` + - `GET /cart` - получение списка корзин с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `min_quantity` - неотрицательное целое число, минимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `max_quantity` - неотрицательное целое число, максимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `POST /cart/{cart_id}/add/{item_id}` - добавление в корзину с `cart_id` + предмета с `item_id`, если товар уже есть, то увеличивается его количество +- item + - `POST /item` - добавление нового товара + - `GET /item/{id}` - получение товара по `id` + - `GET /item` - получение списка товаров с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена (опционально, + если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена (опционально, + если нет, не учитывает в фильтре) + - `show_deleted` - булевая переменная, показывать ли удаленные товары (по + умолчанию `False`) + - `PUT /item/{id}` - замена товара по `id` (создание запрещено, только замена + существующего) + - `PATCH /item/{id}` - частичное обновление товара по `id` (разрешено менять + все поля, кроме `deleted`) + - `DELETE /item/{id}` - удаление товара по `id` (товар помечается как + удаленный) + +Способ хранение данных на усмотрение. + +Более подробные детали и требования к работе методов смотрите в тестах. +Модификация тестов при потребности допускается (но не смысловая). + +Чтобы запустить тесты только для этого задания вызовите: + +```sh +pytest -vv --showlocals --strict ./hw2/test_homework_2_1.py +``` + +Если получаете ошибку на подобии `No module named 'shop_api'` +Понадобится еще такая команда, после которой можно запускать тесты: + +```sh +export PYTHONPATH=${PWD}/hw2/hw +``` + +## Доп. Задание - WebSocket (+ доп балл) + +Реализовать чат для пользователей в отдельных комнатах (в примере один на всех). + +Пользователи подключаются к чату по WebSocket ручке `/chat/{chat_name}`. +Пользователи, которые ввели один и тот же `chat_name` буду подключены к одному +чату (то есть будут получать сообщения друг от друга). Пользователи не +подключенные к диалогу не будут получать сообщения. + +Сообщение - текст в теле сообщения от клиента. Сервер должен broadcast'ить +сообщения на других пользователей в своем чате. Каждому клиенту сервер +присваивает случайное имя и дополняет каждое сообщение именем пользователя в +начале в следующем виде: `{username} :: {message}`. + +Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 diff --git a/hw3/hw/__init__.py b/hw3/hw/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw/docker-compose.yml b/hw3/hw/docker-compose.yml new file mode 100644 index 00000000..b990f773 --- /dev/null +++ b/hw3/hw/docker-compose.yml @@ -0,0 +1,30 @@ +services: + local: + build: + context: . + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + depends_on: + - prometheus + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + depends_on: + - local diff --git a/hw3/hw/prometteuse.PNG b/hw3/hw/prometteuse.PNG new file mode 100644 index 00000000..d30e5559 Binary files /dev/null and b/hw3/hw/prometteuse.PNG differ diff --git a/hw3/hw/python b/hw3/hw/python new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw/request duration seocnd sum.PNG b/hw3/hw/request duration seocnd sum.PNG new file mode 100644 index 00000000..6e580684 Binary files /dev/null and b/hw3/hw/request duration seocnd sum.PNG differ diff --git a/hw3/hw/requirements.txt b/hw3/hw/requirements.txt new file mode 100644 index 00000000..5e709f64 --- /dev/null +++ b/hw3/hw/requirements.txt @@ -0,0 +1,11 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + +prometheus_fastapi_instrumentator diff --git a/hw3/hw/settings/prometheus/prometheus.yml b/hw3/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..e443970b --- /dev/null +++ b/hw3/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw3/hw/shop Fast api.PNG b/hw3/hw/shop Fast api.PNG new file mode 100644 index 00000000..7ed412d7 Binary files /dev/null and b/hw3/hw/shop Fast api.PNG differ diff --git a/hw3/hw/shop_api/__init__.py b/hw3/hw/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw/shop_api/cart/contracts.py b/hw3/hw/shop_api/cart/contracts.py new file mode 100644 index 00000000..ae1c2509 --- /dev/null +++ b/hw3/hw/shop_api/cart/contracts.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from typing import Any + +from pydantic import BaseModel + +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +# class CartItemRequest(BaseModel): +# items: list[CartItemInfo] +# price: float + +# def as_cart_info(self) -> CartInfo: +# items = [CartItemInfo(**item.dict()) for item in self.items] +# # price = +# return CartInfo(items=items, price=self.price) + + + +class CartResponse(BaseModel): + id: int + items: list[CartItemInfo] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + return CartResponse( + id=entity.id, + items=entity.info.items, + price=entity.info.price, + ) \ No newline at end of file diff --git a/hw3/hw/shop_api/cart/routers.py b/hw3/hw/shop_api/cart/routers.py new file mode 100644 index 00000000..d510513c --- /dev/null +++ b/hw3/hw/shop_api/cart/routers.py @@ -0,0 +1,72 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse + +import shop_api +import shop_api.item +import shop_api.item.store + + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart(response: Response) -> CartResponse: + entity = store.create() + + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, +): + return [ + CartResponse.from_entity(e) + for e in store.get_many( + offset, limit, min_price, max_price, min_quantity, max_quantity + ) + ] + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int): + item_entity = shop_api.item.store.get_one(item_id) + + entity = store.add(cart_id, item_entity) + + return CartResponse.from_entity(entity) \ No newline at end of file diff --git a/hw3/hw/shop_api/cart/store/__init__.py b/hw3/hw/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..e14c47c2 --- /dev/null +++ b/hw3/hw/shop_api/cart/store/__init__.py @@ -0,0 +1,13 @@ +from .models import CartItemInfo, CartEntity, CartInfo +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartEntity", + "CartInfo", + "CartItemInfo", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw3/hw/shop_api/cart/store/models.py b/hw3/hw/shop_api/cart/store/models.py new file mode 100644 index 00000000..edc7bdda --- /dev/null +++ b/hw3/hw/shop_api/cart/store/models.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo diff --git a/hw3/hw/shop_api/cart/store/queries.py b/hw3/hw/shop_api/cart/store/queries.py new file mode 100644 index 00000000..36a14d32 --- /dev/null +++ b/hw3/hw/shop_api/cart/store/queries.py @@ -0,0 +1,89 @@ +from logging import info +from typing import Iterable + +from shop_api.item.store.models import ItemEntity +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +_data: dict[int, CartInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def create() -> CartEntity: + _id = next(_id_generator) + info = CartInfo(items=[], price=0.0) + _data[_id] = info + return CartEntity(id=_id, info=info) + + +def add(cart_id: int, item_entity: ItemEntity) -> CartEntity: + cart_info = _data[cart_id] + for ci in cart_info.items: + if ci.id == item_entity.id: + ci.quantity += 1 + ci.available = not item_entity.info.deleted + break + else: + cart_info.items.append( + CartItemInfo( + id=item_entity.id, + name=item_entity.info.name, + quantity=1, + available=not item_entity.info.deleted, + ) + ) + cart_info.price += item_entity.info.price + _data[cart_id] = cart_info + + return CartEntity(id=cart_id, info=cart_info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> CartEntity | None: + if id not in _data: + return None + + return CartEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + min_quantity: int = None, + max_quantity: int = None, +) -> Iterable[CartEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + sum_quantity = 0 + for item in info.items: + sum_quantity += item.quantity + + if min_quantity is not None and min_quantity > sum_quantity: + continue + if max_quantity is not None and max_quantity < sum_quantity: + continue + + if offset <= curr < offset + limit: + yield CartEntity(id, info) + + curr += 1 diff --git a/hw3/hw/shop_api/item/contracts.py b/hw3/hw/shop_api/item/contracts.py new file mode 100644 index 00000000..6d516d31 --- /dev/null +++ b/hw3/hw/shop_api/item/contracts.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) diff --git a/hw3/hw/shop_api/item/routers.py b/hw3/hw/shop_api/item/routers.py new file mode 100644 index 00000000..d95d39d2 --- /dev/null +++ b/hw3/hw/shop_api/item/routers.py @@ -0,0 +1,112 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter(prefix="/item") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item(info: ItemRequest, response: Response) -> ItemResponse: + entity = store.add(info.as_item_info()) + + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] | None = None, +): + return [ + ItemResponse.from_entity(e) + for e in store.get_many(offset, limit, min_price, max_price, show_deleted) + ] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item(id: int, info: PatchItemRequest) -> ItemResponse: + entity = store.patch(id, info.as_patch_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, +) -> ItemResponse: + entity = store.update(id, info.as_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item(id: int) -> Response: + store.delete(id) + return Response("") diff --git a/hw3/hw/shop_api/item/store/__init__.py b/hw3/hw/shop_api/item/store/__init__.py new file mode 100644 index 00000000..bf69aa56 --- /dev/null +++ b/hw3/hw/shop_api/item/store/__init__.py @@ -0,0 +1,14 @@ +from .models import PatchItemInfo, ItemEntity, ItemInfo +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "ItemEntity", + "ItemInfo", + "PatchItemInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw3/hw/shop_api/item/store/models.py b/hw3/hw/shop_api/item/store/models.py new file mode 100644 index 00000000..22bb1735 --- /dev/null +++ b/hw3/hw/shop_api/item/store/models.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None \ No newline at end of file diff --git a/hw3/hw/shop_api/item/store/queries.py b/hw3/hw/shop_api/item/store/queries.py new file mode 100644 index 00000000..eff87679 --- /dev/null +++ b/hw3/hw/shop_api/item/store/queries.py @@ -0,0 +1,80 @@ +from typing import Iterable + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +_data: dict[int, ItemInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: ItemInfo) -> ItemEntity: + _id = next(_id_generator) + _data[_id] = info + + return ItemEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> ItemEntity | None: + if id not in _data: + return None + + return ItemEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + show_deleted: bool = False, +) -> Iterable[ItemEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + if not show_deleted and info.deleted: + continue + + if offset <= curr < offset + limit: + yield ItemEntity(id, info) + + curr += 1 + + +def update(id: int, info: ItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + _data[id] = info + + return ItemEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.price is not None: + _data[id].price = patch_info.price + + return ItemEntity(id=id, info=_data[id]) diff --git a/hw3/hw/shop_api/main.py b/hw3/hw/shop_api/main.py new file mode 100644 index 00000000..58b419d4 --- /dev/null +++ b/hw3/hw/shop_api/main.py @@ -0,0 +1,24 @@ +from fastapi import FastAPI + +from shop_api.cart.routers import router as cart +from shop_api.item.routers import router as item + +from prometheus_fastapi_instrumentator import Instrumentator + + +app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +# Initialise l'instrumentation +instrumentator = Instrumentator() + +# Instrumente l'application et expose le point de terminaison /metrics +instrumentator.instrument(app) +instrumentator.expose(app) + +@app.get("/") +async def root(): + return {"message": "API Shop is running"} + diff --git a/hw3/hw/shop_api/uvicorn b/hw3/hw/shop_api/uvicorn new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw/test_homework2.py b/hw3/hw/test_homework2.py new file mode 100644 index 00000000..60a1f36a --- /dev/null +++ b/hw3/hw/test_homework2.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +from shop_api.main import app + +client = TestClient(app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@pytest.fixture() +def existing_not_empty_cart_id( + existing_empty_cart_id: int, + existing_items: list[int], +) -> int: + for item_id in faker.random_elements(existing_items, unique=False, length=3): + client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + + return existing_empty_cart_id + + +@pytest.fixture() +def existing_item() -> dict[str, Any]: + return client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ).json() + + +@pytest.fixture() +def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: + item_id = existing_item["id"] + client.delete(f"/item/{item_id}") + + existing_item["deleted"] = True + return existing_item + + +def test_post_cart() -> None: + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.parametrize( + ("cart", "not_empty"), + [ + ("existing_empty_cart_id", False), + ("existing_not_empty_cart_id", True), + ], +) +def test_get_cart(request, cart: int, not_empty: bool) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + len_items = len(response_json["items"]) + assert len_items > 0 if not_empty else len_items == 0 + + if not_empty: + price = 0 + + for item in response_json["items"]: + item_id = item["id"] + price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + else: + assert response_json["price"] == 0.0 + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000.0}, HTTPStatus.OK), + ({"max_price": 20.0}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query: dict[str, Any], status_code: int): + response = client.get("/cart", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + quantity = sum(item["quantity"] for cart in data for item in cart["items"]) + + if "min_quantity" in query: + assert quantity >= query["min_quantity"] + + if "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_post_item() -> None: + item = {"name": "test item", "price": 9.99} + response = client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +def test_get_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_item_list(query: dict[str, Any], status_code: int) -> None: + response = client.get("/item", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + if "show_deleted" in query and query["show_deleted"] is False: + assert all(item["deleted"] is False for item in data) + + +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +def test_put_item( + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = client.put(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + new_item = existing_item.copy() + new_item.update(body) + assert response.json() == new_item + + +@pytest.mark.parametrize( + ("item", "body", "status_code"), + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing_item", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing_item", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: + item_data: dict[str, Any] = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + patch_response_body = response.json() + + response = client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +def test_delete_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK diff --git a/hw3/rest_example/README.md b/hw3/rest_example/README.md new file mode 100644 index 00000000..dc17981e --- /dev/null +++ b/hw3/rest_example/README.md @@ -0,0 +1,3 @@ +# REST API Example + +Пример REST API как пример из лекции, тут храним данные прямо в приложении (в оперативной памяти), т.к. код чисто для примера, не делайте так в продакшене, пожалуйста diff --git a/hw3/rest_example/__init__.py b/hw3/rest_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/rest_example/api/__init__.py b/hw3/rest_example/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/rest_example/api/pokemon/__init__.py b/hw3/rest_example/api/pokemon/__init__.py new file mode 100644 index 00000000..ccd625f1 --- /dev/null +++ b/hw3/rest_example/api/pokemon/__init__.py @@ -0,0 +1,9 @@ +from .contracts import PatchPokemonRequest, PokemonRequest, PokemonResponse +from .routes import router + +__all__ = [ + "PokemonResponse", + "PokemonRequest", + "PatchPokemonRequest", + "router", +] diff --git a/hw3/rest_example/api/pokemon/contracts.py b/hw3/rest_example/api/pokemon/contracts.py new file mode 100644 index 00000000..a985b15b --- /dev/null +++ b/hw3/rest_example/api/pokemon/contracts.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from hw2.rest_example.store.models import ( + PatchPokemonInfo, + PokemonEntity, + PokemonInfo, +) + + +class PokemonResponse(BaseModel): + id: int + name: str + published: bool + + @staticmethod + def from_entity(entity: PokemonEntity) -> PokemonResponse: + return PokemonResponse( + id=entity.id, + name=entity.info.name, + published=entity.info.published, + ) + + +class PokemonRequest(BaseModel): + name: str + published: bool + + def as_pokemon_info(self) -> PokemonInfo: + return PokemonInfo(name=self.name, published=self.published) + + +class PatchPokemonRequest(BaseModel): + name: str | None = None + published: bool | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_pokemon_info(self) -> PatchPokemonInfo: + return PatchPokemonInfo(name=self.name, published=self.published) diff --git a/hw3/rest_example/api/pokemon/routes.py b/hw3/rest_example/api/pokemon/routes.py new file mode 100644 index 00000000..ab935c9a --- /dev/null +++ b/hw3/rest_example/api/pokemon/routes.py @@ -0,0 +1,119 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeInt, PositiveInt + +from hw2.rest_example import store + +from .contracts import ( + PatchPokemonRequest, + PokemonRequest, + PokemonResponse, +) + +router = APIRouter(prefix="/pokemon") + + +@router.get("/") +async def get_pokemon_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, +) -> list[PokemonResponse]: + return [PokemonResponse.from_entity(e) for e in store.get_many(offset, limit)] + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested pokemon", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested pokemon as one was not found", + }, + }, +) +async def get_pokemon_by_id(id: int) -> PokemonResponse: + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_pokemon(info: PokemonRequest, response: Response) -> PokemonResponse: + entity = store.add(info.as_pokemon_info()) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/pokemon/{entity.id}" + + return PokemonResponse.from_entity(entity) + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched pokemon", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify pokemon as one was not found", + }, + }, +) +async def patch_pokemon(id: int, info: PatchPokemonRequest) -> PokemonResponse: + entity = store.patch(id, info.as_patch_pokemon_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@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_pokemon( + id: int, + info: PokemonRequest, + upsert: Annotated[bool, Query()] = False, +) -> PokemonResponse: + entity = ( + store.upsert(id, info.as_pokemon_info()) + if upsert + else store.update(id, info.as_pokemon_info()) + ) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_pokemon(id: int) -> Response: + store.delete(id) + return Response("") diff --git a/hw3/rest_example/main.py b/hw3/rest_example/main.py new file mode 100644 index 00000000..26a2cf80 --- /dev/null +++ b/hw3/rest_example/main.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +from hw2.rest_example.api.pokemon import router + +app = FastAPI(title="Pokemon REST API Example") + +app.include_router(router) diff --git a/hw3/rest_example/requirements.txt b/hw3/rest_example/requirements.txt new file mode 100644 index 00000000..b66bec1e --- /dev/null +++ b/hw3/rest_example/requirements.txt @@ -0,0 +1 @@ +fastapi>=0.117.1 diff --git a/hw3/rest_example/store/__init__.py b/hw3/rest_example/store/__init__.py new file mode 100644 index 00000000..cb99d02a --- /dev/null +++ b/hw3/rest_example/store/__init__.py @@ -0,0 +1,15 @@ +from .models import PatchPokemonInfo, PokemonEntity, PokemonInfo +from .queries import add, delete, get_many, get_one, patch, update, upsert + +__all__ = [ + "PokemonEntity", + "PokemonInfo", + "PatchPokemonInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "upsert", + "patch", +] diff --git a/hw3/rest_example/store/models.py b/hw3/rest_example/store/models.py new file mode 100644 index 00000000..95cd40b9 --- /dev/null +++ b/hw3/rest_example/store/models.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class PokemonInfo: + name: str + published: bool + + +@dataclass(slots=True) +class PokemonEntity: + id: int + info: PokemonInfo + + +@dataclass(slots=True) +class PatchPokemonInfo: + name: str | None = None + published: bool | None = None diff --git a/hw3/rest_example/store/queries.py b/hw3/rest_example/store/queries.py new file mode 100644 index 00000000..959492d7 --- /dev/null +++ b/hw3/rest_example/store/queries.py @@ -0,0 +1,75 @@ +from typing import Iterable + +from hw2.rest_example.store.models import ( + PatchPokemonInfo, + PokemonEntity, + PokemonInfo, +) + +_data = dict[int, PokemonInfo]() + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: PokemonInfo) -> PokemonEntity: + _id = next(_id_generator) + _data[_id] = info + + return PokemonEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> PokemonEntity | None: + if id not in _data: + return None + + return PokemonEntity(id=id, info=_data[id]) + + +def get_many(offset: int = 0, limit: int = 10) -> Iterable[PokemonEntity]: + curr = 0 + for id, info in _data.items(): + if offset <= curr < offset + limit: + yield PokemonEntity(id, info) + + curr += 1 + + +def update(id: int, info: PokemonInfo) -> PokemonEntity | None: + if id not in _data: + return None + + _data[id] = info + + return PokemonEntity(id=id, info=info) + + +def upsert(id: int, info: PokemonInfo) -> PokemonEntity: + _data[id] = info + + return PokemonEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchPokemonInfo) -> PokemonEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.published is not None: + _data[id].published = patch_info.published + + return PokemonEntity(id=id, info=_data[id]) diff --git a/hw3/ws_example/README.md b/hw3/ws_example/README.md new file mode 100644 index 00000000..471f0d10 --- /dev/null +++ b/hw3/ws_example/README.md @@ -0,0 +1,19 @@ +# WebSocket Example + +Минимально рабочий пример WebSocket broadcaster'а как пример из лекции. Сервер принимает подключения от клиентов и рассылает сообщения всем подключенным одновременно. + +Важная особенность: сервер должен хранить активные WebSocket подключения непосредственно в оперативной памяти, из-за чего эта штука stateful. При перезапуске сервера все соединения потеряются, и горизонтальное масштабирование становится проблематичным без дополнительных решений. + +Для запуска сервера: + +```sh +uvicorn server:app --reload +``` + +Для запуска клиента в отдельном терминале: + +```sh +python client.py +``` + +Можно отправлять сообщения всем подключенным клиентам через POST запрос на `/publish`. diff --git a/hw3/ws_example/__init__.py b/hw3/ws_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/ws_example/client.py b/hw3/ws_example/client.py new file mode 100644 index 00000000..0af3441b --- /dev/null +++ b/hw3/ws_example/client.py @@ -0,0 +1,6 @@ +from websocket import create_connection + +ws = create_connection("ws://localhost:8000/subscribe") + +while True: + print(ws.recv()) diff --git a/hw3/ws_example/requirements.txt b/hw3/ws_example/requirements.txt new file mode 100644 index 00000000..3af9a713 --- /dev/null +++ b/hw3/ws_example/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.117.1 +websockets>=0.2.1 diff --git a/hw3/ws_example/server.py b/hw3/ws_example/server.py new file mode 100644 index 00000000..2bb5f1d1 --- /dev/null +++ b/hw3/ws_example/server.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass, field +from uuid import uuid4 + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect + +app = FastAPI() + + +@dataclass(slots=True) +class Broadcaster: + subscribers: list[WebSocket] = field(init=False, default_factory=list) + + async def subscribe(self, ws: WebSocket) -> None: + await ws.accept() + self.subscribers.append(ws) + + async def unsubscribe(self, ws: WebSocket) -> None: + self.subscribers.remove(ws) + + async def publish(self, message: str) -> None: + for ws in self.subscribers: + await ws.send_text(message) + + +broadcaster = Broadcaster() + + +@app.post("/publish") +async def post_publish(request: Request): + message = (await request.body()).decode() + await broadcaster.publish(message) + + +@app.websocket("/subscribe") +async def ws_subscribe(ws: WebSocket): + client_id = uuid4() + await broadcaster.subscribe(ws) + await broadcaster.publish(f"client {client_id} subscribed") + + try: + while True: + text = await ws.receive_text() + await broadcaster.publish(text) + except WebSocketDisconnect: + broadcaster.unsubscribe(ws) + await broadcaster.publish(f"client {client_id} unsubscribed") diff --git a/hw4/grpc_example/README.md b/hw4/grpc_example/README.md new file mode 100644 index 00000000..b62bdbd2 --- /dev/null +++ b/hw4/grpc_example/README.md @@ -0,0 +1,26 @@ +# gRPC Example + +Идейно изначально мы описываем .proto файл + +Затем командой генерим код (для этого нам понадобится библиотечка из requirements.txt), который будет за нас отправлять и принимать такие сообщения: + +```sh +python3 -m grpc_tools.protoc \ + --proto_path=./hw2/grpc_example/proto/ \ + --python_out=./hw2/grpc_example \ + --grpc_python_out=./hw2/grpc_example \ + --pyi_out=./hw2/grpc_example \ + ping.proto +``` + +Затем используя сгенерированный код можно написать свой клиент или сервер, тут уже нвписаны примеры в example_service.py и example_client.py + +Чтобы запустить сервис и клиент можно использовать следующие команды: + +```sh +python3 -m hw2.grpc_example.example_service +``` + +```sh +python3 -m hw2.grpc_example.example_client +``` diff --git a/hw4/grpc_example/__init__.py b/hw4/grpc_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/grpc_example/example_client.py b/hw4/grpc_example/example_client.py new file mode 100644 index 00000000..2d520d8c --- /dev/null +++ b/hw4/grpc_example/example_client.py @@ -0,0 +1,25 @@ +import grpc + +import hw2.grpc_example.ping_pb2 as pb2 +import hw2.grpc_example.ping_pb2_grpc as pb2_grpc + + +def message_from_input_generator(): + while True: + message = input() + + if not message: + return + + yield pb2.PingRequest(message=message) + + +if __name__ == "__main__": + with grpc.insecure_channel("localhost:50051") as channel: + stub = pb2_grpc.ExampleStub(channel) + + response = stub.Ping(pb2.PingRequest(message="message lol")) + print(response) + + for response in stub.PingStream(message_from_input_generator()): + print(response) diff --git a/hw4/grpc_example/example_service.py b/hw4/grpc_example/example_service.py new file mode 100644 index 00000000..8eaa9207 --- /dev/null +++ b/hw4/grpc_example/example_service.py @@ -0,0 +1,25 @@ +from concurrent import futures +from typing import Iterable + +import grpc + +import hw2.grpc_example.ping_pb2 as pb2 +import hw2.grpc_example.ping_pb2_grpc as pb2_grpc + + +class ExampleService(pb2_grpc.ExampleServicer): + def Ping(self, request: pb2.PingRequest, context): + return pb2.PongResponse(message=request.message) + + def PingStream(self, request_iterator: Iterable[pb2.PingRequest], context): + for message in request_iterator: + yield pb2.PongResponse(message=message.message) + + +if __name__ == "__main__": + print("running server") + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + pb2_grpc.add_ExampleServicer_to_server(ExampleService(), server) + server.add_insecure_port("[::]:50051") + server.start() + server.wait_for_termination() diff --git a/hw4/grpc_example/proto/ping.proto b/hw4/grpc_example/proto/ping.proto new file mode 100644 index 00000000..7c09b204 --- /dev/null +++ b/hw4/grpc_example/proto/ping.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package example; + +service Example { + rpc Ping(PingRequest) returns (PongResponse); + rpc PingStream(stream PingRequest) returns (stream PongResponse); +} + +message PingRequest { + string message = 1; +} + +message PongResponse { + string message = 1; +} \ No newline at end of file diff --git a/hw4/grpc_example/requirements.txt b/hw4/grpc_example/requirements.txt new file mode 100644 index 00000000..420e91c3 --- /dev/null +++ b/hw4/grpc_example/requirements.txt @@ -0,0 +1 @@ +grpcio-tools>=1.75.0 diff --git a/hw4/hw/Dockerfile b/hw4/hw/Dockerfile new file mode 100644 index 00000000..a11b8927 --- /dev/null +++ b/hw4/hw/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.12 + +# Variables d'environnement pour optimiser Python +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +# Mettre à jour et installer gcc (nécessaire pour certaines dépendances) +RUN apt-get update && apt-get install -y gcc + +# Mettre à jour pip +RUN python -m pip install --upgrade pip + +# Créer et définir le répertoire de travail +WORKDIR /app + +# Copier d'abord requirements.txt pour optimiser le cache Docker +COPY requirements.txt . + +# Installer les dépendances Python +RUN pip install --no-cache-dir -r requirements.txt + +# Copier le reste de l'application +COPY . . + +# Exposer le port 8080 +EXPOSE 8080 + +# Commande pour lancer l'application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"] \ No newline at end of file diff --git a/hw4/hw/README.md b/hw4/hw/README.md new file mode 100644 index 00000000..ba9f23c8 --- /dev/null +++ b/hw4/hw/README.md @@ -0,0 +1,123 @@ +# ДЗ + +## Задание - REST API (3 балла) + +Реализовать REST + RPC API для выдуманного интернет магазина. + +Тесты завязаны на объект `app = FastAPI(title="Shop API")` из файла [hw2/hw/shop_api/main.py](./shop_api/main.py), поэтому реализовывайте его используя его + +Ресурсы: + +- корзина (cart) + + Пример структуры ресурса: + + ```json + { + "id": 123, // идентификатор корзины + "items": [ // список товаров в корзине + { + "id": 1, // id товара + "name": "Туалетная бумага \"Поцелуй\", рулон", // название + "quantity": 3, // количество товара в корзине + "available": true // доступе ли (не удален ли) товар + }, + { + "id": 535, + "name": "Золотая цепочка \"Abendsonne\"", + "quantity": 1, + "available": false, + }, + ], + "price": 234.4 // общая сумма заказа + } + ``` + +- товар (item) + + Пример структуры ресурса: + + ```json + { + "id": 321, // идентификатор товара + "name": "Молоко \"Буреночка\" 1л.", // наименование товара + "price": 159.99, // цена товара + "deleted": false // удален ли товар, по умолчанию false + } + ``` + +Запросы для реализации: + +- cart + - `POST cart` - создание, работает как RPC, не принимает тело, возвращает + идентификатор + - `GET /cart/{id}` - получение корзины по `id` + - `GET /cart` - получение списка корзин с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `min_quantity` - неотрицательное целое число, минимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `max_quantity` - неотрицательное целое число, максимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `POST /cart/{cart_id}/add/{item_id}` - добавление в корзину с `cart_id` + предмета с `item_id`, если товар уже есть, то увеличивается его количество +- item + - `POST /item` - добавление нового товара + - `GET /item/{id}` - получение товара по `id` + - `GET /item` - получение списка товаров с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена (опционально, + если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена (опционально, + если нет, не учитывает в фильтре) + - `show_deleted` - булевая переменная, показывать ли удаленные товары (по + умолчанию `False`) + - `PUT /item/{id}` - замена товара по `id` (создание запрещено, только замена + существующего) + - `PATCH /item/{id}` - частичное обновление товара по `id` (разрешено менять + все поля, кроме `deleted`) + - `DELETE /item/{id}` - удаление товара по `id` (товар помечается как + удаленный) + +Способ хранение данных на усмотрение. + +Более подробные детали и требования к работе методов смотрите в тестах. +Модификация тестов при потребности допускается (но не смысловая). + +Чтобы запустить тесты только для этого задания вызовите: + +```sh +pytest -vv --showlocals --strict ./hw2/test_homework_2_1.py +``` + +Если получаете ошибку на подобии `No module named 'shop_api'` +Понадобится еще такая команда, после которой можно запускать тесты: + +```sh +export PYTHONPATH=${PWD}/hw2/hw +``` + +## Доп. Задание - WebSocket (+ доп балл) + +Реализовать чат для пользователей в отдельных комнатах (в примере один на всех). + +Пользователи подключаются к чату по WebSocket ручке `/chat/{chat_name}`. +Пользователи, которые ввели один и тот же `chat_name` буду подключены к одному +чату (то есть будут получать сообщения друг от друга). Пользователи не +подключенные к диалогу не будут получать сообщения. + +Сообщение - текст в теле сообщения от клиента. Сервер должен broadcast'ить +сообщения на других пользователей в своем чате. Каждому клиенту сервер +присваивает случайное имя и дополняет каждое сообщение именем пользователя в +начале в следующем виде: `{username} :: {message}`. + +Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 diff --git a/hw4/hw/__init__.py b/hw4/hw/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/hw/database.py b/hw4/hw/database.py new file mode 100644 index 00000000..5fccedc5 --- /dev/null +++ b/hw4/hw/database.py @@ -0,0 +1,24 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.declarative import declarative_base +import os + +# URL de connexion à PostgreSQL +DATABASE_URL = "postgresql://postgres:password@postgres:5432/shop_db" + +# Moteur de connexion +engine = create_engine(DATABASE_URL) + +# Session pour interagir avec la base +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base pour tous nos modèles +Base = declarative_base() + +# Fonction pour obtenir une session (utilisée dans les routes) +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/hw4/hw/docker-compose.yml b/hw4/hw/docker-compose.yml new file mode 100644 index 00000000..536a37b2 --- /dev/null +++ b/hw4/hw/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + postgres: + image: postgres:15 + container_name: shop_postgres + environment: + POSTGRES_DB: shop_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 + + api: + build: . + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgresql://postgres:password@postgres:5432/shop_db + depends_on: + postgres: + condition: service_healthy + volumes: + - .:/app + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw4/hw/init_db.py b/hw4/hw/init_db.py new file mode 100644 index 00000000..73daefc8 --- /dev/null +++ b/hw4/hw/init_db.py @@ -0,0 +1,12 @@ +from database import engine, Base +from shop_api.item.store.models import ItemDB +from shop_api.cart.store.models import CartDB, CartItemDB + +def create_tables(): + print("Création des tables dans la base de données...") + Base.metadata.create_all(bind=engine) + print("✅ Tables créées avec succès!") + print("📊 Tables créées : items, carts, cart_items") + +if __name__ == "__main__": + create_tables() \ No newline at end of file diff --git a/hw4/hw/main.py b/hw4/hw/main.py new file mode 100644 index 00000000..ac75017c --- /dev/null +++ b/hw4/hw/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from shop_api.cart.routers import router as cart +from shop_api.item.routers import router as item + +app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +@app.get("/") +async def root(): + return {"message": "API Shop is running"} \ No newline at end of file diff --git a/hw4/hw/python b/hw4/hw/python new file mode 100644 index 00000000..e69de29b diff --git a/hw4/hw/report.txt b/hw4/hw/report.txt new file mode 100644 index 00000000..ee0ceabe --- /dev/null +++ b/hw4/hw/report.txt @@ -0,0 +1,58 @@ +Тестовые данные созданы с использованием существующих моделей + +================================================== +DIRTY READ - READ UNCOMMITTED +================================================== +Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита) +Транзакция 1: Изменил цену с 100.0 на 150.0 +Транзакция 2: Читаю цену TestItem1... +Транзакция 2: Вижу 100.0€ -> DIRTY READ! +Транзакция 1: Делаю rollback! + +================================================== +НЕТ DIRTY READ - READ COMMITTED +================================================== +Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита) +Транзакция 1: Изменил цену с 100.0 на 150.0 +Транзакция 2: Читаю цену TestItem1... +Транзакция 2: Вижу 100.0€ -> Чистые данные! +Транзакция 1: Делаю rollback! + +================================================== +NON-REPEATABLE READ - READ COMMITTED +================================================== +Транзакция 2: Первое чтение -> 200.0€ +Транзакция 1: Изменяю TestItem2 +100€ и коммит +Транзакция 1: Изменил цену с 200.0 на 300.0 и закоммитил +Транзакция 2: Второе чтение -> 300.0€ +NON-REPEATABLE READ обнаружен! + +================================================== +НЕТ NON-REPEATABLE READ - REPEATABLE READ +================================================== +Транзакция 2: Первое чтение -> 300.0€ +Транзакция 1: Изменяю TestItem2 +150€ и коммит +Транзакция 1: Изменил цену с 300.0 на 450.0 и закоммитил +Транзакция 2: Второе чтение -> 300.0€ +Нет Non-Repeatable Read с REPEATABLE READ! + +================================================== +PHANTOM READ - REPEATABLE READ +================================================== +Транзакция 2: Первый подсчет -> 5 товаров +Транзакция 1: Добавляю новый товар 'PhantomItem' +Транзакция 1: Новый товар добавлен и закоммичен! +Транзакция 2: Второй подсчет -> 5 товаров + +================================================== +НЕТ PHANTOM READ - SERIALIZABLE +================================================== +Транзакция 2: Первый подсчет -> 6 товаров +Транзакция 1: Добавляю новый товар 'SerializableItem' +Транзакция 1: Новый товар добавлен и закоммичен! +Транзакция 2: Второй подсчет -> 6 товаров +Нет Phantom Read с SERIALIZABLE! + +================================================== +ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА! +================================================== \ No newline at end of file diff --git a/hw4/hw/requirements.txt b/hw4/hw/requirements.txt new file mode 100644 index 00000000..564d70cc --- /dev/null +++ b/hw4/hw/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 +alembic==1.13.0 +pydantic==2.5.0 \ No newline at end of file diff --git a/hw4/hw/shop_api/__init__.py b/hw4/hw/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/hw/shop_api/cart/contracts.py b/hw4/hw/shop_api/cart/contracts.py new file mode 100644 index 00000000..c052cee3 --- /dev/null +++ b/hw4/hw/shop_api/cart/contracts.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from pydantic import BaseModel +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + +class CartResponse(BaseModel): + id: int + items: list[CartItemResponse] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + items = [CartItemResponse(id=item.id, name=item.name, quantity=item.quantity, available=item.available) for item in entity.info.items] + return CartResponse(id=entity.id, items=items, price=entity.info.price) \ No newline at end of file diff --git a/hw4/hw/shop_api/cart/routers.py b/hw4/hw/shop_api/cart/routers.py new file mode 100644 index 00000000..612ef4db --- /dev/null +++ b/hw4/hw/shop_api/cart/routers.py @@ -0,0 +1,101 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from sqlalchemy.orm import Session +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse +import shop_api.item.store +from database import get_db + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart( + response: Response, + db: Session = Depends(get_db) +) -> CartResponse: + entity = store.create(db) + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{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, + db: Session = Depends(get_db) +): + entity = store.get_one(id, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + db: Session = Depends(get_db) +): + return [ + CartResponse.from_entity(e) + for e in store.get_many(db, offset, limit, min_price, max_price, min_quantity, max_quantity) + ] + + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart( + cart_id: int, + item_id: int, + db: Session = Depends(get_db) +): + # Récupère l'item depuis la base + item_entity = shop_api.item.store.get_one(item_id, db) + + if not item_entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item {item_id} not found", + ) + + # Ajoute l'item au panier + entity = store.add(cart_id, item_entity, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart {cart_id} not found", + ) + + return CartResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_cart( + id: int, + db: Session = Depends(get_db) +) -> Response: + store.delete(id, db) + return Response("", status_code=HTTPStatus.NO_CONTENT) \ No newline at end of file diff --git a/hw4/hw/shop_api/cart/store/__init__.py b/hw4/hw/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..e14c47c2 --- /dev/null +++ b/hw4/hw/shop_api/cart/store/__init__.py @@ -0,0 +1,13 @@ +from .models import CartItemInfo, CartEntity, CartInfo +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartEntity", + "CartInfo", + "CartItemInfo", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw4/hw/shop_api/cart/store/models.py b/hw4/hw/shop_api/cart/store/models.py new file mode 100644 index 00000000..9d55e963 --- /dev/null +++ b/hw4/hw/shop_api/cart/store/models.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, ForeignKey, DateTime +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from database import Base + +class CartDB(Base): + __tablename__ = "carts" + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + items = relationship("CartItemDB", back_populates="cart") + +class CartItemDB(Base): + __tablename__ = "cart_items" + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, ForeignKey("carts.id")) + item_id = Column(Integer, ForeignKey("items.id")) + quantity = Column(Integer, default=1) + cart = relationship("CartDB", back_populates="items") + item = relationship("ItemDB") + +from dataclasses import dataclass + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo \ No newline at end of file diff --git a/hw4/hw/shop_api/cart/store/queries.py b/hw4/hw/shop_api/cart/store/queries.py new file mode 100644 index 00000000..a868b394 --- /dev/null +++ b/hw4/hw/shop_api/cart/store/queries.py @@ -0,0 +1,60 @@ +from sqlalchemy.orm import Session +from typing import Iterable +from .models import CartDB, CartItemDB, CartEntity, CartInfo, CartItemInfo +from shop_api.item.store.models import ItemDB + +def create(db: Session) -> CartEntity: + db_cart = CartDB() + db.add(db_cart) + db.commit() + db.refresh(db_cart) + return CartEntity(id=db_cart.id, info=CartInfo(items=[], price=0.0)) + +def add(cart_id: int, item_entity, db: Session) -> CartEntity: + db_cart = db.query(CartDB).filter(CartDB.id == cart_id).first() + if not db_cart: + return None + existing_item = db.query(CartItemDB).filter(CartItemDB.cart_id == cart_id, CartItemDB.item_id == item_entity.id).first() + if existing_item: + existing_item.quantity += 1 + else: + new_item = CartItemDB(cart_id=cart_id, item_id=item_entity.id, quantity=1) + db.add(new_item) + db.commit() + return get_one(cart_id, db) + +def delete(id: int, db: Session) -> None: + db_cart = db.query(CartDB).filter(CartDB.id == id).first() + if db_cart: + db.delete(db_cart) + db.commit() + +def get_one(id: int, db: Session) -> CartEntity | None: + db_cart = db.query(CartDB).filter(CartDB.id == id).first() + if not db_cart: + return None + total_price = 0.0 + cart_items = [] + for cart_item in db_cart.items: + item = cart_item.item + item_total = item.price * cart_item.quantity + total_price += item_total + cart_items.append(CartItemInfo(id=item.id, name=item.name, quantity=cart_item.quantity, available=not item.deleted)) + return CartEntity(id=db_cart.id, info=CartInfo(items=cart_items, price=total_price)) + +def get_many(db: Session, offset: int = 0, limit: int = 10, min_price: float = None, max_price: float = None, min_quantity: int = None, max_quantity: int = None) -> Iterable[CartEntity]: + db_carts = db.query(CartDB).offset(offset).limit(limit).all() + for db_cart in db_carts: + cart_entity = get_one(db_cart.id, db) + if not cart_entity: + continue + if min_price is not None and cart_entity.info.price < min_price: + continue + if max_price is not None and cart_entity.info.price > max_price: + continue + total_quantity = sum(item.quantity for item in cart_entity.info.items) + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + yield cart_entity \ No newline at end of file diff --git a/hw4/hw/shop_api/item/contracts.py b/hw4/hw/shop_api/item/contracts.py new file mode 100644 index 00000000..5f2766f1 --- /dev/null +++ b/hw4/hw/shop_api/item/contracts.py @@ -0,0 +1,29 @@ +from __future__ import annotations +from pydantic import BaseModel, ConfigDict +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse(id=entity.id, name=entity.info.name, price=entity.info.price, deleted=entity.info.deleted) + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + model_config = ConfigDict(extra="forbid") + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) \ No newline at end of file diff --git a/hw4/hw/shop_api/item/routers.py b/hw4/hw/shop_api/item/routers.py new file mode 100644 index 00000000..fc237136 --- /dev/null +++ b/hw4/hw/shop_api/item/routers.py @@ -0,0 +1,127 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from sqlalchemy.orm import Session +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest +from database import get_db + +router = APIRouter(prefix="/item") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item( + info: ItemRequest, + response: Response, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.add(info.as_item_info(), db) + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id( + id: int, + db: Session = Depends(get_db) +): + entity = store.get_one(id, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] = False, + db: Session = Depends(get_db) +): + return [ + ItemResponse.from_entity(e) + for e in store.get_many(db, offset, limit, min_price, max_price, show_deleted) + ] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item( + id: int, + info: PatchItemRequest, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.patch(id, info.as_patch_item_info(), db) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.update(id, info.as_item_info(), db) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item( + id: int, + db: Session = Depends(get_db) +) -> Response: + store.delete(id, db) + return Response("", status_code=HTTPStatus.NO_CONTENT) \ No newline at end of file diff --git a/hw4/hw/shop_api/item/store/__init__.py b/hw4/hw/shop_api/item/store/__init__.py new file mode 100644 index 00000000..bf69aa56 --- /dev/null +++ b/hw4/hw/shop_api/item/store/__init__.py @@ -0,0 +1,14 @@ +from .models import PatchItemInfo, ItemEntity, ItemInfo +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "ItemEntity", + "ItemInfo", + "PatchItemInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw4/hw/shop_api/item/store/models.py b/hw4/hw/shop_api/item/store/models.py new file mode 100644 index 00000000..1ae1f37e --- /dev/null +++ b/hw4/hw/shop_api/item/store/models.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean +from database import Base + +class ItemDB(Base): + __tablename__ = "items" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + +from dataclasses import dataclass + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None \ No newline at end of file diff --git a/hw4/hw/shop_api/item/store/queries.py b/hw4/hw/shop_api/item/store/queries.py new file mode 100644 index 00000000..180dfb51 --- /dev/null +++ b/hw4/hw/shop_api/item/store/queries.py @@ -0,0 +1,57 @@ +from sqlalchemy.orm import Session +from typing import Iterable +from .models import ItemDB, ItemEntity, ItemInfo, PatchItemInfo + +def add(info: ItemInfo, db: Session) -> ItemEntity: + db_item = ItemDB(name=info.name, price=info.price, deleted=info.deleted) + db.add(db_item) + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def delete(id: int, db: Session) -> None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if db_item: + db.delete(db_item) + db.commit() + +def get_one(id: int, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def get_many(db: Session, offset: int = 0, limit: int = 10, min_price: float = None, max_price: float = None, show_deleted: bool = False) -> Iterable[ItemEntity]: + query = db.query(ItemDB) + if min_price is not None: + query = query.filter(ItemDB.price >= min_price) + if max_price is not None: + query = query.filter(ItemDB.price <= max_price) + if not show_deleted: + query = query.filter(ItemDB.deleted == False) + db_items = query.offset(offset).limit(limit).all() + for db_item in db_items: + yield ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def update(id: int, info: ItemInfo, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + db_item.name = info.name + db_item.price = info.price + db_item.deleted = info.deleted + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def patch(id: int, patch_info: PatchItemInfo, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + if patch_info.name is not None: + db_item.name = patch_info.name + if patch_info.price is not None: + db_item.price = patch_info.price + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) \ No newline at end of file diff --git a/hw4/hw/shop_api/uvicorn b/hw4/hw/shop_api/uvicorn new file mode 100644 index 00000000..e69de29b diff --git a/hw4/hw/test_homework2.py b/hw4/hw/test_homework2.py new file mode 100644 index 00000000..60a1f36a --- /dev/null +++ b/hw4/hw/test_homework2.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +from shop_api.main import app + +client = TestClient(app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@pytest.fixture() +def existing_not_empty_cart_id( + existing_empty_cart_id: int, + existing_items: list[int], +) -> int: + for item_id in faker.random_elements(existing_items, unique=False, length=3): + client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + + return existing_empty_cart_id + + +@pytest.fixture() +def existing_item() -> dict[str, Any]: + return client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ).json() + + +@pytest.fixture() +def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: + item_id = existing_item["id"] + client.delete(f"/item/{item_id}") + + existing_item["deleted"] = True + return existing_item + + +def test_post_cart() -> None: + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.parametrize( + ("cart", "not_empty"), + [ + ("existing_empty_cart_id", False), + ("existing_not_empty_cart_id", True), + ], +) +def test_get_cart(request, cart: int, not_empty: bool) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + len_items = len(response_json["items"]) + assert len_items > 0 if not_empty else len_items == 0 + + if not_empty: + price = 0 + + for item in response_json["items"]: + item_id = item["id"] + price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + else: + assert response_json["price"] == 0.0 + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000.0}, HTTPStatus.OK), + ({"max_price": 20.0}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query: dict[str, Any], status_code: int): + response = client.get("/cart", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + quantity = sum(item["quantity"] for cart in data for item in cart["items"]) + + if "min_quantity" in query: + assert quantity >= query["min_quantity"] + + if "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_post_item() -> None: + item = {"name": "test item", "price": 9.99} + response = client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +def test_get_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_item_list(query: dict[str, Any], status_code: int) -> None: + response = client.get("/item", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + if "show_deleted" in query and query["show_deleted"] is False: + assert all(item["deleted"] is False for item in data) + + +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +def test_put_item( + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = client.put(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + new_item = existing_item.copy() + new_item.update(body) + assert response.json() == new_item + + +@pytest.mark.parametrize( + ("item", "body", "status_code"), + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing_item", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing_item", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: + item_data: dict[str, Any] = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + patch_response_body = response.json() + + response = client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +def test_delete_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK diff --git a/hw4/hw/transaction_demo.py b/hw4/hw/transaction_demo.py new file mode 100644 index 00000000..e00c6c7c --- /dev/null +++ b/hw4/hw/transaction_demo.py @@ -0,0 +1,355 @@ +import threading +import time +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from database import SessionLocal +from shop_api.item.store.models import ItemDB +from shop_api.cart.store.models import CartDB + +# Конфигурация базы данных +DATABASE_URL = "postgresql://postgres:password@postgres:5432/shop_db" +engine = create_engine(DATABASE_URL) +Session = sessionmaker(bind=engine) + +def setup_test_data(): + """Подготовка тестовых данных используя существующие модели""" + session = Session() + try: + # Очистка тестовых данных + session.query(ItemDB).filter(ItemDB.name.in_(["TestItem1", "TestItem2", "TestItem3"])).delete() + session.commit() + + # Создание тестовых товаров используя модель ItemDB + test_items = [ + ItemDB(name="TestItem1", price=100.0, deleted=False), + ItemDB(name="TestItem2", price=200.0, deleted=False), + ItemDB(name="TestItem3", price=300.0, deleted=False) + ] + + session.add_all(test_items) + session.commit() + print("Тестовые данные созданы с использованием существующих моделей") + + except Exception as e: + session.rollback() + print(f"Ошибка: {e}") + finally: + session.close() + +def dirty_read_demo(): + """Демонстрация Dirty Read (грязное чтение)""" + print("\n" + "="*50) + print("DIRTY READ - READ UNCOMMITTED") + print("="*50) + + def transaction1(): + """Транзакция, которая изменяет и откатывает""" + session = Session() + try: + print("Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита)") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + original_price = item.price + item.price = item.price + 50 + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price}") + + time.sleep(3) # Пауза для чтения Т2 + print("Транзакция 1: Делаю rollback!") + session.rollback() + + finally: + session.close() + + def transaction2(): + """Транзакция, которая читает незакоммиченные данные""" + session = Session() + try: + time.sleep(1) # Ждет изменения Т1 + print("Транзакция 2: Читаю цену TestItem1...") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + print(f"Транзакция 2: Вижу {item.price}€ -> DIRTY READ!") + + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t1.start() + t2.start() + t1.join() + t2.join() + +def no_dirty_read_demo(): + """Демонстрация отсутствия Dirty Read с READ COMMITTED""" + print("\n" + "="*50) + print("НЕТ DIRTY READ - READ COMMITTED") + print("="*50) + + def transaction1(): + session = Session() + try: + print("Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита)") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + original_price = item.price + item.price = item.price + 50 + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price}") + + time.sleep(3) + print("Транзакция 1: Делаю rollback!") + session.rollback() + + finally: + session.close() + + def transaction2(): + session = Session() + try: + time.sleep(1) + print("Транзакция 2: Читаю цену TestItem1...") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + print(f"Транзакция 2: Вижу {item.price}€ -> Чистые данные!") + + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t1.start() + t2.start() + t1.join() + t2.join() + +def non_repeatable_read_demo(): + """Демонстрация Non-Repeatable Read с READ COMMITTED""" + print("\n" + "="*50) + print("NON-REPEATABLE READ - READ COMMITTED") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Изменяю TestItem2 +100€ и коммит") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + original_price = item.price + item.price = item.price + 100 + session.commit() + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price} и закоммитил") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + # Первое чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price1 = item.price + print(f"Транзакция 2: Первое чтение -> {price1}€") + + time.sleep(2) # Ждет изменения Т1 + + # Второе чтение + session.expire_all() # Сбрасываем кэш + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price2 = item.price + print(f"Транзакция 2: Второе чтение -> {price2}€") + + if price1 != price2: + print("NON-REPEATABLE READ обнаружен!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def no_non_repeatable_read_demo(): + """Демонстрация отсутствия Non-Repeatable Read с REPEATABLE READ""" + print("\n" + "="*50) + print("НЕТ NON-REPEATABLE READ - REPEATABLE READ") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Изменяю TestItem2 +150€ и коммит") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + original_price = item.price + item.price = item.price + 150 + session.commit() + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price} и закоммитил") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + + # Первое чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price1 = item.price + print(f"Транзакция 2: Первое чтение -> {price1}€") + + time.sleep(2) # Ждет изменения Т1 + + # Второе чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price2 = item.price + print(f"Транзакция 2: Второе чтение -> {price2}€") + + if price1 == price2: + print("Нет Non-Repeatable Read с REPEATABLE READ!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def phantom_read_demo(): + """Демонстрация Phantom Read с REPEATABLE READ""" + print("\n" + "="*50) + print("PHANTOM READ - REPEATABLE READ") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Добавляю новый товар 'PhantomItem'") + + new_item = ItemDB(name="PhantomItem", price=400.0, deleted=False) + session.add(new_item) + session.commit() + print("Транзакция 1: Новый товар добавлен и закоммичен!") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + + # Первое чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count1 = len(items) + print(f"Транзакция 2: Первый подсчет -> {count1} товаров") + + time.sleep(2) # Ждет добавления Т1 + + # Второе чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count2 = len(items) + print(f"Транзакция 2: Второй подсчет -> {count2} товаров") + + if count1 != count2: + print("PHANTOM READ обнаружен!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def no_phantom_read_demo(): + """Демонстрация отсутствия Phantom Read с SERIALIZABLE""" + print("\n" + "="*50) + print("НЕТ PHANTOM READ - SERIALIZABLE") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Добавляю новый товар 'SerializableItem'") + + new_item = ItemDB(name="SerializableItem", price=500.0, deleted=False) + session.add(new_item) + session.commit() + print("Транзакция 1: Новый товар добавлен и закоммичен!") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + + # Первое чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count1 = len(items) + print(f"Транзакция 2: Первый подсчет -> {count1} товаров") + + time.sleep(2) # Ждет добавления Т1 + + # Второе чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count2 = len(items) + print(f"Транзакция 2: Второй подсчет -> {count2} товаров") + + if count1 == count2: + print("Нет Phantom Read с SERIALIZABLE!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +if __name__ == "__main__": + print("ДЕМОНСТРАЦИЯ ПРОБЛЕМ ТРАНЗАКЦИЙ") + print("База данных: PostgreSQL с SQLAlchemy") + print("Используются существующие модели ItemDB") + + # Подготовка + setup_test_data() + + # Демонстрации + dirty_read_demo() # 1. Dirty Read с READ UNCOMMITTED + no_dirty_read_demo() # 2. Нет Dirty Read с READ COMMITTED + non_repeatable_read_demo() # 3. Non-repeatable Read с READ COMMITTED + no_non_repeatable_read_demo() # 4. Нет Non-repeatable Read с REPEATABLE READ + phantom_read_demo() # 5. Phantom Read с REPEATABLE READ + no_phantom_read_demo() # 6. Нет Phantom Read с SERIALIZABLE + + print("\n" + "="*50) + print("ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА!") + print("="*50) \ No newline at end of file diff --git a/hw4/rest_example/README.md b/hw4/rest_example/README.md new file mode 100644 index 00000000..dc17981e --- /dev/null +++ b/hw4/rest_example/README.md @@ -0,0 +1,3 @@ +# REST API Example + +Пример REST API как пример из лекции, тут храним данные прямо в приложении (в оперативной памяти), т.к. код чисто для примера, не делайте так в продакшене, пожалуйста diff --git a/hw4/rest_example/__init__.py b/hw4/rest_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/rest_example/api/__init__.py b/hw4/rest_example/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/rest_example/api/pokemon/__init__.py b/hw4/rest_example/api/pokemon/__init__.py new file mode 100644 index 00000000..ccd625f1 --- /dev/null +++ b/hw4/rest_example/api/pokemon/__init__.py @@ -0,0 +1,9 @@ +from .contracts import PatchPokemonRequest, PokemonRequest, PokemonResponse +from .routes import router + +__all__ = [ + "PokemonResponse", + "PokemonRequest", + "PatchPokemonRequest", + "router", +] diff --git a/hw4/rest_example/api/pokemon/contracts.py b/hw4/rest_example/api/pokemon/contracts.py new file mode 100644 index 00000000..a985b15b --- /dev/null +++ b/hw4/rest_example/api/pokemon/contracts.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from hw2.rest_example.store.models import ( + PatchPokemonInfo, + PokemonEntity, + PokemonInfo, +) + + +class PokemonResponse(BaseModel): + id: int + name: str + published: bool + + @staticmethod + def from_entity(entity: PokemonEntity) -> PokemonResponse: + return PokemonResponse( + id=entity.id, + name=entity.info.name, + published=entity.info.published, + ) + + +class PokemonRequest(BaseModel): + name: str + published: bool + + def as_pokemon_info(self) -> PokemonInfo: + return PokemonInfo(name=self.name, published=self.published) + + +class PatchPokemonRequest(BaseModel): + name: str | None = None + published: bool | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_pokemon_info(self) -> PatchPokemonInfo: + return PatchPokemonInfo(name=self.name, published=self.published) diff --git a/hw4/rest_example/api/pokemon/routes.py b/hw4/rest_example/api/pokemon/routes.py new file mode 100644 index 00000000..ab935c9a --- /dev/null +++ b/hw4/rest_example/api/pokemon/routes.py @@ -0,0 +1,119 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeInt, PositiveInt + +from hw2.rest_example import store + +from .contracts import ( + PatchPokemonRequest, + PokemonRequest, + PokemonResponse, +) + +router = APIRouter(prefix="/pokemon") + + +@router.get("/") +async def get_pokemon_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, +) -> list[PokemonResponse]: + return [PokemonResponse.from_entity(e) for e in store.get_many(offset, limit)] + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested pokemon", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested pokemon as one was not found", + }, + }, +) +async def get_pokemon_by_id(id: int) -> PokemonResponse: + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_pokemon(info: PokemonRequest, response: Response) -> PokemonResponse: + entity = store.add(info.as_pokemon_info()) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/pokemon/{entity.id}" + + return PokemonResponse.from_entity(entity) + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched pokemon", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify pokemon as one was not found", + }, + }, +) +async def patch_pokemon(id: int, info: PatchPokemonRequest) -> PokemonResponse: + entity = store.patch(id, info.as_patch_pokemon_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@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_pokemon( + id: int, + info: PokemonRequest, + upsert: Annotated[bool, Query()] = False, +) -> PokemonResponse: + entity = ( + store.upsert(id, info.as_pokemon_info()) + if upsert + else store.update(id, info.as_pokemon_info()) + ) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_pokemon(id: int) -> Response: + store.delete(id) + return Response("") diff --git a/hw4/rest_example/main.py b/hw4/rest_example/main.py new file mode 100644 index 00000000..26a2cf80 --- /dev/null +++ b/hw4/rest_example/main.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +from hw2.rest_example.api.pokemon import router + +app = FastAPI(title="Pokemon REST API Example") + +app.include_router(router) diff --git a/hw4/rest_example/requirements.txt b/hw4/rest_example/requirements.txt new file mode 100644 index 00000000..b66bec1e --- /dev/null +++ b/hw4/rest_example/requirements.txt @@ -0,0 +1 @@ +fastapi>=0.117.1 diff --git a/hw4/rest_example/store/__init__.py b/hw4/rest_example/store/__init__.py new file mode 100644 index 00000000..cb99d02a --- /dev/null +++ b/hw4/rest_example/store/__init__.py @@ -0,0 +1,15 @@ +from .models import PatchPokemonInfo, PokemonEntity, PokemonInfo +from .queries import add, delete, get_many, get_one, patch, update, upsert + +__all__ = [ + "PokemonEntity", + "PokemonInfo", + "PatchPokemonInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "upsert", + "patch", +] diff --git a/hw4/rest_example/store/models.py b/hw4/rest_example/store/models.py new file mode 100644 index 00000000..95cd40b9 --- /dev/null +++ b/hw4/rest_example/store/models.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class PokemonInfo: + name: str + published: bool + + +@dataclass(slots=True) +class PokemonEntity: + id: int + info: PokemonInfo + + +@dataclass(slots=True) +class PatchPokemonInfo: + name: str | None = None + published: bool | None = None diff --git a/hw4/rest_example/store/queries.py b/hw4/rest_example/store/queries.py new file mode 100644 index 00000000..959492d7 --- /dev/null +++ b/hw4/rest_example/store/queries.py @@ -0,0 +1,75 @@ +from typing import Iterable + +from hw2.rest_example.store.models import ( + PatchPokemonInfo, + PokemonEntity, + PokemonInfo, +) + +_data = dict[int, PokemonInfo]() + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: PokemonInfo) -> PokemonEntity: + _id = next(_id_generator) + _data[_id] = info + + return PokemonEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> PokemonEntity | None: + if id not in _data: + return None + + return PokemonEntity(id=id, info=_data[id]) + + +def get_many(offset: int = 0, limit: int = 10) -> Iterable[PokemonEntity]: + curr = 0 + for id, info in _data.items(): + if offset <= curr < offset + limit: + yield PokemonEntity(id, info) + + curr += 1 + + +def update(id: int, info: PokemonInfo) -> PokemonEntity | None: + if id not in _data: + return None + + _data[id] = info + + return PokemonEntity(id=id, info=info) + + +def upsert(id: int, info: PokemonInfo) -> PokemonEntity: + _data[id] = info + + return PokemonEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchPokemonInfo) -> PokemonEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.published is not None: + _data[id].published = patch_info.published + + return PokemonEntity(id=id, info=_data[id]) diff --git a/hw4/ws_example/README.md b/hw4/ws_example/README.md new file mode 100644 index 00000000..471f0d10 --- /dev/null +++ b/hw4/ws_example/README.md @@ -0,0 +1,19 @@ +# WebSocket Example + +Минимально рабочий пример WebSocket broadcaster'а как пример из лекции. Сервер принимает подключения от клиентов и рассылает сообщения всем подключенным одновременно. + +Важная особенность: сервер должен хранить активные WebSocket подключения непосредственно в оперативной памяти, из-за чего эта штука stateful. При перезапуске сервера все соединения потеряются, и горизонтальное масштабирование становится проблематичным без дополнительных решений. + +Для запуска сервера: + +```sh +uvicorn server:app --reload +``` + +Для запуска клиента в отдельном терминале: + +```sh +python client.py +``` + +Можно отправлять сообщения всем подключенным клиентам через POST запрос на `/publish`. diff --git a/hw4/ws_example/__init__.py b/hw4/ws_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/ws_example/client.py b/hw4/ws_example/client.py new file mode 100644 index 00000000..0af3441b --- /dev/null +++ b/hw4/ws_example/client.py @@ -0,0 +1,6 @@ +from websocket import create_connection + +ws = create_connection("ws://localhost:8000/subscribe") + +while True: + print(ws.recv()) diff --git a/hw4/ws_example/requirements.txt b/hw4/ws_example/requirements.txt new file mode 100644 index 00000000..3af9a713 --- /dev/null +++ b/hw4/ws_example/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.117.1 +websockets>=0.2.1 diff --git a/hw4/ws_example/server.py b/hw4/ws_example/server.py new file mode 100644 index 00000000..2bb5f1d1 --- /dev/null +++ b/hw4/ws_example/server.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass, field +from uuid import uuid4 + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect + +app = FastAPI() + + +@dataclass(slots=True) +class Broadcaster: + subscribers: list[WebSocket] = field(init=False, default_factory=list) + + async def subscribe(self, ws: WebSocket) -> None: + await ws.accept() + self.subscribers.append(ws) + + async def unsubscribe(self, ws: WebSocket) -> None: + self.subscribers.remove(ws) + + async def publish(self, message: str) -> None: + for ws in self.subscribers: + await ws.send_text(message) + + +broadcaster = Broadcaster() + + +@app.post("/publish") +async def post_publish(request: Request): + message = (await request.body()).decode() + await broadcaster.publish(message) + + +@app.websocket("/subscribe") +async def ws_subscribe(ws: WebSocket): + client_id = uuid4() + await broadcaster.subscribe(ws) + await broadcaster.publish(f"client {client_id} subscribed") + + try: + while True: + text = await ws.receive_text() + await broadcaster.publish(text) + except WebSocketDisconnect: + broadcaster.unsubscribe(ws) + await broadcaster.publish(f"client {client_id} unsubscribed") diff --git a/hw5/hw/Dockerfile b/hw5/hw/Dockerfile new file mode 100644 index 00000000..66e8a782 --- /dev/null +++ b/hw5/hw/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.12 + +# Variables d'environnement pour optimiser Python +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +# Mettre à jour et installer gcc (nécessaire pour certaines dépendances) +RUN apt-get update && apt-get install -y gcc + +# Mettre à jour pip +RUN python -m pip install --upgrade pip + +# Créer et définir le répertoire de travail +WORKDIR /app + +# Copier uniquement le fichier requirements-test.txt +COPY requirements-test.txt . + +# Installer les dépendances +RUN pip install --upgrade pip +RUN pip install -r requirements-test.txt + +# Copier le reste de l'application +COPY . . + +# Exposer le port 8080 +EXPOSE 8080 + +# Commande pour lancer l'application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"] \ No newline at end of file diff --git a/hw5/hw/README.md b/hw5/hw/README.md new file mode 100644 index 00000000..cc08c034 --- /dev/null +++ b/hw5/hw/README.md @@ -0,0 +1,124 @@ +# ДЗ + +## Задание - REST API (3 балла) + +Реализовать REST + RPC API для выдуманного интернет магазина. + +Тесты завязаны на объект `app = FastAPI(title="Shop API")` из файла [hw2/hw/shop_api/main.py](./shop_api/main.py), поэтому реализовывайте его используя его + +Ресурсы: + +- корзина (cart) + + Пример структуры ресурса: + + ```json + { + "id": 123, // идентификатор корзины + "items": [ // список товаров в корзине + { + "id": 1, // id товара + "name": "Туалетная бумага \"Поцелуй\", рулон", // название + "quantity": 3, // количество товара в корзине + "available": true // доступе ли (не удален ли) товар + }, + { + "id": 535, + "name": "Золотая цепочка \"Abendsonne\"", + "quantity": 1, + "available": false, + }, + ], + "price": 234.4 // общая сумма заказа + } + ``` + +- товар (item) + + Пример структуры ресурса: + + ```json + { + "id": 321, // идентификатор товара + "name": "Молоко \"Буреночка\" 1л.", // наименование товара + "price": 159.99, // цена товара + "deleted": false // удален ли товар, по умолчанию false + } + ``` + +Запросы для реализации: + +- cart + - `POST cart` - создание, работает как RPC, не принимает тело, возвращает + идентификатор + - `GET /cart/{id}` - получение корзины по `id` + - `GET /cart` - получение списка корзин с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `min_quantity` - неотрицательное целое число, минимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `max_quantity` - неотрицательное целое число, максимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `POST /cart/{cart_id}/add/{item_id}` - добавление в корзину с `cart_id` + предмета с `item_id`, если товар уже есть, то увеличивается его количество +- item + - `POST /item` - добавление нового товара + - `GET /item/{id}` - получение товара по `id` + - `GET /item` - получение списка товаров с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена (опционально, + если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена (опционально, + если нет, не учитывает в фильтре) + - `show_deleted` - булевая переменная, показывать ли удаленные товары (по + умолчанию `False`) + - `PUT /item/{id}` - замена товара по `id` (создание запрещено, только замена + существующего) + - `PATCH /item/{id}` - частичное обновление товара по `id` (разрешено менять + все поля, кроме `deleted`) + - `DELETE /item/{id}` - удаление товара по `id` (товар помечается как + удаленный) + +Способ хранение данных на усмотрение. + +Более подробные детали и требования к работе методов смотрите в тестах. +Модификация тестов при потребности допускается (но не смысловая). + +Чтобы запустить тесты только для этого задания вызовите: + +```sh +pytest -vv --showlocals --strict ./hw2/test_homework_2_1.py +``` + +Если получаете ошибку на подобии `No module named 'shop_api'` +Понадобится еще такая команда, после которой можно запускать тесты: + +```sh +export PYTHONPATH=${PWD}/hw2/hw +``` + +## Доп. Задание - WebSocket (+ доп балл) + +Реализовать чат для пользователей в отдельных комнатах (в примере один на всех). + +Пользователи подключаются к чату по WebSocket ручке `/chat/{chat_name}`. +Пользователи, которые ввели один и тот же `chat_name` буду подключены к одному +чату (то есть будут получать сообщения друг от друга). Пользователи не +подключенные к диалогу не будут получать сообщения. + +Сообщение - текст в теле сообщения от клиента. Сервер должен broadcast'ить +сообщения на других пользователей в своем чате. Каждому клиенту сервер +присваивает случайное имя и дополняет каждое сообщение именем пользователя в +начале в следующем виде: `{username} :: {message}`. + +Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 +"# CI Test" diff --git a/hw5/hw/__init__.py b/hw5/hw/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/hw/database.py b/hw5/hw/database.py new file mode 100644 index 00000000..2f0a2ccc --- /dev/null +++ b/hw5/hw/database.py @@ -0,0 +1,22 @@ +# database.py +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +import os + +def get_database_url(): + if os.getenv("TESTING"): + return "postgresql://postgres:password@postgres:5432/test_shop_db" + else: + return os.getenv("DATABASE_URL", "postgresql://postgres:password@postgres:5432/shop_db") + +DATABASE_URL = get_database_url() +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/hw5/hw/docker-compose.yml b/hw5/hw/docker-compose.yml new file mode 100644 index 00000000..472ebca7 --- /dev/null +++ b/hw5/hw/docker-compose.yml @@ -0,0 +1,53 @@ +version: "3.9" + +services: + postgres: + image: postgres:15 + container_name: shop_postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: shop_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: . + container_name: hw_api + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgresql://postgres:password@postgres:5432/shop_db + depends_on: + postgres: + condition: service_healthy + volumes: + - .:/app + + tests: + build: . + container_name: hw_tests + environment: + - TESTING=1 + - DATABASE_URL=postgresql://postgres:password@postgres:5432/test_shop_db + - PYTHONPATH=/app + depends_on: + postgres: + condition: service_healthy + volumes: + - .:/app + command: > + bash -c " + echo '🧪 Lancement des tests unitaires...'; + pytest -v --maxfail=1 --disable-warnings + " + +volumes: + postgres_data: diff --git a/hw5/hw/init_db.py b/hw5/hw/init_db.py new file mode 100644 index 00000000..73daefc8 --- /dev/null +++ b/hw5/hw/init_db.py @@ -0,0 +1,12 @@ +from database import engine, Base +from shop_api.item.store.models import ItemDB +from shop_api.cart.store.models import CartDB, CartItemDB + +def create_tables(): + print("Création des tables dans la base de données...") + Base.metadata.create_all(bind=engine) + print("✅ Tables créées avec succès!") + print("📊 Tables créées : items, carts, cart_items") + +if __name__ == "__main__": + create_tables() \ No newline at end of file diff --git a/hw5/hw/init_db_test.sql b/hw5/hw/init_db_test.sql new file mode 100644 index 00000000..23d14d2e --- /dev/null +++ b/hw5/hw/init_db_test.sql @@ -0,0 +1 @@ +CREATE DATABASE test_shop_db; \ No newline at end of file diff --git a/hw5/hw/main.py b/hw5/hw/main.py new file mode 100644 index 00000000..ac75017c --- /dev/null +++ b/hw5/hw/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from shop_api.cart.routers import router as cart +from shop_api.item.routers import router as item + +app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +@app.get("/") +async def root(): + return {"message": "API Shop is running"} \ No newline at end of file diff --git a/hw5/hw/pytest.ini b/hw5/hw/pytest.ini new file mode 100644 index 00000000..11d9c572 --- /dev/null +++ b/hw5/hw/pytest.ini @@ -0,0 +1,6 @@ +[tool:pytest] +testpaths = tests +addopts = -v --cov=shop_api --cov-report=term-missing --cov-report=html +python_files = test_*.py +python_classes = Test* +python_functions = test_* \ No newline at end of file diff --git a/hw5/hw/report.txt b/hw5/hw/report.txt new file mode 100644 index 00000000..ee0ceabe --- /dev/null +++ b/hw5/hw/report.txt @@ -0,0 +1,58 @@ +Тестовые данные созданы с использованием существующих моделей + +================================================== +DIRTY READ - READ UNCOMMITTED +================================================== +Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита) +Транзакция 1: Изменил цену с 100.0 на 150.0 +Транзакция 2: Читаю цену TestItem1... +Транзакция 2: Вижу 100.0€ -> DIRTY READ! +Транзакция 1: Делаю rollback! + +================================================== +НЕТ DIRTY READ - READ COMMITTED +================================================== +Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита) +Транзакция 1: Изменил цену с 100.0 на 150.0 +Транзакция 2: Читаю цену TestItem1... +Транзакция 2: Вижу 100.0€ -> Чистые данные! +Транзакция 1: Делаю rollback! + +================================================== +NON-REPEATABLE READ - READ COMMITTED +================================================== +Транзакция 2: Первое чтение -> 200.0€ +Транзакция 1: Изменяю TestItem2 +100€ и коммит +Транзакция 1: Изменил цену с 200.0 на 300.0 и закоммитил +Транзакция 2: Второе чтение -> 300.0€ +NON-REPEATABLE READ обнаружен! + +================================================== +НЕТ NON-REPEATABLE READ - REPEATABLE READ +================================================== +Транзакция 2: Первое чтение -> 300.0€ +Транзакция 1: Изменяю TestItem2 +150€ и коммит +Транзакция 1: Изменил цену с 300.0 на 450.0 и закоммитил +Транзакция 2: Второе чтение -> 300.0€ +Нет Non-Repeatable Read с REPEATABLE READ! + +================================================== +PHANTOM READ - REPEATABLE READ +================================================== +Транзакция 2: Первый подсчет -> 5 товаров +Транзакция 1: Добавляю новый товар 'PhantomItem' +Транзакция 1: Новый товар добавлен и закоммичен! +Транзакция 2: Второй подсчет -> 5 товаров + +================================================== +НЕТ PHANTOM READ - SERIALIZABLE +================================================== +Транзакция 2: Первый подсчет -> 6 товаров +Транзакция 1: Добавляю новый товар 'SerializableItem' +Транзакция 1: Новый товар добавлен и закоммичен! +Транзакция 2: Второй подсчет -> 6 товаров +Нет Phantom Read с SERIALIZABLE! + +================================================== +ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА! +================================================== \ No newline at end of file diff --git a/hw5/hw/requirements-test.txt b/hw5/hw/requirements-test.txt new file mode 100644 index 00000000..8bf265ba --- /dev/null +++ b/hw5/hw/requirements-test.txt @@ -0,0 +1,19 @@ +# ===== DÉPENDANCES PRINCIPALES ===== +fastapi>=0.104.0 +uvicorn>=0.24.0 +starlette>=0.27.0 +sqlalchemy>=2.0.0 +psycopg2-binary>=2.9.0 +alembic>=1.13.0 +pydantic>=2.5.0 +python-dotenv>=1.0.0 + +# ===== DÉPENDANCES DE TESTS ===== +pytest>=7.4.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 +pytest-asyncio>=0.21.0 +httpx>=0.25.0 +requests>=2.31.0 +responses>=0.24.1 +faker>=19.0.0 \ No newline at end of file diff --git a/hw5/hw/setup.py b/hw5/hw/setup.py new file mode 100644 index 00000000..dbc807c3 --- /dev/null +++ b/hw5/hw/setup.py @@ -0,0 +1,7 @@ +from setuptools import setup, find_packages + +setup( + name="shop_api", + version="0.1.0", + packages=find_packages(), +) \ No newline at end of file diff --git a/hw5/hw/shop_api/__init__.py b/hw5/hw/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/hw/shop_api/cart/contracts.py b/hw5/hw/shop_api/cart/contracts.py new file mode 100644 index 00000000..c052cee3 --- /dev/null +++ b/hw5/hw/shop_api/cart/contracts.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from pydantic import BaseModel +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + +class CartResponse(BaseModel): + id: int + items: list[CartItemResponse] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + items = [CartItemResponse(id=item.id, name=item.name, quantity=item.quantity, available=item.available) for item in entity.info.items] + return CartResponse(id=entity.id, items=items, price=entity.info.price) \ No newline at end of file diff --git a/hw5/hw/shop_api/cart/routers.py b/hw5/hw/shop_api/cart/routers.py new file mode 100644 index 00000000..612ef4db --- /dev/null +++ b/hw5/hw/shop_api/cart/routers.py @@ -0,0 +1,101 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from sqlalchemy.orm import Session +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse +import shop_api.item.store +from database import get_db + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart( + response: Response, + db: Session = Depends(get_db) +) -> CartResponse: + entity = store.create(db) + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{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, + db: Session = Depends(get_db) +): + entity = store.get_one(id, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + db: Session = Depends(get_db) +): + return [ + CartResponse.from_entity(e) + for e in store.get_many(db, offset, limit, min_price, max_price, min_quantity, max_quantity) + ] + + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart( + cart_id: int, + item_id: int, + db: Session = Depends(get_db) +): + # Récupère l'item depuis la base + item_entity = shop_api.item.store.get_one(item_id, db) + + if not item_entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item {item_id} not found", + ) + + # Ajoute l'item au panier + entity = store.add(cart_id, item_entity, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart {cart_id} not found", + ) + + return CartResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_cart( + id: int, + db: Session = Depends(get_db) +) -> Response: + store.delete(id, db) + return Response("", status_code=HTTPStatus.NO_CONTENT) \ No newline at end of file diff --git a/hw5/hw/shop_api/cart/store/__init__.py b/hw5/hw/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..e14c47c2 --- /dev/null +++ b/hw5/hw/shop_api/cart/store/__init__.py @@ -0,0 +1,13 @@ +from .models import CartItemInfo, CartEntity, CartInfo +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartEntity", + "CartInfo", + "CartItemInfo", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw5/hw/shop_api/cart/store/models.py b/hw5/hw/shop_api/cart/store/models.py new file mode 100644 index 00000000..9d55e963 --- /dev/null +++ b/hw5/hw/shop_api/cart/store/models.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, ForeignKey, DateTime +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from database import Base + +class CartDB(Base): + __tablename__ = "carts" + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + items = relationship("CartItemDB", back_populates="cart") + +class CartItemDB(Base): + __tablename__ = "cart_items" + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, ForeignKey("carts.id")) + item_id = Column(Integer, ForeignKey("items.id")) + quantity = Column(Integer, default=1) + cart = relationship("CartDB", back_populates="items") + item = relationship("ItemDB") + +from dataclasses import dataclass + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo \ No newline at end of file diff --git a/hw5/hw/shop_api/cart/store/queries.py b/hw5/hw/shop_api/cart/store/queries.py new file mode 100644 index 00000000..54dc56f3 --- /dev/null +++ b/hw5/hw/shop_api/cart/store/queries.py @@ -0,0 +1,102 @@ +from sqlalchemy.orm import Session +from typing import Iterable +from .models import CartDB, CartItemDB, CartEntity, CartInfo, CartItemInfo +from shop_api.item.store.models import ItemDB + +def create(db: Session) -> CartEntity: + db_cart = CartDB() + db.add(db_cart) + db.commit() + db.refresh(db_cart) + return CartEntity(id=db_cart.id, info=CartInfo(items=[], price=0.0)) + +def add(cart_id: int, item_entity, db: Session) -> CartEntity: + db_cart = db.query(CartDB).filter(CartDB.id == cart_id).first() + if not db_cart: + return None + + # Vérifie que l'item existe en base + db_item = db.query(ItemDB).filter(ItemDB.id == item_entity.id).first() + if not db_item: + return None + + existing_item = db.query(CartItemDB).filter( + CartItemDB.cart_id == cart_id, + CartItemDB.item_id == item_entity.id + ).first() + + if existing_item: + existing_item.quantity += 1 + else: + new_item = CartItemDB(cart_id=cart_id, item_id=item_entity.id, quantity=1) + db.add(new_item) + + db.commit() + return get_one(cart_id, db) + +def delete(id: int, db: Session) -> None: + db_cart = db.query(CartDB).filter(CartDB.id == id).first() + if db_cart: + # Supprime d'abord les items du panier + db.query(CartItemDB).filter(CartItemDB.cart_id == id).delete() + db.delete(db_cart) + db.commit() + +def get_one(id: int, db: Session) -> CartEntity | None: + from shop_api.item.store.models import ItemDB + db_cart = db.query(CartDB).filter(CartDB.id == id).first() + if not db_cart: + return None + + total_price = 0.0 + cart_items = [] + + for cart_item in db_cart.items: + item = cart_item.item + if item: # Vérification de sécurité + item_total = item.price * cart_item.quantity + total_price += item_total + cart_items.append(CartItemInfo( + id=item.id, + name=item.name, + quantity=cart_item.quantity, + available=not item.deleted + )) + + return CartEntity(id=db_cart.id, info=CartInfo(items=cart_items, price=total_price)) + +def get_many(db: Session, offset: int = 0, limit: int = 10, + min_price: float = None, max_price: float = None, + min_quantity: int = None, max_quantity: int = None) -> Iterable[CartEntity]: + + query = db.query(CartDB) + + # Applique les filtres au niveau de la requête SQL pour plus d'efficacité + if min_price is not None or max_price is not None: + # Sous-requête pour les paniers avec prix dans la plage + from sqlalchemy import func + subquery = db.query(CartItemDB.cart_id).join(ItemDB).group_by(CartItemDB.cart_id) + + if min_price is not None: + subquery = subquery.having(func.sum(ItemDB.price * CartItemDB.quantity) >= min_price) + if max_price is not None: + subquery = subquery.having(func.sum(ItemDB.price * CartItemDB.quantity) <= max_price) + + query = query.filter(CartDB.id.in_(subquery)) + + db_carts = query.offset(offset).limit(limit).all() + + for db_cart in db_carts: + cart_entity = get_one(db_cart.id, db) + if not cart_entity: + continue + + # Filtres supplémentaires au niveau application + total_quantity = sum(item.quantity for item in cart_entity.info.items) + + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + yield cart_entity \ No newline at end of file diff --git a/hw5/hw/shop_api/item/contracts.py b/hw5/hw/shop_api/item/contracts.py new file mode 100644 index 00000000..a1791bc8 --- /dev/null +++ b/hw5/hw/shop_api/item/contracts.py @@ -0,0 +1,62 @@ +from __future__ import annotations +from pydantic import BaseModel, ConfigDict, field_validator +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted + ) + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + @field_validator('price') + @classmethod + def validate_price(cls, v): + if v < 0: + raise ValueError('Le prix ne peut pas être négatif') + return v + + @field_validator('name') + @classmethod + def validate_name(cls, v): + if not v or not v.strip(): + raise ValueError('Le nom ne peut pas être vide') + return v.strip() + + 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") + + @field_validator('price') + @classmethod + def validate_price(cls, v): + if v is not None and v < 0: + raise ValueError('Le prix ne peut pas être négatif') + return v + + @field_validator('name') + @classmethod + def validate_name(cls, v): + if v is not None and (not v or not v.strip()): + raise ValueError('Le nom ne peut pas être vide') + return v.strip() if v else v + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) \ No newline at end of file diff --git a/hw5/hw/shop_api/item/routers.py b/hw5/hw/shop_api/item/routers.py new file mode 100644 index 00000000..fc237136 --- /dev/null +++ b/hw5/hw/shop_api/item/routers.py @@ -0,0 +1,127 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from sqlalchemy.orm import Session +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest +from database import get_db + +router = APIRouter(prefix="/item") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item( + info: ItemRequest, + response: Response, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.add(info.as_item_info(), db) + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id( + id: int, + db: Session = Depends(get_db) +): + entity = store.get_one(id, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] = False, + db: Session = Depends(get_db) +): + return [ + ItemResponse.from_entity(e) + for e in store.get_many(db, offset, limit, min_price, max_price, show_deleted) + ] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item( + id: int, + info: PatchItemRequest, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.patch(id, info.as_patch_item_info(), db) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.update(id, info.as_item_info(), db) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item( + id: int, + db: Session = Depends(get_db) +) -> Response: + store.delete(id, db) + return Response("", status_code=HTTPStatus.NO_CONTENT) \ No newline at end of file diff --git a/hw5/hw/shop_api/item/store/__init__.py b/hw5/hw/shop_api/item/store/__init__.py new file mode 100644 index 00000000..bf69aa56 --- /dev/null +++ b/hw5/hw/shop_api/item/store/__init__.py @@ -0,0 +1,14 @@ +from .models import PatchItemInfo, ItemEntity, ItemInfo +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "ItemEntity", + "ItemInfo", + "PatchItemInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw5/hw/shop_api/item/store/models.py b/hw5/hw/shop_api/item/store/models.py new file mode 100644 index 00000000..1ae1f37e --- /dev/null +++ b/hw5/hw/shop_api/item/store/models.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean +from database import Base + +class ItemDB(Base): + __tablename__ = "items" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + +from dataclasses import dataclass + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None \ No newline at end of file diff --git a/hw5/hw/shop_api/item/store/queries.py b/hw5/hw/shop_api/item/store/queries.py new file mode 100644 index 00000000..180dfb51 --- /dev/null +++ b/hw5/hw/shop_api/item/store/queries.py @@ -0,0 +1,57 @@ +from sqlalchemy.orm import Session +from typing import Iterable +from .models import ItemDB, ItemEntity, ItemInfo, PatchItemInfo + +def add(info: ItemInfo, db: Session) -> ItemEntity: + db_item = ItemDB(name=info.name, price=info.price, deleted=info.deleted) + db.add(db_item) + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def delete(id: int, db: Session) -> None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if db_item: + db.delete(db_item) + db.commit() + +def get_one(id: int, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def get_many(db: Session, offset: int = 0, limit: int = 10, min_price: float = None, max_price: float = None, show_deleted: bool = False) -> Iterable[ItemEntity]: + query = db.query(ItemDB) + if min_price is not None: + query = query.filter(ItemDB.price >= min_price) + if max_price is not None: + query = query.filter(ItemDB.price <= max_price) + if not show_deleted: + query = query.filter(ItemDB.deleted == False) + db_items = query.offset(offset).limit(limit).all() + for db_item in db_items: + yield ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def update(id: int, info: ItemInfo, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + db_item.name = info.name + db_item.price = info.price + db_item.deleted = info.deleted + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def patch(id: int, patch_info: PatchItemInfo, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + if patch_info.name is not None: + db_item.name = patch_info.name + if patch_info.price is not None: + db_item.price = patch_info.price + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) \ No newline at end of file diff --git a/hw5/hw/shop_api/uvicorn b/hw5/hw/shop_api/uvicorn new file mode 100644 index 00000000..e69de29b diff --git a/hw5/hw/test.db b/hw5/hw/test.db new file mode 100644 index 00000000..a5acdca6 Binary files /dev/null and b/hw5/hw/test.db differ diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py new file mode 100644 index 00000000..d8523cf2 --- /dev/null +++ b/hw5/hw/tests/conftest.py @@ -0,0 +1,57 @@ +# tests/conftest.py +import sys +import os +import pytest + + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.append('/app') + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from database import Base + +from fastapi.testclient import TestClient +from main import app # adapte selon ton projet +from shop_api.cart.routers import get_db as cart_get_db + + +TEST_DATABASE_URL = os.getenv( + "DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_shop_db" +) + + +engine = create_engine(TEST_DATABASE_URL, echo=False) + + +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +@pytest.fixture(scope="function") +def db_session(): + """ + Fournit une session SQLAlchemy pour chaque test. + Crée toutes les tables avant le test et les supprime après. + """ + Base.metadata.create_all(bind=engine) + session = TestingSessionLocal() + try: + yield session + session.commit() + finally: + session.close() + Base.metadata.drop_all(bind=engine) + +@pytest.fixture(scope="function") +def client(db_session): + def _get_test_db(): + try: + yield db_session + finally: + pass + + app.dependency_overrides[cart_get_db] = _get_test_db + + with TestClient(app) as c: + yield c + + app.dependency_overrides = {} \ No newline at end of file diff --git a/hw5/hw/tests/pytest b/hw5/hw/tests/pytest new file mode 100644 index 00000000..e69de29b diff --git a/hw5/hw/tests/test_contracts/test_cart_contracts.py b/hw5/hw/tests/test_contracts/test_cart_contracts.py new file mode 100644 index 00000000..b97082a6 --- /dev/null +++ b/hw5/hw/tests/test_contracts/test_cart_contracts.py @@ -0,0 +1,35 @@ +import pytest +from shop_api.cart.contracts import CartItemResponse, CartResponse +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + +class TestCartContracts: + def test_cart_item_response(self): + """Test CartItemResponse""" + response = CartItemResponse( + id=1, + name="Test Item", + quantity=2, + available=True + ) + + assert response.id == 1 + assert response.name == "Test Item" + assert response.quantity == 2 + assert response.available is True + + def test_cart_response_from_entity(self): + """Test la conversion d'Entity vers Response""" + items = [ + CartItemInfo(id=1, name="Item1", quantity=1, available=True), + CartItemInfo(id=2, name="Item2", quantity=3, available=False) + ] + entity = CartEntity(id=1, info=CartInfo(items=items, price=400.0)) + + response = CartResponse.from_entity(entity) + + assert response.id == 1 + assert response.price == 400.0 + assert len(response.items) == 2 + assert response.items[0].name == "Item1" + assert response.items[1].quantity == 3 + assert response.items[1].available is False \ No newline at end of file diff --git a/hw5/hw/tests/test_contracts/test_edge_contracts.py b/hw5/hw/tests/test_contracts/test_edge_contracts.py new file mode 100644 index 00000000..106044a6 --- /dev/null +++ b/hw5/hw/tests/test_contracts/test_edge_contracts.py @@ -0,0 +1,44 @@ +import pytest +from pydantic import ValidationError +from shop_api.item.contracts import ItemRequest, PatchItemRequest + +class TestEdgeContracts: + def test_item_request_edge_cases(self): + """Test des cas limites pour ItemRequest""" + # Prix à 0 (devroit être valide) + request = ItemRequest(name="Free Item", price=0.0) + assert request.price == 0.0 + + # Prix très élevé + request = ItemRequest(name="Expensive", price=999999.99) + assert request.price == 999999.99 + + # Nom avec espaces (devrait être strippé par le validateur) + request = ItemRequest(name=" Test Item ", price=10.0) + assert request.name == "Test Item" + + def test_patch_item_request_edge_cases(self): + """Test des cas limites pour PatchItemRequest""" + # Tous les champs None + patch = PatchItemRequest() + assert patch.name is None + assert patch.price is None + + # Prix à 0 + patch = PatchItemRequest(price=0.0) + assert patch.price == 0.0 + + # Nom vide (devrait être validé) + with pytest.raises(ValueError): + PatchItemRequest(name="") + + def test_item_request_boundary_values(self): + """Test des valeurs aux limites""" + # Prix limite (0) + request = ItemRequest(name="Test", price=0.0) + assert request.price == 0.0 + + # Nom très long + long_name = "A" * 100 + request = ItemRequest(name=long_name, price=10.0) + assert request.name == long_name \ No newline at end of file diff --git a/hw5/hw/tests/test_contracts/test_item_contracts.py b/hw5/hw/tests/test_contracts/test_item_contracts.py new file mode 100644 index 00000000..3b7063fa --- /dev/null +++ b/hw5/hw/tests/test_contracts/test_item_contracts.py @@ -0,0 +1,67 @@ +import pytest +from pydantic import ValidationError +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest +from shop_api.item.store.models import ItemEntity, ItemInfo + +class TestItemContracts: + def test_item_request_valid(self): + """Test ItemRequest valide""" + request = ItemRequest(name="Test Item", price=100.0) + + assert request.name == "Test Item" + assert request.price == 100.0 + assert request.deleted is False # Valeur par défaut + + def test_item_request_invalid(self): + + with pytest.raises(ValueError): + ItemRequest(name="Test", price=-10.0) + + # Nom vide + with pytest.raises(ValueError): + ItemRequest(name="", price=10.0) + + # Données manquantes - Pydantic lève ValidationError + with pytest.raises(ValidationError): + ItemRequest(name="Test") # price manquant + + + def test_item_response_from_entity(self): + """Test la conversion d'Entity vers Response""" + entity = ItemEntity( + id=1, + info=ItemInfo(name="Test", price=50.0, deleted=False) + ) + + response = ItemResponse.from_entity(entity) + + assert response.id == 1 + assert response.name == "Test" + assert response.price == 50.0 + assert response.deleted is False + + def test_item_request_as_item_info(self): + """Test la conversion vers ItemInfo""" + request = ItemRequest(name="Test", price=75.0, deleted=True) + info = request.as_item_info() + + assert info.name == "Test" + assert info.price == 75.0 + assert info.deleted is True + + def test_patch_item_request(self): + """Test PatchItemRequest""" + # Partiel + patch = PatchItemRequest(name="Nouveau nom") + assert patch.name == "Nouveau nom" + assert patch.price is None + + # Conversion vers PatchItemInfo + patch_info = patch.as_patch_item_info() + assert patch_info.name == "Nouveau nom" + assert patch_info.price is None + + def test_patch_item_request_extra_fields(self): + """Test que les champs supplémentaires sont interdits""" + with pytest.raises(ValidationError): + PatchItemRequest(name="Test", invalid_field="value") \ No newline at end of file diff --git a/hw5/hw/tests/test_edge_cases.py b/hw5/hw/tests/test_edge_cases.py new file mode 100644 index 00000000..303eab57 --- /dev/null +++ b/hw5/hw/tests/test_edge_cases.py @@ -0,0 +1,45 @@ +import pytest +from shop_api.cart.store import queries as cart_queries +from shop_api.item.store import queries as item_queries +from shop_api.item.store.models import ItemInfo, ItemEntity, ItemDB + +class TestEdgeCases: + def test_add_item_to_nonexistent_cart(self, db_session): + """Test ajout d'item à un panier qui n'existe pas""" + # Créer un item valide + item_info = ItemInfo(name="Test", price=10.0, deleted=False) + db_item = ItemDB(name=item_info.name, price=item_info.price, deleted=item_info.deleted) + db_session.add(db_item) + db_session.commit() + db_session.refresh(db_item) + + item_entity = ItemEntity(id=db_item.id, info=item_info) + + result = cart_queries.add(999999, item_entity, db_session) + assert result is None + + def test_get_many_carts_edge_cases(self, db_session): + """Test get_many avec des filtres extrêmes""" + # Filtres qui ne matchent rien + carts = list(cart_queries.get_many(db_session, min_price=1000000)) + assert len(carts) == 0 + + # Filtres avec valeurs None + carts = list(cart_queries.get_many(db_session, min_price=None, max_price=None)) + assert isinstance(carts, list) + + def test_cart_queries_none_handling(self, db_session): + """Test gestion des valeurs None dans les queries""" + # get_one avec ID None + result = cart_queries.get_one(None, db_session) + assert result is None + + def test_add_nonexistent_item_to_cart(self, db_session): + """Test ajout d'un item qui n'existe pas en base""" + cart = cart_queries.create(db_session) + + # Créer une entité item avec un ID qui n'existe pas en base + fake_item_entity = ItemEntity(id=999999, info=ItemInfo(name="Fake", price=10.0, deleted=False)) + + result = cart_queries.add(cart.id, fake_item_entity, db_session) + assert result is None \ No newline at end of file diff --git a/hw5/hw/tests/test_final_coverage.py b/hw5/hw/tests/test_final_coverage.py new file mode 100644 index 00000000..601426c9 --- /dev/null +++ b/hw5/hw/tests/test_final_coverage.py @@ -0,0 +1,56 @@ +import pytest +from shop_api.cart.store import queries as cart_queries +from shop_api.item.store.models import ItemInfo, ItemEntity, ItemDB +from shop_api.item.contracts import PatchItemRequest + +class TestFinalCoverage: + def test_get_many_carts_complex_filters(self, db_session): + """Test get_many avec tous les types de filtres""" + # Créer des paniers avec différentes quantités + for i in range(3): + cart = cart_queries.create(db_session) + if i > 0: + # Créer et ajouter des items + item_info = ItemInfo(name=f"Item{i}", price=10.0 * i, deleted=False) + db_item = ItemDB(name=item_info.name, price=item_info.price, deleted=item_info.deleted) + db_session.add(db_item) + db_session.commit() + db_session.refresh(db_item) + item_entity = ItemEntity(id=db_item.id, info=item_info) + cart_queries.add(cart.id, item_entity, db_session) + + # Tester avec min_quantity et max_quantity + carts_min = list(cart_queries.get_many(db_session, min_quantity=1)) + carts_max = list(cart_queries.get_many(db_session, max_quantity=0)) + carts_both = list(cart_queries.get_many(db_session, min_quantity=1, max_quantity=10)) + + assert isinstance(carts_min, list) + assert isinstance(carts_max, list) + assert isinstance(carts_both, list) + + def test_patch_item_request_all_none(self, db_session): + """Test PatchItemRequest avec tous les champs None""" + patch = PatchItemRequest(name=None, price=None) + patch_info = patch.as_patch_item_info() + + assert patch_info.name is None + assert patch_info.price is None + + def test_cart_queries_return_none_cases(self, db_session): + """Test les cas où les queries retournent None""" + # Test avec des IDs négatifs + result = cart_queries.get_one(-1, db_session) + assert result is None + + # Test delete avec ID négatif + cart_queries.delete(-1, db_session) # Ne devrait pas crasher + + def test_item_contracts_edge_validators(self): + """Test des validateurs edge dans les contracts""" + # Test du validateur de prix dans PatchItemRequest + patch = PatchItemRequest(price=0.0) + assert patch.price == 0.0 + + # Test du validateur de nom + patch = PatchItemRequest(name="Valid Name") + assert patch.name == "Valid Name" \ No newline at end of file diff --git a/hw5/hw/tests/test_integration/test_full_flow.py b/hw5/hw/tests/test_integration/test_full_flow.py new file mode 100644 index 00000000..31f39861 --- /dev/null +++ b/hw5/hw/tests/test_integration/test_full_flow.py @@ -0,0 +1,82 @@ +import pytest +from fastapi import status + +class TestIntegration: + def test_complete_shopping_flow(self, client, db_session): + """Test un flux complet d'achat""" + # 1. Crée quelques items + items_data = [ + {"name": "Laptop", "price": 999.99}, + {"name": "Mouse", "price": 29.99}, + {"name": "Keyboard", "price": 79.99} + ] + + item_ids = [] + for item_data in items_data: + response = client.post("/item/", json=item_data) + assert response.status_code == status.HTTP_201_CREATED + item_ids.append(response.json()["id"]) + + # 2. Crée un panier + cart_response = client.post("/cart/") + assert cart_response.status_code == status.HTTP_201_CREATED + cart_id = cart_response.json()["id"] + + # 3. Ajoute des items au panier + # Ajoute le laptop + response = client.post(f"/cart/{cart_id}/add/{item_ids[0]}") + assert response.status_code == status.HTTP_200_OK + assert response.json()["price"] == 999.99 + + # Ajoute deux souris + response = client.post(f"/cart/{cart_id}/add/{item_ids[1]}") + assert response.status_code == status.HTTP_200_OK + response = client.post(f"/cart/{cart_id}/add/{item_ids[1]}") + assert response.status_code == status.HTTP_200_OK + + # 4. Vérifie le panier final + cart_response = client.get(f"/cart/{cart_id}") + assert cart_response.status_code == status.HTTP_200_OK + cart_data = cart_response.json() + + assert len(cart_data["items"]) == 2 # Laptop + Souris (même item mais agrégé) + assert cart_data["price"] == 999.99 + (29.99 * 2) # 1059.97 + + # Trouve l'item souris dans le panier + mouse_item = next(item for item in cart_data["items"] if item["name"] == "Mouse") + assert mouse_item["quantity"] == 2 + + def test_item_lifecycle(self, client, db_session): + """Test cycle de vie complet d'un item""" + # 1. Création + create_response = client.post("/item/", json={"name": "Test Lifecycle", "price": 100.0}) + assert create_response.status_code == status.HTTP_201_CREATED + item_id = create_response.json()["id"] + + # 2. Lecture + get_response = client.get(f"/item/{item_id}") + assert get_response.status_code == status.HTTP_200_OK + assert get_response.json()["name"] == "Test Lifecycle" + + # 3. Mise à jour partielle + patch_response = client.patch(f"/item/{item_id}", json={"price": 150.0}) + assert patch_response.status_code == status.HTTP_200_OK + assert patch_response.json()["price"] == 150.0 + assert patch_response.json()["name"] == "Test Lifecycle" # Inchangé + + # 4. Mise à jour complète + put_response = client.put( + f"/item/{item_id}", + json={"name": "Updated Lifecycle", "price": 200.0, "deleted": True} + ) + assert put_response.status_code == status.HTTP_200_OK + assert put_response.json()["name"] == "Updated Lifecycle" + assert put_response.json()["deleted"] is True + + # 5. Suppression + delete_response = client.delete(f"/item/{item_id}") + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + # 6. Vérifie que l'item n'existe plus + final_get = client.get(f"/item/{item_id}") + assert final_get.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/hw5/hw/tests/test_main.py b/hw5/hw/tests/test_main.py new file mode 100644 index 00000000..4cfc45d6 --- /dev/null +++ b/hw5/hw/tests/test_main.py @@ -0,0 +1,14 @@ +import pytest +from fastapi import status + +class TestMain: + def test_root_endpoint(self, client): + """Test le endpoint racine""" + response = client.get("/") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "API Shop is running"} + + def test_404_for_unknown_route(self, client): + """Test qu'une route inconnue retourne 404""" + response = client.get("/unknown-route") + assert response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/hw5/hw/tests/test_models/test_cart_models.py b/hw5/hw/tests/test_models/test_cart_models.py new file mode 100644 index 00000000..b36abbc3 --- /dev/null +++ b/hw5/hw/tests/test_models/test_cart_models.py @@ -0,0 +1,35 @@ +import pytest +from shop_api.cart.store.models import CartItemInfo, CartInfo, CartEntity + +class TestCartModels: + def test_cart_item_info_creation(self): + """Test la création d'un CartItemInfo""" + item_info = CartItemInfo(id=1, name="Test Item", quantity=3, available=True) + + assert item_info.id == 1 + assert item_info.name == "Test Item" + assert item_info.quantity == 3 + assert item_info.available is True + + def test_cart_info_creation(self): + """Test la création d'un CartInfo""" + items = [ + CartItemInfo(id=1, name="Item1", quantity=2, available=True), + CartItemInfo(id=2, name="Item2", quantity=1, available=False) + ] + cart_info = CartInfo(items=items, price=300.0) + + assert len(cart_info.items) == 2 + assert cart_info.price == 300.0 + assert cart_info.items[0].name == "Item1" + assert cart_info.items[1].available is False + + def test_cart_entity_creation(self): + """Test la création d'un CartEntity""" + items = [CartItemInfo(id=1, name="Test", quantity=1, available=True)] + cart_info = CartInfo(items=items, price=100.0) + cart_entity = CartEntity(id=5, info=cart_info) + + assert cart_entity.id == 5 + assert cart_entity.info.price == 100.0 + assert len(cart_entity.info.items) == 1 \ No newline at end of file diff --git a/hw5/hw/tests/test_models/test_item_models.py b/hw5/hw/tests/test_models/test_item_models.py new file mode 100644 index 00000000..2b725e52 --- /dev/null +++ b/hw5/hw/tests/test_models/test_item_models.py @@ -0,0 +1,38 @@ +import pytest +from shop_api.item.store.models import ItemInfo, ItemEntity, PatchItemInfo + +class TestItemModels: + def test_item_info_creation(self): + """Test la création d'un ItemInfo""" + info = ItemInfo(name="Test Item", price=99.99, deleted=False) + + assert info.name == "Test Item" + assert info.price == 99.99 + assert info.deleted is False + + def test_item_entity_creation(self): + """Test la création d'un ItemEntity""" + info = ItemInfo(name="Test", price=50.0, deleted=True) + entity = ItemEntity(id=1, info=info) + + assert entity.id == 1 + assert entity.info.name == "Test" + assert entity.info.price == 50.0 + assert entity.info.deleted is True + + def test_patch_item_info_partial(self): + """Test PatchItemInfo avec valeurs partielles""" + # Seulement le nom + patch1 = PatchItemInfo(name="Nouveau nom") + assert patch1.name == "Nouveau nom" + assert patch1.price is None + + # Seulement le prix + patch2 = PatchItemInfo(price=150.0) + assert patch2.price == 150.0 + assert patch2.name is None + + # Les deux + patch3 = PatchItemInfo(name="Test", price=200.0) + assert patch3.name == "Test" + assert patch3.price == 200.0 \ No newline at end of file diff --git a/hw5/hw/tests/test_queries/test_cart_queries.py b/hw5/hw/tests/test_queries/test_cart_queries.py new file mode 100644 index 00000000..5bac555a --- /dev/null +++ b/hw5/hw/tests/test_queries/test_cart_queries.py @@ -0,0 +1,87 @@ +import pytest +from shop_api.cart.store import queries +from shop_api.item.store.models import ItemInfo, ItemEntity, ItemDB + +class TestCartQueries: + + def test_create_cart(self, db_session): + """Test la création d'un panier""" + entity = queries.create(db_session) + assert entity.id is not None + assert entity.info.items == [] + assert entity.info.price == 0.0 + + def test_get_one_cart(self, db_session): + """Test la récupération d'un panier""" + created = queries.create(db_session) + entity = queries.get_one(created.id, db_session) + assert entity is not None + assert entity.id == created.id + assert entity.info.price == 0.0 + + def test_add_item_to_cart(self, db_session): + """Test l'ajout d'un item au panier""" + item_info = ItemInfo(name="Test Item", price=25.0, deleted=False) + db_item = ItemDB(name=item_info.name, price=item_info.price, deleted=item_info.deleted) + db_session.add(db_item) + db_session.commit() + db_session.refresh(db_item) + + cart = queries.create(db_session) + item_entity = ItemEntity(id=db_item.id, info=item_info) + updated_cart = queries.add(cart.id, item_entity, db_session) + + assert updated_cart is not None + assert len(updated_cart.info.items) == 1 + assert updated_cart.info.items[0].name == "Test Item" + assert updated_cart.info.items[0].quantity == 1 + assert updated_cart.info.price == 25.0 + + def test_add_item_twice_increases_quantity(self, db_session): + """Test que l'ajout du même item incrémente la quantité""" + item_info = ItemInfo(name="Test", price=10.0, deleted=False) + db_item = ItemDB(name=item_info.name, price=item_info.price, deleted=item_info.deleted) + db_session.add(db_item) + db_session.commit() + db_session.refresh(db_item) + + cart = queries.create(db_session) + item_entity = ItemEntity(id=db_item.id, info=item_info) + queries.add(cart.id, item_entity, db_session) + updated_cart = queries.add(cart.id, item_entity, db_session) + + assert len(updated_cart.info.items) == 1 + assert updated_cart.info.items[0].quantity == 2 + assert updated_cart.info.price == 20.0 + + def test_get_many_carts_with_filters(self, db_session): + """Test la récupération avec filtres""" + items = [] + for i in range(3): + db_item = ItemDB(name=f"Item{i}", price=10.0 * i, deleted=False) + db_session.add(db_item) + items.append(db_item) + db_session.commit() + for item in items: + db_session.refresh(item) + + carts = [] + for i in range(3): + cart = queries.create(db_session) + if i > 0: + item_entity = ItemEntity( + id=items[i].id, + info=ItemInfo(name=items[i].name, price=items[i].price, deleted=False) + ) + queries.add(cart.id, item_entity, db_session) + carts.append(cart) + + filtered_carts = list(queries.get_many(db_session, min_price=10.0)) + assert len(filtered_carts) == 2 + + def test_delete_cart(self, db_session): + """Test la suppression d'un panier""" + cart = queries.create(db_session) + queries.delete(cart.id, db_session) + result = queries.get_one(cart.id, db_session) + assert result is None diff --git a/hw5/hw/tests/test_queries/test_item_queries.py b/hw5/hw/tests/test_queries/test_item_queries.py new file mode 100644 index 00000000..5f03992d --- /dev/null +++ b/hw5/hw/tests/test_queries/test_item_queries.py @@ -0,0 +1,120 @@ +import pytest +from shop_api.item.store import queries +from shop_api.item.store.models import ItemInfo, PatchItemInfo + +class TestItemQueries: + def test_add_item(self, db_session): + """Test l'ajout d'un item""" + info = ItemInfo(name="Test Item", price=100.0, deleted=False) + + entity = queries.add(info, db_session) + + assert entity.info.name == "Test Item" + assert entity.info.price == 100.0 + assert entity.id is not None + + # Vérifie en base + db_item = db_session.query(queries.ItemDB).filter_by(id=entity.id).first() + assert db_item.name == "Test Item" + + def test_get_one_existing(self, db_session): + """Test la récupération d'un item existant""" + # Crée un item d'abord + info = ItemInfo(name="Get Test", price=50.0, deleted=False) + created = queries.add(info, db_session) + + # Récupère + entity = queries.get_one(created.id, db_session) + + assert entity is not None + assert entity.id == created.id + assert entity.info.name == "Get Test" + + def test_get_one_nonexistent(self, db_session): + """Test la récupération d'un item inexistant""" + entity = queries.get_one(9999, db_session) + assert entity is None + + def test_get_many(self, db_session): + """Test la récupération de plusieurs items""" + # Crée quelques items + items_data = [ + ItemInfo(name="Item1", price=10.0, deleted=False), + ItemInfo(name="Item2", price=20.0, deleted=False), + ItemInfo(name="Item3", price=30.0, deleted=True) # Supprimé + ] + + for info in items_data: + queries.add(info, db_session) + + # Récupère sans les supprimés + entities = list(queries.get_many(db_session, show_deleted=False)) + assert len(entities) == 2 + + # Récupère avec les supprimés + entities = list(queries.get_many(db_session, show_deleted=True)) + assert len(entities) == 3 + + def test_get_many_with_filters(self, db_session): + """Test les filtres prix""" + # Crée des items avec différents prix + prices = [10.0, 25.0, 50.0, 75.0, 100.0] + for price in prices: + queries.add(ItemInfo(name=f"Item{price}", price=price, deleted=False), db_session) + + # Filtre prix min + entities = list(queries.get_many(db_session, min_price=30.0)) + assert len(entities) == 3 # 50, 75, 100 + + # Filtre prix max + entities = list(queries.get_many(db_session, max_price=30.0)) + assert len(entities) == 2 # 10, 25 + + # Filtre min et max + entities = list(queries.get_many(db_session, min_price=20.0, max_price=60.0)) + assert len(entities) == 2 # 25, 50 + + def test_update_item(self, db_session): + """Test la mise à jour complète d'un item""" + # Crée un item + original = queries.add(ItemInfo(name="Original", price=10.0, deleted=False), db_session) + + # Met à jour + new_info = ItemInfo(name="Updated", price=20.0, deleted=True) + updated = queries.update(original.id, new_info, db_session) + + assert updated is not None + assert updated.info.name == "Updated" + assert updated.info.price == 20.0 + assert updated.info.deleted is True + + def test_update_nonexistent(self, db_session): + """Test la mise à jour d'un item inexistant""" + info = ItemInfo(name="Test", price=10.0, deleted=False) + result = queries.update(9999, info, db_session) + assert result is None + + def test_patch_item(self, db_session): + """Test la mise à jour partielle""" + # Crée un item + original = queries.add(ItemInfo(name="Original", price=10.0, deleted=False), db_session) + + # Patch seulement le nom + patch_info = PatchItemInfo(name="Patched") + patched = queries.patch(original.id, patch_info, db_session) + + assert patched is not None + assert patched.info.name == "Patched" + assert patched.info.price == 10.0 # Inchangé + + def test_delete_item(self, db_session): + """Test la suppression d'un item""" + # Crée un item + entity = queries.add(ItemInfo(name="To Delete", price=10.0, deleted=False), db_session) + + # Supprime + queries.delete(entity.id, db_session) + + # Vérifie qu'il n'existe plus + result = queries.get_one(entity.id, db_session) + assert result is None \ No newline at end of file diff --git a/hw5/hw/tests/test_routers/test_cart_routers.py b/hw5/hw/tests/test_routers/test_cart_routers.py new file mode 100644 index 00000000..8447fff5 --- /dev/null +++ b/hw5/hw/tests/test_routers/test_cart_routers.py @@ -0,0 +1,97 @@ +import pytest +from fastapi import status + +class TestCartRouters: + def test_create_cart_success(self, client, db_session): + """Test la création d'un panier via API""" + response = client.post("/cart/") + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert "id" in data + assert data["items"] == [] + assert data["price"] == 0.0 + assert "location" in response.headers + + def test_get_cart_by_id_success(self, client, db_session): + """Test la récupération d'un panier existant""" + # Crée un panier d'abord + create_response = client.post("/cart/") + cart_id = create_response.json()["id"] + + # Récupère + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == cart_id + assert data["items"] == [] + assert data["price"] == 0.0 + + def test_get_cart_by_id_not_found(self, client): + """Test la récupération d'un panier inexistant""" + response = client.get("/cart/9999") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_get_cart_list(self, client, db_session): + """Test la liste des paniers""" + # Crée quelques paniers + for i in range(3): + client.post("/cart/") + + response = client.get("/cart/") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) >= 3 + + def test_add_item_to_cart_success(self, client, db_session): + """Test l'ajout d'un item au panier""" + # Crée un panier + cart_response = client.post("/cart/") + cart_id = cart_response.json()["id"] + + # Crée un item + item_response = client.post("/item/", json={"name": "Cart Item", "price": 25.0}) + item_id = item_response.json()["id"] + + # Ajoute au panier + response = client.post(f"/cart/{cart_id}/add/{item_id}") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["name"] == "Cart Item" + assert data["items"][0]["quantity"] == 1 + assert data["price"] == 25.0 + + def test_add_item_to_cart_cart_not_found(self, client, db_session): + """Test l'ajout avec panier inexistant""" + # Crée un item + item_response = client.post("/item/", json={"name": "Test", "price": 10.0}) + item_id = item_response.json()["id"] + + response = client.post(f"/cart/9999/add/{item_id}") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_add_item_to_cart_item_not_found(self, client, db_session): + """Test l'ajout avec item inexistant""" + # Crée un panier + cart_response = client.post("/cart/") + cart_id = cart_response.json()["id"] + + response = client.post(f"/cart/{cart_id}/add/9999") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_cart_success(self, client, db_session): + """Test la suppression d'un panier""" + # Crée un panier + create_response = client.post("/cart/") + cart_id = create_response.json()["id"] + + # Supprime + response = client.delete(f"/cart/{cart_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Vérifie qu'il n'existe plus + get_response = client.get(f"/cart/{cart_id}") + assert get_response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/hw5/hw/tests/test_routers/test_item_routers.py b/hw5/hw/tests/test_routers/test_item_routers.py new file mode 100644 index 00000000..879851e5 --- /dev/null +++ b/hw5/hw/tests/test_routers/test_item_routers.py @@ -0,0 +1,143 @@ +import pytest +from fastapi import status +from shop_api.item.store.models import ItemInfo + +class TestItemRouters: + def test_create_item_success(self, client, db_session): + """Test la création d'un item via API""" + response = client.post( + "/item/", + json={"name": "API Test Item", "price": 99.99} + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "API Test Item" + assert data["price"] == 99.99 + assert data["deleted"] is False + assert "id" in data + assert "location" in response.headers + + def test_create_item_invalid_data(self, client): + """Test la création avec données invalides""" + # Prix négatif + response = client.post( + "/item/", + json={"name": "Test", "price": -10.0} + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + # Données manquantes + response = client.post( + "/item/", + json={"name": "Test"} # price manquant + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + def test_get_item_by_id_success(self, client, db_session): + """Test la récupération d'un item existant""" + # Crée un item d'abord + create_response = client.post("/item/", json={"name": "Get Test", "price": 50.0}) + item_id = create_response.json()["id"] + + # Récupère + response = client.get(f"/item/{item_id}") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Get Test" + assert data["price"] == 50.0 + assert data["id"] == item_id + + def test_get_item_by_id_not_found(self, client): + """Test la récupération d'un item inexistant""" + response = client.get("/item/9999") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not found" in response.json()["detail"].lower() + + def test_get_item_list(self, client, db_session): + """Test la liste des items avec filtres""" + # Crée quelques items + items_data = [ + {"name": "Item10", "price": 10.0}, + {"name": "Item30", "price": 30.0}, + {"name": "Item50", "price": 50.0} + ] + for item_data in items_data: + client.post("/item/", json=item_data) + + # Récupère tous + response = client.get("/item/") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) >= 3 + + # Avec filtre prix min + response = client.get("/item/?min_price=25.0") + assert response.status_code == status.HTTP_200_OK + data = response.json() + # Vérifie que tous les items ont prix >= 25 + for item in data: + assert item["price"] >= 25.0 + + def test_update_item_success(self, client, db_session): + """Test la mise à jour complète d'un item""" + # Crée un item + create_response = client.post("/item/", json={"name": "Original", "price": 10.0}) + item_id = create_response.json()["id"] + + # Met à jour + response = client.put( + f"/item/{item_id}", + json={"name": "Updated", "price": 20.0, "deleted": True} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Updated" + assert data["price"] == 20.0 + assert data["deleted"] is True + + def test_update_item_not_found(self, client): + """Test la mise à jour d'un item inexistant""" + response = client.put( + "/item/9999", + json={"name": "Test", "price": 10.0} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_patch_item_success(self, client, db_session): + """Test la mise à jour partielle""" + # Crée un item + create_response = client.post("/item/", json={"name": "Original", "price": 10.0}) + item_id = create_response.json()["id"] + + # Patch seulement le nom + response = client.patch( + f"/item/{item_id}", + json={"name": "Patched"} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Patched" + assert data["price"] == 10.0 # Inchangé + + def test_patch_item_not_found(self, client): + """Test le patch d'un item inexistant""" + response = client.patch("/item/9999", json={"name": "Test"}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_item_success(self, client, db_session): + """Test la suppression d'un item""" + # Crée un item + create_response = client.post("/item/", json={"name": "To Delete", "price": 10.0}) + item_id = create_response.json()["id"] + + # Supprime + response = client.delete(f"/item/{item_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Vérifie qu'il n'existe plus + get_response = client.get(f"/item/{item_id}") + assert get_response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/hw5/hw/transaction_demo.py b/hw5/hw/transaction_demo.py new file mode 100644 index 00000000..e00c6c7c --- /dev/null +++ b/hw5/hw/transaction_demo.py @@ -0,0 +1,355 @@ +import threading +import time +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from database import SessionLocal +from shop_api.item.store.models import ItemDB +from shop_api.cart.store.models import CartDB + +# Конфигурация базы данных +DATABASE_URL = "postgresql://postgres:password@postgres:5432/shop_db" +engine = create_engine(DATABASE_URL) +Session = sessionmaker(bind=engine) + +def setup_test_data(): + """Подготовка тестовых данных используя существующие модели""" + session = Session() + try: + # Очистка тестовых данных + session.query(ItemDB).filter(ItemDB.name.in_(["TestItem1", "TestItem2", "TestItem3"])).delete() + session.commit() + + # Создание тестовых товаров используя модель ItemDB + test_items = [ + ItemDB(name="TestItem1", price=100.0, deleted=False), + ItemDB(name="TestItem2", price=200.0, deleted=False), + ItemDB(name="TestItem3", price=300.0, deleted=False) + ] + + session.add_all(test_items) + session.commit() + print("Тестовые данные созданы с использованием существующих моделей") + + except Exception as e: + session.rollback() + print(f"Ошибка: {e}") + finally: + session.close() + +def dirty_read_demo(): + """Демонстрация Dirty Read (грязное чтение)""" + print("\n" + "="*50) + print("DIRTY READ - READ UNCOMMITTED") + print("="*50) + + def transaction1(): + """Транзакция, которая изменяет и откатывает""" + session = Session() + try: + print("Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита)") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + original_price = item.price + item.price = item.price + 50 + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price}") + + time.sleep(3) # Пауза для чтения Т2 + print("Транзакция 1: Делаю rollback!") + session.rollback() + + finally: + session.close() + + def transaction2(): + """Транзакция, которая читает незакоммиченные данные""" + session = Session() + try: + time.sleep(1) # Ждет изменения Т1 + print("Транзакция 2: Читаю цену TestItem1...") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + print(f"Транзакция 2: Вижу {item.price}€ -> DIRTY READ!") + + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t1.start() + t2.start() + t1.join() + t2.join() + +def no_dirty_read_demo(): + """Демонстрация отсутствия Dirty Read с READ COMMITTED""" + print("\n" + "="*50) + print("НЕТ DIRTY READ - READ COMMITTED") + print("="*50) + + def transaction1(): + session = Session() + try: + print("Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита)") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + original_price = item.price + item.price = item.price + 50 + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price}") + + time.sleep(3) + print("Транзакция 1: Делаю rollback!") + session.rollback() + + finally: + session.close() + + def transaction2(): + session = Session() + try: + time.sleep(1) + print("Транзакция 2: Читаю цену TestItem1...") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + print(f"Транзакция 2: Вижу {item.price}€ -> Чистые данные!") + + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t1.start() + t2.start() + t1.join() + t2.join() + +def non_repeatable_read_demo(): + """Демонстрация Non-Repeatable Read с READ COMMITTED""" + print("\n" + "="*50) + print("NON-REPEATABLE READ - READ COMMITTED") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Изменяю TestItem2 +100€ и коммит") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + original_price = item.price + item.price = item.price + 100 + session.commit() + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price} и закоммитил") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + # Первое чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price1 = item.price + print(f"Транзакция 2: Первое чтение -> {price1}€") + + time.sleep(2) # Ждет изменения Т1 + + # Второе чтение + session.expire_all() # Сбрасываем кэш + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price2 = item.price + print(f"Транзакция 2: Второе чтение -> {price2}€") + + if price1 != price2: + print("NON-REPEATABLE READ обнаружен!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def no_non_repeatable_read_demo(): + """Демонстрация отсутствия Non-Repeatable Read с REPEATABLE READ""" + print("\n" + "="*50) + print("НЕТ NON-REPEATABLE READ - REPEATABLE READ") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Изменяю TestItem2 +150€ и коммит") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + original_price = item.price + item.price = item.price + 150 + session.commit() + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price} и закоммитил") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + + # Первое чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price1 = item.price + print(f"Транзакция 2: Первое чтение -> {price1}€") + + time.sleep(2) # Ждет изменения Т1 + + # Второе чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price2 = item.price + print(f"Транзакция 2: Второе чтение -> {price2}€") + + if price1 == price2: + print("Нет Non-Repeatable Read с REPEATABLE READ!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def phantom_read_demo(): + """Демонстрация Phantom Read с REPEATABLE READ""" + print("\n" + "="*50) + print("PHANTOM READ - REPEATABLE READ") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Добавляю новый товар 'PhantomItem'") + + new_item = ItemDB(name="PhantomItem", price=400.0, deleted=False) + session.add(new_item) + session.commit() + print("Транзакция 1: Новый товар добавлен и закоммичен!") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + + # Первое чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count1 = len(items) + print(f"Транзакция 2: Первый подсчет -> {count1} товаров") + + time.sleep(2) # Ждет добавления Т1 + + # Второе чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count2 = len(items) + print(f"Транзакция 2: Второй подсчет -> {count2} товаров") + + if count1 != count2: + print("PHANTOM READ обнаружен!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def no_phantom_read_demo(): + """Демонстрация отсутствия Phantom Read с SERIALIZABLE""" + print("\n" + "="*50) + print("НЕТ PHANTOM READ - SERIALIZABLE") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Добавляю новый товар 'SerializableItem'") + + new_item = ItemDB(name="SerializableItem", price=500.0, deleted=False) + session.add(new_item) + session.commit() + print("Транзакция 1: Новый товар добавлен и закоммичен!") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + + # Первое чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count1 = len(items) + print(f"Транзакция 2: Первый подсчет -> {count1} товаров") + + time.sleep(2) # Ждет добавления Т1 + + # Второе чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count2 = len(items) + print(f"Транзакция 2: Второй подсчет -> {count2} товаров") + + if count1 == count2: + print("Нет Phantom Read с SERIALIZABLE!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +if __name__ == "__main__": + print("ДЕМОНСТРАЦИЯ ПРОБЛЕМ ТРАНЗАКЦИЙ") + print("База данных: PostgreSQL с SQLAlchemy") + print("Используются существующие модели ItemDB") + + # Подготовка + setup_test_data() + + # Демонстрации + dirty_read_demo() # 1. Dirty Read с READ UNCOMMITTED + no_dirty_read_demo() # 2. Нет Dirty Read с READ COMMITTED + non_repeatable_read_demo() # 3. Non-repeatable Read с READ COMMITTED + no_non_repeatable_read_demo() # 4. Нет Non-repeatable Read с REPEATABLE READ + phantom_read_demo() # 5. Phantom Read с REPEATABLE READ + no_phantom_read_demo() # 6. Нет Phantom Read с SERIALIZABLE + + print("\n" + "="*50) + print("ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА!") + print("="*50) \ No newline at end of file