From d48cff1aed966e08deb8d45dd589e8fde0684f2a Mon Sep 17 00:00:00 2001 From: User Y1OV Date: Sun, 28 Sep 2025 22:31:29 +0300 Subject: [PATCH 1/7] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) 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 From bcec17caa0cf7337519608d46ca75c56861d9074 Mon Sep 17 00:00:00 2001 From: User Y1OV Date: Sun, 12 Oct 2025 21:37:06 +0300 Subject: [PATCH 2/7] hw3 --- lecture3/Dockerfile | 10 +++---- lecture3/demo_service/api.py | 44 ++++------------------------- lecture3/demo_service/contracts.py | 18 ------------ lecture3/demo_service/store.py | 27 ------------------ lecture3/docker-compose.yml | 10 +++++++ lecture3/photo/1.jpg | Bin 0 -> 38180 bytes lecture3/photo/2.jpg | Bin 0 -> 21133 bytes lecture3/requirements.txt | 8 ++++-- 8 files changed, 26 insertions(+), 91 deletions(-) delete mode 100644 lecture3/demo_service/contracts.py delete mode 100644 lecture3/demo_service/store.py create mode 100644 lecture3/photo/1.jpg create mode 100644 lecture3/photo/2.jpg 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 0000000000000000000000000000000000000000..fa3fa8add44ccee92be22c4a374e4d416b0d2baf GIT binary patch literal 38180 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 literal 0 HcmV?d00001 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 From 45cd20e9463295560345ed12c688e024c229154e Mon Sep 17 00:00:00 2001 From: User Y1OV Date: Sun, 26 Oct 2025 21:13:03 +0300 Subject: [PATCH 3/7] hw4 --- lecture4/hw4_stepa/Dockerfile | 10 ++ lecture4/hw4_stepa/README.md | 20 +++ lecture4/hw4_stepa/api/__init__.py | 0 lecture4/hw4_stepa/api/data/__init__.py | 0 lecture4/hw4_stepa/api/data/db_setup.py | 20 +++ lecture4/hw4_stepa/api/data/models.py | 27 +++ lecture4/hw4_stepa/api/main.py | 19 +++ lecture4/hw4_stepa/api/routes.py | 117 +++++++++++++ lecture4/hw4_stepa/api/schemas.py | 24 +++ lecture4/hw4_stepa/api/services.py | 214 ++++++++++++++++++++++++ lecture4/hw4_stepa/database/init.sql | 36 ++++ lecture4/hw4_stepa/docker-compose.yml | 21 +++ lecture4/hw4_stepa/requirements.txt | 6 + lecture4/hw4_stepa/test_api.sh | 68 ++++++++ lecture4/hw4_stepa/test_isolation.py | 119 +++++++++++++ 15 files changed, 701 insertions(+) create mode 100644 lecture4/hw4_stepa/Dockerfile create mode 100644 lecture4/hw4_stepa/README.md create mode 100644 lecture4/hw4_stepa/api/__init__.py create mode 100644 lecture4/hw4_stepa/api/data/__init__.py create mode 100644 lecture4/hw4_stepa/api/data/db_setup.py create mode 100644 lecture4/hw4_stepa/api/data/models.py create mode 100644 lecture4/hw4_stepa/api/main.py create mode 100644 lecture4/hw4_stepa/api/routes.py create mode 100644 lecture4/hw4_stepa/api/schemas.py create mode 100644 lecture4/hw4_stepa/api/services.py create mode 100644 lecture4/hw4_stepa/database/init.sql create mode 100644 lecture4/hw4_stepa/docker-compose.yml create mode 100644 lecture4/hw4_stepa/requirements.txt create mode 100644 lecture4/hw4_stepa/test_api.sh create mode 100644 lecture4/hw4_stepa/test_isolation.py 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/lecture4/hw4_stepa/api/__init__.py b/lecture4/hw4_stepa/api/__init__.py new file mode 100644 index 00000000..e69de29b 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") From 6ac1305f2d51ad65b6bba11f51efce32efa80921 Mon Sep 17 00:00:00 2001 From: User Y1OV Date: Sun, 26 Oct 2025 22:18:34 +0300 Subject: [PATCH 4/7] hw5 --- hw2/hw/.coveragerc | 24 ++++++ hw2/hw/__init__.py | 0 hw2/hw/pytest.ini | 17 ++++ hw2/hw/shop_api/main.py | 175 +++++++++++++++++++++++++++++++++++++++- hw5.yml | 30 +++++++ 5 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 hw2/hw/.coveragerc delete mode 100644 hw2/hw/__init__.py create mode 100644 hw2/hw/pytest.ini create mode 100644 hw5.yml 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/__init__.py b/hw2/hw/__init__.py deleted file mode 100644 index e69de29b..00000000 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/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/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 From ff4bbda28281b8ec15a08715716969adf9e11c52 Mon Sep 17 00:00:00 2001 From: User Y1OV Date: Sun, 26 Oct 2025 22:58:08 +0300 Subject: [PATCH 5/7] fix for hw5 --- hw2/hw/test_homework2.py | 341 +++++++++++++++++---------------------- 1 file changed, 144 insertions(+), 197 deletions(-) diff --git a/hw2/hw/test_homework2.py b/hw2/hw/test_homework2.py index 60a1f36a..08bbecc4 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,130 @@ 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 - - -@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) + 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( - ("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}) + 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}) + 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}) + 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 From b430a349f2020fe9e34e9201999c3a19892edf32 Mon Sep 17 00:00:00 2001 From: User Y1OV Date: Sun, 26 Oct 2025 23:02:05 +0300 Subject: [PATCH 6/7] fix for hw5 --- hw2/hw/test_homework2.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hw2/hw/test_homework2.py b/hw2/hw/test_homework2.py index 08bbecc4..718a602a 100644 --- a/hw2/hw/test_homework2.py +++ b/hw2/hw/test_homework2.py @@ -211,17 +211,17 @@ def test_list_items_filters_and_pagination(): 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}) + 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}) + 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}) + 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 @@ -229,3 +229,4 @@ def test_list_items_filters_and_pagination(): r = client.get("/item", params={"show_deleted": True, "offset": 0, "limit": 1}) assert r.status_code == HTTPStatus.OK assert len(r.json()) == 1 + From 2000ad067a7c5b4b7eea096b948eced95a2bcca6 Mon Sep 17 00:00:00 2001 From: User Y1OV Date: Sun, 26 Oct 2025 23:06:38 +0300 Subject: [PATCH 7/7] fix for hw5 --- hw2/hw/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) 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