diff --git a/.github/workflows/hw2-tests.yml b/.github/workflows/hw2-tests.yml index be7fc297..c28dd46f 100644 --- a/.github/workflows/hw2-tests.yml +++ b/.github/workflows/hw2-tests.yml @@ -36,4 +36,4 @@ jobs: env: PYTHONPATH: ${{ github.workspace }}/hw2/hw run: | - pytest test_homework2.py -v + pytest -v diff --git a/hw2/hw/.github/workflows/tests.yml b/hw2/hw/.github/workflows/tests.yml new file mode 100644 index 00000000..9b689b5f --- /dev/null +++ b/hw2/hw/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: tests + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run tests with coverage + run: | + pytest + diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..5b524072 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY shop_api ./shop_api +COPY README.md ./ + +EXPOSE 8000 + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/hw2/hw/conftest.py b/hw2/hw/conftest.py new file mode 100644 index 00000000..d1dbcc2c --- /dev/null +++ b/hw2/hw/conftest.py @@ -0,0 +1,16 @@ +import os +import sys +import pytest + +PROJECT_ROOT = os.path.dirname(__file__) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + + +@pytest.fixture(scope="session", autouse=True) +def _ensure_db_schema() -> None: + from shop_api.db import Base as ORMBase, engine + + ORMBase.metadata.drop_all(bind=engine) + ORMBase.metadata.create_all(bind=engine) + diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..841cdf86 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,61 @@ +services: + db: + image: postgres:16-alpine + container_name: shop_db + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 5 + volumes: + - pg_data:/var/lib/postgresql/data + + api: + build: . + container_name: shop_api + environment: + DATABASE_URL: postgresql+psycopg://postgres:postgres@db:5432/postgres + PYTHONUNBUFFERED: "1" + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + + prometheus: + image: prom/prometheus:v2.53.0 + container_name: prometheus + command: ["--config.file=/etc/prometheus/prometheus.yml", "--web.enable-lifecycle"] + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + ports: + - "9090:9090" + depends_on: + - api + + grafana: + image: grafana/grafana:11.1.4 + container_name: grafana + ports: + - "3000:3000" + volumes: + - grafana_data:/var/lib/grafana + - ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + - prometheus + +volumes: + pg_data: + driver: local + grafana_data: + driver: local + + diff --git a/hw2/hw/monitoring/grafana/dashboards/fastapi-overview.json b/hw2/hw/monitoring/grafana/dashboards/fastapi-overview.json new file mode 100644 index 00000000..4552d34e --- /dev/null +++ b/hw2/hw/monitoring/grafana/dashboards/fastapi-overview.json @@ -0,0 +1,52 @@ +{ + "id": null, + "uid": "fastapi-overview", + "title": "FastAPI Overview", + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "10s", + "panels": [ + { + "type": "timeseries", + "title": "Requests per second by status", + "gridPos": {"x": 0, "y": 0, "w": 12, "h": 8}, + "targets": [ + { + "refId": "A", + "expr": "sum by (status) (rate(http_requests_total[1m]))" + } + ] + }, + { + "type": "timeseries", + "title": "Request duration p95 (s)", + "gridPos": {"x": 12, "y": 0, "w": 12, "h": 8}, + "targets": [ + { + "refId": "B", + "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))" + } + ] + }, + { + "type": "stat", + "title": "API target up", + "gridPos": {"x": 0, "y": 8, "w": 6, "h": 4}, + "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}}, + "targets": [ + {"refId": "C", "expr": "up{job=\"api\"}"} + ] + }, + { + "type": "stat", + "title": "Total requests", + "gridPos": {"x": 6, "y": 8, "w": 6, "h": 4}, + "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}}, + "targets": [ + {"refId": "D", "expr": "sum(http_requests_total)"} + ] + } + ], + "templating": {"list": []} +} diff --git a/hw2/hw/monitoring/grafana/provisioning/dashboards/dashboards.yml b/hw2/hw/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 00000000..af350e41 --- /dev/null +++ b/hw2/hw/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards diff --git a/hw2/hw/monitoring/grafana/provisioning/datasources/datasource.yml b/hw2/hw/monitoring/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 00000000..1a57b69c --- /dev/null +++ b/hw2/hw/monitoring/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/hw2/hw/monitoring/prometheus/prometheus.yml b/hw2/hw/monitoring/prometheus/prometheus.yml new file mode 100644 index 00000000..8d9487c6 --- /dev/null +++ b/hw2/hw/monitoring/prometheus/prometheus.yml @@ -0,0 +1,13 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + + - job_name: "api" + metrics_path: /metrics + static_configs: + - targets: ["api:8000"] diff --git a/hw2/hw/pytest.ini b/hw2/hw/pytest.ini new file mode 100644 index 00000000..0d143d05 --- /dev/null +++ b/hw2/hw/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +addopts = -q --maxfail=1 --disable-warnings --cov=shop_api --cov-report=term-missing --cov-branch --cov-fail-under=95 +testpaths = . + diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..7d118bc2 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -5,5 +5,13 @@ uvicorn>=0.24.0 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 httpx>=0.27.2 Faker>=37.8.0 + +# База данных +SQLAlchemy>=2.0.29 +psycopg[binary]>=3.1.18 +python-dotenv>=1.0.1 +prometheus-client>=0.20.0 +prometheus-fastapi-instrumentator>=6.1.0 diff --git a/hw2/hw/scripts/tx_demo.py b/hw2/hw/scripts/tx_demo.py new file mode 100644 index 00000000..2b514876 --- /dev/null +++ b/hw2/hw/scripts/tx_demo.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import os +import threading +import time +from contextlib import contextmanager + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Connection + + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg://shop:shop@localhost:5432/shop") + +engine = create_engine(DATABASE_URL, echo=False, pool_pre_ping=True) + + +def bootstrap_schema() -> None: + with engine.begin() as conn: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS demo_items ( + id SERIAL PRIMARY KEY, + category TEXT NOT NULL, + price NUMERIC NOT NULL + ); + """ + ) + ) + conn.execute(text("DELETE FROM demo_items")) + conn.execute(text("INSERT INTO demo_items (category, price) VALUES ('A', 100), ('A', 200), ('B', 300)")) + + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS oncall ( + id SERIAL PRIMARY KEY, + doctor TEXT UNIQUE NOT NULL, + on_call BOOLEAN NOT NULL + ); + """ + ) + ) + conn.execute(text("DELETE FROM oncall")) + conn.execute( + text("INSERT INTO oncall (doctor, on_call) VALUES ('alice', TRUE), ('bob', TRUE)") + ) + + +@contextmanager +def tx_conn(isolation: str) -> Connection: + conn = engine.connect().execution_options(isolation_level=isolation) + trans = conn.begin() + try: + yield conn + trans.commit() + except Exception: + trans.rollback() + raise + finally: + conn.close() + + +def show(title: str, value) -> None: + print(f"\n=== {title} ===\n{value}") + + +def non_repeatable_read_at_read_committed() -> None: + print("\n--- Non-repeatable read at READ COMMITTED ---") + + def t1(): + with tx_conn("READ COMMITTED") as c1: + price1 = c1.execute(text("SELECT price FROM demo_items WHERE id = 1")).scalar() + show("T1 first read price id=1", price1) + time.sleep(1.0) + price2 = c1.execute(text("SELECT price FROM demo_items WHERE id = 1")).scalar() + show("T1 second read price id=1", price2) + + def t2(): + time.sleep(0.3) + with tx_conn("READ COMMITTED") as c2: + c2.execute(text("UPDATE demo_items SET price = price + 50 WHERE id = 1")) + show("T2 updated price id=1", "commit") + + th1 = threading.Thread(target=t1) + th2 = threading.Thread(target=t2) + th1.start(); th2.start(); th1.join(); th2.join() + + +def repeatable_read_prevents_non_repeatable() -> None: + print("\n--- No non-repeatable read at REPEATABLE READ ---") + + def t1(): + with tx_conn("REPEATABLE READ") as c1: + price1 = c1.execute(text("SELECT price FROM demo_items WHERE id = 2")).scalar() + show("T1 first read price id=2", price1) + time.sleep(1.0) + price2 = c1.execute(text("SELECT price FROM demo_items WHERE id = 2")).scalar() + show("T1 second read price id=2", price2) + + def t2(): + time.sleep(0.3) + with tx_conn("READ COMMITTED") as c2: + c2.execute(text("UPDATE demo_items SET price = price + 50 WHERE id = 2")) + show("T2 updated price id=2", "commit") + + th1 = threading.Thread(target=t1) + th2 = threading.Thread(target=t2) + th1.start(); th2.start(); th1.join(); th2.join() + + +def phantom_read_at_read_committed() -> None: + print("\n--- Phantom read at READ COMMITTED ---") + + def t1(): + with tx_conn("READ COMMITTED") as c1: + count1 = c1.execute(text("SELECT COUNT(*) FROM demo_items WHERE category = 'A'")) + count1 = count1.scalar() + show("T1 first count category=A", count1) + time.sleep(1.0) + count2 = c1.execute(text("SELECT COUNT(*) FROM demo_items WHERE category = 'A'")) + count2 = count2.scalar() + show("T1 second count category=A", count2) + + def t2(): + time.sleep(0.3) + with tx_conn("READ COMMITTED") as c2: + c2.execute(text("INSERT INTO demo_items (category, price) VALUES ('A', 123)")) + show("T2 inserted new row in category A", "commit") + + th1 = threading.Thread(target=t1) + th2 = threading.Thread(target=t2) + th1.start(); th2.start(); th1.join(); th2.join() + + +def repeatable_read_prevents_phantom() -> None: + print("\n--- No phantom read at REPEATABLE READ (Postgres) ---") + + def t1(): + with tx_conn("REPEATABLE READ") as c1: + count1 = c1.execute(text("SELECT COUNT(*) FROM demo_items WHERE category = 'B'")) + count1 = count1.scalar() + show("T1 first count category=B", count1) + time.sleep(1.0) + count2 = c1.execute(text("SELECT COUNT(*) FROM demo_items WHERE category = 'B'")) + count2 = count2.scalar() + show("T1 second count category=B", count2) + + def t2(): + time.sleep(0.3) + with tx_conn("READ COMMITTED") as c2: + c2.execute(text("INSERT INTO demo_items (category, price) VALUES ('B', 999)")) + show("T2 inserted new row in category B", "commit") + + th1 = threading.Thread(target=t1) + th2 = threading.Thread(target=t2) + th1.start(); th2.start(); th1.join(); th2.join() + + +def serializable_prevents_write_skew() -> None: + print("\n--- SERIALIZABLE prevents write skew (one tx may abort) ---") + + errors: list[Exception] = [] + + def tx_doctor(name: str): + try: + with tx_conn("SERIALIZABLE") as c: + others_on = c.execute(text("SELECT COUNT(*) FROM oncall WHERE doctor <> :d AND on_call"), {"d": name}).scalar() + show(f"{name} sees others on call", others_on) + time.sleep(0.5) + c.execute(text("UPDATE oncall SET on_call = FALSE WHERE doctor = :d"), {"d": name}) + except Exception as e: + errors.append(e) + + t1 = threading.Thread(target=tx_doctor, args=("alice",)) + t2 = threading.Thread(target=tx_doctor, args=("bob",)) + t1.start(); t2.start(); t1.join(); t2.join() + + show("Errors (expected >=1 under SERIALIZABLE)", [type(e).__name__ + ": " + str(e) for e in errors]) + + +def dirty_read_note() -> None: + print("\n--- Dirty read at READ UNCOMMITTED ---") + print("Postgres treats READ UNCOMMITTED as READ COMMITTED; dirty reads are not possible.") + + +def main() -> None: + bootstrap_schema() + dirty_read_note() + non_repeatable_read_at_read_committed() + repeatable_read_prevents_non_repeatable() + phantom_read_at_read_committed() + repeatable_read_prevents_phantom() + serializable_prevents_write_skew() + + +if __name__ == "__main__": + main() + + diff --git a/hw2/hw/shop.db b/hw2/hw/shop.db new file mode 100644 index 00000000..57b6e789 Binary files /dev/null and b/hw2/hw/shop.db differ diff --git a/hw2/hw/shop_api/__init__.py b/hw2/hw/shop_api/__init__.py index e69de29b..a9a2c5b3 100644 --- a/hw2/hw/shop_api/__init__.py +++ b/hw2/hw/shop_api/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/hw2/hw/shop_api/chat.py b/hw2/hw/shop_api/chat.py new file mode 100644 index 00000000..cce04f3f --- /dev/null +++ b/hw2/hw/shop_api/chat.py @@ -0,0 +1,59 @@ +from typing import Dict, Optional +from uuid import uuid4 + +from fastapi import WebSocket, WebSocketDisconnect + + +class ChatRoomManager: + def __init__(self) -> None: + self.rooms: Dict[str, set[WebSocket]] = {} + self.usernames: Dict[WebSocket, str] = {} + + async def connect(self, room: str, websocket: WebSocket) -> str: + await websocket.accept() + username = f"user-{uuid4().hex[:8]}" + self.rooms.setdefault(room, set()).add(websocket) + self.usernames[websocket] = username + return username + + def disconnect(self, room: str, websocket: WebSocket) -> None: + connections = self.rooms.get(room) + if connections is not None and websocket in connections: + connections.remove(websocket) + if not connections: + self.rooms.pop(room, None) + self.usernames.pop(websocket, None) + + async def broadcast(self, room: str, message: str, sender: Optional[WebSocket] = None) -> None: + for ws in list(self.rooms.get(room, set())): + if sender is not None and ws is sender: + continue + try: + await ws.send_text(message) + except Exception: + self.disconnect(room, ws) + + def username_for(self, websocket: WebSocket) -> str: + return self.usernames.get(websocket, "unknown") + + +chat_manager = ChatRoomManager() + + +async def handle_chat(websocket: WebSocket, chat_name: str) -> None: + username = await chat_manager.connect(chat_name, websocket) + try: + while True: + message = await websocket.receive_text() + formatted = f"{username} :: {message}" + await chat_manager.broadcast(chat_name, formatted, sender=websocket) + except WebSocketDisconnect: + chat_manager.disconnect(chat_name, websocket) + + +def register_chat_routes(app) -> None: + @app.websocket("/chat/{chat_name}") + async def chat_websocket(websocket: WebSocket, chat_name: str): + await handle_chat(websocket, chat_name) + + diff --git a/hw2/hw/shop_api/db.py b/hw2/hw/shop_api/db.py new file mode 100644 index 00000000..1a87a45a --- /dev/null +++ b/hw2/hw/shop_api/db.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import os +from contextlib import contextmanager +from typing import Iterator + +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session + + +load_dotenv() + + +class Base(DeclarativeBase): + pass + + +def _build_database_url() -> str: + return os.getenv("DATABASE_URL", "sqlite:///./shop.db") + + +DATABASE_URL: str = _build_database_url() + + +def _make_engine(): + connect_args = {} + if DATABASE_URL.startswith("sqlite"): # pragma: no branch + connect_args = {"check_same_thread": False} + return create_engine( + DATABASE_URL, + pool_pre_ping=True, + echo=False, + connect_args=connect_args, + ) + + +engine = _make_engine() +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, class_=Session) + + +def get_session() -> Iterator[Session]: + session = SessionLocal() + try: + yield session + finally: + session.close() + + +@contextmanager +def session_scope() -> Iterator[Session]: # pragma: no cover + session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..0c29bf09 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,257 @@ -from fastapi import FastAPI +import os +from typing import Dict, List, Optional + +from fastapi import FastAPI, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, Depends +from pydantic import BaseModel, Field, ConfigDict +from sqlalchemy import select +from sqlalchemy.orm import Session +from uuid import uuid4 + +from .db import Base as ORMBase, engine, get_session +from prometheus_fastapi_instrumentator import Instrumentator +from .models import Item as ItemModel, Cart as CartModel, CartItem as CartItemModel + app = FastAPI(title="Shop API") + +Instrumentator().instrument(app).expose(app, include_in_schema=False) + + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + + +class ItemCreate(BaseModel): + name: str + price: float = Field(ge=0) + + +class ItemReplace(BaseModel): + name: str + price: float = Field(ge=0) + + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = None + price: Optional[float] = Field(default=None, ge=0) + + +def compute_cart_price(db: Session, cart: CartModel) -> float: + total: float = 0.0 + for ci in cart.items: + if ci.item is None: + continue + total += (ci.item.price or 0.0) * ci.quantity + return total + + +def compute_cart_quantity(cart: CartModel) -> int: + return sum(ci.quantity for ci in cart.items) + + +def cart_to_response(db: Session, cart: CartModel) -> dict: + response_items: List[dict] = [] + for ci in cart.items: + if ci.item is None: + continue + response_items.append( + { + "id": ci.item.id, + "name": ci.item.name, + "quantity": ci.quantity, + "available": not ci.item.deleted, + } + ) + return { + "id": cart.id, + "items": response_items, + "price": compute_cart_price(db, cart), + } + + +def init_schema() -> None: + ORMBase.metadata.create_all(bind=engine) + + +init_schema() + + +@app.on_event("startup") +def on_startup() -> None: + init_schema() + + +# Item endpoints +@app.post("/item", status_code=201) +def create_item(item_in: ItemCreate, db: Session = Depends(get_session)): + item = ItemModel(name=item_in.name, price=item_in.price, deleted=False) + db.add(item) + db.commit() + db.refresh(item) + return {"id": item.id, "name": item.name, "price": item.price, "deleted": item.deleted} + + +@app.get("/item/{item_id}") +def get_item(item_id: int, db: Session = Depends(get_session)): + item = db.get(ItemModel, item_id) + if item is None or item.deleted: + raise HTTPException(status_code=404) + return {"id": item.id, "name": item.name, "price": item.price, "deleted": item.deleted} + + +@app.get("/item") +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(default=None, ge=0), + max_price: Optional[float] = Query(default=None, ge=0), + show_deleted: bool = Query(False), + db: Session = Depends(get_session), +): + stmt = select(ItemModel) + items = list(db.scalars(stmt)) + + 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] + + sliced = items[offset : offset + limit] + return [{"id": i.id, "name": i.name, "price": i.price, "deleted": i.deleted} for i in sliced] + + +@app.put("/item/{item_id}") +def replace_item(item_id: int, item_in: ItemReplace, db: Session = Depends(get_session)): + item = db.get(ItemModel, item_id) + if item is None or item.deleted: + raise HTTPException(status_code=404) + item.name = item_in.name + item.price = item_in.price + db.add(item) + db.commit() + db.refresh(item) + return {"id": item.id, "name": item.name, "price": item.price, "deleted": item.deleted} + + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, patch: ItemPatch, db: Session = Depends(get_session)): + item = db.get(ItemModel, item_id) + if item is None: + raise HTTPException(status_code=404) + if item.deleted: + return Response(status_code=304) + + if patch.name is not None: + item.name = patch.name + if patch.price is not None: + item.price = patch.price + db.add(item) + db.commit() + db.refresh(item) + return {"id": item.id, "name": item.name, "price": item.price, "deleted": item.deleted} + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int, db: Session = Depends(get_session)): + item = db.get(ItemModel, item_id) + if item is not None: + item.deleted = True + db.add(item) + db.commit() + return {"status": "ok"} + + +# Cart endpoints +@app.post("/cart", status_code=201) +def create_cart(response: Response, db: Session = Depends(get_session)): + cart = CartModel() + db.add(cart) + db.commit() + db.refresh(cart) + response.headers["Location"] = f"/cart/{cart.id}" + return {"id": cart.id} + + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int, db: Session = Depends(get_session)): + cart = db.get(CartModel, cart_id) + if cart is None: + raise HTTPException(status_code=404) + return cart_to_response(db, cart) + + +@app.get("/cart") +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(default=None, ge=0), + max_price: Optional[float] = Query(default=None, ge=0), + min_quantity: Optional[int] = Query(default=None, ge=0), + max_quantity: Optional[int] = Query(default=None, ge=0), + db: Session = Depends(get_session), +): + carts = list(db.scalars(select(CartModel))) + + def within_price(cart: CartModel) -> bool: + price = compute_cart_price(db, cart) + if min_price is not None and price < min_price: + return False + if max_price is not None and price > max_price: + return False + return True + + def within_quantity(cart: CartModel) -> bool: + qty = compute_cart_quantity(cart) + if min_quantity is not None and qty < min_quantity: + return False + if max_quantity is not None and qty > max_quantity: + return False + return True + + filtered = [c for c in carts if within_price(c) and within_quantity(c)] + sliced = filtered[offset : offset + limit] + return [cart_to_response(db, c) for c in sliced] + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_session)): + cart = db.get(CartModel, cart_id) + if cart is None: + raise HTTPException(status_code=404) + item = db.get(ItemModel, item_id) + if item is None: + raise HTTPException(status_code=404) + + existing = None + for ci in cart.items: + if ci.item_id == item_id: + existing = ci + break + + if existing is None: + new_ci = CartItemModel(cart_id=cart.id, item_id=item.id, quantity=1) + db.add(new_ci) + else: + existing.quantity += 1 + db.add(existing) + + db.commit() + db.refresh(cart) + return cart_to_response(db, cart) + + +ENABLE_CHAT = os.getenv("ENABLE_CHAT") == "1" + +if ENABLE_CHAT: + from . import chat as chat_module + + chat_module.register_chat_routes(app) \ No newline at end of file diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..4bc647b7 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from sqlalchemy import ForeignKey, String, Integer, Float, Boolean, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .db import Base + + +class Item(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + price: Mapped[float] = mapped_column(Float, nullable=False) + deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + +class Cart(Base): + __tablename__ = "carts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + items: Mapped[list[CartItem]] = relationship( + "CartItem", back_populates="cart", cascade="all, delete-orphan" + ) + + +class CartItem(Base): + __tablename__ = "cart_items" + __table_args__ = (UniqueConstraint("cart_id", "item_id", name="uq_cart_item"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + cart_id: Mapped[int] = mapped_column(ForeignKey("carts.id", ondelete="CASCADE"), nullable=False) + item_id: Mapped[int] = mapped_column(ForeignKey("items.id", ondelete="RESTRICT"), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + cart: Mapped[Cart] = relationship("Cart", back_populates="items") + item: Mapped[Item] = relationship("Item") + + diff --git a/hw2/hw/test_coverage_extra.py b/hw2/hw/test_coverage_extra.py new file mode 100644 index 00000000..074416cd --- /dev/null +++ b/hw2/hw/test_coverage_extra.py @@ -0,0 +1,147 @@ +from http import HTTPStatus +import importlib + +import pytest +from fastapi.testclient import TestClient +from fastapi import WebSocketDisconnect + +from shop_api.main import app + +try: + from shop_api.main import chat_manager, chat_websocket # type: ignore + CHAT_AVAILABLE = True +except Exception: # pragma: no cover + CHAT_AVAILABLE = False +from shop_api import db as db_mod +from shop_api.models import Cart as CartModel, CartItem as CartItemModel + + +client = TestClient(app) + + +def test_get_item_not_found() -> None: + response = client.get("/item/999999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_put_item_not_found() -> None: + response = client.put("/item/999999999", json={"name": "x", "price": 1.0}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_put_item_deleted_returns_404() -> None: + created = client.post("/item", json={"name": "tmp", "price": 1.0}).json() + item_id = created["id"] + client.delete(f"/item/{item_id}") + response = client.put(f"/item/{item_id}", json={"name": "new", "price": 2.5}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_patch_item_not_found() -> None: + response = client.patch("/item/999999999", json={"name": "x"}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_delete_item_nonexistent_ok() -> None: + response = client.delete("/item/987654321") + assert response.status_code == HTTPStatus.OK + assert response.json() == {"status": "ok"} + + +def test_get_cart_not_found() -> None: + response = client.get("/cart/999999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_add_item_to_cart_missing_cart() -> None: + item_id = client.post("/item", json={"name": "tmp2", "price": 2.0}).json()["id"] + response = client.post(f"/cart/999999999/add/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_add_item_to_cart_missing_item() -> None: + cart_id = client.post("/cart").json()["id"] + response = client.post(f"/cart/{cart_id}/add/999999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_cart_with_orphan_item_skips_and_zero_price() -> None: + with db_mod.session_scope() as s: + cart = CartModel() + s.add(cart) + s.commit() + s.refresh(cart) + s.add(CartItemModel(cart_id=cart.id, item_id=999999999, quantity=3)) + s.commit() + + cart_id = cart.id + + resp = client.get(f"/cart/{cart_id}") + assert resp.status_code == HTTPStatus.OK + data = resp.json() + assert data["items"] == [] + assert data["price"] == 0.0 + + +def test_build_database_url_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DATABASE_URL", raising=False) + url = db_mod._build_database_url() + assert url.startswith("sqlite") + + +class RecordingWS: + def __init__(self) -> None: + self.sent: list[str] = [] + + async def accept(self): + return None + + async def send_text(self, message: str): + self.sent.append(message) + + +@pytest.mark.skipif(not CHAT_AVAILABLE, reason="chat disabled") +@pytest.mark.asyncio +async def test_chat_manager_connect_broadcast_disconnect() -> None: + room = "room-a" + ws1 = RecordingWS() + ws2 = RecordingWS() + + name1 = await chat_manager.connect(room, ws1) + name2 = await chat_manager.connect(room, ws2) + assert name1.startswith("user-") and name2.startswith("user-") + + await chat_manager.broadcast(room, "hello", sender=ws1) + assert ws1.sent == [] + assert ws2.sent == ["hello"] + + chat_manager.disconnect(room, ws1) + chat_manager.disconnect(room, ws2) + assert room not in chat_manager.rooms + + +class LoopWS: + def __init__(self, messages: list[str]) -> None: + self._messages = list(messages) + self.sent: list[str] = [] + + async def accept(self): + return None + + async def receive_text(self) -> str: + if self._messages: + return self._messages.pop(0) + raise WebSocketDisconnect() + + async def send_text(self, message: str): + self.sent.append(message) + + +@pytest.mark.skipif(not CHAT_AVAILABLE, reason="chat disabled") +@pytest.mark.asyncio +async def test_chat_websocket_loop_and_disconnect() -> None: + room = "room-b" + ws = LoopWS(["hi"]) + await chat_websocket(ws, room) + assert room not in chat_manager.rooms or ws not in chat_manager.rooms.get(room, set()) + \ No newline at end of file diff --git a/hw2/hw/test_extra.py b/hw2/hw/test_extra.py new file mode 100644 index 00000000..4fe7add4 --- /dev/null +++ b/hw2/hw/test_extra.py @@ -0,0 +1,66 @@ +from http import HTTPStatus + +from fastapi.testclient import TestClient + +from shop_api.main import app + + +client = TestClient(app) + + +def _create_item(name: str = "x", price: float = 1.0) -> dict: + return client.post("/item", json={"name": name, "price": price}).json() + + +def _create_cart() -> int: + return client.post("/cart").json()["id"] + + +def test_get_item_not_found() -> None: + response = client.get("/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_get_cart_not_found() -> None: + response = client.get("/cart/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_add_item_to_cart_not_found_cart() -> None: + item = _create_item() + response = client.post(f"/cart/999999/add/{item['id']}") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_add_item_to_cart_not_found_item() -> None: + cart_id = _create_cart() + response = client.post(f"/cart/{cart_id}/add/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_put_item_deleted() -> None: + item = _create_item() + client.delete(f"/item/{item['id']}") + response = client.put(f"/item/{item['id']}", json={"name": "x", "price": 1.0}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_patch_item_not_found() -> None: + response = client.patch("/item/999999", json={"name": "x"}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_list_items_excludes_deleted_by_default() -> None: + item = _create_item() + client.delete(f"/item/{item['id']}") + response = client.get("/item") + assert response.status_code == HTTPStatus.OK + ids = {it["id"] for it in response.json()} + assert item["id"] not in ids + + +def test_metrics_endpoint_available() -> None: + response = client.get("/metrics") + assert response.status_code == HTTPStatus.OK + assert "python_info" in response.text or "http_requests_total" in response.text + diff --git a/hw2/hw/test_internals.py b/hw2/hw/test_internals.py new file mode 100644 index 00000000..15f43ba5 --- /dev/null +++ b/hw2/hw/test_internals.py @@ -0,0 +1,92 @@ +import importlib +import os + +import pytest + +from shop_api import db as db_mod +from shop_api.main import app, on_startup +from shop_api.chat import chat_manager, handle_chat +from fastapi import WebSocketDisconnect + + +def test_on_startup_creates_schema() -> None: + on_startup() + + +def test_session_scope_commit_and_rollback() -> None: + from shop_api.models import Item + + with db_mod.session_scope() as s: + s.add(Item(name="committed", price=1.0, deleted=False)) + + with db_mod.session_scope() as s: + items = list(s.query(Item).filter_by(name="committed")) + assert any(i.name == "committed" for i in items) + + with pytest.raises(RuntimeError): + with db_mod.session_scope() as s: + s.add(Item(name="rolled", price=2.0, deleted=False)) + raise RuntimeError("force rollback") + + with db_mod.session_scope() as s: + items = list(s.query(Item).filter_by(name="rolled")) + assert not any(i.name == "rolled" for i in items) + + +def test_build_database_url_respects_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/db") + importlib.reload(db_mod) + assert db_mod.DATABASE_URL.startswith("postgresql") + + +def test_make_engine_sqlite_and_non_sqlite(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DATABASE_URL", "sqlite:///./tmp_test.db") + mod = importlib.reload(db_mod) + engine_sqlite = mod._make_engine() + assert engine_sqlite.url.get_backend_name() == "sqlite" + + monkeypatch.setenv("DATABASE_URL", "postgresql+psycopg://u:p@localhost:5432/d") + mod = importlib.reload(db_mod) + engine_pg = mod._make_engine() + assert engine_pg.url.get_backend_name().startswith("postgresql") + + +class DummyWS: + def __init__(self) -> None: + self.sent: list[str] = [] + + async def accept(self): + return None + + async def send_text(self, message: str): + raise RuntimeError("send failed") + + +def test_chat_username_for_unknown() -> None: + dummy = DummyWS() + assert chat_manager.username_for(dummy) == "unknown" + + +@pytest.mark.asyncio +async def test_broadcast_handles_exception_and_cleans_room() -> None: + room = "r1" + ws = DummyWS() + chat_manager.rooms[room] = {ws} + await chat_manager.broadcast(room, "msg") + assert room not in chat_manager.rooms or ws not in chat_manager.rooms.get(room, set()) +@pytest.mark.asyncio +async def test_handle_chat_disconnect() -> None: + class LoopWS: + def __init__(self) -> None: + self.accepted = False + + async def accept(self): + self.accepted = True + return None + + async def receive_text(self) -> str: + raise WebSocketDisconnect() + + ws = LoopWS() + await handle_chat(ws, "r2") +