diff --git a/hw1/README.md b/hw1/README.md index 517057e7..e0f73d91 100644 --- a/hw1/README.md +++ b/hw1/README.md @@ -5,7 +5,7 @@ ### 1. Установите зависимости ```bash -pip install -r requirements.txt +pip3 install --break-system-packages -r requirements.txt ``` ### 2. Реализуйте ASGI приложение diff --git a/hw1/app.py b/hw1/app.py index 6107b870..faf39949 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,42 @@ from typing import Any, Awaitable, Callable +import math +import json +from urllib.parse import parse_qs +from http import HTTPStatus + + +def compute_fibonacci(n: int) -> int: + if n == 0: + return 0 + a, b = 0, 1 + for _ in range(1, n + 1): + a, b = b, a + b + return a + + +async def send_response( + send: Callable[[dict[str, Any]], Awaitable[None]], + status: int, + body: bytes = b"", + content_type: bytes = b"text/plain", +): + await send( + { + "type": "http.response.start", + "status": status, + "headers": [[b"content-type", content_type]], + } + ) + await send({"type": "http.response.body", "body": body}) + + +async def send_json( + send: Callable[[dict[str, Any]], Awaitable[None]], + data: dict[str, Any], + status: int = HTTPStatus.OK, +): + body = json.dumps(data).encode("utf-8") + await send_response(send, status, body, b"application/json") async def application( @@ -12,8 +50,94 @@ async def application( 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": + method = scope["method"] + path = scope["path"] + + if path == "/factorial": + if method != "GET": + await send_response(send, HTTPStatus.METHOD_NOT_ALLOWED, b"Method Not Allowed") + return + query_string = scope["query_string"].decode() + query = parse_qs(query_string) + n_str = query.get("n", [None])[0] + if n_str is None: + await send_response(send, HTTPStatus.UNPROCESSABLE_ENTITY, b"Missing parameter 'n'") + return + try: + n = int(n_str) + except ValueError: + await send_response(send, HTTPStatus.UNPROCESSABLE_ENTITY, b"Invalid parameter 'n'") + return + if n < 0: + await send_response(send, HTTPStatus.BAD_REQUEST, b"Parameter 'n' must be non-negative") + return + result = math.factorial(n) + await send_json(send, {"result": result}) + + elif path.startswith("/fibonacci/"): + if method != "GET": + await send_response(send, HTTPStatus.METHOD_NOT_ALLOWED, b"Method Not Allowed") + return + n_str = path[len("/fibonacci/"):] + if not n_str: + await send_response(send, HTTPStatus.UNPROCESSABLE_ENTITY, b"Missing number in path") + return + try: + n = int(n_str) + except ValueError: + await send_response(send, HTTPStatus.UNPROCESSABLE_ENTITY, b"Invalid number in path") + return + if n < 0: + await send_response(send, HTTPStatus.BAD_REQUEST, b"Number must be non-negative") + return + result = compute_fibonacci(n) + await send_json(send, {"result": result}) + + elif path == "/mean": + if method != "GET": + await send_response(send, HTTPStatus.METHOD_NOT_ALLOWED, b"Method Not Allowed") + return + message = await receive() + if message["type"] != "http.request": + await send_response(send, HTTPStatus.INTERNAL_SERVER_ERROR, b"Invalid request") + return + body = message.get("body", b"") + if not body: + await send_response(send, HTTPStatus.UNPROCESSABLE_ENTITY, b"Empty body") + return + try: + data = json.loads(body) + if not isinstance(data, list): + await send_response(send, HTTPStatus.UNPROCESSABLE_ENTITY, b"Body must be a list") + return + if not data: + await send_response(send, HTTPStatus.BAD_REQUEST, b"Empty list") + return + numbers = [] + for item in data: + if not isinstance(item, (int, float)): + await send_response(send, HTTPStatus.UNPROCESSABLE_ENTITY, b"All elements must be numbers") + return + numbers.append(float(item)) + mean_value = sum(numbers) / len(numbers) + await send_json(send, {"result": mean_value}) + except json.JSONDecodeError: + await send_response(send, HTTPStatus.UNPROCESSABLE_ENTITY, b"Invalid JSON") + return + else: + await send_response(send, HTTPStatus.NOT_FOUND, b"Not Found") + else: + return 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) diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..d1f8f4d1 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY shop_api/ ./shop_api/ + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] \ 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..ec339994 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,64 @@ +version: '3.8' + +services: + app: + build: . + restart: unless-stopped + ports: + - "8000:8000" + networks: + - monitoring + - db_network + depends_on: + - db + - prometheus + environment: + - DATABASE_URL=postgresql://shop:shop@db:5432/shop + + db: + image: postgres:15 + restart: unless-stopped + environment: + POSTGRES_USER: shop + POSTGRES_PASSWORD: shop + POSTGRES_DB: shop + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - db_network + ports: + - "5432:5432" + + prometheus: + image: prom/prometheus:latest + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + networks: + - monitoring + + grafana: + image: grafana/grafana:latest + restart: unless-stopped + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana_data:/var/lib/grafana + networks: + - monitoring + +networks: + monitoring: + db_network: + +volumes: + grafana_data: + postgres_data: {} \ No newline at end of file diff --git a/hw2/hw/img.png b/hw2/hw/img.png new file mode 100644 index 00000000..82c633c3 Binary files /dev/null and b/hw2/hw/img.png differ diff --git a/hw2/hw/prometheus.yml b/hw2/hw/prometheus.yml new file mode 100644 index 00000000..2fbb35fb --- /dev/null +++ b/hw2/hw/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'shop-api' + static_configs: + - targets: ['app:8000'] + metrics_path: '/metrics' \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..01db03b6 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,11 @@ # Основные зависимости для ASGI приложения -fastapi>=0.117.1 -uvicorn>=0.24.0 +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +prometheus-fastapi-instrumentator==6.1.0 +pydantic==2.5.3 +pydantic-core==2.14.6 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 # Зависимости для тестирования pytest>=7.4.0 diff --git a/hw2/hw/run_scripts.bash b/hw2/hw/run_scripts.bash new file mode 100644 index 00000000..2e30c971 --- /dev/null +++ b/hw2/hw/run_scripts.bash @@ -0,0 +1,5 @@ +docker-compose up --build -d + +sleep 20 + +python3 scripts/isolation_demo.py \ No newline at end of file diff --git a/hw2/hw/scripts/dirty_read.py b/hw2/hw/scripts/dirty_read.py new file mode 100644 index 00000000..8032f4a3 --- /dev/null +++ b/hw2/hw/scripts/dirty_read.py @@ -0,0 +1,30 @@ +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +import time + +engine = create_engine("postgresql://shop:shop@localhost:5432/shop") +Session = sessionmaker(bind=engine) + +s1 = Session() +s1.connection(execution_options={"isolation_level": "READ UNCOMMITTED"}) +s2 = Session() +s2.connection(execution_options={"isolation_level": "READ UNCOMMITTED"}) + +s1.execute(text("DELETE FROM items WHERE name LIKE 'dirty%'")) +s1.commit() + +print("DIRTY READ (READ UNCOMMITTED):") +s1.execute(text("BEGIN")) +s1.execute(text("INSERT INTO items (name, price) VALUES ('dirty_item', 999)")) +print("S1: inserted dirty_item") + +print("S2: sees uncommitted data?") +res = s2.execute(text("SELECT name FROM items WHERE name = 'dirty_item'")).fetchone() +print("S2 sees:", res) + +s1.rollback() +print("S1 rolled back") +res = s2.execute(text("SELECT name FROM items WHERE name = 'dirty_item'")).fetchone() +print("S2 after rollback sees:", res) + +s1.close(); s2.close() \ No newline at end of file diff --git a/hw2/hw/scripts/isolation_demo.py b/hw2/hw/scripts/isolation_demo.py new file mode 100644 index 00000000..f8d908c7 --- /dev/null +++ b/hw2/hw/scripts/isolation_demo.py @@ -0,0 +1,182 @@ +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +import time +import threading + +engine = create_engine("postgresql://shop:shop@localhost:5432/shop") +Session = sessionmaker(bind=engine) + + +def cleanup(): + with engine.connect() as conn: + conn.execute(text("DELETE FROM items WHERE name LIKE 'demo_%'")) + conn.commit() + + +cleanup() + +print("=== ДЕМОНСТРАЦИЯ УРОВНЕЙ ИЗОЛЯЦИИ ===\n") + +print("1. DIRTY READ (READ UNCOMMITTED):") +s1 = Session() +s2 = Session() +s1.connection(execution_options={"isolation_level": "READ UNCOMMITTED"}) +s2.connection(execution_options={"isolation_level": "READ UNCOMMITTED"}) + +s1.execute(text("BEGIN")) +s1.execute(text("INSERT INTO items (name, price) VALUES ('demo_dirty', 999)")) +print(" S1: вставил demo_dirty (не закоммичено)") + +res = s2.execute(text("SELECT name FROM items WHERE name = 'demo_dirty'")).fetchone() +print(f" S2 (READ UNCOMMITTED): видит → {res}") + +s1.rollback() +print(" S1: откатил транзакцию") +s1.close() +s2.close() + + + + + + + +print("\n2. NO DIRTY READ (READ COMMITTED):") +s1 = Session() +s2 = Session() +s1.connection(execution_options={"isolation_level": "READ COMMITTED"}) +s2.connection(execution_options={"isolation_level": "READ COMMITTED"}) + +s1.execute(text("BEGIN")) +s1.execute(text("INSERT INTO items (name, price) VALUES ('demo_dirty2', 888)")) + +res = s2.execute(text("SELECT name FROM items WHERE name = 'demo_dirty2'")).fetchone() +print(f" S2 (READ COMMITTED): НЕ видит → {res}") + +s1.commit() +res = s2.execute(text("SELECT name FROM items WHERE name = 'demo_dirty2'")).fetchone() +print(f" S2 после COMMIT: видит → {res}") + +s1.close() +s2.close() + + + + + + +print("\n3. NON-REPEATABLE READ (READ COMMITTED):") +s1 = Session() +s2 = Session() +s1.connection(execution_options={"isolation_level": "READ COMMITTED"}) +s2.connection(execution_options={"isolation_level": "READ COMMITTED"}) + +with engine.connect() as conn: + conn.execute(text("DELETE FROM items WHERE name = 'demo_nr'")) + conn.commit() + +s1.execute(text("BEGIN")) +res1 = s1.execute(text("SELECT price FROM items WHERE name = 'demo_nr'")).fetchone() +print(f" S1: первый SELECT → {res1}") + +s2.execute(text("INSERT INTO items (name, price) VALUES ('demo_nr', 500)")) +s2.commit() +print(" S2: вставил demo_nr = 500") + +res2 = s1.execute(text("SELECT price FROM items WHERE name = 'demo_nr'")).fetchone() +print(f" S1: второй SELECT → {res2} ← НЕПОВТОРЯЕМОЕ ЧТЕНИЕ!") + +s1.rollback() +s1.close() +s2.close() + + + + + + +print("\n4. NO NON-REPEATABLE READ (REPEATABLE READ):") +s1 = Session() +s2 = Session() +s1.connection(execution_options={"isolation_level": "REPEATABLE READ"}) +s2.connection(execution_options={"isolation_level": "REPEATABLE READ"}) + +s1.execute(text("BEGIN")) +res1 = s1.execute(text("SELECT price FROM items WHERE name = 'demo_rr'")).fetchone() +print(f" S1: первый SELECT → {res1}") + +s2.execute(text("INSERT INTO items (name, price) VALUES ('demo_rr', 700)")) +s2.commit() +print(" S2: вставил demo_rr = 700") + +res2 = s1.execute(text("SELECT price FROM items WHERE name = 'demo_rr'")).fetchone() +print(f" S1: второй SELECT → {res2} ← ДАННЫЕ НЕ ИЗМЕНИЛИСЬ!") + +s1.rollback() +s1.close() +s2.close() + + + + + + +print("\n5. PHANTOM READ (REPEATABLE READ):") +s1 = Session() +s2 = Session() +s1.connection(execution_options={"isolation_level": "REPEATABLE READ"}) +s2.connection(execution_options={"isolation_level": "REPEATABLE READ"}) + +s1.execute(text("BEGIN")) +count1 = s1.execute(text("SELECT COUNT(*) FROM items WHERE price > 100")).fetchone()[0] +print(f" S1: первый COUNT(price > 100) → {count1}") + +s2.execute(text("INSERT INTO items (name, price) VALUES ('demo_p1', 200), ('demo_p2', 300)")) +s2.commit() +print(" S2: вставил 2 товара с price > 100") + +count2 = s1.execute(text("SELECT COUNT(*) FROM items WHERE price > 100")).fetchone()[0] +print(f" S1: второй COUNT → {count2} ← ФАНТОМНЫЕ СТРОКИ!") + +s1.rollback() +s1.close() +s2.close() + + + + + +print("\n6. NO PHANTOM READ (SERIALIZABLE):") +s1 = Session() +s2 = Session() +s1.connection(execution_options={"isolation_level": "SERIALIZABLE"}) +s2.connection(execution_options={"isolation_level": "SERIALIZABLE"}) + +s1.execute(text("BEGIN")) +count1 = s1.execute(text("SELECT COUNT(*) FROM items WHERE price > 100")).fetchone()[0] +print(f" S1: первый COUNT(price > 100) → {count1}") + + +def insert_phantom(): + time.sleep(1) + s2.execute(text("BEGIN")) + s2.execute(text("INSERT INTO items (name, price) VALUES ('demo_s1', 400), ('demo_s2', 500)")) + print(" S2: пытается вставить 2 товара...") + s2.commit() + print(" S2: успешно вставил") + + +thread = threading.Thread(target=insert_phantom) +thread.start() + +time.sleep(2) +count2 = s1.execute(text("SELECT COUNT(*) FROM items WHERE price > 100")).fetchone()[0] +print(f" S1: второй COUNT → {count2} ← ФАНТОМОВ НЕТ!") + +s1.rollback() +thread.join() +s1.close() +s2.close() + +print("\n=== ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА ===") +cleanup() diff --git a/hw2/hw/scripts/non_repeatable_read.py b/hw2/hw/scripts/non_repeatable_read.py new file mode 100644 index 00000000..62225abe --- /dev/null +++ b/hw2/hw/scripts/non_repeatable_read.py @@ -0,0 +1,30 @@ +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +engine = create_engine("postgresql://shop:shop@localhost:5432/shop") +Session = sessionmaker(bind=engine) + +s1 = Session() +s2 = Session() + +s1.connection(execution_options={"isolation_level": "READ COMMITTED"}) +s2.connection(execution_options={"isolation_level": "READ COMMITTED"}) + +s1.execute(text("DELETE FROM items WHERE name = 'test_item'")) +s1.commit() + +print("\nNON-REPEATABLE READ (READ COMMITTED):") +print("S1: first read") +res1 = s1.execute(text("SELECT price FROM items WHERE name = 'test_item'")).fetchone() +print("S1 sees:", res1) + +s2.execute(text("INSERT INTO items (name, price) VALUES ('test_item', 100)")) +s2.commit() + +print("S2: inserted test_item with price 100") + +print("S1: second read in same transaction") +res2 = s1.execute(text("SELECT price FROM items WHERE name = 'test_item'")).fetchone() +print("S1 sees different value:", res2) # ← Non-repeatable! + +s1.close(); s2.close() \ No newline at end of file diff --git a/hw2/hw/scripts/phantom_read.py b/hw2/hw/scripts/phantom_read.py new file mode 100644 index 00000000..75c586e1 --- /dev/null +++ b/hw2/hw/scripts/phantom_read.py @@ -0,0 +1,31 @@ +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +import time + +engine = create_engine("postgresql://shop:shop@localhost:5432/shop") +Session = sessionmaker(bind=engine) + +s1 = Session() +s2 = Session() + +s1.connection(execution_options={"isolation_level": "REPEATABLE READ"}) +s2.connection(execution_options={"isolation_level": "REPEATABLE READ"}) + +s1.execute(text("DELETE FROM items WHERE name LIKE 'phantom%'")) +s1.commit() + +print("\nPHANTOM READ (REPEATABLE READ):") +print("S1: first read") +res1 = s1.execute(text("SELECT COUNT(*) FROM items WHERE price > 50")).fetchone() +print("S1 sees count:", res1[0]) + +s2.execute(text("INSERT INTO items (name, price) VALUES ('phantom1', 100), ('phantom2', 200)")) +s2.commit() + +print("S2: inserted 2 items with price > 50") + +print("S1: second read in same transaction") +res2 = s1.execute(text("SELECT COUNT(*) FROM items WHERE price > 50")).fetchone() +print("S1 sees new rows (phantom):", res2[0]) + +s1.close(); s2.close() \ No newline at end of file diff --git a/hw2/hw/scripts_out.txt b/hw2/hw/scripts_out.txt new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..0586a10a 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,234 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Query, Depends +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from typing import List, Optional +from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, text +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, Session +from prometheus_fastapi_instrumentator import Instrumentator +import os -app = FastAPI(title="Shop API") +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://shop:shop@localhost:5432/shop") +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +class ItemDB(Base): + __tablename__ = "items" + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + + +class CartDB(Base): + __tablename__ = "carts" + id = Column(Integer, primary_key=True, index=True) + + +class CartItemDB(Base): + __tablename__ = "cart_items" + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, nullable=False) + item_id = Column(Integer, nullable=False) + quantity = Column(Integer, default=1) + + +class ItemCreate(BaseModel): + name: str = Field(..., min_length=1) + price: float = Field(..., gt=0) + + +class Item(ItemCreate): + id: int + deleted: bool = False + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: List[CartItem] = [] + price: float = 0.0 + + +fastapi_app = FastAPI(title="Shop API") +Instrumentator().instrument(fastapi_app).expose(fastapi_app) + +from starlette.applications import Starlette +from starlette.routing import Mount + +app = Starlette( + routes=[ + Mount("/", app=fastapi_app) + ] +) + + +@fastapi_app.on_event("startup") +def on_startup(): + Base.metadata.create_all(bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@fastapi_app.post("/item", response_model=Item, status_code=201) +def create_item(item: ItemCreate, db: Session = Depends(get_db)): + db_item = ItemDB(name=item.name, price=item.price) + db.add(db_item) + db.commit() + db.refresh(db_item) + return Item(id=db_item.id, **item.dict(), deleted=False) + + +@fastapi_app.get("/item/{id}", response_model=Item) +def get_item(id: int, db: Session = Depends(get_db)): + item = db.get(ItemDB, id) + if not item or item.deleted: + raise HTTPException(404, "Item not found") + return Item.from_orm(item) + + +@fastapi_app.get("/item") +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, ge=1), + min_prices: Optional[float] = Query(None, ge=0), + max_prices: Optional[float] = Query(None, ge=0), + show_deleted: bool = False, + db: Session = Depends(get_db) +): + query = db.query(ItemDB) + if not show_deleted: + query = query.filter(ItemDB.deleted == False) + if min_prices is not None: + query = query.filter(ItemDB.price >= min_prices) + if max_prices is not None: + query = query.filter(ItemDB.price <= max_prices) + items = query.offset(offset).limit(limit).all() + return [Item.from_orm(i) for i in items] + + +@fastapi_app.put("/item/{id}", response_model=Item) +def update_item(id: int, item: ItemCreate, db: Session = Depends(get_db)): + db_item = db.get(ItemDB, id) + if not db_item: + raise HTTPException(404, "Item not found") + db_item.name = item.name + db_item.price = item.price + db.commit() + db.refresh(db_item) + return Item.from_orm(db_item) + + +@fastapi_app.patch("/item/{id}", response_model=Item) +def partial_update_item(id: int, item: dict, db: Session = Depends(get_db)): + db_item = db.get(ItemDB, id) + if not db_item: + raise HTTPException(404, "Item not found") + if db_item.deleted: + raise HTTPException(304, "Item is deleted") + for key, value in item.items(): + if key in ["name", "price"]: + setattr(db_item, key, value) + db.commit() + db.refresh(db_item) + return Item.from_orm(db_item) + + +@fastapi_app.delete("/item/{id}") +def delete_item(id: int, db: Session = Depends(get_db)): + db_item = db.get(ItemDB, id) + if not db_item: + raise HTTPException(404, "Item not found") + db_item.deleted = True + db.commit() + return {"status": "success"} + + +@fastapi_app.post("/cart", status_code=201) +def create_cart(db: Session = Depends(get_db)): + cart = CartDB() + db.add(cart) + db.commit() + db.refresh(cart) + return JSONResponse( + content={"id": cart.id}, + headers={"Location": f"/cart/{cart.id}"} + ) + + +@fastapi_app.get("/cart/{id}", response_model=Cart) +def get_cart(id: int, db: Session = Depends(get_db)): + cart = db.get(CartDB, id) + if not cart: + raise HTTPException(404, "Cart not found") + items = db.query(CartItemDB).filter(CartItemDB.cart_id == id).all() + total = 0.0 + cart_items = [] + for ci in items: + item = db.get(ItemDB, ci.item_id) + if item and not item.deleted: + cart_items.append(CartItem( + id=item.id, name=item.name, quantity=ci.quantity, available=True + )) + total += item.price * ci.quantity + else: + cart_items.append(CartItem( + id=ci.item_id, name="Unknown", quantity=ci.quantity, available=False + )) + return Cart(id=cart.id, items=cart_items, price=total) + + +@fastapi_app.get("/cart") +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, ge=1), + min_price: Optional[float] = None, + max_price: Optional[float] = None, + min_quantity: Optional[int] = None, + max_quantity: Optional[int] = None, + db: Session = Depends(get_db) +): + carts = db.query(CartDB).offset(offset).limit(limit).all() + result = [] + for cart in carts: + cart_data = get_cart(cart.id, db) + result.append(cart_data) + if min_price is not None: + result = [c for c in result if c.price >= min_price] + if max_price is not None: + result = [c for c in result if c.price <= max_price] + if min_quantity is not None: + result = [c for c in result if sum(i.quantity for i in c.items) >= min_quantity] + if max_quantity is not None: + result = [c for c in result if sum(i.quantity for i in c.items) <= max_quantity] + return result + + +@fastapi_app.post("/cart/{cart_id}/add/{item_id}") +def add_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + cart = db.get(CartDB, cart_id) + item = db.get(ItemDB, item_id) + if not cart or not item: + raise HTTPException(404, "Not found") + ci = db.query(CartItemDB).filter_by(cart_id=cart_id, item_id=item_id).first() + if ci: + ci.quantity += 1 + else: + ci = CartItemDB(cart_id=cart_id, item_id=item_id) + db.add(ci) + db.commit() + return {"status": "success"} diff --git a/hw2/hw/start.bash b/hw2/hw/start.bash new file mode 100755 index 00000000..6c0c2fe7 --- /dev/null +++ b/hw2/hw/start.bash @@ -0,0 +1,9 @@ +docker-compose down && docker-compose up --build -d + +sleep 30 + +curl http://localhost:8000/metrics | head -20 + +for i in {1..5}; do curl http://localhost:8000/cart; done + +curl http://localhost:9090/api/v1/query?query=http_server_requests_seconds_count_total