diff --git a/hw1/app.py b/hw1/app.py index 6107b870..caed818a 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,96 @@ from typing import Any, Awaitable, Callable +from http import HTTPStatus +from typing import List +import math, json + + +def fibonacci(n: int) -> int: + if n in (0, 1): + return n + a, b = 0, 1 + for _ in range(2, n): + a, b = b, a + b + return b + + +async def parse_endpoint( + scope, receive: Callable[[], Awaitable[dict[str, Any]]], status=200 +): + path = scope.get("path", None) + if not path: + return 404, None + + query_string = scope.get("query_string", b"") + + if path.startswith("/fibonacci/"): + try: + n = int(path.split("/fibonacci/")[1]) + if n < 0: + return 400, None + return status, fibonacci(n) + except (IndexError, ValueError): + return 422, None + + elif path == "/factorial": + query_string = query_string.decode("utf-8") + try: + n = int(query_string.split("n=")[1]) + if n < 0: + return 400, None + return status, math.factorial(n) + except (IndexError, ValueError): + return 422, None + + elif path == "/mean": + + query_string = query_string.decode("utf-8") + event = await receive() + event = event.get("body", b"") + + event = json.loads(event.decode()) + + if event is None: + return 422, None + if len(event) == 0: + return 400, None + try: + numbers = event + numbers = sum(numbers) / len(numbers) + return status, numbers + except (IndexError, ValueError): + return 422, None + + return 404, None + + +async def processor( + scope, + send, + receive: Callable[[], Awaitable[dict[str, Any]]], +): + status, res = await parse_endpoint(scope, receive) + + if res is None: + await send( + { + "type": "http.response.start", + "status": status, + "headers": [[b"content-type", b"application/json"]], + } + ) + return await send({"type": "http.response.body", "body": b"Not found"}) + + res = {"result": f"{res}"} + await send( + { + "type": "http.response.start", + "status": status, + "headers": [ + [b"content-type", b"application/json"], + ], + } + ) + return await send({"type": "http.response.body", "body": json.dumps(res).encode()}) async def application( @@ -12,8 +104,11 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + # assert scope["type"] == "http" + await processor(scope, send, receive) + if __name__ == "__main__": import uvicorn + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..1ab02459 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,28 @@ +# Use an official Python runtime as a parent image +FROM python:3.9-alpine 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 python -m pip install --upgrade pip + +# Set the working directory in the container +WORKDIR /app + +# Copy the current directory contents into the container at /app +COPY . /app + +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt +RUN pip install "fastapi[standard]" +RUN pip install sqlalchemy +# Make port 8000 available to the world outside this container +EXPOSE 8080 + +# Run app.py when the container launches +# CMD ["python", "app.py"] +FROM base AS local +CMD ["fastapi", "dev", "/app/shop_api/main.py", "--port", "8080", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/hw2/hw/assets/hw3.gif b/hw2/hw/assets/hw3.gif new file mode 100644 index 00000000..e8ed8105 Binary files /dev/null and b/hw2/hw/assets/hw3.gif differ diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..215871b1 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3" + +services: + + local: + image: hw3/grafana:2025 + 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/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..62de1873 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,9 @@ -# Основные зависимости для 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 +pydantic==2.11.10 +prometheus-fastapi-instrumentator \ No newline at end of file diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..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/main.py b/hw2/hw/shop_api/main.py index f60a8c60..b89f4795 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,450 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from sqlalchemy import create_engine +from .model import Cart, DBCart, Base, Item, DBItem, CartItem +from fastapi import Query, HTTPException +from typing import List, Optional +from http import HTTPStatus +from sqlalchemy.orm.attributes import flag_modified +from fastapi.responses import JSONResponse +from http import HTTPStatus +from prometheus_fastapi_instrumentator import Instrumentator + +import random + +SQLALCHEMY_DATABASE_URL = "sqlite:///./shop.db" +engine = create_engine(SQLALCHEMY_DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base.metadata.create_all(bind=engine) + app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +all_catrs = [] +def maybe_raise_random_error(): + if random.random() < 0.1: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Random error occurred" + ) + +@app.post("/cart") +async def create_cart(): + db = SessionLocal() + try: + cart_count = db.query(DBCart).count() + new_cart_id = cart_count + 1 + db_cart = DBCart(id=new_cart_id) + + db.add(db_cart) + db.commit() + db.refresh(db_cart) + cart = Cart( + id=db_cart.id, + items=db_cart.items if db_cart.items else [], + price=db_cart.price if db_cart.price is not None else 0.0, + ) + return JSONResponse( + status_code=HTTPStatus.CREATED, + content={"id": cart.id}, + headers={"location": f"/cart/{cart.id}"}, + ) + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@app.get("/cart/{cart_id}", response_model=Cart) +async def get_cart(cart_id: int): + db = SessionLocal() + try: + cart = db.query(DBCart).filter(DBCart.id == cart_id).first() + if cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + print(cart.items) + cart_items = [CartItem(**item) for item in cart.items] if cart.items else [] + + return Cart( + id=cart.id, items=cart_items, price=0 if not cart.price else cart.price + ) + finally: + db.close() + + +@app.get("/cart", response_model=List[Cart]) +async def get_carts( + offset: int = Query(0, ge=0, description="Offset for pagination"), + limit: int = Query(10, ge=1, le=100, description="Limit for pagination"), + min_price: Optional[float] = Query( + None, ge=0, description="Minimum price inclusive" + ), + max_price: Optional[float] = Query( + None, ge=0, description="Maximum price inclusive" + ), + min_quantity: Optional[int] = Query( + None, ge=0, description="Minimum total quantity inclusive" + ), + max_quantity: Optional[int] = Query( + None, ge=0, description="Maximum total quantity inclusive" + ), +): + db = SessionLocal() + maybe_raise_random_error() + try: + query = db.query(DBCart) + + # Apply price filters + if min_price is not None: + query = query.filter(DBCart.price >= min_price) + if max_price is not None: + query = query.filter(DBCart.price <= max_price) + + # Apply quantity filters + if min_quantity is not None or max_quantity is not None: + # We need to calculate total quantity for each cart + carts_with_quantities = [] + all_carts = query.all() + print(f"\n\n all_carts={all_carts}") + + for cart in all_carts: + total_quantity = ( + sum(item["quantity"] for item in cart.items) if cart.items else 0 + ) + + # Check quantity filters + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + carts_with_quantities.append(cart) + + # Apply pagination manually after filtering + paginated_carts = carts_with_quantities[offset : offset + limit] + + return [ + Cart( + id=cart.id, + items=( + [CartItem(**item) for item in cart.items] if cart.items else [] + ), + price=cart.price if cart.price is not None else 0.0, + ) + for cart in paginated_carts + ] + else: + # No quantity filters, apply normal pagination + db_carts = query.offset(offset).limit(limit).all() + + return [ + Cart( + id=cart.id, + items=( + [CartItem(**item) for item in cart.items] if cart.items else [] + ), + price=cart.price if cart.price is not None else 0.0, + ) + for cart in db_carts + ] + finally: + db.close() + + +# POST /cart/{cart_id}/add/{item_id} - добавление предмета в корзину +@app.post("/cart/{cart_id}/add/{item_id}") +async def add_item_to_cart(cart_id: int, item_id: int): + db = SessionLocal() + try: + # Check if cart exists + db_cart = db.query(DBCart).filter(DBCart.id == cart_id).first() + if db_cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + + # Check if item exists and is not deleted + db_item = ( + db.query(DBItem) + .filter(DBItem.id == item_id, DBItem.deleted == False) + .first() + ) + if db_item is None: + raise HTTPException(status_code=404, detail="Item not found or is deleted") + + # Get current items or initialize empty list + current_items = db_cart.items if db_cart.items else [] + print(current_items, item_id) + + # Check if item already exists in cart + item_found = False + updated_items = [] + for item in current_items: + if item["id"] == item_id: + # Increase quantity + item["quantity"] += 1 + item_found = True + updated_items.append(item) + + # If item not found, add new entry + if not item_found: + updated_items.append({"id": item_id, "quantity": 1}) + + # Update cart items + db_cart.items = updated_items + + # Recalculate total price + total_price = 0.0 + for item in updated_items: + item_obj = db.query(DBItem).filter(DBItem.id == item["id"]).first() + if item_obj: + total_price += item_obj.price * item["quantity"] + + db_cart.price = total_price + flag_modified(db_cart, "items") + + db.commit() + + return {"message": f"Item {item_id} added to cart {cart_id} successfully"} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@app.get("/") +async def read_root(): + return {"Hello": "World"} + + +@app.post("/item", status_code=HTTPStatus.CREATED) +async def create_item(item: Item): + db = SessionLocal() + try: + # Check if item with this ID already exists + + if item.id is None: + max_id = db.query(DBItem).count() + new_id = 1 if max_id is None else max_id + 1 + else: + existing_item = db.query(DBItem).filter(DBItem.id == item.id).first() + + if existing_item: + raise HTTPException( + status_code=400, detail="Item with this ID already exists" + ) + new_id = item.id + print(new_id) + db_item = DBItem(id=new_id, name=item.name, price=item.price, deleted=False) + + db.add(db_item) + db.commit() + db.refresh(db_item) + # print(Item(db_item.id, db_item.name, db_item.price, db_item.deleted)) + + return JSONResponse( + status_code=HTTPStatus.CREATED, + content={ + "id": db_item.id, + "name": db_item.name, + "price": db_item.price, + "deleted": db_item.deleted, + }, + headers={"location": f"/item/{db_item.id}"}, + ) + + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# GET /item/{id} - получение товара по id +@app.get("/item/{item_id}", response_model=Item) +async def get_item(item_id: int): + maybe_raise_random_error() + db = SessionLocal() + try: + db_item = db.query(DBItem).filter(DBItem.id == item_id).first() + if db_item is None or db_item.deleted: + raise HTTPException(status_code=404, detail="Item not found") + + return Item( + id=db_item.id, + name=db_item.name, + price=db_item.price, + deleted=db_item.deleted, + ) + finally: + db.close() + + +# GET /item - получение списка товаров с query-параметрами +@app.get("/item", response_model=List[Item]) +async def get_items( + offset: int = Query(0, ge=0, description="Offset for pagination"), + limit: int = Query(10, ge=1, le=100, description="Limit for pagination"), + min_price: Optional[float] = Query(None, ge=0, description="Minimum price"), + max_price: Optional[float] = Query(None, ge=0, description="Maximum price"), + show_deleted: bool = Query(False, description="Show deleted items"), +): + db = SessionLocal() + try: + query = db.query(DBItem) + + # Apply price filters + if min_price is not None: + query = query.filter(DBItem.price >= min_price) + if max_price is not None: + query = query.filter(DBItem.price <= max_price) + + # Apply deleted filter + if not show_deleted: + query = query.filter(DBItem.deleted == False) + + # Apply pagination + db_items = query.offset(offset).limit(limit).all() + + return [ + Item(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + for item in db_items + ] + finally: + db.close() + + +# PUT /item/{id} - замена товара по id +@app.put("/item/{item_id}", response_model=Item) +async def replace_item(item_id: int, item: Item): + db = SessionLocal() + try: + db_item = db.query(DBItem).filter(DBItem.id == item_id).first() + if db_item is None: + raise HTTPException(status_code=404, detail="Item not found") + + # Update all fields + db_item.name = item.name + db_item.price = item.price + db_item.deleted = item.deleted + + db.commit() + db.refresh(db_item) + + return Item( + id=db_item.id, + name=db_item.name, + price=db_item.price, + deleted=db_item.deleted, + ) + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +from fastapi import HTTPException +from http import HTTPStatus + + +# PATCH /item/{id} - частичное обновление товара по id +@app.patch("/item/{item_id}", response_model=Item) +async def update_item(item_id: int, item_update: dict): + db = SessionLocal() + try: + db_item = db.query(DBItem).filter(DBItem.id == item_id).first() + if db_item is None: + raise HTTPException(status_code=404, detail="Item not found") + + # If item is deleted, return 304 NOT MODIFIED regardless of update body + if db_item.deleted: + raise HTTPException( + status_code=HTTPStatus.NOT_MODIFIED, detail="Item is deleted" + ) + + # Check if trying to update deleted field + if "deleted" in item_update: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Cannot update deleted field with PATCH", + ) + + # If no fields to update, return current item + if not item_update: + return Item( + id=db_item.id, + name=db_item.name, + price=db_item.price, + deleted=db_item.deleted, + ) + + # Update allowed fields and validate + allowed_fields = ["name", "price"] + has_valid_updates = False + + for field, value in item_update.items(): + if field in allowed_fields: + setattr(db_item, field, value) + has_valid_updates = True + else: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail=f"Cannot update field: {field}", + ) + + # Only commit if there were valid updates + if has_valid_updates: + db.commit() + db.refresh(db_item) + + return Item( + id=db_item.id, + name=db_item.name, + price=db_item.price, + deleted=db_item.deleted, + ) + except HTTPException: + db.rollback() + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# DELETE /item/{id} - удаление товара по id +@app.delete("/item/{item_id}") +async def delete_item(item_id: int): + db = SessionLocal() + try: + db_item = db.query(DBItem).filter(DBItem.id == item_id).first() + if db_item is not None and db_item.deleted: + return {"message": "Item deleted already"} + if db_item is None: + raise HTTPException(status_code=404, detail="Item not found") + + if db_item.deleted: + raise HTTPException(status_code=400, detail="Item already deleted") + + # Soft delete - mark as deleted + db_item.deleted = True + db.commit() + + return {"message": "Item deleted successfully"} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() diff --git a/hw2/hw/shop_api/model.py b/hw2/hw/shop_api/model.py new file mode 100644 index 00000000..593c572c --- /dev/null +++ b/hw2/hw/shop_api/model.py @@ -0,0 +1,45 @@ +from typing import List, Optional +from pydantic import BaseModel +from fastapi import FastAPI, HTTPException +from sqlalchemy import Column, Integer, String, JSON, Float, Boolean +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from typing import Optional + +Base = declarative_base() + + +class Item(BaseModel): + id: Optional[int] = None + name: str + price: float + deleted: Optional[bool] = False + + +class DBCart(Base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, index=True) + items = Column(JSON, default=[]) # Stores cart items as JSON + price = Column(Float, nullable=True) # Optional user association + + +class CartItem(BaseModel): + id: int + quantity: int + + +class Cart(BaseModel): + id: int + items: List[CartItem] = [] + price: float = 0 + + +# First, create the DBItem model +class DBItem(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False)