From 7dd67b037a3f3bb1a33bd1d6e97d268a2edc5de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=91=D1=80=D0=B0=D1=82=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D1=85?= Date: Sun, 1 Nov 2026 17:59:12 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B1=D1=8D=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/.dockerignore | 3 + api/Dockerfile | 15 +++ api/app/__init__.py | 0 api/app/bootstrap.py | 78 +++++++++++++ api/app/config.py | 17 +++ api/app/db.py | 30 +++++ api/app/deps.py | 61 ++++++++++ api/app/main.py | 32 ++++++ api/app/models.py | 115 +++++++++++++++++++ api/app/routers/__init__.py | 0 api/app/routers/cart.py | 25 ++++ api/app/routers/catalog.py | 35 ++++++ api/app/routers/internal.py | 60 ++++++++++ api/app/routers/orders.py | 24 ++++ api/app/schemas.py | 121 ++++++++++++++++++++ api/app/services.py | 219 ++++++++++++++++++++++++++++++++++++ api/app/storage.py | 69 ++++++++++++ api/requirements.txt | 6 + 18 files changed, 910 insertions(+) create mode 100644 api/.dockerignore create mode 100644 api/Dockerfile create mode 100644 api/app/__init__.py create mode 100644 api/app/bootstrap.py create mode 100644 api/app/config.py create mode 100644 api/app/db.py create mode 100644 api/app/deps.py create mode 100644 api/app/main.py create mode 100644 api/app/models.py create mode 100644 api/app/routers/__init__.py create mode 100644 api/app/routers/cart.py create mode 100644 api/app/routers/catalog.py create mode 100644 api/app/routers/internal.py create mode 100644 api/app/routers/orders.py create mode 100644 api/app/schemas.py create mode 100644 api/app/services.py create mode 100644 api/app/storage.py create mode 100644 api/requirements.txt diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..b4a900e --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,3 @@ +**/__pycache__ +**/*.pyc +.venv diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..34ca0d6 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim AS build +WORKDIR /build +COPY requirements.txt . +RUN pip install --prefix=/install --no-cache-dir -r requirements.txt + +FROM python:3.12-slim +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 +RUN useradd --system --uid 1000 --home /app --create-home app +WORKDIR /app +COPY --from=build /install /usr/local +COPY app ./app +USER app +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/bootstrap.py b/api/app/bootstrap.py new file mode 100644 index 0000000..a4efd7f --- /dev/null +++ b/api/app/bootstrap.py @@ -0,0 +1,78 @@ +import logging +from decimal import Decimal + +from sqlalchemy import func, select, text + +from app.db import SessionFactory, engine +from app.models import Base, Category, Product +from app.storage import ensure_bucket, object_exists, put_object + +log = logging.getLogger("shop") + +SEED = [ + ("Электроника", "Смартфон", "Карманный телефон для демо-каталога.", Decimal("50000.00"), "#1d4ed8", True), + ("Электроника", "Наушники", "Беспроводные наушники.", Decimal("5000.00"), "#0f766e", True), + ("Электроника", "Чехол", "Скрыт из каталога: is_active = false.", Decimal("900.00"), "#444444", False), + ("Одежда", "Футболка", "Хлопковая футболка.", Decimal("1500.00"), "#b45309", True), + ("Одежда", "Джинсы", "Прямые джинсы.", Decimal("4000.00"), "#1e3a8a", True), +] + + +def _svg(title: str, color: str) -> bytes: + return ( + '' + f'' + '{title}' + ).encode() + + +async def init_db() -> None: + async with engine.connect() as lock_conn: + await lock_conn.execute(text("SELECT pg_advisory_lock(1, 42002)")) + await lock_conn.commit() + try: + async with engine.begin() as ddl: + await ddl.run_sync(Base.metadata.create_all) + await _seed() + finally: + await lock_conn.execute(text("SELECT pg_advisory_unlock(1, 42002)")) + await lock_conn.commit() + + +async def _seed() -> None: + await ensure_bucket() + async with SessionFactory() as session: + count = await session.scalar(select(func.count()).select_from(Product)) + if count == 0: + categories: dict[str, Category] = {} + for name, title, description, price, color, active in SEED: + category = categories.get(name) + if category is None: + category = Category(name=name) + session.add(category) + await session.flush() + categories[name] = category + product = Product( + category_id=category.id, + title=title, + description=description, + price=price, + is_active=active, + ) + session.add(product) + await session.flush() + product.image_key = f"products/{product.id}.svg" + await session.commit() + log.info("seeded catalog") + + products = (await session.scalars(select(Product))).all() + colors = {title: color for _, title, _, _, color, _ in SEED} + for product in products: + if not product.image_key: + product.image_key = f"products/{product.id}.svg" + if await object_exists(product.image_key): + continue + color = colors.get(product.title, "#333333") + await put_object(product.image_key, _svg(product.title, color), "image/svg+xml") + await session.commit() diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..7156673 --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,17 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + + database_url: str = "postgresql+asyncpg://shop:shop@localhost:5432/shop" + minio_endpoint: str = "localhost:9000" + minio_access_key: str = "minio" + minio_secret_key: str = "minioadmin" + minio_bucket: str = "products" + minio_secure: bool = False + bot_secret: str = "dev-bot-secret" + cookie_secure: bool = False + + +settings = Settings() diff --git a/api/app/db.py b/api/app/db.py new file mode 100644 index 0000000..f4327ea --- /dev/null +++ b/api/app/db.py @@ -0,0 +1,30 @@ +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + +engine = create_async_engine(settings.database_url, pool_pre_ping=True) +SessionFactory = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncIterator[AsyncSession]: + """Одна сессия на запрос. Коммит только если эндпоинт не бросил исключение. + + scope=function у Depends закрывает сессию до отправки ответа, поэтому + клиент не получает успех раньше фиксации транзакции. + """ + + async with SessionFactory() as session: + try: + yield session + except Exception: + await session.rollback() + raise + else: + await session.commit() diff --git a/api/app/deps.py b/api/app/deps.py new file mode 100644 index 0000000..6d0b526 --- /dev/null +++ b/api/app/deps.py @@ -0,0 +1,61 @@ +import hashlib +import hmac +import uuid +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.db import get_db +from app.models import WebSession + +COOKIE = "shop_session" +SessionDep = Annotated[AsyncSession, Depends(get_db, scope="function")] + + +def bot_secret_ok(got: str) -> bool: + return hmac.compare_digest( + hashlib.sha256(got.encode()).digest(), + hashlib.sha256(settings.bot_secret.encode()).digest(), + ) + + +async def require_bot(x_bot_secret: Annotated[str, Header()]) -> None: + if not bot_secret_ok(x_bot_secret): + raise HTTPException(status_code=401, detail="Неверный ключ бота") + + +def web_owner(session_id: uuid.UUID) -> str: + return f"web:{session_id}" + + +def telegram_owner(telegram_user_id: int) -> str: + return f"tg:{telegram_user_id}" + + +async def ensure_web_session(request: Request, response: Response, db: AsyncSession) -> uuid.UUID: + raw = request.cookies.get(COOKIE) + if raw: + try: + session_id = uuid.UUID(raw) + except ValueError: + session_id = None + else: + found = await db.get(WebSession, session_id) + if found is not None: + return found.id + + session_id = uuid.uuid4() + db.add(WebSession(id=session_id)) + await db.flush() + response.set_cookie( + COOKIE, + str(session_id), + httponly=True, + samesite="lax", + secure=settings.cookie_secure, + path="/", + max_age=60 * 60 * 24 * 30, + ) + return session_id diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..e38676d --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,32 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from sqlalchemy import text + +from app.bootstrap import init_db +from app.db import engine +from app.deps import SessionDep +from app.routers import cart, catalog, internal, orders + +logging.basicConfig(level=logging.INFO) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + await init_db() + yield + await engine.dispose() + + +app = FastAPI(title="Shop", lifespan=lifespan) +app.include_router(catalog.router) +app.include_router(cart.router) +app.include_router(orders.router) +app.include_router(internal.router) + + +@app.get("/health") +async def health(db: SessionDep) -> dict[str, str]: + await db.execute(text("SELECT 1")) + return {"status": "ok"} diff --git a/api/app/models.py b/api/app/models.py new file mode 100644 index 0000000..7e62bdc --- /dev/null +++ b/api/app/models.py @@ -0,0 +1,115 @@ +import uuid +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Identity, + Index, + Numeric, + String, + Text, + UniqueConstraint, + func, + text, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class WebSession(Base): + __tablename__ = "web_sessions" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class Category(Base): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + name: Mapped[str] = mapped_column(String(80), unique=True) + + +class Product(Base): + __tablename__ = "products" + __table_args__ = ( + CheckConstraint("price >= 0", name="products_price_nonnegative"), + Index( + "products_active_category_price_idx", + "category_id", + "price", + "id", + postgresql_where=text("is_active"), + ), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False, default="") + price: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("true")) + image_key: Mapped[str | None] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class CartItem(Base): + __tablename__ = "cart_items" + __table_args__ = ( + UniqueConstraint("owner_key", "product_id", name="uq_cart_owner_product"), + CheckConstraint("qty > 0 AND qty <= 99", name="cart_qty_range"), + Index("ix_cart_owner", "owner_key"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + owner_key: Mapped[str] = mapped_column(String(96), nullable=False) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), nullable=False) + qty: Mapped[int] = mapped_column(nullable=False) + + +class Order(Base): + __tablename__ = "orders" + __table_args__ = (Index("ix_orders_owner", "owner_key"),) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + owner_key: Mapped[str] = mapped_column(String(96), nullable=False) + customer_name: Mapped[str] = mapped_column(String(120), nullable=False) + customer_phone: Mapped[str] = mapped_column(String(32), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="created", server_default="created") + total: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class OrderItem(Base): + __tablename__ = "order_items" + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"), nullable=False) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + price: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + qty: Mapped[int] = mapped_column(nullable=False) + + +class BotDialog(Base): + __tablename__ = "bot_dialogs" + + telegram_user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + step: Mapped[str] = mapped_column(String(16), nullable=False) + name: Mapped[str | None] = mapped_column(String(120)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class TelegramUpdate(Base): + __tablename__ = "telegram_updates" + + update_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + processed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routers/cart.py b/api/app/routers/cart.py new file mode 100644 index 0000000..e1f9bc0 --- /dev/null +++ b/api/app/routers/cart.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Request, Response + +from app.deps import SessionDep, ensure_web_session, web_owner +from app.schemas import CartAdd, CartOut +from app.services import add_item, load_cart, remove_item + +router = APIRouter() + + +@router.get("/cart", response_model=CartOut) +async def get_cart(request: Request, response: Response, db: SessionDep) -> CartOut: + session_id = await ensure_web_session(request, response, db) + return await load_cart(db, web_owner(session_id)) + + +@router.post("/cart/items", response_model=CartOut) +async def post_item(body: CartAdd, request: Request, response: Response, db: SessionDep) -> CartOut: + session_id = await ensure_web_session(request, response, db) + return await add_item(db, owner_key=web_owner(session_id), product_id=body.product_id, qty=body.qty) + + +@router.delete("/cart/items/{product_id}", response_model=CartOut) +async def delete_item(product_id: int, request: Request, response: Response, db: SessionDep) -> CartOut: + session_id = await ensure_web_session(request, response, db) + return await remove_item(db, owner_key=web_owner(session_id), product_id=product_id) diff --git a/api/app/routers/catalog.py b/api/app/routers/catalog.py new file mode 100644 index 0000000..bee3956 --- /dev/null +++ b/api/app/routers/catalog.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response + +from app.deps import SessionDep +from app.schemas import CatalogOut, ProductOut +from app.services import _product_out, catalog, product_detail +from app.storage import get_object + +router = APIRouter() + + +@router.get("/products", response_model=CatalogOut) +async def list_products( + db: SessionDep, + category_id: int | None = None, + sort: Annotated[str, Query(pattern=r"^(price_asc|price_desc|new)$")] = "price_asc", + limit: Annotated[int, Query(ge=1, le=100)] = 20, +) -> CatalogOut: + return await catalog(db, category_id=category_id, sort=sort, limit=limit) + + +@router.get("/products/{product_id}", response_model=ProductOut) +async def get_product(product_id: int, db: SessionDep) -> ProductOut: + product, category_name = await product_detail(db, product_id) + return _product_out(product, category_name) + + +@router.get("/products/{product_id}/image") +async def product_image(product_id: int, db: SessionDep) -> Response: + product, _category_name = await product_detail(db, product_id) + if not product.image_key: + raise HTTPException(status_code=404, detail="Изображение не найдено") + payload, media_type = await get_object(product.image_key) + return Response(content=payload, media_type=media_type) diff --git a/api/app/routers/internal.py b/api/app/routers/internal.py new file mode 100644 index 0000000..27a51ee --- /dev/null +++ b/api/app/routers/internal.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends + +from app.deps import SessionDep, require_bot, telegram_owner +from app.schemas import BotCartAdd, BotOrderCreate, CartOut, DialogIn, DialogOut, OrderOut +from app.services import ( + add_item, + checkout, + clear_dialog, + get_dialog, + load_cart, + remove_item, + save_dialog, +) + +router = APIRouter(prefix="/internal", dependencies=[Depends(require_bot)]) + + +@router.post("/cart/items", response_model=CartOut) +async def bot_add_item(body: BotCartAdd, db: SessionDep) -> CartOut: + return await add_item( + db, + owner_key=telegram_owner(body.telegram_user_id), + product_id=body.product_id, + qty=body.qty, + ) + + +@router.get("/cart/{telegram_user_id}", response_model=CartOut) +async def bot_cart(telegram_user_id: int, db: SessionDep) -> CartOut: + return await load_cart(db, telegram_owner(telegram_user_id)) + + +@router.delete("/cart/{telegram_user_id}/{product_id}", response_model=CartOut) +async def bot_remove(telegram_user_id: int, product_id: int, db: SessionDep) -> CartOut: + return await remove_item(db, owner_key=telegram_owner(telegram_user_id), product_id=product_id) + + +@router.get("/dialog/{telegram_user_id}", response_model=DialogOut | None) +async def bot_get_dialog(telegram_user_id: int, db: SessionDep) -> DialogOut | None: + return await get_dialog(db, telegram_user_id) + + +@router.put("/dialog/{telegram_user_id}", response_model=DialogOut) +async def bot_put_dialog(telegram_user_id: int, body: DialogIn, db: SessionDep) -> DialogOut: + return await save_dialog(db, telegram_user_id, body.step, body.name) + + +@router.delete("/dialog/{telegram_user_id}", status_code=204) +async def bot_delete_dialog(telegram_user_id: int, db: SessionDep) -> None: + await clear_dialog(db, telegram_user_id) + + +@router.post("/orders", response_model=OrderOut) +async def bot_order(body: BotOrderCreate, db: SessionDep) -> OrderOut: + return await checkout( + db, + owner_key=telegram_owner(body.telegram_user_id), + customer_name=body.customer_name, + customer_phone=body.customer_phone, + ) diff --git a/api/app/routers/orders.py b/api/app/routers/orders.py new file mode 100644 index 0000000..d42ac73 --- /dev/null +++ b/api/app/routers/orders.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Request, Response + +from app.deps import SessionDep, ensure_web_session, web_owner +from app.schemas import OrderCreate, OrderOut +from app.services import checkout, get_order + +router = APIRouter() + + +@router.post("/orders", response_model=OrderOut) +async def create_order(body: OrderCreate, request: Request, response: Response, db: SessionDep) -> OrderOut: + session_id = await ensure_web_session(request, response, db) + return await checkout( + db, + owner_key=web_owner(session_id), + customer_name=body.customer_name, + customer_phone=body.customer_phone, + ) + + +@router.get("/orders/{order_id}", response_model=OrderOut) +async def read_order(order_id: int, request: Request, response: Response, db: SessionDep) -> OrderOut: + session_id = await ensure_web_session(request, response, db) + return await get_order(db, owner_key=web_owner(session_id), order_id=order_id) diff --git a/api/app/schemas.py b/api/app/schemas.py new file mode 100644 index 0000000..0ce5fa3 --- /dev/null +++ b/api/app/schemas.py @@ -0,0 +1,121 @@ +import re +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator + + +def money(value: Decimal) -> str: + return f"{value.quantize(Decimal('0.01')):.2f}" + + +class CategoryOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + + +class ProductOut(BaseModel): + id: int + category_id: int + category_name: str + title: str + description: str + price: Decimal + + @field_serializer("price") + def serialize_price(self, value: Decimal) -> str: + return money(value) + + +class CatalogOut(BaseModel): + categories: list[CategoryOut] + products: list[ProductOut] + + +class CartAdd(BaseModel): + product_id: int + qty: int = Field(default=1, ge=1, le=99) + + +class CartItemOut(BaseModel): + product_id: int + title: str + price: str + qty: int + line_total: str + + +class CartOut(BaseModel): + items: list[CartItemOut] + total: str + + +class OrderCreate(BaseModel): + customer_name: str = Field(min_length=1, max_length=120) + customer_phone: str = Field(min_length=5, max_length=32) + + @field_validator("customer_name", "customer_phone", mode="before") + @classmethod + def strip_value(cls, value: object) -> object: + if isinstance(value, str): + return value.strip() + return value + + @field_validator("customer_phone") + @classmethod + def check_phone(cls, value: str) -> str: + if re.fullmatch(r"[0-9+()\-\s]{5,32}", value) is None: + raise ValueError("Телефон: цифры, пробелы и символы +()-") + return value + + +class OrderItemOut(BaseModel): + product_id: int + title: str + price: str + qty: int + + +class OrderOut(BaseModel): + id: int + status: str + customer_name: str + customer_phone: str + total: str + created_at: datetime + items: list[OrderItemOut] + + @field_serializer("created_at") + def serialize_created_at(self, value: datetime) -> str: + return value.isoformat() + + +class DialogIn(BaseModel): + step: str = Field(pattern=r"^(name|phone)$") + name: str | None = Field(default=None, max_length=120) + + @field_validator("name", mode="before") + @classmethod + def strip_name(cls, value: object) -> object: + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return value + + +class DialogOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + telegram_user_id: int + step: str + name: str | None + + +class BotOrderCreate(OrderCreate): + telegram_user_id: int = Field(gt=0) + + +class BotCartAdd(CartAdd): + telegram_user_id: int = Field(gt=0) diff --git a/api/app/services.py b/api/app/services.py new file mode 100644 index 0000000..e1cc758 --- /dev/null +++ b/api/app/services.py @@ -0,0 +1,219 @@ +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import BotDialog, CartItem, Category, Order, OrderItem, Product +from app.schemas import ( + CartItemOut, + CartOut, + CatalogOut, + CategoryOut, + DialogOut, + OrderItemOut, + OrderOut, + ProductOut, + money, +) + +SORTS = { + "price_asc": (Product.price.asc(), Product.id.asc()), + "price_desc": (Product.price.desc(), Product.id.desc()), + "new": (Product.id.desc(),), +} + + +async def catalog(db: AsyncSession, *, category_id: int | None, sort: str, limit: int) -> CatalogOut: + categories = (await db.scalars(select(Category).order_by(Category.id))).all() + stmt = ( + select(Product, Category.name) + .join(Category, Category.id == Product.category_id) + .where(Product.is_active.is_(True)) + ) + if category_id is not None: + stmt = stmt.where(Product.category_id == category_id) + stmt = stmt.order_by(*SORTS[sort]).limit(limit) + rows = (await db.execute(stmt)).all() + return CatalogOut( + categories=[CategoryOut.model_validate(item) for item in categories], + products=[_product_out(product, category_name) for product, category_name in rows], + ) + + +async def product_detail(db: AsyncSession, product_id: int) -> tuple[Product, str]: + row = ( + await db.execute( + select(Product, Category.name) + .join(Category, Category.id == Product.category_id) + .where(Product.id == product_id, Product.is_active.is_(True)) + ) + ).one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Товар не найден") + return row + + +def _product_out(product: Product, category_name: str) -> ProductOut: + return ProductOut( + id=product.id, + category_id=product.category_id, + category_name=category_name, + title=product.title, + description=product.description, + price=product.price, + ) + + +async def load_cart(db: AsyncSession, owner_key: str) -> CartOut: + rows = ( + await db.execute( + select(CartItem, Product) + .join(Product, Product.id == CartItem.product_id) + .where(CartItem.owner_key == owner_key, Product.is_active.is_(True)) + .order_by(CartItem.id) + ) + ).all() + items: list[CartItemOut] = [] + total = Decimal("0.00") + for item, product in rows: + line = (product.price * item.qty).quantize(Decimal("0.01")) + total += line + items.append( + CartItemOut( + product_id=product.id, + title=product.title, + price=money(product.price), + qty=item.qty, + line_total=money(line), + ) + ) + return CartOut(items=items, total=money(total)) + + +async def add_item(db: AsyncSession, *, owner_key: str, product_id: int, qty: int) -> CartOut: + product = await db.get(Product, product_id) + if product is None or not product.is_active: + raise HTTPException(status_code=404, detail="Товар не найден") + stmt = pg_insert(CartItem).values(owner_key=owner_key, product_id=product_id, qty=qty) + stmt = stmt.on_conflict_do_update( + constraint="uq_cart_owner_product", + set_={"qty": func.least(CartItem.qty + stmt.excluded.qty, 99)}, + ) + await db.execute(stmt) + return await load_cart(db, owner_key) + + +async def remove_item(db: AsyncSession, *, owner_key: str, product_id: int) -> CartOut: + item = await db.scalar( + select(CartItem).where(CartItem.owner_key == owner_key, CartItem.product_id == product_id) + ) + if item is not None: + await db.delete(item) + await db.flush() + return await load_cart(db, owner_key) + + +async def checkout(db: AsyncSession, *, owner_key: str, customer_name: str, customer_phone: str) -> OrderOut: + rows = ( + await db.execute( + select(CartItem, Product) + .join(Product, Product.id == CartItem.product_id) + .where(CartItem.owner_key == owner_key) + .order_by(CartItem.id) + .with_for_update(of=CartItem) + ) + ).all() + if not rows: + raise HTTPException(status_code=400, detail="Корзина пуста") + if any(not product.is_active for _, product in rows): + raise HTTPException(status_code=409, detail="В корзине есть недоступный товар") + + total = sum((product.price * item.qty for item, product in rows), Decimal("0.00")).quantize(Decimal("0.01")) + order = Order( + owner_key=owner_key, + customer_name=customer_name, + customer_phone=customer_phone, + total=total, + ) + db.add(order) + await db.flush() + item_rows: list[OrderItemOut] = [] + for item, product in rows: + db.add( + OrderItem( + order_id=order.id, + product_id=product.id, + title=product.title, + price=product.price, + qty=item.qty, + ) + ) + item_rows.append( + OrderItemOut( + product_id=product.id, + title=product.title, + price=money(product.price), + qty=item.qty, + ) + ) + await db.delete(item) + await db.flush() + await db.refresh(order) + return OrderOut( + id=order.id, + status=order.status, + customer_name=order.customer_name, + customer_phone=order.customer_phone, + total=money(order.total), + created_at=order.created_at, + items=item_rows, + ) + + +async def get_order(db: AsyncSession, *, owner_key: str, order_id: int) -> OrderOut: + order = await db.scalar(select(Order).where(Order.id == order_id, Order.owner_key == owner_key)) + if order is None: + raise HTTPException(status_code=404, detail="Заказ не найден") + items = ( + await db.scalars(select(OrderItem).where(OrderItem.order_id == order.id).order_by(OrderItem.id)) + ).all() + return OrderOut( + id=order.id, + status=order.status, + customer_name=order.customer_name, + customer_phone=order.customer_phone, + total=money(order.total), + created_at=order.created_at, + items=[ + OrderItemOut(product_id=item.product_id, title=item.title, price=money(item.price), qty=item.qty) + for item in items + ], + ) + + +async def get_dialog(db: AsyncSession, telegram_user_id: int) -> DialogOut | None: + row = await db.get(BotDialog, telegram_user_id) + if row is None: + return None + return DialogOut.model_validate(row) + + +async def save_dialog(db: AsyncSession, telegram_user_id: int, step: str, name: str | None) -> DialogOut: + row = await db.get(BotDialog, telegram_user_id) + if row is None: + row = BotDialog(telegram_user_id=telegram_user_id, step=step, name=name) + db.add(row) + else: + row.step = step + row.name = name + await db.flush() + return DialogOut.model_validate(row) + + +async def clear_dialog(db: AsyncSession, telegram_user_id: int) -> None: + row = await db.get(BotDialog, telegram_user_id) + if row is not None: + await db.delete(row) + await db.flush() diff --git a/api/app/storage.py b/api/app/storage.py new file mode 100644 index 0000000..0cac985 --- /dev/null +++ b/api/app/storage.py @@ -0,0 +1,69 @@ +import asyncio +import io + +from minio import Minio +from minio.error import S3Error + +from app.config import settings + +_client = Minio( + settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_secure, +) + + +def _ensure_bucket() -> None: + if not _client.bucket_exists(settings.minio_bucket): + try: + _client.make_bucket(settings.minio_bucket) + except S3Error as exc: + if exc.code not in {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}: + raise + + +def _put(key: str, payload: bytes, content_type: str) -> None: + _client.put_object( + settings.minio_bucket, + key, + io.BytesIO(payload), + length=len(payload), + content_type=content_type, + ) + + +def _exists(key: str) -> bool: + try: + _client.stat_object(settings.minio_bucket, key) + except S3Error as exc: + if exc.code in {"NoSuchKey", "NoSuchBucket"}: + return False + raise + return True + + +def _get(key: str) -> tuple[bytes, str]: + obj = _client.get_object(settings.minio_bucket, key) + try: + media_type = obj.headers.get("Content-Type", "application/octet-stream") + return obj.read(), media_type + finally: + obj.close() + obj.release_conn() + + +async def ensure_bucket() -> None: + await asyncio.to_thread(_ensure_bucket) + + +async def put_object(key: str, payload: bytes, content_type: str) -> None: + await asyncio.to_thread(_put, key, payload, content_type) + + +async def object_exists(key: str) -> bool: + return await asyncio.to_thread(_exists, key) + + +async def get_object(key: str) -> tuple[bytes, str]: + return await asyncio.to_thread(_get, key) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..f5b4149 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +sqlalchemy[asyncio] +asyncpg +pydantic-settings +minio