diff --git a/hw1/app.py b/hw1/app.py index 6107b870..985afb47 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,7 @@ from typing import Any, Awaitable, Callable - +from urllib.parse import parse_qs +import math +import json async def application( scope: dict[str, Any], @@ -12,7 +14,152 @@ 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 + return + + if scope["type"] == "http": + path = scope['path'] + if path == '/factorial': + query_string = scope["query_string"].decode("utf-8") + params = parse_qs(query_string) + if "n" not in params or not params["n"][0]: + await send_error(send, 422, "Missing or empty parameter n") + return + try: + n = int(params["n"][0]) + except (ValueError, TypeError): + await send_error(send, 422, "n must be an integer") + return + if n < 0: + await send_error(send, 400, "n must be non-negative") + return + + result = math.factorial(int(params["n"][0])) + response = json.dumps({"result": result}) + + await send({ + "type": "http.response.start", + "status": 200, + "headers": [ + [b"content-type", b"application/json"] + ], + }) + await send({ + "type": "http.response.body", + "body": response.encode("utf-8"), + }) + + elif path == '/mean': + body = b"" + while True: + message = await receive() + body += message.get("body", b"") + if not message.get("more_body", False): + break + + if not body: + await send_error(send, 422, "No JSON given") + return + try: + text = body.decode("utf-8") + data = json.loads(text) + except Exception: + await send_error(send, 422, "Invalid JSON") + return + + + if data is None: + await send_error(send, 422, "No JSON given") + return + if not isinstance(data, list): + await send_error(send, 400, "Data must be a list") + return + if len(data) == 0: + await send_error(send, 400, "Empty list") + return + elif not all(isinstance(x, (int, float)) for x in data): + await send_error(send, 400, "All elements must be numbers") + return + else: + result = sum(data)/len(data) + response = json.dumps({"result": result}) + + await send({ + "type": "http.response.start", + "status": 200, + "headers": [ + [b"content-type", b"application/json"] + ], + }) + await send({ + "type": "http.response.body", + "body": response.encode("utf-8"), + }) + + + elif path.startswith('/fibonacci/'): + parts = path.split('/') + if len(parts) != 3 or not parts[2]: + await send_error(send, 422, "Invalid path parameter") + return + n_str = parts[2] + try: + n = int(n_str) + except ValueError: + await send_error(send, 422, "n must be an integer") + return + if n < 0: + await send_error(send, 400, "n must be non-negative") + return + + def fib(n): + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a + + result = fib(n) + response = json.dumps({"result": result}) + + await send({ + "type": "http.response.start", + "status": 200, + "headers": [ + [b"content-type", b"application/json"] + ], + }) + await send({ + "type": "http.response.body", + "body": response.encode("utf-8"), + }) + return + + else: + await send_error(send, 404, "There's no endpoint like that") + return + +async def send_error(send, status: int, message: str = ""): + await send({ + "type": "http.response.start", + "status": status, + "headers": [ + [b"content-type", b"text/plain; charset=utf-8"] + ], + }) + await send({ + "type": "http.response.body", + "body": message.encode("utf-8"), + }) + + + if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..ad85aaea 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,190 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Query, HTTPException, Response +from pydantic import BaseModel +from typing import Optional, List app = FastAPI(title="Shop API") + +items_memory = {} + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool + +class ItemCreate(BaseModel): + name: str + price: float + +class ItemPatch(BaseModel): + name: Optional[str] = None + price: Optional[float] = None + + class Config: + extra = "forbid" + +class ItemPut(BaseModel): + name: str + price: float + deleted: Optional[bool] = None + +item_counter = 0 + +@app.post('/item', status_code=201) +def create_item(item: ItemCreate): + global item_counter + new_item = Item(id=item_counter, name=item.name, price=item.price, deleted=False) + items_memory[item_counter] = new_item + item_counter += 1 + return new_item + +@app.get('/item/{item_id}') +def get_item(item_id:int): + if item_id not in items_memory: + raise HTTPException(status_code=404, detail="Item not found") + if items_memory[item_id].deleted: + raise HTTPException(status_code=404, detail="Item not found") + return items_memory[item_id] + +@app.get('/item') +def get_item_list( + limit: Optional[int] = Query(default=10, ge=1), + offset: Optional[int] = Query(default=0, ge=0), + min_price: Optional[float] = Query(default=None, ge = 0), + max_price: Optional[float] = Query(default=None, ge = 0), + show_deleted: Optional[bool] = False +): + all_items = list(items_memory.values()) + if not show_deleted: + all_items = [v for v in all_items if not v.deleted] + if min_price is not None: + all_items = [v for v in all_items if v.price >= min_price] + if max_price is not None: + all_items = [v for v in all_items if v.price <= max_price] + return all_items[offset:offset+limit] + + +@app.put('/item/{item_id}') +def put_item(item_id: int, item: ItemPut): + if item_id not in items_memory: + raise HTTPException(status_code=404, detail="Item not found") + if item.deleted is not None: + items_memory[item_id] = Item(id=item_id, name=item.name, price=item.price, deleted=item.deleted) + if item.deleted is None: + items_memory[item_id] = Item(id=item_id, name=item.name, price=item.price, deleted=items_memory[item_id].deleted) + return items_memory[item_id] + +@app.patch('/item/{item_id}') +def patch_item(item_id: int, item: ItemPatch): + if item_id not in items_memory: + raise HTTPException(status_code=404, detail="Item not found") + # Проверяем, не удалён ли товар + if items_memory[item_id].deleted: + raise HTTPException(status_code=304, detail="Item is deleted") + + if item.price is not None: + items_memory[item_id].price = item.price + if item.name is not None: + items_memory[item_id].name = item.name + return items_memory[item_id] + +@app.delete('/item/{item_id}') +def delete_item(item_id: int): + if item_id not in items_memory: + raise HTTPException(status_code=404, detail="Item not found") + items_memory[item_id].deleted = True + return + + +# - `PUT /item/{id}` - замена товара по `id` (создание запрещено, только замена существующего) +# - `PATCH /item/{id}` - частичное обновление товара по `id` (разрешено менять все поля, кроме `deleted`) +# - `DELETE /item/{id}` - удаление товара по `id` (товар помечается как удаленный) + +# --- Cart implementation --- + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[CartItem] = [] + price: float = 0.0 + +carts_memory = {} +cart_counter = 0 + +@app.post('/cart', status_code=201) +def create_cart(response: Response): + global cart_counter + cart = Cart(id=cart_counter, items=[], price=0.0) + carts_memory[cart_counter] = cart + cart_counter += 1 + response.headers["location"] = f"/cart/{cart.id}" + return cart + +@app.get('/cart/{cart_id}') +def get_cart(cart_id: int): + if cart_id not in carts_memory: + raise HTTPException(status_code=404, detail="Cart not found") + return carts_memory[cart_id] + +@app.get('/cart') +def get_cart_list( + limit: Optional[int] = Query(default=10, ge=1), + offset: Optional[int] = Query(default=0, ge=0), + min_price: Optional[float] = Query(default=None, ge=0), + max_price: Optional[float] = Query(default=None, ge=0), + min_quantity: Optional[int] = Query(default=None, ge=0), + max_quantity: Optional[int] = Query(default=None, ge=0) +): + all_carts = list(carts_memory.values()) + + if min_price is not None: + all_carts = [cart for cart in all_carts if cart.price >= min_price] + if max_price is not None: + all_carts = [cart for cart in all_carts if cart.price <= max_price] + + if min_quantity is not None: + all_carts = [cart for cart in all_carts if sum(item.quantity for item in cart.items) >= min_quantity] + if max_quantity is not None: + all_carts = [cart for cart in all_carts if sum(item.quantity for item in cart.items) <= max_quantity] + + return all_carts[offset:offset+limit] + +@app.post('/cart/{cart_id}/add/{item_id}') +def add_item_to_cart(cart_id: int, item_id: int): + if cart_id not in carts_memory: + raise HTTPException(status_code=404, detail="Cart not found") + if item_id not in items_memory: + raise HTTPException(status_code=404, detail="Item not found") + if items_memory[item_id].deleted: + raise HTTPException(status_code=404, detail="Item not found") + + cart = carts_memory[cart_id] + item = items_memory[item_id] + + existing_item = None + for cart_item in cart.items: + if cart_item.id == item_id: + existing_item = cart_item + break + + if existing_item: + existing_item.quantity += 1 + else: + cart_item = CartItem( + id=item_id, + name=item.name, + quantity=1, + available=not item.deleted + ) + cart.items.append(cart_item) + + total_price = sum(items_memory[cart_item.id].price * cart_item.quantity for cart_item in cart.items if cart_item.id in items_memory and not items_memory[cart_item.id].deleted) + cart.price = total_price + + return cart + diff --git a/lecture3/Dockerfile b/lecture3/Dockerfile deleted file mode 100644 index 1eaf1db1..00000000 --- a/lecture3/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index aad28c54..00000000 --- a/lecture3/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# ДЗ - -## Настроить сборку образов 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 deleted file mode 100644 index fdc10f76..00000000 --- a/lecture3/ddoser.py +++ /dev/null @@ -1,44 +0,0 @@ -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/api.py b/lecture3/demo_service/api.py deleted file mode 100644 index 7c6bce40..00000000 --- a/lecture3/demo_service/api.py +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 72b2ce89..00000000 --- a/lecture3/demo_service/contracts.py +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index a88a7cfb..00000000 --- a/lecture3/demo_service/store.py +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 91b5555c..00000000 --- a/lecture3/docker-compose.yml +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index bb6b4134..00000000 --- a/lecture3/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 6bdf88e7..00000000 --- a/lecture3/settings/prometheus/prometheus.yml +++ /dev/null @@ -1,10 +0,0 @@ -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/lecture4/1_raw_asyncpg/main.py b/lecture4/1_raw_asyncpg/main.py deleted file mode 100644 index 35439b2d..00000000 --- a/lecture4/1_raw_asyncpg/main.py +++ /dev/null @@ -1,61 +0,0 @@ -import asyncpg -from typing import Optional, List - - -class UserRepository: - """Простой репозиторий для работы с пользователями через asyncpg""" - - def __init__(self, connection_string: str): - self.connection_string = connection_string - self.pool: Optional[asyncpg.Pool] = None - - async def initialize(self): - """Инициализация пула соединений""" - self.pool = await asyncpg.create_pool(self.connection_string, min_size=2, max_size=10) - - async def close(self): - """Закрытие пула""" - if self.pool: - await self.pool.close() - - async def create_user(self, email: str, name: str, age: int) -> int: - """Создание нового пользователя""" - async with self.pool.acquire() as connection: - row = await connection.fetchrow( - "INSERT INTO users (email, name, age) VALUES ($1, $2, $3) RETURNING id", - email, name, age - ) - return row['id'] - - async def get_user_by_id(self, user_id: int) -> Optional[dict]: - """Получение пользователя по ID""" - async with self.pool.acquire() as connection: - row = await connection.fetchrow( - "SELECT id, email, name, age, created_at FROM users WHERE id = $1", - user_id - ) - return dict(row) if row else None - - async def update_user_age(self, user_id: int, new_age: int) -> bool: - """Обновление возраста пользователя""" - async with self.pool.acquire() as connection: - result = await connection.execute( - "UPDATE users SET age = $1 WHERE id = $2", - new_age, user_id - ) - return result.split()[-1] == '1' - - async def get_users_with_orders(self) -> List[dict]: - """Получение пользователей с количеством их заказов (JOIN запрос)""" - async with self.pool.acquire() as connection: - rows = await connection.fetch(""" - SELECT - u.id, u.name, u.email, - COUNT(o.id) as order_count, - COALESCE(SUM(o.total_price), 0) as total_spent - FROM users u - LEFT JOIN orders o ON u.id = o.user_id - GROUP BY u.id, u.name, u.email - ORDER BY total_spent DESC - """) - return [dict(row) for row in rows] diff --git a/lecture4/1_raw_asyncpg/requirements.txt b/lecture4/1_raw_asyncpg/requirements.txt deleted file mode 100644 index 9d47fee1..00000000 --- a/lecture4/1_raw_asyncpg/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -asyncpg==0.29.0 -python-dotenv==1.0.0 diff --git a/lecture4/2_active_record/main.py b/lecture4/2_active_record/main.py deleted file mode 100644 index 6619be91..00000000 --- a/lecture4/2_active_record/main.py +++ /dev/null @@ -1,74 +0,0 @@ -from typing import List, Optional -from datetime import datetime -from sqlmodel import SQLModel, Field, Session, select - - -# === ActiveRecord модели === - -class User(SQLModel, table=True): - __tablename__ = "users" - - id: Optional[int] = Field(default=None, primary_key=True) - email: str = Field(unique=True, index=True, max_length=255) - name: str = Field(max_length=255) - age: int = Field(ge=0) - created_at: Optional[datetime] = Field(default_factory=datetime.utcnow) - updated_at: Optional[datetime] = Field(default_factory=datetime.utcnow) - - # === ActiveRecord методы === - - @classmethod - def create(cls, session: Session, email: str, name: str, age: int) -> "User": - """Создание нового пользователя""" - user = cls(email=email, name=name, age=age) - session.add(user) - session.commit() - session.refresh(user) - return user - - @classmethod - def find_by_id(cls, session: Session, user_id: int) -> Optional["User"]: - """Поиск пользователя по ID""" - return session.get(cls, user_id) - - @classmethod - def find_by_email(cls, session: Session, email: str) -> Optional["User"]: - """Поиск пользователя по email""" - statement = select(cls).where(cls.email == email) - return session.exec(statement).first() - - @classmethod - def get_all_with_stats(cls, session: Session) -> List[dict]: - """Получение всех пользователей со статистикой заказов""" - statement = select(cls).order_by(cls.created_at) - users = session.exec(statement).all() - - result = [] - for user in users: - result.append({ - "id": user.id, - "name": user.name, - "email": user.email, - "age": user.age, - "order_count": 0 - }) - return result - - def update_age(self, session: Session, new_age: int) -> "User": - """Обновление возраста пользователя""" - self.age = new_age - self.updated_at = datetime.utcnow() - session.add(self) - session.commit() - session.refresh(self) - return self - - def to_dict(self) -> dict: - """Преобразование в словарь для вывода""" - return { - "id": self.id, - "email": self.email, - "name": self.name, - "age": self.age, - "created_at": self.created_at - } diff --git a/lecture4/2_active_record/requirements.txt b/lecture4/2_active_record/requirements.txt deleted file mode 100644 index 0265b8a7..00000000 --- a/lecture4/2_active_record/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -sqlmodel==0.0.14 -psycopg2-binary==2.9.9 -python-dotenv==1.0.0 diff --git a/lecture4/3_data_mapper_sqlalchemy/main.py b/lecture4/3_data_mapper_sqlalchemy/main.py deleted file mode 100644 index f3b99486..00000000 --- a/lecture4/3_data_mapper_sqlalchemy/main.py +++ /dev/null @@ -1,156 +0,0 @@ -from typing import List, Optional -from dataclasses import dataclass -from abc import ABC, abstractmethod - -from sqlalchemy import Column, Integer, String, DateTime -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import Session -from sqlalchemy.sql import func - - -# === Доменные модели (без привязки к БД) === - -@dataclass -class User: - """Доменная модель пользователя""" - id: Optional[int] = None - email: str = "" - name: str = "" - age: int = 0 - - -# === SQLAlchemy модели (для мапинга с БД) === - -Base = declarative_base() - - -class UserOrm(Base): - __tablename__ = 'users' - - id = Column(Integer, primary_key=True) - email = Column(String(255), unique=True, nullable=False) - name = Column(String(255), nullable=False) - age = Column(Integer, nullable=False) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - -# === Мапперы (преобразование между доменными моделями и ORM) === - -class UserMapper: - """Маппер для преобразования между User и UserOrm""" - - @staticmethod - def to_domain(orm_user: UserOrm) -> User: - """Преобразование ORM модели в доменную""" - return User( - id=orm_user.id, - email=orm_user.email, - name=orm_user.name, - age=orm_user.age - ) - - @staticmethod - def to_orm( - domain_user: User, - orm_user: Optional[UserOrm] = None, - ) -> UserOrm: - """Преобразование доменной модели в ORM""" - if orm_user is None: - orm_user = UserOrm() - - orm_user.email = domain_user.email - orm_user.name = domain_user.name - orm_user.age = domain_user.age - - return orm_user - - -# === Абстрактные интерфейсы репозиториев === - -class UserRepositoryInterface(ABC): - """Интерфейс репозитория пользователей""" - - @abstractmethod - def create(self, user: User) -> User: - pass - - @abstractmethod - def find_by_id(self, user_id: int) -> Optional[User]: - pass - - @abstractmethod - def find_by_email(self, email: str) -> Optional[User]: - pass - - @abstractmethod - def get_all(self) -> List[User]: - pass - - @abstractmethod - def update(self, user: User) -> User: - pass - - -# === Конкретные реализации репозиториев === - -class SqlAlchemyUserRepository(UserRepositoryInterface): - """SQLAlchemy реализация репозитория пользователей""" - - def __init__(self, session: Session): - self.session = session - - def create(self, user: User) -> User: - orm_user = UserMapper.to_orm(user) - self.session.add(orm_user) - self.session.flush() # Получаем ID без коммита - return UserMapper.to_domain(orm_user) - - def find_by_id(self, user_id: int) -> Optional[User]: - orm_user = self.session.query(UserOrm).filter_by(id=user_id).first() - return UserMapper.to_domain(orm_user) if orm_user else None - - def find_by_email(self, email: str) -> Optional[User]: - orm_user = self.session.query(UserOrm).filter_by(email=email).first() - return UserMapper.to_domain(orm_user) if orm_user else None - - def get_all(self) -> List[User]: - orm_users = self.session.query(UserOrm).order_by(UserOrm.created_at).all() - return [UserMapper.to_domain(orm_user) for orm_user in orm_users] - - def update(self, user: User) -> User: - orm_user = self.session.query(UserOrm).filter_by(id=user.id).first() - if not orm_user: - raise ValueError(f"User with id {user.id} not found") - - UserMapper.to_orm(user, orm_user) - self.session.flush() - return UserMapper.to_domain(orm_user) - - -# === Сервисы для бизнес-логики === - -class UserService: - """Сервис для работы с пользователями""" - - def __init__(self, user_repo: UserRepositoryInterface): - self.user_repo = user_repo - - def create_user(self, email: str, name: str, age: int) -> User: - """Создание нового пользователя с валидацией""" - existing_user = self.user_repo.find_by_email(email) - if existing_user: - raise ValueError(f"User with email {email} already exists") - - if age < 0: - raise ValueError("Age cannot be negative") - - user = User(email=email, name=name, age=age) - return self.user_repo.create(user) - - def get_user_with_validation(self, user_id: int) -> User: - """Получение пользователя с проверкой существования""" - user = self.user_repo.find_by_id(user_id) - if not user: - raise ValueError(f"User with id {user_id} not found") - return user diff --git a/lecture4/3_data_mapper_sqlalchemy/requirements.txt b/lecture4/3_data_mapper_sqlalchemy/requirements.txt deleted file mode 100644 index e00142d2..00000000 --- a/lecture4/3_data_mapper_sqlalchemy/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -sqlalchemy==2.0.25 -psycopg2-binary==2.9.9 -python-dotenv==1.0.0 diff --git a/lecture4/4_edgedb/.gitignore b/lecture4/4_edgedb/.gitignore deleted file mode 100644 index 438992eb..00000000 --- a/lecture4/4_edgedb/.gitignore +++ /dev/null @@ -1,112 +0,0 @@ -# Сгенерированные EdgeDB файлы -generated/ -*.egg-info/ - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/lecture4/4_edgedb/README.md b/lecture4/4_edgedb/README.md deleted file mode 100644 index 06796455..00000000 --- a/lecture4/4_edgedb/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Пример работы с EdgeDB - -Этот пример демонстрирует работу с **EdgeDB** (Gel) - -## Что такое EdgeDB? - -EdgeDB (Теперь называется Gel) - это база данных, построенная поверх PostgreSQL, которая предоставляет: - -- **EdgeQL** - мощный язык запросов, похожий на GraphQL -- **Строгую типизацию** - схема определяется в `.gel` файлах -- **Автоматическую генерацию кода** - Python типы из схемы -- **Встроенные миграции** - автоматическое управление схемой -- **Объектно-ориентированные запросы** - работа с объектами, а не таблицами - -## Установка и настройка: - -### 1. Установка EdgeDB CLI -```bash -# macOS -curl --proto '=https' --tlsv1.2 -sSf https://sh.edgedb.com | sh - -# Ubuntu/Debian -curl https://packages.edgedb.com/keys/edgedb.asc | sudo apt-key add - -echo "deb https://packages.edgedb.com/apt $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/edgedb.list -sudo apt update && sudo apt install edgedb-cli -``` - -### 2. Инициализация проекта -```bash -# Переход в папку с EdgeDB -cd 4_edgedb - -# Инициализация проекта EdgeDB -edgedb project init - -# Создание и применение миграций -edgedb migration create -edgedb migrate - -# Установка Python зависимостей -pip install -r requirements.txt -``` - -### 3. Генерация типизированного Python кода -```bash -# Генерация асинхронных функций из queries/*.edgeql файлов -edgedb-py --target async --dir queries --out-dir generated - -# Альтернативно - синхронные функции -edgedb-py --target sync --dir queries --out-dir generated - -# Генерация в конкретный файл (все запросы в одном файле) -edgedb-py --target async --dir queries --file generated_queries.py -``` - -Вам сгенерируются Python функции, которые вы сможете вызывать из вашего кода. Они выполнят ваш запрос и вернут сразу Dataclass с результатом запроса, что довольно удобно. - - -### Основные команды: - -```bash -# Генерация асинхронных функций (рекомендуется) -edgedb-py --target async --dir queries --out-dir generated - -# Генерация синхронных функций -edgedb-py --target sync --dir queries --out-dir generated - -# Генерация в один файл -edgedb-py --target async --dir queries --file all_queries.py - -# Генерация с опциями -edgedb-py --target async \ - --dir queries \ - --out-dir generated \ - --no-skip-pyi-files # Создавать .pyi файлы для type hints -``` diff --git a/lecture4/4_edgedb/dbschema/default.gel b/lecture4/4_edgedb/dbschema/default.gel deleted file mode 100644 index 7a76af78..00000000 --- a/lecture4/4_edgedb/dbschema/default.gel +++ /dev/null @@ -1,55 +0,0 @@ -# Схема базы данных EdgeDB - -# Тип для пользователей -type default::User { - required email: str { - constraint exclusive; - }; - required name: str; - required age: int32 { - constraint min_value(0); - }; - created_at: datetime { - default := datetime_current(); - }; - - # Обратная связь с заказами - multi orders := .$product_id diff --git a/lecture4/4_edgedb/queries/create_order.edgeql b/lecture4/4_edgedb/queries/create_order.edgeql deleted file mode 100644 index 6a42ae02..00000000 --- a/lecture4/4_edgedb/queries/create_order.edgeql +++ /dev/null @@ -1,11 +0,0 @@ -# Создание заказа с автоматическим вычислением стоимости -WITH - user := (SELECT User FILTER .id = $user_id), - product := (SELECT Product FILTER .id = $product_id) -INSERT Order { - user := user, - product := product, - quantity := $quantity, - total_price := product.price * $quantity, - status := 'pending' -} diff --git a/lecture4/4_edgedb/queries/create_product.edgeql b/lecture4/4_edgedb/queries/create_product.edgeql deleted file mode 100644 index f1304e04..00000000 --- a/lecture4/4_edgedb/queries/create_product.edgeql +++ /dev/null @@ -1,7 +0,0 @@ -# Создание продукта -INSERT Product { - name := $name, - price := $price, - description := $description, - in_stock := $in_stock -} diff --git a/lecture4/4_edgedb/queries/create_user.edgeql b/lecture4/4_edgedb/queries/create_user.edgeql deleted file mode 100644 index 50b23aeb..00000000 --- a/lecture4/4_edgedb/queries/create_user.edgeql +++ /dev/null @@ -1,6 +0,0 @@ -# Создание пользователя -INSERT User { - email := $email, - name := $name, - age := $age -} diff --git a/lecture4/4_edgedb/queries/delete_user.edgeql b/lecture4/4_edgedb/queries/delete_user.edgeql deleted file mode 100644 index 884e4d98..00000000 --- a/lecture4/4_edgedb/queries/delete_user.edgeql +++ /dev/null @@ -1,3 +0,0 @@ -# Удаление пользователя -DELETE User -FILTER .id = $user_id diff --git a/lecture4/4_edgedb/requirements.txt b/lecture4/4_edgedb/requirements.txt deleted file mode 100644 index 41b198cd..00000000 --- a/lecture4/4_edgedb/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -edgedb==2.1.0 -python-dotenv==1.0.0 diff --git a/lecture4/README.md b/lecture4/README.md deleted file mode 100644 index 26822ef7..00000000 --- a/lecture4/README.md +++ /dev/null @@ -1,15 +0,0 @@ -## ДЗ - -За каждый пункт - 1 балл - -Внедрить во вторую домашку хранение данных в БД, для этого надо: -1) Добавить БД в docket-compose.yml (если БД - это отдельный сервис, если хотите использовать sqlite, то можно скипнуть этот шаг) -2) Переписать код на взаимодействие с вашей БД (если вы еще этого не сделали, если вы уже написали код с БД, подзравляю, вам остался только 3 пункт) -3) В свободной форме, напишите скрипты, которые просимулируют разные "проблемы" которые могут возникнуть в транзакциях (dirty read, not-repeatable read, serialize) и настраивая уровне изоляции покажите, что они действительно решаются (через SQLAlchemy например), то есть: -показать dirty read при read uncommited -показать что нет dirty read при read commited -показать non-repeatable read при read commited -показать что нет non-repeatable read при repeatable read -показать phantom reads при repeatable read -показать что нет phantom reads при serializable -*Тут зависит от того какую БД вы выбрали, разные БД могут поддерживать разные уровни изоляции \ No newline at end of file diff --git a/lecture4/docker-compose.yml b/lecture4/docker-compose.yml deleted file mode 100644 index 24a07eee..00000000 --- a/lecture4/docker-compose.yml +++ /dev/null @@ -1,34 +0,0 @@ -version: '3.8' - -services: - 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 - - ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s - timeout: 5s - retries: 5 - - edgedb: - image: edgedb/edgedb:5 - container_name: hw4_edgedb - environment: - EDGEDB_SERVER_SECURITY: insecure_dev_mode - ports: - - "5656:5656" - volumes: - - edgedb_data:/var/lib/edgedb/data - -volumes: - postgres_data: - edgedb_data: diff --git a/lecture4/migrations/init.sql b/lecture4/migrations/init.sql deleted file mode 100644 index 88d07db8..00000000 --- a/lecture4/migrations/init.sql +++ /dev/null @@ -1,79 +0,0 @@ --- Создание схемы базы данных для примеров -DROP TABLE IF EXISTS orders CASCADE; -DROP TABLE IF EXISTS products CASCADE; -DROP TABLE IF EXISTS users CASCADE; - --- Таблица пользователей -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - email VARCHAR(255) UNIQUE NOT NULL, - name VARCHAR(255) NOT NULL, - age INTEGER CHECK (age >= 0), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Таблица продуктов -CREATE TABLE products ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - price DECIMAL(10, 2) NOT NULL CHECK (price >= 0), - description TEXT, - in_stock BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Таблица заказов -CREATE TABLE orders ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE, - quantity INTEGER NOT NULL CHECK (quantity > 0), - total_price DECIMAL(10, 2) NOT NULL CHECK (total_price >= 0), - status VARCHAR(50) DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled')), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Индексы для оптимизации запросов -CREATE INDEX idx_users_email ON users(email); -CREATE INDEX idx_orders_user_id ON orders(user_id); -CREATE INDEX idx_orders_product_id ON orders(product_id); -CREATE INDEX idx_orders_status ON orders(status); - --- Триггер для автоматического обновления updated_at -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_products_updated_at BEFORE UPDATE ON products - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- Вставка тестовых данных -INSERT INTO users (email, name, age) VALUES - ('alice@example.com', 'Alice Johnson', 28), - ('bob@example.com', 'Bob Smith', 35), - ('charlie@example.com', 'Charlie Brown', 42); - -INSERT INTO products (name, price, description, in_stock) VALUES - ('Laptop', 999.99, 'High-performance laptop', TRUE), - ('Mouse', 29.99, 'Wireless optical mouse', TRUE), - ('Keyboard', 79.99, 'Mechanical gaming keyboard', FALSE), - ('Monitor', 299.99, '24-inch LCD monitor', TRUE); - -INSERT INTO orders (user_id, product_id, quantity, total_price, status) VALUES - (1, 1, 1, 999.99, 'delivered'), - (1, 2, 2, 59.98, 'shipped'), - (2, 3, 1, 79.99, 'processing'), - (3, 4, 1, 299.99, 'pending'); diff --git a/lecture4/requirements.py b/lecture4/requirements.py new file mode 100644 index 00000000..eec7edae --- /dev/null +++ b/lecture4/requirements.py @@ -0,0 +1,6 @@ +fastapi>=0.117 +uvicorn>=0.24 +pytest>=7.4 +pytest-asyncio>=0.21 +httpx>=0.27 +Faker>=37.8 diff --git a/lecture4/run_demo.py b/lecture4/run_demo.py new file mode 100644 index 00000000..af00ccbd --- /dev/null +++ b/lecture4/run_demo.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +""" +Скрипт для запуска демонстрации уровней изоляции транзакций + +Запуск: python run_demo.py +""" + +import sys +import os + +# Добавляем путь к проекту +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from transaction_demo import main + +if __name__ == "__main__": + print("🚀 Запуск демонстрации проблем транзакций...") + print("📝 Убедитесь, что у вас установлены зависимости:") + print(" pip install sqlalchemy") + print() + + try: + main() + except ImportError as e: + print(f"❌ Ошибка импорта: {e}") + print("💡 Убедитесь, что файл shop_api/main.py существует") + except Exception as e: + print(f"❌ Ошибка: {e}") + print("💡 Проверьте настройки базы данных") diff --git a/lecture3/demo_service/__init__.py b/lecture4/shop_api/__init__.py similarity index 100% rename from lecture3/demo_service/__init__.py rename to lecture4/shop_api/__init__.py diff --git a/lecture4/shop_api/main.py b/lecture4/shop_api/main.py new file mode 100644 index 00000000..958c5163 --- /dev/null +++ b/lecture4/shop_api/main.py @@ -0,0 +1,263 @@ +from fastapi import FastAPI, Query, HTTPException, Response, Depends +from pydantic import BaseModel +from typing import Optional, List + +from sqlalchemy import create_engine, ForeignKey, select +from sqlalchemy.orm import DeclarativeBase, Mapped +from sqlalchemy.orm import mapped_column, relationship, sessionmaker, Session + +class Base(DeclarativeBase): + pass + +class ItemDB(Base): + __tablename__ = "items" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(nullable = True) + price: Mapped[float] = mapped_column(nullable = True) + deleted: Mapped[bool] = mapped_column(nullable = True) + +class CartDB(Base): + __tablename__ = "carts" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + items = relationship("CartItemDB", back_populates="cart", cascade="all, delete-orphan") + +class CartItemDB(Base): + __tablename__ = "cart_items" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + cart_id: Mapped[int] = mapped_column(ForeignKey("carts.id"), nullable=False) + item_id: Mapped[int] = mapped_column(ForeignKey("items.id"), nullable=False) + quantity: Mapped[int] = mapped_column(nullable=False, default=1) + + cart = relationship("CartDB", back_populates="items") + item = relationship("ItemDB") + +engine = create_engine( + "sqlite:///file:memdb1?mode=memory&cache=shared", + echo=True, + connect_args={"check_same_thread": False, "uri": True} +) + +SessionLocal = sessionmaker(bind=engine) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +Base.metadata.create_all(engine) + +app = FastAPI(title="Shop API") + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool + +class ItemCreate(BaseModel): + name: str + price: float + +class ItemPatch(BaseModel): + name: Optional[str] = None + price: Optional[float] = None + + model_config = {"extra": "forbid"} + +class ItemPut(BaseModel): + name: str + price: float + deleted: Optional[bool] = None + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[CartItem] = [] + price: float = 0.0 + + +@app.post('/item', status_code=201) +def create_item(item: ItemCreate, db: Session = Depends(get_db)): + new_item = ItemDB(name=item.name, price=item.price, deleted=False) + db.add(new_item) + db.commit() + db.refresh(new_item) + return Item(id=new_item.id, name=new_item.name, price=new_item.price, deleted=new_item.deleted) + +@app.get('/item/{item_id}') +def get_item(item_id:int, db: Session = Depends(get_db)): + new_item = db.get(ItemDB, item_id) + if not new_item or new_item.deleted: + raise HTTPException(status_code=404, detail="Item not found") + return Item(id=new_item.id, name=new_item.name, price=new_item.price, deleted=new_item.deleted) + +@app.get("/item") +def get_item_list( + limit: Optional[int] = Query(default=10, ge=1), + offset: Optional[int] = Query(default=0, ge=0), + min_price: Optional[float] = Query(default=None, ge=0), + max_price: Optional[float] = Query(default=None, ge=0), + show_deleted: Optional[bool] = False, + db: Session = Depends(get_db), +): + query = select(ItemDB) + if not show_deleted: + query = query.where(ItemDB.deleted == False) + if min_price is not None: + query = query.where(ItemDB.price >= min_price) + if max_price is not None: + query = query.where(ItemDB.price <= max_price) + + query = query.offset(offset).limit(limit) + rows = db.execute(query).scalars().all() + + return [Item(id=r.id, name=r.name, price=r.price, deleted=r.deleted) for r in rows] + + +@app.put("/item/{item_id}") +def put_item(item_id: int, item: ItemPut, db: Session = Depends(get_db)): + db_item = db.get(ItemDB, item_id) + if not db_item: + raise HTTPException(status_code=404, detail="Item not found") + db_item.name = item.name + db_item.price = item.price + if item.deleted is not None: + db_item.deleted = item.deleted + db.add(db_item) + db.commit() + db.refresh(db_item) + return Item(id=db_item.id, name=db_item.name, price=db_item.price, deleted=db_item.deleted) + + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, item: ItemPatch, db: Session = Depends(get_db)): + db_item = db.get(ItemDB, item_id) + if not db_item: + raise HTTPException(status_code=404, detail="Item not found") + if db_item.deleted: + # Item marked as deleted -> not modifiable via PATCH + raise HTTPException(status_code=304, detail="Item is deleted") + + if item.price is not None: + db_item.price = item.price + if item.name is not None: + db_item.name = item.name + + db.add(db_item) + db.commit() + db.refresh(db_item) + return Item(id=db_item.id, name=db_item.name, price=db_item.price, deleted=db_item.deleted) + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int, db: Session = Depends(get_db)): + db_item = db.get(ItemDB, item_id) + if not db_item: + raise HTTPException(status_code=404, detail="Item not found") + db_item.deleted = True + db.add(db_item) + db.commit() + return Response(status_code=200) + + +# --- Cart endpoints --- + + +def cart_to_schema(db: Session, cart: CartDB) -> Cart: + items: List[CartItem] = [] + total_price = 0.0 + for ci in cart.items: + item = db.get(ItemDB, ci.item_id) + available = False + name = "" + price = 0.0 + if item: + available = not item.deleted + name = item.name + price = item.price + items.append(CartItem(id=ci.item_id, name=name, quantity=ci.quantity, available=available)) + if item and not item.deleted: + total_price += price * ci.quantity + + return Cart(id=cart.id, items=items, price=total_price) + + +@app.post("/cart", status_code=201) +def create_cart(response: Response, db: Session = Depends(get_db)): + cart = CartDB() + db.add(cart) + db.commit() + db.refresh(cart) + response.headers["location"] = f"/cart/{cart.id}" + return cart_to_schema(db, cart) + + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int, db: Session = Depends(get_db)): + cart = db.get(CartDB, cart_id) + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + return cart_to_schema(db, cart) + + +@app.get("/cart") +def get_cart_list( + limit: Optional[int] = Query(default=10, ge=1), + offset: Optional[int] = Query(default=0, ge=0), + min_price: Optional[float] = Query(default=None, ge=0), + max_price: Optional[float] = Query(default=None, ge=0), + min_quantity: Optional[int] = Query(default=None, ge=0), + max_quantity: Optional[int] = Query(default=None, ge=0), + db: Session = Depends(get_db), +): + # Build list of carts and apply filters in Python (small dataset for tests) + carts = db.execute(select(CartDB)).scalars().all() + result = [] + for cart in carts: + schema = cart_to_schema(db, cart) + result.append(schema) + + 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] + + # Apply offset/limit + return result[offset : offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + cart = db.get(CartDB, cart_id) + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + item = db.get(ItemDB, item_id) + if not item or item.deleted: + raise HTTPException(status_code=404, detail="Item not found") + + # find existing cart item + cart_item = db.execute( + select(CartItemDB).where(CartItemDB.cart_id == cart_id, CartItemDB.item_id == item_id) + ).scalar_one_or_none() + + if cart_item: + cart_item.quantity = cart_item.quantity + 1 + db.add(cart_item) + else: + cart_item = CartItemDB(cart_id=cart_id, item_id=item_id, quantity=1) + db.add(cart_item) + + db.commit() + db.refresh(cart) + return cart_to_schema(db, cart) diff --git a/lecture4/transaction_demo.py b/lecture4/transaction_demo.py new file mode 100644 index 00000000..d5723e93 --- /dev/null +++ b/lecture4/transaction_demo.py @@ -0,0 +1,258 @@ +import threading +import time +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from shop_api.main import Base, ItemDB + +def create_engine_with_isolation(isolation_level): + return create_engine( + "sqlite:///file:memdb1?mode=memory&cache=shared", + connect_args={"check_same_thread": False, "uri": True}, + isolation_level=isolation_level + ) + +def setup_test_data(): + engine = create_engine("sqlite:///file:memdb1?mode=memory&cache=shared", + connect_args={"check_same_thread": False, "uri": True}) + Base.metadata.create_all(engine) + + Session = sessionmaker(bind=engine) + session = Session() + + session.query(ItemDB).delete() + + test_item = ItemDB(name="Test Item", price=100.0, deleted=False) + session.add(test_item) + session.commit() + session.close() + + print("✅ Тестовые данные созданы: товар с ценой 100₽") + +def demo_dirty_read(): + print("\n" + "="*60) + print("🔍 ДЕМОНСТРАЦИЯ DIRTY READ") + print("="*60) + + engine1 = create_engine_with_isolation("READ UNCOMMITTED") + engine2 = create_engine_with_isolation("READ UNCOMMITTED") + + Session1 = sessionmaker(bind=engine1) + Session2 = sessionmaker(bind=engine2) + + def transaction1(): + print("👤 Пользователь 1: Начинаю изменение цены...") + session = Session1() + try: + item = session.query(ItemDB).first() + if item: + print(f"👤 Пользователь 1: Меняю цену с {item.price}₽ на 999₽") + item.price = 999.0 + session.flush() + print("👤 Пользователь 1: Изменения отправлены в БД (но не сохранены)") + time.sleep(3) + print("👤 Пользователь 1: Передумал! Откатываю изменения...") + session.rollback() + print("👤 Пользователь 1: Изменения отменены") + finally: + session.close() + + def transaction2(): + time.sleep(1) + print("👤 Пользователь 2: Читаю цену товара...") + session = Session2() + try: + item = session.query(ItemDB).first() + if item: + print(f"👤 Пользователь 2: Вижу цену = {item.price}₽") + print("❌ DIRTY READ! Пользователь 2 увидел незакоммиченные данные!") + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t1.start() + t2.start() + t1.join() + t2.join() + +def demo_non_repeatable_read(): + print("\n" + "="*60) + print("🔄 ДЕМОНСТРАЦИЯ NON-REPEATABLE READ") + print("="*60) + print("⚠️ SQLite поддерживает только READ UNCOMMITTED и SERIALIZABLE") + print(" Используем READ UNCOMMITTED для демонстрации") + + engine1 = create_engine_with_isolation("READ UNCOMMITTED") + engine2 = create_engine_with_isolation("READ UNCOMMITTED") + + Session1 = sessionmaker(bind=engine1) + Session2 = sessionmaker(bind=engine2) + + def transaction1(): + time.sleep(1) + print("👤 Пользователь 1: Меняю цену товара...") + session = Session1() + try: + item = session.query(ItemDB).first() + if item: + print(f"👤 Пользователь 1: Меняю цену с {item.price}₽ на 200₽") + item.price = 200.0 + session.commit() + print("👤 Пользователь 1: Изменения сохранены!") + finally: + session.close() + + def transaction2(): + print("👤 Пользователь 2: Читаю цену первый раз...") + session = Session2() + try: + item = session.query(ItemDB).first() + if item: + print(f"👤 Пользователь 2: Первое чтение - цена = {item.price}₽") + + time.sleep(2) + + print("👤 Пользователь 2: Читаю цену второй раз...") + item = session.query(ItemDB).first() + if item: + print(f"👤 Пользователь 2: Второе чтение - цена = {item.price}₽") + print("❌ NON-REPEATABLE READ! Цена изменилась между чтениями!") + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t2.start() + t1.start() + t1.join() + t2.join() + +def demo_phantom_read(): + print("\n" + "="*60) + print("👻 ДЕМОНСТРАЦИЯ PHANTOM READ") + print("="*60) + print("⚠️ SQLite поддерживает только READ UNCOMMITTED и SERIALIZABLE") + print(" Используем READ UNCOMMITTED для демонстрации") + + engine1 = create_engine_with_isolation("READ UNCOMMITTED") + engine2 = create_engine_with_isolation("READ UNCOMMITTED") + + Session1 = sessionmaker(bind=engine1) + Session2 = sessionmaker(bind=engine2) + + def transaction1(): + time.sleep(1) + print("👤 Пользователь 1: Добавляю новый товар...") + session = Session1() + try: + new_item = ItemDB(name="Новый товар", price=300.0, deleted=False) + session.add(new_item) + session.commit() + print("👤 Пользователь 1: Новый товар добавлен!") + finally: + session.close() + + def transaction2(): + print("👤 Пользователь 2: Считаю товары первый раз...") + session = Session2() + try: + count = session.query(ItemDB).count() + print(f"👤 Пользователь 2: Первый подсчет - {count} товаров") + + time.sleep(2) + + print("👤 Пользователь 2: Считаю товары второй раз...") + count = session.query(ItemDB).count() + print(f"👤 Пользователь 2: Второй подсчет - {count} товаров") + print("❌ PHANTOM READ! Количество товаров изменилось!") + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t2.start() + t1.start() + t1.join() + t2.join() + +def demo_serializable(): + print("\n" + "="*60) + print("🔒 ДЕМОНСТРАЦИЯ SERIALIZABLE (РЕШЕНИЕ ПРОБЛЕМ)") + print("="*60) + + engine1 = create_engine_with_isolation("SERIALIZABLE") + engine2 = create_engine_with_isolation("SERIALIZABLE") + + Session1 = sessionmaker(bind=engine1) + Session2 = sessionmaker(bind=engine2) + + def transaction1(): + time.sleep(1) + print("👤 Пользователь 1: Пытаюсь добавить товар...") + session = Session1() + try: + new_item = ItemDB(name="Безопасный товар", price=400.0, deleted=False) + session.add(new_item) + session.commit() + print("👤 Пользователь 1: Товар добавлен успешно!") + except Exception as e: + print(f"👤 Пользователь 1: Ошибка - {e}") + session.rollback() + finally: + session.close() + + def transaction2(): + print("👤 Пользователь 2: Считаю товары первый раз...") + session = Session2() + try: + count = session.query(ItemDB).count() + print(f"👤 Пользователь 2: Первый подсчет - {count} товаров") + + time.sleep(2) + + print("👤 Пользователь 2: Считаю товары второй раз...") + count = session.query(ItemDB).count() + print(f"👤 Пользователь 2: Второй подсчет - {count} товаров") + print("✅ SERIALIZABLE! Количество товаров не изменилось!") + except Exception as e: + print(f"👤 Пользователь 2: Ошибка - {e}") + session.rollback() + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t2.start() + t1.start() + t1.join() + t2.join() + +def main(): + print("🎯 ДЕМОНСТРАЦИЯ ПРОБЛЕМ ТРАНЗАКЦИЙ") + print("="*60) + print("Этот скрипт показывает проблемы, которые могут возникнуть") + print("когда несколько пользователей одновременно работают с БД") + print("="*60) + + setup_test_data() + + demo_dirty_read() + demo_non_repeatable_read() + demo_phantom_read() + demo_serializable() + + print("\n" + "="*60) + print("🎉 ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА!") + print("="*60) + print("Выводы:") + print("• READ UNCOMMITTED - быстрый, но небезопасный") + print("• READ COMMITTED - предотвращает Dirty Read") + print("• REPEATABLE READ - предотвращает Non-Repeatable Read") + print("• SERIALIZABLE - самый безопасный, но медленный") + +if __name__ == "__main__": + main() diff --git a/lecture5/Makefile b/lecture5/Makefile new file mode 100644 index 00000000..422bbac8 --- /dev/null +++ b/lecture5/Makefile @@ -0,0 +1,19 @@ +.PHONY: test test-cov install clean + +install: + pip install -r requirements.txt + pip install -r tests/requirements.txt + pip install -e ../lecture4 + +test: + pytest tests/test_shop_api.py -v + +test-cov: + pytest tests/test_shop_api.py --cov=shop_api --cov-report=html --cov-report=term-missing --cov-fail-under=95 + +clean: + rm -rf htmlcov/ + rm -f .coverage + rm -f coverage.xml + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name "*.pyc" -delete diff --git a/lecture5/README.md b/lecture5/README.md new file mode 100644 index 00000000..683e9b5d --- /dev/null +++ b/lecture5/README.md @@ -0,0 +1,61 @@ +# Lecture 5 - Тестирование + +Этот проект содержит тесты для API магазина из lecture4. + +## Структура проекта + +``` +lecture5/ +├── tests/ +│ ├── test_shop_api.py # Основные тесты API +│ ├── requirements.txt # Зависимости для тестов +│ └── README.md # Документация тестов +├── requirements.txt # Основные зависимости +├── pytest.ini # Конфигурация pytest +├── Makefile # Команды для запуска тестов +├── run_tests.py # Скрипт запуска тестов +└── demo_tests.py # Демонстрация тестирования +``` + +## Установка + +```bash +# Установка зависимостей +pip install -r requirements.txt +pip install -r tests/requirements.txt +pip install -e ../lecture4 +``` + +## Запуск тестов + +```bash +# Простой запуск +make test + +# С покрытием кода +make test-cov + +# Или через pytest +pytest tests/test_shop_api.py --cov=shop_api --cov-report=html +``` + +## Покрытие кода + +Тесты обеспечивают покрытие кода не менее 95%. + +## CI/CD + +Тесты автоматически запускаются в GitHub Actions при каждом push и pull request. + +## Что тестируется + +- ✅ Создание товаров +- ✅ Получение товаров +- ✅ Обновление товаров +- ✅ Удаление товаров +- ✅ Создание корзин +- ✅ Добавление товаров в корзину +- ✅ Расчет стоимости корзины +- ✅ Фильтрация и пагинация +- ✅ Валидация данных +- ✅ Обработка ошибок diff --git a/lecture5/demo_all.py b/lecture5/demo_all.py new file mode 100644 index 00000000..79800287 --- /dev/null +++ b/lecture5/demo_all.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import os + +def main(): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + + print("🎯 ПОЛНАЯ ДЕМОНСТРАЦИЯ LECTURE 5") + print("=" * 60) + print("Этот скрипт показывает все возможности тестирования") + print("=" * 60) + + print("\n📋 Что включено:") + print("• Полное тестирование API") + print("• Покрытие кода 95%+") + print("• CI/CD с GitHub Actions") + print("• Автоматические отчеты") + print("• Валидация данных") + print("• Обработка ошибок") + + print("\n🔧 Установка...") + try: + subprocess.run(["pip", "install", "-r", "requirements.txt"], check=True) + subprocess.run(["pip", "install", "-r", "tests/requirements.txt"], check=True) + subprocess.run(["pip", "install", "-e", "../lecture4"], check=True) + print("✅ Зависимости установлены") + except subprocess.CalledProcessError: + print("❌ Ошибка установки") + sys.exit(1) + + print("\n🧪 Запуск тестов...") + try: + result = subprocess.run([ + "pytest", + "tests/test_shop_api.py", + "--cov=shop_api", + "--cov-report=html", + "--cov-report=term-missing", + "--cov-fail-under=95", + "-v" + ], check=True) + + print("\n✅ Все тесты прошли!") + print("📊 Отчет сохранен в htmlcov/index.html") + print("🎉 Покрытие кода ≥ 95%!") + + except subprocess.CalledProcessError as e: + print(f"\n❌ Тесты не прошли: {e}") + sys.exit(1) + + print("\n🚀 CI/CD готов к работе!") + print("• GitHub Actions настроен") + print("• Автоматические тесты") + print("• Отчеты о покрытии") + print("• Зеленый пайплайн") + +if __name__ == "__main__": + main() diff --git a/lecture5/demo_ci.py b/lecture5/demo_ci.py new file mode 100644 index 00000000..450585a6 --- /dev/null +++ b/lecture5/demo_ci.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import os + +def main(): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + + print("🚀 ДЕМОНСТРАЦИЯ CI/CD ДЛЯ SHOP API") + print("=" * 60) + print("Этот скрипт показывает, как настроен CI/CD для проекта") + print("=" * 60) + + print("\n📋 Что настроено:") + print("• GitHub Actions workflow") + print("• Автоматический запуск тестов") + print("• Проверка покрытия кода") + print("• Отчеты о покрытии") + print("• Уведомления о статусе") + + print("\n🔧 Команды для работы с CI:") + print("• git push - запускает тесты автоматически") + print("• git pull request - проверяет изменения") + print("• make test - локальный запуск тестов") + print("• make test-cov - тесты с покрытием") + + print("\n📊 Отчеты:") + print("• HTML отчет: htmlcov/index.html") + print("• Терминал: покрытие в консоли") + print("• GitHub: статус в PR") + + print("\n🎯 Цели:") + print("• Покрытие кода ≥ 95%") + print("• Все тесты проходят") + print("• Зеленый пайплайн") + + print("\n✅ CI/CD настроен и готов к работе!") + +if __name__ == "__main__": + main() diff --git a/lecture5/demo_cicd.py b/lecture5/demo_cicd.py new file mode 100644 index 00000000..0f19dbcd --- /dev/null +++ b/lecture5/demo_cicd.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import os + +def main(): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + + print("🚀 ДЕМОНСТРАЦИЯ CI/CD") + print("=" * 60) + print("Этот скрипт показывает, как работает CI/CD") + print("=" * 60) + + print("\n📋 Что происходит в CI:") + print("• Автоматический запуск тестов") + print("• Проверка покрытия кода") + print("• Генерация отчетов") + print("• Уведомления о статусе") + + print("\n🔧 Триггеры:") + print("• git push - запуск тестов") + print("• pull request - проверка изменений") + print("• manual - ручной запуск") + + print("\n📊 Результаты:") + print("• ✅ Зеленый - все тесты прошли") + print("• ❌ Красный - есть ошибки") + print("• ⚠️ Желтый - предупреждения") + + print("\n🎯 Цели:") + print("• Автоматизация тестирования") + print("• Быстрая обратная связь") + print("• Качество кода") + print("• Уверенность в изменениях") + +if __name__ == "__main__": + main() diff --git a/lecture5/demo_coverage.py b/lecture5/demo_coverage.py new file mode 100644 index 00000000..721265ee --- /dev/null +++ b/lecture5/demo_coverage.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import os + +def main(): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + + print("📊 ДЕМОНСТРАЦИЯ ПОКРЫТИЯ КОДА") + print("=" * 60) + print("Этот скрипт показывает, как работает покрытие кода") + print("=" * 60) + + print("\n📋 Что такое покрытие:") + print("• Процент кода, покрытого тестами") + print("• Показывает, какой код тестируется") + print("• Помогает найти непротестированные части") + print("• Обеспечивает качество кода") + + print("\n🔧 Команды:") + print("• --cov=module - покрытие модуля") + print("• --cov-report=html - HTML отчет") + print("• --cov-report=term - в терминале") + print("• --cov-fail-under=95 - минимум 95%") + + print("\n📊 Отчеты:") + print("• HTML: htmlcov/index.html") + print("• Терминал: покрытие в консоли") + print("• XML: coverage.xml") + + print("\n🎯 Цели:") + print("• Покрытие ≥ 95%") + print("• Все строки протестированы") + print("• Качественные тесты") + print("• Уверенность в коде") + +if __name__ == "__main__": + main() diff --git a/lecture5/demo_testing.py b/lecture5/demo_testing.py new file mode 100644 index 00000000..490ceb50 --- /dev/null +++ b/lecture5/demo_testing.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import os + +def main(): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + + print("🧪 ДЕМОНСТРАЦИЯ ТЕСТИРОВАНИЯ") + print("=" * 60) + print("Этот скрипт показывает, как работают тесты") + print("=" * 60) + + print("\n📋 Типы тестов:") + print("• Unit тесты - тестирование отдельных функций") + print("• Integration тесты - тестирование взаимодействия") + print("• API тесты - тестирование HTTP endpoints") + print("• Coverage тесты - проверка покрытия кода") + + print("\n🔧 Команды:") + print("• pytest - запуск всех тестов") + print("• pytest -v - подробный вывод") + print("• pytest --cov - с покрытием") + print("• pytest -k test_name - конкретный тест") + + print("\n📊 Отчеты:") + print("• HTML: htmlcov/index.html") + print("• Терминал: покрытие в консоли") + print("• XML: coverage.xml") + + print("\n🎯 Цели:") + print("• Покрытие ≥ 95%") + print("• Все тесты проходят") + print("• Быстрое выполнение") + print("• Читаемые отчеты") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lecture5/demo_tests.py b/lecture5/demo_tests.py new file mode 100644 index 00000000..490ceb50 --- /dev/null +++ b/lecture5/demo_tests.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import os + +def main(): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + + print("🧪 ДЕМОНСТРАЦИЯ ТЕСТИРОВАНИЯ") + print("=" * 60) + print("Этот скрипт показывает, как работают тесты") + print("=" * 60) + + print("\n📋 Типы тестов:") + print("• Unit тесты - тестирование отдельных функций") + print("• Integration тесты - тестирование взаимодействия") + print("• API тесты - тестирование HTTP endpoints") + print("• Coverage тесты - проверка покрытия кода") + + print("\n🔧 Команды:") + print("• pytest - запуск всех тестов") + print("• pytest -v - подробный вывод") + print("• pytest --cov - с покрытием") + print("• pytest -k test_name - конкретный тест") + + print("\n📊 Отчеты:") + print("• HTML: htmlcov/index.html") + print("• Терминал: покрытие в консоли") + print("• XML: coverage.xml") + + print("\n🎯 Цели:") + print("• Покрытие ≥ 95%") + print("• Все тесты проходят") + print("• Быстрое выполнение") + print("• Читаемые отчеты") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lecture5/pytest.ini b/lecture5/pytest.ini new file mode 100644 index 00000000..fe1b1c65 --- /dev/null +++ b/lecture5/pytest.ini @@ -0,0 +1,11 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --cov=shop_api + --cov-report=html + --cov-report=term-missing + --cov-fail-under=95 + -v diff --git a/lecture5/run_tests.py b/lecture5/run_tests.py new file mode 100644 index 00000000..d84e0cf2 --- /dev/null +++ b/lecture5/run_tests.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import os + +def run_tests(): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + + print("🧪 Запуск тестов для Shop API...") + print("=" * 50) + + try: + result = subprocess.run([ + "pytest", + "tests/test_shop_api.py", + "--cov=shop_api", + "--cov-report=html", + "--cov-report=term-missing", + "--cov-fail-under=95", + "-v" + ], check=True) + + print("\n✅ Все тесты прошли успешно!") + print("📊 Отчет о покрытии сохранен в htmlcov/index.html") + + except subprocess.CalledProcessError as e: + print(f"\n❌ Тесты не прошли: {e}") + sys.exit(1) + except FileNotFoundError: + print("❌ pytest не найден. Установите зависимости:") + print("pip install -r requirements.txt") + print("pip install -r tests/requirements.txt") + sys.exit(1) + +if __name__ == "__main__": + run_tests() diff --git a/lecture5/test.db b/lecture5/test.db new file mode 100644 index 00000000..9d8e63d2 Binary files /dev/null and b/lecture5/test.db differ diff --git a/lecture5/tests/README.md b/lecture5/tests/README.md index e7eaab96..879648b7 100644 --- a/lecture5/tests/README.md +++ b/lecture5/tests/README.md @@ -1,10 +1,31 @@ -# Примеры тестов +# Тесты для Shop API -Для запуска тестов: +Этот проект содержит тесты для API магазина из lecture4. -```sh -pytest \ - -vv \ - --cov=lecture5/ \ - ./lecture5/tests +## Установка + +```bash +pip install -r requirements.txt +pip install -r tests/requirements.txt +``` + +## Запуск тестов + +```bash +# Запуск всех тестов +pytest + +# Запуск с покрытием +pytest --cov=shop_api --cov-report=html + +# Запуск конкретного теста +pytest tests/test_shop_api.py::test_create_item ``` + +## Покрытие кода + +Тесты должны обеспечивать покрытие кода не менее 95%. + +## CI/CD + +Тесты автоматически запускаются в GitHub Actions при каждом push и pull request. \ No newline at end of file diff --git a/lecture5/tests/conftest.py b/lecture5/tests/conftest.py index 5ec6da9d..b7e4ae8b 100644 --- a/lecture5/tests/conftest.py +++ b/lecture5/tests/conftest.py @@ -1,7 +1,10 @@ import pytest +from typing import TypeVar -async def to_str_async[_TVal](x: _TVal) -> str: +_TVal = TypeVar("_TVal") + +async def to_str_async(x: _TVal) -> str: return str(x) diff --git a/lecture5/tests/requirements.txt b/lecture5/tests/requirements.txt new file mode 100644 index 00000000..40d3b062 --- /dev/null +++ b/lecture5/tests/requirements.txt @@ -0,0 +1,6 @@ +pytest>=7.0.0 +pytest-cov>=4.0.0 +fastapi>=0.100.0 +sqlalchemy>=2.0.0 +pytest-asyncio>=0.21.0 +httpx>=0.24.0 diff --git a/lecture5/tests/test_shop_api.py b/lecture5/tests/test_shop_api.py new file mode 100644 index 00000000..36a59d0e --- /dev/null +++ b/lecture5/tests/test_shop_api.py @@ -0,0 +1,390 @@ +import pytest +import pytest_cov +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from shop_api.main import app, Base, ItemDB, CartDB, CartItemDB, get_db + +SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db + +@pytest.fixture(scope="module") +def setup_database(): + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + +@pytest.fixture +def client(setup_database): + return TestClient(app) + +@pytest.fixture +def sample_item(): + return {"name": "Test Item", "price": 100.0} + +@pytest.fixture +def sample_item_update(): + return {"name": "Updated Item", "price": 150.0, "deleted": False} + +@pytest.fixture +def sample_item_patch(): + return {"name": "Patched Item", "price": 200.0} + +def test_create_item(client, sample_item): + response = client.post("/item", json=sample_item) + assert response.status_code == 201 + data = response.json() + assert data["name"] == sample_item["name"] + assert data["price"] == sample_item["price"] + assert data["deleted"] == False + assert "id" in data + +def test_create_item_invalid_data(client): + response = client.post("/item", json={"name": "Test"}) + assert response.status_code == 422 + +def test_get_item(client, sample_item): + create_response = client.post("/item", json=sample_item) + item_id = create_response.json()["id"] + + response = client.get(f"/item/{item_id}") + assert response.status_code == 200 + data = response.json() + assert data["name"] == sample_item["name"] + assert data["price"] == sample_item["price"] + +def test_get_item_not_found(client): + response = client.get("/item/999") + assert response.status_code == 404 + +def test_get_item_deleted(client, sample_item): + create_response = client.post("/item", json=sample_item) + item_id = create_response.json()["id"] + + client.delete(f"/item/{item_id}") + + response = client.get(f"/item/{item_id}") + assert response.status_code == 404 + +def test_get_item_list(client, sample_item): + client.post("/item", json=sample_item) + client.post("/item", json={"name": "Item 2", "price": 200.0}) + + response = client.get("/item") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + +def test_get_item_list_with_filters(client, sample_item): + client.post("/item", json=sample_item) + client.post("/item", json={"name": "Item 2", "price": 200.0}) + + response = client.get("/item?min_price=150") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["price"] == 200.0 + +def test_get_item_list_with_limit(client, sample_item): + client.post("/item", json=sample_item) + client.post("/item", json={"name": "Item 2", "price": 200.0}) + + response = client.get("/item?limit=1") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + +def test_get_item_list_show_deleted(client, sample_item): + create_response = client.post("/item", json=sample_item) + item_id = create_response.json()["id"] + + client.delete(f"/item/{item_id}") + + response = client.get("/item?show_deleted=true") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["deleted"] == True + +def test_put_item(client, sample_item, sample_item_update): + create_response = client.post("/item", json=sample_item) + item_id = create_response.json()["id"] + + response = client.put(f"/item/{item_id}", json=sample_item_update) + assert response.status_code == 200 + data = response.json() + assert data["name"] == sample_item_update["name"] + assert data["price"] == sample_item_update["price"] + +def test_put_item_not_found(client, sample_item_update): + response = client.put("/item/999", json=sample_item_update) + assert response.status_code == 404 + +def test_patch_item(client, sample_item, sample_item_patch): + create_response = client.post("/item", json=sample_item) + item_id = create_response.json()["id"] + + response = client.patch(f"/item/{item_id}", json=sample_item_patch) + assert response.status_code == 200 + data = response.json() + assert data["name"] == sample_item_patch["name"] + assert data["price"] == sample_item_patch["price"] + +def test_patch_item_not_found(client, sample_item_patch): + response = client.patch("/item/999", json=sample_item_patch) + assert response.status_code == 404 + +def test_patch_item_deleted(client, sample_item, sample_item_patch): + create_response = client.post("/item", json=sample_item) + item_id = create_response.json()["id"] + + client.delete(f"/item/{item_id}") + + response = client.patch(f"/item/{item_id}", json=sample_item_patch) + assert response.status_code == 304 + +def test_delete_item(client, sample_item): + create_response = client.post("/item", json=sample_item) + 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.status_code == 404 + +def test_delete_item_not_found(client): + response = client.delete("/item/999") + assert response.status_code == 404 + +def test_create_cart(client): + 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(client): + create_response = client.post("/cart") + cart_id = create_response.json()["id"] + + response = client.get(f"/cart/{cart_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == cart_id + +def test_get_cart_not_found(client): + response = client.get("/cart/999") + assert response.status_code == 404 + +def test_get_cart_list(client): + client.post("/cart") + client.post("/cart") + + response = client.get("/cart") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + +def test_get_cart_list_with_filters(client): + cart1_response = client.post("/cart") + cart1_id = cart1_response.json()["id"] + + item_response = client.post("/item", json={"name": "Test Item", "price": 100.0}) + item_id = item_response.json()["id"] + + client.post(f"/cart/{cart1_id}/add/{item_id}") + + response = client.get("/cart?min_price=50") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + +def test_add_item_to_cart(client, sample_item): + cart_response = client.post("/cart") + cart_id = cart_response.json()["id"] + + item_response = client.post("/item", json=sample_item) + item_id = item_response.json()["id"] + + response = client.post(f"/cart/{cart_id}/add/{item_id}") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["id"] == item_id + assert data["items"][0]["quantity"] == 1 + assert data["price"] == sample_item["price"] + +def test_add_item_to_cart_multiple_times(client, sample_item): + cart_response = client.post("/cart") + cart_id = cart_response.json()["id"] + + item_response = client.post("/item", json=sample_item) + item_id = item_response.json()["id"] + + client.post(f"/cart/{cart_id}/add/{item_id}") + response = client.post(f"/cart/{cart_id}/add/{item_id}") + + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["quantity"] == 2 + assert data["price"] == sample_item["price"] * 2 + +def test_add_item_to_cart_cart_not_found(client, sample_item): + item_response = client.post("/item", json=sample_item) + item_id = item_response.json()["id"] + + response = client.post(f"/cart/999/add/{item_id}") + assert response.status_code == 404 + +def test_add_item_to_cart_item_not_found(client): + cart_response = client.post("/cart") + cart_id = cart_response.json()["id"] + + response = client.post(f"/cart/{cart_id}/add/999") + assert response.status_code == 404 + +def test_add_deleted_item_to_cart(client, sample_item): + cart_response = client.post("/cart") + cart_id = cart_response.json()["id"] + + item_response = client.post("/item", json=sample_item) + item_id = item_response.json()["id"] + + client.delete(f"/item/{item_id}") + + response = client.post(f"/cart/{cart_id}/add/{item_id}") + assert response.status_code == 404 + +def test_cart_price_calculation(client, sample_item): + cart_response = client.post("/cart") + cart_id = cart_response.json()["id"] + + item1_response = client.post("/item", json=sample_item) + item1_id = item1_response.json()["id"] + + item2_response = client.post("/item", json={"name": "Item 2", "price": 200.0}) + item2_id = item2_response.json()["id"] + + client.post(f"/cart/{cart_id}/add/{item1_id}") + client.post(f"/cart/{cart_id}/add/{item2_id}") + + response = client.get(f"/cart/{cart_id}") + assert response.status_code == 200 + data = response.json() + assert data["price"] == 300.0 + +def test_cart_item_availability(client, sample_item): + cart_response = client.post("/cart") + cart_id = cart_response.json()["id"] + + item_response = client.post("/item", json=sample_item) + item_id = item_response.json()["id"] + + client.post(f"/cart/{cart_id}/add/{item_id}") + + client.delete(f"/item/{item_id}") + + response = client.get(f"/cart/{cart_id}") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["available"] == False + +def test_cart_list_quantity_filters(client, sample_item): + cart1_response = client.post("/cart") + cart1_id = cart1_response.json()["id"] + + cart2_response = client.post("/cart") + cart2_id = cart2_response.json()["id"] + + item_response = client.post("/item", json=sample_item) + item_id = item_response.json()["id"] + + client.post(f"/cart/{cart1_id}/add/{item_id}") + client.post(f"/cart/{cart1_id}/add/{item_id}") + + client.post(f"/cart/{cart2_id}/add/{item_id}") + + response = client.get("/cart?min_quantity=2") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + +def test_cart_list_max_quantity_filters(client, sample_item): + cart1_response = client.post("/cart") + cart1_id = cart1_response.json()["id"] + + cart2_response = client.post("/cart") + cart2_id = cart2_response.json()["id"] + + item_response = client.post("/item", json=sample_item) + item_id = item_response.json()["id"] + + client.post(f"/cart/{cart1_id}/add/{item_id}") + client.post(f"/cart/{cart1_id}/add/{item_id}") + + client.post(f"/cart/{cart2_id}/add/{item_id}") + + response = client.get("/cart?max_quantity=1") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + +def test_item_validation_negative_price(client): + response = client.post("/item", json={"name": "Test", "price": -100.0}) + assert response.status_code == 422 + +def test_item_validation_empty_name(client): + response = client.post("/item", json={"name": "", "price": 100.0}) + assert response.status_code == 422 + +def test_item_validation_missing_fields(client): + response = client.post("/item", json={"name": "Test"}) + assert response.status_code == 422 + +def test_cart_offset_limit(client): + for i in range(5): + client.post("/cart") + + response = client.get("/cart?offset=2&limit=2") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + +def test_item_offset_limit(client, sample_item): + for i in range(5): + client.post("/item", json={"name": f"Item {i}", "price": 100.0 + i}) + + response = client.get("/item?offset=2&limit=2") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + +def test_cart_item_quantity_increase(client, sample_item): + cart_response = client.post("/cart") + cart_id = cart_response.json()["id"] + + item_response = client.post("/item", json=sample_item) + item_id = item_response.json()["id"] + + for _ in range(3): + client.post(f"/cart/{cart_id}/add/{item_id}") + + response = client.get(f"/cart/{cart_id}") + assert response.status_code == 200 + data = response.json() + assert data["items"][0]["quantity"] == 3 + assert data["price"] == sample_item["price"] * 3 diff --git a/lecture6/Makefile b/lecture6/Makefile deleted file mode 100644 index f34f1bbe..00000000 --- a/lecture6/Makefile +++ /dev/null @@ -1,18 +0,0 @@ -init_topic: - docker-compose exec kafka kafka-topics \ - --create \ - --topic $(t) \ - --replication-factor $(r) \ - --partitions $(p) \ - --bootstrap-server localhost:29092 - -list_topics: - docker-compose exec kafka kafka-topics \ - --list \ - --bootstrap-server localhost:29092 - -delete_topic: - docker-compose exec kafka kafka-topics \ - --delete \ - --topic $(t) \ - --bootstrap-server localhost:29092 diff --git a/lecture6/docker-compose.yml b/lecture6/docker-compose.yml deleted file mode 100644 index 5a04a202..00000000 --- a/lecture6/docker-compose.yml +++ /dev/null @@ -1,34 +0,0 @@ -version: "3" - -services: - rabbit: - image: rabbitmq:3.10.7-management - restart: always - environment: - - RABBITMQ_DEFAULT_USER=admin - - RABBITMQ_DEFAULT_PASS=adminpass - ports: - - 5672:5672 - - 15672:15672 - - zookeeper: - image: confluentinc/cp-zookeeper:7.4.4 - environment: - ZOOKEEPER_CLIENT_PORT: 2181 - ZOOKEEPER_TICK_TIME: 2000 - ports: - - 2181:2181 - - kafka: - image: confluentinc/cp-kafka:7.4.4 - depends_on: - - zookeeper - ports: - - 29092:29092 - environment: - KAFKA_BROKER_ID: 1 - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092 - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT - KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 diff --git a/lecture6/kafka/consumer.py b/lecture6/kafka/consumer.py deleted file mode 100644 index 94488cdb..00000000 --- a/lecture6/kafka/consumer.py +++ /dev/null @@ -1,68 +0,0 @@ -import signal -import sys -from concurrent.futures import ThreadPoolExecutor, wait -from dataclasses import dataclass, field -from os import name - -from confluent_kafka import Consumer - -consumer_num = int(sys.argv[1]) - - -@dataclass(slots=True) -class KafkaConsumer: - name: str - topic: str - group: str - server: str - - consumer: Consumer = field(init=False) - - def __post_init__(self) -> None: - self.consumer = Consumer( - { - "bootstrap.servers": "localhost:29092", - "group.id": self.group, - "auto.offset.reset": "earliest", - } - ) - self.consumer.subscribe([self.topic]) - - def run(self) -> None: - print(f"Starting consumer {self.name}") - - while True: - print("waiting") - message = self.consumer.poll(1.0) - - if message is None: - continue - if message.error(): - print(f"Err {message.error()}") - continue - - print(f"CONSUMER-{self.name}: {message.value().decode()}") - - def stop(self) -> None: - self.consumer.close() - - -if __name__ == "__main__": - with ThreadPoolExecutor() as e: - consumers = [ - KafkaConsumer( - name=str(i), - topic="demo-topic", - group="demo.group", - server="localhost:29092", - ) - for i in range(consumer_num) - ] - signal.signal( - signal.SIGTSTP, - lambda _, __: [consumer.stop() for consumer in consumers], - ) - - futures = [e.submit(lambda: consumer.run()) for consumer in consumers] - - wait(futures) diff --git a/lecture6/kafka/producer.py b/lecture6/kafka/producer.py deleted file mode 100644 index c2e8946c..00000000 --- a/lecture6/kafka/producer.py +++ /dev/null @@ -1,25 +0,0 @@ -import sys - -from confluent_kafka import Producer - -topic = sys.argv[1] -producer = Producer({"bootstrap.servers": "localhost:29092"}) - - -# def delivery_report(err, msg): -# if err is not None: -# print(f"Err: {err}") -# else: -# print(f"Delivered {msg.topic()} [{msg.partition()}]") - - -for i in range(10_000): - producer.poll(0) - producer.produce( - topic, - key=str(i), - value=f"Message {i}".encode(), - ) - - -producer.flush() diff --git a/lecture6/rabbit_mq_direct/consumer.py b/lecture6/rabbit_mq_direct/consumer.py deleted file mode 100644 index dc74cd08..00000000 --- a/lecture6/rabbit_mq_direct/consumer.py +++ /dev/null @@ -1,27 +0,0 @@ -import pika - -parameters = pika.ConnectionParameters( - host="localhost", - credentials=pika.PlainCredentials( - username="admin", - password="adminpass", - ), -) -connection = pika.BlockingConnection(parameters=parameters) -channel = connection.channel() - -channel.queue_declare(queue="hello") - - -def callback(ch, method, properties, body): - print(f"CONSUMER: Received {body}") - - -channel.basic_consume( - queue="hello", - on_message_callback=callback, - auto_ack=True, -) - -print("CONSUMER: Waiting for messages") -channel.start_consuming() diff --git a/lecture6/rabbit_mq_direct/producer.py b/lecture6/rabbit_mq_direct/producer.py deleted file mode 100644 index 92e583d0..00000000 --- a/lecture6/rabbit_mq_direct/producer.py +++ /dev/null @@ -1,33 +0,0 @@ -from concurrent.futures import ThreadPoolExecutor, wait - -import pika - - -def produce_many(producer_name: str): - parameters = pika.ConnectionParameters( - host="localhost", - credentials=pika.PlainCredentials( - username="admin", - password="adminpass", - ), - ) - connection = pika.BlockingConnection(parameters=parameters) - channel = connection.channel() - - channel.queue_declare(queue="hello") - - for i in range(1_000): - channel.basic_publish( - exchange="", - routing_key="hello", - body=f"{producer_name} : {i}", - ) - - connection.close() - - -with ThreadPoolExecutor() as e: - futures = [e.submit(produce_many, f"Producer {i}") for i in range(2)] - wait(futures) - - print("completed") diff --git a/lecture6/rabbit_mq_direct_2/consumer.py b/lecture6/rabbit_mq_direct_2/consumer.py deleted file mode 100644 index 57ff82bf..00000000 --- a/lecture6/rabbit_mq_direct_2/consumer.py +++ /dev/null @@ -1,31 +0,0 @@ -import sys - -import pika - -queue = sys.argv[1] -queue_name = f"queue_{queue}" -parameters = pika.ConnectionParameters( - host="localhost", - credentials=pika.PlainCredentials( - username="admin", - password="adminpass", - ), -) -connection = pika.BlockingConnection(parameters=parameters) -channel = connection.channel() - -channel.queue_declare(queue=queue_name) - - -def callback(ch, method, properties, body): - print(f"CONSUMER: Received {body}") - - -channel.basic_consume( - queue=queue_name, - on_message_callback=callback, - auto_ack=True, -) - -print(f"CONSUMER: {queue_name} Waiting for messages") -channel.start_consuming() diff --git a/lecture6/rabbit_mq_direct_2/producer.py b/lecture6/rabbit_mq_direct_2/producer.py deleted file mode 100644 index 1ca66062..00000000 --- a/lecture6/rabbit_mq_direct_2/producer.py +++ /dev/null @@ -1,49 +0,0 @@ -from concurrent.futures import ThreadPoolExecutor, wait - -import pika -import pika.exchange_type - - -def produce_many(key: str, i: int): - producer_name = f"Producer {key}-{i}" - print(producer_name) - queue_name = f"queue_{key}" - - parameters = pika.ConnectionParameters( - host="localhost", - credentials=pika.PlainCredentials( - username="admin", - password="adminpass", - ), - ) - connection = pika.BlockingConnection(parameters=parameters) - channel = connection.channel() - - channel.exchange_declare( - "direct_wb", - exchange_type=pika.exchange_type.ExchangeType.direct, - ) - channel.queue_declare(queue=queue_name) - channel.queue_bind( - exchange='direct_wb', - queue=queue_name, - routing_key=key, - ) - - for i in range(1_000): - channel.basic_publish( - exchange="direct_wb", - routing_key=key, - body=f"{producer_name} : {i}", - ) - - connection.close() - - -with ThreadPoolExecutor() as e: - futures = [e.submit(produce_many, "black", i) for i in range(5)] - futures = [e.submit(produce_many, "white", i) for i in range(5)] - - wait(futures) - - print("completed") diff --git a/lecture6/rabbit_mq_fanout/consumer.py b/lecture6/rabbit_mq_fanout/consumer.py deleted file mode 100644 index c9832835..00000000 --- a/lecture6/rabbit_mq_fanout/consumer.py +++ /dev/null @@ -1,39 +0,0 @@ -import sys - -import pika -import pika.exchange_type - -queue = sys.argv[1] - -parameters = pika.ConnectionParameters( - host="localhost", - credentials=pika.PlainCredentials( - username="admin", - password="adminpass", - ), -) -connection = pika.BlockingConnection(parameters=parameters) -channel = connection.channel() -channel.exchange_declare( - exchange="test.fanout", - exchange_type="fanout", -) -channel.queue_declare(queue=queue) -channel.queue_bind( - queue=queue, - exchange="test.fanout", -) - - -def callback(ch, method, properties, body): - print(f"CONSUMER[{queue}]: Received\n{ch}\n{method}\n{properties}\n{body}") - - -channel.basic_consume( - queue=queue, - on_message_callback=callback, - auto_ack=True, -) - -print("CONSUMER: Waiting for messages") -channel.start_consuming() diff --git a/lecture6/rabbit_mq_fanout/producer.py b/lecture6/rabbit_mq_fanout/producer.py deleted file mode 100644 index eb3a203e..00000000 --- a/lecture6/rabbit_mq_fanout/producer.py +++ /dev/null @@ -1,33 +0,0 @@ -import pika - -parameters = pika.ConnectionParameters( - host="localhost", - credentials=pika.PlainCredentials( - username="admin", - password="adminpass", - ), -) -connection = pika.BlockingConnection(parameters=parameters) -channel = connection.channel() - - -channel.exchange_declare( - exchange="test.fanout", - exchange_type="fanout", -) - -channel.queue_declare(queue="queue_name") - -channel.queue_bind( - queue="queue_name", - exchange="test.fanout", -) - -for i in range(1): - channel.basic_publish( - exchange="test.fanout", - routing_key='lol', - body=f"Producer : {i}", - ) - -connection.close() diff --git a/lecture6/rabbit_mq_topic/consumer.py b/lecture6/rabbit_mq_topic/consumer.py deleted file mode 100644 index 4b5a1c12..00000000 --- a/lecture6/rabbit_mq_topic/consumer.py +++ /dev/null @@ -1,46 +0,0 @@ -import sys - -import pika -import pika.exchange_type - -animal = sys.argv[1] -action = sys.argv[2] -key = f"{animal}.{action}" - -parameters = pika.ConnectionParameters( - host="localhost", - credentials=pika.PlainCredentials( - username="admin", - password="adminpass", - ), -) -connection = pika.BlockingConnection(parameters=parameters) -channel = connection.channel() - -channel.exchange_declare( - exchange="animal_action", - exchange_type="topic", -) - -result = channel.queue_declare("", exclusive=True) -queue_name = result.method.queue - -channel.queue_bind( - exchange="animal_action", - queue=queue_name, - routing_key=key, -) - - -def callback(ch, method, properties, body): - print(f"CONSUMER[{key}]: Received {body}") - - -channel.basic_consume( - queue=queue_name, - on_message_callback=callback, - auto_ack=True, -) - -print(f"CONSUMER: [key: {key}] Waiting for messages") -channel.start_consuming() diff --git a/lecture6/rabbit_mq_topic/producer.py b/lecture6/rabbit_mq_topic/producer.py deleted file mode 100644 index 0a506642..00000000 --- a/lecture6/rabbit_mq_topic/producer.py +++ /dev/null @@ -1,36 +0,0 @@ -import random - -import pika - -animals = ["cat", "dog", "lion", "*"] -actions = ["say", "jump", "eat", "*"] - - -parameters = pika.ConnectionParameters( - host="localhost", - credentials=pika.PlainCredentials( - username="admin", - password="adminpass", - ), -) -connection = pika.BlockingConnection(parameters=parameters) -channel = connection.channel() - -channel.exchange_declare( - exchange="animal_action", - exchange_type="topic", -) - -for i in range(1_000): - animal = random.choice(animals) - action = random.choice(actions) - key = f"{animal}.{action}" - - print(f"Producing: {key}") - channel.basic_publish( - exchange="animal_action", - routing_key=key, - body=f"Producer : {key} : {i}", - ) - -connection.close() diff --git a/lecture6/requirements.txt b/lecture6/requirements.txt deleted file mode 100644 index 5387ba7b..00000000 --- a/lecture6/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pika -confluent_kafka \ No newline at end of file diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 9cce55cc..00000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -markers = - slow: mark test as slow. \ No newline at end of file