From d9c1bc312f9312fe93da48b3aaa1ee24edc42b37 Mon Sep 17 00:00:00 2001 From: glukhov324 Date: Sun, 26 Oct 2025 17:31:35 +0700 Subject: [PATCH 1/2] add hw4 --- hw4_5/Dockerfile | 14 ++ hw4_5/README.md | 22 ++ hw4_5/alembic.ini | 147 +++++++++++++ hw4_5/alembic/README | 1 + hw4_5/alembic/env.py | 97 +++++++++ hw4_5/alembic/script.py.mako | 28 +++ .../a976573e4c55_added_required_tables.py | 54 +++++ hw4_5/docker-compose.yml | 25 +++ hw4_5/requirements.txt | 23 ++ hw4_5/scripts/start.sh | 3 + hw4_5/src/config/__init__.py | 1 + hw4_5/src/config/settings.py | 25 +++ hw4_5/src/crud/__init__.py | 2 + hw4_5/src/crud/base.py | 83 ++++++++ hw4_5/src/crud/cart.py | 196 +++++++++++++++++ hw4_5/src/crud/item.py | 56 +++++ hw4_5/src/db/__init__.py | 1 + hw4_5/src/db/deps.py | 7 + hw4_5/src/db/session.py | 17 ++ hw4_5/src/main.py | 18 ++ hw4_5/src/models.py | 63 ++++++ hw4_5/src/routers/__init__.py | 2 + hw4_5/src/routers/cart.py | 101 +++++++++ hw4_5/src/routers/item.py | 147 +++++++++++++ hw4_5/src/schemas.py | 50 +++++ hw4_5/start_pg_app.py | 13 ++ .../transactions_problems_scripts.py | 198 ++++++++++++++++++ 27 files changed, 1394 insertions(+) create mode 100644 hw4_5/Dockerfile create mode 100644 hw4_5/README.md create mode 100644 hw4_5/alembic.ini create mode 100644 hw4_5/alembic/README create mode 100644 hw4_5/alembic/env.py create mode 100644 hw4_5/alembic/script.py.mako create mode 100644 hw4_5/alembic/versions/a976573e4c55_added_required_tables.py create mode 100644 hw4_5/docker-compose.yml create mode 100644 hw4_5/requirements.txt create mode 100644 hw4_5/scripts/start.sh create mode 100644 hw4_5/src/config/__init__.py create mode 100644 hw4_5/src/config/settings.py create mode 100644 hw4_5/src/crud/__init__.py create mode 100644 hw4_5/src/crud/base.py create mode 100644 hw4_5/src/crud/cart.py create mode 100644 hw4_5/src/crud/item.py create mode 100644 hw4_5/src/db/__init__.py create mode 100644 hw4_5/src/db/deps.py create mode 100644 hw4_5/src/db/session.py create mode 100644 hw4_5/src/main.py create mode 100644 hw4_5/src/models.py create mode 100644 hw4_5/src/routers/__init__.py create mode 100644 hw4_5/src/routers/cart.py create mode 100644 hw4_5/src/routers/item.py create mode 100644 hw4_5/src/schemas.py create mode 100644 hw4_5/start_pg_app.py create mode 100644 hw4_5/transactions_problems_scripts/transactions_problems_scripts.py diff --git a/hw4_5/Dockerfile b/hw4_5/Dockerfile new file mode 100644 index 00000000..115353d8 --- /dev/null +++ b/hw4_5/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12 + + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR app +COPY . . + +RUN pip install -r requirements.txt + +RUN chmod -R +x scripts/start.sh . + +ENTRYPOINT [ "sh", "scripts/start.sh" ] \ No newline at end of file diff --git a/hw4_5/README.md b/hw4_5/README.md new file mode 100644 index 00000000..39981942 --- /dev/null +++ b/hw4_5/README.md @@ -0,0 +1,22 @@ +# ДЗ 4 + +1. Запуск приложения и БД +```sh +# Postgres +docker compose up + +# Окружение +python3.12 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt + +# Миграции и запуск приложения +source scripts/start.sh +``` + +2. Запуск скриптов для демонстрации проблем с транзакциями: +```sh +export PYTHONPATH=${PWD}/src/ +python transactions_problems_scripts/transactions_problems_scripts.py +``` \ No newline at end of file diff --git a/hw4_5/alembic.ini b/hw4_5/alembic.ini new file mode 100644 index 00000000..7f7f01de --- /dev/null +++ b/hw4_5/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/hw4_5/alembic/README b/hw4_5/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/hw4_5/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/hw4_5/alembic/env.py b/hw4_5/alembic/env.py new file mode 100644 index 00000000..0756f5c4 --- /dev/null +++ b/hw4_5/alembic/env.py @@ -0,0 +1,97 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context + +from src.config import settings +from src.models import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_url(): + user = settings.POSTGRES_USER + password = settings.POSTGRES_PASSWORD + host = settings.POSTGRES_HOST + port = settings.POSTGRES_PORT + db = settings.POSTGRES_DB + url = f"postgresql://{user}:{password}@{host}:{port}/{db}" + return url + + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url", None) + if not url: + url = get_url() + + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section) + if not config.get_main_option("sqlalchemy.url", None): + configuration["sqlalchemy.url"] = get_url() + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + include_schemas=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/hw4_5/alembic/script.py.mako b/hw4_5/alembic/script.py.mako new file mode 100644 index 00000000..11016301 --- /dev/null +++ b/hw4_5/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/hw4_5/alembic/versions/a976573e4c55_added_required_tables.py b/hw4_5/alembic/versions/a976573e4c55_added_required_tables.py new file mode 100644 index 00000000..4b68d1a2 --- /dev/null +++ b/hw4_5/alembic/versions/a976573e4c55_added_required_tables.py @@ -0,0 +1,54 @@ +"""Added required tables + +Revision ID: a976573e4c55 +Revises: +Create Date: 2025-10-23 22:02:14.410133 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a976573e4c55' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('carts', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('items', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('deleted', sa.Boolean(), server_default='false', nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('cart_items', + sa.Column('cart_id', sa.UUID(), nullable=False), + sa.Column('item_id', sa.UUID(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('available', sa.Boolean(), server_default='false', nullable=False), + sa.ForeignKeyConstraint(['cart_id'], ['carts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.PrimaryKeyConstraint('cart_id', 'item_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('cart_items') + op.drop_table('items') + op.drop_table('carts') + # ### end Alembic commands ### diff --git a/hw4_5/docker-compose.yml b/hw4_5/docker-compose.yml new file mode 100644 index 00000000..76937e22 --- /dev/null +++ b/hw4_5/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3" + +volumes: + postgres-data: + +networks: + net: + +services: + postgres: + container_name: postgres_hw + environment: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + image: postgres:15 + volumes: + - postgres-data:/var/lib/postgresql/data + restart: on-failure + ports: + - "5432:5432" + networks: + - net \ No newline at end of file diff --git a/hw4_5/requirements.txt b/hw4_5/requirements.txt new file mode 100644 index 00000000..134cdfd7 --- /dev/null +++ b/hw4_5/requirements.txt @@ -0,0 +1,23 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +pydantic==2.11.9 +pydantic-settings==2.11.0 +sqlalchemy==2.0.44 +alembic==1.17.0 +asyncpg==0.30.0 +greenlet==3.2.4 +psycopg2==2.9.11 +uvicorn>=0.24.0 +websockets>=1.8.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio==1.2.0 +pytest-asyncio>=0.21.0 +httpx==0.23.1 +Faker>=37.8.0 +pytest-cov==7.0.0 +gevent==25.9.1 + +# prometheus +prometheus-fastapi-instrumentator==7.1.0 \ No newline at end of file diff --git a/hw4_5/scripts/start.sh b/hw4_5/scripts/start.sh new file mode 100644 index 00000000..ecc390c8 --- /dev/null +++ b/hw4_5/scripts/start.sh @@ -0,0 +1,3 @@ +#! bin/bash +PYTHONPATH=. alembic upgrade head +python start_pg_app.py \ No newline at end of file diff --git a/hw4_5/src/config/__init__.py b/hw4_5/src/config/__init__.py new file mode 100644 index 00000000..61c140c9 --- /dev/null +++ b/hw4_5/src/config/__init__.py @@ -0,0 +1 @@ +from .settings import settings \ No newline at end of file diff --git a/hw4_5/src/config/settings.py b/hw4_5/src/config/settings.py new file mode 100644 index 00000000..0dad299c --- /dev/null +++ b/hw4_5/src/config/settings.py @@ -0,0 +1,25 @@ +from pydantic import Field +from pydantic_settings import SettingsConfigDict, BaseSettings + + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env") + + # Server + HOST: str = Field("0.0.0.0") + PORT: int = Field("8000") + + # Postgres + POSTGRES_HOST: str = Field("0.0.0.0") + POSTGRES_PORT: int = Field("5432") + POSTGRES_USER: str = Field("postgres") + POSTGRES_PASSWORD: str = Field("postgres") + POSTGRES_DB: str = Field("postgres") + + @property + def DATABASE_URL(self): + return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + + +settings = Settings() \ No newline at end of file diff --git a/hw4_5/src/crud/__init__.py b/hw4_5/src/crud/__init__.py new file mode 100644 index 00000000..181a8302 --- /dev/null +++ b/hw4_5/src/crud/__init__.py @@ -0,0 +1,2 @@ +from .item import crud_item +from .cart import crud_cart \ No newline at end of file diff --git a/hw4_5/src/crud/base.py b/hw4_5/src/crud/base.py new file mode 100644 index 00000000..9659cff4 --- /dev/null +++ b/hw4_5/src/crud/base.py @@ -0,0 +1,83 @@ +from typing import TypeVar, Generic, Optional, List, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, Result +from pydantic import BaseModel +from uuid import UUID + +from ..models import Base + + + +ModelType = TypeVar("ModelType", bound=Base) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) + + + +class CRUDBase(Generic[ModelType, CreateSchemaType]): + def __init__( + self, + model: ModelType + ): + self.model = model + + async def create( + self, + db: AsyncSession, + *, + obj_in: Optional[CreateSchemaType] = None, + **kwargs: Any + ) -> ModelType: + + if obj_in is not None: + obj_data = obj_in.model_dump() + else: + obj_data = kwargs + + db_obj = self.model(**obj_data) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + + + async def get( + self, + db: AsyncSession, + id: UUID + ) -> Optional[ModelType]: + + query = select(self.model).where(self.model.id == id) + result: Result = await db.execute(query) + return result.scalars().first() + + + async def update( + self, + db: AsyncSession, + *, + id: UUID, + obj_in: dict | BaseModel, + skip_deleted_check: bool = False # если модель не имеет deleted + ) -> Optional[ModelType]: + + db_obj = await self.get(db, id) + if not db_obj: + return None + + if hasattr(db_obj, 'deleted') and not skip_deleted_check: + if db_obj.deleted: + return None + + if isinstance(obj_in, BaseModel): + update_data = obj_in.model_dump(exclude_unset=True) # только переданные поля + else: + update_data = obj_in + + for field, value in update_data.items(): + if hasattr(db_obj, field): + setattr(db_obj, field, value) + + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj diff --git a/hw4_5/src/crud/cart.py b/hw4_5/src/crud/cart.py new file mode 100644 index 00000000..94c0b7f9 --- /dev/null +++ b/hw4_5/src/crud/cart.py @@ -0,0 +1,196 @@ +from typing import List, Optional +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from sqlalchemy.orm import joinedload + +from .base import CRUDBase +from ..models import CartModel, CartItemModel, ItemModel +from ..schemas import CartItemResponse, CartResponse + + +class CRUDCart(CRUDBase[CartModel, None]): + + async def get_carts_with_filters( + self, + db: AsyncSession, + offset: int = 0, + limit: int = 10, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + min_quantity: Optional[int] = None, + max_quantity: Optional[int] = None, + ) -> List[CartResponse]: + + quantity_subq = ( + select( + CartItemModel.cart_id, + func.coalesce(func.sum(CartItemModel.quantity), 0).label("total_quantity") + ) + .join(ItemModel, CartItemModel.item_id == ItemModel.id) + .where(ItemModel.deleted.is_(False)) + .group_by(CartItemModel.cart_id) + .subquery() + ) + + query = ( + select( + CartModel.id, + CartModel.price, + func.coalesce(quantity_subq.c.total_quantity, 0).label("total_quantity") + ) + .select_from(CartModel) + .outerjoin(quantity_subq, CartModel.id == quantity_subq.c.cart_id) + ) + + if min_price is not None: + query = query.where(CartModel.price >= min_price) + if max_price is not None: + query = query.where(CartModel.price <= max_price) + if min_quantity is not None: + query = query.where(func.coalesce(quantity_subq.c.total_quantity, 0) >= min_quantity) + if max_quantity is not None: + query = query.where(func.coalesce(quantity_subq.c.total_quantity, 0) <= max_quantity) + + query = query.offset(offset).limit(limit) + + result = await db.execute(query) + cart_rows = result.fetchall() + + if not cart_rows: + return [] + + cart_ids = [r.id for r in cart_rows] + cart_info = {r.id: r for r in cart_rows} + + cart_items_result = await db.execute( + select(CartItemModel) + .options(joinedload(CartItemModel.item)) + .join(ItemModel, CartItemModel.item_id == ItemModel.id) + .where(CartItemModel.cart_id.in_(cart_ids)) + .where(ItemModel.deleted.is_(False)) + ) + cart_items = cart_items_result.scalars().all() + + cart_items_map = {} + for ci in cart_items: + cart_items_map.setdefault(ci.cart_id, []).append(ci) + + response = [] + for cart_id in cart_ids: + row = cart_info[cart_id] + total_quantity = row.total_quantity + items = [] + if total_quantity > 0: + items = [ + CartItemResponse( + id=ci.item_id, + name=ci.item.name, + quantity=ci.quantity, + available=ci.available, + ) + for ci in cart_items_map.get(cart_id, []) + ] + response.append( + CartResponse( + id=cart_id, + items=items, + price=row.price + ) + ) + return response + + + async def add_item_to_cart( + self, + db: AsyncSession, + *, + cart_id: UUID, + item_id: UUID, + ) -> bool: + + cart_result = await db.execute( + select(CartModel) + .where(CartModel.id == cart_id) + .with_for_update() + ) + cart = cart_result.scalar_one_or_none() + if not cart: + return False + + item_result = await db.execute( + select(ItemModel) + .where(ItemModel.id == item_id) + .where(ItemModel.deleted.is_(False)) + ) + item = item_result.scalar_one_or_none() + if not item: + return False + + cart_item_result = await db.execute( + select(CartItemModel) + .where(CartItemModel.cart_id == cart_id) + .where(CartItemModel.item_id == item_id) + ) + existing_cart_item = cart_item_result.scalar_one_or_none() + + if existing_cart_item: + existing_cart_item.quantity += 1 + price_delta = item.price + else: + new_cart_item = CartItemModel( + cart_id=cart_id, + item_id=item_id, + quantity=1, + available=True + ) + db.add(new_cart_item) + price_delta = item.price + + cart.price += price_delta + + try: + await db.commit() + return True + except Exception: + await db.rollback() + return False + + + async def get_cart_with_items( + self, + db: AsyncSession, + *, + id: UUID, + ): + cart = await db.get(CartModel, id) + if not cart: + return None + + await db.refresh(cart, ["items"]) + + items = [] + total_quantity = 0 + for ci in cart.items: + if ci.item.deleted: + continue + items.append( + CartItemResponse( + id=ci.item_id, + name=ci.item.name, + quantity=ci.quantity, + available=ci.available, + ) + ) + total_quantity += ci.quantity + + return CartResponse( + id=cart.id, + items=items, + price=cart.price + ) + + + +crud_cart = CRUDCart(CartModel) \ No newline at end of file diff --git a/hw4_5/src/crud/item.py b/hw4_5/src/crud/item.py new file mode 100644 index 00000000..36a00268 --- /dev/null +++ b/hw4_5/src/crud/item.py @@ -0,0 +1,56 @@ +from typing import List, Optional +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from .base import CRUDBase +from ..models import ItemModel +from src.schemas import ItemCreate + + +class CRUDItem(CRUDBase[ItemModel, ItemCreate]): + + async def get_items_with_filters( + self, + db: AsyncSession, + offset: int, + limit: int, + min_price: Optional[float], + max_price: Optional[float], + show_deleted: bool, + ) -> List[ItemModel]: + + query = select(ItemModel) + query = query.where(ItemModel.deleted.is_(show_deleted)) + + if min_price is not None: + query = query.where(ItemModel.price >= min_price) + if max_price is not None: + query = query.where(ItemModel.price <= max_price) + + query = query.offset(offset).limit(limit) + + result = await db.execute(query) + return list(result.scalars().all()) + + + async def soft_delete( + self, + db: AsyncSession, + *, + id: UUID, + ) -> Optional[ItemModel]: + + obj = await self.get(db, id) + if obj is None: + return None + + obj.deleted = True + db.add(obj) + await db.commit() + await db.refresh(obj) + return obj + + +crud_item = CRUDItem(ItemModel) \ No newline at end of file diff --git a/hw4_5/src/db/__init__.py b/hw4_5/src/db/__init__.py new file mode 100644 index 00000000..d4889125 --- /dev/null +++ b/hw4_5/src/db/__init__.py @@ -0,0 +1 @@ +from .deps import get_db \ No newline at end of file diff --git a/hw4_5/src/db/deps.py b/hw4_5/src/db/deps.py new file mode 100644 index 00000000..aa2d3296 --- /dev/null +++ b/hw4_5/src/db/deps.py @@ -0,0 +1,7 @@ +from src.db.session import async_session + + + +async def get_db(): + async with async_session() as session: + yield session \ No newline at end of file diff --git a/hw4_5/src/db/session.py b/hw4_5/src/db/session.py new file mode 100644 index 00000000..5d1600d6 --- /dev/null +++ b/hw4_5/src/db/session.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + +from src.config import settings + + + +db_engine = create_async_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + max_overflow=0, +) + +async_session = async_sessionmaker( + autocommit=False, + autoflush=False, + bind=db_engine +) diff --git a/hw4_5/src/main.py b/hw4_5/src/main.py new file mode 100644 index 00000000..87bbdd48 --- /dev/null +++ b/hw4_5/src/main.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI + +from src.routers import item_router, cart_router + + + +pg_app = FastAPI() + +pg_app.include_router( + router=cart_router, + prefix="/carts", + tags=["Cart"] +) +pg_app.include_router( + router=item_router, + prefix="/items", + tags=["Items"] +) \ No newline at end of file diff --git a/hw4_5/src/models.py b/hw4_5/src/models.py new file mode 100644 index 00000000..41d6a685 --- /dev/null +++ b/hw4_5/src/models.py @@ -0,0 +1,63 @@ +from typing import List +from uuid import UUID as UUID_PY, uuid4 +from sqlalchemy import String, Boolean, Integer, Float, ForeignKey +from sqlalchemy.dialects.postgresql import UUID as UUID_PG +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class ItemModel(Base): + __tablename__ = "items" + + id: Mapped[UUID_PY] = mapped_column(UUID_PG(as_uuid=True), primary_key=True, default=lambda: uuid4().hex) + name: Mapped[str] = mapped_column(String, nullable=False) + price: Mapped[float] = mapped_column(Float) + deleted: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="false" + ) + + +class CartModel(Base): + __tablename__ = "carts" + + id: Mapped[UUID_PY] = mapped_column( + UUID_PG(as_uuid=True), + primary_key=True, + default=lambda: uuid4().hex + ) + + items: Mapped[List["CartItemModel"]] = relationship( + back_populates="cart", + cascade="all, delete-orphan", + lazy="selectin", + ) + price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) + + +class CartItemModel(Base): + __tablename__ = "cart_items" + + cart_id: Mapped[UUID_PY] = mapped_column( + ForeignKey("carts.id", ondelete="CASCADE"), + primary_key=True + ) + item_id: Mapped[UUID_PY] = mapped_column( + ForeignKey("items.id"), + primary_key=True + ) + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + cart: Mapped["CartModel"] = relationship(back_populates="items") + item: Mapped["ItemModel"] = relationship(lazy="joined") + available: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="false" + ) \ No newline at end of file diff --git a/hw4_5/src/routers/__init__.py b/hw4_5/src/routers/__init__.py new file mode 100644 index 00000000..674c96c7 --- /dev/null +++ b/hw4_5/src/routers/__init__.py @@ -0,0 +1,2 @@ +from .cart import router as cart_router +from .item import router as item_router \ No newline at end of file diff --git a/hw4_5/src/routers/cart.py b/hw4_5/src/routers/cart.py new file mode 100644 index 00000000..bf6ca1fa --- /dev/null +++ b/hw4_5/src/routers/cart.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, status, Response, Query, Depends +from fastapi.responses import JSONResponse +from typing import List, Optional +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession + +from src.crud import crud_cart +from src.db import get_db +from src.schemas import ( + CartResponse, + CartCreateResponse, + Msg +) + + +router = APIRouter() + + +@router.post( + path="", + response_model=CartCreateResponse, + status_code=status.HTTP_201_CREATED +) +async def create_cart( + response: Response, + db: AsyncSession = Depends(get_db) +): + new_cart = await crud_cart.create(db) + cart_id = new_cart.id + response.headers["Location"] = f"/carts/{cart_id}" + return CartCreateResponse(id=cart_id) + + +@router.get( + path="/{id}", + response_model=CartResponse +) +async def get_cart( + id: UUID, + db: AsyncSession = Depends(get_db) +): + cart = await crud_cart.get_cart_with_items(db=db, id=id) + if cart is None: + return JSONResponse( + content=Msg(msg="Корзина не найдена").model_dump(), + status_code=404 + ) + return cart + + +@router.get( + path="", + response_model=List[CartResponse] +) +async def get_list_carts( + offset: Optional[int] = Query(None, ge=0), + limit: Optional[int] = Query(None, ge=1), + min_price: Optional[float] = Query(None, ge=0.0), + max_price: Optional[float] = Query(None, ge=0.0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0), + db: AsyncSession = Depends(get_db) +): + carts = await crud_cart.get_carts_with_filters( + db=db, + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + min_quantity=min_quantity, + max_quantity=max_quantity, + ) + + return carts + + +@router.post( + path="/{cart_id}/add/{item_id}", + response_model=Msg +) +async def add_item_to_cart_endpoint( + cart_id: UUID, + item_id: UUID, + db: AsyncSession = Depends(get_db) +): + cart = await crud_cart.add_item_to_cart( + db=db, + cart_id=cart_id, + item_id=item_id + ) + + if not cart: + return JSONResponse( + content=Msg(msg="Ничего не найдено").model_dump(), + status_code=404 + ) + + return JSONResponse( + content=Msg(msg=f"Айтем {item_id} успешно добавлен в корзину {cart_id}").model_dump(), + status_code=200 + ) \ No newline at end of file diff --git a/hw4_5/src/routers/item.py b/hw4_5/src/routers/item.py new file mode 100644 index 00000000..de6f0ce2 --- /dev/null +++ b/hw4_5/src/routers/item.py @@ -0,0 +1,147 @@ +from uuid import UUID +from fastapi import APIRouter, status, Query, Depends +from fastapi.responses import JSONResponse +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from src.crud import crud_item +from src.db import get_db +from src.schemas import ( + ItemCreate, + ItemResponse, + Msg, ItemUpdate, ItemPatch +) + +router = APIRouter() + + +@router.post( + path="", + response_model=ItemResponse, + status_code=status.HTTP_201_CREATED +) +async def create_item_endpoint( + item: ItemCreate, + db: AsyncSession = Depends(get_db) +): + new_item = await crud_item.create(db=db, obj_in=item) + return new_item + + +@router.get( + path="/{id}", + response_model=ItemResponse +) +async def get_item_endpoint( + id: UUID, + db: AsyncSession = Depends(get_db) +): + item = await crud_item.get(db=db, id=id) + if not item: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + return item + + +@router.get( + path="", + response_model=List[ItemResponse] +) +async def get_list_items_endpoint( + offset: Optional[int] = Query(None, ge=0), + limit: Optional[int] = Query(None, ge=1), + min_price: Optional[float] = Query(None, ge=0.0), + max_price: Optional[float] = Query(None, ge=0.0), + show_deleted: Optional[bool] = Query(False), + db: AsyncSession = Depends(get_db) +): + items = await crud_item.get_items_with_filters( + db=db, + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + show_deleted=show_deleted + ) + + return items + + +@router.put( + path="/{id}", + response_model=ItemResponse +) +async def update_full_item_endpoint( + id: UUID, + item: ItemUpdate, + db: AsyncSession = Depends(get_db) +): + + item_updated = await crud_item.update( + db=db, + id=id, + obj_in=item + ) + if not item_updated: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + + return item_updated + + +@router.patch( + path="/{id}", + response_model=ItemResponse +) +async def update_item_partial_endpoint( + id: UUID, + item: ItemPatch, + db: AsyncSession = Depends(get_db) +): + + item_patched = await crud_item.update( + db=db, + id=id, + obj_in=item + ) + + if not item_patched: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + + return item_patched + + +@router.delete( + path="/{id}", + response_model=ItemResponse +) +async def delete_item_endpoint( + id: UUID, + db: AsyncSession = Depends(get_db) +): + item_deleted = await crud_item.soft_delete( + db=db, + id=id + ) + + if not item_deleted: + return JSONResponse( + content=Msg( + msg="Ничего не найдено" + ).model_dump(), + status_code=404 + ) + return item_deleted \ No newline at end of file diff --git a/hw4_5/src/schemas.py b/hw4_5/src/schemas.py new file mode 100644 index 00000000..678ca06a --- /dev/null +++ b/hw4_5/src/schemas.py @@ -0,0 +1,50 @@ +from pydantic import BaseModel, ConfigDict +from typing import List, Optional +from uuid import UUID + + +class CartCreate(BaseModel): + pass + +class CartCreateResponse(BaseModel): + id: UUID + +class CartItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + name: str + quantity: float + available: bool + + +class CartResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + items: List[CartItemResponse] = [] + price: float + + +class ItemCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + price: float + + +class ItemUpdate(ItemCreate): + pass + +class ItemPatch(ItemCreate): + name: Optional[str] = None + price: Optional[float] = None + +class ItemResponse(ItemCreate): + model_config = ConfigDict(from_attributes=True) + + id: UUID + deleted: bool + + +class Msg(BaseModel): + msg: str \ No newline at end of file diff --git a/hw4_5/start_pg_app.py b/hw4_5/start_pg_app.py new file mode 100644 index 00000000..cacea25d --- /dev/null +++ b/hw4_5/start_pg_app.py @@ -0,0 +1,13 @@ +import uvicorn + +from src.config import settings + + + +if __name__ == "__main__": + uvicorn.run( + app="src.main:pg_app", + host=settings.HOST, + port=settings.PORT, + reload=False + ) \ No newline at end of file diff --git a/hw4_5/transactions_problems_scripts/transactions_problems_scripts.py b/hw4_5/transactions_problems_scripts/transactions_problems_scripts.py new file mode 100644 index 00000000..a76f1d1f --- /dev/null +++ b/hw4_5/transactions_problems_scripts/transactions_problems_scripts.py @@ -0,0 +1,198 @@ +import asyncio +from uuid import UUID +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy import text + +from config import settings +from models import Base, ItemModel + + + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +AsyncSession = async_sessionmaker(engine, expire_on_commit=False) + +ITEM_ID = UUID("edf925f2-c112-423a-ac24-a70c6faebffc") +NEW_ITEM_ID_1 = UUID("81e5a9ac-6362-47be-bc33-0d740cac83ca") +NEW_ITEM_ID_2 = UUID("dc90ee64-6010-4d75-9062-7fa1fe387014") + + +async def setup_test_data(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + async with AsyncSession() as session: + session.add(ItemModel(id=ITEM_ID, name="Тестовый товар", price=100.0, deleted=False)) + await session.commit() + + +async def demo_1_dirty_read(): + print("\n=== 1. Dirty Read при READ UNCOMMITTED ===") + print("В PostgreSQL уровень READ UNCOMMITTED автоматически повышается до READ COMMITTED") + print("→ Грязное чтение НЕВОЗМОЖНО.") + + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + await s1.execute( + text("UPDATE items SET price = 50 WHERE id = :id"), + {"id": str(ITEM_ID)} + ) + print("T1: обновила цену на 50, но не коммитит") + + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + price = (await s2.execute( + text("SELECT price FROM items WHERE id = :id"), + {"id": str(ITEM_ID)} + )).scalar() + print(f"T2: прочитала цену = {price} (ожидаемо: 100.0)") + + await s1.rollback() + + +async def demo_2_no_dirty_read(): + print("\n=== 2. Отсутствие Dirty Read при READ COMMITTED ===") + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + await s1.execute( + text("UPDATE items SET price = 50 WHERE id = :id"), + {"id": str(ITEM_ID)} + ) + print("T1: обновила цену на 50, но не коммитит") + + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + price = (await s2.execute( + text("SELECT price FROM items WHERE id = :id"), + {"id": str(ITEM_ID)} + )).scalar() + print(f"T2: прочитала цену = {price}") # → 100.0 + + await s1.commit() + + +async def demo_3_non_repeatable_read(): + print("\n=== 3. Non-Repeatable Read при READ COMMITTED ===") + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + p1 = (await s2.execute(text("SELECT price FROM items WHERE id = :id"), {"id": str(ITEM_ID)})).scalar() + print(f"T2: первое чтение = {p1}") + + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + await s1.execute(text("UPDATE items SET price = 200 WHERE id = :id"), {"id": str(ITEM_ID)}) + await s1.commit() + print("T1: обновила и закоммитила") + + p2 = (await s2.execute(text("SELECT price FROM items WHERE id = :id"), {"id": str(ITEM_ID)})).scalar() + print(f"T2: второе чтение = {p2}") + + +async def demo_4_no_non_repeatable_read(): + print("\n=== 4. Отсутствие Non-Repeatable Read при REPEATABLE READ ===") + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + p1 = (await s2.execute(text("SELECT price FROM items WHERE id = :id"), {"id": str(ITEM_ID)})).scalar() + print(f"T2: первое чтение = {p1}") # → 100.0 + + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + await s1.execute(text("UPDATE items SET price = 200 WHERE id = :id"), {"id": str(ITEM_ID)}) + await s1.commit() + print("T1: обновила и закоммитила") + + p2 = (await s2.execute(text("SELECT price FROM items WHERE id = :id"), {"id": str(ITEM_ID)})).scalar() + print(f"T2: второе чтение = {p2}") # → 100.0 + + +async def demo_5_phantom_read(): + print("\n=== 5. Phantom Read при READ COMMITTED ===") + print("Условие: SELECT с WHERE price < 150") + + async with AsyncSession() as setup_sess: + async with setup_sess.begin(): + await setup_sess.execute(text("DELETE FROM items")) + await setup_sess.execute(text(""" + INSERT INTO items (id, name, price, deleted) + VALUES ('11111111-1111-1111-1111-111111111111', 'Товар A', 100.0, false) + """)) + + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + c1 = (await s2.execute( + text("SELECT COUNT(*) FROM items WHERE price < 150") + )).scalar() + print(f"T2: первое количество = {c1}") # → 1 + + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + await s1.execute(text(""" + INSERT INTO items (id, name, price, deleted) + VALUES (:id, 'Новый товар', 120.0, false) + """), {"id": str(UUID("33333333-3333-3333-3333-333333333333"))}) + await s1.commit() + print("T1: добавила товар с price=120 (в диапазоне)") + + c2 = (await s2.execute( + text("SELECT COUNT(*) FROM items WHERE price < 150") + )).scalar() + print(f"T2: второе количество = {c2}") # → 2 + + if c2 > c1: + print("Phantom Read обнаружен: появилась новая строка в диапазоне!") + + +async def demo_6_no_phantom_read(): + print("\n=== 6. Отсутствие Phantom Read при SERIALIZABLE ===") + async with AsyncSession() as s2: + async with s2.begin(): + await s2.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + c1 = (await s2.execute(text("SELECT COUNT(*) FROM items WHERE deleted = false"))).scalar() + print(f"T2: первое количество = {c1}") # → 2 + + async with AsyncSession() as s1: + async with s1.begin(): + await s1.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + await s1.execute(text(""" + INSERT INTO items (id, name, price, deleted) + VALUES (:id, 'Ещё товар', 88.88, false) + """), {"id": str(NEW_ITEM_ID_2)}) + await s1.commit() + print("T1: добавила товар") + + try: + c2 = (await s2.execute(text("SELECT COUNT(*) FROM items WHERE deleted = false"))).scalar() + print(f"T2: второе количество = {c2}") # → 2 или ошибка + except Exception as e: + print(f"T2: ошибка сериализации (ожидаемо): {type(e).__name__}") + + +async def main(): + print("Демонстрация уровней изоляции транзакций в PostgreSQL") + print("Таблицы: items, carts, cart_items") + + await setup_test_data() + + await demo_1_dirty_read() + await demo_2_no_dirty_read() + await demo_3_non_repeatable_read() + await demo_4_no_non_repeatable_read() + await demo_5_phantom_read() + await demo_6_no_phantom_read() + + print("\nВсе демонстрации завершены!") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file From 4ed591baaf95b320cc95269317ebc80dfdc3d64b Mon Sep 17 00:00:00 2001 From: glukhov324 Date: Sun, 26 Oct 2025 17:32:46 +0700 Subject: [PATCH 2/2] add hw5 --- .github/workflows/hw5_tests.yml | 51 ++++++++ hw4_5/.coveragerc | 12 ++ hw4_5/pytest.ini | 3 + hw4_5/tests/conftest.py | 73 ++++++++++++ hw4_5/tests/test_cart.py | 198 ++++++++++++++++++++++++++++++++ hw4_5/tests/test_db.py | 10 ++ hw4_5/tests/test_item.py | 157 +++++++++++++++++++++++++ 7 files changed, 504 insertions(+) create mode 100644 .github/workflows/hw5_tests.yml create mode 100644 hw4_5/.coveragerc create mode 100644 hw4_5/pytest.ini create mode 100644 hw4_5/tests/conftest.py create mode 100644 hw4_5/tests/test_cart.py create mode 100644 hw4_5/tests/test_db.py create mode 100644 hw4_5/tests/test_item.py diff --git a/.github/workflows/hw5_tests.yml b/.github/workflows/hw5_tests.yml new file mode 100644 index 00000000..57a9f7a9 --- /dev/null +++ b/.github/workflows/hw5_tests.yml @@ -0,0 +1,51 @@ +name: "HW5 Tests" + +on: + pull_request: + branches: [ main ] + paths: [ 'hw4_5/**' ] + push: + branches: [ main ] + paths: [ 'hw4_5/**' ] + +jobs: + tests-hw5: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + + env: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/postgres + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: hw4_5/app/requirements.txt + + - name: Install deps + run: pip install -r hw4_5/requirements.txt + + - name: Run tests (hw5) + working-directory: hw4_5 + env: + PYTHONPATH: ${{ github.workspace }}/hw4_5 + run: pytest -q --cov=src --cov-report=term-missing + + - name: DB alembic migrations + working-directory: hw4_5 + env: + PYTHONPATH: ${{ github.workspace }}/hw4_5 + run: PYTHONPATH=. alembic upgrade head \ No newline at end of file diff --git a/hw4_5/.coveragerc b/hw4_5/.coveragerc new file mode 100644 index 00000000..198cfc9b --- /dev/null +++ b/hw4_5/.coveragerc @@ -0,0 +1,12 @@ +[run] +source = src +branch = True +concurrency = gevent +context = test + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError \ No newline at end of file diff --git a/hw4_5/pytest.ini b/hw4_5/pytest.ini new file mode 100644 index 00000000..af5970c0 --- /dev/null +++ b/hw4_5/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests \ No newline at end of file diff --git a/hw4_5/tests/conftest.py b/hw4_5/tests/conftest.py new file mode 100644 index 00000000..1e0e62f3 --- /dev/null +++ b/hw4_5/tests/conftest.py @@ -0,0 +1,73 @@ +import asyncio +import pytest +import pytest_asyncio +import httpx +from httpx._transports.asgi import ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.pool import NullPool +from sqlalchemy import text + +from src.config import settings +from src.models import Base + + + +test_engine = create_async_engine( + settings.DATABASE_URL, + poolclass=NullPool, + echo=False, +) + +TestingSessionLocal = async_sessionmaker( + bind=test_engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +@pytest.fixture(scope="session", autouse=True) +def init_test_db(): + asyncio.run(_setup_db()) + yield + asyncio.run(_teardown_db()) + + +async def _setup_db(): + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + +async def _teardown_db(): + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(autouse=True) +async def clean_db(): + async with test_engine.begin() as conn: + for table in reversed(Base.metadata.sorted_tables): + await conn.execute(text(f"TRUNCATE {table.name} RESTART IDENTITY CASCADE")) + + +@pytest_asyncio.fixture +async def client(): + from src import main + from src.db.deps import get_db + + async def override_get_db(): + async with TestingSessionLocal() as session: + print(f"→ OPEN session {id(session)}") + try: + yield session + finally: + print(f"← CLOSE session {id(session)}") + + pg_app = main.pg_app + pg_app.dependency_overrides[get_db] = override_get_db + + transport = ASGITransport(app=pg_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + pg_app.dependency_overrides.clear() \ No newline at end of file diff --git a/hw4_5/tests/test_cart.py b/hw4_5/tests/test_cart.py new file mode 100644 index 00000000..46a2a147 --- /dev/null +++ b/hw4_5/tests/test_cart.py @@ -0,0 +1,198 @@ +import httpx +from http import HTTPStatus +from uuid import UUID +import pytest + + +class TestCartAPI: + + @pytest.mark.asyncio + async def test_create_cart(self, client: httpx.AsyncClient): + response = await client.post("/carts") + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert "id" in data + UUID(data["id"]) + assert response.headers["Location"] == f"/carts/{data['id']}" + + @pytest.mark.asyncio + async def test_get_cart_not_found(self, client: httpx.AsyncClient): + fake_id = "12345678-1234-5678-1234-567812345678" + response = await client.get(f"/carts/{fake_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["msg"] == "Корзина не найдена" + + @pytest.mark.asyncio + async def test_get_empty_cart(self, client: httpx.AsyncClient): + cart = (await client.post("/carts")).json() + cart_id = cart["id"] + + response = await client.get(f"/carts/{cart_id}") + assert response.status_code == HTTPStatus.OK + cart_data = response.json() + assert cart_data["items"] == [] + assert cart_data["price"] == 0.0 + + @pytest.mark.asyncio + async def test_get_cart_with_items(self, client: httpx.AsyncClient): + item_resp = await client.post("/items", json={"name": "Phone", "price": 500.0}) + item = item_resp.json() + item_id = item["id"] + + cart_resp = await client.post("/carts") + cart_id = cart_resp.json()["id"] + + await client.post(f"/carts/{cart_id}/add/{item_id}") + + response = await client.get(f"/carts/{cart_id}") + assert response.status_code == HTTPStatus.OK + cart = response.json() + assert cart["id"] == cart_id + assert cart["price"] == 500.0 + assert len(cart["items"]) == 1 + + cart_item = cart["items"][0] + assert cart_item["id"] == item_id + assert cart_item["name"] == "Phone" + assert cart_item["quantity"] == 1.0 + assert cart_item["available"] is True + + @pytest.mark.asyncio + async def test_add_item_twice_increases_quantity(self, client: httpx.AsyncClient): + item = (await client.post("/items", json={"name": "Phone", "price": 500.0})).json() + item_id = item["id"] + + cart = (await client.post("/carts")).json() + cart_id = cart["id"] + + await client.post(f"/carts/{cart_id}/add/{item_id}") + await client.post(f"/carts/{cart_id}/add/{item_id}") + + response = await client.get(f"/carts/{cart_id}") + cart_data = response.json() + assert len(cart_data["items"]) == 1 + assert cart_data["items"][0]["quantity"] == 2.0 + assert cart_data["price"] == 1000.0 + + @pytest.mark.asyncio + async def test_list_carts_with_min_price(self, client: httpx.AsyncClient): + cheap = (await client.post("/items", json={"name": "Cheap", "price": 10.0})).json() + expensive = (await client.post("/items", json={"name": "Expensive", "price": 200.0})).json() + + cart1 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart1['id']}/add/{cheap['id']}") + + cart2 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart2['id']}/add/{expensive['id']}") + + response = await client.get("/carts", params={"min_price": 100.0}) + carts = response.json() + assert len(carts) == 1 + assert carts[0]["id"] == cart2["id"] + assert carts[0]["price"] == 200.0 + + @pytest.mark.asyncio + async def test_list_carts_with_max_price(self, client: httpx.AsyncClient): + cheap = (await client.post("/items", json={"name": "Cheap", "price": 10.0})).json() + expensive = (await client.post("/items", json={"name": "Expensive", "price": 200.0})).json() + + cart1 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart1['id']}/add/{cheap['id']}") + + cart2 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart2['id']}/add/{expensive['id']}") + + response = await client.get("/carts", params={"max_price": 100.0}) + carts = response.json() + assert len(carts) == 1 + assert carts[0]["id"] == cart1["id"] + assert carts[0]["price"] == 10.0 + + response = await client.get("/carts", params={"max_price": 5.0}) + carts = response.json() + assert len(carts) == 0 + + @pytest.mark.asyncio + async def test_list_carts_with_max_quantity(self, client: httpx.AsyncClient): + item = (await client.post("/items", json={"name": "Test", "price": 10.0})).json() + + cart1 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart1['id']}/add/{item['id']}") + + cart2 = (await client.post("/carts")).json() + await client.post(f"/carts/{cart2['id']}/add/{item['id']}") + await client.post(f"/carts/{cart2['id']}/add/{item['id']}") + + resp = await client.get("/carts", params={"max_quantity": 1}) + carts = resp.json() + assert len(carts) == 1 + assert carts[0]["id"] == cart1["id"] + + @pytest.mark.asyncio + async def test_list_carts_min_quantity_zero(self, client: httpx.AsyncClient): + empty_cart = (await client.post("/carts")).json() + + item = (await client.post("/items", json={"name": "Test", "price": 5.0})).json() + filled_cart = (await client.post("/carts")).json() + await client.post(f"/carts/{filled_cart['id']}/add/{item['id']}") + + resp = await client.get("/carts", params={"min_quantity": 0}) + carts = resp.json() + assert len(carts) == 2 + cart_ids = {c["id"] for c in carts} + assert empty_cart["id"] in cart_ids + assert filled_cart["id"] in cart_ids + + @pytest.mark.asyncio + async def test_add_item_to_cart_cart_not_found(self, client: httpx.AsyncClient): + fake_cart_id = "12345678-1234-5678-1234-567812345678" + item = (await client.post("/items", json={"name": "Test", "price": 1.0})).json() + response = await client.post(f"/carts/{fake_cart_id}/add/{item['id']}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["msg"] == "Ничего не найдено" + + @pytest.mark.asyncio + async def test_add_item_to_cart_item_not_found(self, client: httpx.AsyncClient): + cart = (await client.post("/carts")).json() + fake_item_id = "87654321-4321-8765-4321-876543210987" + response = await client.post(f"/carts/{cart['id']}/add/{fake_item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["msg"] == "Ничего не найдено" + + @pytest.mark.asyncio + async def test_add_deleted_item_to_cart(self, client: httpx.AsyncClient): + item = (await client.post("/items", json={"name": "DeletedItem", "price": 99.0})).json() + await client.delete(f"/items/{item['id']}") + + cart = (await client.post("/carts")).json() + response = await client.post(f"/carts/{cart['id']}/add/{item['id']}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["msg"] == "Ничего не найдено" + + @pytest.mark.asyncio + async def test_get_cart_with_deleted_item(self, client: httpx.AsyncClient): + item = (await client.post("/items", json={"name": "ToBeDeleted", "price": 100.0})).json() + item_id = item["id"] + + cart = (await client.post("/carts")).json() + cart_id = cart["id"] + await client.post(f"/carts/{cart_id}/add/{item_id}") + + await client.delete(f"/items/{item_id}") + + response = await client.get(f"/carts/{cart_id}") + assert response.status_code == HTTPStatus.OK + cart_data = response.json() + assert len(cart_data["items"]) == 0 + + @pytest.mark.asyncio + async def test_list_carts_max_quantity_zero(self, client: httpx.AsyncClient): + empty_cart = (await client.post("/carts")).json() + item = (await client.post("/items", json={"name": "Test", "price": 10.0})).json() + filled_cart = (await client.post("/carts")).json() + await client.post(f"/carts/{filled_cart['id']}/add/{item['id']}") + + resp = await client.get("/carts", params={"max_quantity": 0, "offset": 0, "limit": 10}) + carts = resp.json() + assert len(carts) == 1 + assert carts[0]["id"] == empty_cart["id"] \ No newline at end of file diff --git a/hw4_5/tests/test_db.py b/hw4_5/tests/test_db.py new file mode 100644 index 00000000..307e3ab6 --- /dev/null +++ b/hw4_5/tests/test_db.py @@ -0,0 +1,10 @@ +import pytest +from src.db.deps import get_db + + + +@pytest.mark.asyncio +async def test_get_db(): + async for session in get_db(): + assert session is not None + break \ No newline at end of file diff --git a/hw4_5/tests/test_item.py b/hw4_5/tests/test_item.py new file mode 100644 index 00000000..1fa996b5 --- /dev/null +++ b/hw4_5/tests/test_item.py @@ -0,0 +1,157 @@ +import httpx +from http import HTTPStatus +from uuid import UUID, uuid4 +import pytest + + +class TestItemAPI: + + @pytest.mark.asyncio + async def test_create_item(self, client: httpx.AsyncClient): + response = await client.post("/items", json={"name": "Laptop", "price": 999.99}) + assert response.status_code == HTTPStatus.CREATED + data = response.json() + UUID(data["id"]) + + response = await client.get(f"/items/{data["id"]}") + assert response.status_code == HTTPStatus.OK + response = response.json() + assert response["name"] == "Laptop" + assert response["price"] == 999.99 + assert response["deleted"] is False + + @pytest.mark.asyncio + async def test_get_item_not_found(self, client: httpx.AsyncClient): + fake_id = "12345678-1234-5678-1234-567812345678" + response = await client.get(f"/items/{fake_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["msg"] == "Ничего не найдено" + + + @pytest.mark.asyncio + async def test_list_items_with_min_price(self, client: httpx.AsyncClient): + await client.post("/items", json={"name": "Cheap", "price": 5.0}) + await client.post("/items", json={"name": "Expensive", "price": 150.0}) + + resp = await client.get("/items", params={"min_price": 100.0}) + items = resp.json() + assert len(items) == 1 + assert all(i["price"] >= 100.0 for i in items) + + @pytest.mark.asyncio + async def test_list_items_with_max_price(self, client: httpx.AsyncClient): + await client.post("/items", json={"name": "Cheap", "price": 10.0}) + await client.post("/items", json={"name": "Expensive", "price": 200.0}) + + resp = await client.get("/items", params={"max_price": 50.0}) + items = resp.json() + assert len(items) == 1 + assert items[0]["name"] == "Cheap" + + @pytest.mark.asyncio + async def test_update_item_full(self, client: httpx.AsyncClient): + id = uuid4() + resp = (await client.put(f"/items/{id}", json={"name": "New", "price": 200.0})) + assert resp.json()["msg"] == "Ничего не найдено" + + item = (await client.post("/items", json={"name": "Old", "price": 100.0})).json() + item_id = item["id"] + updated = (await client.put(f"/items/{item_id}", json={"name": "New", "price": 200.0})).json() + assert updated["name"] == "New" + assert updated["price"] == 200.0 + assert updated["deleted"] is False + + @pytest.mark.asyncio + async def test_update_item_partial(self, client: httpx.AsyncClient): + + id = uuid4() + resp = (await client.patch(f"/items/{id}", json={"price": 150.0})) + assert resp.json()["msg"] == "Ничего не найдено" + + item = (await client.post("/items", json={"name": "Original", "price": 100.0})).json() + item_id = item["id"] + patched = (await client.patch(f"/items/{item_id}", json={"price": 150.0})).json() + assert patched["name"] == "Original" + assert patched["price"] == 150.0 + assert patched["deleted"] is False + + @pytest.mark.asyncio + async def test_soft_delete_item(self, client: httpx.AsyncClient): + item = (await client.post("/items", json={"name": "ToBeDeleted", "price": 10.0})).json() + item_id = item["id"] + assert item["deleted"] is False + + del_resp = await client.delete(f"/items/{item_id}") + assert del_resp.status_code == HTTPStatus.OK + deleted_item = del_resp.json() + assert deleted_item["id"] == item_id + assert deleted_item["deleted"] is True + + list_resp = await client.get("/items") + items = list_resp.json() + assert all(i["id"] != item_id for i in items) + + list_with_deleted = await client.get("/items", params={"show_deleted": True}) + items = list_with_deleted.json() + deleted_item = next((i for i in items if i["id"] == item_id), None) + assert deleted_item is not None + assert deleted_item["deleted"] is True + + @pytest.mark.asyncio + async def test_get_items_pagination(self, client: httpx.AsyncClient): + for i in range(5): + await client.post("/items", json={"name": f"Item{i}", "price": float(i + 10)}) + + resp = await client.get("/items", params={"offset": 2, "limit": 2}) + items = resp.json() + assert len(items) == 2 + assert items[0]["name"] == "Item2" + assert items[1]["name"] == "Item3" + + @pytest.mark.asyncio + async def test_get_items_show_deleted_with_price_filter(self, client: httpx.AsyncClient): + item1 = (await client.post("/items", json={"name": "Active", "price": 50.0})).json() + item2 = (await client.post("/items", json={"name": "DeletedCheap", "price": 10.0})).json() + item3 = (await client.post("/items", json={"name": "DeletedExpensive", "price": 200.0})).json() + + await client.delete(f"/items/{item2['id']}") + await client.delete(f"/items/{item3['id']}") + + resp = await client.get("/items", params={"show_deleted": True, "min_price": 100.0}) + items = resp.json() + assert len(items) == 1 + assert items[0]["id"] == item3["id"] + assert items[0]["deleted"] is True + + @pytest.mark.asyncio + async def test_get_items_offset_without_limit(self, client: httpx.AsyncClient): + for i in range(3): + await client.post("/items", json={"name": f"Item{i}", "price": 10.0}) + + resp = await client.get("/items", params={"offset": 1}) + items = resp.json() + assert len(items) == 2 + assert items[0][("na" + "me")] == "Item1" + + @pytest.mark.asyncio + async def test_get_items_show_deleted_with_price_filter(self, client: httpx.AsyncClient): + item1 = (await client.post("/items", json={"name": "Active", "price": 50.0})).json() + item2 = (await client.post("/items", json={"name": "DeletedCheap", "price": 10.0})).json() + item3 = (await client.post("/items", json={"name": "DeletedExpensive", "price": 200.0})).json() + + await client.delete(f"/items/{item2['id']}") + await client.delete(f"/items/{item3['id']}") + + resp = await client.get("/items", params={"show_deleted": True, "min_price": 100.0}) + items = resp.json() + assert len(items) == 1 + assert items[0]["id"] == item3["id"] + assert items[0]["deleted"] is True + + @pytest.mark.asyncio + async def test_delete_unknown_item(self, client: httpx.AsyncClient): + + id = uuid4() + resp = await client.delete(f"/items/{id}") + assert resp.json()["msg"] == "Ничего не найдено" \ No newline at end of file