diff --git a/hw1/app.py b/hw1/app.py index 6107b870..dfc47805 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,7 @@ from typing import Any, Awaitable, Callable +import json +from math import prod +from urllib.parse import parse_qs async def application( @@ -12,8 +15,117 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + + if scope.get("type") != "http": + await _send_json(send, {"detail": "unsupported scope type"}, status=422) + return + + method = scope.get("method", "GET").upper() + path: str = scope.get("path", "/") + query = parse_qs((scope.get("query_string") or b"").decode( + "utf-8"), keep_blank_values=True) + + if path.startswith("/fibonacci/"): + n_str = path[len("/fibonacci/"):].strip("/") + if n_str == "": + await _send_json(send, {"detail": "n is required"}, status=422) + return + try: + n = int(n_str) + except ValueError: + await _send_json(send, {"detail": "n must be an integer"}, status=422) + return + if n < 0: + await _send_json(send, {"detail": "n must be non-negative"}, status=400) + return + + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + await _send_json(send, {"result": a}, status=200) + return + + if path == "/factorial": + if method != "GET": + await _send_json(send, {"detail": "not found"}, status=404) + return + + raw = query.get("n", [None])[0] + if raw is None or raw == "": + await _send_json(send, {"detail": "n is required"}, status=422) + return + try: + n = int(raw) + except ValueError: + await _send_json(send, {"detail": "n must be an integer"}, status=422) + return + if n < 0: + await _send_json(send, {"detail": "n must be non-negative"}, status=400) + return + + result = 1 if n == 0 else prod(range(1, n + 1)) + await _send_json(send, {"result": result}, status=200) + return + + if path == "/mean": + if method != "GET": + await _send_json(send, {"detail": "not found"}, status=404) + return + + body_bytes = await _read_body(receive) + if not body_bytes: + await _send_json(send, {"detail": "json body required (array of numbers)"}, status=422) + return + + try: + data = json.loads(body_bytes.decode("utf-8")) + except json.JSONDecodeError: + await _send_json(send, {"detail": "invalid JSON"}, status=422) + return + + if not isinstance(data, list): + await _send_json(send, {"detail": "body must be a JSON array"}, status=422) + return + + if len(data) == 0: + await _send_json(send, {"detail": "array must not be empty"}, status=400) + return + + try: + nums = [float(x) for x in data] + except (TypeError, ValueError): + await _send_json(send, {"detail": "array must contain only numbers"}, status=422) + return + + mean_value = sum(nums) / len(nums) + await _send_json(send, {"result": mean_value}, status=200) + return + + await _send_json(send, {"detail": "not found"}, status=404) + + +async def _read_body(receive: Callable[[], Awaitable[dict[str, Any]]]) -> bytes: + body = b"" + more = True + while more: + message = await receive() + if message["type"] != "http.request": + break + body += message.get("body", b"") + more = message.get("more_body", False) + return body + + +async def _send_json(send: Callable[[dict[str, Any]], Awaitable[None]], payload: dict, status: int = 200): + body = json.dumps(payload).encode("utf-8") + headers = [ + (b"content-type", b"application/json; charset=utf-8"), + (b"content-length", str(len(body)).encode("ascii")), + ] + await send({"type": "http.response.start", "status": status, "headers": headers}) + await send({"type": "http.response.body", "body": body}) + if __name__ == "__main__": import uvicorn - uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..43812477 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..290b1a30 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always \ No newline at end of file diff --git a/hw2/hw/ping.py b/hw2/hw/ping.py new file mode 100644 index 00000000..67a8d1d5 --- /dev/null +++ b/hw2/hw/ping.py @@ -0,0 +1,51 @@ +import time + +import requests + +BASE = 'http://localhost:8080' + + +def run_tests(): + # создаем товар + r = requests.post(f'{BASE}/item', json={'name': 'Test Item', 'price': 10.5}) + print('POST /item', r.status_code, r.json()) + item_id = r.json()['id'] + + # получаем товар + r = requests.get(f'{BASE}/item/{item_id}') + print('GET /item/{id}', r.status_code, r.json()) + + # обновляем товар + r = requests.patch(f'{BASE}/item/{item_id}', json={'price': 12.0}) + print('PATCH /item/{id}', r.status_code, r.json()) + + # список товаров + r = requests.get(f'{BASE}/item') + print('GET /item', r.status_code, len(r.json())) + + # создаем корзину + r = requests.post(f'{BASE}/cart') + print('POST /cart', r.status_code, r.json()) + cart_id = r.json()['id'] + + # получаем корзину + r = requests.get(f'{BASE}/cart/{cart_id}') + print('GET /cart/{id}', r.status_code, r.json()) + + # добавляем товар в корзину + r = requests.post(f'{BASE}/cart/{cart_id}/add/{item_id}') + print('POST /cart/{cart_id}/add/{item_id}', r.status_code, r.json()) + + # список корзин + r = requests.get(f'{BASE}/cart') + print('GET /cart', r.status_code, len(r.json())) + + +if __name__ == '__main__': + while True: + try: + run_tests() + except Exception as e: + print('Error:', e) + + time.sleep(1) \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..679714b9 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,7 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-fastapi-instrumentator==7.1.0 # для prometheus + grafana (hm3) # Зависимости для тестирования pytest>=7.4.0 diff --git a/hw2/hw/settings/grafana-dashboard.json b/hw2/hw/settings/grafana-dashboard.json new file mode 100644 index 00000000..de97d72d --- /dev/null +++ b/hw2/hw/settings/grafana-dashboard.json @@ -0,0 +1,607 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "df01l3rlsp728b" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "4xx" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "HTTP 500" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#bf1b00", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 0, + "y": 0 + }, + "id": 13, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "$$hashKey": "object:140", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "sum by (status) (rate(http_requests_total[1m]))", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ status }}", + "range": true, + "refId": "A" + } + ], + "title": "Request per minute", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "df01l3rlsp728b" + }, + "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": "smooth", + "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 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 14, + "x": 5, + "y": 0 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "$$hashKey": "object:146", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "http_request_duration_seconds_sum{handler!=\"none\"} / http_request_duration_seconds_count", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ handler }}", + "range": true, + "refId": "A" + } + ], + "title": "Average response time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "df01l3rlsp728b" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "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 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 19, + "y": 0 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "$$hashKey": "object:638", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "rate(process_cpu_seconds_total{}[30s])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "cpu", + "range": true, + "refId": "A" + } + ], + "title": "CPU usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "df01l3rlsp728b" + }, + "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": 19, + "x": 0, + "y": 7 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "$$hashKey": "object:214", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "increase(http_requests_total{}[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ method }} {{ handler }}", + "range": true, + "refId": "A" + } + ], + "title": "Total requests per minute", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "df01l3rlsp728b" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "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 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 19, + "y": 7 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "$$hashKey": "object:638", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "process_resident_memory_bytes{}", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "mem", + "range": true, + "refId": "A" + } + ], + "title": "Memory usage", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "auto", + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [] + }, + "timezone": "", + "title": "FastAPI Dashboard", + "uid": "_eX4mpl3", + "version": 2 +} \ No newline at end of file diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..275d8544 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: demo-service-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..67d48b73 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,317 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Response, Query, HTTPException +from typing import Optional +import uvicorn +import json +import os +from http import HTTPStatus +from prometheus_fastapi_instrumentator import Instrumentator app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +carts_file = "carts.json" +items_file = "items.json" + +def load_json(file_path): + if not os.path.exists(file_path): + with open(file_path, 'w', encoding='utf-8') as f: + json.dump({}, f) + with open(file_path, 'r', encoding='utf-8') as f: + carts = json.load(f) + return carts + +def save_json(json_file, file_path): + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(json_file, f, indent=4) + + +### CART +@app.post("/cart") +def create_cart(): + + carts = load_json(carts_file) + if carts: + cart_id = max(carts.keys()) + else: + cart_id = 1 + carts[cart_id] = {'id': cart_id, 'items': [], 'price': 0.0 } + + save_json(carts, carts_file) + + response = Response( + content=json.dumps(carts[cart_id]), + status_code=HTTPStatus.CREATED, + media_type="application/json", + headers={} + ) + response.headers["Location"] = f"/cart/{cart_id}" + return response + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int): + carts = load_json(carts_file) + return carts[str(cart_id)] + +@app.get("/cart") +def get_carts( + offset: int = Query(0, ge=0, description="Смещение по списку"), + limit: int = Query(10, gt=0, description="Ограничение на количество"), + min_price: Optional[float] = Query(None, ge=0, description="Минимальная цена"), + max_price: Optional[float] = Query(None, ge=0, description="Максимальная цена"), + min_quantity: Optional[int] = Query(None, ge=0, description="Минимальное количество товаров"), + max_quantity: Optional[int] = Query(None, ge=0, description="Максимальное количество товаров") +): + carts = load_json(carts_file) + cart_list = list(carts.values()) + + filtered_carts = [] + + for cart in cart_list: + # Фильтр по цене + price_ok = True + if min_price is not None and cart['price'] < min_price: + price_ok = False + if max_price is not None and cart['price'] > max_price: + price_ok = False + if not price_ok: + continue + + filtered_carts.append(cart) + + # Теперь применяем фильтры min_quantity/max_quantity ко всему результату + # Эти фильтры работают на уровне всего набора данных + + if min_quantity is not None or max_quantity is not None: + # Вычисляем общее количество товаров во всех отфильтрованных корзинах + total_quantity_all_carts = sum( + item['quantity'] + for cart in filtered_carts + for item in cart['items'] + ) + + # Проверяем условия для всего набора + if min_quantity is not None and total_quantity_all_carts < min_quantity: + # Если общее количество меньше min_quantity, возвращаем пустой список + filtered_carts = [] + elif max_quantity is not None and total_quantity_all_carts > max_quantity: + # Если общее количество больше max_quantity, возвращаем пустой список + filtered_carts = [] + + # Сортируем по ID + filtered_carts.sort(key=lambda x: x['id']) + + # Пагинация + start_idx = offset + end_idx = offset + limit + result = filtered_carts[start_idx:end_idx] + + return result + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_to_cart(cart_id: int, item_id: int): + carts = load_json(carts_file) + items = load_json(items_file) + item = items[str(item_id)] + cart = carts[str(cart_id)] + + # Проверяем, есть ли товар в корзине + item_found = False + for cart_item in cart['items']: + if cart_item['id'] == item_id: + # Если товар уже есть, увеличиваем quantity на 1 + cart_item['quantity'] += 1 + item_found = True + break + + # Если товара нет в корзине, добавляем его + if not item_found: + cart['items'].append(item) + + # Пересчитываем общую стоимость корзины + total_price = 0.0 + for cart_item in cart['items']: + total_price += cart_item['price'] * cart_item['quantity'] + cart['price'] = total_price + + save_json(carts, carts_file) + return carts[str(cart_id)] + +### ITEM + +@app.post("/item") +def create_item(item: dict): + items = load_json(items_file) + if items: + item_id = str(max(items.keys())) + else: + item_id = str(1) + item['id'] = int(item_id) + item['quantity'] = 1 + items[item_id] = item + + + save_json(items, items_file) + + response = Response( + content=json.dumps(items[item_id]), + status_code=HTTPStatus.CREATED, + media_type="application/json", + headers={} + ) + return response + + +@app.get("/item/{item_id}") +def get_item(item_id: int): + items = load_json(items_file) + + if str(item_id) not in items: + raise HTTPException(status_code=404, detail="Item not found") + + item = items[str(item_id)] + + # Для удаленных товаров возвращаем 404 + if item.get('deleted', False): + raise HTTPException(status_code=404, detail="Item not found") + + return item + + +@app.get("/item") +def get_items( + offset: int = Query(0, ge=0, description="Смещение по списку"), + limit: int = Query(10, gt=0, description="Ограничение на количество"), + min_price: Optional[float] = Query(None, ge=0, description="Минимальная цена"), + max_price: Optional[float] = Query(None, ge=0, description="Максимальная цена"), + show_deleted: bool = Query(False, description="Показывать удаленные товары") +): + items = load_json(items_file) + item_list = list(items.values()) + + filtered_items = [] + + for item in item_list: + # Фильтр по статусу удаления + if not show_deleted and item.get('deleted', False): + continue + + # Фильтр по цене + price_ok = True + if min_price is not None and item['price'] < min_price: + price_ok = False + if max_price is not None and item['price'] > max_price: + price_ok = False + if not price_ok: + continue + + filtered_items.append(item) + + # Сортируем по ID (или можно по другому полю) + filtered_items.sort(key=lambda x: x['id']) + + # Пагинация + start_idx = offset + end_idx = offset + limit + result = filtered_items[start_idx:end_idx] + + return result + + +@app.put("/item/{item_id}") +def update_item(item_id: int, item_data: dict): + items = load_json(items_file) + + # Проверяем существование товара + if str(item_id) not in items: + raise HTTPException(status_code=404, detail="Item not found") + + item = items[str(item_id)] + + # Проверяем, что товар не удален + if item.get('deleted', False): + raise HTTPException(status_code=404, detail="Item not found") + + # Проверяем обязательные поля + if 'name' not in item_data or 'price' not in item_data: + raise HTTPException( + status_code=422, + detail="Name and price are required for PUT" + ) + + # Полная замена товара (сохраняем только id и переданные поля) + updated_item = { + 'id': item_id, + 'name': item_data['name'], + 'price': item_data['price'], + 'quantity': 1 + } + + items[str(item_id)] = updated_item + save_json(items, items_file) + + return updated_item + + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, item_data: dict): + items = load_json(items_file) + + if str(item_id) not in items: + raise HTTPException(status_code=404, detail="Item not found") + + item = items[str(item_id)] + + # Для удаленных товаров возвращаем 304 + if item.get('deleted', False): + raise HTTPException(status_code=304, detail="Item is deleted") + + # Проверяем на лишние поля + allowed_fields = {'name', 'price'} + extra_fields = set(item_data.keys()) - allowed_fields + if extra_fields: + raise HTTPException( + status_code=422, + detail=f"Extra fields not allowed: {extra_fields}" + ) + + # Запрещаем менять deleted + if 'deleted' in item_data: + raise HTTPException(status_code=422, detail="Cannot change deleted status") + + # Если тело пустое - возвращаем текущий товар без изменений + if not item_data: + return item + + # Частичное обновление + if 'name' in item_data: + item['name'] = item_data['name'] + + if 'price' in item_data: + if item_data['price'] < 0: + raise HTTPException(status_code=422, detail="Price cannot be negative") + item['price'] = item_data['price'] + + save_json(items, items_file) + return item + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int): + items = load_json(items_file) + + if str(item_id) not in items: + return HTTPStatus.NOT_FOUND + + item = items[str(item_id)] + + # Если уже удален - все равно успех + if not item.get('deleted', False): + item['deleted'] = True + save_json(items, items_file) + + # Возвращаем просто сообщение, а не весь item + return HTTPStatus.OK + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file