diff --git a/.github/workflows/hw5_tests.yml b/.github/workflows/hw5_tests.yml new file mode 100644 index 00000000..74a4979a --- /dev/null +++ b/.github/workflows/hw5_tests.yml @@ -0,0 +1,51 @@ +name: "HW5 Tests" + +on: + pull_request: + branches: [ main ] + paths: [ 'hw4_5/**' ] + push: + branches: [ main ] + paths: [ 'hw4_5/**' ] + +jobs: + tests-hw5: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + + env: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/postgres + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: hw4_5/requirements.txt + + - name: Install deps + run: pip install -r hw4_5/requirements.txt + + - name: Run tests (hw5) + working-directory: hw4_5 + env: + PYTHONPATH: ${{ github.workspace }}/hw4_5 + run: pytest -q --cov=src --cov-report=term-missing + + - name: DB alembic migrations + working-directory: hw4_5 + env: + PYTHONPATH: ${{ github.workspace }}/hw4_5 + run: PYTHONPATH=. alembic upgrade head \ No newline at end of file diff --git a/hw1/app.py b/hw1/app.py index 6107b870..112daa23 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,6 +1,71 @@ from typing import Any, Awaitable, Callable +import json +import re + +def parse_query_string(qs): + """ + Парсит query string в словарь списков: 'n=5&x=3' → {'n': ['5'], 'x': ['3']} + """ + if not qs: + return {} + parsed = {} + pairs = qs.split('&') + for pair in pairs: + if '=' in pair: + key, value = pair.split('=', 1) + key = unquote(key) + value = unquote(value) + if key in parsed: + parsed[key].append(value) + else: + parsed[key] = [value] + return parsed + + +def unquote(string): + """ + Минимальная реализация URL-декодирования без urllib. + Поддерживает %XX и замену '+' на пробел. + """ + string = string.replace('+', ' ') + res = "" + i = 0 + while i < len(string): + if string[i] == '%' and i + 2 < len(string): + try: + hex_val = string[i+1:i+3] + byte_val = bytes.fromhex(hex_val) + char = byte_val.decode('utf-8') + res += char + i += 3 + continue + except Exception: + pass + res += string[i] + i += 1 + return res + + +async def send_json(send, status, data): + """ + Отправляет JSON-ответ. Гарантированно совместима с ASGI/h11. + """ + body = json.dumps(data, ensure_ascii=False).encode('utf-8') + headers = [ + (b'content-type', b'application/json'), + ] + await send({ + 'type': 'http.response.start', + 'status': status, + 'headers': headers, + }) + await send({ + 'type': 'http.response.body', + 'body': body, + }) + async def application( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], @@ -14,6 +79,137 @@ async def application( """ # TODO: Ваша реализация здесь + if scope["type"] != "http": + await send({ + "type": "http.response.start", + "status": 400, + "headers": [(b'content-type', b'text/plain')] + }) + + await send({ + "type": "http.response.body", + "body": b"Only HTTP supported" + }) + return + + path = scope["path"] + method = scope["method"] + + if method != "GET": + await send_json(send, 404, {"error": "Only GET methods allowed"}) + return + + if path == "/factorial" and method == "GET": + await handle_factorial(scope, receive, send) + + elif path.startswith("/fibonacci") and method == "GET": + await handle_fibonacci(scope, receive, send) + + elif path == "/mean" and method == "GET": + await handle_mean(scope, receive, send) + + else: + await send_json(send, 404, {"error": "Not Found"}) + + +async def handle_factorial(scope, recieve, send): + query_string = scope["query_string"].decode("utf-8") + params = parse_query_string(query_string) + + if "n" not in params or len(params["n"]) == 0: + await send_json(send, 422, {"error": "Missing query parameter: n"}) + return + + n_str = params["n"][0] + + try: + n = int(n_str) + except ValueError: + await send_json(send, 422, {"error": "n must be an integer"}) + return + + if n < 0: + await send_json(send, 400, {"error": "n must be a non-negative integer"}) + return + + result = 1 + for i in range(1, n + 1): + result *= i + + await send_json(send, 200, {"result": result}) + + + +async def handle_fibonacci(scope, recieve, send): + + path = scope["path"] + match = re.match(r"/fibonacci/(-?\d+)", path) + + if not match: + await send_json(send, 422, {"error": "Invalid path format. Expected /fibonacci/"}) + return + + try: + n = int(match.group(1)) + except ValueError: + await send_json(send, 422, {"error": "n must be an integer"}) + return + + if n < 0: + await send_json(send, 400, {"error": "n must be a non-negative integer"}) + return + + if n == 0: + fib = 0 + + elif n == 1: + fib = 1 + + else: + a, b = 0, 1 + for i in range(2, n + 1): + a, b = b, a + b + fib = b + + await send_json(send, 200, {"result": fib}) + + + + +async def handle_mean(scope, recieve, send): + body = b"" + more_body = True + + while more_body: + message = await recieve() + body += message.get("body", b"") + more_body = message.get("more_body", False) + + if not body: + await send_json(send, 422, {"error": "No body received"}) + return + + try: + data = json.loads(body) + if not isinstance(data, list): + raise ValueError("Expected JSON Array") + + numbers = [float(x) for x in data] + + except (json.JSONDecodeError, ValueError, TypeError, OverflowError): + await send_json(send, 422, {"error": "All values must be numbers"}) + return + + if len(numbers) == 0: + await send_json(send, 400, {"error": "No values received"}) + return + + mean_value = sum(numbers) / len(numbers) + await send_json(send, 200, {"result": mean_value}) + + + + 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..1dd029ce --- /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 +COPY . . + +ENV VIRTUAL_ENV=app/.venv \ + PATH=app/.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/client_websockets.py b/hw2/hw/client_websockets.py new file mode 100644 index 00000000..016d972a --- /dev/null +++ b/hw2/hw/client_websockets.py @@ -0,0 +1,68 @@ +import asyncio +import websockets + + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 8000 + + +async def chat_client(room_name: str, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT): + uri = f"ws://{host}:{port}/chat/{room_name}" + print(f"Подключение к комнате '{room_name}' по адресу {uri}...") + + try: + async with websockets.connect(uri) as websocket: + + welcome = await websocket.recv() + print(f"{welcome}\n") + print("Чат запущен! Введите сообщение и нажмите Enter.") + print("Чтобы выйти — введите 'quit' или нажмите Ctrl+C.\n") + + async def receive_messages(): + try: + while True: + message = await websocket.recv() + print(f"\r{message}") + print("Вы: ", end="", flush=True) + except websockets.exceptions.ConnectionClosed: + print("\nСоединение закрыто сервером.") + return + + async def send_messages(): + loop = asyncio.get_event_loop() + while True: + + msg = await loop.run_in_executor(None, input, "Вы: ") + if msg.strip().lower() == 'quit': + break + if msg.strip(): + await websocket.send(msg.strip()) + + await asyncio.gather(receive_messages(), send_messages()) + + except KeyboardInterrupt: + print("\nВыход по запросу пользователя.") + except Exception as e: + print(f"\nОшибка подключения: {e}") + print("Убедитесь, что сервер запущен: uvicorn main:app") + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description="WebSocket чат-клиент") + parser.add_argument("room", help="Имя комнаты для подключения") + parser.add_argument("--host", default=DEFAULT_HOST, help="Хост сервера (по умолчанию: localhost)") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Порт сервера (по умолчанию: 8000)") + + args = parser.parse_args() + + try: + asyncio.run(chat_client(args.room, args.host, args.port)) + except KeyboardInterrupt: + + print("\nДо встречи!") + + +if __name__ == "__main__": + main() \ 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..d2d1f35a --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + networks: + - monitor-net + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + networks: + - monitor-net + + 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 + networks: + - monitor-net + + +networks: + monitor-net: \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..974044ed 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,13 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +websockets>=1.8.0 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 + +# prometheus +prometheus-fastapi-instrumentator==7.1.0 \ No newline at end of file diff --git a/hw2/hw/screenshots_grafana/1.png b/hw2/hw/screenshots_grafana/1.png new file mode 100644 index 00000000..8d75ba36 Binary files /dev/null and b/hw2/hw/screenshots_grafana/1.png differ diff --git a/hw2/hw/screenshots_grafana/2.png b/hw2/hw/screenshots_grafana/2.png new file mode 100644 index 00000000..6ebe9fab Binary files /dev/null and b/hw2/hw/screenshots_grafana/2.png differ diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..6bdf88e7 --- /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 diff --git a/hw2/hw/shop_api/chat_manager/__init__.py b/hw2/hw/shop_api/chat_manager/__init__.py new file mode 100644 index 00000000..5fcbda19 --- /dev/null +++ b/hw2/hw/shop_api/chat_manager/__init__.py @@ -0,0 +1 @@ +from shop_api.chat_manager.chat_manager import chat_manager \ No newline at end of file diff --git a/hw2/hw/shop_api/chat_manager/chat_manager.py b/hw2/hw/shop_api/chat_manager/chat_manager.py new file mode 100644 index 00000000..a73bba87 --- /dev/null +++ b/hw2/hw/shop_api/chat_manager/chat_manager.py @@ -0,0 +1,73 @@ +import uuid +from typing import List, Dict +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + + + +class ChatManager: + def __init__(self): + + self.rooms: Dict[str, List[WebSocket]] = {} + self.user_data: Dict[WebSocket, tuple[str, str]] = {} + + def generate_username(self) -> str: + return f"user-{uuid.uuid4().hex}" + + async def connect(self, websocket: WebSocket, chat_name: str) -> str: + + await websocket.accept() + username = self.generate_username() + + if chat_name not in self.rooms: + self.rooms[chat_name] = [] + + self.rooms[chat_name].append(websocket) + self.user_data[websocket] = (username, chat_name) + + await websocket.send_text(f"Вы подключены как: {username}") + return username + + + async def disconnect(self, websocket: WebSocket): + + if websocket not in self.user_data: + return + + username, chat_name = self.user_data[websocket] + del self.user_data[websocket] + + if chat_name in self.rooms: + if websocket in self.rooms[chat_name]: + self.rooms[chat_name].remove(websocket) + # Удаляем пустую комнату + if not self.rooms[chat_name]: + del self.rooms[chat_name] + + + async def publish(self, websocket: WebSocket, message: str): + + if websocket not in self.user_data: + return + + username, chat_name = self.user_data[websocket] + full_message = f"{username} :: {message}" + + if chat_name not in self.rooms: + return + + disconnected = [] + for client in self.rooms[chat_name]: + try: + await client.send_text(full_message) + except Exception: + disconnected.append(client) + + for client in disconnected: + if client in self.rooms[chat_name]: + self.rooms[chat_name].remove(client) + if client in self.user_data: + del self.user_data[client] + + + +chat_manager = ChatManager() \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..389f5226 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,12 @@ from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator + +from shop_api.routers import cart_router, item_router, chat_router + + app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) +app.include_router(cart_router) +app.include_router(item_router) +app.include_router(chat_router) \ No newline at end of file diff --git a/hw2/hw/shop_api/routers/__init__.py b/hw2/hw/shop_api/routers/__init__.py new file mode 100644 index 00000000..5bc6a52f --- /dev/null +++ b/hw2/hw/shop_api/routers/__init__.py @@ -0,0 +1,3 @@ +from shop_api.routers.cart import router as cart_router +from shop_api.routers.item import router as item_router +from shop_api.routers.chat import router as chat_router \ No newline at end of file diff --git a/hw2/hw/shop_api/routers/cart.py b/hw2/hw/shop_api/routers/cart.py new file mode 100644 index 00000000..6345108f --- /dev/null +++ b/hw2/hw/shop_api/routers/cart.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, status, Response, Query +from fastapi.responses import JSONResponse +from typing import List, Optional + +from store.queries.cart import ( + add_cart, get_cart_by_id, + list_carts, add_item_to_cart +) +from shop_api.schemas import ( + CartResponse, + CartCreateResponse, + Msg +) + + + + +router = APIRouter( + prefix="/cart", + tags=["Cart"] +) + + +@router.post( + path="", + response_model=CartCreateResponse, + status_code=status.HTTP_201_CREATED +) +def create_cart(response: Response): + cart_id = add_cart() + response.headers["Location"] = f"/cart/{cart_id}" + return CartCreateResponse(id=cart_id) + + +@router.get( + path="/{id}", + response_model=CartResponse +) +async def get_cart(id: int): + cart = get_cart_by_id(id) + print(cart) + if cart is None: + return JSONResponse( + content=Msg(msg="Корзина не найдена").model_dump(), + status_code=404 + ) + + return cart + + +@router.get( + path="", + response_model=List[CartResponse] +) +async def get_list_carts( + offset: Optional[int] = Query(None, ge=0), + limit: Optional[int] = Query(None, ge=1), + min_price: Optional[float] = Query(None, ge=0.0), + max_price: Optional[float] = Query(None, ge=0.0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0) +): + + carts = list_carts( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + min_quantity=min_quantity, + max_quantity=max_quantity + ) + + return carts + + +@router.post( + path="/{cart_id}/add/{item_id}", + response_model=CartResponse +) +async def add_item_to_cart_endpoint( + cart_id: int, + item_id: int +): + cart = add_item_to_cart( + cart_id=cart_id, + item_id=item_id + ) + + if not cart: + return JSONResponse( + content=Msg(msg="Ничего не найдено").model_dump(), + status_code=404 + ) + print(cart) + return cart \ No newline at end of file diff --git a/hw2/hw/shop_api/routers/chat.py b/hw2/hw/shop_api/routers/chat.py new file mode 100644 index 00000000..796cff91 --- /dev/null +++ b/hw2/hw/shop_api/routers/chat.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from shop_api.chat_manager import chat_manager + + + +router = APIRouter( + prefix="/chat", + tags=["Chat"] +) + + +@router.websocket("/{chat_name}") +async def websocket_endpoint(websocket: WebSocket, chat_name: str): + try: + await chat_manager.connect(websocket, chat_name) + while True: + data = await websocket.receive_text() + await chat_manager.publish(websocket, data) + except WebSocketDisconnect: + pass + finally: + await chat_manager.disconnect(websocket) \ No newline at end of file diff --git a/hw2/hw/shop_api/routers/item.py b/hw2/hw/shop_api/routers/item.py new file mode 100644 index 00000000..db7d3836 --- /dev/null +++ b/hw2/hw/shop_api/routers/item.py @@ -0,0 +1,170 @@ +from fastapi import APIRouter, status, Response, Query +from fastapi.responses import JSONResponse +from http import HTTPStatus +from typing import List, Optional + +from store.queries.item import ( + add_item, get_item_by_id, + list_items, update_item_full, + update_item_partial, delete_item, + data_item +) +from shop_api.schemas import ( + ItemCreate, + ItemResponse, + CartResponse, + Msg +) + + + + +router = APIRouter( + prefix="/item", + tags=["Item"] +) + + +@router.post( + path="", + response_model=ItemResponse, + status_code=status.HTTP_201_CREATED +) +async def create_item_endpoint(item: ItemCreate): + if not item.price or not item.name: + return JSONResponse( + content=Msg( + msg="Не заполнены нужные поля" + ).model_dump(), + status_code=422 + ) + new_item = add_item( + name=item.name, + price=item.price, + ) + return new_item + + +@router.get( + path="/{id}", + response_model=ItemResponse +) +async def get_item_endpoint(id: int): + item = get_item_by_id(id) + if not item: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + + return item + + +@router.get( + path="", + response_model=List[ItemResponse] +) +async def get_list_items_endpoint( + offset: Optional[int] = Query(None, ge=0), + limit: Optional[int] = Query(None, ge=1), + min_price: Optional[float] = Query(None, ge=0.0), + max_price: Optional[float] = Query(None, ge=0.0), + show_deleted: Optional[bool] = Query(False) +): + items = list_items( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + show_deleted=show_deleted + ) + + return items + + +@router.put( + path="/{id}", + response_model=ItemResponse +) +async def update_full_item_endpoint( + id: int, + item: ItemCreate +): + + if not item.price or not item.name: + return JSONResponse( + content=Msg( + msg="Отсутствуют некоторые параметры" + ).model_dump(), + status_code=422 + + ) + if id not in data_item: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + + item = update_item_full( + item_id=id, + name=item.name, + price=item.price + ) + + return item + + +@router.patch( + path="/{id}", + response_model=ItemResponse +) +async def update_item_partial_endpoint( + id: int, + item: ItemCreate +): + + if id not in data_item: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + + if data_item[id].deleted: + return JSONResponse( + content=Msg( + msg="Айтем удален" + ).model_dump(), + status_code=304 + ) + + item = update_item_partial( + item_id=id, + name=item.name, + price=item.price, + ) + + return item + + +@router.delete( + path="/{id}", + response_model=ItemResponse +) +async def delete_item_endpoint(id: int): + item = delete_item(id) + + if not item: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=HTTPStatus.NOT_FOUND + ) + + return item \ No newline at end of file diff --git a/hw2/hw/shop_api/schemas.py b/hw2/hw/shop_api/schemas.py new file mode 100644 index 00000000..9ae1402b --- /dev/null +++ b/hw2/hw/shop_api/schemas.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel, ConfigDict +from typing import List, Optional + + + +class CartCreateResponse(BaseModel): + id: int + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: float + available: bool + + +class CartResponse(BaseModel): + id: int + items: List[CartItemResponse] = [] + price: float + + +class ItemCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: Optional[str] = None + price: Optional[float] = None + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + +class Msg(BaseModel): + msg: str \ No newline at end of file diff --git a/hw2/hw/store/__init__.py b/hw2/hw/store/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/store/models.py b/hw2/hw/store/models.py new file mode 100644 index 00000000..d2fedf44 --- /dev/null +++ b/hw2/hw/store/models.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import List + + + +@dataclass(slots=True) +class Item: + id: int + name: str + price: float + deleted: bool + + +@dataclass(slots=True) +class CartItem: + id: int + name: str + quantity: int + available: bool + + +@dataclass(slots=True) +class Cart: + id: int + items: List[CartItem] + price: float = 0.0 \ No newline at end of file diff --git a/hw2/hw/store/queries/__init__.py b/hw2/hw/store/queries/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/store/queries/cart.py b/hw2/hw/store/queries/cart.py new file mode 100644 index 00000000..d67f8e88 --- /dev/null +++ b/hw2/hw/store/queries/cart.py @@ -0,0 +1,154 @@ +from typing import Optional, List + +from store.queries.utils import id_generator +from store.queries.item import get_item_by_id, data_item +from store.models import ( + Cart, + Item, + CartItem +) + + + +data_cart = dict[int, Cart]() +cart_id_generator = id_generator() + + + +def add_cart() -> int: + new_cart_id = next(cart_id_generator) + data_cart[new_cart_id] = Cart(new_cart_id, []) + return new_cart_id + + +def get_cart_by_id(id: int) -> Cart | None: + if id not in data_cart: + return None + cart = data_cart[id] + if cart.items: + recalculate_cart(cart) + return data_cart[id] + + +def recalculate_cart(cart: Cart): + total = 0.0 + for item in cart.items: + cart_item = get_item_by_id(item.id) + print(cart_item) + if cart_item and not cart_item.deleted: + item.available = True + total += cart_item.price * item.quantity + else: + item.available = False + + cart.price = total + + +def update_item_full( + item_id: int, + name: Optional[str] = None, + price: Optional[float] = None, + deleted: Optional[bool] = None, +) -> Item | None: + if not item_id in data_item: + return None + + if name: + data_item[item_id].name = name + + if price: + data_item[item_id].price = price + + if deleted is not None: + data_item[item_id].deleted = deleted + + return data_item[item_id] + + +def update_item_partial( + item_id: int, + name: Optional[str] = None, + price: Optional[float] = None +) -> Item | None: + + if item_id not in data_item: + return None + + if name: + data_item[item_id].name = name + + if price: + data_item[item_id].price = price + + return data_item[item_id] + + +def list_carts( + offset: Optional[int] = None, + limit: Optional[int] = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + min_quantity: Optional[int] = None, + max_quantity: Optional[int] = None +) -> List[Cart]: + + result = [] + for cart in data_cart.values(): + if cart.items: + recalculate_cart(cart) + total_quantity = sum(item.quantity for item in cart.items) + + if min_price is not None and cart.price < min_price: + continue + + if max_price is not None and cart.price > max_price: + continue + + 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(cart) + + offset = offset or 0 + limit = limit or len(result) + + return result[offset:offset + limit] + + +def add_item_to_cart( + cart_id: int, + item_id: int +) -> Cart | None: + + if not cart_id in data_cart or \ + not item_id in data_item: + return None + + cart = get_cart_by_id(cart_id) + item = get_item_by_id(item_id) + + if item.deleted: + return None + + added = False + if cart.items: + for cart_item in cart.items: + if cart_item.id == item_id: + added = True + cart_item.quantity += 1 + break + + if not added: + cart.items.append( + CartItem( + id=item_id, + name=item.name, + quantity=1, + available=True + ) + ) + + return cart \ No newline at end of file diff --git a/hw2/hw/store/queries/item.py b/hw2/hw/store/queries/item.py new file mode 100644 index 00000000..2a91c2a7 --- /dev/null +++ b/hw2/hw/store/queries/item.py @@ -0,0 +1,101 @@ +from typing import Optional, List + +from store.models import Item +from store.queries.utils import id_generator + + + +data_item = dict[int, Item]() +item_id_generator = id_generator() + + +def add_item( + name: str, + price: float +) -> Item: + new_item_id = next(item_id_generator) + new_item = Item(new_item_id, name, price, False) + data_item[new_item_id] = new_item + return new_item + + +def delete_item(item_id: int) -> Item | None: + if item_id not in data_item: + return None + + data_item[item_id].deleted = True + return data_item[item_id] + + +def get_item_by_id(id: int) -> Item | None: + if not id in data_item: + return None + + item = data_item[id] + + if item.deleted: + return None + + return data_item[id] + + +def list_items( + offset: Optional[int] = None, + limit: Optional[int] = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + show_deleted: Optional[bool] = False +) -> List[Item] | None: + + items = list(data_item.values()) + if show_deleted is not None and show_deleted is False: + items = [item for item in items if not item.deleted] + + if min_price is not None: + items = [item for item in items if item.price >= min_price] + + if max_price is not None: + items = [item for item in items if item.price <= max_price] + + offset = offset or 0 + limit = limit or len(items) + + return items[offset:offset + limit] + +def update_item_full( + item_id: int, + name: Optional[str] = None, + price: Optional[float] = None, + deleted: Optional[bool] = None, +) -> Item | None: + if not item_id in data_item: + return None + + if name: + data_item[item_id].name = name + + if price: + data_item[item_id].price = price + + if deleted is not None: + data_item[item_id].deleted = deleted + + return data_item[item_id] + + +def update_item_partial( + item_id: int, + name: Optional[str] = None, + price: Optional[float] = None +) -> Item | None: + + if item_id not in data_item: + return None + + if name: + data_item[item_id].name = name + + if price: + data_item[item_id].price = price + + return data_item[item_id] \ No newline at end of file diff --git a/hw2/hw/store/queries/utils.py b/hw2/hw/store/queries/utils.py new file mode 100644 index 00000000..20955f51 --- /dev/null +++ b/hw2/hw/store/queries/utils.py @@ -0,0 +1,9 @@ +from typing import Iterable + + + +def id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 \ No newline at end of file diff --git a/hw2/hw/test_websockets.py b/hw2/hw/test_websockets.py new file mode 100644 index 00000000..b2a9c66f --- /dev/null +++ b/hw2/hw/test_websockets.py @@ -0,0 +1,36 @@ +import pytest +from fastapi.testclient import TestClient +from shop_api.main import app + + +client = TestClient(app) + + +def test_websocket_connection(): + + with client.websocket_connect("/chat/testroom") as websocket: + + data = websocket.receive_text() + assert data.startswith("Вы подключены как: user-") + + websocket.send_text("Привет из теста!") + + response = websocket.receive_text() + assert " :: Привет из теста!" in response + assert response.startswith("user-") + + +def test_message_format_with_extracted_username(): + with client.websocket_connect("/chat/test") as ws: + + welcome = ws.receive_text() + assert welcome.startswith("Вы подключены как: ") + username = welcome.replace("Вы подключены как: ", "").strip() + + test_msg = "Привет, это тест!" + ws.send_text(test_msg) + + response = ws.receive_text() + + expected = f"{username} :: {test_msg}" + assert response == expected, f"Ожидалось '{expected}', получено '{response}'" \ No newline at end of file diff --git a/hw4_5/.coveragerc b/hw4_5/.coveragerc new file mode 100644 index 00000000..198cfc9b --- /dev/null +++ b/hw4_5/.coveragerc @@ -0,0 +1,12 @@ +[run] +source = src +branch = True +concurrency = gevent +context = test + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError \ No newline at end of file diff --git a/hw4_5/Dockerfile b/hw4_5/Dockerfile new file mode 100644 index 00000000..115353d8 --- /dev/null +++ b/hw4_5/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12 + + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR app +COPY . . + +RUN pip install -r requirements.txt + +RUN chmod -R +x scripts/start.sh . + +ENTRYPOINT [ "sh", "scripts/start.sh" ] \ No newline at end of file diff --git a/hw4_5/README.md b/hw4_5/README.md new file mode 100644 index 00000000..39981942 --- /dev/null +++ b/hw4_5/README.md @@ -0,0 +1,22 @@ +# ДЗ 4 + +1. Запуск приложения и БД +```sh +# Postgres +docker compose up + +# Окружение +python3.12 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt + +# Миграции и запуск приложения +source scripts/start.sh +``` + +2. Запуск скриптов для демонстрации проблем с транзакциями: +```sh +export PYTHONPATH=${PWD}/src/ +python transactions_problems_scripts/transactions_problems_scripts.py +``` \ No newline at end of file diff --git a/hw4_5/alembic.ini b/hw4_5/alembic.ini new file mode 100644 index 00000000..7f7f01de --- /dev/null +++ b/hw4_5/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/hw4_5/alembic/README b/hw4_5/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/hw4_5/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/hw4_5/alembic/env.py b/hw4_5/alembic/env.py new file mode 100644 index 00000000..0756f5c4 --- /dev/null +++ b/hw4_5/alembic/env.py @@ -0,0 +1,97 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context + +from src.config import settings +from src.models import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_url(): + user = settings.POSTGRES_USER + password = settings.POSTGRES_PASSWORD + host = settings.POSTGRES_HOST + port = settings.POSTGRES_PORT + db = settings.POSTGRES_DB + url = f"postgresql://{user}:{password}@{host}:{port}/{db}" + return url + + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url", None) + if not url: + url = get_url() + + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section) + if not config.get_main_option("sqlalchemy.url", None): + configuration["sqlalchemy.url"] = get_url() + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + include_schemas=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/hw4_5/alembic/script.py.mako b/hw4_5/alembic/script.py.mako new file mode 100644 index 00000000..11016301 --- /dev/null +++ b/hw4_5/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/hw4_5/alembic/versions/a976573e4c55_added_required_tables.py b/hw4_5/alembic/versions/a976573e4c55_added_required_tables.py new file mode 100644 index 00000000..4b68d1a2 --- /dev/null +++ b/hw4_5/alembic/versions/a976573e4c55_added_required_tables.py @@ -0,0 +1,54 @@ +"""Added required tables + +Revision ID: a976573e4c55 +Revises: +Create Date: 2025-10-23 22:02:14.410133 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a976573e4c55' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('carts', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('items', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('deleted', sa.Boolean(), server_default='false', nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('cart_items', + sa.Column('cart_id', sa.UUID(), nullable=False), + sa.Column('item_id', sa.UUID(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('available', sa.Boolean(), server_default='false', nullable=False), + sa.ForeignKeyConstraint(['cart_id'], ['carts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.PrimaryKeyConstraint('cart_id', 'item_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('cart_items') + op.drop_table('items') + op.drop_table('carts') + # ### end Alembic commands ### diff --git a/hw4_5/docker-compose.yml b/hw4_5/docker-compose.yml new file mode 100644 index 00000000..76937e22 --- /dev/null +++ b/hw4_5/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3" + +volumes: + postgres-data: + +networks: + net: + +services: + postgres: + container_name: postgres_hw + environment: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + image: postgres:15 + volumes: + - postgres-data:/var/lib/postgresql/data + restart: on-failure + ports: + - "5432:5432" + networks: + - net \ No newline at end of file diff --git a/hw4_5/pytest.ini b/hw4_5/pytest.ini new file mode 100644 index 00000000..af5970c0 --- /dev/null +++ b/hw4_5/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests \ No newline at end of file diff --git a/hw4_5/requirements.txt b/hw4_5/requirements.txt new file mode 100644 index 00000000..134cdfd7 --- /dev/null +++ b/hw4_5/requirements.txt @@ -0,0 +1,23 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +pydantic==2.11.9 +pydantic-settings==2.11.0 +sqlalchemy==2.0.44 +alembic==1.17.0 +asyncpg==0.30.0 +greenlet==3.2.4 +psycopg2==2.9.11 +uvicorn>=0.24.0 +websockets>=1.8.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio==1.2.0 +pytest-asyncio>=0.21.0 +httpx==0.23.1 +Faker>=37.8.0 +pytest-cov==7.0.0 +gevent==25.9.1 + +# prometheus +prometheus-fastapi-instrumentator==7.1.0 \ No newline at end of file diff --git a/hw4_5/scripts/start.sh b/hw4_5/scripts/start.sh new file mode 100644 index 00000000..ecc390c8 --- /dev/null +++ b/hw4_5/scripts/start.sh @@ -0,0 +1,3 @@ +#! bin/bash +PYTHONPATH=. alembic upgrade head +python start_pg_app.py \ No newline at end of file diff --git a/hw4_5/src/config/__init__.py b/hw4_5/src/config/__init__.py new file mode 100644 index 00000000..61c140c9 --- /dev/null +++ b/hw4_5/src/config/__init__.py @@ -0,0 +1 @@ +from .settings import settings \ No newline at end of file diff --git a/hw4_5/src/config/settings.py b/hw4_5/src/config/settings.py new file mode 100644 index 00000000..0dad299c --- /dev/null +++ b/hw4_5/src/config/settings.py @@ -0,0 +1,25 @@ +from pydantic import Field +from pydantic_settings import SettingsConfigDict, BaseSettings + + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env") + + # Server + HOST: str = Field("0.0.0.0") + PORT: int = Field("8000") + + # Postgres + POSTGRES_HOST: str = Field("0.0.0.0") + POSTGRES_PORT: int = Field("5432") + POSTGRES_USER: str = Field("postgres") + POSTGRES_PASSWORD: str = Field("postgres") + POSTGRES_DB: str = Field("postgres") + + @property + def DATABASE_URL(self): + return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + + +settings = Settings() \ No newline at end of file diff --git a/hw4_5/src/crud/__init__.py b/hw4_5/src/crud/__init__.py new file mode 100644 index 00000000..181a8302 --- /dev/null +++ b/hw4_5/src/crud/__init__.py @@ -0,0 +1,2 @@ +from .item import crud_item +from .cart import crud_cart \ No newline at end of file diff --git a/hw4_5/src/crud/base.py b/hw4_5/src/crud/base.py new file mode 100644 index 00000000..9659cff4 --- /dev/null +++ b/hw4_5/src/crud/base.py @@ -0,0 +1,83 @@ +from typing import TypeVar, Generic, Optional, List, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, Result +from pydantic import BaseModel +from uuid import UUID + +from ..models import Base + + + +ModelType = TypeVar("ModelType", bound=Base) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) + + + +class CRUDBase(Generic[ModelType, CreateSchemaType]): + def __init__( + self, + model: ModelType + ): + self.model = model + + async def create( + self, + db: AsyncSession, + *, + obj_in: Optional[CreateSchemaType] = None, + **kwargs: Any + ) -> ModelType: + + if obj_in is not None: + obj_data = obj_in.model_dump() + else: + obj_data = kwargs + + db_obj = self.model(**obj_data) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + + + async def get( + self, + db: AsyncSession, + id: UUID + ) -> Optional[ModelType]: + + query = select(self.model).where(self.model.id == id) + result: Result = await db.execute(query) + return result.scalars().first() + + + async def update( + self, + db: AsyncSession, + *, + id: UUID, + obj_in: dict | BaseModel, + skip_deleted_check: bool = False # если модель не имеет deleted + ) -> Optional[ModelType]: + + db_obj = await self.get(db, id) + if not db_obj: + return None + + if hasattr(db_obj, 'deleted') and not skip_deleted_check: + if db_obj.deleted: + return None + + if isinstance(obj_in, BaseModel): + update_data = obj_in.model_dump(exclude_unset=True) # только переданные поля + else: + update_data = obj_in + + for field, value in update_data.items(): + if hasattr(db_obj, field): + setattr(db_obj, field, value) + + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj diff --git a/hw4_5/src/crud/cart.py b/hw4_5/src/crud/cart.py new file mode 100644 index 00000000..94c0b7f9 --- /dev/null +++ b/hw4_5/src/crud/cart.py @@ -0,0 +1,196 @@ +from typing import List, Optional +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from sqlalchemy.orm import joinedload + +from .base import CRUDBase +from ..models import CartModel, CartItemModel, ItemModel +from ..schemas import CartItemResponse, CartResponse + + +class CRUDCart(CRUDBase[CartModel, None]): + + async def get_carts_with_filters( + self, + db: AsyncSession, + offset: int = 0, + limit: int = 10, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + min_quantity: Optional[int] = None, + max_quantity: Optional[int] = None, + ) -> List[CartResponse]: + + quantity_subq = ( + select( + CartItemModel.cart_id, + func.coalesce(func.sum(CartItemModel.quantity), 0).label("total_quantity") + ) + .join(ItemModel, CartItemModel.item_id == ItemModel.id) + .where(ItemModel.deleted.is_(False)) + .group_by(CartItemModel.cart_id) + .subquery() + ) + + query = ( + select( + CartModel.id, + CartModel.price, + func.coalesce(quantity_subq.c.total_quantity, 0).label("total_quantity") + ) + .select_from(CartModel) + .outerjoin(quantity_subq, CartModel.id == quantity_subq.c.cart_id) + ) + + if min_price is not None: + query = query.where(CartModel.price >= min_price) + if max_price is not None: + query = query.where(CartModel.price <= max_price) + if min_quantity is not None: + query = query.where(func.coalesce(quantity_subq.c.total_quantity, 0) >= min_quantity) + if max_quantity is not None: + query = query.where(func.coalesce(quantity_subq.c.total_quantity, 0) <= max_quantity) + + query = query.offset(offset).limit(limit) + + result = await db.execute(query) + cart_rows = result.fetchall() + + if not cart_rows: + return [] + + cart_ids = [r.id for r in cart_rows] + cart_info = {r.id: r for r in cart_rows} + + cart_items_result = await db.execute( + select(CartItemModel) + .options(joinedload(CartItemModel.item)) + .join(ItemModel, CartItemModel.item_id == ItemModel.id) + .where(CartItemModel.cart_id.in_(cart_ids)) + .where(ItemModel.deleted.is_(False)) + ) + cart_items = cart_items_result.scalars().all() + + cart_items_map = {} + for ci in cart_items: + cart_items_map.setdefault(ci.cart_id, []).append(ci) + + response = [] + for cart_id in cart_ids: + row = cart_info[cart_id] + total_quantity = row.total_quantity + items = [] + if total_quantity > 0: + items = [ + CartItemResponse( + id=ci.item_id, + name=ci.item.name, + quantity=ci.quantity, + available=ci.available, + ) + for ci in cart_items_map.get(cart_id, []) + ] + response.append( + CartResponse( + id=cart_id, + items=items, + price=row.price + ) + ) + return response + + + async def add_item_to_cart( + self, + db: AsyncSession, + *, + cart_id: UUID, + item_id: UUID, + ) -> bool: + + cart_result = await db.execute( + select(CartModel) + .where(CartModel.id == cart_id) + .with_for_update() + ) + cart = cart_result.scalar_one_or_none() + if not cart: + return False + + item_result = await db.execute( + select(ItemModel) + .where(ItemModel.id == item_id) + .where(ItemModel.deleted.is_(False)) + ) + item = item_result.scalar_one_or_none() + if not item: + return False + + cart_item_result = await db.execute( + select(CartItemModel) + .where(CartItemModel.cart_id == cart_id) + .where(CartItemModel.item_id == item_id) + ) + existing_cart_item = cart_item_result.scalar_one_or_none() + + if existing_cart_item: + existing_cart_item.quantity += 1 + price_delta = item.price + else: + new_cart_item = CartItemModel( + cart_id=cart_id, + item_id=item_id, + quantity=1, + available=True + ) + db.add(new_cart_item) + price_delta = item.price + + cart.price += price_delta + + try: + await db.commit() + return True + except Exception: + await db.rollback() + return False + + + async def get_cart_with_items( + self, + db: AsyncSession, + *, + id: UUID, + ): + cart = await db.get(CartModel, id) + if not cart: + return None + + await db.refresh(cart, ["items"]) + + items = [] + total_quantity = 0 + for ci in cart.items: + if ci.item.deleted: + continue + items.append( + CartItemResponse( + id=ci.item_id, + name=ci.item.name, + quantity=ci.quantity, + available=ci.available, + ) + ) + total_quantity += ci.quantity + + return CartResponse( + id=cart.id, + items=items, + price=cart.price + ) + + + +crud_cart = CRUDCart(CartModel) \ No newline at end of file diff --git a/hw4_5/src/crud/item.py b/hw4_5/src/crud/item.py new file mode 100644 index 00000000..36a00268 --- /dev/null +++ b/hw4_5/src/crud/item.py @@ -0,0 +1,56 @@ +from typing import List, Optional +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from .base import CRUDBase +from ..models import ItemModel +from src.schemas import ItemCreate + + +class CRUDItem(CRUDBase[ItemModel, ItemCreate]): + + async def get_items_with_filters( + self, + db: AsyncSession, + offset: int, + limit: int, + min_price: Optional[float], + max_price: Optional[float], + show_deleted: bool, + ) -> List[ItemModel]: + + query = select(ItemModel) + query = query.where(ItemModel.deleted.is_(show_deleted)) + + if min_price is not None: + query = query.where(ItemModel.price >= min_price) + if max_price is not None: + query = query.where(ItemModel.price <= max_price) + + query = query.offset(offset).limit(limit) + + result = await db.execute(query) + return list(result.scalars().all()) + + + async def soft_delete( + self, + db: AsyncSession, + *, + id: UUID, + ) -> Optional[ItemModel]: + + obj = await self.get(db, id) + if obj is None: + return None + + obj.deleted = True + db.add(obj) + await db.commit() + await db.refresh(obj) + return obj + + +crud_item = CRUDItem(ItemModel) \ No newline at end of file diff --git a/hw4_5/src/db/__init__.py b/hw4_5/src/db/__init__.py new file mode 100644 index 00000000..d4889125 --- /dev/null +++ b/hw4_5/src/db/__init__.py @@ -0,0 +1 @@ +from .deps import get_db \ No newline at end of file diff --git a/hw4_5/src/db/deps.py b/hw4_5/src/db/deps.py new file mode 100644 index 00000000..aa2d3296 --- /dev/null +++ b/hw4_5/src/db/deps.py @@ -0,0 +1,7 @@ +from src.db.session import async_session + + + +async def get_db(): + async with async_session() as session: + yield session \ No newline at end of file diff --git a/hw4_5/src/db/session.py b/hw4_5/src/db/session.py new file mode 100644 index 00000000..5d1600d6 --- /dev/null +++ b/hw4_5/src/db/session.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + +from src.config import settings + + + +db_engine = create_async_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + max_overflow=0, +) + +async_session = async_sessionmaker( + autocommit=False, + autoflush=False, + bind=db_engine +) diff --git a/hw4_5/src/main.py b/hw4_5/src/main.py new file mode 100644 index 00000000..9ee41150 --- /dev/null +++ b/hw4_5/src/main.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI + +from src.routers import item_router, cart_router + + + +pg_app = FastAPI(title="Shop API") + +pg_app.include_router( + router=cart_router, + prefix="/carts", + tags=["Cart"] +) +pg_app.include_router( + router=item_router, + prefix="/items", + tags=["Items"] +) \ No newline at end of file diff --git a/hw4_5/src/models.py b/hw4_5/src/models.py new file mode 100644 index 00000000..41d6a685 --- /dev/null +++ b/hw4_5/src/models.py @@ -0,0 +1,63 @@ +from typing import List +from uuid import UUID as UUID_PY, uuid4 +from sqlalchemy import String, Boolean, Integer, Float, ForeignKey +from sqlalchemy.dialects.postgresql import UUID as UUID_PG +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class ItemModel(Base): + __tablename__ = "items" + + id: Mapped[UUID_PY] = mapped_column(UUID_PG(as_uuid=True), primary_key=True, default=lambda: uuid4().hex) + name: Mapped[str] = mapped_column(String, nullable=False) + price: Mapped[float] = mapped_column(Float) + deleted: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="false" + ) + + +class CartModel(Base): + __tablename__ = "carts" + + id: Mapped[UUID_PY] = mapped_column( + UUID_PG(as_uuid=True), + primary_key=True, + default=lambda: uuid4().hex + ) + + items: Mapped[List["CartItemModel"]] = relationship( + back_populates="cart", + cascade="all, delete-orphan", + lazy="selectin", + ) + price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) + + +class CartItemModel(Base): + __tablename__ = "cart_items" + + cart_id: Mapped[UUID_PY] = mapped_column( + ForeignKey("carts.id", ondelete="CASCADE"), + primary_key=True + ) + item_id: Mapped[UUID_PY] = mapped_column( + ForeignKey("items.id"), + primary_key=True + ) + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + cart: Mapped["CartModel"] = relationship(back_populates="items") + item: Mapped["ItemModel"] = relationship(lazy="joined") + available: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="false" + ) \ No newline at end of file diff --git a/hw4_5/src/routers/__init__.py b/hw4_5/src/routers/__init__.py new file mode 100644 index 00000000..674c96c7 --- /dev/null +++ b/hw4_5/src/routers/__init__.py @@ -0,0 +1,2 @@ +from .cart import router as cart_router +from .item import router as item_router \ No newline at end of file diff --git a/hw4_5/src/routers/cart.py b/hw4_5/src/routers/cart.py new file mode 100644 index 00000000..bf6ca1fa --- /dev/null +++ b/hw4_5/src/routers/cart.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, status, Response, Query, Depends +from fastapi.responses import JSONResponse +from typing import List, Optional +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession + +from src.crud import crud_cart +from src.db import get_db +from src.schemas import ( + CartResponse, + CartCreateResponse, + Msg +) + + +router = APIRouter() + + +@router.post( + path="", + response_model=CartCreateResponse, + status_code=status.HTTP_201_CREATED +) +async def create_cart( + response: Response, + db: AsyncSession = Depends(get_db) +): + new_cart = await crud_cart.create(db) + cart_id = new_cart.id + response.headers["Location"] = f"/carts/{cart_id}" + return CartCreateResponse(id=cart_id) + + +@router.get( + path="/{id}", + response_model=CartResponse +) +async def get_cart( + id: UUID, + db: AsyncSession = Depends(get_db) +): + cart = await crud_cart.get_cart_with_items(db=db, id=id) + if cart is None: + return JSONResponse( + content=Msg(msg="Корзина не найдена").model_dump(), + status_code=404 + ) + return cart + + +@router.get( + path="", + response_model=List[CartResponse] +) +async def get_list_carts( + offset: Optional[int] = Query(None, ge=0), + limit: Optional[int] = Query(None, ge=1), + min_price: Optional[float] = Query(None, ge=0.0), + max_price: Optional[float] = Query(None, ge=0.0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0), + db: AsyncSession = Depends(get_db) +): + carts = await crud_cart.get_carts_with_filters( + db=db, + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + min_quantity=min_quantity, + max_quantity=max_quantity, + ) + + return carts + + +@router.post( + path="/{cart_id}/add/{item_id}", + response_model=Msg +) +async def add_item_to_cart_endpoint( + cart_id: UUID, + item_id: UUID, + db: AsyncSession = Depends(get_db) +): + cart = await crud_cart.add_item_to_cart( + db=db, + cart_id=cart_id, + item_id=item_id + ) + + if not cart: + return JSONResponse( + content=Msg(msg="Ничего не найдено").model_dump(), + status_code=404 + ) + + return JSONResponse( + content=Msg(msg=f"Айтем {item_id} успешно добавлен в корзину {cart_id}").model_dump(), + status_code=200 + ) \ No newline at end of file diff --git a/hw4_5/src/routers/item.py b/hw4_5/src/routers/item.py new file mode 100644 index 00000000..de6f0ce2 --- /dev/null +++ b/hw4_5/src/routers/item.py @@ -0,0 +1,147 @@ +from uuid import UUID +from fastapi import APIRouter, status, Query, Depends +from fastapi.responses import JSONResponse +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from src.crud import crud_item +from src.db import get_db +from src.schemas import ( + ItemCreate, + ItemResponse, + Msg, ItemUpdate, ItemPatch +) + +router = APIRouter() + + +@router.post( + path="", + response_model=ItemResponse, + status_code=status.HTTP_201_CREATED +) +async def create_item_endpoint( + item: ItemCreate, + db: AsyncSession = Depends(get_db) +): + new_item = await crud_item.create(db=db, obj_in=item) + return new_item + + +@router.get( + path="/{id}", + response_model=ItemResponse +) +async def get_item_endpoint( + id: UUID, + db: AsyncSession = Depends(get_db) +): + item = await crud_item.get(db=db, id=id) + if not item: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + return item + + +@router.get( + path="", + response_model=List[ItemResponse] +) +async def get_list_items_endpoint( + offset: Optional[int] = Query(None, ge=0), + limit: Optional[int] = Query(None, ge=1), + min_price: Optional[float] = Query(None, ge=0.0), + max_price: Optional[float] = Query(None, ge=0.0), + show_deleted: Optional[bool] = Query(False), + db: AsyncSession = Depends(get_db) +): + items = await crud_item.get_items_with_filters( + db=db, + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + show_deleted=show_deleted + ) + + return items + + +@router.put( + path="/{id}", + response_model=ItemResponse +) +async def update_full_item_endpoint( + id: UUID, + item: ItemUpdate, + db: AsyncSession = Depends(get_db) +): + + item_updated = await crud_item.update( + db=db, + id=id, + obj_in=item + ) + if not item_updated: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + + return item_updated + + +@router.patch( + path="/{id}", + response_model=ItemResponse +) +async def update_item_partial_endpoint( + id: UUID, + item: ItemPatch, + db: AsyncSession = Depends(get_db) +): + + item_patched = await crud_item.update( + db=db, + id=id, + obj_in=item + ) + + if not item_patched: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + + return item_patched + + +@router.delete( + path="/{id}", + response_model=ItemResponse +) +async def delete_item_endpoint( + id: UUID, + db: AsyncSession = Depends(get_db) +): + item_deleted = await crud_item.soft_delete( + db=db, + id=id + ) + + if not item_deleted: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + return item_deleted \ No newline at end of file diff --git a/hw4_5/src/schemas.py b/hw4_5/src/schemas.py new file mode 100644 index 00000000..678ca06a --- /dev/null +++ b/hw4_5/src/schemas.py @@ -0,0 +1,50 @@ +from pydantic import BaseModel, ConfigDict +from typing import List, Optional +from uuid import UUID + + +class CartCreate(BaseModel): + pass + +class CartCreateResponse(BaseModel): + id: UUID + +class CartItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + name: str + quantity: float + available: bool + + +class CartResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + items: List[CartItemResponse] = [] + price: float + + +class ItemCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + price: float + + +class ItemUpdate(ItemCreate): + pass + +class ItemPatch(ItemCreate): + name: Optional[str] = None + price: Optional[float] = None + +class ItemResponse(ItemCreate): + model_config = ConfigDict(from_attributes=True) + + id: UUID + deleted: bool + + +class Msg(BaseModel): + msg: str \ No newline at end of file diff --git a/hw4_5/start_pg_app.py b/hw4_5/start_pg_app.py new file mode 100644 index 00000000..cacea25d --- /dev/null +++ b/hw4_5/start_pg_app.py @@ -0,0 +1,13 @@ +import uvicorn + +from src.config import settings + + + +if __name__ == "__main__": + uvicorn.run( + app="src.main:pg_app", + host=settings.HOST, + port=settings.PORT, + reload=False + ) \ No newline at end of file diff --git a/hw4_5/tests/conftest.py b/hw4_5/tests/conftest.py new file mode 100644 index 00000000..1e0e62f3 --- /dev/null +++ b/hw4_5/tests/conftest.py @@ -0,0 +1,73 @@ +import asyncio +import pytest +import pytest_asyncio +import httpx +from httpx._transports.asgi import ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.pool import NullPool +from sqlalchemy import text + +from src.config import settings +from src.models import Base + + + +test_engine = create_async_engine( + settings.DATABASE_URL, + poolclass=NullPool, + echo=False, +) + +TestingSessionLocal = async_sessionmaker( + bind=test_engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +@pytest.fixture(scope="session", autouse=True) +def init_test_db(): + asyncio.run(_setup_db()) + yield + asyncio.run(_teardown_db()) + + +async def _setup_db(): + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + +async def _teardown_db(): + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(autouse=True) +async def clean_db(): + async with test_engine.begin() as conn: + for table in reversed(Base.metadata.sorted_tables): + await conn.execute(text(f"TRUNCATE {table.name} RESTART IDENTITY CASCADE")) + + +@pytest_asyncio.fixture +async def client(): + from src import main + from src.db.deps import get_db + + async def override_get_db(): + async with TestingSessionLocal() as session: + print(f"→ OPEN session {id(session)}") + try: + yield session + finally: + print(f"← CLOSE session {id(session)}") + + pg_app = main.pg_app + pg_app.dependency_overrides[get_db] = override_get_db + + transport = ASGITransport(app=pg_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + pg_app.dependency_overrides.clear() \ No newline at end of file diff --git a/hw4_5/tests/test_cart.py b/hw4_5/tests/test_cart.py new file mode 100644 index 00000000..46a2a147 --- /dev/null +++ b/hw4_5/tests/test_cart.py @@ -0,0 +1,198 @@ +import httpx +from http import HTTPStatus +from uuid import UUID +import pytest + + +class TestCartAPI: + + @pytest.mark.asyncio + async def test_create_cart(self, client: httpx.AsyncClient): + response = await client.post("/carts") + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert "id" in data + UUID(data["id"]) + assert response.headers["Location"] == f"/carts/{data['id']}" + + @pytest.mark.asyncio + async def test_get_cart_not_found(self, client: httpx.AsyncClient): + fake_id = "12345678-1234-5678-1234-567812345678" + response = await client.get(f"/carts/{fake_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["msg"] == "Корзина не найдена" + + @pytest.mark.asyncio + async def test_get_empty_cart(self, client: httpx.AsyncClient): + cart = (await client.post("/carts")).json() + cart_id = cart["id"] + + response = await client.get(f"/carts/{cart_id}") + assert response.status_code == HTTPStatus.OK + cart_data = response.json() + assert cart_data["items"] == [] + assert cart_data["price"] == 0.0 + + @pytest.mark.asyncio + async def test_get_cart_with_items(self, client: httpx.AsyncClient): + item_resp = await client.post("/items", json={"name": "Phone", "price": 500.0}) + item = item_resp.json() + item_id = item["id"] + + cart_resp = await client.post("/carts") + cart_id = cart_resp.json()["id"] + + await client.post(f"/carts/{cart_id}/add/{item_id}") + + response = await client.get(f"/carts/{cart_id}") + assert response.status_code == HTTPStatus.OK + cart = response.json() + assert cart["id"] == cart_id + assert cart["price"] == 500.0 + assert len(cart["items"]) == 1 + + cart_item = cart["items"][0] + assert cart_item["id"] == item_id + assert cart_item["name"] == "Phone" + assert cart_item["quantity"] == 1.0 + assert cart_item["available"] is True + + @pytest.mark.asyncio + async def test_add_item_twice_increases_quantity(self, client: httpx.AsyncClient): + item = (await client.post("/items", json={"name": "Phone", "price": 500.0})).json() + item_id = item["id"] + + cart = (await client.post("/carts")).json() + cart_id = cart["id"] + + await client.post(f"/carts/{cart_id}/add/{item_id}") + await client.post(f"/carts/{cart_id}/add/{item_id}") + + response = await client.get(f"/carts/{cart_id}") + cart_data = response.json() + assert len(cart_data["items"]) == 1 + assert cart_data["items"][0]["quantity"] == 2.0 + assert cart_data["price"] == 1000.0 + + @pytest.mark.asyncio + async def test_list_carts_with_min_price(self, client: httpx.AsyncClient): + cheap = (await client.post("/items", json={"name": "Cheap", "price": 10.0})).json() + expensive = (await client.post("/items", json={"name": "Expensive", "price": 200.0})).json() + + cart1 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart1['id']}/add/{cheap['id']}") + + cart2 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart2['id']}/add/{expensive['id']}") + + response = await client.get("/carts", params={"min_price": 100.0}) + carts = response.json() + assert len(carts) == 1 + assert carts[0]["id"] == cart2["id"] + assert carts[0]["price"] == 200.0 + + @pytest.mark.asyncio + async def test_list_carts_with_max_price(self, client: httpx.AsyncClient): + cheap = (await client.post("/items", json={"name": "Cheap", "price": 10.0})).json() + expensive = (await client.post("/items", json={"name": "Expensive", "price": 200.0})).json() + + cart1 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart1['id']}/add/{cheap['id']}") + + cart2 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart2['id']}/add/{expensive['id']}") + + response = await client.get("/carts", params={"max_price": 100.0}) + carts = response.json() + assert len(carts) == 1 + assert carts[0]["id"] == cart1["id"] + assert carts[0]["price"] == 10.0 + + response = await client.get("/carts", params={"max_price": 5.0}) + carts = response.json() + assert len(carts) == 0 + + @pytest.mark.asyncio + async def test_list_carts_with_max_quantity(self, client: httpx.AsyncClient): + item = (await client.post("/items", json={"name": "Test", "price": 10.0})).json() + + cart1 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart1['id']}/add/{item['id']}") + + cart2 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart2['id']}/add/{item['id']}") + await client.post(f"/carts/{cart2['id']}/add/{item['id']}") + + resp = await client.get("/carts", params={"max_quantity": 1}) + carts = resp.json() + assert len(carts) == 1 + assert carts[0]["id"] == cart1["id"] + + @pytest.mark.asyncio + async def test_list_carts_min_quantity_zero(self, client: httpx.AsyncClient): + empty_cart = (await client.post("/carts")).json() + + item = (await client.post("/items", json={"name": "Test", "price": 5.0})).json() + filled_cart = (await client.post("/carts")).json() + await client.post(f"/carts/{filled_cart['id']}/add/{item['id']}") + + resp = await client.get("/carts", params={"min_quantity": 0}) + carts = resp.json() + assert len(carts) == 2 + cart_ids = {c["id"] for c in carts} + assert empty_cart["id"] in cart_ids + assert filled_cart["id"] in cart_ids + + @pytest.mark.asyncio + async def test_add_item_to_cart_cart_not_found(self, client: httpx.AsyncClient): + fake_cart_id = "12345678-1234-5678-1234-567812345678" + item = (await client.post("/items", json={"name": "Test", "price": 1.0})).json() + response = await client.post(f"/carts/{fake_cart_id}/add/{item['id']}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["msg"] == "Ничего не найдено" + + @pytest.mark.asyncio + async def test_add_item_to_cart_item_not_found(self, client: httpx.AsyncClient): + cart = (await client.post("/carts")).json() + fake_item_id = "87654321-4321-8765-4321-876543210987" + response = await client.post(f"/carts/{cart['id']}/add/{fake_item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["msg"] == "Ничего не найдено" + + @pytest.mark.asyncio + async def test_add_deleted_item_to_cart(self, client: httpx.AsyncClient): + item = (await client.post("/items", json={"name": "DeletedItem", "price": 99.0})).json() + await client.delete(f"/items/{item['id']}") + + cart = (await client.post("/carts")).json() + response = await client.post(f"/carts/{cart['id']}/add/{item['id']}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["msg"] == "Ничего не найдено" + + @pytest.mark.asyncio + async def test_get_cart_with_deleted_item(self, client: httpx.AsyncClient): + item = (await client.post("/items", json={"name": "ToBeDeleted", "price": 100.0})).json() + item_id = item["id"] + + cart = (await client.post("/carts")).json() + cart_id = cart["id"] + await client.post(f"/carts/{cart_id}/add/{item_id}") + + await client.delete(f"/items/{item_id}") + + response = await client.get(f"/carts/{cart_id}") + assert response.status_code == HTTPStatus.OK + cart_data = response.json() + assert len(cart_data["items"]) == 0 + + @pytest.mark.asyncio + async def test_list_carts_max_quantity_zero(self, client: httpx.AsyncClient): + empty_cart = (await client.post("/carts")).json() + item = (await client.post("/items", json={"name": "Test", "price": 10.0})).json() + filled_cart = (await client.post("/carts")).json() + await client.post(f"/carts/{filled_cart['id']}/add/{item['id']}") + + resp = await client.get("/carts", params={"max_quantity": 0, "offset": 0, "limit": 10}) + carts = resp.json() + assert len(carts) == 1 + assert carts[0]["id"] == empty_cart["id"] \ No newline at end of file diff --git a/hw4_5/tests/test_db.py b/hw4_5/tests/test_db.py new file mode 100644 index 00000000..307e3ab6 --- /dev/null +++ b/hw4_5/tests/test_db.py @@ -0,0 +1,10 @@ +import pytest +from src.db.deps import get_db + + + +@pytest.mark.asyncio +async def test_get_db(): + async for session in get_db(): + assert session is not None + break \ No newline at end of file diff --git a/hw4_5/tests/test_item.py b/hw4_5/tests/test_item.py new file mode 100644 index 00000000..1fa996b5 --- /dev/null +++ b/hw4_5/tests/test_item.py @@ -0,0 +1,157 @@ +import httpx +from http import HTTPStatus +from uuid import UUID, uuid4 +import pytest + + +class TestItemAPI: + + @pytest.mark.asyncio + async def test_create_item(self, client: httpx.AsyncClient): + response = await client.post("/items", json={"name": "Laptop", "price": 999.99}) + assert response.status_code == HTTPStatus.CREATED + data = response.json() + UUID(data["id"]) + + response = await client.get(f"/items/{data["id"]}") + assert response.status_code == HTTPStatus.OK + response = response.json() + assert response["name"] == "Laptop" + assert response["price"] == 999.99 + assert response["deleted"] is False + + @pytest.mark.asyncio + async def test_get_item_not_found(self, client: httpx.AsyncClient): + fake_id = "12345678-1234-5678-1234-567812345678" + response = await client.get(f"/items/{fake_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["msg"] == "Ничего не найдено" + + + @pytest.mark.asyncio + async def test_list_items_with_min_price(self, client: httpx.AsyncClient): + await client.post("/items", json={"name": "Cheap", "price": 5.0}) + await client.post("/items", json={"name": "Expensive", "price": 150.0}) + + resp = await client.get("/items", params={"min_price": 100.0}) + items = resp.json() + assert len(items) == 1 + assert all(i["price"] >= 100.0 for i in items) + + @pytest.mark.asyncio + async def test_list_items_with_max_price(self, client: httpx.AsyncClient): + await client.post("/items", json={"name": "Cheap", "price": 10.0}) + await client.post("/items", json={"name": "Expensive", "price": 200.0}) + + resp = await client.get("/items", params={"max_price": 50.0}) + items = resp.json() + assert len(items) == 1 + assert items[0]["name"] == "Cheap" + + @pytest.mark.asyncio + async def test_update_item_full(self, client: httpx.AsyncClient): + id = uuid4() + resp = (await client.put(f"/items/{id}", json={"name": "New", "price": 200.0})) + assert resp.json()["msg"] == "Ничего не найдено" + + item = (await client.post("/items", json={"name": "Old", "price": 100.0})).json() + item_id = item["id"] + updated = (await client.put(f"/items/{item_id}", json={"name": "New", "price": 200.0})).json() + assert updated["name"] == "New" + assert updated["price"] == 200.0 + assert updated["deleted"] is False + + @pytest.mark.asyncio + async def test_update_item_partial(self, client: httpx.AsyncClient): + + id = uuid4() + resp = (await client.patch(f"/items/{id}", json={"price": 150.0})) + assert resp.json()["msg"] == "Ничего не найдено" + + item = (await client.post("/items", json={"name": "Original", "price": 100.0})).json() + item_id = item["id"] + patched = (await client.patch(f"/items/{item_id}", json={"price": 150.0})).json() + assert patched["name"] == "Original" + assert patched["price"] == 150.0 + assert patched["deleted"] is False + + @pytest.mark.asyncio + async def test_soft_delete_item(self, client: httpx.AsyncClient): + item = (await client.post("/items", json={"name": "ToBeDeleted", "price": 10.0})).json() + item_id = item["id"] + assert item["deleted"] is False + + del_resp = await client.delete(f"/items/{item_id}") + assert del_resp.status_code == HTTPStatus.OK + deleted_item = del_resp.json() + assert deleted_item["id"] == item_id + assert deleted_item["deleted"] is True + + list_resp = await client.get("/items") + items = list_resp.json() + assert all(i["id"] != item_id for i in items) + + list_with_deleted = await client.get("/items", params={"show_deleted": True}) + items = list_with_deleted.json() + deleted_item = next((i for i in items if i["id"] == item_id), None) + assert deleted_item is not None + assert deleted_item["deleted"] is True + + @pytest.mark.asyncio + async def test_get_items_pagination(self, client: httpx.AsyncClient): + for i in range(5): + await client.post("/items", json={"name": f"Item{i}", "price": float(i + 10)}) + + resp = await client.get("/items", params={"offset": 2, "limit": 2}) + items = resp.json() + assert len(items) == 2 + assert items[0]["name"] == "Item2" + assert items[1]["name"] == "Item3" + + @pytest.mark.asyncio + async def test_get_items_show_deleted_with_price_filter(self, client: httpx.AsyncClient): + item1 = (await client.post("/items", json={"name": "Active", "price": 50.0})).json() + item2 = (await client.post("/items", json={"name": "DeletedCheap", "price": 10.0})).json() + item3 = (await client.post("/items", json={"name": "DeletedExpensive", "price": 200.0})).json() + + await client.delete(f"/items/{item2['id']}") + await client.delete(f"/items/{item3['id']}") + + resp = await client.get("/items", params={"show_deleted": True, "min_price": 100.0}) + items = resp.json() + assert len(items) == 1 + assert items[0]["id"] == item3["id"] + assert items[0]["deleted"] is True + + @pytest.mark.asyncio + async def test_get_items_offset_without_limit(self, client: httpx.AsyncClient): + for i in range(3): + await client.post("/items", json={"name": f"Item{i}", "price": 10.0}) + + resp = await client.get("/items", params={"offset": 1}) + items = resp.json() + assert len(items) == 2 + assert items[0][("na" + "me")] == "Item1" + + @pytest.mark.asyncio + async def test_get_items_show_deleted_with_price_filter(self, client: httpx.AsyncClient): + item1 = (await client.post("/items", json={"name": "Active", "price": 50.0})).json() + item2 = (await client.post("/items", json={"name": "DeletedCheap", "price": 10.0})).json() + item3 = (await client.post("/items", json={"name": "DeletedExpensive", "price": 200.0})).json() + + await client.delete(f"/items/{item2['id']}") + await client.delete(f"/items/{item3['id']}") + + resp = await client.get("/items", params={"show_deleted": True, "min_price": 100.0}) + items = resp.json() + assert len(items) == 1 + assert items[0]["id"] == item3["id"] + assert items[0]["deleted"] is True + + @pytest.mark.asyncio + async def test_delete_unknown_item(self, client: httpx.AsyncClient): + + id = uuid4() + resp = await client.delete(f"/items/{id}") + assert resp.json()["msg"] == "Ничего не найдено" \ No newline at end of file diff --git a/hw4_5/transactions_problems_scripts/transactions_problems_scripts.py b/hw4_5/transactions_problems_scripts/transactions_problems_scripts.py new file mode 100644 index 00000000..a76f1d1f --- /dev/null +++ b/hw4_5/transactions_problems_scripts/transactions_problems_scripts.py @@ -0,0 +1,198 @@ +import asyncio +from uuid import UUID +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy import text + +from config import settings +from models import Base, ItemModel + + + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +AsyncSession = async_sessionmaker(engine, expire_on_commit=False) + +ITEM_ID = UUID("edf925f2-c112-423a-ac24-a70c6faebffc") +NEW_ITEM_ID_1 = UUID("81e5a9ac-6362-47be-bc33-0d740cac83ca") +NEW_ITEM_ID_2 = UUID("dc90ee64-6010-4d75-9062-7fa1fe387014") + + +async def setup_test_data(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + async with AsyncSession() as session: + session.add(ItemModel(id=ITEM_ID, name="Тестовый товар", price=100.0, deleted=False)) + await session.commit() + + +async def demo_1_dirty_read(): + print("\n=== 1. Dirty Read при READ UNCOMMITTED ===") + print("В PostgreSQL уровень READ UNCOMMITTED автоматически повышается до READ COMMITTED") + print("→ Грязное чтение НЕВОЗМОЖНО.") + + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + await s1.execute( + text("UPDATE items SET price = 50 WHERE id = :id"), + {"id": str(ITEM_ID)} + ) + print("T1: обновила цену на 50, но не коммитит") + + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + price = (await s2.execute( + text("SELECT price FROM items WHERE id = :id"), + {"id": str(ITEM_ID)} + )).scalar() + print(f"T2: прочитала цену = {price} (ожидаемо: 100.0)") + + await s1.rollback() + + +async def demo_2_no_dirty_read(): + print("\n=== 2. Отсутствие Dirty Read при READ COMMITTED ===") + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + await s1.execute( + text("UPDATE items SET price = 50 WHERE id = :id"), + {"id": str(ITEM_ID)} + ) + print("T1: обновила цену на 50, но не коммитит") + + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + price = (await s2.execute( + text("SELECT price FROM items WHERE id = :id"), + {"id": str(ITEM_ID)} + )).scalar() + print(f"T2: прочитала цену = {price}") # → 100.0 + + await s1.commit() + + +async def demo_3_non_repeatable_read(): + print("\n=== 3. Non-Repeatable Read при READ COMMITTED ===") + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + p1 = (await s2.execute(text("SELECT price FROM items WHERE id = :id"), {"id": str(ITEM_ID)})).scalar() + print(f"T2: первое чтение = {p1}") + + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + await s1.execute(text("UPDATE items SET price = 200 WHERE id = :id"), {"id": str(ITEM_ID)}) + await s1.commit() + print("T1: обновила и закоммитила") + + p2 = (await s2.execute(text("SELECT price FROM items WHERE id = :id"), {"id": str(ITEM_ID)})).scalar() + print(f"T2: второе чтение = {p2}") + + +async def demo_4_no_non_repeatable_read(): + print("\n=== 4. Отсутствие Non-Repeatable Read при REPEATABLE READ ===") + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + p1 = (await s2.execute(text("SELECT price FROM items WHERE id = :id"), {"id": str(ITEM_ID)})).scalar() + print(f"T2: первое чтение = {p1}") # → 100.0 + + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + await s1.execute(text("UPDATE items SET price = 200 WHERE id = :id"), {"id": str(ITEM_ID)}) + await s1.commit() + print("T1: обновила и закоммитила") + + p2 = (await s2.execute(text("SELECT price FROM items WHERE id = :id"), {"id": str(ITEM_ID)})).scalar() + print(f"T2: второе чтение = {p2}") # → 100.0 + + +async def demo_5_phantom_read(): + print("\n=== 5. Phantom Read при READ COMMITTED ===") + print("Условие: SELECT с WHERE price < 150") + + async with AsyncSession() as setup_sess: + async with setup_sess.begin(): + await setup_sess.execute(text("DELETE FROM items")) + await setup_sess.execute(text(""" + INSERT INTO items (id, name, price, deleted) + VALUES ('11111111-1111-1111-1111-111111111111', 'Товар A', 100.0, false) + """)) + + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + c1 = (await s2.execute( + text("SELECT COUNT(*) FROM items WHERE price < 150") + )).scalar() + print(f"T2: первое количество = {c1}") # → 1 + + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + await s1.execute(text(""" + INSERT INTO items (id, name, price, deleted) + VALUES (:id, 'Новый товар', 120.0, false) + """), {"id": str(UUID("33333333-3333-3333-3333-333333333333"))}) + await s1.commit() + print("T1: добавила товар с price=120 (в диапазоне)") + + c2 = (await s2.execute( + text("SELECT COUNT(*) FROM items WHERE price < 150") + )).scalar() + print(f"T2: второе количество = {c2}") # → 2 + + if c2 > c1: + print("Phantom Read обнаружен: появилась новая строка в диапазоне!") + + +async def demo_6_no_phantom_read(): + print("\n=== 6. Отсутствие Phantom Read при SERIALIZABLE ===") + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + c1 = (await s2.execute(text("SELECT COUNT(*) FROM items WHERE deleted = false"))).scalar() + print(f"T2: первое количество = {c1}") # → 2 + + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + await s1.execute(text(""" + INSERT INTO items (id, name, price, deleted) + VALUES (:id, 'Ещё товар', 88.88, false) + """), {"id": str(NEW_ITEM_ID_2)}) + await s1.commit() + print("T1: добавила товар") + + try: + c2 = (await s2.execute(text("SELECT COUNT(*) FROM items WHERE deleted = false"))).scalar() + print(f"T2: второе количество = {c2}") # → 2 или ошибка + except Exception as e: + print(f"T2: ошибка сериализации (ожидаемо): {type(e).__name__}") + + +async def main(): + print("Демонстрация уровней изоляции транзакций в PostgreSQL") + print("Таблицы: items, carts, cart_items") + + await setup_test_data() + + await demo_1_dirty_read() + await demo_2_no_dirty_read() + await demo_3_non_repeatable_read() + await demo_4_no_non_repeatable_read() + await demo_5_phantom_read() + await demo_6_no_phantom_read() + + print("\nВсе демонстрации завершены!") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file