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