From 7d462abdf469c069060138a31d41aa882b2412d9 Mon Sep 17 00:00:00 2001 From: Teplyakov Nikolay Date: Tue, 23 Sep 2025 00:30:54 +0300 Subject: [PATCH 1/4] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 100 +++++++++++++++++++++++++++++++++++++++++++++- hw1/handlers.py | 78 ++++++++++++++++++++++++++++++++++++ hw1/router.py | 77 +++++++++++++++++++++++++++++++++++ hw1/structures.py | 15 +++++++ 4 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 hw1/handlers.py create mode 100644 hw1/router.py create mode 100644 hw1/structures.py diff --git a/hw1/app.py b/hw1/app.py index 6107b870..94d9e8ee 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,14 @@ from typing import Any, Awaitable, Callable +import json +from http import HTTPStatus +from router import Router, HTTPError, HTTPResponse, build_error_response +from handlers import handle_factorial, handle_fibonacci, handle_mean +router = Router() + +router.add_route('GET', '/factorial', handle_factorial) +router.add_route('GET', '/fibonacci/{n}', handle_fibonacci) +router.add_route('GET', '/mean', handle_mean) async def application( scope: dict[str, Any], @@ -7,13 +16,100 @@ async def application( send: Callable[[dict[str, Any]], Awaitable[None]], ): """ + ASGI приложение с использованием шаблонного метода для роутинга Args: scope: Словарь с информацией о запросе receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + 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 + + elif scope["type"] == "http": + await handle_http_request(scope, receive, send) + + +async def handle_http_request( + scope: dict[str, Any], + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + """Обработка HTTP запросов""" + path = scope["path"] + method = scope["method"] + query_string = scope.get("query_string", b"").decode() + + response = HTTPResponse(status=HTTPStatus.OK, body={ 'result': 'None'}) + try: + body = await parse_request_body(scope, receive) + + handler, context = await router.route(method, path, query_string, body) + + response: HTTPResponse = await handler(context) + + except HTTPError as e: + response = build_error_response(e.status, e.error, e.message) + except Exception as e: + response = build_error_response(HTTPStatus.INTERNAL_SERVER_ERROR, 'Internal Server Error', str(e)) + finally: + await send_response(send, response) + + +async def send_response(send: Callable, response: HTTPResponse): + """Отправка ответов""" + response_body = json.dumps(response['body']).encode("utf-8") + + await send({ + 'type': 'http.response.start', + 'status': response['status'], + 'headers': [ + (b'content-type', b'application/json'), + (b'content-length', str(len(response_body)).encode()) + ], + }) + + await send({ + 'type': 'http.response.body', + 'body': response_body, + }) + +async def parse_request_body(scope: dict[str, Any], receive: Callable) -> Any: + """Загрузка тела запроса""" + headers = dict(scope.get("headers", [])) + content_length = headers.get(b"content-length") + + if content_length: + try: + content_length = int(content_length.decode()) + except (ValueError, AttributeError): + content_length = 0 + else: + content_length = 0 + + if content_length <= 0: + return None + + body = b"" + more_body = True + while more_body: + message = await receive() + body += message.get("body", b"") + more_body = message.get("more_body", False) + + if not body: + return None + + try: + return json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "Invalid JSON format") 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/hw1/handlers.py b/hw1/handlers.py new file mode 100644 index 00000000..cb0c37ae --- /dev/null +++ b/hw1/handlers.py @@ -0,0 +1,78 @@ +from structures import HTTPError, HTTPResponse +from http import HTTPStatus +import math + +async def handle_factorial(context: dict) -> HTTPResponse: + """Обработка факториала""" + query_params = context['query_params'] + + if 'n' not in query_params: + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "Parameter 'n' is required") + + try: + n = int(query_params['n']) + except ValueError: + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "Parameter 'n' must be an integer") + + if n < 0: + raise HTTPError(HTTPStatus.BAD_REQUEST, "Bad Request", "Parameter 'n' must be non-negative") + + try: + result = math.factorial(n) + return { + 'status': HTTPStatus.OK, + 'body': {'result': result} + } + except OverflowError: + raise HTTPError(HTTPStatus.BAD_REQUEST, "Bad Request", "Parameter 'n' is too large") + + +async def handle_fibonacci(context: dict) -> HTTPResponse: + """Обработка чисел Фибоначчи""" + path_params = context['path_params'] + + try: + n = int(path_params['n']) + except (KeyError, ValueError): + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "Invalid number parameter") + + if n < 0: + raise HTTPError(HTTPStatus.BAD_REQUEST, "Bad Request", "Number must be non-negative") + + if n in [0, 1]: + result = n + else: + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + result = b + + return { + 'status': HTTPStatus.OK, + 'body': {'result': result} + } + + +async def handle_mean(context: dict) -> HTTPResponse: + """Обработка среднего значения""" + body_data = context['body'] + + if body_data is None: + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "JSON body is required") + + if not isinstance(body_data, list): + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "Expected a list of numbers") + + if not body_data: + raise HTTPError(HTTPStatus.BAD_REQUEST, "Bad Request", "At least one number is required") + + try: + numbers = [float(num) for num in body_data] + except (TypeError, ValueError): + raise HTTPError(HTTPStatus.UNPROCESSABLE_ENTITY, "Unprocessable Entity", "All values must be valid numbers") + + mean = sum(numbers) / len(numbers) + return { + 'status': HTTPStatus.OK, + 'body': {'result': mean} + } diff --git a/hw1/router.py b/hw1/router.py new file mode 100644 index 00000000..3befb485 --- /dev/null +++ b/hw1/router.py @@ -0,0 +1,77 @@ +from typing import Any, Callable, Optional, Dict, TypedDict +import re +from http import HTTPStatus +from structures import HTTPResponse, HTTPError + +def build_error_response(status: int, error: str, message: str) -> HTTPResponse: + response: HTTPResponse = { + 'status': status, + 'body': { + 'error': error, + 'message': message + } + } + return response + +class Router: + + def __init__(self): + self.routes = [] + self.handlers: Dict[str, Callable] = {} + + def add_route(self, method: str, path: str, handler: Callable): + """Добавляет маршрут""" + pattern = self._path_to_pattern(path) + self.routes.append({ + 'method': method.upper(), + 'pattern': pattern, + 'handler': handler + }) + + @staticmethod + def _path_to_pattern(path: str) -> re.Pattern: + """Конвертирует путь с параметрами в regex паттерн""" + pattern = re.sub(r'\{(\w+)}', r'(?P<\1>[^/]+)', path) + return re.compile(f'^{pattern}$') + + async def route(self, method: str, path: str, query_string: str, body: Optional[Any]) -> tuple[Callable, dict]: + method = method.upper() + + route_match = self._match_route(method, path) + if not route_match: + raise HTTPError(HTTPStatus.NOT_FOUND, "Not Found", "Endpoint not found") + + handler, path_params = route_match + + query_params = self._parse_query_string(query_string) + + request_context = { + 'path_params': path_params, + 'query_params': query_params, + 'body': body + } + + return handler, request_context + + def _match_route(self, method: str, path: str) -> Optional[tuple[Callable, dict]]: + """Ищет подходящий маршрут и возвращает handler""" + for route in self.routes: + if route['method'] != method: + continue + + match = route['pattern'].match(path) + if match: + return route['handler'], match.groupdict() + + return None + + @staticmethod + def _parse_query_string(query_string: str) -> dict: + """Парсит query string в словарь""" + params = {} + if query_string: + for pair in query_string.split('&'): + if '=' in pair: + key, value = pair.split('=', 1) + params[key] = value + return params \ No newline at end of file diff --git a/hw1/structures.py b/hw1/structures.py new file mode 100644 index 00000000..1db30d42 --- /dev/null +++ b/hw1/structures.py @@ -0,0 +1,15 @@ +from typing import TypedDict + +class HTTPResponse(TypedDict): + """Типизированный ответ обработчика""" + status: int + body: dict + +class HTTPError(Exception): + """Кастомное исключение для HTTP ошибок""" + + def __init__(self, status: int, error: str, message: str): + self.status = status + self.error = error + self.message = message + super().__init__(f"{error}: {message}") \ No newline at end of file From 6ff6ffb3b5cc8b27c80cd6649ef593ce2693575d Mon Sep 17 00:00:00 2001 From: Teplyakov Nikolay Date: Sat, 4 Oct 2025 01:32:45 +0300 Subject: [PATCH 2/4] add: Completed hw2 and the additional task Made an API for the store, made models for `redoc`. Made a chat on websockets --- hw2/__init__.py | 0 hw2/hw/shop_api/chat/__init__.py | 0 hw2/hw/shop_api/chat/websocket.py | 93 +++++++++++++++++++++++++++++ hw2/hw/shop_api/main.py | 10 ++++ hw2/hw/shop_api/models.py | 32 ++++++++++ hw2/hw/shop_api/routes/carts.py | 51 ++++++++++++++++ hw2/hw/shop_api/routes/items.py | 67 +++++++++++++++++++++ hw2/hw/shop_api/storage.py | 98 +++++++++++++++++++++++++++++++ 8 files changed, 351 insertions(+) create mode 100644 hw2/__init__.py create mode 100644 hw2/hw/shop_api/chat/__init__.py create mode 100644 hw2/hw/shop_api/chat/websocket.py create mode 100644 hw2/hw/shop_api/models.py create mode 100644 hw2/hw/shop_api/routes/carts.py create mode 100644 hw2/hw/shop_api/routes/items.py create mode 100644 hw2/hw/shop_api/storage.py diff --git a/hw2/__init__.py b/hw2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/chat/__init__.py b/hw2/hw/shop_api/chat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/chat/websocket.py b/hw2/hw/shop_api/chat/websocket.py new file mode 100644 index 00000000..5d68e9ed --- /dev/null +++ b/hw2/hw/shop_api/chat/websocket.py @@ -0,0 +1,93 @@ +import json +import uuid +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import Dict, List +import asyncio + + +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[str, List[WebSocket]] = {} + self.usernames: Dict[WebSocket, str] = {} + + async def connect(self, websocket: WebSocket, chat_name: str): + await websocket.accept() + + if chat_name not in self.active_connections: + self.active_connections[chat_name] = [] + + self.active_connections[chat_name].append(websocket) + + username = f"User_{uuid.uuid4().hex[:8]}" + self.usernames[websocket] = username + + await self.broadcast_message( + chat_name, + f"🟢 {username} :: присоединился к чату", + exclude_websocket=websocket + ) + + await websocket.send_text(f"🤖 Добро пожаловать в чат '{chat_name}'! Ваше имя: {username}") + + def disconnect(self, websocket: WebSocket, chat_name: str): + if chat_name in self.active_connections: + if websocket in self.active_connections[chat_name]: + self.active_connections[chat_name].remove(websocket) + + if not self.active_connections[chat_name]: + del self.active_connections[chat_name] + + if websocket in self.usernames: + username = self.usernames[websocket] + del self.usernames[websocket] + + if chat_name in self.active_connections: + self.broadcast_message_sync( + chat_name, + f"🔴 {username} :: покинул чат", + exclude_websocket=websocket + ) + + async def broadcast_message(self, chat_name: str, message: str, exclude_websocket: WebSocket = None): + if chat_name in self.active_connections: + for connection in self.active_connections[chat_name]: + if connection != exclude_websocket: + try: + await connection.send_text(message) + except: + self.disconnect(connection, chat_name) + + def broadcast_message_sync(self, chat_name: str, message: str, exclude_websocket: WebSocket = None): + """Синхронная версия для использования в disconnect""" + if chat_name in self.active_connections: + disconnected = [] + for connection in self.active_connections[chat_name]: + if connection != exclude_websocket: + try: + asyncio.create_task(connection.send_text(message)) + except: + disconnected.append(connection) + + for connection in disconnected: + self.disconnect(connection, chat_name) + + async def handle_message(self, websocket: WebSocket, chat_name: str, message: str): + username = self.usernames.get(websocket, "Unknown") + formatted_message = f"{username} :: {message}" + await self.broadcast_message(chat_name, formatted_message) + + +manager = ConnectionManager() +chat_router = APIRouter() + + +@chat_router.websocket("/chat/{chat_name}") +async def websocket_endpoint(websocket: WebSocket, chat_name: str): + await manager.connect(websocket, chat_name) + + try: + while True: + data = await websocket.receive_text() + await manager.handle_message(websocket, chat_name, data) + except WebSocketDisconnect: + manager.disconnect(websocket, chat_name) \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..a96d51a9 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,13 @@ from fastapi import FastAPI +from .routes import items, carts +from .chat.websocket import chat_router app = FastAPI(title="Shop API") + +app.include_router(items.router, tags=["items"]) +app.include_router(carts.router, tags=["carts"]) +app.include_router(chat_router, tags=["chat"]) + +@app.get("/") +async def root(): + return {"message": "Shop API is running"} \ No newline at end of file diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..263c94e1 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional + +class ItemBase(BaseModel): + name: str = Field(..., min_length=1, description="Наименование товара", examples=["Молоко \"Бурёнка\" 1л"]) + price: float = Field(..., gt=0, description="Цена товара", examples=[159.99]) + +class ItemCreate(ItemBase): + pass + +class ItemUpdate(BaseModel): + model_config = ConfigDict(extra='forbid') + name: Optional[str] = Field(None, min_length=1, description="Новое наименование товара", examples=["Молоко \"Новое\" 1л"]) + price: Optional[float] = Field(None, gt=0, description="Новая цена товара", examples=[169.99]) + +class Item(ItemBase): + id: int = Field(..., description="Уникальный идентификатор товара", examples=[321]) + deleted: bool = Field(False, description="Товар удален (soft delete)") + +class CartItem(BaseModel): + id: int = Field(..., gt=0, description="ID товара", examples=[1]) + name: str = Field(..., min_length=1, description="Название товара", examples=["Туалетная бумага \"Поцелуй\", рулон"]) + quantity: int = Field(..., ge=1, description="Количество товара в корзине", examples=[3]) + available: bool = Field(..., description="Товар доступен для заказа") + +class Cart(BaseModel): + id: int = Field(..., description="Уникальный идентификатор корзины", examples=[123]) + items: List[CartItem] = Field(..., description="Список товаров в корзине") + price: float = Field(..., description="Общая сумма заказа", examples=[234.4]) + +class CartCreateResponse(BaseModel): + id: int = Field(..., description="Идентификатор созданной корзины", examples=[123]) \ No newline at end of file diff --git a/hw2/hw/shop_api/routes/carts.py b/hw2/hw/shop_api/routes/carts.py new file mode 100644 index 00000000..6ad2a0ad --- /dev/null +++ b/hw2/hw/shop_api/routes/carts.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, HTTPException, Query, status, Response +from typing import List, Optional +from ..models import Cart, CartCreateResponse +from ..storage import storage + +router = APIRouter(prefix="/cart", tags=["carts"]) + + +@router.post("/", response_model=CartCreateResponse, status_code=status.HTTP_201_CREATED) +def create_cart(response: Response): + cart_id = storage.create_cart() + response.headers["Location"] = f"/cart/{cart_id}" + return CartCreateResponse(id=cart_id) + + +@router.get("/{cart_id}", response_model=Cart) +def get_cart(cart_id: int): + cart = storage.get_cart(cart_id) + if not cart: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found") + return cart + + +@router.get("/", response_model=List[Cart]) +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, ge=1), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0) +): + def cart_filter(cart: Cart) -> bool: + total_quantity = sum(item.quantity for item in cart.items) + return all([ + min_price is None or cart.price >= min_price, + max_price is None or cart.price <= max_price, + min_quantity is None or total_quantity >= min_quantity, + max_quantity is None or total_quantity <= max_quantity + ]) + + filtered_carts = list(filter(cart_filter, storage.carts.values())) + return filtered_carts[offset:offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int): + success = storage.add_item_to_cart(cart_id, item_id) + if not success: + raise HTTPException(status_code=404, detail="Cart or item not found") + return {"message": "Item added to cart"} \ No newline at end of file diff --git a/hw2/hw/shop_api/routes/items.py b/hw2/hw/shop_api/routes/items.py new file mode 100644 index 00000000..fdd5fa0f --- /dev/null +++ b/hw2/hw/shop_api/routes/items.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, HTTPException, Query, status +from typing import List, Optional +from ..models import Item, ItemCreate, ItemUpdate +from ..storage import storage + +router = APIRouter(prefix="/item", tags=["items"]) + +@router.post("/", response_model=Item, status_code=status.HTTP_201_CREATED) +def create_item(item: ItemCreate): + return storage.create_item(item) + + +@router.get("/{item_id}", response_model=Item) +def get_item(item_id: int): + item = storage.get_available_item(item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + return item + + +@router.get("/", response_model=List[Item]) +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, ge=1), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False +): + items = storage.list_items(show_deleted=show_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] + + return items[offset:offset + limit] + + +@router.put("/{item_id}", response_model=Item) +def replace_item(item_id: int, item: ItemCreate): + existing = storage.get_item(item_id) + if not existing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + + updated_item = storage.replace_item(item_id, item) + return updated_item + + +@router.patch("/{item_id}", response_model=Item) +def update_item(item_id: int, item_update: ItemUpdate): + existing_item = storage.get_available_item(item_id) + if not existing_item: + raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED, detail="Item not found or deleted") + + try: + item = storage.update_item(item_id, item_update) + return item + except ValueError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + + +@router.delete("/{item_id}", response_model=Item) +def delete_item(item_id: int): + item = storage.delete_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + return item \ No newline at end of file diff --git a/hw2/hw/shop_api/storage.py b/hw2/hw/shop_api/storage.py new file mode 100644 index 00000000..062cfcda --- /dev/null +++ b/hw2/hw/shop_api/storage.py @@ -0,0 +1,98 @@ +from typing import Dict, List, Optional +from .models import Item, Cart, CartItem, ItemCreate, ItemUpdate + + +class Storage: + def __init__(self): + self.items: Dict[int, Item] = {} + self.carts: Dict[int, Cart] = {} + self._next_item_id = 1 + self._next_cart_id = 1 + + def create_item(self, item_data: ItemCreate) -> Item: + item_id = self._next_item_id + self._next_item_id += 1 + item = Item(id=item_id, **item_data.model_dump()) + self.items[item_id] = item + return item + + def get_item(self, item_id: int) -> Optional[Item]: + return self.items.get(item_id) + + def get_available_item(self, item_id: int) -> Optional[Item]: + item = self.items.get(item_id) + return item if item and not item.deleted else None + + def list_items(self, show_deleted: bool = False) -> List[Item]: + items = list(self.items.values()) + if not show_deleted: + items = [item for item in items if not item.deleted] + return items + + def replace_item(self, item_id: int, item_data: ItemCreate) -> Optional[Item]: + if item_id not in self.items: + return None + + current_deleted = self.items[item_id].deleted + self.items[item_id] = Item( + id=item_id, + **item_data.model_dump(), + deleted=current_deleted + ) + return self.items[item_id] + + def update_item(self, item_id: int, update_data: ItemUpdate) -> Optional[Item]: + if item_id not in self.items: + return None + + item = self.items[item_id] + update_dict = update_data.model_dump(exclude_unset=True) + + for field, value in update_dict.items(): + setattr(item, field, value) + + return item + + def delete_item(self, item_id: int) -> Optional[Item]: + if item_id not in self.items: + return None + self.items[item_id].deleted = True + return self.items[item_id] + + def create_cart(self) -> int: + cart_id = self._next_cart_id + self._next_cart_id += 1 + cart = Cart(id=cart_id, items=[], price=0.0) + self.carts[cart_id] = cart + return cart_id + + def get_cart(self, cart_id: int) -> Optional[Cart]: + return self.carts.get(cart_id) + + def add_item_to_cart(self, cart_id: int, item_id: int) -> bool: + if cart_id not in self.carts: + return False + + item = self.get_available_item(item_id) + if not item: + return False + + cart = self.carts[cart_id] + + cart_item = CartItem( + id=item_id, + name=item.name, + quantity=1, + available=True + ) + cart.items.append(cart_item) + + cart.price = sum( + cart_item.quantity * self.items[cart_item.id].price + for cart_item in cart.items + if cart_item.available + ) + + return True + +storage = Storage() \ No newline at end of file From 79cf1101bb9a89329ce1f85183589951e9155e9a Mon Sep 17 00:00:00 2001 From: Ilya Lure Date: Fri, 3 Oct 2025 18:10:50 +0300 Subject: [PATCH 3/4] Add lecture3 code and HW3 --- lecture3/Dockerfile | 23 +++++++++++ lecture3/README.md | 13 ++++++ lecture3/ddoser.py | 44 +++++++++++++++++++++ lecture3/demo_service/__init__.py | 0 lecture3/demo_service/api.py | 42 ++++++++++++++++++++ lecture3/demo_service/contracts.py | 18 +++++++++ lecture3/demo_service/store.py | 27 +++++++++++++ lecture3/docker-compose.yml | 31 +++++++++++++++ lecture3/requirements.txt | 3 ++ lecture3/settings/prometheus/prometheus.yml | 10 +++++ 10 files changed, 211 insertions(+) create mode 100644 lecture3/Dockerfile create mode 100644 lecture3/README.md create mode 100644 lecture3/ddoser.py create mode 100644 lecture3/demo_service/__init__.py create mode 100644 lecture3/demo_service/api.py create mode 100644 lecture3/demo_service/contracts.py create mode 100644 lecture3/demo_service/store.py create mode 100644 lecture3/docker-compose.yml create mode 100644 lecture3/requirements.txt create mode 100644 lecture3/settings/prometheus/prometheus.yml diff --git a/lecture3/Dockerfile b/lecture3/Dockerfile new file mode 100644 index 00000000..1eaf1db1 --- /dev/null +++ b/lecture3/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", "demo_service.api:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/lecture3/README.md b/lecture3/README.md new file mode 100644 index 00000000..aad28c54 --- /dev/null +++ b/lecture3/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/lecture3/ddoser.py b/lecture3/ddoser.py new file mode 100644 index 00000000..fdc10f76 --- /dev/null +++ b/lecture3/ddoser.py @@ -0,0 +1,44 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests +from faker import Faker + +faker = Faker() + + +def create_users(): + for _ in range(500): + user = faker.profile() + response = requests.post( + "http://localhost:8080/create-user", + json={ + "username": user["username"], + "first_name": user["name"], + "last_name": "", + }, + ) + + print(response) + + +def get_users(): + for _ in range(500): + + response = requests.post( + "http://localhost:8080/get-user", + params={"id": faker.random_number(digits=2)}, + ) + print(response) + + +with ThreadPoolExecutor() as executor: + futures = {} + + for i in range(15): + futures[executor.submit(create_users)] = f"create-user-{i}" + + for _ in range(15): + futures[executor.submit(get_users)] = f"get-users-{i}" + + for future in as_completed(futures): + print(f"completed {futures[future]}") diff --git a/lecture3/demo_service/__init__.py b/lecture3/demo_service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lecture3/demo_service/api.py b/lecture3/demo_service/api.py new file mode 100644 index 00000000..7c6bce40 --- /dev/null +++ b/lecture3/demo_service/api.py @@ -0,0 +1,42 @@ +from http import HTTPStatus +from typing import Annotated +import random + +from fastapi import FastAPI, HTTPException, Query +from prometheus_fastapi_instrumentator import Instrumentator + +from demo_service import store +from demo_service.contracts import UserRequest, UserResource + +app = FastAPI(title="Demo User API") +Instrumentator().instrument(app).expose(app) + + +def maybe_raise_random_error(): + if random.random() < 0.1: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Random error occurred" + ) + + +@app.post( + "/create-user", + response_model=UserResource, + status_code=HTTPStatus.CREATED, +) +async def create_user(body: UserRequest) -> UserResource: + maybe_raise_random_error() + return store.insert(body) + + +@app.post("/get-user") +async def get_user(id: Annotated[int, Query()]) -> UserResource: + maybe_raise_random_error() + + resource = store.select(id) + + if not resource: + raise HTTPException(HTTPStatus.NOT_FOUND) + + return resource diff --git a/lecture3/demo_service/contracts.py b/lecture3/demo_service/contracts.py new file mode 100644 index 00000000..72b2ce89 --- /dev/null +++ b/lecture3/demo_service/contracts.py @@ -0,0 +1,18 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class UserResource(BaseModel): + uid: int + username: str + first_name: str + last_name: str + birthdate: datetime | None = None + + +class UserRequest(BaseModel): + username: str + first_name: str + last_name: str + birthdate: datetime | None = None diff --git a/lecture3/demo_service/store.py b/lecture3/demo_service/store.py new file mode 100644 index 00000000..a88a7cfb --- /dev/null +++ b/lecture3/demo_service/store.py @@ -0,0 +1,27 @@ +from typing import Iterable + +from demo_service.contracts import UserRequest, UserResource + + +def _generate_int_id() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_users = dict[int, UserResource]() +_id_generator = _generate_int_id() + + +def insert(user: UserRequest) -> UserResource: + id = next(_id_generator) + resource = UserResource(uid=id, **user.model_dump()) + + _users[id] = resource + + return resource + + +def select(id: int) -> UserResource | None: + return _users.get(id, None) diff --git a/lecture3/docker-compose.yml b/lecture3/docker-compose.yml new file mode 100644 index 00000000..91b5555c --- /dev/null +++ b/lecture3/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/lecture3/requirements.txt b/lecture3/requirements.txt new file mode 100644 index 00000000..bb6b4134 --- /dev/null +++ b/lecture3/requirements.txt @@ -0,0 +1,3 @@ +fastapi +uvicorn +prometheus-fastapi-instrumentator \ No newline at end of file diff --git a/lecture3/settings/prometheus/prometheus.yml b/lecture3/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..6bdf88e7 --- /dev/null +++ b/lecture3/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 From 2c3c0b28f42a7af84579eb1ecce3bedb034cb692 Mon Sep 17 00:00:00 2001 From: Teplyakov Nikolay Date: Sat, 11 Oct 2025 15:21:43 +0300 Subject: [PATCH 4/4] Did hw3 --- hw3/Dockerfile | 12 ++ hw3/README.md | 15 ++ hw3/__init__.py | 0 hw3/docker-compose.yml | 42 +++++ hw3/images/grafana.png | Bin 0 -> 85600 bytes hw3/prometheus.yml | 7 + hw3/requirements.txt | 13 ++ hw3/shop_api/__init__.py | 0 hw3/shop_api/chat/__init__.py | 0 hw3/shop_api/chat/websocket.py | 93 +++++++++++ hw3/shop_api/main.py | 16 ++ hw3/shop_api/models.py | 32 ++++ hw3/shop_api/routes/carts.py | 51 ++++++ hw3/shop_api/routes/items.py | 67 ++++++++ hw3/shop_api/storage.py | 98 ++++++++++++ hw3/test_homework2.py | 284 +++++++++++++++++++++++++++++++++ 16 files changed, 730 insertions(+) create mode 100644 hw3/Dockerfile create mode 100644 hw3/README.md create mode 100644 hw3/__init__.py create mode 100644 hw3/docker-compose.yml create mode 100644 hw3/images/grafana.png create mode 100644 hw3/prometheus.yml create mode 100644 hw3/requirements.txt create mode 100644 hw3/shop_api/__init__.py create mode 100644 hw3/shop_api/chat/__init__.py create mode 100644 hw3/shop_api/chat/websocket.py create mode 100644 hw3/shop_api/main.py create mode 100644 hw3/shop_api/models.py create mode 100644 hw3/shop_api/routes/carts.py create mode 100644 hw3/shop_api/routes/items.py create mode 100644 hw3/shop_api/storage.py create mode 100644 hw3/test_homework2.py diff --git a/hw3/Dockerfile b/hw3/Dockerfile new file mode 100644 index 00000000..d965c728 --- /dev/null +++ b/hw3/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/hw3/README.md b/hw3/README.md new file mode 100644 index 00000000..7d889c7a --- /dev/null +++ b/hw3/README.md @@ -0,0 +1,15 @@ +# ДЗ + +## Настроить сборку образов Docker и мониторинг с помощью Prometheus и Grafana + +Интегрировать Docker с Prometheus и Grafana в любой уже написанный в ДЗ сервис (по аналогии с тем, как в репе) + +По сути, если вы выполнили вторую домашку, то теперь для неё надо написать Dockerfile и настроить мониторинг. Если вторую домашку вы не делали, то можно взять сервис из [rest_example](../hw2/rest_example/main.py) + +Сдача через PR, так же нужно: + +1) Dockerfile для сборки сервиса +2) docker-compose.yml для локального разворачивания в Docker +3) Приложить скрин с парой Дашбордов в Grafana + +![grafana.png](images/grafana.png) \ No newline at end of file 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..ae64cbb5 --- /dev/null +++ b/hw3/docker-compose.yml @@ -0,0 +1,42 @@ +version: '3.8' + +services: + shop-api: + build: . + ports: + - "8000:8000" + volumes: + - .:/app + depends_on: + - prometheus + networks: + - monitoring + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + networks: + - monitoring + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-storage:/var/lib/grafana + depends_on: + - prometheus + networks: + - monitoring + +volumes: + grafana-storage: + +networks: + monitoring: + driver: bridge \ No newline at end of file diff --git a/hw3/images/grafana.png b/hw3/images/grafana.png new file mode 100644 index 0000000000000000000000000000000000000000..7d141930ec6b99167f71cbd2707879b08bfc75d7 GIT binary patch literal 85600 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

hc~8RAL#a&5&mu=l6I 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

huKmTWPHzcJrww2#v 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 Xkf){F{TT`Dj)Pn_xKwn(_RfC*{C)$d literal 0 HcmV?d00001 diff --git a/hw3/prometheus.yml b/hw3/prometheus.yml new file mode 100644 index 00000000..e0c857fb --- /dev/null +++ b/hw3/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'shop-api' + static_configs: + - targets: ['shop-api:8000'] \ No newline at end of file diff --git a/hw3/requirements.txt b/hw3/requirements.txt new file mode 100644 index 00000000..7c42d154 --- /dev/null +++ b/hw3/requirements.txt @@ -0,0 +1,13 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + +# Мониторинг +prometheus-fastapi-instrumentator>=7.0.0 + 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/chat/__init__.py b/hw3/shop_api/chat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/shop_api/chat/websocket.py b/hw3/shop_api/chat/websocket.py new file mode 100644 index 00000000..5d68e9ed --- /dev/null +++ b/hw3/shop_api/chat/websocket.py @@ -0,0 +1,93 @@ +import json +import uuid +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import Dict, List +import asyncio + + +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[str, List[WebSocket]] = {} + self.usernames: Dict[WebSocket, str] = {} + + async def connect(self, websocket: WebSocket, chat_name: str): + await websocket.accept() + + if chat_name not in self.active_connections: + self.active_connections[chat_name] = [] + + self.active_connections[chat_name].append(websocket) + + username = f"User_{uuid.uuid4().hex[:8]}" + self.usernames[websocket] = username + + await self.broadcast_message( + chat_name, + f"🟢 {username} :: присоединился к чату", + exclude_websocket=websocket + ) + + await websocket.send_text(f"🤖 Добро пожаловать в чат '{chat_name}'! Ваше имя: {username}") + + def disconnect(self, websocket: WebSocket, chat_name: str): + if chat_name in self.active_connections: + if websocket in self.active_connections[chat_name]: + self.active_connections[chat_name].remove(websocket) + + if not self.active_connections[chat_name]: + del self.active_connections[chat_name] + + if websocket in self.usernames: + username = self.usernames[websocket] + del self.usernames[websocket] + + if chat_name in self.active_connections: + self.broadcast_message_sync( + chat_name, + f"🔴 {username} :: покинул чат", + exclude_websocket=websocket + ) + + async def broadcast_message(self, chat_name: str, message: str, exclude_websocket: WebSocket = None): + if chat_name in self.active_connections: + for connection in self.active_connections[chat_name]: + if connection != exclude_websocket: + try: + await connection.send_text(message) + except: + self.disconnect(connection, chat_name) + + def broadcast_message_sync(self, chat_name: str, message: str, exclude_websocket: WebSocket = None): + """Синхронная версия для использования в disconnect""" + if chat_name in self.active_connections: + disconnected = [] + for connection in self.active_connections[chat_name]: + if connection != exclude_websocket: + try: + asyncio.create_task(connection.send_text(message)) + except: + disconnected.append(connection) + + for connection in disconnected: + self.disconnect(connection, chat_name) + + async def handle_message(self, websocket: WebSocket, chat_name: str, message: str): + username = self.usernames.get(websocket, "Unknown") + formatted_message = f"{username} :: {message}" + await self.broadcast_message(chat_name, formatted_message) + + +manager = ConnectionManager() +chat_router = APIRouter() + + +@chat_router.websocket("/chat/{chat_name}") +async def websocket_endpoint(websocket: WebSocket, chat_name: str): + await manager.connect(websocket, chat_name) + + try: + while True: + data = await websocket.receive_text() + await manager.handle_message(websocket, chat_name, data) + except WebSocketDisconnect: + manager.disconnect(websocket, chat_name) \ No newline at end of file diff --git a/hw3/shop_api/main.py b/hw3/shop_api/main.py new file mode 100644 index 00000000..3aa49093 --- /dev/null +++ b/hw3/shop_api/main.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator +from .routes import items, carts +from .chat.websocket import chat_router + +app = FastAPI(title="Shop API") + +app.include_router(items.router, tags=["items"]) +app.include_router(carts.router, tags=["carts"]) +app.include_router(chat_router, tags=["chat"]) + +Instrumentator().instrument(app).expose(app) + +@app.get("/") +async def root(): + return {"message": "Shop API is running"} \ No newline at end of file diff --git a/hw3/shop_api/models.py b/hw3/shop_api/models.py new file mode 100644 index 00000000..263c94e1 --- /dev/null +++ b/hw3/shop_api/models.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional + +class ItemBase(BaseModel): + name: str = Field(..., min_length=1, description="Наименование товара", examples=["Молоко \"Бурёнка\" 1л"]) + price: float = Field(..., gt=0, description="Цена товара", examples=[159.99]) + +class ItemCreate(ItemBase): + pass + +class ItemUpdate(BaseModel): + model_config = ConfigDict(extra='forbid') + name: Optional[str] = Field(None, min_length=1, description="Новое наименование товара", examples=["Молоко \"Новое\" 1л"]) + price: Optional[float] = Field(None, gt=0, description="Новая цена товара", examples=[169.99]) + +class Item(ItemBase): + id: int = Field(..., description="Уникальный идентификатор товара", examples=[321]) + deleted: bool = Field(False, description="Товар удален (soft delete)") + +class CartItem(BaseModel): + id: int = Field(..., gt=0, description="ID товара", examples=[1]) + name: str = Field(..., min_length=1, description="Название товара", examples=["Туалетная бумага \"Поцелуй\", рулон"]) + quantity: int = Field(..., ge=1, description="Количество товара в корзине", examples=[3]) + available: bool = Field(..., description="Товар доступен для заказа") + +class Cart(BaseModel): + id: int = Field(..., description="Уникальный идентификатор корзины", examples=[123]) + items: List[CartItem] = Field(..., description="Список товаров в корзине") + price: float = Field(..., description="Общая сумма заказа", examples=[234.4]) + +class CartCreateResponse(BaseModel): + id: int = Field(..., description="Идентификатор созданной корзины", examples=[123]) \ No newline at end of file diff --git a/hw3/shop_api/routes/carts.py b/hw3/shop_api/routes/carts.py new file mode 100644 index 00000000..6ad2a0ad --- /dev/null +++ b/hw3/shop_api/routes/carts.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, HTTPException, Query, status, Response +from typing import List, Optional +from ..models import Cart, CartCreateResponse +from ..storage import storage + +router = APIRouter(prefix="/cart", tags=["carts"]) + + +@router.post("/", response_model=CartCreateResponse, status_code=status.HTTP_201_CREATED) +def create_cart(response: Response): + cart_id = storage.create_cart() + response.headers["Location"] = f"/cart/{cart_id}" + return CartCreateResponse(id=cart_id) + + +@router.get("/{cart_id}", response_model=Cart) +def get_cart(cart_id: int): + cart = storage.get_cart(cart_id) + if not cart: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found") + return cart + + +@router.get("/", response_model=List[Cart]) +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, ge=1), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0) +): + def cart_filter(cart: Cart) -> bool: + total_quantity = sum(item.quantity for item in cart.items) + return all([ + min_price is None or cart.price >= min_price, + max_price is None or cart.price <= max_price, + min_quantity is None or total_quantity >= min_quantity, + max_quantity is None or total_quantity <= max_quantity + ]) + + filtered_carts = list(filter(cart_filter, storage.carts.values())) + return filtered_carts[offset:offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int): + success = storage.add_item_to_cart(cart_id, item_id) + if not success: + raise HTTPException(status_code=404, detail="Cart or item not found") + return {"message": "Item added to cart"} \ No newline at end of file diff --git a/hw3/shop_api/routes/items.py b/hw3/shop_api/routes/items.py new file mode 100644 index 00000000..fdd5fa0f --- /dev/null +++ b/hw3/shop_api/routes/items.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, HTTPException, Query, status +from typing import List, Optional +from ..models import Item, ItemCreate, ItemUpdate +from ..storage import storage + +router = APIRouter(prefix="/item", tags=["items"]) + +@router.post("/", response_model=Item, status_code=status.HTTP_201_CREATED) +def create_item(item: ItemCreate): + return storage.create_item(item) + + +@router.get("/{item_id}", response_model=Item) +def get_item(item_id: int): + item = storage.get_available_item(item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + return item + + +@router.get("/", response_model=List[Item]) +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, ge=1), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False +): + items = storage.list_items(show_deleted=show_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] + + return items[offset:offset + limit] + + +@router.put("/{item_id}", response_model=Item) +def replace_item(item_id: int, item: ItemCreate): + existing = storage.get_item(item_id) + if not existing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + + updated_item = storage.replace_item(item_id, item) + return updated_item + + +@router.patch("/{item_id}", response_model=Item) +def update_item(item_id: int, item_update: ItemUpdate): + existing_item = storage.get_available_item(item_id) + if not existing_item: + raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED, detail="Item not found or deleted") + + try: + item = storage.update_item(item_id, item_update) + return item + except ValueError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + + +@router.delete("/{item_id}", response_model=Item) +def delete_item(item_id: int): + item = storage.delete_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + return item \ No newline at end of file diff --git a/hw3/shop_api/storage.py b/hw3/shop_api/storage.py new file mode 100644 index 00000000..062cfcda --- /dev/null +++ b/hw3/shop_api/storage.py @@ -0,0 +1,98 @@ +from typing import Dict, List, Optional +from .models import Item, Cart, CartItem, ItemCreate, ItemUpdate + + +class Storage: + def __init__(self): + self.items: Dict[int, Item] = {} + self.carts: Dict[int, Cart] = {} + self._next_item_id = 1 + self._next_cart_id = 1 + + def create_item(self, item_data: ItemCreate) -> Item: + item_id = self._next_item_id + self._next_item_id += 1 + item = Item(id=item_id, **item_data.model_dump()) + self.items[item_id] = item + return item + + def get_item(self, item_id: int) -> Optional[Item]: + return self.items.get(item_id) + + def get_available_item(self, item_id: int) -> Optional[Item]: + item = self.items.get(item_id) + return item if item and not item.deleted else None + + def list_items(self, show_deleted: bool = False) -> List[Item]: + items = list(self.items.values()) + if not show_deleted: + items = [item for item in items if not item.deleted] + return items + + def replace_item(self, item_id: int, item_data: ItemCreate) -> Optional[Item]: + if item_id not in self.items: + return None + + current_deleted = self.items[item_id].deleted + self.items[item_id] = Item( + id=item_id, + **item_data.model_dump(), + deleted=current_deleted + ) + return self.items[item_id] + + def update_item(self, item_id: int, update_data: ItemUpdate) -> Optional[Item]: + if item_id not in self.items: + return None + + item = self.items[item_id] + update_dict = update_data.model_dump(exclude_unset=True) + + for field, value in update_dict.items(): + setattr(item, field, value) + + return item + + def delete_item(self, item_id: int) -> Optional[Item]: + if item_id not in self.items: + return None + self.items[item_id].deleted = True + return self.items[item_id] + + def create_cart(self) -> int: + cart_id = self._next_cart_id + self._next_cart_id += 1 + cart = Cart(id=cart_id, items=[], price=0.0) + self.carts[cart_id] = cart + return cart_id + + def get_cart(self, cart_id: int) -> Optional[Cart]: + return self.carts.get(cart_id) + + def add_item_to_cart(self, cart_id: int, item_id: int) -> bool: + if cart_id not in self.carts: + return False + + item = self.get_available_item(item_id) + if not item: + return False + + cart = self.carts[cart_id] + + cart_item = CartItem( + id=item_id, + name=item.name, + quantity=1, + available=True + ) + cart.items.append(cart_item) + + cart.price = sum( + cart_item.quantity * self.items[cart_item.id].price + for cart_item in cart.items + if cart_item.available + ) + + return True + +storage = Storage() \ No newline at end of file diff --git a/hw3/test_homework2.py b/hw3/test_homework2.py new file mode 100644 index 00000000..60a1f36a --- /dev/null +++ b/hw3/test_homework2.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +from shop_api.main import app + +client = TestClient(app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@pytest.fixture() +def existing_not_empty_cart_id( + existing_empty_cart_id: int, + existing_items: list[int], +) -> int: + for item_id in faker.random_elements(existing_items, unique=False, length=3): + client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + + return existing_empty_cart_id + + +@pytest.fixture() +def existing_item() -> dict[str, Any]: + return client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ).json() + + +@pytest.fixture() +def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: + item_id = existing_item["id"] + client.delete(f"/item/{item_id}") + + existing_item["deleted"] = True + return existing_item + + +def test_post_cart() -> None: + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.parametrize( + ("cart", "not_empty"), + [ + ("existing_empty_cart_id", False), + ("existing_not_empty_cart_id", True), + ], +) +def test_get_cart(request, cart: int, not_empty: bool) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + len_items = len(response_json["items"]) + assert len_items > 0 if not_empty else len_items == 0 + + if not_empty: + price = 0 + + for item in response_json["items"]: + item_id = item["id"] + price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + else: + assert response_json["price"] == 0.0 + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000.0}, HTTPStatus.OK), + ({"max_price": 20.0}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query: dict[str, Any], status_code: int): + response = client.get("/cart", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + quantity = sum(item["quantity"] for cart in data for item in cart["items"]) + + if "min_quantity" in query: + assert quantity >= query["min_quantity"] + + if "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_post_item() -> None: + item = {"name": "test item", "price": 9.99} + response = client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +def test_get_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_item_list(query: dict[str, Any], status_code: int) -> None: + response = client.get("/item", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + if "show_deleted" in query and query["show_deleted"] is False: + assert all(item["deleted"] is False for item in data) + + +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +def test_put_item( + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = client.put(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + new_item = existing_item.copy() + new_item.update(body) + assert response.json() == new_item + + +@pytest.mark.parametrize( + ("item", "body", "status_code"), + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing_item", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing_item", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: + item_data: dict[str, Any] = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + patch_response_body = response.json() + + response = client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +def test_delete_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK