feat: добавил бэк

This commit is contained in:
Илья Братских 2026-11-01 17:59:12 +03:00
parent f6a817d7bd
commit 7dd67b037a
18 changed files with 910 additions and 0 deletions

3
api/.dockerignore Normal file
View File

@ -0,0 +1,3 @@
**/__pycache__
**/*.pyc
.venv

15
api/Dockerfile Normal file
View File

@ -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"]

0
api/app/__init__.py Normal file
View File

78
api/app/bootstrap.py Normal file
View File

@ -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 (
'<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360">'
f'<rect width="100%" height="100%" fill="{color}"/>'
'<text x="50%" y="54%" fill="#ffffff" font-size="36" text-anchor="middle" '
f'font-family="sans-serif">{title}</text></svg>'
).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()

17
api/app/config.py Normal file
View File

@ -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()

30
api/app/db.py Normal file
View File

@ -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()

61
api/app/deps.py Normal file
View File

@ -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

32
api/app/main.py Normal file
View File

@ -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"}

115
api/app/models.py Normal file
View File

@ -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())

View File

25
api/app/routers/cart.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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,
)

24
api/app/routers/orders.py Normal file
View File

@ -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)

121
api/app/schemas.py Normal file
View File

@ -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)

219
api/app/services.py Normal file
View File

@ -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()

69
api/app/storage.py Normal file
View File

@ -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)

6
api/requirements.txt Normal file
View File

@ -0,0 +1,6 @@
fastapi
uvicorn[standard]
sqlalchemy[asyncio]
asyncpg
pydantic-settings
minio