diff --git a/.github/workflows/hw5-ci.yml b/.github/workflows/hw5-ci.yml new file mode 100644 index 00000000..03fc8f77 --- /dev/null +++ b/.github/workflows/hw5-ci.yml @@ -0,0 +1,57 @@ +name: Python CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: [3.11, 3.13] + + defaults: + run: + working-directory: hw5 + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov fastapi uvicorn httpx + + - name: Set PYTHONPATH + run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV + + - name: Run tests with coverage + run: | + pytest --cov=shop_api \ + --cov-report=term-missing \ + --cov-report=xml \ + --cov-report=html \ + tests/ + + - name: Upload coverage XML + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml + + - name: Upload coverage HTML + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: htmlcov/ diff --git a/hw1/app.py b/hw1/app.py index 6107b870..0322e89c 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,8 @@ from typing import Any, Awaitable, Callable +from urllib.parse import parse_qs +import json +from utils import fibonacci, factorial, mean async def application( scope: dict[str, Any], @@ -12,7 +15,92 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope.get("type") == "lifespan": + while True: + message = await receive() + if message.get("type") == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message.get("type") == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + path = scope["path"] + query = parse_qs(scope["query_string"].decode()) + method = scope.get("method", "GET") + + def json_response(data: dict, status_code: int = 200): + return json.dumps(data).encode(), b"application/json", status_code + + if path == "/factorial" and method == "GET": + n_raw = query.get("n", [None])[0] + if not n_raw: + body, content_type, status = json_response({"detail": "Missing or empty 'n' parameter"}, 422) + else: + try: + n = int(n_raw) + except Exception: + body, content_type, status = json_response({"detail": "'n' must be an integer"}, 422) + else: + if n < 0: + body, content_type, status = json_response({"detail": "'n' must be >= 0"}, 400) + else: + body, content_type, status = json_response({"result": factorial(n)}) + + elif path.startswith("/fibonacci") and method == "GET": + parts = path.split("/") + n_raw = parts[2] if len(parts) > 2 else "" + if not n_raw: + body, content_type, status = json_response({"detail": "Missing or empty n parameter"}, 422) + else: + try: + n = int(n_raw) + except Exception: + body, content_type, status = json_response({"detail": "n must be integer"}, 422) + else: + if n < 0: + body, content_type, status = json_response({"detail": "n must be >= 0"}, 400) + else: + body, content_type, status = json_response({"result": fibonacci(n)}) + + elif path == "/mean" and method == "GET": + raw_body = scope.get("body", b"") + while True: + message = await receive() + if message["type"] == "http.request": + raw_body += message.get("body", b"") + if not message.get("more_body", False): + break + try: + body_data = json.loads(raw_body.decode()) if raw_body else None + except Exception: + body_data = None + + if body_data is None: + body, content_type, status = json_response({"detail": "Missing or invalid body"}, 422) + elif not isinstance(body_data, list) or not body_data: + body, content_type, status = json_response({"detail": "Body must be non-empty list"}, 400) + else: + try: + numbers = [float(x) for x in body_data] + except Exception: + body, content_type, status = json_response({"detail": "All elements must be numbers"}, 400) + else: + body, content_type, status = json_response({"result": mean(",".join(str(x) for x in numbers))}) + + else: + body, content_type, status = json_response({"detail": "Not Found"}, 404) + + await send({ + "type": "http.response.start", + "status": status, + "headers": [[b"content-type", content_type]] + }) + + await send({ + "type": "http.response.body", + "body": body + }) + if __name__ == "__main__": import uvicorn diff --git a/hw1/utils.py b/hw1/utils.py new file mode 100644 index 00000000..de73f266 --- /dev/null +++ b/hw1/utils.py @@ -0,0 +1,19 @@ +def fibonacci(n): + n = int(n) + if n <= 0: + return [] + seq = [0, 1] + for _ in range(2, n): + seq.append(seq[-1] + seq[-2]) + return seq[:n] + +def factorial(n): + n = int(n) + result = 1 + for i in range(2, n+1): + result *= i + return result + +def mean(numbers): + numbers = [float(x) for x in numbers.split(",")] + return sum(numbers) / len(numbers) \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..952e76fc 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,9 @@ from fastapi import FastAPI +from shop_api.routers import items, carts, chat + app = FastAPI(title="Shop API") + +app.include_router(items.router) +app.include_router(carts.router) +app.include_router(chat.router) \ No newline at end of file diff --git a/hw2/hw/shop_api/models/__init__.py b/hw2/hw/shop_api/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/routers/__init__.py b/hw2/hw/shop_api/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/routers/carts.py b/hw2/hw/shop_api/routers/carts.py new file mode 100644 index 00000000..39a179be --- /dev/null +++ b/hw2/hw/shop_api/routers/carts.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, HTTPException, Query, Path, Response +from shop_api.schemas.cart import Cart +from shop_api.utils.cart_utils import compute_cart +from shop_api.storage.memory import _carts, _items, _lock, _next_cart_id +from typing import Optional, List + + +router = APIRouter(prefix="/cart", tags=["carts"]) + +@router.post("/", status_code=201) +def create_cart(response: Response): + global _next_cart_id + with _lock: + cid = _next_cart_id + _next_cart_id += 1 + _carts[cid] = {} + response.headers["Location"] = f"/cart/{cid}" + return {"id": cid} + +@router.get("/{id}", response_model=Cart) +def get_cart(id: int = Path(..., gt=0)): + try: + return compute_cart(id) + except KeyError: + raise HTTPException(status_code=404, detail="cart not found") + +@router.get("/", 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), +): + carts = [] + for cid in sorted(_carts.keys()): + cart = compute_cart(cid) + total_qty = sum(i.quantity for i in 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 + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + carts.append(cart) + return carts[offset: offset + limit] + +@router.post("/{cart_id}/add/{item_id}", response_model=Cart) +def add_item(cart_id: int, item_id: int): + if cart_id not in _carts: + raise HTTPException(status_code=404, detail="cart not found") + if item_id not in _items: + raise HTTPException(status_code=404, detail="item not found") + _carts[cart_id][item_id] = _carts[cart_id].get(item_id, 0) + 1 + return compute_cart(cart_id) diff --git a/hw2/hw/shop_api/routers/chat.py b/hw2/hw/shop_api/routers/chat.py new file mode 100644 index 00000000..0dcf6da9 --- /dev/null +++ b/hw2/hw/shop_api/routers/chat.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from collections import defaultdict +import random +import string +from typing import Dict, List + +router = APIRouter(prefix="/chat", tags=["chat"]) + +chat_rooms: Dict[str, List[WebSocket]] = defaultdict(list) +usernames: Dict[WebSocket, str] = {} + +def random_username() -> str: + return ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + +@router.websocket("/{chat_name}") +async def websocket_chat(websocket: WebSocket, chat_name: str): + await websocket.accept() + username = random_username() + usernames[websocket] = username + chat_rooms[chat_name].append(websocket) + try: + while True: + data = await websocket.receive_text() + message = f"{username} :: {data}" + for ws in chat_rooms[chat_name]: + if ws != websocket: + await ws.send_text(message) + except WebSocketDisconnect: + chat_rooms[chat_name].remove(websocket) + del usernames[websocket] diff --git a/hw2/hw/shop_api/routers/items.py b/hw2/hw/shop_api/routers/items.py new file mode 100644 index 00000000..3942bd8b --- /dev/null +++ b/hw2/hw/shop_api/routers/items.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, HTTPException, Query, Response +from shop_api.schemas.item import Item, ItemCreate +from shop_api.storage.memory import _items, _lock, _next_item_id +from typing import Optional, List + + +router = APIRouter(prefix="/item", tags=["items"]) + +@router.post("/", response_model=Item, status_code=201) +def create_item(item: ItemCreate): + global _next_item_id + with _lock: + iid = _next_item_id + _next_item_id += 1 + new_item = Item(id=iid, name=item.name, price=item.price) + _items[iid] = new_item + return new_item + +@router.get("/{id}", response_model=Item) +def get_item(id: int): + item = _items.get(id) + if not item or item.deleted: + raise HTTPException(status_code=404, detail="item not found") + return item + +@router.get("/", 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 = Query(False), +): + items = [] + for it in _items.values(): + if not show_deleted and it.deleted: + continue + if min_price is not None and it.price < min_price: + continue + if max_price is not None and it.price > max_price: + continue + items.append(it) + return items[offset: offset + limit] + +@router.put("/{id}", response_model=Item) +def replace_item(id: int, item: ItemCreate): + if id not in _items: + raise HTTPException(status_code=404, detail="item not found") + existing = _items[id] + existing.name = item.name + existing.price = item.price + _items[id] = existing + return existing + +@router.patch("/{id}", response_model=Item) +def patch_item(id: int, patch: dict): + if id not in _items: + raise HTTPException(status_code=404, detail="item not found") + item = _items[id] + if item.deleted: + return Response(status_code=304) + allowed_keys = {"name", "price"} + if not set(patch.keys()).issubset(allowed_keys): + raise HTTPException(status_code=422) + if "price" in patch: + price = patch["price"] + if price is not None and price < 0: + raise HTTPException(status_code=422) + item.price = price + if "name" in patch: + item.name = patch["name"] + _items[id] = item + return item + +@router.delete("/{id}") +def delete_item(id: int): + item = _items.get(id) + if not item: + return {"status": "ok"} + item.deleted = True + return {"status": "ok"} \ No newline at end of file diff --git a/hw2/hw/shop_api/schemas/__init__.py b/hw2/hw/shop_api/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/schemas/cart.py b/hw2/hw/shop_api/schemas/cart.py new file mode 100644 index 00000000..4e3ed90c --- /dev/null +++ b/hw2/hw/shop_api/schemas/cart.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel +from typing import List + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float \ No newline at end of file diff --git a/hw2/hw/shop_api/schemas/item.py b/hw2/hw/shop_api/schemas/item.py new file mode 100644 index 00000000..53021cde --- /dev/null +++ b/hw2/hw/shop_api/schemas/item.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class ItemBase(BaseModel): + name: str + price: float = Field(..., ge=0) + +class ItemCreate(ItemBase): + pass + +class Item(ItemBase): + id: int + deleted: bool = False + +class ItemPatch(BaseModel): + name: Optional[str] + price: Optional[float] \ No newline at end of file diff --git a/hw2/hw/shop_api/storage/__init__.py b/hw2/hw/shop_api/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/storage/memory.py b/hw2/hw/shop_api/storage/memory.py new file mode 100644 index 00000000..e769f81d --- /dev/null +++ b/hw2/hw/shop_api/storage/memory.py @@ -0,0 +1,9 @@ +from typing import Dict +from threading import Lock +from shop_api.schemas.item import Item + +_items: Dict[int, Item] = {} +_carts: Dict[int, Dict[int, int]] = {} +_next_item_id = 1 +_next_cart_id = 1 +_lock = Lock() diff --git a/hw2/hw/shop_api/utils/cart_utils.py b/hw2/hw/shop_api/utils/cart_utils.py new file mode 100644 index 00000000..b0c65c2e --- /dev/null +++ b/hw2/hw/shop_api/utils/cart_utils.py @@ -0,0 +1,20 @@ +from shop_api.schemas.cart import Cart, CartItem +from shop_api.storage.memory import _carts, _items + + +def compute_cart(cart_id: int) -> Cart: + if cart_id not in _carts: + raise KeyError + bag = _carts[cart_id] + items_out = [] + total = 0.0 + for iid, qty in bag.items(): + item = _items.get(iid) + if item is None: + name, available = "", False + else: + name, available = item.name, not item.deleted + items_out.append(CartItem(id=iid, name=name, quantity=qty, available=available)) + if item and not item.deleted: + total += item.price * qty + return Cart(id=cart_id, items=items_out, price=total) \ No newline at end of file diff --git a/hw2/hw/websocket_test.py b/hw2/hw/websocket_test.py new file mode 100644 index 00000000..2f14a519 --- /dev/null +++ b/hw2/hw/websocket_test.py @@ -0,0 +1,40 @@ +from fastapi.testclient import TestClient +from shop_api.main import app +import threading + +client = TestClient(app) + + +def test_chat_broadcast(): + with ( + client.websocket_connect("/chat/testroom") as ws1, + client.websocket_connect("/chat/testroom") as ws2 + ): + ws1.send_text("Hello world!") + message = ws2.receive_text() + + assert "Hello world!" in message + assert "::" in message + + +def test_chat_isolation_between_rooms(): + with ( + client.websocket_connect("/chat/testroom") as ws1, + client.websocket_connect("/chat/anotherroom") as ws2 + ): + ws1.send_text("Message to room1") + + received = [] + + def try_receive(): + try: + msg = ws2.receive_text() + received.append(msg) + except Exception: + pass + + t = threading.Thread(target=try_receive, daemon=True) + t.start() + t.join(timeout=0.5) + + assert not received diff --git a/hw3/Dockerfile b/hw3/Dockerfile new file mode 100644 index 00000000..f08e675f --- /dev/null +++ b/hw3/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/hw3/README.md b/hw3/README.md new file mode 100644 index 00000000..33cd5544 --- /dev/null +++ b/hw3/README.md @@ -0,0 +1,8 @@ +### Grafana + Prometheus +![Tech Dashboard](./pics/tech_dashboard.jpg) + +![Business Dashboard](./pics/business_dashboard.jpg) + +### Запуск: +```bash +docker-compose up --build diff --git a/hw3/docker-compose.yml b/hw3/docker-compose.yml new file mode 100644 index 00000000..695d209d --- /dev/null +++ b/hw3/docker-compose.yml @@ -0,0 +1,37 @@ +version: "3.9" + +services: + app: + build: . + container_name: shop_api + ports: + - "8000:8000" + networks: + - shopnet + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + networks: + - shopnet + + grafana: + image: grafana/grafana:latest + container_name: grafana + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_SECURITY_ADMIN_USER=admin + ports: + - "3000:3000" + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning + networks: + - shopnet + +networks: + shopnet: + driver: bridge diff --git a/hw3/grafana/provisioning/dashboards/dashboard.yml b/hw3/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..472a2279 --- /dev/null +++ b/hw3/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: "Shop API Technical" + folder: "Tech" + options: + path: /etc/grafana/provisioning/dashboards + - name: "Shop API Business" + folder: "Business" + options: + path: /etc/grafana/provisioning/dashboards diff --git a/hw3/grafana/provisioning/dashboards/shop_api_business_dashboard.json b/hw3/grafana/provisioning/dashboards/shop_api_business_dashboard.json new file mode 100644 index 00000000..794d4643 --- /dev/null +++ b/hw3/grafana/provisioning/dashboards/shop_api_business_dashboard.json @@ -0,0 +1,334 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 4, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 15, + "x": 0, + "y": 0 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "code", + "expr": "rate(shop_carts_created_total[1m])", + "legendFormat": "Carts per minute", + "range": true, + "refId": "A" + } + ], + "title": "Carts Created per Minute", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 15, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "count(shop_ws_connections)", + "refId": "A" + } + ], + "title": "Active Chat Rooms", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 15, + "x": 0, + "y": 6 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "sum(rate(shop_ws_messages_total[1m])) by (chat_name)", + "legendFormat": "{{chat_name}}", + "refId": "A" + } + ], + "title": "Chat Messages per Minute", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 15, + "y": 6 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "sum(shop_items_total)", + "refId": "A" + } + ], + "title": "Total Items in Stock", + "type": "stat" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "timepicker": {}, + "timezone": "browser", + "title": "Business Metrics", + "uid": "440d983f-0b3d-4579-b85d-3ee2dad1babd", + "version": 2 +} \ No newline at end of file diff --git a/hw3/grafana/provisioning/dashboards/shop_api_dashboard.json b/hw3/grafana/provisioning/dashboards/shop_api_dashboard.json new file mode 100644 index 00000000..5779d8e0 --- /dev/null +++ b/hw3/grafana/provisioning/dashboards/shop_api_dashboard.json @@ -0,0 +1,408 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(http_requests_total[1m])) by (method, handler)", + "legendFormat": "{{method}} {{handler}}", + "range": true, + "refId": "A" + } + ], + "title": "HTTP Requests per Second", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p95", + "refId": "A" + } + ], + "title": "Request Duration (p95)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 7 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "shop_ws_connections", + "legendFormat": "{{chat_name}}", + "refId": "A" + } + ], + "title": "Active WebSocket Connections", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 7 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "increase(shop_ws_messages_total[5m])", + "legendFormat": "{{chat_name}}", + "refId": "A" + } + ], + "title": "WebSocket Messages Sent", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "timepicker": {}, + "timezone": "browser", + "title": "Shop API Dashboard", + "uid": "ecd408fb-c57c-4ba2-8452-260e76f954ed", + "version": 3 +} \ No newline at end of file diff --git a/hw3/grafana/provisioning/datasources/datasource.yml b/hw3/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 00000000..7285dbe4 --- /dev/null +++ b/hw3/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + orgId: 1 + uid: prometheus_ds + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/hw3/pics/business_dashboard.jpg b/hw3/pics/business_dashboard.jpg new file mode 100644 index 00000000..0abdb880 Binary files /dev/null and b/hw3/pics/business_dashboard.jpg differ diff --git a/hw3/pics/tech_dashboard.jpg b/hw3/pics/tech_dashboard.jpg new file mode 100644 index 00000000..937ebdf0 Binary files /dev/null and b/hw3/pics/tech_dashboard.jpg differ diff --git a/hw3/prometheus/prometheus.yml b/hw3/prometheus/prometheus.yml new file mode 100644 index 00000000..046265e3 --- /dev/null +++ b/hw3/prometheus/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: 'shop_api' + static_configs: + - targets: ['app:8000'] + metrics_path: /metrics diff --git a/hw3/requirements.txt b/hw3/requirements.txt new file mode 100644 index 00000000..415c999e --- /dev/null +++ b/hw3/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn[standard] +prometheus-fastapi-instrumentator +pydantic \ No newline at end of file diff --git a/hw3/shop_api/__init__.py b/hw3/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/shop_api/main.py b/hw3/shop_api/main.py new file mode 100644 index 00000000..030e07d8 --- /dev/null +++ b/hw3/shop_api/main.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI +from shop_api.routers import items, carts, chat +from prometheus_fastapi_instrumentator import Instrumentator + + +app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +app.include_router(items.router) +app.include_router(carts.router) +app.include_router(chat.router) \ No newline at end of file diff --git a/hw3/shop_api/metrics.py b/hw3/shop_api/metrics.py new file mode 100644 index 00000000..0b18aff2 --- /dev/null +++ b/hw3/shop_api/metrics.py @@ -0,0 +1,8 @@ +from prometheus_client import Counter, Gauge + +carts_created_counter = Counter( + "shop_carts_created_total", "Total number of carts created" +) +items_in_stock_gauge = Gauge( + "shop_items_total", "Current number of non-deleted items in stock" +) diff --git a/hw3/shop_api/models/__init__.py b/hw3/shop_api/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/shop_api/routers/__init__.py b/hw3/shop_api/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/shop_api/routers/carts.py b/hw3/shop_api/routers/carts.py new file mode 100644 index 00000000..37076862 --- /dev/null +++ b/hw3/shop_api/routers/carts.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, HTTPException, Query, Path, Response +from shop_api.schemas.cart import Cart +from shop_api.utils.cart_utils import compute_cart +from shop_api.metrics import carts_created_counter +from shop_api.storage.memory import _carts, _items, _lock, _next_cart_id +from typing import Optional, List + + +router = APIRouter(prefix="/cart", tags=["carts"]) + +@router.post("/", status_code=201) +def create_cart(response: Response): + global _next_cart_id + with _lock: + cid = _next_cart_id + _next_cart_id += 1 + _carts[cid] = {} + response.headers["Location"] = f"/cart/{cid}" + carts_created_counter.inc() + return {"id": cid} + +@router.get("/{id}", response_model=Cart) +def get_cart(id: int = Path(..., gt=0)): + try: + return compute_cart(id) + except KeyError: + raise HTTPException(status_code=404, detail="cart not found") + +@router.get("/", 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), +): + carts = [] + for cid in sorted(_carts.keys()): + cart = compute_cart(cid) + total_qty = sum(i.quantity for i in 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 + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + carts.append(cart) + return carts[offset: offset + limit] + +@router.post("/{cart_id}/add/{item_id}", response_model=Cart) +def add_item(cart_id: int, item_id: int): + if cart_id not in _carts: + raise HTTPException(status_code=404, detail="cart not found") + if item_id not in _items: + raise HTTPException(status_code=404, detail="item not found") + _carts[cart_id][item_id] = _carts[cart_id].get(item_id, 0) + 1 + return compute_cart(cart_id) diff --git a/hw3/shop_api/routers/chat.py b/hw3/shop_api/routers/chat.py new file mode 100644 index 00000000..ef026015 --- /dev/null +++ b/hw3/shop_api/routers/chat.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from collections import defaultdict +import random +import string +from typing import Dict, List +from prometheus_client import Counter, Gauge + +ws_connections = Gauge("shop_ws_connections", "Active WebSocket connections", ["chat_name"]) +ws_messages_total = Counter("shop_ws_messages_total", "Total WebSocket messages sent", ["chat_name"]) + +router = APIRouter(prefix="/chat", tags=["chat"]) + +chat_rooms: Dict[str, List[WebSocket]] = defaultdict(list) +usernames: Dict[WebSocket, str] = {} + +def random_username() -> str: + return ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + +@router.websocket("/{chat_name}") +async def websocket_chat(websocket: WebSocket, chat_name: str): + await websocket.accept() + username = random_username() + usernames[websocket] = username + chat_rooms[chat_name].append(websocket) + ws_connections.labels(chat_name=chat_name).inc() # <--- увеличиваем число соединений + + try: + while True: + data = await websocket.receive_text() + message = f"{username} :: {data}" + for ws in chat_rooms[chat_name]: + if ws != websocket: + await ws.send_text(message) + ws_messages_total.labels(chat_name=chat_name).inc() # <--- увеличиваем счетчик сообщений + except WebSocketDisconnect: + chat_rooms[chat_name].remove(websocket) + del usernames[websocket] + ws_connections.labels(chat_name=chat_name).dec() # <--- уменьшаем число соединений + diff --git a/hw3/shop_api/routers/items.py b/hw3/shop_api/routers/items.py new file mode 100644 index 00000000..e432edb8 --- /dev/null +++ b/hw3/shop_api/routers/items.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, HTTPException, Query, Response +from shop_api.schemas.item import Item, ItemCreate +from shop_api.storage.memory import _items, _lock, _next_item_id +from typing import Optional, List +from shop_api.metrics import items_in_stock_gauge + +router = APIRouter(prefix="/item", tags=["items"]) + +@router.post("/", response_model=Item, status_code=201) +def create_item(item: ItemCreate): + global _next_item_id + with _lock: + iid = _next_item_id + _next_item_id += 1 + new_item = Item(id=iid, name=item.name, price=item.price) + _items[iid] = new_item + items_in_stock_gauge.inc() + return new_item + +@router.get("/{id}", response_model=Item) +def get_item(id: int): + item = _items.get(id) + if not item or item.deleted: + raise HTTPException(status_code=404, detail="item not found") + return item + +@router.get("/", 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 = Query(False), +): + items = [] + for it in _items.values(): + if not show_deleted and it.deleted: + continue + if min_price is not None and it.price < min_price: + continue + if max_price is not None and it.price > max_price: + continue + items.append(it) + return items[offset: offset + limit] + +@router.put("/{id}", response_model=Item) +def replace_item(id: int, item: ItemCreate): + if id not in _items: + raise HTTPException(status_code=404, detail="item not found") + existing = _items[id] + existing.name = item.name + existing.price = item.price + _items[id] = existing + return existing + +@router.patch("/{id}", response_model=Item) +def patch_item(id: int, patch: dict): + if id not in _items: + raise HTTPException(status_code=404, detail="item not found") + item = _items[id] + if item.deleted: + return Response(status_code=304) + allowed_keys = {"name", "price"} + if not set(patch.keys()).issubset(allowed_keys): + raise HTTPException(status_code=422) + if "price" in patch: + price = patch["price"] + if price is not None and price < 0: + raise HTTPException(status_code=422) + item.price = price + if "name" in patch: + item.name = patch["name"] + _items[id] = item + return item + +@router.delete("/{id}") +def delete_item(id: int): + item = _items.get(id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not item.deleted: + item.deleted = True + items_in_stock_gauge.dec() \ No newline at end of file diff --git a/hw3/shop_api/schemas/__init__.py b/hw3/shop_api/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/shop_api/schemas/cart.py b/hw3/shop_api/schemas/cart.py new file mode 100644 index 00000000..4e3ed90c --- /dev/null +++ b/hw3/shop_api/schemas/cart.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel +from typing import List + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float \ No newline at end of file diff --git a/hw3/shop_api/schemas/item.py b/hw3/shop_api/schemas/item.py new file mode 100644 index 00000000..53021cde --- /dev/null +++ b/hw3/shop_api/schemas/item.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class ItemBase(BaseModel): + name: str + price: float = Field(..., ge=0) + +class ItemCreate(ItemBase): + pass + +class Item(ItemBase): + id: int + deleted: bool = False + +class ItemPatch(BaseModel): + name: Optional[str] + price: Optional[float] \ No newline at end of file diff --git a/hw3/shop_api/storage/__init__.py b/hw3/shop_api/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/shop_api/storage/memory.py b/hw3/shop_api/storage/memory.py new file mode 100644 index 00000000..e769f81d --- /dev/null +++ b/hw3/shop_api/storage/memory.py @@ -0,0 +1,9 @@ +from typing import Dict +from threading import Lock +from shop_api.schemas.item import Item + +_items: Dict[int, Item] = {} +_carts: Dict[int, Dict[int, int]] = {} +_next_item_id = 1 +_next_cart_id = 1 +_lock = Lock() diff --git a/hw3/shop_api/utils/cart_utils.py b/hw3/shop_api/utils/cart_utils.py new file mode 100644 index 00000000..b0c65c2e --- /dev/null +++ b/hw3/shop_api/utils/cart_utils.py @@ -0,0 +1,20 @@ +from shop_api.schemas.cart import Cart, CartItem +from shop_api.storage.memory import _carts, _items + + +def compute_cart(cart_id: int) -> Cart: + if cart_id not in _carts: + raise KeyError + bag = _carts[cart_id] + items_out = [] + total = 0.0 + for iid, qty in bag.items(): + item = _items.get(iid) + if item is None: + name, available = "", False + else: + name, available = item.name, not item.deleted + items_out.append(CartItem(id=iid, name=name, quantity=qty, available=available)) + if item and not item.deleted: + total += item.price * qty + return Cart(id=cart_id, items=items_out, price=total) \ No newline at end of file diff --git a/hw3/test.py b/hw3/test.py new file mode 100644 index 00000000..a05b0cca --- /dev/null +++ b/hw3/test.py @@ -0,0 +1,102 @@ +import asyncio +import random +import string +import threading +import time +import requests +import websockets +from http import HTTPStatus + +API_URL = "http://localhost:8000" +WS_URL = "ws://localhost:8000/chat" + +def random_name(length=8): + return ''.join(random.choices(string.ascii_lowercase, k=length)) + +def random_price(): + return round(random.uniform(5, 500), 2) + + +def simulate_http_load(): + while True: + try: + item = {"name": f"Item {random_name()}", "price": random_price()} + start = time.perf_counter() + r = requests.post(f"{API_URL}/item", json=item) + latency = time.perf_counter() - start + + if r.status_code == HTTPStatus.CREATED: + item_id = r.json()["id"] + requests.get(f"{API_URL}/item/{item_id}") + requests.put( + f"{API_URL}/item/{item_id}", + json={"name": item["name"], "price": item["price"] + 1}, + ) + if random.random() < 0.2: + requests.delete(f"{API_URL}/item/{item_id}") + else: + requests.get(f"{API_URL}/item/99999999") + + cart_response = requests.post(f"{API_URL}/cart") + if cart_response.status_code == HTTPStatus.CREATED: + cart_id = cart_response.json()["id"] + if r.status_code == HTTPStatus.CREATED: + requests.post(f"{API_URL}/cart/{cart_id}/add/{r.json()['id']}") + requests.get(f"{API_URL}/cart/{cart_id}") + else: + requests.get(f"{API_URL}/cart/-1") + + requests.get(f"{API_URL}/item", params={ + "min_price": random.uniform(1, 50), + "max_price": random.uniform(100, 500), + "offset": random.randint(0, 5), + "limit": random.randint(1, 10), + }) + + requests.get(f"{API_URL}/cart", params={ + "min_price": random.uniform(1, 50), + "max_price": random.uniform(100, 500), + "offset": random.randint(0, 5), + "limit": random.randint(1, 10), + }) + + asyncio.run(asyncio.sleep(random.uniform(0.05, 0.2))) + + except Exception as e: + print(f"[HTTP Error] {e}") + + +async def simulate_websocket_load(chat_name: str): + uri = f"{WS_URL}/{chat_name}" + try: + async with websockets.connect(uri) as ws: + for _ in range(random.randint(5, 15)): + msg = f"Hello from {random_name()}!" + await ws.send(msg) + try: + await asyncio.wait_for(ws.recv(), timeout=1) + except asyncio.TimeoutError: + pass + await asyncio.sleep(random.uniform(0.2, 1.0)) + except Exception as e: + print(f"[WS Error] {e}") + + +def run_websocket_load(): + asyncio.run(simulate_websocket_load(random.choice(["general", "orders", "support", "promo"]))) + + +if __name__ == "__main__": + print("Started") + + for _ in range(8): + threading.Thread(target=simulate_http_load, daemon=True).start() + + for _ in range(4): + threading.Thread(target=run_websocket_load, daemon=True).start() + + try: + while True: + time.sleep(2) + except KeyboardInterrupt: + print("Stopped") diff --git a/hw4/Dockerfile b/hw4/Dockerfile new file mode 100644 index 00000000..f08e675f --- /dev/null +++ b/hw4/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/hw4/README.md b/hw4/README.md new file mode 100644 index 00000000..cad0c96f --- /dev/null +++ b/hw4/README.md @@ -0,0 +1,9 @@ +### Технический мониторинг +![Tech Dashboard](./pics/tech_dashboard.jpg) + +### Бизнес-метрики +![Business Dashboard](./pics/business_dashboard.jpg) + +### Запуск: +```bash +docker-compose up --build \ No newline at end of file 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..01a84a56 --- /dev/null +++ b/hw4/docker-compose.yml @@ -0,0 +1,51 @@ +services: + db: + image: postgres:15 + environment: + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_pass + POSTGRES_DB: shop_db + ports: + - "5432:5432" + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U shop_user -d shop_db"] + interval: 2s + timeout: 2s + retries: 10 + + shop-api: + build: . + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_pass@db:5432/shop_db + ports: + - "8000:8000" + + prometheus: + image: prom/prometheus:latest + volumes: + - ./prometheus:/etc/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + ports: + - "9090:9090" + + grafana: + image: grafana/grafana:latest + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + ports: + - "3000:3000" + depends_on: + - prometheus + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + +volumes: + db_data: + grafana_data: diff --git a/hw4/grafana/provisioning/dashboards/dashboard.yml b/hw4/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..472a2279 --- /dev/null +++ b/hw4/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: "Shop API Technical" + folder: "Tech" + options: + path: /etc/grafana/provisioning/dashboards + - name: "Shop API Business" + folder: "Business" + options: + path: /etc/grafana/provisioning/dashboards diff --git a/hw4/grafana/provisioning/dashboards/shop_api_business_dashboard.json b/hw4/grafana/provisioning/dashboards/shop_api_business_dashboard.json new file mode 100644 index 00000000..794d4643 --- /dev/null +++ b/hw4/grafana/provisioning/dashboards/shop_api_business_dashboard.json @@ -0,0 +1,334 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 4, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 15, + "x": 0, + "y": 0 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "code", + "expr": "rate(shop_carts_created_total[1m])", + "legendFormat": "Carts per minute", + "range": true, + "refId": "A" + } + ], + "title": "Carts Created per Minute", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 15, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "count(shop_ws_connections)", + "refId": "A" + } + ], + "title": "Active Chat Rooms", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 15, + "x": 0, + "y": 6 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "sum(rate(shop_ws_messages_total[1m])) by (chat_name)", + "legendFormat": "{{chat_name}}", + "refId": "A" + } + ], + "title": "Chat Messages per Minute", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 15, + "y": 6 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "sum(shop_items_total)", + "refId": "A" + } + ], + "title": "Total Items in Stock", + "type": "stat" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "timepicker": {}, + "timezone": "browser", + "title": "Business Metrics", + "uid": "440d983f-0b3d-4579-b85d-3ee2dad1babd", + "version": 2 +} \ No newline at end of file diff --git a/hw4/grafana/provisioning/dashboards/shop_api_dashboard.json b/hw4/grafana/provisioning/dashboards/shop_api_dashboard.json new file mode 100644 index 00000000..5779d8e0 --- /dev/null +++ b/hw4/grafana/provisioning/dashboards/shop_api_dashboard.json @@ -0,0 +1,408 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(http_requests_total[1m])) by (method, handler)", + "legendFormat": "{{method}} {{handler}}", + "range": true, + "refId": "A" + } + ], + "title": "HTTP Requests per Second", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p95", + "refId": "A" + } + ], + "title": "Request Duration (p95)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 7 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "shop_ws_connections", + "legendFormat": "{{chat_name}}", + "refId": "A" + } + ], + "title": "Active WebSocket Connections", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_ds" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 7 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "increase(shop_ws_messages_total[5m])", + "legendFormat": "{{chat_name}}", + "refId": "A" + } + ], + "title": "WebSocket Messages Sent", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "timepicker": {}, + "timezone": "browser", + "title": "Shop API Dashboard", + "uid": "ecd408fb-c57c-4ba2-8452-260e76f954ed", + "version": 3 +} \ No newline at end of file diff --git a/hw4/grafana/provisioning/datasources/datasource.yml b/hw4/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 00000000..7285dbe4 --- /dev/null +++ b/hw4/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + orgId: 1 + uid: prometheus_ds + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/hw4/pics/business_dashboard.jpg b/hw4/pics/business_dashboard.jpg new file mode 100644 index 00000000..0abdb880 Binary files /dev/null and b/hw4/pics/business_dashboard.jpg differ diff --git a/hw4/pics/tech_dashboard.jpg b/hw4/pics/tech_dashboard.jpg new file mode 100644 index 00000000..937ebdf0 Binary files /dev/null and b/hw4/pics/tech_dashboard.jpg differ diff --git a/hw4/prometheus/prometheus.yml b/hw4/prometheus/prometheus.yml new file mode 100644 index 00000000..046265e3 --- /dev/null +++ b/hw4/prometheus/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: 'shop_api' + static_configs: + - targets: ['app:8000'] + metrics_path: /metrics diff --git a/hw4/requirements.txt b/hw4/requirements.txt new file mode 100644 index 00000000..76867af0 --- /dev/null +++ b/hw4/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +prometheus-fastapi-instrumentator +pydantic +sqlalchemy +asyncpg \ No newline at end of file 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/database.py b/hw4/shop_api/database.py new file mode 100644 index 00000000..3319d7b3 --- /dev/null +++ b/hw4/shop_api/database.py @@ -0,0 +1,17 @@ +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker, declarative_base + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://shop_user:shop_pass@db:5432/shop_db" +) + +engine = create_async_engine(DATABASE_URL, echo=True, future=True) +async_session_maker = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) +Base = declarative_base() + + +async def get_session() -> AsyncSession: + async with async_session_maker() as session: + yield session diff --git a/hw4/shop_api/main.py b/hw4/shop_api/main.py new file mode 100644 index 00000000..1d866fb9 --- /dev/null +++ b/hw4/shop_api/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI +from shop_api.routers import items, carts +from shop_api.database import Base, engine + +app = FastAPI(title="Shop API") + +@app.on_event("startup") +async def on_startup(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +app.include_router(items.router) +app.include_router(carts.router) diff --git a/hw4/shop_api/metrics.py b/hw4/shop_api/metrics.py new file mode 100644 index 00000000..0b18aff2 --- /dev/null +++ b/hw4/shop_api/metrics.py @@ -0,0 +1,8 @@ +from prometheus_client import Counter, Gauge + +carts_created_counter = Counter( + "shop_carts_created_total", "Total number of carts created" +) +items_in_stock_gauge = Gauge( + "shop_items_total", "Current number of non-deleted items in stock" +) diff --git a/hw4/shop_api/models/__init__.py b/hw4/shop_api/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/shop_api/models/cart.py b/hw4/shop_api/models/cart.py new file mode 100644 index 00000000..2695bee4 --- /dev/null +++ b/hw4/shop_api/models/cart.py @@ -0,0 +1,22 @@ +from sqlalchemy import Column, Integer, Float, ForeignKey +from sqlalchemy.orm import relationship +from shop_api.database import Base + + +class Cart(Base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, index=True) + price = Column(Float, default=0.0) + items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan") + + +class CartItem(Base): + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, ForeignKey("carts.id")) + item_id = Column(Integer, ForeignKey("items.id")) + quantity = Column(Integer, default=1) + + cart = relationship("Cart", back_populates="items") \ No newline at end of file diff --git a/hw4/shop_api/models/item.py b/hw4/shop_api/models/item.py new file mode 100644 index 00000000..184b6617 --- /dev/null +++ b/hw4/shop_api/models/item.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean +from shop_api.database import Base + +class Item(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) \ No newline at end of file diff --git a/hw4/shop_api/routers/__init__.py b/hw4/shop_api/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/shop_api/routers/carts.py b/hw4/shop_api/routers/carts.py new file mode 100644 index 00000000..38b0444f --- /dev/null +++ b/hw4/shop_api/routers/carts.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from typing import List, Optional + +from shop_api.database import get_session +from shop_api.models.cart import Cart, CartItem +from shop_api.models.item import Item +from shop_api.schemas.cart import CartOut, CartItemOut +from shop_api.metrics import carts_created_counter + +router = APIRouter(prefix="/cart", tags=["cart"]) + + +@router.post("/", status_code=201) +async def create_cart(session: AsyncSession = Depends(get_session)): + cart = Cart() + session.add(cart) + await session.commit() + await session.refresh(cart) + + carts_created_counter.inc() + return {"id": cart.id} + + +@router.post("/{cart_id}/add/{item_id}", status_code=200) +async def add_item_to_cart(cart_id: int, item_id: int, session: AsyncSession = Depends(get_session)): + cart = await session.get(Cart, cart_id) + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + item = await session.get(Item, item_id) + if not item or item.deleted: + raise HTTPException(status_code=404, detail="Item not found") + + result = await session.execute( + select(CartItem).where(CartItem.cart_id == cart_id, CartItem.item_id == item_id) + ) + cart_item = result.scalar_one_or_none() + + if cart_item: + cart_item.quantity += 1 + else: + cart_item = CartItem(cart_id=cart_id, item_id=item_id, quantity=1) + session.add(cart_item) + + await session.commit() + return {"status": "ok"} + + +@router.get("/{cart_id}", response_model=CartOut) +async def get_cart(cart_id: int, session: AsyncSession = Depends(get_session)): + cart = await session.get(Cart, cart_id) + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + result = await session.execute( + select(CartItem, Item) + .join(Item, CartItem.item_id == Item.id) + .where(CartItem.cart_id == cart_id) + ) + + cart_items = [] + total_price = 0.0 + for cart_item, item in result.all(): + cart_items.append( + CartItemOut( + id=item.id, + name=item.name, + quantity=cart_item.quantity, + available=not item.deleted, + ) + ) + if not item.deleted: + total_price += item.price * cart_item.quantity + + cart.price = total_price + await session.commit() + + return CartOut(id=cart.id, items=cart_items, price=total_price) diff --git a/hw4/shop_api/routers/chat.py b/hw4/shop_api/routers/chat.py new file mode 100644 index 00000000..ef026015 --- /dev/null +++ b/hw4/shop_api/routers/chat.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from collections import defaultdict +import random +import string +from typing import Dict, List +from prometheus_client import Counter, Gauge + +ws_connections = Gauge("shop_ws_connections", "Active WebSocket connections", ["chat_name"]) +ws_messages_total = Counter("shop_ws_messages_total", "Total WebSocket messages sent", ["chat_name"]) + +router = APIRouter(prefix="/chat", tags=["chat"]) + +chat_rooms: Dict[str, List[WebSocket]] = defaultdict(list) +usernames: Dict[WebSocket, str] = {} + +def random_username() -> str: + return ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + +@router.websocket("/{chat_name}") +async def websocket_chat(websocket: WebSocket, chat_name: str): + await websocket.accept() + username = random_username() + usernames[websocket] = username + chat_rooms[chat_name].append(websocket) + ws_connections.labels(chat_name=chat_name).inc() # <--- увеличиваем число соединений + + try: + while True: + data = await websocket.receive_text() + message = f"{username} :: {data}" + for ws in chat_rooms[chat_name]: + if ws != websocket: + await ws.send_text(message) + ws_messages_total.labels(chat_name=chat_name).inc() # <--- увеличиваем счетчик сообщений + except WebSocketDisconnect: + chat_rooms[chat_name].remove(websocket) + del usernames[websocket] + ws_connections.labels(chat_name=chat_name).dec() # <--- уменьшаем число соединений + diff --git a/hw4/shop_api/routers/items.py b/hw4/shop_api/routers/items.py new file mode 100644 index 00000000..2c3def81 --- /dev/null +++ b/hw4/shop_api/routers/items.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import List, Optional + +from shop_api.database import get_session +from shop_api.models.item import Item +from shop_api.schemas.item import ItemCreate, ItemOut +from shop_api.metrics import items_in_stock_gauge + +router = APIRouter(prefix="/item", tags=["item"]) + + +@router.post("/", response_model=ItemOut, status_code=201) +async def create_item(item: ItemCreate, session: AsyncSession = Depends(get_session)): + new_item = Item(**item.dict()) + session.add(new_item) + await session.commit() + await session.refresh(new_item) + + items_in_stock_gauge.inc() + return new_item + + +@router.get("/{item_id}", response_model=ItemOut) +async def get_item(item_id: int, session: AsyncSession = Depends(get_session)): + item = await session.get(Item, item_id) + if not item or item.deleted: + raise HTTPException(status_code=404, detail="Item not found") + return item + + +@router.get("/", response_model=List[ItemOut]) +async 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 = Query(False), + session: AsyncSession = Depends(get_session), +): + query = select(Item) + if not show_deleted: + query = query.where(Item.deleted.is_(False)) + if min_price is not None: + query = query.where(Item.price >= min_price) + if max_price is not None: + query = query.where(Item.price <= max_price) + + result = await session.execute(query.offset(offset).limit(limit)) + return result.scalars().all() + + +@router.delete("/{item_id}", status_code=200) +async def delete_item(item_id: int, session: AsyncSession = Depends(get_session)): + item = await session.get(Item, item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not item.deleted: + item.deleted = True + await session.commit() + items_in_stock_gauge.dec() + return {"status": "ok"} diff --git a/hw4/shop_api/schemas/__init__.py b/hw4/shop_api/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/shop_api/schemas/cart.py b/hw4/shop_api/schemas/cart.py new file mode 100644 index 00000000..c1ae093f --- /dev/null +++ b/hw4/shop_api/schemas/cart.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel +from typing import List + +class CartItemOut(BaseModel): + id: int + name: str + quantity: int + available: bool + +class CartOut(BaseModel): + id: int + items: List[CartItemOut] + price: float + + class Config: + orm_mode = True diff --git a/hw4/shop_api/schemas/item.py b/hw4/shop_api/schemas/item.py new file mode 100644 index 00000000..9093763d --- /dev/null +++ b/hw4/shop_api/schemas/item.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel + +class ItemBase(BaseModel): + name: str + price: float + +class ItemCreate(ItemBase): + pass + +class ItemUpdate(ItemBase): + deleted: bool = False + +class ItemOut(ItemBase): + id: int + deleted: bool + + class Config: + orm_mode = True diff --git a/hw4/shop_api/storage/__init__.py b/hw4/shop_api/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/shop_api/storage/memory.py b/hw4/shop_api/storage/memory.py new file mode 100644 index 00000000..e769f81d --- /dev/null +++ b/hw4/shop_api/storage/memory.py @@ -0,0 +1,9 @@ +from typing import Dict +from threading import Lock +from shop_api.schemas.item import Item + +_items: Dict[int, Item] = {} +_carts: Dict[int, Dict[int, int]] = {} +_next_item_id = 1 +_next_cart_id = 1 +_lock = Lock() diff --git a/hw4/shop_api/utils/cart_utils.py b/hw4/shop_api/utils/cart_utils.py new file mode 100644 index 00000000..b0c65c2e --- /dev/null +++ b/hw4/shop_api/utils/cart_utils.py @@ -0,0 +1,20 @@ +from shop_api.schemas.cart import Cart, CartItem +from shop_api.storage.memory import _carts, _items + + +def compute_cart(cart_id: int) -> Cart: + if cart_id not in _carts: + raise KeyError + bag = _carts[cart_id] + items_out = [] + total = 0.0 + for iid, qty in bag.items(): + item = _items.get(iid) + if item is None: + name, available = "", False + else: + name, available = item.name, not item.deleted + items_out.append(CartItem(id=iid, name=name, quantity=qty, available=available)) + if item and not item.deleted: + total += item.price * qty + return Cart(id=cart_id, items=items_out, price=total) \ No newline at end of file diff --git a/hw4/test.py b/hw4/test.py new file mode 100644 index 00000000..a05b0cca --- /dev/null +++ b/hw4/test.py @@ -0,0 +1,102 @@ +import asyncio +import random +import string +import threading +import time +import requests +import websockets +from http import HTTPStatus + +API_URL = "http://localhost:8000" +WS_URL = "ws://localhost:8000/chat" + +def random_name(length=8): + return ''.join(random.choices(string.ascii_lowercase, k=length)) + +def random_price(): + return round(random.uniform(5, 500), 2) + + +def simulate_http_load(): + while True: + try: + item = {"name": f"Item {random_name()}", "price": random_price()} + start = time.perf_counter() + r = requests.post(f"{API_URL}/item", json=item) + latency = time.perf_counter() - start + + if r.status_code == HTTPStatus.CREATED: + item_id = r.json()["id"] + requests.get(f"{API_URL}/item/{item_id}") + requests.put( + f"{API_URL}/item/{item_id}", + json={"name": item["name"], "price": item["price"] + 1}, + ) + if random.random() < 0.2: + requests.delete(f"{API_URL}/item/{item_id}") + else: + requests.get(f"{API_URL}/item/99999999") + + cart_response = requests.post(f"{API_URL}/cart") + if cart_response.status_code == HTTPStatus.CREATED: + cart_id = cart_response.json()["id"] + if r.status_code == HTTPStatus.CREATED: + requests.post(f"{API_URL}/cart/{cart_id}/add/{r.json()['id']}") + requests.get(f"{API_URL}/cart/{cart_id}") + else: + requests.get(f"{API_URL}/cart/-1") + + requests.get(f"{API_URL}/item", params={ + "min_price": random.uniform(1, 50), + "max_price": random.uniform(100, 500), + "offset": random.randint(0, 5), + "limit": random.randint(1, 10), + }) + + requests.get(f"{API_URL}/cart", params={ + "min_price": random.uniform(1, 50), + "max_price": random.uniform(100, 500), + "offset": random.randint(0, 5), + "limit": random.randint(1, 10), + }) + + asyncio.run(asyncio.sleep(random.uniform(0.05, 0.2))) + + except Exception as e: + print(f"[HTTP Error] {e}") + + +async def simulate_websocket_load(chat_name: str): + uri = f"{WS_URL}/{chat_name}" + try: + async with websockets.connect(uri) as ws: + for _ in range(random.randint(5, 15)): + msg = f"Hello from {random_name()}!" + await ws.send(msg) + try: + await asyncio.wait_for(ws.recv(), timeout=1) + except asyncio.TimeoutError: + pass + await asyncio.sleep(random.uniform(0.2, 1.0)) + except Exception as e: + print(f"[WS Error] {e}") + + +def run_websocket_load(): + asyncio.run(simulate_websocket_load(random.choice(["general", "orders", "support", "promo"]))) + + +if __name__ == "__main__": + print("Started") + + for _ in range(8): + threading.Thread(target=simulate_http_load, daemon=True).start() + + for _ in range(4): + threading.Thread(target=run_websocket_load, daemon=True).start() + + try: + while True: + time.sleep(2) + except KeyboardInterrupt: + print("Stopped") diff --git a/hw4/test_db.py b/hw4/test_db.py new file mode 100644 index 00000000..9138df32 --- /dev/null +++ b/hw4/test_db.py @@ -0,0 +1,13 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text + +DATABASE_URL = "postgresql+asyncpg://shop_user:shop_pass@localhost:5432/shop_db" +engine = create_async_engine(DATABASE_URL) + +async def test_db(): + async with engine.begin() as conn: + result = await conn.execute(text("SELECT 1;")) + print(result.fetchall()) + +asyncio.run(test_db()) diff --git a/hw4/tx_demos/README.md b/hw4/tx_demos/README.md new file mode 100644 index 00000000..b761192c --- /dev/null +++ b/hw4/tx_demos/README.md @@ -0,0 +1,44 @@ +# HW4 + +## Запуск проекта + +```bash +docker compose up -d +``` + +## Структура базы данных + +Таблицы: + +- carts (хранит корзины пользователей): id, price +- items (хранит товары): id, name, price, deleted +- cart_items (связывает корзины и товары многие ко многим): id, cart_id, item_id. quantity + +## Демонстрации транзакционных аномалий (dirty read, non-repeatable read, phantom) + +### Dirty read + +```bash +python tx_demos/demo_dirty_read.py +``` + +В PostgreSQL не возникает даже при READ UNCOMMITTED, так как этот уровень фактически работает как READ COMMITTED. \ +Невозможно прочитать неподтверждённые изменения другой транзакции. + +### Non-repeatable read + +```bash +python tx_demos/non_repeatable_read.py +``` + +Возможно при READ COMMITTED, потому что между двумя чтениями другая транзакция может изменить данные и зафиксировать изменения. \ +Данные, считанные повторно, могут измениться. + +### Phantom read + +```bash +python tx_demos/demo_phantom_read.py +``` + +Возможно при REPEATABLE READ, но предотвращается при SERIALIZABLE. \ +Между двумя одинаковыми запросами может появиться (или исчезнуть) новая строка, удовлетворяющая условию выборки. \ No newline at end of file diff --git a/hw4/tx_demos/__init__.py b/hw4/tx_demos/__init__.py new file mode 100644 index 00000000..8538f779 --- /dev/null +++ b/hw4/tx_demos/__init__.py @@ -0,0 +1 @@ +# package marker for tx_demos diff --git a/hw4/tx_demos/db_setup.py b/hw4/tx_demos/db_setup.py new file mode 100644 index 00000000..19840159 --- /dev/null +++ b/hw4/tx_demos/db_setup.py @@ -0,0 +1,79 @@ +import asyncio +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +DATABASE_URL = "postgresql+asyncpg://shop_user:shop_pass@localhost:5432/shop_db" +engine = create_async_engine(DATABASE_URL) + + +async def create_tables(): + async with engine.begin() as conn: + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS carts ( + id SERIAL PRIMARY KEY, + price FLOAT DEFAULT 0.0 + ); + """ + ) + ) + + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS items ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + price FLOAT NOT NULL, + deleted BOOLEAN DEFAULT FALSE + ); + """ + ) + ) + + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS cart_items ( + id SERIAL PRIMARY KEY, + cart_id INTEGER REFERENCES carts(id) ON DELETE CASCADE, + item_id INTEGER REFERENCES items(id), + quantity INTEGER DEFAULT 1 + ); + """ + ) + ) + + +async def seed_default(): + async with engine.begin() as conn: + await conn.execute( + text( + """ + INSERT INTO carts (price) VALUES (100.0) + ON CONFLICT DO NOTHING; + """ + ) + ) + + await conn.execute( + text( + """ + INSERT INTO items (name, price, deleted) + VALUES ('Item A', 10.0, FALSE), + ('Item B', 20.0, FALSE) + ON CONFLICT DO NOTHING; + """ + ) + ) + + +async def prepare(): + await create_tables() + await seed_default() + + +if __name__ == "__main__": + asyncio.run(prepare()) + print("Done.") diff --git a/hw4/tx_demos/demo_dirty_read.py b/hw4/tx_demos/demo_dirty_read.py new file mode 100644 index 00000000..0fd32410 --- /dev/null +++ b/hw4/tx_demos/demo_dirty_read.py @@ -0,0 +1,49 @@ +import asyncio +from sqlalchemy import text +from tx_demos.db_setup import prepare, engine + + +async def dirty_read_demo(isolation_level: str): + async with engine.connect() as conn1: + trans1 = await conn1.begin() + try: + await conn1.execute(text(f"SET TRANSACTION ISOLATION LEVEL {isolation_level}")) + print(f"[T1] Установлен уровень изоляции: {isolation_level}. Обновляю carts.price -> 999 (без фиксации)") + await conn1.execute(text("UPDATE carts SET price = 999 WHERE id = 1")) + + async with engine.connect() as conn2: + trans2 = await conn2.begin() + try: + await conn2.execute(text(f"SET TRANSACTION ISOLATION LEVEL {isolation_level}")) + print("[T2] Выполняю чтение значения price для корзины с id = 1") + res = await conn2.execute(text("SELECT price FROM carts WHERE id = 1")) + row = res.first() + print(f"[T2] Получено значение price: {row[0] if row else 'нет данных'}") + await trans2.commit() + print("[T2] Транзакция зафиксирована") + except Exception as e: + await trans2.rollback() + print(f"[T2] Ошибка: {e}. Транзакция отменена") + + print("[T1] Отменяю изменения, фиксации не будет") + await trans1.rollback() + except Exception as e: + print(f"[T1] Ошибка: {e}. Транзакция отменена") + await trans1.rollback() + + +async def main(): + print("Подготовка базы данных: создание таблиц и тестовых данных") + await prepare() + + print("\n=== Демонстрация dirty read (READ UNCOMMITTED) ===") + await dirty_read_demo("READ UNCOMMITTED") + + print("\n=== Демонстрация без dirty read (READ COMMITTED) ===") + await dirty_read_demo("READ COMMITTED") + + print("\nЗавершено") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/hw4/tx_demos/demo_non_repeatable_read.py b/hw4/tx_demos/demo_non_repeatable_read.py new file mode 100644 index 00000000..9041497b --- /dev/null +++ b/hw4/tx_demos/demo_non_repeatable_read.py @@ -0,0 +1,60 @@ +import asyncio +from sqlalchemy import text +from tx_demos.db_setup import prepare, engine + + +async def non_repeatable_demo(isolation_level: str): + async with engine.connect() as conn1: + trans1 = await conn1.begin() + try: + await conn1.execute(text(f"SET TRANSACTION ISOLATION LEVEL {isolation_level}")) + print(f"\n=== Уровень изоляции: {isolation_level} ===") + print("[T1] Первое чтение значения price из таблицы carts") + + res1 = await conn1.execute(text("SELECT price FROM carts WHERE id = 1")) + r1 = res1.first()[0] + print(f"[T1] Результат первого чтения: {r1}") + + async with engine.connect() as conn2: + trans2 = await conn2.begin() + try: + print("[T2] Обновляю carts.price -> 200 и фиксирую изменения") + await conn2.execute(text("UPDATE carts SET price = 200 WHERE id = 1")) + await trans2.commit() + print("[T2] Транзакция зафиксирована") + except Exception as e: + await trans2.rollback() + print(f"[T2] Ошибка: {e}. Транзакция отменена") + + print("[T1] Второе чтение значения price после фиксации изменений во второй транзакции") + res2 = await conn1.execute(text("SELECT price FROM carts WHERE id = 1")) + r2 = res2.first()[0] + print(f"[T1] Результат второго чтения: {r2}") + + if r1 != r2: + print("[T1] Обнаружено non repeatable read (значение изменилось между запросами)") + else: + print("[T1] Значение не изменилось. Non repeatable read не произошло") + + await trans1.commit() + print("[T1] Транзакция зафиксирована") + except Exception as e: + await trans1.rollback() + print(f"[T1] Ошибка: {e}. Транзакция отменена") + + +async def main_async(): + print("Подготовка базы данных: создание таблиц и тестовых данных") + await prepare() + + print("\n--- Демонстрация non repeatable read (READ COMMITTED) ---") + await non_repeatable_demo("READ COMMITTED") + + print("\n--- Демонстрация предотвращения non repeatable read (REPEATABLE READ) ---") + await non_repeatable_demo("REPEATABLE READ") + + print("\nЗавершено") + + +if __name__ == "__main__": + asyncio.run(main_async()) diff --git a/hw4/tx_demos/demo_phantom_read.py b/hw4/tx_demos/demo_phantom_read.py new file mode 100644 index 00000000..a55f1497 --- /dev/null +++ b/hw4/tx_demos/demo_phantom_read.py @@ -0,0 +1,65 @@ +import asyncio +from sqlalchemy import text +from tx_demos.db_setup import prepare, engine + + +async def phantom_demo(isolation_level: str): + async with engine.connect() as conn1: + trans1 = await conn1.begin() + try: + await conn1.execute(text(f"SET TRANSACTION ISOLATION LEVEL {isolation_level}")) + print(f"\n=== Уровень изоляции: {isolation_level} ===") + + print("[T1] Выполняю первый подсчет записей, где deleted = FALSE") + res1 = await conn1.execute(text("SELECT COUNT(*) FROM items WHERE deleted = FALSE")) + c1 = res1.first()[0] + print(f"[T1] Количество записей при первом чтении: {c1}") + + async with engine.connect() as conn2: + trans2 = await conn2.begin() + try: + print("[T2] Добавляю новую запись (deleted = FALSE) и фиксирую изменения") + await conn2.execute( + text("INSERT INTO items (name, price, deleted) VALUES ('PhantomItem', 50.0, FALSE)") + ) + await trans2.commit() + print("[T2] Транзакция зафиксирована") + except Exception as e: + await trans2.rollback() + print(f"[T2] Ошибка: {e}. Транзакция отменена") + + print("[T1] Повторяю подсчет записей, где deleted = FALSE, после фиксации второй транзакции") + res2 = await conn1.execute(text("SELECT COUNT(*) FROM items WHERE deleted = FALSE")) + c2 = res2.first()[0] + print(f"[T1] Количество записей при втором чтении: {c2}") + + if c1 != c2: + print("[T1] Обнаружено phantom read (в результате появилась новая запись)") + else: + print("[T1] Phantom read не зафиксировано, результаты совпадают") + + await trans1.commit() + print("[T1] Транзакция зафиксирована") + except Exception as e: + await trans1.rollback() + print(f"[T1] Ошибка: {e}. Транзакция отменена") + + +async def main(): + print("Подготовка базы данных: создание таблиц и начальных данных") + await prepare() + + print("\n--- Демонстрация phantom read (READ COMMITTED) ---") + await phantom_demo("READ COMMITTED") + + print("\n--- Демонстрация предотвращения phantom read (REPEATABLE READ) ---") + await phantom_demo("REPEATABLE READ") + + print("\n--- Полная изоляция (SERIALIZABLE) ---") + await phantom_demo("SERIALIZABLE") + + print("\nЗавершено") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/hw5/__init__.py b/hw5/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/requirements.txt b/hw5/requirements.txt new file mode 100644 index 00000000..49106c6e --- /dev/null +++ b/hw5/requirements.txt @@ -0,0 +1,7 @@ +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 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/main.py b/hw5/shop_api/main.py new file mode 100644 index 00000000..952e76fc --- /dev/null +++ b/hw5/shop_api/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from shop_api.routers import items, carts, chat + + +app = FastAPI(title="Shop API") + +app.include_router(items.router) +app.include_router(carts.router) +app.include_router(chat.router) \ No newline at end of file diff --git a/hw5/shop_api/models/__init__.py b/hw5/shop_api/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/shop_api/routers/__init__.py b/hw5/shop_api/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/shop_api/routers/carts.py b/hw5/shop_api/routers/carts.py new file mode 100644 index 00000000..39a179be --- /dev/null +++ b/hw5/shop_api/routers/carts.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, HTTPException, Query, Path, Response +from shop_api.schemas.cart import Cart +from shop_api.utils.cart_utils import compute_cart +from shop_api.storage.memory import _carts, _items, _lock, _next_cart_id +from typing import Optional, List + + +router = APIRouter(prefix="/cart", tags=["carts"]) + +@router.post("/", status_code=201) +def create_cart(response: Response): + global _next_cart_id + with _lock: + cid = _next_cart_id + _next_cart_id += 1 + _carts[cid] = {} + response.headers["Location"] = f"/cart/{cid}" + return {"id": cid} + +@router.get("/{id}", response_model=Cart) +def get_cart(id: int = Path(..., gt=0)): + try: + return compute_cart(id) + except KeyError: + raise HTTPException(status_code=404, detail="cart not found") + +@router.get("/", 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), +): + carts = [] + for cid in sorted(_carts.keys()): + cart = compute_cart(cid) + total_qty = sum(i.quantity for i in 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 + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + carts.append(cart) + return carts[offset: offset + limit] + +@router.post("/{cart_id}/add/{item_id}", response_model=Cart) +def add_item(cart_id: int, item_id: int): + if cart_id not in _carts: + raise HTTPException(status_code=404, detail="cart not found") + if item_id not in _items: + raise HTTPException(status_code=404, detail="item not found") + _carts[cart_id][item_id] = _carts[cart_id].get(item_id, 0) + 1 + return compute_cart(cart_id) diff --git a/hw5/shop_api/routers/chat.py b/hw5/shop_api/routers/chat.py new file mode 100644 index 00000000..0dcf6da9 --- /dev/null +++ b/hw5/shop_api/routers/chat.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from collections import defaultdict +import random +import string +from typing import Dict, List + +router = APIRouter(prefix="/chat", tags=["chat"]) + +chat_rooms: Dict[str, List[WebSocket]] = defaultdict(list) +usernames: Dict[WebSocket, str] = {} + +def random_username() -> str: + return ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + +@router.websocket("/{chat_name}") +async def websocket_chat(websocket: WebSocket, chat_name: str): + await websocket.accept() + username = random_username() + usernames[websocket] = username + chat_rooms[chat_name].append(websocket) + try: + while True: + data = await websocket.receive_text() + message = f"{username} :: {data}" + for ws in chat_rooms[chat_name]: + if ws != websocket: + await ws.send_text(message) + except WebSocketDisconnect: + chat_rooms[chat_name].remove(websocket) + del usernames[websocket] diff --git a/hw5/shop_api/routers/items.py b/hw5/shop_api/routers/items.py new file mode 100644 index 00000000..3942bd8b --- /dev/null +++ b/hw5/shop_api/routers/items.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, HTTPException, Query, Response +from shop_api.schemas.item import Item, ItemCreate +from shop_api.storage.memory import _items, _lock, _next_item_id +from typing import Optional, List + + +router = APIRouter(prefix="/item", tags=["items"]) + +@router.post("/", response_model=Item, status_code=201) +def create_item(item: ItemCreate): + global _next_item_id + with _lock: + iid = _next_item_id + _next_item_id += 1 + new_item = Item(id=iid, name=item.name, price=item.price) + _items[iid] = new_item + return new_item + +@router.get("/{id}", response_model=Item) +def get_item(id: int): + item = _items.get(id) + if not item or item.deleted: + raise HTTPException(status_code=404, detail="item not found") + return item + +@router.get("/", 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 = Query(False), +): + items = [] + for it in _items.values(): + if not show_deleted and it.deleted: + continue + if min_price is not None and it.price < min_price: + continue + if max_price is not None and it.price > max_price: + continue + items.append(it) + return items[offset: offset + limit] + +@router.put("/{id}", response_model=Item) +def replace_item(id: int, item: ItemCreate): + if id not in _items: + raise HTTPException(status_code=404, detail="item not found") + existing = _items[id] + existing.name = item.name + existing.price = item.price + _items[id] = existing + return existing + +@router.patch("/{id}", response_model=Item) +def patch_item(id: int, patch: dict): + if id not in _items: + raise HTTPException(status_code=404, detail="item not found") + item = _items[id] + if item.deleted: + return Response(status_code=304) + allowed_keys = {"name", "price"} + if not set(patch.keys()).issubset(allowed_keys): + raise HTTPException(status_code=422) + if "price" in patch: + price = patch["price"] + if price is not None and price < 0: + raise HTTPException(status_code=422) + item.price = price + if "name" in patch: + item.name = patch["name"] + _items[id] = item + return item + +@router.delete("/{id}") +def delete_item(id: int): + item = _items.get(id) + if not item: + return {"status": "ok"} + item.deleted = True + return {"status": "ok"} \ No newline at end of file diff --git a/hw5/shop_api/schemas/__init__.py b/hw5/shop_api/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/shop_api/schemas/cart.py b/hw5/shop_api/schemas/cart.py new file mode 100644 index 00000000..4e3ed90c --- /dev/null +++ b/hw5/shop_api/schemas/cart.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel +from typing import List + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float \ No newline at end of file diff --git a/hw5/shop_api/schemas/item.py b/hw5/shop_api/schemas/item.py new file mode 100644 index 00000000..53021cde --- /dev/null +++ b/hw5/shop_api/schemas/item.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class ItemBase(BaseModel): + name: str + price: float = Field(..., ge=0) + +class ItemCreate(ItemBase): + pass + +class Item(ItemBase): + id: int + deleted: bool = False + +class ItemPatch(BaseModel): + name: Optional[str] + price: Optional[float] \ No newline at end of file diff --git a/hw5/shop_api/storage/__init__.py b/hw5/shop_api/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/shop_api/storage/memory.py b/hw5/shop_api/storage/memory.py new file mode 100644 index 00000000..e769f81d --- /dev/null +++ b/hw5/shop_api/storage/memory.py @@ -0,0 +1,9 @@ +from typing import Dict +from threading import Lock +from shop_api.schemas.item import Item + +_items: Dict[int, Item] = {} +_carts: Dict[int, Dict[int, int]] = {} +_next_item_id = 1 +_next_cart_id = 1 +_lock = Lock() diff --git a/hw5/shop_api/utils/cart_utils.py b/hw5/shop_api/utils/cart_utils.py new file mode 100644 index 00000000..b0c65c2e --- /dev/null +++ b/hw5/shop_api/utils/cart_utils.py @@ -0,0 +1,20 @@ +from shop_api.schemas.cart import Cart, CartItem +from shop_api.storage.memory import _carts, _items + + +def compute_cart(cart_id: int) -> Cart: + if cart_id not in _carts: + raise KeyError + bag = _carts[cart_id] + items_out = [] + total = 0.0 + for iid, qty in bag.items(): + item = _items.get(iid) + if item is None: + name, available = "", False + else: + name, available = item.name, not item.deleted + items_out.append(CartItem(id=iid, name=name, quantity=qty, available=available)) + if item and not item.deleted: + total += item.price * qty + return Cart(id=cart_id, items=items_out, price=total) \ 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..c6358576 --- /dev/null +++ b/hw5/tests/test_carts.py @@ -0,0 +1,137 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from shop_api.routers import carts +from shop_api.storage import memory +from shop_api.schemas.cart import Cart, CartItem + +app = FastAPI() +app.include_router(carts.router) +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def setup_memory(monkeypatch): + class DummyLock: + def __enter__(self): + return self + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + monkeypatch.setattr(memory, "_lock", DummyLock()) + + memory._carts.clear() + memory._items.clear() + memory._next_cart_id = 1 + memory._next_item_id = 1 + + memory._items[1] = type("Item", (), {"name": "Item1", "price": 10.0, "deleted": False})() + yield + memory._carts.clear() + memory._items.clear() + memory._next_cart_id = 1 + memory._next_item_id = 1 + + +def mock_compute_cart(cart_id: int): + if cart_id not in memory._carts: + raise KeyError + bag = memory._carts[cart_id] + items_out = [] + total = 0.0 + for iid, qty in bag.items(): + item = memory._items[iid] + items_out.append(CartItem( + id=iid, + name=item.name, + quantity=qty, + available=not item.deleted + )) + if not item.deleted: + total += item.price * qty + return Cart(id=cart_id, items=items_out, price=total) + + +def test_create_cart(): + response = client.post("/cart/") + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert response.headers["Location"] == f"/cart/{data['id']}" + assert data["id"] in memory._carts + + +def test_get_cart_success(monkeypatch): + memory._carts[1] = {} + monkeypatch.setattr("shop_api.utils.cart_utils.compute_cart", mock_compute_cart) + response = client.get("/cart/1") + assert response.status_code == 200 + data = response.json() + assert data["id"] == 1 + assert isinstance(data["items"], list) + assert data["price"] == 0.0 + + +def test_get_cart_not_found(): + response = client.get("/cart/999") + assert response.status_code == 404 + assert response.json() == {"detail": "cart not found"} + + +def test_list_carts_filters(monkeypatch): + memory._carts[1] = {1: 2} + memory._carts[2] = {1: 5} + monkeypatch.setattr("shop_api.utils.cart_utils.compute_cart", mock_compute_cart) + + resp = client.get("/cart/") + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + resp = client.get("/cart/?min_price=30") + data = resp.json() + assert len(data) == 1 + assert data[0]["id"] == 2 + + resp = client.get("/cart/?max_price=20") + data = resp.json() + assert len(data) == 1 + assert data[0]["id"] == 1 + + resp = client.get("/cart/?min_quantity=3") + data = resp.json() + assert len(data) == 1 + assert data[0]["id"] == 2 + + resp = client.get("/cart/?max_quantity=3") + data = resp.json() + assert len(data) == 1 + assert data[0]["id"] == 1 + + resp = client.get("/cart/?offset=1&limit=1") + data = resp.json() + assert len(data) == 1 + assert data[0]["id"] == 2 + + +def test_add_item_success(monkeypatch): + memory._carts[1] = {} + monkeypatch.setattr("shop_api.utils.cart_utils.compute_cart", mock_compute_cart) + response = client.post("/cart/1/add/1") + assert response.status_code == 200 + data = response.json() + assert data["id"] == 1 + assert data["items"][0]["quantity"] == 1 + assert memory._carts[1][1] == 1 + + +def test_add_item_cart_not_found(): + response = client.post("/cart/999/add/1") + assert response.status_code == 404 + assert response.json() == {"detail": "cart not found"} + + +def test_add_item_item_not_found(): + memory._carts[1] = {} + response = client.post("/cart/1/add/999") + assert response.status_code == 404 + assert response.json() == {"detail": "item not found"} diff --git a/hw5/tests/test_chat_ws.py b/hw5/tests/test_chat_ws.py new file mode 100644 index 00000000..3fa82f4a --- /dev/null +++ b/hw5/tests/test_chat_ws.py @@ -0,0 +1,63 @@ +import pytest +from fastapi import FastAPI, WebSocketDisconnect +from fastapi.testclient import TestClient +from shop_api.routers import chat + +app = FastAPI() +app.include_router(chat.router) +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_chat_rooms(): + chat.chat_rooms.clear() + chat.usernames.clear() + yield + chat.chat_rooms.clear() + chat.usernames.clear() + + +def test_random_username_length(): + username = chat.random_username() + assert len(username) == 8 + assert username.isalnum() + + +def test_websocket_connect_and_disconnect(): + with client.websocket_connect("/chat/room1") as ws: + ws.send_text("test") + assert chat.chat_rooms.get("room1") == [] + assert len(chat.usernames) == 0 + + +def test_websocket_send_receive_between_two_clients(): + with client.websocket_connect("/chat/roomX") as ws1, \ + client.websocket_connect("/chat/roomX") as ws2: + + ws1.send_text("Hello") + msg = ws2.receive_text() + assert " :: Hello" in msg + + ws2.send_text("Hi there") + msg2 = ws1.receive_text() + assert " :: Hi there" in msg2 + + assert chat.chat_rooms.get("roomX") == [] + assert len(chat.usernames) == 0 + + +def test_websocket_multiple_messages_and_disconnect(): + with client.websocket_connect("/chat/roomY") as ws1, \ + client.websocket_connect("/chat/roomY") as ws2: + + ws1.send_text("First") + ws2.send_text("Second") + + msg2 = ws2.receive_text() + assert " :: First" in msg2 + + msg1 = ws1.receive_text() + assert " :: Second" in msg1 + + assert chat.chat_rooms.get("roomY") == [] + assert len(chat.usernames) == 0 diff --git a/hw5/tests/test_fastapi.py b/hw5/tests/test_fastapi.py new file mode 100644 index 00000000..aa0d0148 --- /dev/null +++ b/hw5/tests/test_fastapi.py @@ -0,0 +1,20 @@ +from fastapi.testclient import TestClient +from shop_api import main + +client = TestClient(main.app) + + +def test_app_exists(): + assert main.app is not None + + +def test_routers_included(): + route_paths = [route.path for route in main.app.routes] + assert "/item/" in route_paths or any(p.startswith("/item") for p in route_paths) + assert "/cart/" in route_paths or any(p.startswith("/cart") for p in route_paths) + assert "/chat/" in route_paths or any(p.startswith("/chat") for p in route_paths) + + +def test_main_app_root_status(): + response = client.get("/") + assert response.status_code in (404, 200) diff --git a/hw5/tests/test_items.py b/hw5/tests/test_items.py new file mode 100644 index 00000000..2893873d --- /dev/null +++ b/hw5/tests/test_items.py @@ -0,0 +1,142 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from shop_api.routers import items +from shop_api.storage import memory + +app = FastAPI() +app.include_router(items.router) +client = TestClient(app) + +@pytest.fixture(autouse=True) +def setup_memory(monkeypatch): + class DummyLock: + def __enter__(self): + return self + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + monkeypatch.setattr(memory, "_lock", DummyLock()) + + memory._items.clear() + memory._next_item_id = 1 + + +def test_create_item(): + payload = {"name": "TestItem", "price": 100.0} + resp = client.post("/item/", json=payload) + assert resp.status_code == 201 + data = resp.json() + assert data["id"] == 1 + assert data["name"] == "TestItem" + assert data["price"] == 100.0 + assert 1 in memory._items + + +def test_get_item_success(): + memory._items[1] = type("Item", (), {"id": 1, "name": "Item1", "price": 10.0, "deleted": False})() + resp = client.get("/item/1") + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == 1 + assert data["name"] == "Item1" + + +def test_get_item_not_found(): + resp = client.get("/item/999") + assert resp.status_code == 404 + assert resp.json() == {"detail": "item not found"} + + memory._items[1] = type("Item", (), {"id": 1, "name": "Item1", "price": 10.0, "deleted": True})() + resp = client.get("/item/1") + assert resp.status_code == 404 + + +def test_list_items_filters(): + memory._items[1] = type("Item", (), {"id": 1, "name": "A", "price": 10, "deleted": False})() + memory._items[2] = type("Item", (), {"id": 2, "name": "B", "price": 20, "deleted": False})() + memory._items[3] = type("Item", (), {"id": 3, "name": "C", "price": 30, "deleted": True})() + + resp = client.get("/item/") + data = resp.json() + assert len(data) == 2 + + resp = client.get("/item/?show_deleted=true") + data = resp.json() + assert len(data) == 3 + + resp = client.get("/item/?min_price=15") + data = resp.json() + assert all(d["price"] >= 15 for d in data) + + resp = client.get("/item/?max_price=15") + data = resp.json() + assert all(d["price"] <= 15 for d in data) + + resp = client.get("/item/?offset=1&limit=1") + data = resp.json() + assert len(data) == 1 + + +def test_replace_item_success(): + memory._items[1] = type("Item", (), {"id": 1, "name": "Old", "price": 10.0, "deleted": False})() + payload = {"name": "NewName", "price": 99.0} + resp = client.put("/item/1", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "NewName" + assert data["price"] == 99.0 + + +def test_replace_item_not_found(): + payload = {"name": "X", "price": 1.0} + resp = client.put("/item/999", json=payload) + assert resp.status_code == 404 + + +def test_patch_item_success(): + memory._items[1] = type("Item", (), {"id": 1, "name": "Old", "price": 10.0, "deleted": False})() + resp = client.patch("/item/1", json={"name": "New"}) + assert resp.status_code == 200 + assert memory._items[1].name == "New" + + resp = client.patch("/item/1", json={"price": 50}) + assert resp.status_code == 200 + assert memory._items[1].price == 50 + + resp = client.patch("/item/1", json={"name": "Final", "price": 100}) + assert resp.status_code == 200 + assert memory._items[1].name == "Final" + assert memory._items[1].price == 100 + + +def test_patch_item_not_found(): + resp = client.patch("/item/999", json={"name": "X"}) + assert resp.status_code == 404 + + +def test_patch_item_deleted(): + memory._items[1] = type("Item", (), {"id": 1, "name": "X", "price": 10.0, "deleted": True})() + resp = client.patch("/item/1", json={"name": "Y"}) + assert resp.status_code == 304 + + +def test_patch_item_invalid_field(): + memory._items[1] = type("Item", (), {"id": 1, "name": "X", "price": 10.0, "deleted": False})() + resp = client.patch("/item/1", json={"invalid": 123}) + assert resp.status_code == 422 + + resp = client.patch("/item/1", json={"price": -10}) + assert resp.status_code == 422 + + +def test_delete_item(): + memory._items[1] = type("Item", (), {"id": 1, "name": "X", "price": 10.0, "deleted": False})() + resp = client.delete("/item/1") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + assert memory._items[1].deleted is True + + resp = client.delete("/item/999") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"}