Compare commits
15 Commits
1b78f2ba36
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09046ee882 | |||
| 69187bc6ec | |||
| 7031ed4d46 | |||
| 04be991865 | |||
| 981db23b6f | |||
| a6f06f3493 | |||
| 608447816e | |||
| d2fb817a44 | |||
| b02511e2b9 | |||
| 8ad3ecd804 | |||
| b30aa59e00 | |||
| fea3ceea87 | |||
| 48600d7802 | |||
| 3ef80048a9 | |||
| 406356c240 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -176,4 +176,6 @@ cython_debug/
|
|||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
# Static / IMG
|
# Static / IMG
|
||||||
static/img
|
static/img
|
||||||
|
static/*
|
||||||
|
.env.prod
|
||||||
36
alembic/versions/8011a3651239_users.py
Normal file
36
alembic/versions/8011a3651239_users.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""+users
|
||||||
|
|
||||||
|
Revision ID: 8011a3651239
|
||||||
|
Revises: 6621b149364e
|
||||||
|
Create Date: 2026-08-18 13:32:42.638070
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '8011a3651239'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '6621b149364e'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.BIGINT(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('users')
|
||||||
|
# ### end Alembic commands ###
|
||||||
12
compose.yml.local
Normal file
12
compose.yml.local
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
internal: false
|
||||||
15
config.py
15
config.py
@@ -14,7 +14,6 @@ class CardInfo:
|
|||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
### Environment Variables ###
|
### Environment Variables ###
|
||||||
|
|
||||||
BOT_TOKEN = os.getenv("BOT_TOKEN", "")
|
BOT_TOKEN = os.getenv("BOT_TOKEN", "")
|
||||||
@@ -29,8 +28,18 @@ card_info = CardInfo(os.getenv("CARD_NUMBER", ""), os.getenv("CARD_HOLDER", ""))
|
|||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/1")
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/1")
|
||||||
PROXY_URL = os.getenv("PROXY_URL")
|
PROXY_URL = os.getenv("PROXY_URL")
|
||||||
|
|
||||||
|
WELCOME_VIDEO_NOTE_FP = os.getenv("WELCOME_VIDEO_NOTE_FP", "static/welcome.mp4")
|
||||||
|
CHANNEL_URL = os.getenv("CHANNEL_URL", "https://t.me/shveitech")
|
||||||
|
CHANNEL_ID = os.getenv("CHANNEL_ID")
|
||||||
|
|
||||||
|
if not CHANNEL_ID:
|
||||||
|
raise Exception("Specify Channel ID in .env")
|
||||||
|
|
||||||
### Constants ###
|
### Constants ###
|
||||||
|
|
||||||
PAGE_SIZE = 8
|
PAGE_SIZE = 8
|
||||||
VERIFIED_ACCOUNTS = [8480400744, 6551145249]
|
VERIFIED_ACCOUNTS: list[int] = [int(el) for el in os.getenv("ADMINS", "").split(",")]
|
||||||
NOTIFICATION_CHANNEL = -1003836761482
|
NOTIFICATION_CHANNEL = os.getenv("NOTIFICATION_CHANNEL")
|
||||||
|
|
||||||
|
if not NOTIFICATION_CHANNEL:
|
||||||
|
raise Exception("Specify Notification Channel in .env")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from .invoices import Invoice, InvoiceStatus
|
from .invoices import Invoice, InvoiceStatus
|
||||||
from .orders import Order, OrderItem, OrderStatus
|
from .orders import Order, OrderItem, OrderStatus
|
||||||
from .products import Category, Product
|
from .products import Category, Product
|
||||||
|
from .users import User
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Category",
|
"Category",
|
||||||
@@ -10,4 +11,5 @@ __all__ = [
|
|||||||
"OrderItem",
|
"OrderItem",
|
||||||
"OrderStatus",
|
"OrderStatus",
|
||||||
"Product",
|
"Product",
|
||||||
|
"User",
|
||||||
]
|
]
|
||||||
|
|||||||
10
db/models/users.py
Normal file
10
db/models/users.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from sqlalchemy import BIGINT
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BIGINT, unique=True, nullable=False, primary_key=True)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
env_file: prod.env
|
env_file: .env
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: shveitech_db
|
container_name: shveitech_postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
@@ -18,8 +18,8 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
ports:
|
networks:
|
||||||
- "5432:5432"
|
- internal
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -29,11 +29,11 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
ports:
|
networks:
|
||||||
- "6379:6379"
|
- internal
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
env_file: prod.env
|
env_file: .env
|
||||||
build:
|
build:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: shveitech_bot
|
container_name: shveitech_bot
|
||||||
@@ -54,6 +54,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./images:/app/images
|
- ./images:/app/images
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- internal
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
internal: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CatalogueView:
|
|||||||
children: list[Category] | None = None
|
children: list[Category] | None = None
|
||||||
products: list[Product] | None = None
|
products: list[Product] | None = None
|
||||||
page: int = 0
|
page: int = 0
|
||||||
|
total: int = 0
|
||||||
parent_id: int | str | None = None
|
parent_id: int | str | None = None
|
||||||
has_next: bool = False
|
has_next: bool = False
|
||||||
show_menu: bool = False
|
show_menu: bool = False
|
||||||
|
|||||||
@@ -1,21 +1,40 @@
|
|||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.types import CallbackQuery, Message
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from dto.catalogue import CategoryActionContext
|
from dto.catalogue import CategoryActionContext, ProductActionContext
|
||||||
from misc.filters import IsVerified
|
from misc.filters import IsVerified
|
||||||
from misc.kb.client import created_category, get_back_to_catalogue
|
from misc.kb.client import created_category, get_back_to_catalogue
|
||||||
from misc.kb.common import return_menu
|
from misc.kb.common import return_menu
|
||||||
from misc.mapper import parse_cat_id
|
from misc.mapper import parse_cat_id
|
||||||
from misc.states import AdminControlStorage
|
from misc.states import AdminControlStorage
|
||||||
from repositories.categories import CategoriesRepository
|
from repositories.categories import CategoriesRepository
|
||||||
|
from repositories.products import ProductRepository
|
||||||
from services.catalogue import CatalogueService
|
from services.catalogue import CatalogueService
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(IsVerified(), F.data.startswith("new:"))
|
def skip_product_description_kb(parent_id: str) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="Пропустить", callback_data="new_product:skip_description")],
|
||||||
|
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def skip_product_photo_kb(parent_id: str) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="Пропустить", callback_data="new_product:skip_photo")],
|
||||||
|
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(IsVerified(), F.data.regexp(r"^new:(category|product):.+$"))
|
||||||
async def creation_init(
|
async def creation_init(
|
||||||
cb: CallbackQuery,
|
cb: CallbackQuery,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
@@ -41,7 +60,12 @@ async def creation_init(
|
|||||||
await state.set_data({"ctx": ctx})
|
await state.set_data({"ctx": ctx})
|
||||||
|
|
||||||
if mode == "product":
|
if mode == "product":
|
||||||
...
|
await cb.message.edit_text(
|
||||||
|
f"{path} <i>-> ...</i>\n\n<b>📦 Введите название товара.</b>",
|
||||||
|
reply_markup=get_back_to_catalogue(cat_id),
|
||||||
|
)
|
||||||
|
await state.set_state(AdminControlStorage.new_product_name)
|
||||||
|
await state.set_data({"ctx": ProductActionContext(cb.message, cat_id)})
|
||||||
|
|
||||||
|
|
||||||
@router.message(AdminControlStorage.new_category)
|
@router.message(AdminControlStorage.new_category)
|
||||||
@@ -70,3 +94,131 @@ async def create_new_category(
|
|||||||
category.id, f"🛒 {category.name}", category.parent_id or "root"
|
category.id, f"🛒 {category.name}", category.parent_id or "root"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(IsVerified(), AdminControlStorage.new_product_name)
|
||||||
|
async def create_product_name(msg: Message, state: FSMContext):
|
||||||
|
if not msg.text or not msg.text.strip():
|
||||||
|
await msg.reply("❌ Название не может быть пустым.")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
ctx: ProductActionContext | None = data.get("ctx")
|
||||||
|
if not ctx:
|
||||||
|
await state.clear()
|
||||||
|
await msg.reply("❌ Ошибка при создании товара.", reply_markup=return_menu)
|
||||||
|
return
|
||||||
|
|
||||||
|
await msg.delete()
|
||||||
|
await ctx.msg.edit_text(
|
||||||
|
"<b>📜 Введите описание товара.</b>",
|
||||||
|
reply_markup=skip_product_description_kb(str(ctx.parent_id)),
|
||||||
|
)
|
||||||
|
await state.update_data(name=msg.text.strip())
|
||||||
|
await state.set_state(AdminControlStorage.new_product_description)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(IsVerified(), F.data == "new_product:skip_description")
|
||||||
|
async def skip_product_description(cb: CallbackQuery, state: FSMContext):
|
||||||
|
data = await state.get_data()
|
||||||
|
ctx: ProductActionContext | None = data.get("ctx")
|
||||||
|
if not ctx:
|
||||||
|
await state.clear()
|
||||||
|
await cb.answer("Ошибка при создании товара.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await cb.message.edit_text(
|
||||||
|
"<b>💸 Введите цену товара целым числом.</b>",
|
||||||
|
reply_markup=get_back_to_catalogue(ctx.parent_id),
|
||||||
|
)
|
||||||
|
await state.update_data(description=None)
|
||||||
|
await state.set_state(AdminControlStorage.new_product_price)
|
||||||
|
await cb.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(IsVerified(), AdminControlStorage.new_product_description)
|
||||||
|
async def create_product_description(msg: Message, state: FSMContext):
|
||||||
|
if not msg.text or not msg.text.strip():
|
||||||
|
await msg.reply("❌ Описание не может быть пустым.")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
ctx: ProductActionContext | None = data.get("ctx")
|
||||||
|
if not ctx:
|
||||||
|
await state.clear()
|
||||||
|
await msg.reply("❌ Ошибка при создании товара.", reply_markup=return_menu)
|
||||||
|
return
|
||||||
|
|
||||||
|
await msg.delete()
|
||||||
|
await ctx.msg.edit_text(
|
||||||
|
"<b>💸 Введите цену товара целым числом.</b>",
|
||||||
|
reply_markup=get_back_to_catalogue(ctx.parent_id),
|
||||||
|
)
|
||||||
|
await state.update_data(description=msg.text.strip())
|
||||||
|
await state.set_state(AdminControlStorage.new_product_price)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(IsVerified(), AdminControlStorage.new_product_price)
|
||||||
|
async def create_product_price(
|
||||||
|
msg: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
products_repo: ProductRepository,
|
||||||
|
):
|
||||||
|
if not msg.text or not msg.text.isdigit() or int(msg.text) <= 0:
|
||||||
|
await msg.reply("❌ Цена должна быть положительным целым числом.")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
ctx: ProductActionContext | None = data.get("ctx")
|
||||||
|
if not ctx:
|
||||||
|
await state.clear()
|
||||||
|
await msg.reply("❌ Ошибка при создании товара.", reply_markup=return_menu)
|
||||||
|
return
|
||||||
|
|
||||||
|
await msg.delete()
|
||||||
|
product = await products_repo.add_product(
|
||||||
|
session,
|
||||||
|
category_id=parse_cat_id(ctx.parent_id),
|
||||||
|
name=data["name"],
|
||||||
|
description=data["description"],
|
||||||
|
price=int(msg.text),
|
||||||
|
)
|
||||||
|
await ctx.msg.edit_text(
|
||||||
|
"✅ Основные данные сохранены.\n\n<b>📷 Отправьте фотографию товара или пропустите этот шаг.</b>",
|
||||||
|
reply_markup=skip_product_photo_kb(str(ctx.parent_id)),
|
||||||
|
)
|
||||||
|
await state.update_data(product_id=product.id)
|
||||||
|
await state.set_state(AdminControlStorage.new_product_photo)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(IsVerified(), F.data == "new_product:skip_photo")
|
||||||
|
async def skip_product_photo(cb: CallbackQuery, state: FSMContext):
|
||||||
|
data = await state.get_data()
|
||||||
|
ctx: ProductActionContext | None = data.get("ctx")
|
||||||
|
await state.clear()
|
||||||
|
parent_id = ctx.parent_id if ctx else "root"
|
||||||
|
await cb.message.edit_text("✅ Товар добавлен.", reply_markup=get_back_to_catalogue(parent_id))
|
||||||
|
await cb.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(IsVerified(), AdminControlStorage.new_product_photo)
|
||||||
|
async def create_product_photo(
|
||||||
|
msg: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
products_repo: ProductRepository,
|
||||||
|
):
|
||||||
|
data = await state.get_data()
|
||||||
|
if not msg.photo or not data.get("product_id"):
|
||||||
|
await msg.delete()
|
||||||
|
await msg.reply("❌ Отправьте именно фотографию или нажмите «Пропустить».")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await msg.delete()
|
||||||
|
await products_repo.add_product_file_id(
|
||||||
|
session, int(data["product_id"]), file_id=msg.photo[-1].file_id
|
||||||
|
)
|
||||||
|
ctx: ProductActionContext = data["ctx"]
|
||||||
|
await ctx.msg.edit_text("✅ Товар добавлен.", reply_markup=get_back_to_catalogue(ctx.parent_id))
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ from misc.kb.admins import back_to_product_kb
|
|||||||
from misc.kb.client import get_back_to_catalogue
|
from misc.kb.client import get_back_to_catalogue
|
||||||
from misc.mapper import parse_cat_id
|
from misc.mapper import parse_cat_id
|
||||||
from misc.states import AdminControlStorage
|
from misc.states import AdminControlStorage
|
||||||
from misc.texts import get_product_description
|
from misc.texts import get_product_description, product_editing_mapping
|
||||||
from misc.texts import product_editing_mapping
|
|
||||||
from repositories.categories import CategoriesRepository
|
from repositories.categories import CategoriesRepository
|
||||||
from repositories.products import ProductRepository
|
from repositories.products import ProductRepository
|
||||||
|
|
||||||
@@ -210,6 +209,42 @@ async def delete_product_photo(
|
|||||||
await cb.message.edit_reply_markup(reply_markup=reply_markup)
|
await cb.message.edit_reply_markup(reply_markup=reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(IsVerified(), F.data.regexp(r"^edit_product:\d+:delete$"))
|
||||||
|
async def request_product_deletion(cb: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
product_id = int(cb.data.split(":")[1])
|
||||||
|
await cb.message.edit_reply_markup(reply_markup=admins.confirm_product_deletion(product_id))
|
||||||
|
await cb.answer("Подтвердите удаление товара.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(IsVerified(), F.data.regexp(r"^delete_product:confirm:\d+$"))
|
||||||
|
async def delete_product(
|
||||||
|
cb: CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
products_repo: ProductRepository,
|
||||||
|
):
|
||||||
|
await state.clear()
|
||||||
|
product_id = int(cb.data.split(":")[2])
|
||||||
|
product = await products_repo.get_product_by_id(session, product_id)
|
||||||
|
if not product:
|
||||||
|
await cb.answer("Товар уже удалён.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
category_id = product.category_id
|
||||||
|
try:
|
||||||
|
await products_repo.delete_product(session, product_id)
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
logger.exception("Failed to delete product %s", product_id)
|
||||||
|
await cb.answer("Не удалось удалить товар: он используется в заказах.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await cb.message.delete()
|
||||||
|
await cb.message.answer("✅ Товар удалён.", reply_markup=get_back_to_catalogue(category_id))
|
||||||
|
await cb.answer()
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("edit:"))
|
@router.callback_query(F.data.startswith("edit:"))
|
||||||
async def edit_entry(cb: CallbackQuery, state: FSMContext):
|
async def edit_entry(cb: CallbackQuery, state: FSMContext):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ from aiogram.fsm.context import FSMContext
|
|||||||
from aiogram.types import CallbackQuery, Message
|
from aiogram.types import CallbackQuery, Message
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import NOTIFICATION_CHANNEL
|
from config import CHANNEL_ID, NOTIFICATION_CHANNEL
|
||||||
from db.models.orders import OrderStatus
|
from db.models.orders import OrderStatus
|
||||||
from dto.checkout import CheckoutContext
|
from dto.checkout import CheckoutContext
|
||||||
from misc.kb.admins import customer_contacts
|
from misc.kb.admins import customer_contacts
|
||||||
from misc.kb.client import order_confirmation, order_specs_confirmation
|
from misc.kb.client import order_confirmation, order_specs_confirmation, subscription
|
||||||
from misc.kb.common import return_menu
|
from misc.kb.common import return_menu
|
||||||
from misc.states import CheckoutStorage
|
from misc.states import CheckoutStorage
|
||||||
from misc.texts import get_order_item_list
|
from misc.texts import get_order_item_list
|
||||||
from misc.utils import is_valid_phone
|
from misc.utils import is_member, is_valid_phone
|
||||||
from repositories.orders import OrderRepository
|
from repositories.orders import OrderRepository
|
||||||
from services.orders import OrderService
|
from services.orders import OrderService
|
||||||
|
|
||||||
@@ -180,3 +180,30 @@ async def payment_create(
|
|||||||
)
|
)
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("precheck_order:"))
|
||||||
|
async def precheck_order(
|
||||||
|
cb: CallbackQuery, state: FSMContext, session: AsyncSession, order_repo: OrderRepository
|
||||||
|
):
|
||||||
|
await state.clear()
|
||||||
|
order_id = cb.data.split(":")[1]
|
||||||
|
if not order_id.isdigit():
|
||||||
|
await cb.message.edit_text("🍃 Что-то пошло не так.", reply_markup=return_menu)
|
||||||
|
return
|
||||||
|
|
||||||
|
member = await cb.bot.get_chat_member(CHANNEL_ID, cb.from_user.id)
|
||||||
|
|
||||||
|
if not is_member(member):
|
||||||
|
await cb.message.edit_text(
|
||||||
|
"<b>📚 Мы заметили, что вы не подписаны на канал.</b>",
|
||||||
|
reply_markup=subscription(order_id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await order_confirm(
|
||||||
|
cb=cb,
|
||||||
|
state=state,
|
||||||
|
session=session,
|
||||||
|
order_repo=order_repo,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
|
import os
|
||||||
|
|
||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.types import CallbackQuery, Message
|
from aiogram.types import CallbackQuery, FSInputFile, Message
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import WELCOME_VIDEO_NOTE_FP
|
||||||
from misc.kb.client import main_menu_kb
|
from misc.kb.client import main_menu_kb
|
||||||
from repositories.order_items import OrderItemRepository
|
from repositories.order_items import OrderItemRepository
|
||||||
|
from repositories.users import create_user, get_user
|
||||||
from services.orders import OrderService
|
from services.orders import OrderService
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -22,6 +25,12 @@ async def user_main_menu(
|
|||||||
):
|
):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
if not await get_user(session, msg.from_user.id):
|
||||||
|
if os.path.isfile(WELCOME_VIDEO_NOTE_FP):
|
||||||
|
await msg.bot.send_chat_action(msg.from_user.id, "record_video")
|
||||||
|
await msg.answer_video_note(FSInputFile(WELCOME_VIDEO_NOTE_FP))
|
||||||
|
await create_user(session, msg.from_user.id)
|
||||||
|
|
||||||
cart_items = await order_items_repo.get_items_count_by_customer(session, msg.from_user.id)
|
cart_items = await order_items_repo.get_items_count_by_customer(session, msg.from_user.id)
|
||||||
await msg.answer(
|
await msg.answer(
|
||||||
"<b>Добрый день!</b>\n" 'Нажмите "Каталог" чтобы выбрать товар или воспользуйтесь поиском.',
|
"<b>Добрый день!</b>\n" 'Нажмите "Каталог" чтобы выбрать товар или воспользуйтесь поиском.',
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ async def searching(
|
|||||||
await redis_client.set_search_query(query_hash, msg.text)
|
await redis_client.set_search_query(query_hash, msg.text)
|
||||||
next_cb = f"search:{query_hash}:1"
|
next_cb = f"search:{query_hash}:1"
|
||||||
|
|
||||||
|
total = await products_repo.count_search(session, msg.text, include_hidden=is_admin)
|
||||||
|
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total else None
|
||||||
|
|
||||||
await orig_msg.edit_text(
|
await orig_msg.edit_text(
|
||||||
"<b>🗒 Результаты поиска:</b>",
|
"<b>🗒 Результаты поиска:</b>",
|
||||||
reply_markup=render_products(
|
reply_markup=render_products(
|
||||||
@@ -59,6 +62,8 @@ async def searching(
|
|||||||
back_cb="menu:main",
|
back_cb="menu:main",
|
||||||
next_cb=next_cb,
|
next_cb=next_cb,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
|
page=0,
|
||||||
|
total_pages=total_pages,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,6 +94,9 @@ async def search_pagination(
|
|||||||
include_hidden=is_admin,
|
include_hidden=is_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
total = await products_repo.count_search(session, query, include_hidden=is_admin)
|
||||||
|
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total else None
|
||||||
|
|
||||||
await cb.message.edit_reply_markup(
|
await cb.message.edit_reply_markup(
|
||||||
reply_markup=render_products(
|
reply_markup=render_products(
|
||||||
results[:PAGE_SIZE],
|
results[:PAGE_SIZE],
|
||||||
@@ -97,5 +105,7 @@ async def search_pagination(
|
|||||||
next_cb=f"search:{q_hash}:{page + 1}" if len(results) > PAGE_SIZE else None,
|
next_cb=f"search:{q_hash}:{page + 1}" if len(results) > PAGE_SIZE else None,
|
||||||
prev_cb=f"search:{q_hash}:{page - 1}" if page > 0 else None,
|
prev_cb=f"search:{q_hash}:{page - 1}" if page > 0 else None,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
|
page=page,
|
||||||
|
total_pages=total_pages,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,3 +79,16 @@ def edit_product(product: Product):
|
|||||||
[InlineKeyboardButton(text="⬅️", callback_data=f"cat:{product.category_id}")],
|
[InlineKeyboardButton(text="⬅️", callback_data=f"cat:{product.category_id}")],
|
||||||
]
|
]
|
||||||
).as_markup()
|
).as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_product_deletion(product_id: int) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardBuilder(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✅ Да, удалить", callback_data=f"delete_product:confirm:{product_id}"
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(text="❌ Отмена", callback_data=f"product:{product_id}"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
).as_markup()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from collections.abc import Callable
|
|||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
import config
|
||||||
from config import PAGE_SIZE
|
from config import PAGE_SIZE
|
||||||
from db.models import Category, Product
|
from db.models import Category, Product
|
||||||
from dto.cart import CartItemDTO
|
from dto.cart import CartItemDTO
|
||||||
@@ -81,6 +82,7 @@ def main_menu_kb(cart_items: int) -> InlineKeyboardMarkup:
|
|||||||
InlineKeyboardButton(text="🔍 Поиск", callback_data="find"),
|
InlineKeyboardButton(text="🔍 Поиск", callback_data="find"),
|
||||||
],
|
],
|
||||||
[cart_btn(cart_items)],
|
[cart_btn(cart_items)],
|
||||||
|
[InlineKeyboardButton(text="🌸 Подписаться на канал", url=config.CHANNEL_URL)],
|
||||||
]
|
]
|
||||||
).as_markup()
|
).as_markup()
|
||||||
|
|
||||||
@@ -141,6 +143,8 @@ def render_products(
|
|||||||
columns: int = 2,
|
columns: int = 2,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
cat_id: int | None = None,
|
cat_id: int | None = None,
|
||||||
|
page: int = 0,
|
||||||
|
total_pages: int | None = None,
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
@@ -166,7 +170,8 @@ def render_products(
|
|||||||
nav_buttons = []
|
nav_buttons = []
|
||||||
if prev_cb:
|
if prev_cb:
|
||||||
nav_buttons.append(InlineKeyboardButton(text="◀️", callback_data=prev_cb))
|
nav_buttons.append(InlineKeyboardButton(text="◀️", callback_data=prev_cb))
|
||||||
nav_buttons.append(InlineKeyboardButton(text="🔍", callback_data="find"))
|
page_text = f"Стр. {page + 1}/{total_pages}" if total_pages is not None else f"Стр. {page + 1}"
|
||||||
|
nav_buttons.append(InlineKeyboardButton(text=page_text, callback_data="..."))
|
||||||
if next_cb:
|
if next_cb:
|
||||||
nav_buttons.append(InlineKeyboardButton(text="▶️", callback_data=next_cb))
|
nav_buttons.append(InlineKeyboardButton(text="▶️", callback_data=next_cb))
|
||||||
|
|
||||||
@@ -187,6 +192,7 @@ def render_catalogue(view: CatalogueView, *, is_admin: bool):
|
|||||||
cat_id=view.category,
|
cat_id=view.category,
|
||||||
)
|
)
|
||||||
if view.view_type == CatalogueType.PRODUCTS:
|
if view.view_type == CatalogueType.PRODUCTS:
|
||||||
|
total_pages = (view.total + PAGE_SIZE - 1) // PAGE_SIZE if view.total else None
|
||||||
return render_products(
|
return render_products(
|
||||||
products=view.products[:PAGE_SIZE], # type: ignore
|
products=view.products[:PAGE_SIZE], # type: ignore
|
||||||
product_cb_factory=lambda p: f"product:{p.id}",
|
product_cb_factory=lambda p: f"product:{p.id}",
|
||||||
@@ -195,6 +201,8 @@ def render_catalogue(view: CatalogueView, *, is_admin: bool):
|
|||||||
next_cb=(f"products:{view.category}:{view.page + 1}" if view.has_next else None),
|
next_cb=(f"products:{view.category}:{view.page + 1}" if view.has_next else None),
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
cat_id=view.category,
|
cat_id=view.category,
|
||||||
|
page=view.page,
|
||||||
|
total_pages=total_pages,
|
||||||
)
|
)
|
||||||
|
|
||||||
return get_back_to_catalogue(
|
return get_back_to_catalogue(
|
||||||
@@ -290,7 +298,7 @@ def order_confirmation(order_id: int) -> InlineKeyboardMarkup:
|
|||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text="🟢 Подтвердить заказ",
|
text="🟢 Подтвердить заказ",
|
||||||
callback_data=f"confirm_order:{order_id}",
|
callback_data=f"precheck_order:{order_id}",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
[InlineKeyboardButton(text="❌ Отменить", callback_data="menu:main")],
|
[InlineKeyboardButton(text="❌ Отменить", callback_data="menu:main")],
|
||||||
@@ -298,6 +306,20 @@ def order_confirmation(order_id: int) -> InlineKeyboardMarkup:
|
|||||||
).as_markup()
|
).as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def subscription(order_id: int) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardBuilder(
|
||||||
|
[
|
||||||
|
[InlineKeyboardButton(text="🌸 Подписаться на канал", url=config.CHANNEL_URL)],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="▶️ Продолжить оформление", callback_data=f"confirm_order:{order_id}"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[InlineKeyboardButton(text="🏠", callback_data="menu:main")],
|
||||||
|
]
|
||||||
|
).as_markup()
|
||||||
|
|
||||||
|
|
||||||
def order_specs_confirmation(order_id: int) -> InlineKeyboardMarkup:
|
def order_specs_confirmation(order_id: int) -> InlineKeyboardMarkup:
|
||||||
return InlineKeyboardBuilder(
|
return InlineKeyboardBuilder(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -21,3 +21,7 @@ class AdminControlStorage(StatesGroup):
|
|||||||
edit_product_photo = State()
|
edit_product_photo = State()
|
||||||
edit_category = State()
|
edit_category = State()
|
||||||
new_category = State()
|
new_category = State()
|
||||||
|
new_product_name = State()
|
||||||
|
new_product_description = State()
|
||||||
|
new_product_price = State()
|
||||||
|
new_product_photo = State()
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import phonenumbers
|
import phonenumbers
|
||||||
|
from aiogram.types import (
|
||||||
|
ChatMemberAdministrator,
|
||||||
|
ChatMemberMember,
|
||||||
|
ChatMemberOwner,
|
||||||
|
ResultChatMemberUnion,
|
||||||
|
)
|
||||||
from phonenumbers import NumberParseException
|
from phonenumbers import NumberParseException
|
||||||
|
|
||||||
|
|
||||||
@@ -26,3 +32,13 @@ def b64_to_dict(payload: str) -> dict["str", Any]:
|
|||||||
payload += padding
|
payload += padding
|
||||||
json_bytes = base64.urlsafe_b64decode(payload)
|
json_bytes = base64.urlsafe_b64decode(payload)
|
||||||
return json.loads(json_bytes.decode("utf-8"))
|
return json.loads(json_bytes.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def is_member(chatmember: ResultChatMemberUnion):
|
||||||
|
return any(
|
||||||
|
[
|
||||||
|
isinstance(chatmember, ChatMemberOwner),
|
||||||
|
isinstance(chatmember, ChatMemberAdministrator),
|
||||||
|
isinstance(chatmember, ChatMemberMember),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,35 @@ from db.models import Product
|
|||||||
|
|
||||||
|
|
||||||
class ProductRepository:
|
class ProductRepository:
|
||||||
|
async def delete_product(self, session: AsyncSession, product_id: int) -> Product | None:
|
||||||
|
product = await self.get_product_by_id(session, product_id)
|
||||||
|
if not product:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await session.delete(product)
|
||||||
|
await session.commit()
|
||||||
|
return product
|
||||||
|
|
||||||
|
async def add_product(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
category_id: int | None,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
price: int,
|
||||||
|
) -> Product:
|
||||||
|
product = Product(
|
||||||
|
category_id=category_id,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
price=price,
|
||||||
|
)
|
||||||
|
session.add(product)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(product)
|
||||||
|
return product
|
||||||
|
|
||||||
async def get_product_by_id(
|
async def get_product_by_id(
|
||||||
self, session: AsyncSession, product_id: int, *, include_hidden: bool = True
|
self, session: AsyncSession, product_id: int, *, include_hidden: bool = True
|
||||||
) -> Product | None:
|
) -> Product | None:
|
||||||
@@ -54,6 +83,40 @@ class ProductRepository:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return product
|
return product
|
||||||
|
|
||||||
|
async def count_products_by_category(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
category_id: int,
|
||||||
|
*,
|
||||||
|
include_hidden: bool = False,
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(Product).where(Product.category_id == category_id)
|
||||||
|
|
||||||
|
if not include_hidden:
|
||||||
|
stmt = stmt.where(Product.is_hidden.is_(False))
|
||||||
|
|
||||||
|
return await session.scalar(stmt)
|
||||||
|
|
||||||
|
async def count_search(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
query: str,
|
||||||
|
*,
|
||||||
|
include_hidden: bool = False,
|
||||||
|
) -> int:
|
||||||
|
ts_query = func.plainto_tsquery("simple", query)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Product)
|
||||||
|
.where(Product.search_vector.op("@@")(ts_query))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not include_hidden:
|
||||||
|
stmt = stmt.where(Product.is_hidden.is_(False))
|
||||||
|
|
||||||
|
return await session.scalar(stmt)
|
||||||
|
|
||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|||||||
19
repositories/users.py
Normal file
19
repositories/users.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from db.models.users import User
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user(session: AsyncSession, user_id: int) -> User | None:
|
||||||
|
stmt = select(User).where(User.id == user_id)
|
||||||
|
r = await session.execute(stmt)
|
||||||
|
|
||||||
|
return r.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(session: AsyncSession, user_id: int) -> User:
|
||||||
|
user = User(id=user_id)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return user
|
||||||
@@ -47,6 +47,9 @@ class CatalogueService:
|
|||||||
view.view_type = CatalogueType.PRODUCTS
|
view.view_type = CatalogueType.PRODUCTS
|
||||||
view.products = products[:PAGE_SIZE]
|
view.products = products[:PAGE_SIZE]
|
||||||
view.has_next = len(products) > PAGE_SIZE
|
view.has_next = len(products) > PAGE_SIZE
|
||||||
|
view.total = await self.product_repo.count_products_by_category(
|
||||||
|
session, cat_id, include_hidden=include_hidden
|
||||||
|
)
|
||||||
|
|
||||||
return view
|
return view
|
||||||
view.view_type = CatalogueType.EMPTY
|
view.view_type = CatalogueType.EMPTY
|
||||||
|
|||||||
BIN
static/welcome.mp4
Normal file
BIN
static/welcome.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user