diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 0000000..997a5ae --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,36 @@ +name: "HW5 Tests" + +on: + pull_request: + branches: [ main ] + paths: [ 'lecture5/hw/**', '.github/workflows/hw5-tests.yml' ] + push: + branches: [ main ] + paths: [ 'lecture5/hw/**', '.github/workflows/hw5-tests.yml' ] + +jobs: + test-hw5: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13", "3.14"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: lecture5/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + working-directory: lecture5 + run: | + pytest hw -v --cov=hw.shop_api --cov-report=term-missing --cov-fail-under=95 diff --git a/hw1/app.py b/hw1/app.py index 6107b87..bdeec2f 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,34 @@ +import json +from http import HTTPStatus from typing import Any, Awaitable, Callable +from urllib.parse import parse_qs + + +async def send_json(send, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload).encode("utf-8") + await send({ + "type": "http.response.start", + "status": status, + "headers": [(b"content-type", b"application/json")], + }) + await send({ + "type": "http.response.body", + "body": body, + }) + + +def factorial(n: int) -> int: + result = 1 + for i in range(2, n + 1): + result *= i + return result + + +def fibonacci(n: int) -> int: + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a async def application( @@ -12,7 +42,71 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + method = scope["method"] + path = scope["path"] + + if method != "GET": + await send_json(send, HTTPStatus.NOT_FOUND, {"error": "not found"}) + return + + if path == "/factorial": + query = parse_qs(scope["query_string"].decode()) + n_values = query.get("n") + if not n_values: + await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "n is required"}) + return + try: + n = int(n_values[0]) + except ValueError: + await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "n must be an integer"}) + return + if n < 0: + await send_json(send, HTTPStatus.BAD_REQUEST, {"error": "n must be non-negative"}) + return + await send_json(send, HTTPStatus.OK, {"result": factorial(n)}) + return + + if path.startswith("/fibonacci/"): + n_str = path.removeprefix("/fibonacci/") + try: + n = int(n_str) + except ValueError: + await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "n must be an integer"}) + return + if n < 0: + await send_json(send, HTTPStatus.BAD_REQUEST, {"error": "n must be non-negative"}) + return + await send_json(send, HTTPStatus.OK, {"result": fibonacci(n)}) + return + + if path == "/mean": + message = await receive() + body = message.get("body", b"") + try: + numbers = json.loads(body) + except ValueError: + await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "valid JSON body is required"}) + return + if not isinstance(numbers, list) or not all(isinstance(x, (int, float)) for x in numbers): + await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "body must be a list of numbers"}) + return + if not numbers: + await send_json(send, HTTPStatus.BAD_REQUEST, {"error": "list must not be empty"}) + return + await send_json(send, HTTPStatus.OK, {"result": sum(numbers) / len(numbers)}) + return + + await send_json(send, HTTPStatus.NOT_FOUND, {"error": "not found"}) + if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c6..165d884 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,193 @@ -from fastapi import FastAPI +from http import HTTPStatus +from itertools import count + +from fastapi import FastAPI, HTTPException, Query, Response +from pydantic import BaseModel, ConfigDict app = FastAPI(title="Shop API") + +items_db: dict[int, "Item"] = {} +carts_db: dict[int, dict[int, int]] = {} + +item_id_seq = count(1) +cart_id_seq = count(1) + + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + + +class ItemCreate(BaseModel): + name: str + price: float + + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = None + price: float | None = None + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: list[CartItem] + price: float + + +def build_cart(cart_id: int) -> Cart: + cart_items = [] + price = 0.0 + + for item_id, quantity in carts_db[cart_id].items(): + item = items_db[item_id] + price += item.price * quantity + cart_items.append( + CartItem( + id=item.id, + name=item.name, + quantity=quantity, + available=not item.deleted, + ) + ) + + return Cart(id=cart_id, items=cart_items, price=price) + + +@app.post("/cart", status_code=HTTPStatus.CREATED) +def create_cart(response: Response): + cart_id = next(cart_id_seq) + carts_db[cart_id] = {} + response.headers["location"] = f"/cart/{cart_id}" + return {"id": cart_id} + + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int) -> Cart: + if cart_id not in carts_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + return build_cart(cart_id) + + +@app.get("/cart") +def get_cart_list( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: float | None = Query(None, ge=0), + max_price: float | None = Query(None, ge=0), + min_quantity: int | None = Query(None, ge=0), + max_quantity: int | None = Query(None, ge=0), +) -> list[Cart]: + carts = [build_cart(cart_id) for cart_id in carts_db] + + if min_price is not None: + carts = [c for c in carts if c.price >= min_price] + if max_price is not None: + carts = [c for c in carts if c.price <= max_price] + + if min_quantity is not None: + carts = [c for c in carts if sum(i.quantity for i in c.items) >= min_quantity] + if max_quantity is not None: + carts = [c for c in carts if sum(i.quantity for i in c.items) <= max_quantity] + + return carts[offset : offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int) -> Cart: + if cart_id not in carts_db or item_id not in items_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + cart = carts_db[cart_id] + cart[item_id] = cart.get(item_id, 0) + 1 + + return build_cart(cart_id) + + +@app.post("/item", status_code=HTTPStatus.CREATED) +def create_item(body: ItemCreate) -> Item: + item_id = next(item_id_seq) + item = Item(id=item_id, name=body.name, price=body.price) + items_db[item_id] = item + + return item + + +@app.get("/item/{item_id}") +def get_item(item_id: int) -> Item: + item = items_db.get(item_id) + + if item is None or item.deleted: + raise HTTPException(HTTPStatus.NOT_FOUND) + + return item + + +@app.get("/item") +def get_item_list( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: float | None = Query(None, ge=0), + max_price: float | None = Query(None, ge=0), + show_deleted: bool = False, +) -> list[Item]: + items = list(items_db.values()) + + if not show_deleted: + items = [i for i in items if not i.deleted] + + if min_price is not None: + items = [i for i in items if i.price >= min_price] + if max_price is not None: + items = [i for i in items if i.price <= max_price] + + return items[offset : offset + limit] + + +@app.put("/item/{item_id}") +def put_item(item_id: int, body: ItemCreate) -> Item: + if item_id not in items_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + item = items_db[item_id] + item.name = body.name + item.price = body.price + + return item + + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, body: ItemPatch): + if item_id not in items_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + item = items_db[item_id] + + if item.deleted: + return Response(status_code=HTTPStatus.NOT_MODIFIED) + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(item, field, value) + + return item + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int): + item = items_db.get(item_id) + + if item is not None: + item.deleted = True + + return {"id": item_id, "deleted": True} diff --git a/lecture3/Dockerfile b/lecture3/Dockerfile index 1eaf1db..27b0b63 100644 --- a/lecture3/Dockerfile +++ b/lecture3/Dockerfile @@ -20,4 +20,4 @@ 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", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/lecture3/shop_api/__init__.py b/lecture3/shop_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lecture3/shop_api/main.py b/lecture3/shop_api/main.py new file mode 100644 index 0000000..047e506 --- /dev/null +++ b/lecture3/shop_api/main.py @@ -0,0 +1,195 @@ +from http import HTTPStatus +from itertools import count + +from fastapi import FastAPI, HTTPException, Query, Response +from prometheus_fastapi_instrumentator import Instrumentator +from pydantic import BaseModel, ConfigDict + +app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +items_db: dict[int, "Item"] = {} +carts_db: dict[int, dict[int, int]] = {} + +item_id_seq = count(1) +cart_id_seq = count(1) + + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + + +class ItemCreate(BaseModel): + name: str + price: float + + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = None + price: float | None = None + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: list[CartItem] + price: float + + +def build_cart(cart_id: int) -> Cart: + cart_items = [] + price = 0.0 + + for item_id, quantity in carts_db[cart_id].items(): + item = items_db[item_id] + price += item.price * quantity + cart_items.append( + CartItem( + id=item.id, + name=item.name, + quantity=quantity, + available=not item.deleted, + ) + ) + + return Cart(id=cart_id, items=cart_items, price=price) + + +@app.post("/cart", status_code=HTTPStatus.CREATED) +def create_cart(response: Response): + cart_id = next(cart_id_seq) + carts_db[cart_id] = {} + response.headers["location"] = f"/cart/{cart_id}" + return {"id": cart_id} + + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int) -> Cart: + if cart_id not in carts_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + return build_cart(cart_id) + + +@app.get("/cart") +def get_cart_list( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: float | None = Query(None, ge=0), + max_price: float | None = Query(None, ge=0), + min_quantity: int | None = Query(None, ge=0), + max_quantity: int | None = Query(None, ge=0), +) -> list[Cart]: + carts = [build_cart(cart_id) for cart_id in carts_db] + + if min_price is not None: + carts = [c for c in carts if c.price >= min_price] + if max_price is not None: + carts = [c for c in carts if c.price <= max_price] + + if min_quantity is not None: + carts = [c for c in carts if sum(i.quantity for i in c.items) >= min_quantity] + if max_quantity is not None: + carts = [c for c in carts if sum(i.quantity for i in c.items) <= max_quantity] + + return carts[offset : offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int) -> Cart: + if cart_id not in carts_db or item_id not in items_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + cart = carts_db[cart_id] + cart[item_id] = cart.get(item_id, 0) + 1 + + return build_cart(cart_id) + + +@app.post("/item", status_code=HTTPStatus.CREATED) +def create_item(body: ItemCreate) -> Item: + item_id = next(item_id_seq) + item = Item(id=item_id, name=body.name, price=body.price) + items_db[item_id] = item + + return item + + +@app.get("/item/{item_id}") +def get_item(item_id: int) -> Item: + item = items_db.get(item_id) + + if item is None or item.deleted: + raise HTTPException(HTTPStatus.NOT_FOUND) + + return item + + +@app.get("/item") +def get_item_list( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: float | None = Query(None, ge=0), + max_price: float | None = Query(None, ge=0), + show_deleted: bool = False, +) -> list[Item]: + items = list(items_db.values()) + + if not show_deleted: + items = [i for i in items if not i.deleted] + + if min_price is not None: + items = [i for i in items if i.price >= min_price] + if max_price is not None: + items = [i for i in items if i.price <= max_price] + + return items[offset : offset + limit] + + +@app.put("/item/{item_id}") +def put_item(item_id: int, body: ItemCreate) -> Item: + if item_id not in items_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + item = items_db[item_id] + item.name = body.name + item.price = body.price + + return item + + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, body: ItemPatch): + if item_id not in items_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + item = items_db[item_id] + + if item.deleted: + return Response(status_code=HTTPStatus.NOT_MODIFIED) + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(item, field, value) + + return item + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int): + item = items_db.get(item_id) + + if item is not None: + item.deleted = True + + return {"id": item_id, "deleted": True} diff --git a/lecture4/docker-compose.yml b/lecture4/docker-compose.yml index 3cbf5d7..069542c 100644 --- a/lecture4/docker-compose.yml +++ b/lecture4/docker-compose.yml @@ -19,6 +19,17 @@ services: timeout: 5s retries: 5 + mysql: + image: mysql:8 + container_name: hw4_mysql + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: hw4_db + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + edgedb: image: edgedb/edgedb:5 container_name: hw4_edgedb @@ -31,4 +42,5 @@ services: volumes: postgres_data: + mysql_data: edgedb_data: diff --git a/lecture4/hw/isolation.py b/lecture4/hw/isolation.py new file mode 100644 index 0000000..c769923 --- /dev/null +++ b/lecture4/hw/isolation.py @@ -0,0 +1,82 @@ +import os + +from sqlalchemy import create_engine, text +from sqlalchemy.exc import OperationalError + +engine = create_engine( + os.getenv("DATABASE_URL", "mysql+pymysql://root:password@localhost:3307/hw4_db") +) + +SELECT_BALANCE = text("SELECT balance FROM accounts WHERE id = 1") +COUNT_ROWS = text("SELECT COUNT(*) FROM accounts") + + +def reset(): + with engine.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS accounts")) + conn.execute(text("CREATE TABLE accounts (id INT AUTO_INCREMENT PRIMARY KEY, balance INT)")) + conn.execute(text("INSERT INTO accounts (balance) VALUES (100), (200)")) + + +def connect(level): + return engine.connect().execution_options(isolation_level=level) + + +def dirty_read(level): + reset() + with connect(level) as a, connect(level) as b: + a.execute(text("UPDATE accounts SET balance = 0 WHERE id = 1")) + print(f"{level}: B видит баланс {b.execute(SELECT_BALANCE).scalar()} (A ещё не сделал commit, было 100)") + a.rollback() + + +def non_repeatable_read(level): + reset() + with connect(level) as a, connect(level) as b: + print(f"{level}: A читает баланс {a.execute(SELECT_BALANCE).scalar()}") + b.execute(text("UPDATE accounts SET balance = 500 WHERE id = 1")) + b.commit() + print(f"{level}: A читает снова {a.execute(SELECT_BALANCE).scalar()} (B поменял на 500 и сделал commit)") + + +def phantom_read(level): + reset() + with connect(level) as a, connect(level) as b: + print(f"{level}: A считает строки: {a.execute(COUNT_ROWS).scalar()}") + b.execute(text("INSERT INTO accounts (balance) VALUES (300)")) + b.commit() + print(f"{level}: A считает снова: {a.execute(COUNT_ROWS).scalar()} (B добавил строку и сделал commit)") + a.execute(text("UPDATE accounts SET balance = balance + 1")) + print(f"{level}: A обновил все строки и считает: {a.execute(COUNT_ROWS).scalar()}") + + +def phantom_read_serializable(level): + reset() + with connect(level) as a, connect(level) as b: + print(f"{level}: A считает строки: {a.execute(COUNT_ROWS).scalar()}") + b.execute(text("SET SESSION innodb_lock_wait_timeout = 2")) + try: + b.execute(text("INSERT INTO accounts (balance) VALUES (300)")) + print(f"{level}: B вставил строку, фантом возможен") + except OperationalError: + print(f"{level}: B не смог вставить строку, пока A не закончил (фантома нет)") + print(f"{level}: A считает снова: {a.execute(COUNT_ROWS).scalar()}") + + +print("1. Dirty read есть при READ UNCOMMITTED") +dirty_read("READ UNCOMMITTED") + +print("\n2. Dirty read нет при READ COMMITTED") +dirty_read("READ COMMITTED") + +print("\n3. Non-repeatable read есть при READ COMMITTED") +non_repeatable_read("READ COMMITTED") + +print("\n4. Non-repeatable read нет при REPEATABLE READ") +non_repeatable_read("REPEATABLE READ") + +print("\n5. Phantom read есть при REPEATABLE READ") +phantom_read("REPEATABLE READ") + +print("\n6. Phantom read нет при SERIALIZABLE") +phantom_read_serializable("SERIALIZABLE") diff --git a/lecture4/hw/requirements.txt b/lecture4/hw/requirements.txt new file mode 100644 index 0000000..adffd81 --- /dev/null +++ b/lecture4/hw/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +sqlalchemy +pymysql +cryptography diff --git a/lecture4/hw/shop_api/__init__.py b/lecture4/hw/shop_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lecture4/hw/shop_api/main.py b/lecture4/hw/shop_api/main.py new file mode 100644 index 0000000..3ba5d3b --- /dev/null +++ b/lecture4/hw/shop_api/main.py @@ -0,0 +1,242 @@ +import os +from http import HTTPStatus + +from fastapi import FastAPI, HTTPException, Query, Response +from pydantic import BaseModel, ConfigDict +from sqlalchemy import Boolean, Float, ForeignKey, String, create_engine, func, select +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column + +engine = create_engine( + os.getenv("DATABASE_URL", "mysql+pymysql://root:password@localhost:3307/hw4_db") +) + + +class Base(DeclarativeBase): + pass + + +class ItemDb(Base): + __tablename__ = "shop_items" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255)) + price: Mapped[float] = mapped_column(Float) + deleted: Mapped[bool] = mapped_column(Boolean, default=False) + + +class CartDb(Base): + __tablename__ = "shop_carts" + + id: Mapped[int] = mapped_column(primary_key=True) + + +class CartItemDb(Base): + __tablename__ = "shop_cart_items" + + cart_id: Mapped[int] = mapped_column(ForeignKey("shop_carts.id"), primary_key=True) + item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id"), primary_key=True) + quantity: Mapped[int] = mapped_column(default=0) + + +Base.metadata.create_all(engine) + +app = FastAPI(title="Shop API") + + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + + +class ItemCreate(BaseModel): + name: str + price: float + + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = None + price: float | None = None + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: list[CartItem] + price: float + + +def to_item(item: ItemDb) -> Item: + return Item(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + +def build_cart(session: Session, cart_id: int) -> Cart: + rows = session.execute( + select(ItemDb, CartItemDb.quantity) + .join(CartItemDb, CartItemDb.item_id == ItemDb.id) + .where(CartItemDb.cart_id == cart_id) + ).all() + + items = [ + CartItem(id=i.id, name=i.name, quantity=q, available=not i.deleted) + for i, q in rows + ] + price = sum(i.price * q for i, q in rows) + + return Cart(id=cart_id, items=items, price=price) + + +@app.post("/cart", status_code=HTTPStatus.CREATED) +def create_cart(response: Response): + with Session(engine) as session: + cart = CartDb() + session.add(cart) + session.commit() + response.headers["location"] = f"/cart/{cart.id}" + return {"id": cart.id} + + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int) -> Cart: + with Session(engine) as session: + if session.get(CartDb, cart_id) is None: + raise HTTPException(HTTPStatus.NOT_FOUND) + + return build_cart(session, cart_id) + + +@app.get("/cart") +def get_cart_list( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: float | None = Query(None, ge=0), + max_price: float | None = Query(None, ge=0), + min_quantity: int | None = Query(None, ge=0), + max_quantity: int | None = Query(None, ge=0), +) -> list[Cart]: + with Session(engine) as session: + cart_ids = session.scalars(select(CartDb.id).order_by(CartDb.id)).all() + carts = [build_cart(session, cart_id) for cart_id in cart_ids] + + if min_price is not None: + carts = [c for c in carts if c.price >= min_price] + if max_price is not None: + carts = [c for c in carts if c.price <= max_price] + + if min_quantity is not None: + carts = [c for c in carts if sum(i.quantity for i in c.items) >= min_quantity] + if max_quantity is not None: + carts = [c for c in carts if sum(i.quantity for i in c.items) <= max_quantity] + + return carts[offset : offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int) -> Cart: + with Session(engine) as session: + if session.get(CartDb, cart_id) is None or session.get(ItemDb, item_id) is None: + raise HTTPException(HTTPStatus.NOT_FOUND) + + cart_item = session.get(CartItemDb, (cart_id, item_id)) + if cart_item is None: + session.add(CartItemDb(cart_id=cart_id, item_id=item_id, quantity=1)) + else: + cart_item.quantity += 1 + session.commit() + + return build_cart(session, cart_id) + + +@app.post("/item", status_code=HTTPStatus.CREATED) +def create_item(body: ItemCreate) -> Item: + with Session(engine) as session: + item = ItemDb(name=body.name, price=body.price, deleted=False) + session.add(item) + session.commit() + + return to_item(item) + + +@app.get("/item/{item_id}") +def get_item(item_id: int) -> Item: + with Session(engine) as session: + item = session.get(ItemDb, item_id) + + if item is None or item.deleted: + raise HTTPException(HTTPStatus.NOT_FOUND) + + return to_item(item) + + +@app.get("/item") +def get_item_list( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: float | None = Query(None, ge=0), + max_price: float | None = Query(None, ge=0), + show_deleted: bool = False, +) -> list[Item]: + query = select(ItemDb).order_by(ItemDb.id) + + if not show_deleted: + query = query.where(ItemDb.deleted == False) + if min_price is not None: + query = query.where(ItemDb.price >= min_price) + if max_price is not None: + query = query.where(ItemDb.price <= max_price) + + with Session(engine) as session: + items = session.scalars(query.offset(offset).limit(limit)).all() + return [to_item(i) for i in items] + + +@app.put("/item/{item_id}") +def put_item(item_id: int, body: ItemCreate) -> Item: + with Session(engine) as session: + item = session.get(ItemDb, item_id) + if item is None: + raise HTTPException(HTTPStatus.NOT_FOUND) + + item.name = body.name + item.price = body.price + session.commit() + + return to_item(item) + + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, body: ItemPatch): + with Session(engine) as session: + item = session.get(ItemDb, item_id) + if item is None: + raise HTTPException(HTTPStatus.NOT_FOUND) + + if item.deleted: + return Response(status_code=HTTPStatus.NOT_MODIFIED) + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(item, field, value) + session.commit() + + return to_item(item) + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int): + with Session(engine) as session: + item = session.get(ItemDb, item_id) + + if item is not None: + item.deleted = True + session.commit() + + return {"id": item_id, "deleted": True} diff --git a/lecture5/hw/requirements.txt b/lecture5/hw/requirements.txt new file mode 100644 index 0000000..0d24e07 --- /dev/null +++ b/lecture5/hw/requirements.txt @@ -0,0 +1,4 @@ +fastapi +httpx +pytest +pytest-cov diff --git a/lecture5/hw/shop_api/__init__.py b/lecture5/hw/shop_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lecture5/hw/shop_api/main.py b/lecture5/hw/shop_api/main.py new file mode 100644 index 0000000..165d884 --- /dev/null +++ b/lecture5/hw/shop_api/main.py @@ -0,0 +1,193 @@ +from http import HTTPStatus +from itertools import count + +from fastapi import FastAPI, HTTPException, Query, Response +from pydantic import BaseModel, ConfigDict + +app = FastAPI(title="Shop API") + +items_db: dict[int, "Item"] = {} +carts_db: dict[int, dict[int, int]] = {} + +item_id_seq = count(1) +cart_id_seq = count(1) + + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + + +class ItemCreate(BaseModel): + name: str + price: float + + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = None + price: float | None = None + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: list[CartItem] + price: float + + +def build_cart(cart_id: int) -> Cart: + cart_items = [] + price = 0.0 + + for item_id, quantity in carts_db[cart_id].items(): + item = items_db[item_id] + price += item.price * quantity + cart_items.append( + CartItem( + id=item.id, + name=item.name, + quantity=quantity, + available=not item.deleted, + ) + ) + + return Cart(id=cart_id, items=cart_items, price=price) + + +@app.post("/cart", status_code=HTTPStatus.CREATED) +def create_cart(response: Response): + cart_id = next(cart_id_seq) + carts_db[cart_id] = {} + response.headers["location"] = f"/cart/{cart_id}" + return {"id": cart_id} + + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int) -> Cart: + if cart_id not in carts_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + return build_cart(cart_id) + + +@app.get("/cart") +def get_cart_list( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: float | None = Query(None, ge=0), + max_price: float | None = Query(None, ge=0), + min_quantity: int | None = Query(None, ge=0), + max_quantity: int | None = Query(None, ge=0), +) -> list[Cart]: + carts = [build_cart(cart_id) for cart_id in carts_db] + + if min_price is not None: + carts = [c for c in carts if c.price >= min_price] + if max_price is not None: + carts = [c for c in carts if c.price <= max_price] + + if min_quantity is not None: + carts = [c for c in carts if sum(i.quantity for i in c.items) >= min_quantity] + if max_quantity is not None: + carts = [c for c in carts if sum(i.quantity for i in c.items) <= max_quantity] + + return carts[offset : offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int) -> Cart: + if cart_id not in carts_db or item_id not in items_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + cart = carts_db[cart_id] + cart[item_id] = cart.get(item_id, 0) + 1 + + return build_cart(cart_id) + + +@app.post("/item", status_code=HTTPStatus.CREATED) +def create_item(body: ItemCreate) -> Item: + item_id = next(item_id_seq) + item = Item(id=item_id, name=body.name, price=body.price) + items_db[item_id] = item + + return item + + +@app.get("/item/{item_id}") +def get_item(item_id: int) -> Item: + item = items_db.get(item_id) + + if item is None or item.deleted: + raise HTTPException(HTTPStatus.NOT_FOUND) + + return item + + +@app.get("/item") +def get_item_list( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: float | None = Query(None, ge=0), + max_price: float | None = Query(None, ge=0), + show_deleted: bool = False, +) -> list[Item]: + items = list(items_db.values()) + + if not show_deleted: + items = [i for i in items if not i.deleted] + + if min_price is not None: + items = [i for i in items if i.price >= min_price] + if max_price is not None: + items = [i for i in items if i.price <= max_price] + + return items[offset : offset + limit] + + +@app.put("/item/{item_id}") +def put_item(item_id: int, body: ItemCreate) -> Item: + if item_id not in items_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + item = items_db[item_id] + item.name = body.name + item.price = body.price + + return item + + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, body: ItemPatch): + if item_id not in items_db: + raise HTTPException(HTTPStatus.NOT_FOUND) + + item = items_db[item_id] + + if item.deleted: + return Response(status_code=HTTPStatus.NOT_MODIFIED) + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(item, field, value) + + return item + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int): + item = items_db.get(item_id) + + if item is not None: + item.deleted = True + + return {"id": item_id, "deleted": True} diff --git a/lecture5/hw/test_shop.py b/lecture5/hw/test_shop.py new file mode 100644 index 0000000..6de762e --- /dev/null +++ b/lecture5/hw/test_shop.py @@ -0,0 +1,232 @@ +import pytest +from fastapi.testclient import TestClient + +from hw.shop_api.main import app, carts_db, items_db + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def clean_db(): + items_db.clear() + carts_db.clear() + + +def create_item(name="milk", price=100.0): + return client.post("/item", json={"name": name, "price": price}).json() + + +def create_cart(): + return client.post("/cart").json()["id"] + + +def test_create_cart(): + response = client.post("/cart") + + assert response.status_code == 201 + assert response.headers["location"] == f"/cart/{response.json()['id']}" + + +def test_get_empty_cart(): + cart_id = create_cart() + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == 200 + assert response.json() == {"id": cart_id, "items": [], "price": 0.0} + + +def test_get_cart_not_found(): + response = client.get("/cart/999") + + assert response.status_code == 404 + + +def test_add_item_to_cart_twice(): + item = create_item(price=100.0) + cart_id = create_cart() + + client.post(f"/cart/{cart_id}/add/{item['id']}") + response = client.post(f"/cart/{cart_id}/add/{item['id']}") + + assert response.status_code == 200 + assert response.json()["price"] == 200.0 + assert response.json()["items"] == [ + {"id": item["id"], "name": "milk", "quantity": 2, "available": True} + ] + + +def test_add_item_to_missing_cart(): + item = create_item() + + response = client.post(f"/cart/999/add/{item['id']}") + + assert response.status_code == 404 + + +def test_add_missing_item_to_cart(): + cart_id = create_cart() + + response = client.post(f"/cart/{cart_id}/add/999") + + assert response.status_code == 404 + + +def test_deleted_item_is_not_available_in_cart(): + item = create_item() + cart_id = create_cart() + client.post(f"/cart/{cart_id}/add/{item['id']}") + + client.delete(f"/item/{item['id']}") + response = client.get(f"/cart/{cart_id}") + + assert response.json()["items"][0]["available"] is False + + +def test_get_cart_list_with_filters(): + item = create_item(price=100.0) + create_cart() + cart_with_one = create_cart() + cart_with_two = create_cart() + client.post(f"/cart/{cart_with_one}/add/{item['id']}") + client.post(f"/cart/{cart_with_two}/add/{item['id']}") + client.post(f"/cart/{cart_with_two}/add/{item['id']}") + + assert len(client.get("/cart").json()) == 3 + assert len(client.get("/cart", params={"limit": 1}).json()) == 1 + assert len(client.get("/cart", params={"offset": 2}).json()) == 1 + assert len(client.get("/cart", params={"min_price": 100}).json()) == 2 + assert len(client.get("/cart", params={"max_price": 100}).json()) == 2 + assert len(client.get("/cart", params={"min_quantity": 2}).json()) == 1 + assert len(client.get("/cart", params={"max_quantity": 1}).json()) == 2 + + +def test_get_cart_list_wrong_params(): + assert client.get("/cart", params={"offset": -1}).status_code == 422 + assert client.get("/cart", params={"limit": 0}).status_code == 422 + assert client.get("/cart", params={"min_price": -1}).status_code == 422 + assert client.get("/cart", params={"max_quantity": -1}).status_code == 422 + + +def test_create_item(): + response = client.post("/item", json={"name": "milk", "price": 99.9}) + + assert response.status_code == 201 + assert response.json()["name"] == "milk" + assert response.json()["price"] == 99.9 + assert response.json()["deleted"] is False + + +def test_get_item(): + item = create_item() + + response = client.get(f"/item/{item['id']}") + + assert response.status_code == 200 + assert response.json() == item + + +def test_get_item_not_found(): + assert client.get("/item/999").status_code == 404 + + +def test_get_deleted_item_not_found(): + item = create_item() + client.delete(f"/item/{item['id']}") + + assert client.get(f"/item/{item['id']}").status_code == 404 + + +def test_get_item_list_with_filters(): + create_item("milk", 50.0) + bread = create_item("bread", 150.0) + create_item("cheese", 300.0) + + assert len(client.get("/item").json()) == 3 + assert len(client.get("/item", params={"limit": 1}).json()) == 1 + assert len(client.get("/item", params={"offset": 2}).json()) == 1 + assert len(client.get("/item", params={"min_price": 100}).json()) == 2 + assert len(client.get("/item", params={"max_price": 100}).json()) == 1 + + client.delete(f"/item/{bread['id']}") + + assert len(client.get("/item").json()) == 2 + assert len(client.get("/item", params={"show_deleted": True}).json()) == 3 + + +def test_get_item_list_wrong_params(): + assert client.get("/item", params={"offset": -1}).status_code == 422 + assert client.get("/item", params={"limit": 0}).status_code == 422 + assert client.get("/item", params={"min_price": -1}).status_code == 422 + assert client.get("/item", params={"max_price": -1}).status_code == 422 + + +def test_put_item(): + item = create_item() + + response = client.put(f"/item/{item['id']}", json={"name": "bread", "price": 50.0}) + + assert response.status_code == 200 + assert response.json() == {"id": item["id"], "name": "bread", "price": 50.0, "deleted": False} + + +def test_put_item_not_found(): + response = client.put("/item/999", json={"name": "bread", "price": 50.0}) + + assert response.status_code == 404 + + +def test_put_item_without_price(): + item = create_item() + + response = client.put(f"/item/{item['id']}", json={"name": "bread"}) + + assert response.status_code == 422 + + +def test_patch_item(): + item = create_item(name="milk", price=100.0) + + response = client.patch(f"/item/{item['id']}", json={"price": 50.0}) + + assert response.status_code == 200 + assert response.json()["name"] == "milk" + assert response.json()["price"] == 50.0 + + +def test_patch_item_not_found(): + assert client.patch("/item/999", json={"price": 50.0}).status_code == 404 + + +def test_patch_deleted_item(): + item = create_item() + client.delete(f"/item/{item['id']}") + + response = client.patch(f"/item/{item['id']}", json={"price": 50.0}) + + assert response.status_code == 304 + + +def test_patch_item_with_deleted_field(): + item = create_item() + + response = client.patch(f"/item/{item['id']}", json={"deleted": True}) + + assert response.status_code == 422 + + +def test_delete_item(): + item = create_item() + + response = client.delete(f"/item/{item['id']}") + + assert response.status_code == 200 + assert client.get(f"/item/{item['id']}").status_code == 404 + + +def test_delete_item_twice_and_missing(): + item = create_item() + client.delete(f"/item/{item['id']}") + + assert client.delete(f"/item/{item['id']}").status_code == 200 + assert client.delete("/item/999").status_code == 200