diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 00000000..7d66fb44 --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,55 @@ +name: hw5 tests + +on: + push: + paths: + - "hw5/**" + - ".github/workflows/hw5-tests.yml" + pull_request: + paths: + - "hw5/**" + - ".github/workflows/hw5-tests.yml" + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: hw5 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: shop + POSTGRES_PASSWORD: shop + POSTGRES_DB: shop_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U shop -d shop_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + TEST_DATABASE_URL: postgresql+psycopg2://shop:shop@localhost:5432/shop_test + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + hw5/requirements.txt + hw5/requirements-dev.txt + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests (coverage >= 95%) + run: pytest \ No newline at end of file diff --git a/hw1/app.py b/hw1/app.py index 6107b870..225e694e 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,6 +1,128 @@ +import json +from urllib.parse import parse_qs from typing import Any, Awaitable, Callable +BAD_REQUEST = None, 400 +UNPROCESSABLE_ENTITY = None, 422 +SUCCEFULL = lambda result: ({"result": result}, 200) + + +async def check_integer(n_str): + try: + n = int(n_str) + except (ValueError, TypeError): + return None + return n + + +async def fibonacci(request): + n = await check_integer(request['path_params']['n']) + if n is None: + return UNPROCESSABLE_ENTITY + if n < 0: + return BAD_REQUEST + def fib(n): + if n <= 1: + return n + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + result = fib(n) + return SUCCEFULL(result) + + +async def factorial(request): + query_params = request['query_params'] + if 'n' not in query_params: + return UNPROCESSABLE_ENTITY + n = await check_integer(query_params['n']) + if n is None: + return UNPROCESSABLE_ENTITY + if n < 0: + return BAD_REQUEST + result = 1 + for i in range(1, n + 1): + result *= i + return SUCCEFULL(result) + + +async def mean(request): + body = request.get('body') + if body is None or body == '': + return UNPROCESSABLE_ENTITY + try: + data = json.loads(body) + except (json.JSONDecodeError, TypeError): + return UNPROCESSABLE_ENTITY + if not isinstance(data, list): + return UNPROCESSABLE_ENTITY + if len(data) == 0: + return BAD_REQUEST + try: + numbers = [float(x) for x in data] + except (TypeError, ValueError): + return UNPROCESSABLE_ENTITY + result = sum(numbers) / len(numbers) + return SUCCEFULL(result) + + +async def send_json_response(send, status_code, data): + json_bytes = json.dumps(data, ensure_ascii=False).encode('utf-8') + await send({ + 'type': 'http.response.start', + 'status': status_code, + 'headers': [ + [b'content-type', b'application/json; charset=utf-8'], + [b'content-length', str(len(json_bytes)).encode('ascii')], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': json_bytes, + }) + + +def parse_path(path): + path = path.rstrip('/') + parts = path.split('/') + if len(parts) == 3 and parts[1] == 'fibonacci': + return 'fibonacci', {'n': parts[2]} + if len(parts) == 2 and parts[1] == 'factorial': + return 'factorial', {} + if len(parts) == 2 and parts[1] == 'mean': + return 'mean', {} + return None, {} + + +async def read_body(receive): + body = b'' + while True: + message = await receive() + body += message.get('body', b'') + if not message.get('more_body', False): + break + return body + + +async def handle_lifespan(receive, send): + 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 + + +HANDLERS = { + 'fibonacci': fibonacci, + 'factorial': factorial, + 'mean': mean, +} + + async def application( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], @@ -12,7 +134,35 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + await handle_lifespan(receive, send) + return + if scope["type"] != "http": + return + path = scope['path'] + method = scope['method'] + query_string = scope['query_string'] + query_params = {k: v[0] for k, v in parse_qs(query_string.decode('utf-8')).items()} + body_bytes = await read_body(receive) + body = body_bytes.decode('utf-8') if len(body_bytes) > 0 else None + endpoint, path_params = parse_path(path) + if endpoint is None: + await send_json_response(send, 404, {'error': 'Not Found'}) + return + request = { + 'path': path, + 'method': method, + 'query_params': query_params, + 'path_params': path_params, + 'body': body, + } + handler = HANDLERS[endpoint] + result, status_code = await handler(request) + if result is None: + await send_json_response(send, status_code, {'error': 'Invalid request'}) + else: + await send_json_response(send, status_code, result) + if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..08ae3701 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base AS local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..91b5555c --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..af0702d7 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 + +prometheus-fastapi-instrumentator \ No newline at end of file diff --git a/hw2/hw/screenshot/grafana_dashboards.jpg b/hw2/hw/screenshot/grafana_dashboards.jpg new file mode 100644 index 00000000..7908b8ec Binary files /dev/null and b/hw2/hw/screenshot/grafana_dashboards.jpg differ diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..7fa1951b --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..35d98bd3 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,203 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Query, Response +from prometheus_fastapi_instrumentator import Instrumentator +from pydantic import BaseModel, ConfigDict +from typing import Optional, List + app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +class ItemBase(BaseModel): + name: str + price: float + +class ItemCreate(ItemBase): + pass + +class ItemUpdate(BaseModel): + model_config = ConfigDict(extra='forbid') + + name: Optional[str] = None + price: Optional[float] = None + deleted: Optional[bool] = None + +class Item(ItemBase): + id: int + deleted: bool = False + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float + +class CartIdResponse(BaseModel): + id: int + +items_db: dict[int, Item] = {} +carts_db: dict[int, Cart] = {} + +item_id_cnt = 0 +cart_id_cnt = 0 + +ITEM_NOT_FOUND = "Item not found" +CART_NOT_FOUND = "Cart not found" + + +def calculate_cart(cart: Cart) -> Cart: + total_price = 0.0 + updated_items = [] + for cart_item in cart.items: + db_item = items_db.get(cart_item.id) + if db_item: + is_available = not db_item.deleted + total_price += db_item.price * cart_item.quantity + updated_items.append(CartItem( + id=cart_item.id, + name=db_item.name, + quantity=cart_item.quantity, + available=is_available + )) + return Cart( + id=cart.id, + items=updated_items, + price=total_price + ) + + +@app.post("/item", response_model=Item, status_code=201) +def create_item(item: ItemCreate): + global item_id_cnt + new_item = Item(id=item_id_cnt, name=item.name, price=item.price, deleted=False) + items_db[item_id_cnt] = new_item + item_id_cnt += 1 + return new_item + + +@app.get("/item/{item_id}", response_model=Item) +def get_item(item_id: int): + if item_id not in items_db or items_db[item_id].deleted: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + return items_db[item_id] + + +@app.get("/item", response_model=List[Item]) +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False +): + result = [] + for item in items_db.values(): + if not show_deleted and item.deleted: + continue + if min_price is not None and item.price < min_price: + continue + if max_price is not None and item.price > max_price: + continue + result.append(item) + result.sort(key=lambda x: x.id) + return result[offset:offset + limit] + + +@app.put("/item/{item_id}", response_model=Item) +def update_item(item_id: int, item: ItemCreate): + if item_id not in items_db: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + items_db[item_id].name = item.name + items_db[item_id].price = item.price + items_db[item_id].deleted = False + return items_db[item_id] + + +@app.patch("/item/{item_id}", response_model=Item) +def patch_item(item_id: int, item: ItemUpdate): + if item_id not in items_db or items_db[item_id].deleted: + raise HTTPException(status_code=304, detail="Not modified") + if item.deleted is not None: + raise HTTPException(status_code=422, detail="Cannot update 'deleted' field via PATCH") + if item.name is not None: + items_db[item_id].name = item.name + if item.price is not None: + items_db[item_id].price = item.price + return items_db[item_id] + + +@app.delete("/item/{item_id}", response_model=Item) +def delete_item(item_id: int): + if item_id not in items_db: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + items_db[item_id].deleted = True + return items_db[item_id] + + +@app.post("/cart", response_model=CartIdResponse, status_code=201) +def create_cart(response: Response): + global cart_id_cnt + new_id = cart_id_cnt + carts_db[new_id] = Cart(id=new_id, items=[], price=0.0) + cart_id_cnt += 1 + response.headers["Location"] = f"/cart/{new_id}" + return {"id": new_id} + + +@app.get("/cart/{cart_id}", response_model=Cart) +def get_cart(cart_id: int): + if cart_id not in carts_db: + raise HTTPException(status_code=404, detail=CART_NOT_FOUND) + return calculate_cart(carts_db[cart_id]) + + +@app.get("/cart", response_model=List[Cart]) +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0) +): + result = [] + for cart in carts_db.values(): + calc_cart = calculate_cart(cart) + if min_price is not None and calc_cart.price < min_price: + continue + if max_price is not None and calc_cart.price > max_price: + continue + total_qty = sum(ci.quantity for ci in calc_cart.items) + if min_quantity is not None and total_qty < min_quantity: + continue + if max_quantity is not None and total_qty > max_quantity: + continue + result.append(calc_cart) + result.sort(key=lambda x: x.id) + return result[offset:offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}", response_model=Cart) +def add_to_cart(cart_id: int, item_id: int): + if cart_id not in carts_db: + raise HTTPException(status_code=404, detail=CART_NOT_FOUND) + if item_id not in items_db: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + cart = carts_db[cart_id] + db_item = items_db[item_id] + for cart_item in cart.items: + if cart_item.id == item_id: + cart_item.quantity += 1 + return calculate_cart(cart) + new_cart_item = CartItem( + id=db_item.id, + name=db_item.name, + quantity=1, + available=not db_item.deleted + ) + cart.items.append(new_cart_item) + return calculate_cart(cart) diff --git a/hw4/Dockerfile b/hw4/Dockerfile new file mode 100644 index 00000000..08ae3701 --- /dev/null +++ b/hw4/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base AS local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw4/__init__.py b/hw4/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/docker-compose.yml b/hw4/docker-compose.yml new file mode 100644 index 00000000..2cb1653a --- /dev/null +++ b/hw4/docker-compose.yml @@ -0,0 +1,56 @@ +version: "3" + +services: + + db: + image: postgres:16 + environment: + POSTGRES_USER: shop + POSTGRES_PASSWORD: shop + POSTGRES_DB: shop + ports: + - 5432:5432 + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U shop -d shop"] + interval: 3s + timeout: 3s + retries: 10 + restart: always + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + environment: + DATABASE_URL: postgresql+psycopg2://shop:shop@db:5432/shop + depends_on: + db: + condition: service_healthy + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + +volumes: + pgdata: \ No newline at end of file diff --git a/hw4/requirements.txt b/hw4/requirements.txt new file mode 100644 index 00000000..5f18d8cf --- /dev/null +++ b/hw4/requirements.txt @@ -0,0 +1,13 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + +prometheus-fastapi-instrumentator +sqlalchemy>=2.0 +psycopg2-binary \ No newline at end of file diff --git a/hw4/scripts/isolation.py b/hw4/scripts/isolation.py new file mode 100644 index 00000000..f27a8497 --- /dev/null +++ b/hw4/scripts/isolation.py @@ -0,0 +1,158 @@ +import os +import sys +from contextlib import contextmanager + +from sqlalchemy import create_engine, text +from sqlalchemy.exc import DBAPIError + +DATABASE_URL = os.getenv( + "DATABASE_URL", "postgresql+psycopg2://shop:shop@localhost:5432/shop" +) +engine = create_engine(DATABASE_URL) + + +@contextmanager +def tx(level: str): + conn = engine.connect().execution_options(isolation_level=level) + try: + yield conn + finally: + conn.close() + + +def reset(): + with engine.begin() as c: + c.execute(text("DROP TABLE IF EXISTS iso_demo")) + c.execute( + text( + "CREATE TABLE iso_demo (" + " id serial PRIMARY KEY, kind text NOT NULL, value int NOT NULL)" + ) + ) + c.execute(text("INSERT INTO iso_demo (kind, value) VALUES ('acc', 100)")) + + +def balance(c) -> int: + return c.execute(text("SELECT value FROM iso_demo WHERE kind='acc'")).scalar_one() + + +def count(c, kind: str) -> int: + return c.execute( + text("SELECT count(*) FROM iso_demo WHERE kind=:k"), {"k": kind} + ).scalar_one() + + +_results: list[bool] = [] + + +def check(ok: bool, msg: str): + _results.append(ok) + print(f" {'PASSED: ' if ok else 'FAILED: '} {msg}") + + +def title(s: str): + print(f"\n=== {s} ===") + + +# ---------- сценарии ---------- + +def dirty_read(level: str) -> int: + """B меняет баланс, но не коммитит. Что видит A?""" + reset() + with tx(level) as a, tx(level) as b: + b.execute(text("UPDATE iso_demo SET value = 200 WHERE kind='acc'")) + seen = balance(a) + b.rollback() + return seen + + +def non_repeatable_read(level: str) -> tuple[int, int]: + """A читает строку дважды, между чтениями B меняет её и коммитит.""" + reset() + with tx(level) as a: + first = balance(a) + with tx("READ COMMITTED") as b: + b.execute(text("UPDATE iso_demo SET value = 200 WHERE kind='acc'")) + b.commit() + second = balance(a) + return first, second + + +def phantom_read(level: str) -> tuple[int, int]: + """A дважды считает строки по условию, между чтениями B вставляет новую строку.""" + reset() + with tx(level) as a: + first = count(a, "acc") + with tx("READ COMMITTED") as b: + b.execute(text("INSERT INTO iso_demo (kind, value) VALUES ('acc', 50)")) + b.commit() + second = count(a, "acc") + return first, second + + +def write_skew(level: str) -> tuple[int, list[tuple[str, str]]]: + """ + Инвариант: строк kind='promo' должно быть не больше одной. + Обе транзакции проверяют count == 0 и вставляют свою строку. + Вставка соседа для каждой - "фантом", из-за которого проверка устарела. + """ + reset() + errors: list[tuple[str, str]] = [] + with tx(level) as a, tx(level) as b: + assert count(a, "promo") == 0 + assert count(b, "promo") == 0 + for name, c in (("A", a), ("B", b)): + try: + c.execute(text("INSERT INTO iso_demo (kind, value) VALUES ('promo', 1)")) + c.commit() + except DBAPIError as e: + c.rollback() + errors.append((name, getattr(e.orig, "pgcode", "?"))) + with engine.connect() as c: + return count(c, "promo"), errors + + +def main(): + title("1. Dirty read") + for level in ("READ UNCOMMITTED", "READ COMMITTED"): + seen = dirty_read(level) + check( + seen == 100, + f"{level}: A видит {seen} (B записал 200 без commit) -> грязного чтения нет" + + (" [в PG READ UNCOMMITTED ведёт себя как READ COMMITTED]" + if level == "READ UNCOMMITTED" else ""), + ) + + title("2. Non-repeatable read") + first, second = non_repeatable_read("READ COMMITTED") + check(first != second, f"READ COMMITTED: {first} -> {second}, аномалия ВОСПРОИЗВЕДЕНА") + first, second = non_repeatable_read("REPEATABLE READ") + check(first == second, f"REPEATABLE READ: {first} -> {second}, чтение повторяемо") + + title("3. Phantom read") + first, second = phantom_read("READ COMMITTED") + check(first != second, f"READ COMMITTED: count {first} -> {second}, фантом ВОСПРОИЗВЕДЁН") + first, second = phantom_read("REPEATABLE READ") + check( + first == second, + f"REPEATABLE READ: count {first} -> {second}; в PG фантомов нет " + "(snapshot isolation строже стандарта)", + ) + + title("4. Аномалия, связанная с фантомами (write skew): RR vs SERIALIZABLE") + total, errors = write_skew("REPEATABLE READ") + check(total == 2 and not errors, + f"REPEATABLE READ: в таблице {total} promo-строк (инвариант <=1 нарушен)") + total, errors = write_skew("SERIALIZABLE") + check( + total == 1 and errors and all(code == "40001" for _, code in errors), + f"SERIALIZABLE: в таблице {total} promo-строк, ошибки {errors} " + "(40001 serialization_failure -> транзакцию нужно повторить)", + ) + + with engine.begin() as c: + c.execute(text("DROP TABLE IF EXISTS iso_demo")) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/hw4/settings/prometheus/prometheus.yml b/hw4/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..7fa1951b --- /dev/null +++ b/hw4/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw4/shop_api/__init__.py b/hw4/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/shop_api/db.py b/hw4/shop_api/db.py new file mode 100644 index 00000000..0aaee8d8 --- /dev/null +++ b/hw4/shop_api/db.py @@ -0,0 +1,61 @@ +import os + +from sqlalchemy import ForeignKey, UniqueConstraint, create_engine +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + mapped_column, + relationship, + sessionmaker, +) + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+psycopg2://shop:shop@localhost:5432/shop", +) + +engine = create_engine(DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +class ItemORM(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + price: Mapped[float] + deleted: Mapped[bool] = mapped_column(default=False, server_default="false") + + +class CartORM(Base): + __tablename__ = "carts" + + id: Mapped[int] = mapped_column(primary_key=True) + items: Mapped[list["CartItemORM"]] = relationship( + order_by="CartItemORM.id", + lazy="selectin", + cascade="all, delete-orphan", + ) + + +class CartItemORM(Base): + __tablename__ = "cart_items" + __table_args__ = (UniqueConstraint("cart_id", "item_id", name="uq_cart_item"),) + + id: Mapped[int] = mapped_column(primary_key=True) + cart_id: Mapped[int] = mapped_column( + ForeignKey("carts.id", ondelete="CASCADE"), index=True + ) + item_id: Mapped[int] = mapped_column(ForeignKey("items.id")) + quantity: Mapped[int] = mapped_column(default=1) + + item: Mapped[ItemORM] = relationship(lazy="joined") + + +def get_db(): + with SessionLocal() as db: + yield db \ No newline at end of file diff --git a/hw4/shop_api/main.py b/hw4/shop_api/main.py new file mode 100644 index 00000000..3e078a8f --- /dev/null +++ b/hw4/shop_api/main.py @@ -0,0 +1,237 @@ +from contextlib import asynccontextmanager +from typing import List, Optional + +from fastapi import Depends, FastAPI, HTTPException, Query, Response +from prometheus_fastapi_instrumentator import Instrumentator +from pydantic import BaseModel, ConfigDict +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session + +from shop_api.db import Base, CartItemORM, CartORM, ItemORM, engine, get_db + + +@asynccontextmanager +async def lifespan(_: FastAPI): + Base.metadata.create_all(bind=engine) + yield + + +app = FastAPI(title="Shop API", lifespan=lifespan) +Instrumentator().instrument(app).expose(app) + +class ItemBase(BaseModel): + name: str + price: float + + +class ItemCreate(ItemBase): + pass + + +class ItemUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = None + price: Optional[float] = None + deleted: Optional[bool] = None + + +class Item(ItemBase): + model_config = ConfigDict(from_attributes=True) + + id: int + deleted: bool = False + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float + + +class CartIdResponse(BaseModel): + id: int + + +ITEM_NOT_FOUND = "Item not found" +CART_NOT_FOUND = "Cart not found" + + +def cart_to_schema(cart: CartORM) -> Cart: + items = [ + CartItem( + id=ci.item_id, + name=ci.item.name, + quantity=ci.quantity, + available=not ci.item.deleted, + ) + for ci in cart.items + ] + price = sum(ci.item.price * ci.quantity for ci in cart.items) + return Cart(id=cart.id, items=items, price=price) + + +def load_cart(db: Session, cart_id: int) -> Optional[CartORM]: + return db.scalar( + select(CartORM) + .where(CartORM.id == cart_id) + .execution_options(populate_existing=True) + ) + + +@app.post("/item", response_model=Item, status_code=201) +def create_item(item: ItemCreate, db: Session = Depends(get_db)): + obj = ItemORM(name=item.name, price=item.price, deleted=False) + db.add(obj) + db.commit() + return Item.model_validate(obj) + + +@app.get("/item/{item_id}", response_model=Item) +def get_item(item_id: int, db: Session = Depends(get_db)): + obj = db.get(ItemORM, item_id) + if obj is None or obj.deleted: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + return Item.model_validate(obj) + + +@app.get("/item", response_model=List[Item]) +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False, + db: Session = Depends(get_db), +): + stmt = select(ItemORM) + if not show_deleted: + stmt = stmt.where(ItemORM.deleted.is_(False)) + if min_price is not None: + stmt = stmt.where(ItemORM.price >= min_price) + if max_price is not None: + stmt = stmt.where(ItemORM.price <= max_price) + stmt = stmt.order_by(ItemORM.id).offset(offset).limit(limit) + return [Item.model_validate(o) for o in db.scalars(stmt)] + + +@app.put("/item/{item_id}", response_model=Item) +def update_item(item_id: int, item: ItemCreate, db: Session = Depends(get_db)): + obj = db.get(ItemORM, item_id) + if obj is None: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + obj.name = item.name + obj.price = item.price + obj.deleted = False + db.commit() + return Item.model_validate(obj) + + +@app.patch("/item/{item_id}", response_model=Item) +def patch_item(item_id: int, item: ItemUpdate, db: Session = Depends(get_db)): + obj = db.get(ItemORM, item_id) + if obj is None or obj.deleted: + raise HTTPException(status_code=304, detail="Not modified") + if item.deleted is not None: + raise HTTPException( + status_code=422, detail="Cannot update 'deleted' field via PATCH" + ) + if item.name is not None: + obj.name = item.name + if item.price is not None: + obj.price = item.price + db.commit() + return Item.model_validate(obj) + + +@app.delete("/item/{item_id}", response_model=Item) +def delete_item(item_id: int, db: Session = Depends(get_db)): + obj = db.get(ItemORM, item_id) + if obj is None: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + obj.deleted = True + db.commit() + return Item.model_validate(obj) + + +@app.post("/cart", response_model=CartIdResponse, status_code=201) +def create_cart(response: Response, db: Session = Depends(get_db)): + cart = CartORM() + db.add(cart) + db.commit() + response.headers["Location"] = f"/cart/{cart.id}" + return {"id": cart.id} + + +@app.get("/cart/{cart_id}", response_model=Cart) +def get_cart(cart_id: int, db: Session = Depends(get_db)): + cart = load_cart(db, cart_id) + if cart is None: + raise HTTPException(status_code=404, detail=CART_NOT_FOUND) + return cart_to_schema(cart) + + +@app.get("/cart", response_model=List[Cart]) +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0), + db: Session = Depends(get_db), +): + total_price = func.coalesce(func.sum(ItemORM.price * CartItemORM.quantity), 0.0) + total_qty = func.coalesce(func.sum(CartItemORM.quantity), 0) + stmt = ( + select(CartORM.id) + .outerjoin(CartItemORM, CartItemORM.cart_id == CartORM.id) + .outerjoin(ItemORM, ItemORM.id == CartItemORM.item_id) + .group_by(CartORM.id) + ) + if min_price is not None: + stmt = stmt.having(total_price >= min_price) + if max_price is not None: + stmt = stmt.having(total_price <= max_price) + if min_quantity is not None: + stmt = stmt.having(total_qty >= min_quantity) + if max_quantity is not None: + stmt = stmt.having(total_qty <= max_quantity) + stmt = stmt.order_by(CartORM.id).offset(offset).limit(limit) + + cart_ids = list(db.scalars(stmt)) + if not cart_ids: + return [] + + carts = db.scalars( + select(CartORM) + .where(CartORM.id.in_(cart_ids)) + .order_by(CartORM.id) + .execution_options(populate_existing=True) + ) + return [cart_to_schema(c) for c in carts] + + +@app.post("/cart/{cart_id}/add/{item_id}", response_model=Cart) +def add_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + if db.scalar(select(CartORM.id).where(CartORM.id == cart_id)) is None: + raise HTTPException(status_code=404, detail=CART_NOT_FOUND) + if db.scalar(select(ItemORM.id).where(ItemORM.id == item_id)) is None: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + stmt = pg_insert(CartItemORM).values(cart_id=cart_id, item_id=item_id, quantity=1) + stmt = stmt.on_conflict_do_update( + constraint="uq_cart_item", + set_={"quantity": CartItemORM.quantity + 1}, + ) + db.execute(stmt) + db.commit() + + return cart_to_schema(load_cart(db, cart_id)) \ No newline at end of file diff --git a/hw5/Dockerfile b/hw5/Dockerfile new file mode 100644 index 00000000..08ae3701 --- /dev/null +++ b/hw5/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base AS local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw5/__init__.py b/hw5/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/docker-compose.yml b/hw5/docker-compose.yml new file mode 100644 index 00000000..2cb1653a --- /dev/null +++ b/hw5/docker-compose.yml @@ -0,0 +1,56 @@ +version: "3" + +services: + + db: + image: postgres:16 + environment: + POSTGRES_USER: shop + POSTGRES_PASSWORD: shop + POSTGRES_DB: shop + ports: + - 5432:5432 + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U shop -d shop"] + interval: 3s + timeout: 3s + retries: 10 + restart: always + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + environment: + DATABASE_URL: postgresql+psycopg2://shop:shop@db:5432/shop + depends_on: + db: + condition: service_healthy + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + +volumes: + pgdata: \ No newline at end of file diff --git a/hw5/pytest.ini b/hw5/pytest.ini new file mode 100644 index 00000000..8e197b2a --- /dev/null +++ b/hw5/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +pythonpath = . +testpaths = tests +addopts = --cov=shop_api --cov-report=term-missing --cov-fail-under=95 \ No newline at end of file diff --git a/hw5/requirements.txt b/hw5/requirements.txt new file mode 100644 index 00000000..ccef2d1e --- /dev/null +++ b/hw5/requirements.txt @@ -0,0 +1,14 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-cov +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + +prometheus-fastapi-instrumentator +sqlalchemy>=2.0 +psycopg2-binary \ No newline at end of file diff --git a/hw5/scripts/isolation.py b/hw5/scripts/isolation.py new file mode 100644 index 00000000..f27a8497 --- /dev/null +++ b/hw5/scripts/isolation.py @@ -0,0 +1,158 @@ +import os +import sys +from contextlib import contextmanager + +from sqlalchemy import create_engine, text +from sqlalchemy.exc import DBAPIError + +DATABASE_URL = os.getenv( + "DATABASE_URL", "postgresql+psycopg2://shop:shop@localhost:5432/shop" +) +engine = create_engine(DATABASE_URL) + + +@contextmanager +def tx(level: str): + conn = engine.connect().execution_options(isolation_level=level) + try: + yield conn + finally: + conn.close() + + +def reset(): + with engine.begin() as c: + c.execute(text("DROP TABLE IF EXISTS iso_demo")) + c.execute( + text( + "CREATE TABLE iso_demo (" + " id serial PRIMARY KEY, kind text NOT NULL, value int NOT NULL)" + ) + ) + c.execute(text("INSERT INTO iso_demo (kind, value) VALUES ('acc', 100)")) + + +def balance(c) -> int: + return c.execute(text("SELECT value FROM iso_demo WHERE kind='acc'")).scalar_one() + + +def count(c, kind: str) -> int: + return c.execute( + text("SELECT count(*) FROM iso_demo WHERE kind=:k"), {"k": kind} + ).scalar_one() + + +_results: list[bool] = [] + + +def check(ok: bool, msg: str): + _results.append(ok) + print(f" {'PASSED: ' if ok else 'FAILED: '} {msg}") + + +def title(s: str): + print(f"\n=== {s} ===") + + +# ---------- сценарии ---------- + +def dirty_read(level: str) -> int: + """B меняет баланс, но не коммитит. Что видит A?""" + reset() + with tx(level) as a, tx(level) as b: + b.execute(text("UPDATE iso_demo SET value = 200 WHERE kind='acc'")) + seen = balance(a) + b.rollback() + return seen + + +def non_repeatable_read(level: str) -> tuple[int, int]: + """A читает строку дважды, между чтениями B меняет её и коммитит.""" + reset() + with tx(level) as a: + first = balance(a) + with tx("READ COMMITTED") as b: + b.execute(text("UPDATE iso_demo SET value = 200 WHERE kind='acc'")) + b.commit() + second = balance(a) + return first, second + + +def phantom_read(level: str) -> tuple[int, int]: + """A дважды считает строки по условию, между чтениями B вставляет новую строку.""" + reset() + with tx(level) as a: + first = count(a, "acc") + with tx("READ COMMITTED") as b: + b.execute(text("INSERT INTO iso_demo (kind, value) VALUES ('acc', 50)")) + b.commit() + second = count(a, "acc") + return first, second + + +def write_skew(level: str) -> tuple[int, list[tuple[str, str]]]: + """ + Инвариант: строк kind='promo' должно быть не больше одной. + Обе транзакции проверяют count == 0 и вставляют свою строку. + Вставка соседа для каждой - "фантом", из-за которого проверка устарела. + """ + reset() + errors: list[tuple[str, str]] = [] + with tx(level) as a, tx(level) as b: + assert count(a, "promo") == 0 + assert count(b, "promo") == 0 + for name, c in (("A", a), ("B", b)): + try: + c.execute(text("INSERT INTO iso_demo (kind, value) VALUES ('promo', 1)")) + c.commit() + except DBAPIError as e: + c.rollback() + errors.append((name, getattr(e.orig, "pgcode", "?"))) + with engine.connect() as c: + return count(c, "promo"), errors + + +def main(): + title("1. Dirty read") + for level in ("READ UNCOMMITTED", "READ COMMITTED"): + seen = dirty_read(level) + check( + seen == 100, + f"{level}: A видит {seen} (B записал 200 без commit) -> грязного чтения нет" + + (" [в PG READ UNCOMMITTED ведёт себя как READ COMMITTED]" + if level == "READ UNCOMMITTED" else ""), + ) + + title("2. Non-repeatable read") + first, second = non_repeatable_read("READ COMMITTED") + check(first != second, f"READ COMMITTED: {first} -> {second}, аномалия ВОСПРОИЗВЕДЕНА") + first, second = non_repeatable_read("REPEATABLE READ") + check(first == second, f"REPEATABLE READ: {first} -> {second}, чтение повторяемо") + + title("3. Phantom read") + first, second = phantom_read("READ COMMITTED") + check(first != second, f"READ COMMITTED: count {first} -> {second}, фантом ВОСПРОИЗВЕДЁН") + first, second = phantom_read("REPEATABLE READ") + check( + first == second, + f"REPEATABLE READ: count {first} -> {second}; в PG фантомов нет " + "(snapshot isolation строже стандарта)", + ) + + title("4. Аномалия, связанная с фантомами (write skew): RR vs SERIALIZABLE") + total, errors = write_skew("REPEATABLE READ") + check(total == 2 and not errors, + f"REPEATABLE READ: в таблице {total} promo-строк (инвариант <=1 нарушен)") + total, errors = write_skew("SERIALIZABLE") + check( + total == 1 and errors and all(code == "40001" for _, code in errors), + f"SERIALIZABLE: в таблице {total} promo-строк, ошибки {errors} " + "(40001 serialization_failure -> транзакцию нужно повторить)", + ) + + with engine.begin() as c: + c.execute(text("DROP TABLE IF EXISTS iso_demo")) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/hw5/settings/prometheus/prometheus.yml b/hw5/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..7fa1951b --- /dev/null +++ b/hw5/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw5/shop_api/__init__.py b/hw5/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/shop_api/db.py b/hw5/shop_api/db.py new file mode 100644 index 00000000..0aaee8d8 --- /dev/null +++ b/hw5/shop_api/db.py @@ -0,0 +1,61 @@ +import os + +from sqlalchemy import ForeignKey, UniqueConstraint, create_engine +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + mapped_column, + relationship, + sessionmaker, +) + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+psycopg2://shop:shop@localhost:5432/shop", +) + +engine = create_engine(DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +class ItemORM(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + price: Mapped[float] + deleted: Mapped[bool] = mapped_column(default=False, server_default="false") + + +class CartORM(Base): + __tablename__ = "carts" + + id: Mapped[int] = mapped_column(primary_key=True) + items: Mapped[list["CartItemORM"]] = relationship( + order_by="CartItemORM.id", + lazy="selectin", + cascade="all, delete-orphan", + ) + + +class CartItemORM(Base): + __tablename__ = "cart_items" + __table_args__ = (UniqueConstraint("cart_id", "item_id", name="uq_cart_item"),) + + id: Mapped[int] = mapped_column(primary_key=True) + cart_id: Mapped[int] = mapped_column( + ForeignKey("carts.id", ondelete="CASCADE"), index=True + ) + item_id: Mapped[int] = mapped_column(ForeignKey("items.id")) + quantity: Mapped[int] = mapped_column(default=1) + + item: Mapped[ItemORM] = relationship(lazy="joined") + + +def get_db(): + with SessionLocal() as db: + yield db \ No newline at end of file diff --git a/hw5/shop_api/main.py b/hw5/shop_api/main.py new file mode 100644 index 00000000..3e078a8f --- /dev/null +++ b/hw5/shop_api/main.py @@ -0,0 +1,237 @@ +from contextlib import asynccontextmanager +from typing import List, Optional + +from fastapi import Depends, FastAPI, HTTPException, Query, Response +from prometheus_fastapi_instrumentator import Instrumentator +from pydantic import BaseModel, ConfigDict +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session + +from shop_api.db import Base, CartItemORM, CartORM, ItemORM, engine, get_db + + +@asynccontextmanager +async def lifespan(_: FastAPI): + Base.metadata.create_all(bind=engine) + yield + + +app = FastAPI(title="Shop API", lifespan=lifespan) +Instrumentator().instrument(app).expose(app) + +class ItemBase(BaseModel): + name: str + price: float + + +class ItemCreate(ItemBase): + pass + + +class ItemUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = None + price: Optional[float] = None + deleted: Optional[bool] = None + + +class Item(ItemBase): + model_config = ConfigDict(from_attributes=True) + + id: int + deleted: bool = False + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float + + +class CartIdResponse(BaseModel): + id: int + + +ITEM_NOT_FOUND = "Item not found" +CART_NOT_FOUND = "Cart not found" + + +def cart_to_schema(cart: CartORM) -> Cart: + items = [ + CartItem( + id=ci.item_id, + name=ci.item.name, + quantity=ci.quantity, + available=not ci.item.deleted, + ) + for ci in cart.items + ] + price = sum(ci.item.price * ci.quantity for ci in cart.items) + return Cart(id=cart.id, items=items, price=price) + + +def load_cart(db: Session, cart_id: int) -> Optional[CartORM]: + return db.scalar( + select(CartORM) + .where(CartORM.id == cart_id) + .execution_options(populate_existing=True) + ) + + +@app.post("/item", response_model=Item, status_code=201) +def create_item(item: ItemCreate, db: Session = Depends(get_db)): + obj = ItemORM(name=item.name, price=item.price, deleted=False) + db.add(obj) + db.commit() + return Item.model_validate(obj) + + +@app.get("/item/{item_id}", response_model=Item) +def get_item(item_id: int, db: Session = Depends(get_db)): + obj = db.get(ItemORM, item_id) + if obj is None or obj.deleted: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + return Item.model_validate(obj) + + +@app.get("/item", response_model=List[Item]) +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False, + db: Session = Depends(get_db), +): + stmt = select(ItemORM) + if not show_deleted: + stmt = stmt.where(ItemORM.deleted.is_(False)) + if min_price is not None: + stmt = stmt.where(ItemORM.price >= min_price) + if max_price is not None: + stmt = stmt.where(ItemORM.price <= max_price) + stmt = stmt.order_by(ItemORM.id).offset(offset).limit(limit) + return [Item.model_validate(o) for o in db.scalars(stmt)] + + +@app.put("/item/{item_id}", response_model=Item) +def update_item(item_id: int, item: ItemCreate, db: Session = Depends(get_db)): + obj = db.get(ItemORM, item_id) + if obj is None: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + obj.name = item.name + obj.price = item.price + obj.deleted = False + db.commit() + return Item.model_validate(obj) + + +@app.patch("/item/{item_id}", response_model=Item) +def patch_item(item_id: int, item: ItemUpdate, db: Session = Depends(get_db)): + obj = db.get(ItemORM, item_id) + if obj is None or obj.deleted: + raise HTTPException(status_code=304, detail="Not modified") + if item.deleted is not None: + raise HTTPException( + status_code=422, detail="Cannot update 'deleted' field via PATCH" + ) + if item.name is not None: + obj.name = item.name + if item.price is not None: + obj.price = item.price + db.commit() + return Item.model_validate(obj) + + +@app.delete("/item/{item_id}", response_model=Item) +def delete_item(item_id: int, db: Session = Depends(get_db)): + obj = db.get(ItemORM, item_id) + if obj is None: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + obj.deleted = True + db.commit() + return Item.model_validate(obj) + + +@app.post("/cart", response_model=CartIdResponse, status_code=201) +def create_cart(response: Response, db: Session = Depends(get_db)): + cart = CartORM() + db.add(cart) + db.commit() + response.headers["Location"] = f"/cart/{cart.id}" + return {"id": cart.id} + + +@app.get("/cart/{cart_id}", response_model=Cart) +def get_cart(cart_id: int, db: Session = Depends(get_db)): + cart = load_cart(db, cart_id) + if cart is None: + raise HTTPException(status_code=404, detail=CART_NOT_FOUND) + return cart_to_schema(cart) + + +@app.get("/cart", response_model=List[Cart]) +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0), + db: Session = Depends(get_db), +): + total_price = func.coalesce(func.sum(ItemORM.price * CartItemORM.quantity), 0.0) + total_qty = func.coalesce(func.sum(CartItemORM.quantity), 0) + stmt = ( + select(CartORM.id) + .outerjoin(CartItemORM, CartItemORM.cart_id == CartORM.id) + .outerjoin(ItemORM, ItemORM.id == CartItemORM.item_id) + .group_by(CartORM.id) + ) + if min_price is not None: + stmt = stmt.having(total_price >= min_price) + if max_price is not None: + stmt = stmt.having(total_price <= max_price) + if min_quantity is not None: + stmt = stmt.having(total_qty >= min_quantity) + if max_quantity is not None: + stmt = stmt.having(total_qty <= max_quantity) + stmt = stmt.order_by(CartORM.id).offset(offset).limit(limit) + + cart_ids = list(db.scalars(stmt)) + if not cart_ids: + return [] + + carts = db.scalars( + select(CartORM) + .where(CartORM.id.in_(cart_ids)) + .order_by(CartORM.id) + .execution_options(populate_existing=True) + ) + return [cart_to_schema(c) for c in carts] + + +@app.post("/cart/{cart_id}/add/{item_id}", response_model=Cart) +def add_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + if db.scalar(select(CartORM.id).where(CartORM.id == cart_id)) is None: + raise HTTPException(status_code=404, detail=CART_NOT_FOUND) + if db.scalar(select(ItemORM.id).where(ItemORM.id == item_id)) is None: + raise HTTPException(status_code=404, detail=ITEM_NOT_FOUND) + stmt = pg_insert(CartItemORM).values(cart_id=cart_id, item_id=item_id, quantity=1) + stmt = stmt.on_conflict_do_update( + constraint="uq_cart_item", + set_={"quantity": CartItemORM.quantity + 1}, + ) + db.execute(stmt) + db.commit() + + return cart_to_schema(load_cart(db, cart_id)) \ No newline at end of file diff --git a/hw5/tests/conftest.py b/hw5/tests/conftest.py new file mode 100644 index 00000000..d8ce1245 --- /dev/null +++ b/hw5/tests/conftest.py @@ -0,0 +1,63 @@ +import os + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, text +from sqlalchemy.engine import make_url + +TEST_DATABASE_URL = os.getenv( + "TEST_DATABASE_URL", + "postgresql+psycopg2://shop:shop@localhost:5432/shop_test", +) +os.environ["DATABASE_URL"] = TEST_DATABASE_URL + +from shop_api.db import engine +from shop_api.main import app + + +def _ensure_database() -> None: + """Создаёт тестовую БД, если её ещё нет (в CI она создаётся самим сервисом).""" + url = make_url(TEST_DATABASE_URL) + admin = create_engine(url.set(database="postgres"), isolation_level="AUTOCOMMIT") + with admin.connect() as conn: + exists = conn.execute( + text("SELECT 1 FROM pg_database WHERE datname = :name"), + {"name": url.database}, + ).scalar() + if not exists: + conn.execute(text(f'CREATE DATABASE "{url.database}"')) + admin.dispose() + + +@pytest.fixture(scope="session") +def client(): + _ensure_database() + with TestClient(app) as c: + yield c + + +@pytest.fixture(autouse=True) +def clean_db(client): + """Перед каждым тестом пустые таблицы и счётчики id начинаются с 1.""" + with engine.begin() as conn: + conn.execute(text("TRUNCATE cart_items, carts, items RESTART IDENTITY CASCADE")) + + +@pytest.fixture +def make_item(client): + def _make(name: str = "item", price: float = 10.0) -> dict: + resp = client.post("/item", json={"name": name, "price": price}) + assert resp.status_code == 201 + return resp.json() + + return _make + + +@pytest.fixture +def make_cart(client): + def _make() -> int: + resp = client.post("/cart") + assert resp.status_code == 201 + return resp.json()["id"] + + return _make \ No newline at end of file diff --git a/hw5/tests/test_carts.py b/hw5/tests/test_carts.py new file mode 100644 index 00000000..36130447 --- /dev/null +++ b/hw5/tests/test_carts.py @@ -0,0 +1,168 @@ +from concurrent.futures import ThreadPoolExecutor + +import pytest + + +def ids(resp) -> list[int]: + return [x["id"] for x in resp.json()] + + +def add(client, cart_id: int, item_id: int): + resp = client.post(f"/cart/{cart_id}/add/{item_id}") + assert resp.status_code == 200 + return resp.json() + + +def test_create_cart(client): + resp = client.post("/cart") + assert resp.status_code == 201 + assert resp.json() == {"id": 1} + assert resp.headers["Location"] == "/cart/1" + + +def test_get_empty_cart(client, make_cart): + cart_id = make_cart() + resp = client.get(f"/cart/{cart_id}") + assert resp.status_code == 200 + assert resp.json() == {"id": cart_id, "items": [], "price": 0.0} + + +def test_get_missing_cart(client): + assert client.get("/cart/999").status_code == 404 + + +def test_add_item_and_increment_quantity(client, make_item, make_cart): + item = make_item("milk", 10.0) + cart_id = make_cart() + + cart = add(client, cart_id, item["id"]) + assert cart["items"] == [ + {"id": item["id"], "name": "milk", "quantity": 1, "available": True} + ] + assert cart["price"] == pytest.approx(10.0) + + cart = add(client, cart_id, item["id"]) + assert cart["items"][0]["quantity"] == 2 + assert cart["price"] == pytest.approx(20.0) + + +def test_add_several_items_keeps_insertion_order(client, make_item, make_cart): + first = make_item("first", 1.0) + second = make_item("second", 2.0) + cart_id = make_cart() + + add(client, cart_id, second["id"]) + cart = add(client, cart_id, first["id"]) + + assert [i["name"] for i in cart["items"]] == ["second", "first"] + assert cart["price"] == pytest.approx(3.0) + + +def test_add_to_missing_cart(client, make_item): + item = make_item() + assert client.post(f"/cart/999/add/{item['id']}").status_code == 404 + + +def test_add_missing_item(client, make_cart): + cart_id = make_cart() + assert client.post(f"/cart/{cart_id}/add/999").status_code == 404 + + +def test_cart_reflects_item_price_change(client, make_item, make_cart): + item = make_item("a", 10.0) + cart_id = make_cart() + add(client, cart_id, item["id"]) + + client.put(f"/item/{item['id']}", json={"name": "renamed", "price": 50.0}) + + cart = client.get(f"/cart/{cart_id}").json() + assert cart["items"][0]["name"] == "renamed" + assert cart["price"] == pytest.approx(50.0) + + +def test_deleted_item_is_unavailable_but_still_counted(client, make_item, make_cart): + item = make_item("a", 10.0) + cart_id = make_cart() + add(client, cart_id, item["id"]) + + client.delete(f"/item/{item['id']}") + + cart = client.get(f"/cart/{cart_id}").json() + assert cart["items"][0]["available"] is False + assert cart["price"] == pytest.approx(10.0) + + +def test_concurrent_adds_do_not_lose_increments(client, make_item, make_cart): + """Атомарный upsert: 20 параллельных запросов -> ровно quantity == 20.""" + item = make_item("a", 1.0) + cart_id = make_cart() + + def call(_): + return client.post(f"/cart/{cart_id}/add/{item['id']}").status_code + + with ThreadPoolExecutor(max_workers=8) as pool: + statuses = list(pool.map(call, range(20))) + + assert statuses == [200] * 20 + cart = client.get(f"/cart/{cart_id}").json() + assert cart["items"][0]["quantity"] == 20 + assert cart["price"] == pytest.approx(20.0) + + +@pytest.fixture +def three_carts(client, make_item, make_cart): + """ + cart 1: пустая -> price 0, qty 0 + cart 2: 2 x A(10) -> price 20, qty 2 + cart 3: A(10) + B(100) -> price 110, qty 2 + """ + a = make_item("A", 10.0) + b = make_item("B", 100.0) + c1, c2, c3 = make_cart(), make_cart(), make_cart() + add(client, c2, a["id"]) + add(client, c2, a["id"]) + add(client, c3, a["id"]) + add(client, c3, b["id"]) + return c1, c2, c3 + + +def test_list_carts_all(client, three_carts): + assert ids(client.get("/cart")) == [1, 2, 3] + + +def test_list_carts_price_filters(client, three_carts): + assert ids(client.get("/cart", params={"min_price": 20})) == [2, 3] + assert ids(client.get("/cart", params={"max_price": 20})) == [1, 2] + assert ids(client.get("/cart", params={"min_price": 21, "max_price": 109})) == [] + + +def test_list_carts_quantity_filters(client, three_carts): + assert ids(client.get("/cart", params={"min_quantity": 1})) == [2, 3] + assert ids(client.get("/cart", params={"max_quantity": 1})) == [1] + assert ids(client.get("/cart", params={"min_quantity": 3})) == [] + + +def test_list_carts_pagination(client, three_carts): + assert ids(client.get("/cart", params={"offset": 1, "limit": 1})) == [2] + + +def test_list_carts_returns_full_cart_data(client, three_carts): + carts = client.get("/cart", params={"min_price": 100}).json() + assert len(carts) == 1 + assert carts[0]["price"] == pytest.approx(110.0) + assert [i["name"] for i in carts[0]["items"]] == ["A", "B"] + + +@pytest.mark.parametrize( + "params", + [ + {"limit": 0}, + {"offset": -1}, + {"min_price": -1}, + {"max_price": -1}, + {"min_quantity": -1}, + {"max_quantity": -1}, + ], +) +def test_list_carts_validation(client, params): + assert client.get("/cart", params=params).status_code == 422 \ No newline at end of file diff --git a/hw5/tests/test_items.py b/hw5/tests/test_items.py new file mode 100644 index 00000000..2ba9ccdd --- /dev/null +++ b/hw5/tests/test_items.py @@ -0,0 +1,138 @@ +import pytest + + +def ids(resp) -> list[int]: + return [x["id"] for x in resp.json()] + + +def test_create_item(client): + resp = client.post("/item", json={"name": "milk", "price": 79.5}) + assert resp.status_code == 201 + assert resp.json() == {"id": 1, "name": "milk", "price": 79.5, "deleted": False} + + +def test_create_item_validation_error(client): + resp = client.post("/item", json={"name": "milk"}) + assert resp.status_code == 422 + + +def test_get_item(client, make_item): + item = make_item("bread", 40.0) + resp = client.get(f"/item/{item['id']}") + assert resp.status_code == 200 + assert resp.json() == item + + +def test_get_missing_item(client): + assert client.get("/item/999").status_code == 404 + + +def test_get_deleted_item_is_404(client, make_item): + item = make_item() + client.delete(f"/item/{item['id']}") + assert client.get(f"/item/{item['id']}").status_code == 404 + + +def test_list_items_filters_and_pagination(client, make_item): + for name, price in [("a", 5), ("b", 10), ("c", 20), ("d", 40)]: + make_item(name, price) + + assert ids(client.get("/item")) == [1, 2, 3, 4] + assert ids(client.get("/item", params={"min_price": 10})) == [2, 3, 4] + assert ids(client.get("/item", params={"max_price": 10})) == [1, 2] + assert ids(client.get("/item", params={"min_price": 10, "max_price": 20})) == [2, 3] + assert ids(client.get("/item", params={"offset": 1, "limit": 2})) == [2, 3] + assert ids(client.get("/item", params={"offset": 10})) == [] + + +def test_list_items_show_deleted(client, make_item): + make_item("a") + make_item("b") + client.delete("/item/1") + + assert ids(client.get("/item")) == [2] + assert ids(client.get("/item", params={"show_deleted": True})) == [1, 2] + + +@pytest.mark.parametrize( + "params", + [{"limit": 0}, {"offset": -1}, {"min_price": -1}, {"max_price": -5}], +) +def test_list_items_validation(client, params): + assert client.get("/item", params=params).status_code == 422 + + +def test_put_item_replaces_fields_and_restores_deleted(client, make_item): + item = make_item("old", 1.0) + client.delete(f"/item/{item['id']}") + + resp = client.put(f"/item/{item['id']}", json={"name": "new", "price": 2.5}) + assert resp.status_code == 200 + assert resp.json() == {"id": item["id"], "name": "new", "price": 2.5, "deleted": False} + assert client.get(f"/item/{item['id']}").status_code == 200 + + +def test_put_missing_item(client): + resp = client.put("/item/999", json={"name": "x", "price": 1}) + assert resp.status_code == 404 + + +def test_patch_name_only(client, make_item): + item = make_item("old", 10.0) + resp = client.patch(f"/item/{item['id']}", json={"name": "new"}) + assert resp.status_code == 200 + assert resp.json()["name"] == "new" + assert resp.json()["price"] == 10.0 + + +def test_patch_price_only(client, make_item): + item = make_item("old", 10.0) + resp = client.patch(f"/item/{item['id']}", json={"price": 99.0}) + assert resp.status_code == 200 + assert resp.json()["name"] == "old" + assert resp.json()["price"] == 99.0 + + +def test_patch_name_and_price(client, make_item): + item = make_item("old", 10.0) + resp = client.patch(f"/item/{item['id']}", json={"name": "n", "price": 1.0}) + assert resp.json()["name"] == "n" + assert resp.json()["price"] == 1.0 + + +def test_patch_missing_item_returns_304(client): + assert client.patch("/item/999", json={"name": "x"}).status_code == 304 + + +def test_patch_deleted_item_returns_304(client, make_item): + item = make_item() + client.delete(f"/item/{item['id']}") + assert client.patch(f"/item/{item['id']}", json={"name": "x"}).status_code == 304 + + +def test_patch_deleted_field_is_rejected(client, make_item): + item = make_item() + resp = client.patch(f"/item/{item['id']}", json={"deleted": True}) + assert resp.status_code == 422 + + +def test_patch_unknown_field_is_rejected(client, make_item): + item = make_item() + resp = client.patch(f"/item/{item['id']}", json={"color": "red"}) + assert resp.status_code == 422 + + +def test_delete_item_is_soft_and_idempotent(client, make_item): + item = make_item() + + first = client.delete(f"/item/{item['id']}") + assert first.status_code == 200 + assert first.json()["deleted"] is True + + second = client.delete(f"/item/{item['id']}") + assert second.status_code == 200 + assert second.json()["deleted"] is True + + +def test_delete_missing_item(client): + assert client.delete("/item/999").status_code == 404 \ No newline at end of file diff --git a/hw5/tests/test_misc.py b/hw5/tests/test_misc.py new file mode 100644 index 00000000..c592f26d --- /dev/null +++ b/hw5/tests/test_misc.py @@ -0,0 +1,5 @@ +def test_metrics_endpoint(client): + client.get("/item") + resp = client.get("/metrics") + assert resp.status_code == 200 + assert "http_request" in resp.text \ No newline at end of file