diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 00000000..125f3582 --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,61 @@ +name: "HW5 Tests" + +# Запускаем тесты при изменении файлов в hw2/hw/ +on: + pull_request: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + push: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + +jobs: + test-hw5: + runs-on: ubuntu-latest + + # Добавляем PostgreSQL как service container + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: hw4_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + + strategy: + matrix: + python-version: ["3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: hw2/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + working-directory: hw2/hw + env: + PYTHONPATH: ${{ github.workspace }}/hw2/hw + POSTGRES_PASSWORD: password + POSTGRES_HOST: localhost + POSTGRES_USER: postgres + POSTGRES_DB: hw4_db + run: | + pytest -vv --cov=shop_api/ --cov-fail-under=100 ./shop_api/tests diff --git a/hw1/app.py b/hw1/app.py index 6107b870..b93d9f48 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,31 @@ from typing import Any, Awaitable, Callable +from math import factorial +import json + + +def fibonacci(n: int): + res = 0 + if n > 0: + f = [0] * (n+1) + f[1] = 1 + for i in range(2, n+1): + f[i] = f[i-1] + f[i-2] + res = f[n] + return res + + +async def send_response(response_status_code: int, response_content_type: bytes, response_body: bytes, + send: Callable[[dict[str, Any]], Awaitable[None]]): + await send( + { + "type": "http.response.start", + "status": response_status_code, + "headers": [ + [b"content-type", response_content_type], + ], + } + ) + await send({"type": "http.response.body", "body": response_body}) async def application( @@ -12,7 +39,58 @@ 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'}) + break + elif scope['type'] == 'http': + path = scope["path"] + if path == "/" or path == "/not_found": + await send_response(404, b"text/plain", b"not found", send) + elif path == "/factorial": + try: + n = int(scope["query_string"].decode().replace("n=", "")) + if n < 0: + await send_response(400, b"text/plain", b"Invalid value for n, must be non-negative", send) + else: + result = factorial(n) + response_body = bytes(json.dumps({"result": result}), encoding="utf-8") + await send_response(200, b"application/json", response_body, send) + except ValueError: + await send_response(422, b"text/plain", b"unprocessible entity", send) + elif path.startswith("/fibonacci/"): + try: + n = int(path.split("/")[2]) + if n < 0: + await send_response(400, b"text/plain", b"Invalid value for n, must be non-negative", send) + else: + result = fibonacci(n) + response_body = bytes(json.dumps({"result": result}), encoding="utf-8") + await send_response(200, b"application/json", response_body, send) + except ValueError: + await send_response(422, b"text/plain", b"unprocessible entity", send) + elif path == "/mean": + body = b'' + event = await receive() + if event['type'] == 'http.request': + body = event.get('body', b'') + inp_arr = json.loads(body.decode()) + if inp_arr == None: + await send_response(422, b"text/plain", b"unprocessible entity", send) + else: + if len(inp_arr) == 0: + await send_response(400, b"text/plain", b"Invalid value for body, must be non-empty array of floats", send) + else: + result = sum(inp_arr) / len(inp_arr) + response_body = bytes(json.dumps({"result": result}), encoding="utf-8") + await send_response(200, b"application/json", response_body, send) + else: + await send_response(422, b"text/plain", b"unprocessible entity", send) + if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..8da7252c --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,20 @@ +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 + +COPY . ./ + + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..a8d0b5fe --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,56 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_HOST: postgres + POSTGRES_DB: hw4_db + ports: + - 8080:8080 + depends_on: + postgres: + condition: service_healthy + + 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 + + postgres: + image: postgres:15 + container_name: hw4_postgres + environment: + POSTGRES_DB: hw4_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - ./postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..48d33d04 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,15 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 - +prometheus-fastapi-instrumentator # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 +pytest-cov +pytest-mock +pytest \ 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..275d8544 --- /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 \ No newline at end of file diff --git a/hw2/hw/shop_api/api/__init__.py b/hw2/hw/shop_api/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/api/cart/__init__.py b/hw2/hw/shop_api/api/cart/__init__.py new file mode 100644 index 00000000..07405596 --- /dev/null +++ b/hw2/hw/shop_api/api/cart/__init__.py @@ -0,0 +1,7 @@ +from .contracts import CartResponse +from .routes import cart_router + +__all__ = [ + "CartResponse", + "cart_router", +] \ No newline at end of file diff --git a/hw2/hw/shop_api/api/cart/contracts.py b/hw2/hw/shop_api/api/cart/contracts.py new file mode 100644 index 00000000..050e9188 --- /dev/null +++ b/hw2/hw/shop_api/api/cart/contracts.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pydantic import BaseModel +from typing import Iterable + +from shop_api.store.models import ( + Cart, + CartItem +) + +class CartMapper: + """Маппер для преобразования между CartResponse и Cart (ORM)""" + + @staticmethod + def to_domain(orm_cart: Cart) -> CartResponse: + """Преобразование ORM модели в доменную""" + return CartResponse( + id=orm_cart.id, + items=[CartItemMapper.to_domain(item) for item in orm_cart.items], + price=orm_cart.price + ) + + +class CartItemMapper: + """Маппер для преобразования между CartItemResponse и CartItem (ORM)""" + + @staticmethod + def to_domain(orm_cart_item: CartItem) -> CartItemResponse: + """Преобразование ORM модели в доменную""" + return CartItemResponse( + id=orm_cart_item.id, + name=orm_cart_item.item.name, + quantity=orm_cart_item.quantity, + available=not orm_cart_item.item.deleted + ) + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartResponse(BaseModel): + id: int + items: Iterable[CartItemResponse] + price: float diff --git a/hw2/hw/shop_api/api/cart/routes.py b/hw2/hw/shop_api/api/cart/routes.py new file mode 100644 index 00000000..d2c4796b --- /dev/null +++ b/hw2/hw/shop_api/api/cart/routes.py @@ -0,0 +1,91 @@ +from http import HTTPStatus +from typing import Annotated, Optional + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat +from sqlalchemy.orm import Session + +from shop_api import store + +from .contracts import ( + CartResponse, + CartMapper +) + +cart_router = APIRouter(prefix="/cart") + + +@cart_router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat | None, Query()] = None, + max_price: Annotated[NonNegativeFloat | None, Query()] = None, + min_quantity: Annotated[NonNegativeInt | None, Query()] = None, + max_quantity: Annotated[NonNegativeInt | None, Query()] = None, + db: Session = Depends(store.get_db) +) -> list[CartResponse]: + return [CartMapper.to_domain(orm_cart) for orm_cart in store.get_carts(db, offset, limit, min_price, max_price, min_quantity, max_quantity)] + + +@cart_router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(id: int, db: Session = Depends(store.get_db)) -> CartResponse: + orm_cart = store.get_cart(db, id) + + if not orm_cart: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartMapper.to_domain(orm_cart) + + +@cart_router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_cart(response: Response, db: Session = Depends(store.get_db)) -> CartResponse: + orm_cart = store.add_cart(db) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/cart/{orm_cart.id}" + + return CartMapper.to_domain(orm_cart) + + +@cart_router.post( + "/{cart_id}/add/{item_id}", + status_code=HTTPStatus.CREATED, +) +async def post_cart_item(cart_id: int, item_id: int, db: Session = Depends(store.get_db)) -> CartResponse: + orm_cart = store.get_cart(db, cart_id) + + if not orm_cart: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{cart_id} was not found", + ) + + + orm_item = store.get_item(db, item_id) + + if not orm_item: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{item_id} was not found", + ) + + ret_orm_cart = store.add_item_to_cart(db, orm_cart, orm_item) + + return CartMapper.to_domain(ret_orm_cart) diff --git a/hw2/hw/shop_api/api/item/__init__.py b/hw2/hw/shop_api/api/item/__init__.py new file mode 100644 index 00000000..88f72b72 --- /dev/null +++ b/hw2/hw/shop_api/api/item/__init__.py @@ -0,0 +1,9 @@ +from .contracts import ItemResponse, ItemRequest, PatchItemRequest +from .routes import item_router + +__all__ = [ + "ItemResponse", + "ItemRequest", + "PatchItemRequest", + "item_router", +] \ No newline at end of file diff --git a/hw2/hw/shop_api/api/item/contracts.py b/hw2/hw/shop_api/api/item/contracts.py new file mode 100644 index 00000000..beb5094a --- /dev/null +++ b/hw2/hw/shop_api/api/item/contracts.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict +from typing import Optional + +from shop_api.store.models import ( + Item +) + + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + +class ItemMapper: + """Маппер для преобразования между ItemResponse и Item (ORM)""" + + @staticmethod + def to_domain(orm_item: Item) -> ItemResponse: + """Преобразование ORM модели в доменную""" + return ItemResponse( + id=orm_item.id, + name=orm_item.name, + price=orm_item.price, + deleted=orm_item.deleted + ) + + @staticmethod + def to_orm( + domain_item: ItemRequest, + orm_item: Optional[Item] = None, + ) -> Item: + """Преобразование доменной модели в ORM""" + if orm_item is None: + orm_item = Item() + + orm_item.name = domain_item.name + orm_item.price = domain_item.price + orm_item.deleted = domain_item.deleted + + return orm_item + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") + diff --git a/hw2/hw/shop_api/api/item/routes.py b/hw2/hw/shop_api/api/item/routes.py new file mode 100644 index 00000000..0d07434c --- /dev/null +++ b/hw2/hw/shop_api/api/item/routes.py @@ -0,0 +1,124 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat +from sqlalchemy.orm import Session + +from shop_api import store + +from .contracts import ( + ItemMapper, + ItemResponse, + ItemRequest, + ItemRequest, + PatchItemRequest +) + +item_router = APIRouter(prefix="/item") + + +@item_router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat | None, Query()] = None, + max_price: Annotated[NonNegativeFloat | None, Query()] = None, + show_deleted: Annotated[bool, Query()] = False, + db: Session = Depends(store.get_db) +) -> list[ItemResponse]: + return [ItemMapper.to_domain(orm_item) for orm_item in store.get_items(db, offset, limit, min_price, max_price, show_deleted)] + + +@item_router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id(id: int, db: Session = Depends(store.get_db)) -> ItemResponse: + orm_item = store.get_item(db, id) + + if not orm_item: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemMapper.to_domain(orm_item) + + +@item_router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_item(info: ItemRequest, response: Response, db: Session = Depends(store.get_db)) -> ItemResponse: + orm_item = store.add_item(db, ItemMapper.to_orm(info)) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/item/{orm_item.id}" + + return ItemMapper.to_domain(orm_item) + + +@item_router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item(id: int, info: PatchItemRequest, db: Session = Depends(store.get_db)) -> ItemResponse: + orm_item = store.patch_item(db, id, info.name, info.price) + + if orm_item is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemMapper.to_domain(orm_item) + + +@item_router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted pokemon", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify pokemon as one was not found", + }, + } +) +async def put_item( + id: int, + info: ItemRequest, + db: Session = Depends(store.get_db) +) -> ItemResponse: + orm_item = store.update_item(db, id, info.name, info.price, info.deleted) + + if orm_item is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemMapper.to_domain(orm_item) + + +@item_router.delete("/{id}") +async def delete_item(id: int, db: Session = Depends(store.get_db)) -> Response: + + store.delete_item(db, id) + + return Response("") \ No newline at end of file diff --git a/hw2/hw/shop_api/config/__init__.py b/hw2/hw/shop_api/config/__init__.py new file mode 100644 index 00000000..0e048282 --- /dev/null +++ b/hw2/hw/shop_api/config/__init__.py @@ -0,0 +1,10 @@ +import os + + +POSTGRES_USER = os.environ["POSTGRES_USER"] +POSTGRES_PASSWORD = os.environ["POSTGRES_PASSWORD"] +POSTGRES_HOST = os.environ["POSTGRES_HOST"] +POSTGRES_DB = os.environ["POSTGRES_DB"] + + +SQLALCHEMY_DATABASE_URL = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}/{POSTGRES_DB}" \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..078c4f47 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,16 @@ from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator + +from shop_api.api.item import item_router +from shop_api.api.cart import cart_router +from shop_api.store import models +from shop_api.store.database import engine + +models.Base.metadata.create_all(bind=engine) app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + + +app.include_router(item_router) +app.include_router(cart_router) \ No newline at end of file diff --git a/hw2/hw/shop_api/store/__init__.py b/hw2/hw/shop_api/store/__init__.py new file mode 100644 index 00000000..f3fa4cd1 --- /dev/null +++ b/hw2/hw/shop_api/store/__init__.py @@ -0,0 +1,20 @@ +from .models import Item, Cart, CartItem +from .queries import add_item, delete_item, get_item, get_items, update_item, patch_item, get_carts, get_cart, add_cart, add_item_to_cart +from .dependencies import get_db + +__all__ = [ + "Item", + "Cart", + "CartItem", + "add_item", + "delete_item", + "get_item", + "get_items", + "update_item", + "patch_item", + "get_carts", + "get_cart", + "add_cart", + "add_item_to_cart", + "get_db" +] diff --git a/hw2/hw/shop_api/store/database.py b/hw2/hw/shop_api/store/database.py new file mode 100644 index 00000000..8d56e7c6 --- /dev/null +++ b/hw2/hw/shop_api/store/database.py @@ -0,0 +1,12 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from shop_api.config import SQLALCHEMY_DATABASE_URL + + +engine = create_engine( + SQLALCHEMY_DATABASE_URL +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() \ No newline at end of file diff --git a/hw2/hw/shop_api/store/dependencies.py b/hw2/hw/shop_api/store/dependencies.py new file mode 100644 index 00000000..01213bf6 --- /dev/null +++ b/hw2/hw/shop_api/store/dependencies.py @@ -0,0 +1,10 @@ +from .database import SessionLocal + + +# Dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/hw2/hw/shop_api/store/models.py b/hw2/hw/shop_api/store/models.py new file mode 100644 index 00000000..c8971a5f --- /dev/null +++ b/hw2/hw/shop_api/store/models.py @@ -0,0 +1,55 @@ +from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Float, Boolean +from sqlalchemy.orm import relationship + +from .database import Base + + +class Item(Base): + __tablename__ = "items" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + + # Связь с элементами корзины + cart_items = relationship("CartItem", back_populates="item") + + +class Cart(Base): + """Модель корзины""" + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, autoincrement=True) + + # Связь с элементами корзины + items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan") + + @property + def price(self): + """Вычисление общей суммы корзины""" + return sum(cart_item.total_price for cart_item in self.items) + + # @property + # def quantity(self): + # """Вычисление общей суммы корзины""" + # return sum(cart_item.quantity for cart_item in self.items) + + + +class CartItem(Base): + """Промежуточная таблица для связи корзины и товаров с количеством""" + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True, autoincrement=True) + cart_id = Column(Integer, ForeignKey('carts.id'), nullable=False) + item_id = Column(Integer, ForeignKey('items.id'), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + + # Связи + cart = relationship("Cart", back_populates="items") + item = relationship("Item", back_populates="cart_items") + + @property + def total_price(self): + """Общая цена для данного товара в корзине (цена * количество)""" + return self.item.price * self.quantity diff --git a/hw2/hw/shop_api/store/queries.py b/hw2/hw/shop_api/store/queries.py new file mode 100644 index 00000000..908d0fa4 --- /dev/null +++ b/hw2/hw/shop_api/store/queries.py @@ -0,0 +1,180 @@ +from typing import Iterable +from sqlalchemy import func +from sqlalchemy.orm import Session + +from shop_api.store.models import ( + Item, + Cart, + CartItem +) + + +def add_item(db: Session, orm_item: Item) -> Item: + db.add(orm_item) + db.commit() + db.refresh(orm_item) + return orm_item + + +def delete_item(db: Session, id: int) -> None: + orm_item = get_item(db, id) + if orm_item != None: + orm_item.deleted = True + db.commit() + + +def get_item(db: Session, id: int) -> Item | None: + return db.query(Item).filter(Item.id == id).first() + + +def get_items( + db: Session, + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + show_deleted: bool = False + ) -> Iterable[Item]: + + # Начинаем построение запроса + query = db.query(Item) + + # Фильтр по минимальной цене + if min_price != None: + query = query.filter(Item.price >= min_price) + + # Фильтр по максимальной цене + if max_price != None: + query = query.filter(Item.price <= max_price) + + # Фильтр по удаленным товарам + if not show_deleted: + query = query.filter(Item.deleted == False) + # Если show_deleted=True, показываем все товары включая удаленные + + # Применяем пагинацию + items = query.offset(offset).limit(limit).all() + + return items + + +def update_item(db: Session, id: int, name: str, price: float, deleted: bool) -> Item | None: + orm_item = get_item(db, id) + + if orm_item == None: + return None + + orm_item.name = name + orm_item.price = price + orm_item.deleted = deleted + + db.commit() + db.refresh(orm_item) + + return orm_item + + +# def upsert_item(id: int, info: ItemInfo) -> ItemEntity: +# _items_data[id] = info + +# return ItemEntity(id=id, info=info) + + +def patch_item(db: Session, id: int, name: str | None, price: float | None) -> Item | None: + orm_item = get_item(db, id) + + if orm_item == None: + return None + + if name is not None: + orm_item.name = name + + if price is not None: + orm_item.price = price + + db.commit() + db.refresh(orm_item) + return orm_item + + +def get_carts( + db: Session, + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + min_quantity: int | None = None, + max_quantity: int | None = None + ) -> Iterable[Cart]: + + query = db.query(Cart) + + # Создаем подзапросы для цены и количества + cart_stats_subquery = ( + db.query( + CartItem.cart_id, + func.sum(CartItem.quantity * Item.price).label('total_price'), + func.sum(CartItem.quantity).label('total_quantity') + ) + .join(Item, CartItem.item_id == Item.id) + .group_by(CartItem.cart_id) + .subquery() + ) + + # Присоединяем подзапрос к основному запросу + query = query.join(cart_stats_subquery, Cart.id == cart_stats_subquery.c.cart_id) + + # Фильтрация по цене + if min_price is not None: + query = query.filter(cart_stats_subquery.c.total_price >= min_price) + + if max_price is not None: + query = query.filter(cart_stats_subquery.c.total_price <= max_price) + + # Фильтрация по количеству + if min_quantity is not None: + query = query.filter(cart_stats_subquery.c.total_quantity >= min_quantity) + + if max_quantity is not None: + query = query.filter(cart_stats_subquery.c.total_quantity <= max_quantity) + + # Применяем пагинацию + carts = query.order_by(Cart.id).offset(offset).limit(limit).all() + + return carts + + + +def get_cart(db: Session, id: int) -> Cart | None: + return db.query(Cart).filter(Cart.id == id).first() + + +def add_cart(db: Session) -> Cart: + orm_cart = Cart() + db.add(orm_cart) + db.commit() + db.refresh(orm_cart) + return orm_cart + + +def add_item_to_cart(db: Session, cart: Cart, item: Item) -> Cart: + + # Проверяем, есть ли уже этот товар в корзине + existing_cart_item = db.query(CartItem).filter( + CartItem.cart_id == cart.id, + CartItem.item_id == item.id + ).first() + + if existing_cart_item: + # Если товар уже есть - увеличиваем количество + existing_cart_item.quantity += 1 + cart_item = existing_cart_item + else: + # Если товара нет - создаем новую запись + cart_item = CartItem(cart_id=cart.id, item_id=item.id, quantity=1) + db.add(cart_item) + + db.commit() + db.refresh(cart) + + return cart \ No newline at end of file diff --git a/hw2/hw/shop_api/tests/__init__.py b/hw2/hw/shop_api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/tests/conftest.py b/hw2/hw/shop_api/tests/conftest.py new file mode 100644 index 00000000..65c0800e --- /dev/null +++ b/hw2/hw/shop_api/tests/conftest.py @@ -0,0 +1,12 @@ +import pytest + + + +@pytest.fixture +def sample_item_data(): + return { + "name": "Test Item", + "price": 100.0, + "deleted": False + } + diff --git a/hw2/hw/shop_api/tests/test_carts.py b/hw2/hw/shop_api/tests/test_carts.py new file mode 100644 index 00000000..1c61082d --- /dev/null +++ b/hw2/hw/shop_api/tests/test_carts.py @@ -0,0 +1,82 @@ +from fastapi.testclient import TestClient +from shop_api.main import app + +client = TestClient(app) + + +def test_cart_root(): + response = client.get("/cart/?min_price=100&max_price=200&min_quantity=1&max_quantity=2") + assert response.status_code == 200 + data = response.json() + assert data == [] + + +def test_create_cart(): + response = client.post("/cart/") + assert response.status_code == 201 + data = response.json() + assert data["id"] is not None + assert data["items"] == [] + assert data["price"] == 0.0 + + +def test_get_cart_by_id(): + cart_response = client.post("/cart/") + assert cart_response.status_code == 201 + cart_id = cart_response.json()["id"] + + # Проверяем корзину + cart_response = client.get(f"/cart/{cart_id}") + cart_data = cart_response.json() + assert cart_response.status_code == 200 + + # Запрос несуществующей корзины + cart_response = client.get("/cart/99999999") + cart_data = cart_response.json() + assert cart_response.status_code == 404 + + +def test_add_item_to_cart(sample_item_data): + # Создаем товар + item_response = client.post("/item/", json=sample_item_data) + item_id = item_response.json()["id"] + + # Создаем корзину + cart_response = client.post("/cart/") + cart_id = cart_response.json()["id"] + + # Добавляем товар в корзину + add_response = client.post(f"/cart/{cart_id}/add/{item_id}") + assert add_response.status_code == 201 + + # Проверяем корзину + cart_response = client.get(f"/cart/{cart_id}") + cart_data = cart_response.json() + + assert len(cart_data["items"]) == 1 + assert cart_data["items"][0]["id"] == item_id + assert cart_data["items"][0]["quantity"] == 1 + assert cart_data["price"] == sample_item_data["price"] + + # Добавляем тот же товар в ту же корзину + add_response = client.post(f"/cart/{cart_id}/add/{item_id}") + assert add_response.status_code == 201 + + # Проверяем корзину + cart_response = client.get(f"/cart/{cart_id}") + cart_data = cart_response.json() + + assert len(cart_data["items"]) == 1 + assert cart_data["items"][0]["id"] == item_id + assert cart_data["items"][0]["quantity"] == 2 + assert cart_data["price"] == sample_item_data["price"] * 2 + + # Запрос несуществующей корзины + cart_response = client.post(f"/cart/9999999999/add/{item_id}") + assert cart_response.status_code == 404 + + + # Запрос несуществующего товара + cart_response = client.post(f"/cart/{cart_id}/add/999999999999") + assert cart_response.status_code == 404 + diff --git a/hw2/hw/shop_api/tests/test_items.py b/hw2/hw/shop_api/tests/test_items.py new file mode 100644 index 00000000..a4d3bfc8 --- /dev/null +++ b/hw2/hw/shop_api/tests/test_items.py @@ -0,0 +1,106 @@ +from fastapi.testclient import TestClient +from shop_api.main import app + +client = TestClient(app) + +def test_item_root(): + response = client.get("/item/") + assert response.status_code == 200 + + +def test_create_item(sample_item_data): + response = client.post("/item/", json=sample_item_data) + assert response.status_code == 201 + data = response.json() + assert data["name"] == sample_item_data["name"] + assert data["price"] == sample_item_data["price"] + assert data["deleted"] is False + assert "id" in data + + +def test_get_item(sample_item_data): + # Сначала создаем товар + create_response = client.post("/item/", json=sample_item_data) + item_id = create_response.json()["id"] + + # Затем получаем его + response = client.get(f"/item/{item_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == item_id + assert data["name"] == sample_item_data["name"] + + # Получаем несуществующий товар + response = client.get("/item/99999999") + assert response.status_code == 404 + + +def test_get_items_with_filters(): + # Создаем несколько товаров + client.post("/item/", json={"name": "Item 1", "price": 50.0, "deleted": False}) + client.post("/item/", json={"name": "Item 2", "price": 150.0, "deleted": False}) + client.post("/item/", json={"name": "Item 3", "price": 200.0, "deleted": False}) + + # Тестируем фильтрацию по цене + response = client.get("/item/?min_price=100&max_price=200") + assert response.status_code == 200 + data = response.json() + assert all(100 <= item["price"] <= 200 for item in data) + + +def test_update_item(sample_item_data): + # Создаем товар + create_response = client.post("/item/", json=sample_item_data) + item_id = create_response.json()["id"] + + # Обновляем товар + update_data = {"name": "Updated Item", "price": 200.0, "deleted": False} + response = client.put(f"/item/{item_id}", json=update_data) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Updated Item" + assert data["price"] == 200.0 + + # Обновляем несуществующий товар + response = client.put("/item/99999999", json=update_data) + assert response.status_code == 304 + + +def test_patch_item(sample_item_data): + # Создаем товар + create_response = client.post("/item/", json=sample_item_data) + item_id = create_response.json()["id"] + + # Обновляем товар + patch_data = {"name": "Patched Item"} + response = client.patch(f"/item/{item_id}", json=patch_data) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Patched Item" + assert data["price"] == 100.0 + + # Обновляем цену + patch_data = {"price": 1.1} + response = client.patch(f"/item/{item_id}", json=patch_data) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Patched Item" + assert data["price"] == 1.1 + + # Обновляем несуществующий товар + response = client.patch("/item/99999999", json=patch_data) + assert response.status_code == 304 + + +def test_delete_item(sample_item_data): + # Создаем товар + create_response = client.post("/item/", json=sample_item_data) + item_id = create_response.json()["id"] + + # Удаляем товар (мягкое удаление) + response = client.delete(f"/item/{item_id}") + assert response.status_code == 200 + + # Проверяем, что товар помечен как удаленный + get_response = client.get(f"/item/{item_id}") + assert get_response.json()["deleted"] is True \ No newline at end of file