diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 00000000..a9e23073 --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,53 @@ +name: "HW5 Tests" + +on: + pull_request: + branches: [ main ] + paths: [ 'hw5/**' ] + push: + branches: [ main ] + paths: [ 'hw5/**' ] + +jobs: + test-hw5: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: hw5_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: hw5 + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + working-directory: hw5 + env: + PYTHONPATH: ${{ github.workspace }}/hw5 + run: | + pytest -vv --cov=shop_api ./tests diff --git a/.gitignore b/.gitignore index 852216e6..ec584d56 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ dmypy.json # macOS .DS_Store +/hw4/settings/ diff --git a/hw1/app.py b/hw1/app.py index 6107b870..648cf5b5 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,139 @@ +import math +import json +from http import HTTPStatus from typing import Any, Awaitable, Callable +async def read_body(receive): + body = b'' + more_body = True + while more_body: + message = await receive() + body += message.get('body', b'') + more_body = message.get('more_body', False) + return body + + +def parse_query_string(scope: dict[str, Any]): + result = {} + query_string = scope.get('query_string').decode() + if len(query_string) == 0: + return result + for entry in query_string.split('&'): + key, value = entry.split('=') + result[key] = value + return result + + +def not_found_response(value = ""): + return { + 'value': str(value), + 'code': HTTPStatus.NOT_FOUND + } + +def bad_request_response(value = ""): + return { + 'value': str(value), + 'code': HTTPStatus.BAD_REQUEST + } + +def unprocessable_entity_response(value = ""): + return { + 'value': str(value), + 'code': HTTPStatus.UNPROCESSABLE_ENTITY + } + +def ok_response(value = ""): + return { + 'value': str(value), + 'code': HTTPStatus.OK + } + + +def get_factorial(query_params: dict[str, Any], path_parameters: list[str], body: bytes) -> dict[str, Any]: + n = int(query_params.get('n')) + if n < 0: + return bad_request_response("Invalid value for n, must be a non-negative") + result = math.factorial(n) + return ok_response(result) + + +def get_fibonacci(query_params: dict[str, Any], path_parameters: list[str], body: bytes) -> dict[str, Any]: + n = int(path_parameters[0]) + if n < 0: + return bad_request_response("Invalid value for n, must be a non-negative") + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return ok_response(a) + + +def get_mean(query_params: dict[str, Any], path_parameters: list[str], body: bytes) -> dict[str, Any]: + data = json.loads(body.decode()) + if len(data) == 0: + return bad_request_response("Invalid value for body, must be non-empty array of floats") + result = sum(data) / len(data) + return ok_response(result) + + +def route_request(scope: dict[str, Any]) -> Callable: + method = scope.get('method') + path = scope.get('path') + + function = None + routing_params = [] + + if method == "GET": + if path.startswith("/factorial"): + function = get_factorial + routing_params = path[10:].split("/") + elif path.startswith("/fibonacci"): + function = get_fibonacci + routing_params = path[11:].split("/") + elif path.startswith("/mean"): + function = get_mean + routing_params = path[6:].split("/") + + return [function, routing_params] + + +async def handle_http(scope: dict[str, Any], receive: Callable, send: Callable): + + function, path_parameters = route_request(scope) + if function is None: + response = not_found_response() + + else: + try: + body = await read_body(receive) + query_params = parse_query_string(scope) + response = function(query_params, path_parameters, body) + except Exception: + response = unprocessable_entity_response() + + code = response['code'] + body = json.dumps({"result": response['value']}) + await send({ + 'type': 'http.response.start', + 'status': code, + 'headers': [ + [b'content-type', b'text/plain'], + ] + }) + await send({ + 'type': 'http.response.body', + 'body': body.encode() + }) + + +async def handle_lifespan(scope: dict[str, Any], receive: Callable, send: Callable): + while True: + message = await receive() + if message['type'] == 'lifespan.startup': + await send({'type': 'lifespan.startup.complete'}) + elif message['type'] == 'lifespan.shutdown': + await send({'type': 'lifespan.shutdown.complete'}) + return + async def application( scope: dict[str, Any], @@ -12,7 +146,12 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + scope_type = scope['type'] + if scope_type == 'lifespan': + await handle_lifespan(scope, receive, send) + elif scope_type == 'http': + await handle_http(scope, receive, send) + if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..64a81a9a 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,183 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Response, HTTPException, status +from pydantic import BaseModel, conint, confloat app = FastAPI(title="Shop API") +cart_id_counter = 0 +item_id_counter = 0 + +carts = {} +items = {} + +posint = conint(gt=0) +uint = conint(ge=0) +ufloat = confloat(ge=0) + +class ModifiedItem(BaseModel): + name: str | None = None + price: float | None = None + model_config = { + "extra": "forbid" + } + +class Item(BaseModel): + id: uint = 0 + name: str + price: float + deleted: bool = False + +class CartItem(BaseModel): + id: uint = 0 + name: str + price: float + quantity: int = 0 + deleted: bool = False + +class Cart(BaseModel): + id: int + price: float = 0.0 + items: list[CartItem] = [] + + +""" +cart +""" + +# POST cart - создание, работает как RPC, не принимает тело, возвращает идентификатор +@app.post("/cart", status_code=status.HTTP_201_CREATED) +async def create_cart(response: Response): + global cart_id_counter + cart_id_counter += 1 + new_cart = Cart( + id=cart_id_counter + ) + carts[new_cart.id] = new_cart + response.headers["Location"] = f"/cart/{new_cart.id}" + return {"id": new_cart.id} + + +# GET /cart/{id} - получение корзины по id +@app.get("/cart/{cart_id}", status_code=status.HTTP_200_OK) +async def get_cart(cart_id: int): + return carts[cart_id] + + +# GET /cart - получение списка корзин с query-параметрами +# offset - неотрицательное целое число, смещение по списку (опционально, по-умолчанию 0) +# limit - положительное целое число, ограничение на количество (опционально, по-умолчанию 10) +# min_price - число с плавающей запятой, минимальная цена включительно (опционально, если нет, не учитывает в фильтре) +# max_price - число с плавающей запятой, максимальная цена включительно (опционально, если нет, не учитывает в фильтре) +# min_quantity - неотрицательное целое число, минимальное общее число товаров включительно (опционально, если нет, не учитывается в фильтре) +# max_quantity - неотрицательное целое число, максимальное общее число товаров включительно (опционально, если нет, не учитывается в фильтре) +@app.get("/cart", status_code=status.HTTP_200_OK) +async def get_cart_list(offset: uint = 0, limit: posint = 10, + min_price: ufloat = None, max_price: ufloat = None, + min_quantity: uint = None, max_quantity: uint = None): + filtered_carts = [] + for cart in list(carts.values())[offset:]: + if len(filtered_carts) == limit: + break + + min_price_ok = cart.price >= min_price if min_price else True + max_price_ok = cart.price <= max_price if max_price else True + min_quantity_ok = sum(item.quantity for item in cart.items) >= min_quantity if not min_quantity is None else True + max_quantity_ok = sum(item.quantity for item in cart.items) <= max_quantity if not max_quantity is None else True + + if min_price_ok and max_price_ok and min_quantity_ok and max_quantity_ok: + filtered_carts.append(cart) + return filtered_carts + + +# POST /cart/{cart_id}/add/{item_id} - добавление в корзину с cart_id предмета с item_id, +# если товар уже есть, то увеличивается его количество +@app.post("/cart/{cart_id}/add/{item_id}", status_code=status.HTTP_200_OK) +async def add_to_cart(cart_id: int, item_id: int): + cart = carts[cart_id] + item = items[item_id] + + item_exists = False + for i in range(len(cart.items)): + if cart.items[i].id == item_id: + cart.items[i].quantity += 1 + item_exists = True + break + + if not item_exists: + new_item = CartItem( + **item.model_dump(), + quantity=1 + ) + cart.items.append(new_item) + + cart.price += item.price + + +""" +item +""" + +# POST /item - добавление нового товара +@app.post("/item", status_code=status.HTTP_201_CREATED) +async def create_item(item: Item): + global item_id_counter + item_id_counter += 1 + new_item = Item(id=item_id_counter, name=item.name, price=item.price) + items[new_item.id] = new_item + return new_item + +# GET /item/{id} - получение товара по id +@app.get("/item/{item_id}", status_code=status.HTTP_200_OK) +async def get_item(item_id: int): + if items[item_id].deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND + ) + return items[item_id] + + +# GET /item - получение списка товаров с query-параметрами +# offset - неотрицательное целое число, смещение по списку (опционально, по-умолчанию 0) +# limit - положительное целое число, ограничение на количество (опционально, по-умолчанию 10) +# min_price - число с плавающей запятой, минимальная цена (опционально, если нет, не учитывает в фильтре) +# max_price - число с плавающей запятой, максимальная цена (опционально, если нет, не учитывает в фильтре) +# show_deleted - булевая переменная, показывать ли удаленные товары (по умолчанию False) +@app.get("/item", status_code=status.HTTP_200_OK) +async def get_item_list(offset: uint = 0, limit: posint = 10, + min_price: ufloat = None, max_price: ufloat = None, + show_deleted: bool = False): + filtered_items = [] + for item in list(items.values())[offset:]: + if len(filtered_items) == limit: + break + + min_price_ok = item.price >= min_price if min_price else True + max_price_ok = item.price <= max_price if max_price else True + show_deleted_ok = (not item.deleted) or show_deleted + + if min_price_ok and max_price_ok and show_deleted_ok: + filtered_items.append(item) + + return filtered_items + +# PUT /item/{id} - замена товара по id (создание запрещено, только замена существующего) +@app.put("/item/{item_id}", status_code=status.HTTP_200_OK) +async def put_item(item_id: int, new_item: Item): + new_item.id = item_id + items[item_id] = new_item + return new_item + +# PATCH /item/{id} - частичное обновление товара по id (разрешено менять все поля, кроме deleted) +@app.patch("/item/{item_id}", status_code=status.HTTP_200_OK) +async def patch_item(item_id: int, new_item: ModifiedItem): + item = items[item_id] + if item.deleted: + raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED) + if new_item.name and item.name != new_item.name: + items[item_id].name = new_item.name + if new_item.price and item.price != new_item.price: + items[item_id].price = new_item.price + return items[item_id] + +# DELETE /item/{id} - удаление товара по id (товар помечается как удаленный) +@app.delete("/item/{item_id}", status_code=status.HTTP_200_OK) +async def delete_item(item_id: int): + items[item_id].deleted = True diff --git a/hw3/Dockerfile b/hw3/Dockerfile new file mode 100644 index 00000000..27b0b636 --- /dev/null +++ b/hw3/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw3/README.md b/hw3/README.md new file mode 100644 index 00000000..aad28c54 --- /dev/null +++ b/hw3/README.md @@ -0,0 +1,13 @@ +# ДЗ + +## Настроить сборку образов Docker и мониторинг с помощью Prometheus и Grafana + +Интегрировать Docker с Prometheus и Grafana в любой уже написанный в ДЗ сервис (по аналогии с тем, как в репе) + +По сути, если вы выполнили вторую домашку, то теперь для неё надо написать Dockerfile и настроить мониторинг. Если вторую домашку вы не делали, то можно взять сервис из [rest_example](../hw2/rest_example/main.py) + +Сдача через PR, так же нужно: + +1) Dockerfile для сборки сервиса +2) docker-compose.yml для локального разворачивания в Docker +3) Приложить скрин с парой Дашбордов в Grafana diff --git a/hw3/Screenshot 2025-10-11 grafana-dashboard.png b/hw3/Screenshot 2025-10-11 grafana-dashboard.png new file mode 100644 index 00000000..6be865ea Binary files /dev/null and b/hw3/Screenshot 2025-10-11 grafana-dashboard.png differ diff --git a/hw3/__init__.py b/hw3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/docker-compose.yml b/hw3/docker-compose.yml new file mode 100644 index 00000000..91b5555c --- /dev/null +++ b/hw3/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always diff --git a/hw3/grafana-dashboard.json b/hw3/grafana-dashboard.json new file mode 100644 index 00000000..44fd2094 --- /dev/null +++ b/hw3/grafana-dashboard.json @@ -0,0 +1,290 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "Prometheus", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "HW3 Application Metrics", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "bf0qoawhp68zkb" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 10, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "code", + "expr": "(time() - process_start_time_seconds) / 3600", + "legendFormat": "uptime", + "range": true, + "refId": "A" + } + ], + "title": "Uptime (hours)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "bf0qoawhp68zkb" + }, + "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 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 13, + "x": 10, + "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": "sum(rate(http_requests_total[5m])) by (method, status)", + "legendFormat": "{{method}} {{status}}", + "refId": "A" + } + ], + "title": "Request Rate (RPS)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "bf0qoawhp68zkb" + }, + "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 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 10, + "x": 0, + "y": 3 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "rate(process_cpu_seconds_total[5m])", + "legendFormat": "CPU", + "refId": "A" + } + ], + "title": "Process CPU Usage (seconds)", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "tags": [ + "webapp", + "python", + "prometheus" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "HW3 Application Metrics", + "uid": "859be40b-c262-476d-b206-8d3613d07056", + "version": 2 +} \ No newline at end of file diff --git a/hw3/requirements.txt b/hw3/requirements.txt new file mode 100644 index 00000000..101a06b5 --- /dev/null +++ b/hw3/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.117.1 +uvicorn>=0.24.0 +prometheus-fastapi-instrumentator + diff --git a/hw3/settings/prometheus/prometheus.yml b/hw3/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..7fa1951b --- /dev/null +++ b/hw3/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/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..092921f1 --- /dev/null +++ b/hw3/shop_api/main.py @@ -0,0 +1,186 @@ +from fastapi import FastAPI, Response, HTTPException, status +from pydantic import BaseModel, conint, confloat +from prometheus_fastapi_instrumentator import Instrumentator + +app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +cart_id_counter = 0 +item_id_counter = 0 + +carts = {} +items = {} + +posint = conint(gt=0) +uint = conint(ge=0) +ufloat = confloat(ge=0) + +class ModifiedItem(BaseModel): + name: str | None = None + price: float | None = None + model_config = { + "extra": "forbid" + } + +class Item(BaseModel): + id: uint = 0 + name: str + price: float + deleted: bool = False + +class CartItem(BaseModel): + id: uint = 0 + name: str + price: float + quantity: int = 0 + deleted: bool = False + +class Cart(BaseModel): + id: int + price: float = 0.0 + items: list[CartItem] = [] + + +""" +cart +""" + +# POST cart - создание, работает как RPC, не принимает тело, возвращает идентификатор +@app.post("/cart", status_code=status.HTTP_201_CREATED) +async def create_cart(response: Response): + global cart_id_counter + cart_id_counter += 1 + new_cart = Cart( + id=cart_id_counter + ) + carts[new_cart.id] = new_cart + response.headers["Location"] = f"/cart/{new_cart.id}" + return {"id": new_cart.id} + + +# GET /cart/{id} - получение корзины по id +@app.get("/cart/{cart_id}", status_code=status.HTTP_200_OK) +async def get_cart(cart_id: int): + return carts[cart_id] + + +# GET /cart - получение списка корзин с query-параметрами +# offset - неотрицательное целое число, смещение по списку (опционально, по-умолчанию 0) +# limit - положительное целое число, ограничение на количество (опционально, по-умолчанию 10) +# min_price - число с плавающей запятой, минимальная цена включительно (опционально, если нет, не учитывает в фильтре) +# max_price - число с плавающей запятой, максимальная цена включительно (опционально, если нет, не учитывает в фильтре) +# min_quantity - неотрицательное целое число, минимальное общее число товаров включительно (опционально, если нет, не учитывается в фильтре) +# max_quantity - неотрицательное целое число, максимальное общее число товаров включительно (опционально, если нет, не учитывается в фильтре) +@app.get("/cart", status_code=status.HTTP_200_OK) +async def get_cart_list(offset: uint = 0, limit: posint = 10, + min_price: ufloat = None, max_price: ufloat = None, + min_quantity: uint = None, max_quantity: uint = None): + filtered_carts = [] + for cart in list(carts.values())[offset:]: + if len(filtered_carts) == limit: + break + + min_price_ok = cart.price >= min_price if min_price else True + max_price_ok = cart.price <= max_price if max_price else True + min_quantity_ok = sum(item.quantity for item in cart.items) >= min_quantity if not min_quantity is None else True + max_quantity_ok = sum(item.quantity for item in cart.items) <= max_quantity if not max_quantity is None else True + + if min_price_ok and max_price_ok and min_quantity_ok and max_quantity_ok: + filtered_carts.append(cart) + return filtered_carts + + +# POST /cart/{cart_id}/add/{item_id} - добавление в корзину с cart_id предмета с item_id, +# если товар уже есть, то увеличивается его количество +@app.post("/cart/{cart_id}/add/{item_id}", status_code=status.HTTP_200_OK) +async def add_to_cart(cart_id: int, item_id: int): + cart = carts[cart_id] + item = items[item_id] + + item_exists = False + for i in range(len(cart.items)): + if cart.items[i].id == item_id: + cart.items[i].quantity += 1 + item_exists = True + break + + if not item_exists: + new_item = CartItem( + **item.model_dump(), + quantity=1 + ) + cart.items.append(new_item) + + cart.price += item.price + + +""" +item +""" + +# POST /item - добавление нового товара +@app.post("/item", status_code=status.HTTP_201_CREATED) +async def create_item(item: Item): + global item_id_counter + item_id_counter += 1 + new_item = Item(id=item_id_counter, name=item.name, price=item.price) + items[new_item.id] = new_item + return new_item + +# GET /item/{id} - получение товара по id +@app.get("/item/{item_id}", status_code=status.HTTP_200_OK) +async def get_item(item_id: int): + if items[item_id].deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND + ) + return items[item_id] + + +# GET /item - получение списка товаров с query-параметрами +# offset - неотрицательное целое число, смещение по списку (опционально, по-умолчанию 0) +# limit - положительное целое число, ограничение на количество (опционально, по-умолчанию 10) +# min_price - число с плавающей запятой, минимальная цена (опционально, если нет, не учитывает в фильтре) +# max_price - число с плавающей запятой, максимальная цена (опционально, если нет, не учитывает в фильтре) +# show_deleted - булевая переменная, показывать ли удаленные товары (по умолчанию False) +@app.get("/item", status_code=status.HTTP_200_OK) +async def get_item_list(offset: uint = 0, limit: posint = 10, + min_price: ufloat = None, max_price: ufloat = None, + show_deleted: bool = False): + filtered_items = [] + for item in list(items.values())[offset:]: + if len(filtered_items) == limit: + break + + min_price_ok = item.price >= min_price if min_price else True + max_price_ok = item.price <= max_price if max_price else True + show_deleted_ok = (not item.deleted) or show_deleted + + if min_price_ok and max_price_ok and show_deleted_ok: + filtered_items.append(item) + + return filtered_items + +# PUT /item/{id} - замена товара по id (создание запрещено, только замена существующего) +@app.put("/item/{item_id}", status_code=status.HTTP_200_OK) +async def put_item(item_id: int, new_item: Item): + new_item.id = item_id + items[item_id] = new_item + return new_item + +# PATCH /item/{id} - частичное обновление товара по id (разрешено менять все поля, кроме deleted) +@app.patch("/item/{item_id}", status_code=status.HTTP_200_OK) +async def patch_item(item_id: int, new_item: ModifiedItem): + item = items[item_id] + if item.deleted: + raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED) + if new_item.name and item.name != new_item.name: + items[item_id].name = new_item.name + if new_item.price and item.price != new_item.price: + items[item_id].price = new_item.price + return items[item_id] + +# DELETE /item/{id} - удаление товара по id (товар помечается как удаленный) +@app.delete("/item/{item_id}", status_code=status.HTTP_200_OK) +async def delete_item(item_id: int): + items[item_id].deleted = True \ No newline at end of file diff --git a/hw4/Dockerfile b/hw4/Dockerfile new file mode 100644 index 00000000..27b0b636 --- /dev/null +++ b/hw4/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw4/README.md b/hw4/README.md new file mode 100644 index 00000000..93638951 --- /dev/null +++ b/hw4/README.md @@ -0,0 +1,16 @@ +## ДЗ + +За каждый пункт - 1 балл + +Внедрить во вторую домашку хранение данных в БД, для этого надо: +1) Добавить БД в docket-compose.yml (если БД - это отдельный сервис, если хотите использовать sqlite, то можно скипнуть этот шаг) +2) Переписать код на взаимодействие с вашей БД (если вы еще этого не сделали, если вы уже написали код с БД, подзравляю, вам остался только 3 пункт) +3) В свободной форме, напишите скрипты, которые просимулируют разные "проблемы" которые могут возникнуть в транзакциях (dirty read, not-repeatable read, serialize) и настраивая уровне изоляции покажите, что они действительно решаются (через SQLAlchemy например), то есть: +- показать dirty read при read uncommited +- показать что нет dirty read при read commited +- показать non-repeatable read при read commited +- показать что нет non-repeatable read при repeatable read +- показать phantom reads при repeatable read +- показать что нет phantom reads при serializable + +*Тут зависит от того какую БД вы выбрали, разные БД могут поддерживать разные уровни изоляции \ 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/db_transaction_problems.py b/hw4/db_transaction_problems.py new file mode 100644 index 00000000..8dfaf50a --- /dev/null +++ b/hw4/db_transaction_problems.py @@ -0,0 +1,120 @@ +import threading +import time + +from sqlalchemy.orm import Session +from shop_api.main import ItemOrm, CartItemOrm, engine + +def get_session_with_isolation(level: str) -> Session: + conn = engine.connect() + conn = conn.execution_options(isolation_level=level) + session = Session(bind=conn) + return session + +def clear_items(): + session = get_session_with_isolation("READ COMMITTED") + session.query(CartItemOrm).delete() + session.query(ItemOrm).delete() + session.commit() + session.close() + +def dirty_read_example(): + print("RUN DIRTY READ") + clear_items() + + session1 = get_session_with_isolation("READ UNCOMMITTED") + session2 = get_session_with_isolation("READ UNCOMMITTED") + + session1.connection() + session2.connection() + + def t1(): + item = ItemOrm(name="DirtyItem", price=123) + session1.add(item) + session1.flush() + print("T1: Inserted, not committed") + time.sleep(3) + session1.rollback() + + def t2(): + time.sleep(1) + items = session2.query(ItemOrm).filter_by(name="DirtyItem").all() + print(f"T2: Read items: {items}") + + thread1 = threading.Thread(target=t1) + thread2 = threading.Thread(target=t2) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + +def non_repeatable_read(): + print("\nRUN REPEATABLE READ") + clear_items() + + session1 = get_session_with_isolation("READ COMMITTED") + session2 = get_session_with_isolation("READ COMMITTED") + + session1.connection() + session2.connection() + + item = ItemOrm(name="NonRepeat", price=10) + session1.add(item) + session1.commit() + + def t1(): + i1 = session1.query(ItemOrm).filter_by(name="NonRepeat").first() + print(f"T1 first read: {i1.price}") + time.sleep(3) + i2 = session1.query(ItemOrm).filter_by(name="NonRepeat").first() + print(f"T1 second read: {i2.price}") + + def t2(): + time.sleep(1) + i = session2.query(ItemOrm).filter_by(name="NonRepeat").first() + i.price = 20 + session2.commit() + print("T2: Updated price to 20") + + thread1 = threading.Thread(target=t1) + thread2 = threading.Thread(target=t2) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + +def phantom_read_example(): + print("\nRUN PHANTOM READ") + clear_items() + + session1 = get_session_with_isolation("REPEATABLE READ") + session2 = get_session_with_isolation("REPEATABLE READ") + + session1.connection() + session2.connection() + + def t1(): + items1 = session1.query(ItemOrm).all() + print(f"T1 first read: {[i.name for i in items1]}") + time.sleep(3) + items2 = session1.query(ItemOrm).all() + print(f"T1 second read: {[i.name for i in items2]}") + + def t2(): + time.sleep(1) + item = ItemOrm(name="Phantom", price=50) + session2.add(item) + session2.commit() + print("T2: Added Phantom") + + thread1 = threading.Thread(target=t1) + thread2 = threading.Thread(target=t2) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + +if __name__ == "__main__": + dirty_read_example() + non_repeatable_read() + phantom_read_example() diff --git a/hw4/docker-compose.yml b/hw4/docker-compose.yml new file mode 100644 index 00000000..947dd220 --- /dev/null +++ b/hw4/docker-compose.yml @@ -0,0 +1,44 @@ +version: "3" + +services: + + postgres: + image: postgres:15 + container_name: hw4_postgres + environment: + POSTGRES_DB: hw4_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + local: + container_name: hw4_local + depends_on: + postgres: + condition: service_healthy + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + + transactions: + container_name: hw4_transactions + build: . + command: python -u db_transaction_problems.py + depends_on: + postgres: + condition: service_healthy + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw4/requirements.txt b/hw4/requirements.txt new file mode 100644 index 00000000..2f5464a9 --- /dev/null +++ b/hw4/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.117.1 +uvicorn>=0.24.0 +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 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/main.py b/hw4/shop_api/main.py new file mode 100644 index 00000000..8e636142 --- /dev/null +++ b/hw4/shop_api/main.py @@ -0,0 +1,282 @@ +from fastapi import FastAPI, Response, HTTPException, status +from pydantic import BaseModel, conint, confloat + +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey +from sqlalchemy.orm import relationship, Session +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from fastapi import Depends + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +DB_HOST = "postgres" +DB_PORT = "5432" +DB_NAME = "hw4_db" +DB_USER = "postgres" +DB_PASSWORD = "password" +DATABASE_URL = ( + f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" +) +engine = create_engine( + DATABASE_URL, + echo=False +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +class ItemOrm(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) + +class CartOrm(Base): + __tablename__ = "carts" + id = Column(Integer, primary_key=True, index=True) + price = Column(Float, default=0.0) + items = relationship("CartItemOrm", back_populates="cart") + +class CartItemOrm(Base): + __tablename__ = "cart_items" + id = Column(Integer, primary_key=True) + cart_id = Column(Integer, ForeignKey("carts.id")) + item_id = Column(Integer, ForeignKey("items.id")) + name = Column(String) + price = Column(Float) + quantity = Column(Integer, default=1) + cart = relationship("CartOrm", back_populates="items") + +Base.metadata.create_all(bind=engine) +app = FastAPI(title="Shop API") + +posint = conint(gt=0) +uint = conint(ge=0) +ufloat = confloat(ge=0) + +class ModifiedItem(BaseModel): + name: str | None = None + price: float | None = None + model_config = { + "extra": "forbid" + } + +class Item(BaseModel): + id: uint = 0 + name: str + price: float + deleted: bool = False + +class CartItem(BaseModel): + id: uint = 0 + name: str + price: float + quantity: int = 0 + deleted: bool = False + +class Cart(BaseModel): + id: int + price: float = 0.0 + items: list[CartItem] = [] + + +""" +cart +""" + +# POST cart - создание, работает как RPC, не принимает тело, возвращает идентификатор +@app.post("/cart", status_code=status.HTTP_201_CREATED) +async def create_cart(response: Response, db: Session = Depends(get_db)): + new_cart = CartOrm() + db.add(new_cart) + db.commit() + db.refresh(new_cart) + response.headers["Location"] = f"/cart/{new_cart.id}" + return {"id": new_cart.id} + + +# GET /cart/{id} - получение корзины по id +@app.get("/cart/{cart_id}", status_code=status.HTTP_200_OK) +async def get_cart(cart_id: int, db: Session = Depends(get_db)): + cart = db.query(CartOrm).filter(CartOrm.id == cart_id).first() + if not cart: + raise HTTPException(status_code=404) + return { + "id": cart.id, + "price": cart.price, + "items": [ + { + "id": i.item_id, + "name": i.name, + "price": i.price, + "quantity": i.quantity, + } + for i in cart.items + ] + } + + +# GET /cart - получение списка корзин с query-параметрами +# offset - неотрицательное целое число, смещение по списку (опционально, по-умолчанию 0) +# limit - положительное целое число, ограничение на количество (опционально, по-умолчанию 10) +# min_price - число с плавающей запятой, минимальная цена включительно (опционально, если нет, не учитывает в фильтре) +# max_price - число с плавающей запятой, максимальная цена включительно (опционально, если нет, не учитывает в фильтре) +# min_quantity - неотрицательное целое число, минимальное общее число товаров включительно (опционально, если нет, не учитывается в фильтре) +# max_quantity - неотрицательное целое число, максимальное общее число товаров включительно (опционально, если нет, не учитывается в фильтре) +@app.get("/cart", status_code=status.HTTP_200_OK) +async def get_cart_list( + offset: uint = 0, limit: posint = 10, + min_price: ufloat = None, max_price: ufloat = None, + min_quantity: uint = None, max_quantity: uint = None, + db: Session = Depends(get_db) +): + query = db.query(CartOrm) + + if min_price is not None: + query = query.filter(CartOrm.price >= min_price) + if max_price is not None: + query = query.filter(CartOrm.price <= max_price) + + carts = query.offset(offset).limit(limit).all() + + result = [] + for cart in carts: + total_quantity = sum(i.quantity for i in cart.items) + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + result.append({ + "id": cart.id, + "price": cart.price, + "items": [ + { + "id": i.item_id, + "name": i.name, + "price": i.price, + "quantity": i.quantity + } + for i in cart.items + ], + }) + + return result + + +# POST /cart/{cart_id}/add/{item_id} - добавление в корзину с cart_id предмета с item_id, +# если товар уже есть, то увеличивается его количество +@app.post("/cart/{cart_id}/add/{item_id}", status_code=status.HTTP_200_OK) +async def add_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + cart = db.query(CartOrm).filter(CartOrm.id == cart_id).first() + item = db.query(ItemOrm).filter(ItemOrm.id == item_id, ItemOrm.deleted == False).first() + if not cart or not item: + raise HTTPException(status_code=404) + cart_item = db.query(CartItemOrm).filter_by(cart_id=cart.id, item_id=item.id).first() + if cart_item: + cart_item.quantity += 1 + else: + cart_item = CartItemOrm( + cart_id=cart.id, + item_id=item.id, + name=item.name, + price=item.price, + quantity=1 + ) + db.add(cart_item) + cart.price += item.price + db.commit() + db.refresh(cart) + + +""" +item +""" + +# POST /item - добавление нового товара +@app.post("/item", status_code=status.HTTP_201_CREATED) +async def create_item(item: Item, db: Session = Depends(get_db)): + db_item = ItemOrm(name=item.name, price=item.price) + db.add(db_item) + db.commit() + db.refresh(db_item) + return db_item + + +# GET /item/{id} - получение товара по id +@app.get("/item/{item_id}", status_code=status.HTTP_200_OK) +async def get_item(item_id: int, db: Session = Depends(get_db)): + db_item = db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not db_item or db_item.deleted: + raise HTTPException(status_code=404) + return db_item + + +# GET /item - получение списка товаров с query-параметрами +# offset - неотрицательное целое число, смещение по списку (опционально, по-умолчанию 0) +# limit - положительное целое число, ограничение на количество (опционально, по-умолчанию 10) +# min_price - число с плавающей запятой, минимальная цена (опционально, если нет, не учитывает в фильтре) +# max_price - число с плавающей запятой, максимальная цена (опционально, если нет, не учитывает в фильтре) +# show_deleted - булевая переменная, показывать ли удаленные товары (по умолчанию False) +@app.get("/item", status_code=status.HTTP_200_OK) +async def get_item_list( + offset: uint = 0, limit: posint = 10, + min_price: ufloat = None, max_price: ufloat = None, + show_deleted: bool = False, + db: Session = Depends(get_db) +): + query = db.query(ItemOrm) + if not show_deleted: + query = query.filter(ItemOrm.deleted == False) + if min_price is not None: + query = query.filter(ItemOrm.price >= min_price) + if max_price is not None: + query = query.filter(ItemOrm.price <= max_price) + return query.offset(offset).limit(limit).all() + + +# PUT /item/{id} - замена товара по id (создание запрещено, только замена существующего) +@app.put("/item/{item_id}", status_code=status.HTTP_200_OK) +async def put_item(item_id: int, item: Item, db: Session = Depends(get_db)): + db_item = db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not db_item: + raise HTTPException(status_code=404, detail="Item not found") + db_item.name = item.name + db_item.price = item.price + db.commit() + db.refresh(db_item) + return db_item + + +# PATCH /item/{id} - частичное обновление товара по id (разрешено менять все поля, кроме deleted) +@app.patch("/item/{item_id}", status_code=status.HTTP_200_OK) +async def patch_item(item_id: int, item: ModifiedItem, db: Session = Depends(get_db)): + db_item = db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not db_item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if db_item.deleted: + raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED) + + if item.name is not None and db_item.name != item.name: + db_item.name = item.name + if item.price is not None and db_item.price != item.price: + db_item.price = item.price + db.commit() + db.refresh(db_item) + return db_item + + +# DELETE /item/{id} - удаление товара по id (товар помечается как удаленный) +@app.delete("/item/{item_id}", status_code=status.HTTP_200_OK) +async def delete_item(item_id: int, db: Session = Depends(get_db)): + db_item = db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not db_item: + raise HTTPException(status_code=404) + db_item.deleted = True + db.commit() \ No newline at end of file diff --git a/hw5/Dockerfile b/hw5/Dockerfile new file mode 100644 index 00000000..27b0b636 --- /dev/null +++ b/hw5/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw5/README.md b/hw5/README.md new file mode 100644 index 00000000..33e79328 --- /dev/null +++ b/hw5/README.md @@ -0,0 +1,5 @@ +# ДЗ + +1) Добиться 95% покрытия тестами вашей второй домашки - 1 балл + +2) Настроить автозапуск этих тестов в CI, если вы подключали сторонюю БД, то можно посмотреть вот [сюда](https://dev.to/kashifsoofi/integration-test-postgres-using-github-actions-3lln), чтобы поддержать тесты с ней в CI. По итогу у вас должен получится зеленый пайплайн - оценивается в еще 2 балла. diff --git a/hw5/__init__.py b/hw5/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/docker-compose.yml b/hw5/docker-compose.yml new file mode 100644 index 00000000..dda45d26 --- /dev/null +++ b/hw5/docker-compose.yml @@ -0,0 +1,36 @@ +version: "3" + +services: + + postgres: + image: postgres:15 + container_name: hw5_postgres + environment: + POSTGRES_DB: hw5_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + local: + container_name: hw5_local + depends_on: + postgres: + condition: service_healthy + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw5/requirements.txt b/hw5/requirements.txt new file mode 100644 index 00000000..6cfd288f --- /dev/null +++ b/hw5/requirements.txt @@ -0,0 +1,17 @@ +fastapi==0.120.0 +uvicorn==0.38.0 +sqlalchemy==2.0.44 +psycopg2-binary==2.9.11 +python-dotenv==1.2.1 + +pytest==8.4.2 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +pytest-asyncio==1.0.0 + +responses +faker +httpx + + + 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..fc956f97 --- /dev/null +++ b/hw5/shop_api/main.py @@ -0,0 +1,282 @@ +from fastapi import FastAPI, Response, HTTPException, status +from pydantic import BaseModel, conint, confloat + +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey +from sqlalchemy.orm import relationship, Session +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from fastapi import Depends + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +DB_HOST = "localhost" +DB_PORT = "5432" +DB_NAME = "hw5_db" +DB_USER = "postgres" +DB_PASSWORD = "password" +DATABASE_URL = ( + f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" +) +engine = create_engine( + DATABASE_URL, + echo=False +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +class ItemOrm(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) + +class CartOrm(Base): + __tablename__ = "carts" + id = Column(Integer, primary_key=True, index=True) + price = Column(Float, default=0.0) + items = relationship("CartItemOrm", back_populates="cart") + +class CartItemOrm(Base): + __tablename__ = "cart_items" + id = Column(Integer, primary_key=True) + cart_id = Column(Integer, ForeignKey("carts.id")) + item_id = Column(Integer, ForeignKey("items.id")) + name = Column(String) + price = Column(Float) + quantity = Column(Integer, default=1) + cart = relationship("CartOrm", back_populates="items") + +Base.metadata.create_all(bind=engine) +app = FastAPI(title="Shop API") + +posint = conint(gt=0) +uint = conint(ge=0) +ufloat = confloat(ge=0) + +class ModifiedItem(BaseModel): + name: str | None = None + price: float | None = None + model_config = { + "extra": "forbid" + } + +class Item(BaseModel): + id: uint = 0 + name: str + price: float + deleted: bool = False + +class CartItem(BaseModel): + id: uint = 0 + name: str + price: float + quantity: int = 0 + deleted: bool = False + +class Cart(BaseModel): + id: int + price: float = 0.0 + items: list[CartItem] = [] + + +""" +cart +""" + +# POST cart - создание, работает как RPC, не принимает тело, возвращает идентификатор +@app.post("/cart", status_code=status.HTTP_201_CREATED) +async def create_cart(response: Response, db: Session = Depends(get_db)): + new_cart = CartOrm() + db.add(new_cart) + db.commit() + db.refresh(new_cart) + response.headers["Location"] = f"/cart/{new_cart.id}" + return {"id": new_cart.id} + + +# GET /cart/{id} - получение корзины по id +@app.get("/cart/{cart_id}", status_code=status.HTTP_200_OK) +async def get_cart(cart_id: int, db: Session = Depends(get_db)): + cart = db.query(CartOrm).filter(CartOrm.id == cart_id).first() + if not cart: + raise HTTPException(status_code=404) + return { + "id": cart.id, + "price": cart.price, + "items": [ + { + "id": i.item_id, + "name": i.name, + "price": i.price, + "quantity": i.quantity, + } + for i in cart.items + ] + } + + +# GET /cart - получение списка корзин с query-параметрами +# offset - неотрицательное целое число, смещение по списку (опционально, по-умолчанию 0) +# limit - положительное целое число, ограничение на количество (опционально, по-умолчанию 10) +# min_price - число с плавающей запятой, минимальная цена включительно (опционально, если нет, не учитывает в фильтре) +# max_price - число с плавающей запятой, максимальная цена включительно (опционально, если нет, не учитывает в фильтре) +# min_quantity - неотрицательное целое число, минимальное общее число товаров включительно (опционально, если нет, не учитывается в фильтре) +# max_quantity - неотрицательное целое число, максимальное общее число товаров включительно (опционально, если нет, не учитывается в фильтре) +@app.get("/cart", status_code=status.HTTP_200_OK) +async def get_cart_list( + offset: uint = 0, limit: posint = 10, + min_price: ufloat = None, max_price: ufloat = None, + min_quantity: uint = None, max_quantity: uint = None, + db: Session = Depends(get_db) +): + query = db.query(CartOrm) + + if min_price is not None: + query = query.filter(CartOrm.price >= min_price) + if max_price is not None: + query = query.filter(CartOrm.price <= max_price) + + carts = query.offset(offset).limit(limit).all() + + result = [] + for cart in carts: + total_quantity = sum(i.quantity for i in cart.items) + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + result.append({ + "id": cart.id, + "price": cart.price, + "items": [ + { + "id": i.item_id, + "name": i.name, + "price": i.price, + "quantity": i.quantity + } + for i in cart.items + ], + }) + + return result + + +# POST /cart/{cart_id}/add/{item_id} - добавление в корзину с cart_id предмета с item_id, +# если товар уже есть, то увеличивается его количество +@app.post("/cart/{cart_id}/add/{item_id}", status_code=status.HTTP_200_OK) +async def add_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + cart = db.query(CartOrm).filter(CartOrm.id == cart_id).first() + item = db.query(ItemOrm).filter(ItemOrm.id == item_id, ItemOrm.deleted == False).first() + if not cart or not item: + raise HTTPException(status_code=404) + cart_item = db.query(CartItemOrm).filter_by(cart_id=cart.id, item_id=item.id).first() + if cart_item: + cart_item.quantity += 1 + else: + cart_item = CartItemOrm( + cart_id=cart.id, + item_id=item.id, + name=item.name, + price=item.price, + quantity=1 + ) + db.add(cart_item) + cart.price += item.price + db.commit() + db.refresh(cart) + + +""" +item +""" + +# POST /item - добавление нового товара +@app.post("/item", status_code=status.HTTP_201_CREATED) +async def create_item(item: Item, db: Session = Depends(get_db)): + db_item = ItemOrm(name=item.name, price=item.price) + db.add(db_item) + db.commit() + db.refresh(db_item) + return db_item + + +# GET /item/{id} - получение товара по id +@app.get("/item/{item_id}", status_code=status.HTTP_200_OK) +async def get_item(item_id: int, db: Session = Depends(get_db)): + db_item = db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not db_item or db_item.deleted: + raise HTTPException(status_code=404) + return db_item + + +# GET /item - получение списка товаров с query-параметрами +# offset - неотрицательное целое число, смещение по списку (опционально, по-умолчанию 0) +# limit - положительное целое число, ограничение на количество (опционально, по-умолчанию 10) +# min_price - число с плавающей запятой, минимальная цена (опционально, если нет, не учитывает в фильтре) +# max_price - число с плавающей запятой, максимальная цена (опционально, если нет, не учитывает в фильтре) +# show_deleted - булевая переменная, показывать ли удаленные товары (по умолчанию False) +@app.get("/item", status_code=status.HTTP_200_OK) +async def get_item_list( + offset: uint = 0, limit: posint = 10, + min_price: ufloat = None, max_price: ufloat = None, + show_deleted: bool = False, + db: Session = Depends(get_db) +): + query = db.query(ItemOrm) + if not show_deleted: + query = query.filter(ItemOrm.deleted == False) + if min_price is not None: + query = query.filter(ItemOrm.price >= min_price) + if max_price is not None: + query = query.filter(ItemOrm.price <= max_price) + return query.offset(offset).limit(limit).all() + + +# PUT /item/{id} - замена товара по id (создание запрещено, только замена существующего) +@app.put("/item/{item_id}", status_code=status.HTTP_200_OK) +async def put_item(item_id: int, item: Item, db: Session = Depends(get_db)): + db_item = db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not db_item: + raise HTTPException(status_code=404, detail="Item not found") + db_item.name = item.name + db_item.price = item.price + db.commit() + db.refresh(db_item) + return db_item + + +# PATCH /item/{id} - частичное обновление товара по id (разрешено менять все поля, кроме deleted) +@app.patch("/item/{item_id}", status_code=status.HTTP_200_OK) +async def patch_item(item_id: int, item: ModifiedItem, db: Session = Depends(get_db)): + db_item = db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not db_item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if db_item.deleted: + raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED) + + if item.name is not None and db_item.name != item.name: + db_item.name = item.name + if item.price is not None and db_item.price != item.price: + db_item.price = item.price + db.commit() + db.refresh(db_item) + return db_item + + +# DELETE /item/{id} - удаление товара по id (товар помечается как удаленный) +@app.delete("/item/{item_id}", status_code=status.HTTP_200_OK) +async def delete_item(item_id: int, db: Session = Depends(get_db)): + db_item = db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not db_item: + raise HTTPException(status_code=404) + db_item.deleted = True + db.commit() \ No newline at end of file diff --git a/hw5/tests/__init__.py b/hw5/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/tests/conftest.py b/hw5/tests/conftest.py new file mode 100644 index 00000000..844cdb23 --- /dev/null +++ b/hw5/tests/conftest.py @@ -0,0 +1,21 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from hw5.shop_api.main import app, Base + +DATABASE_URL = "postgresql://postgres:password@localhost:5432/hw5_db" + +engine = create_engine(DATABASE_URL) +TestingSessionLocal = sessionmaker(bind=engine) + +@pytest.fixture(autouse=True) +def clean_db(): + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + yield + +@pytest.fixture() +def client(): + return TestClient(app) diff --git a/hw5/tests/test_carts.py b/hw5/tests/test_carts.py new file mode 100644 index 00000000..9c56f086 --- /dev/null +++ b/hw5/tests/test_carts.py @@ -0,0 +1,97 @@ +def test_create_cart(client): + res = client.post("/cart") + assert res.status_code == 201 + assert res.json()["id"] == 1 + + +def test_get_cart(client): + res_cart = client.post("/cart") + cart_id = res_cart.json()["id"] + assert res_cart.status_code == 201 + + res_item = client.post("/item", json={"name": "item1", "price": 1.0}) + item_id = res_item.json()["id"] + assert res_item.status_code == 201 + + res_add = client.post(f"/cart/{cart_id}/add/{item_id}") + assert res_add.status_code == 200 + + res_get = client.get(f"/cart/{cart_id}") + assert res_get.status_code == 200 + cart = res_get.json() + assert cart["id"] == cart_id + assert cart["price"] == 1.0 + assert len(cart["items"]) == 1 + assert cart["items"][0]["name"] == "item1" + assert cart["items"][0]["quantity"] == 1 + + +def test_get_cart_fail(client): + res_get = client.get(f"/cart/{1}") + assert res_get.status_code == 404 + + +def test_get_cart_list(client): + res_item = client.post("/item", json={"name": "item1", "price": 1.0}) + item_id = res_item.json()["id"] + + res_cart = client.post("/cart") + cart_id = res_cart.json()["id"] + client.post(f"/cart/{cart_id}/add/{item_id}") + + res = client.get("/cart?min_price=0.5&max_price=2.0") + assert res.status_code == 200 + carts = res.json() + assert len(carts) >= 1 + assert carts[0]["id"] == cart_id + + res = client.get("/cart?min_price=0.5") + assert res.status_code == 200 + carts = res.json() + assert len(carts) >= 1 + assert carts[0]["id"] == cart_id + + res = client.get("/cart?max_price=2.0") + assert res.status_code == 200 + carts = res.json() + assert len(carts) >= 1 + assert carts[0]["id"] == cart_id + + +def test_add_to_cart(client): + res_item = client.post("/item", json={"name": "item1", "price": 1.0}) + assert res_item.status_code == 201 + item_id = res_item.json()["id"] + + res_cart = client.post("/cart") + assert res_cart.status_code == 201 + cart_id = res_cart.json()["id"] + + res = client.post(f"/cart/{cart_id}/add/{item_id}") + assert res.status_code == 200 + + res_cart = client.get(f"/cart/{cart_id}") + assert res_cart.status_code == 200 + cart = res_cart.json() + + assert cart["id"] == cart_id + assert len(cart["items"]) == 1 + assert cart["items"][0]["name"] == "item1" + assert cart["items"][0]["quantity"] == 1 + assert cart["price"] == 1.0 + + +def test_add_to_cart_fail(client): + res_item = client.post("/item", json={"name": "item1", "price": 1.0}) + assert res_item.status_code == 201 + item_id = res_item.json()["id"] + + res_cart = client.post("/cart") + assert res_cart.status_code == 201 + cart_id = res_cart.json()["id"] + + res = client.post(f"/cart/{cart_id}/add/{item_id + 1}") + assert res.status_code == 404 + + res = client.post(f"/cart/{cart_id + 1}/add/{item_id}") + assert res.status_code == 404 diff --git a/hw5/tests/test_items.py b/hw5/tests/test_items.py new file mode 100644 index 00000000..191f81d7 --- /dev/null +++ b/hw5/tests/test_items.py @@ -0,0 +1,112 @@ +def test_create_item(client): + res = client.post("/item", json={"name": "item1", "price": 1.0}) + assert res.status_code == 201 + body = res.json() + assert body["id"] == 1 + assert body["name"] == "item1" + assert body["price"] == 1.0 + + +def test_get_item(client): + client.post("/item", json={"name": "item1", "price": 1.0}) + res = client.get("/item/1") + assert res.status_code == 200 + assert res.json()["name"] == "item1" + + +def test_get_item_fail(client): + res = client.get("/item/1") + assert res.status_code == 404 + + +def test_get_item_list(client): + for i in range(1, 4): + res = client.post("/item", json={"name": f"item{i}", "price": float(i)}) + assert res.status_code == 201 + + res_delete = client.delete("/item/2") + assert res_delete.status_code == 200 + + res_list = client.get("/item") + assert res_list.status_code == 200 + items = res_list.json() + assert len(items) == 2 + assert all(item["id"] != 2 for item in items) + + res_all = client.get("/item?show_deleted=true") + assert res_all.status_code == 200 + all_items = res_all.json() + assert len(all_items) == 3 + assert any(item["id"] == 2 for item in all_items) + + res_min = client.get("/item?min_price=2.0") + assert res_min.status_code == 200 + min_items = res_min.json() + assert len(min_items) == 1 + assert all(item["price"] >= 2.0 for item in min_items) + + res_max = client.get("/item?max_price=2.0") + assert res_max.status_code == 200 + max_items = res_max.json() + assert len(max_items) == 1 + assert all(item["price"] <= 2.0 for item in max_items) + + res_range = client.get("/item?min_price=1.0&max_price=2.0") + assert res_range.status_code == 200 + range_items = res_range.json() + assert len(range_items) == 1 + assert all(1.0 <= item["price"] <= 2.0 for item in range_items) + + +def test_put_item(client): + res_create = client.post("/item", json={"name": "item1", "price": 1.0}) + item_id = res_create.json()["id"] + assert res_create.status_code == 201 + + res_put = client.put(f"/item/{item_id}", json={"name": "item2", "price": 2.0}) + assert res_put.status_code == 200 + item = res_put.json() + assert item["id"] == item_id + assert item["name"] == "item2" + assert item["price"] == 2.0 + + res_get = client.get(f"/item/{item_id}") + assert res_get.status_code == 200 + item_get = res_get.json() + assert item_get["name"] == "item2" + assert item_get["price"] == 2.0 + + +def test_put_item_fail(client): + res_create = client.post("/item", json={"name": "item1", "price": 1.0}) + item_id = res_create.json()["id"] + assert res_create.status_code == 201 + + next_id = item_id + 1 + res_put = client.put(f"/item/{next_id}", json={"name": "item2", "price": 2.0}) + assert res_put.status_code == 404 + + +def test_patch_item(client): + client.post("/item", json={"name": "item1", "price": 1.0}) + res = client.patch(f"/item/1", json={"price": 2.0}) + assert res.status_code == 200 + assert res.json()["price"] == 2.0 + + +def test_patch_item_fail(client): + client.post("/item", json={"name": "item1", "price": 1.0}) + res = client.patch(f"/item/2", json={"price": 2.0}) + assert res.status_code == 404 + + client.delete(f"/item/1") + res = client.patch(f"/item/1", json={"price": 2.0}) + assert res.status_code == 304 + + +def test_delete_item(client): + client.post("/item", json={"name": "item1", "price": 1.0}) + res = client.delete(f"/item/1") + assert res.status_code == 200 + res2 = client.get(f"/item/1") + assert res2.status_code == 404