feat: добавил бэк
This commit is contained in:
parent
f6a817d7bd
commit
7dd67b037a
3
api/.dockerignore
Normal file
3
api/.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
.venv
|
||||||
15
api/Dockerfile
Normal file
15
api/Dockerfile
Normal 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
0
api/app/__init__.py
Normal file
78
api/app/bootstrap.py
Normal file
78
api/app/bootstrap.py
Normal 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
17
api/app/config.py
Normal 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
30
api/app/db.py
Normal 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
61
api/app/deps.py
Normal 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
32
api/app/main.py
Normal 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
115
api/app/models.py
Normal 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())
|
||||||
0
api/app/routers/__init__.py
Normal file
0
api/app/routers/__init__.py
Normal file
25
api/app/routers/cart.py
Normal file
25
api/app/routers/cart.py
Normal 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)
|
||||||
35
api/app/routers/catalog.py
Normal file
35
api/app/routers/catalog.py
Normal 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)
|
||||||
60
api/app/routers/internal.py
Normal file
60
api/app/routers/internal.py
Normal 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
24
api/app/routers/orders.py
Normal 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
121
api/app/schemas.py
Normal 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
219
api/app/services.py
Normal 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
69
api/app/storage.py
Normal 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
6
api/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
sqlalchemy[asyncio]
|
||||||
|
asyncpg
|
||||||
|
pydantic-settings
|
||||||
|
minio
|
||||||
Loading…
Reference in New Issue
Block a user