Compare commits

...

15 Commits

21 changed files with 475 additions and 23 deletions

2
.gitignore vendored
View File

@@ -177,3 +177,5 @@ cython_debug/
# Static / IMG
static/img
static/*
.env.prod

View 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
View File

@@ -0,0 +1,12 @@
services:
postgres:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"
networks:
internal:
internal: false

View File

@@ -14,7 +14,6 @@ class CardInfo:
load_dotenv(override=True)
logger = logging.getLogger(__name__)
### Environment Variables ###
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")
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 ###
PAGE_SIZE = 8
VERIFIED_ACCOUNTS = [8480400744, 6551145249]
NOTIFICATION_CHANNEL = -1003836761482
VERIFIED_ACCOUNTS: list[int] = [int(el) for el in os.getenv("ADMINS", "").split(",")]
NOTIFICATION_CHANNEL = os.getenv("NOTIFICATION_CHANNEL")
if not NOTIFICATION_CHANNEL:
raise Exception("Specify Notification Channel in .env")

View File

@@ -1,6 +1,7 @@
from .invoices import Invoice, InvoiceStatus
from .orders import Order, OrderItem, OrderStatus
from .products import Category, Product
from .users import User
__all__ = [
"Category",
@@ -10,4 +11,5 @@ __all__ = [
"OrderItem",
"OrderStatus",
"Product",
"User",
]

10
db/models/users.py Normal file
View 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)

View File

@@ -1,8 +1,8 @@
services:
postgres:
env_file: prod.env
env_file: .env
image: postgres:16-alpine
container_name: shveitech_db
container_name: shveitech_postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
@@ -18,8 +18,8 @@ services:
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"
networks:
- internal
redis:
image: redis:7-alpine
@@ -29,11 +29,11 @@ services:
interval: 10s
timeout: 5s
retries: 5
ports:
- "6379:6379"
networks:
- internal
bot:
env_file: prod.env
env_file: .env
build:
dockerfile: Dockerfile
container_name: shveitech_bot
@@ -54,6 +54,13 @@ services:
restart: unless-stopped
volumes:
- ./images:/app/images
networks:
- default
- internal
networks:
internal:
internal: true
volumes:
postgres_data:

View File

@@ -20,6 +20,7 @@ class CatalogueView:
children: list[Category] | None = None
products: list[Product] | None = None
page: int = 0
total: int = 0
parent_id: int | str | None = None
has_next: bool = False
show_menu: bool = False

View File

@@ -1,21 +1,40 @@
from aiogram import F, Router
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 dto.catalogue import CategoryActionContext
from dto.catalogue import CategoryActionContext, ProductActionContext
from misc.filters import IsVerified
from misc.kb.client import created_category, get_back_to_catalogue
from misc.kb.common import return_menu
from misc.mapper import parse_cat_id
from misc.states import AdminControlStorage
from repositories.categories import CategoriesRepository
from repositories.products import ProductRepository
from services.catalogue import CatalogueService
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(
cb: CallbackQuery,
state: FSMContext,
@@ -41,7 +60,12 @@ async def creation_init(
await state.set_data({"ctx": ctx})
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)
@@ -70,3 +94,131 @@ async def create_new_category(
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))

View File

@@ -13,8 +13,7 @@ from misc.kb.admins import back_to_product_kb
from misc.kb.client import get_back_to_catalogue
from misc.mapper import parse_cat_id
from misc.states import AdminControlStorage
from misc.texts import get_product_description
from misc.texts import product_editing_mapping
from misc.texts import get_product_description, product_editing_mapping
from repositories.categories import CategoriesRepository
from repositories.products import ProductRepository
@@ -210,6 +209,42 @@ async def delete_product_photo(
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:"))
async def edit_entry(cb: CallbackQuery, state: FSMContext):
await state.clear()

View File

@@ -5,15 +5,15 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
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 dto.checkout import CheckoutContext
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.states import CheckoutStorage
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 services.orders import OrderService
@@ -180,3 +180,30 @@ async def payment_create(
)
logger.exception(e)
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,
)

View File

@@ -1,13 +1,16 @@
import contextlib
import os
from aiogram import F, Router
from aiogram.filters import Command
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 config import WELCOME_VIDEO_NOTE_FP
from misc.kb.client import main_menu_kb
from repositories.order_items import OrderItemRepository
from repositories.users import create_user, get_user
from services.orders import OrderService
router = Router()
@@ -22,6 +25,12 @@ async def user_main_menu(
):
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)
await msg.answer(
"<b>Добрый день!</b>\n" 'Нажмите "Каталог" чтобы выбрать товар или воспользуйтесь поиском.',

View File

@@ -51,6 +51,9 @@ async def searching(
await redis_client.set_search_query(query_hash, msg.text)
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(
"<b>🗒 Результаты поиска:</b>",
reply_markup=render_products(
@@ -59,6 +62,8 @@ async def searching(
back_cb="menu:main",
next_cb=next_cb,
is_admin=is_admin,
page=0,
total_pages=total_pages,
),
)
@@ -89,6 +94,9 @@ async def search_pagination(
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(
reply_markup=render_products(
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,
prev_cb=f"search:{q_hash}:{page - 1}" if page > 0 else None,
is_admin=is_admin,
page=page,
total_pages=total_pages,
)
)

View File

@@ -79,3 +79,16 @@ def edit_product(product: Product):
[InlineKeyboardButton(text="⬅️", callback_data=f"cat:{product.category_id}")],
]
).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()

View File

@@ -3,6 +3,7 @@ from collections.abc import Callable
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
import config
from config import PAGE_SIZE
from db.models import Category, Product
from dto.cart import CartItemDTO
@@ -81,6 +82,7 @@ def main_menu_kb(cart_items: int) -> InlineKeyboardMarkup:
InlineKeyboardButton(text="🔍 Поиск", callback_data="find"),
],
[cart_btn(cart_items)],
[InlineKeyboardButton(text="🌸 Подписаться на канал", url=config.CHANNEL_URL)],
]
).as_markup()
@@ -141,6 +143,8 @@ def render_products(
columns: int = 2,
is_admin: bool = False,
cat_id: int | None = None,
page: int = 0,
total_pages: int | None = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
@@ -166,7 +170,8 @@ def render_products(
nav_buttons = []
if 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:
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,
)
if view.view_type == CatalogueType.PRODUCTS:
total_pages = (view.total + PAGE_SIZE - 1) // PAGE_SIZE if view.total else None
return render_products(
products=view.products[:PAGE_SIZE], # type: ignore
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),
is_admin=is_admin,
cat_id=view.category,
page=view.page,
total_pages=total_pages,
)
return get_back_to_catalogue(
@@ -290,7 +298,7 @@ def order_confirmation(order_id: int) -> InlineKeyboardMarkup:
[
InlineKeyboardButton(
text="🟢 Подтвердить заказ",
callback_data=f"confirm_order:{order_id}",
callback_data=f"precheck_order:{order_id}",
)
],
[InlineKeyboardButton(text="❌ Отменить", callback_data="menu:main")],
@@ -298,6 +306,20 @@ def order_confirmation(order_id: int) -> InlineKeyboardMarkup:
).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:
return InlineKeyboardBuilder(
[

View File

@@ -21,3 +21,7 @@ class AdminControlStorage(StatesGroup):
edit_product_photo = State()
edit_category = State()
new_category = State()
new_product_name = State()
new_product_description = State()
new_product_price = State()
new_product_photo = State()

View File

@@ -3,6 +3,12 @@ import json
from typing import Any
import phonenumbers
from aiogram.types import (
ChatMemberAdministrator,
ChatMemberMember,
ChatMemberOwner,
ResultChatMemberUnion,
)
from phonenumbers import NumberParseException
@@ -26,3 +32,13 @@ def b64_to_dict(payload: str) -> dict["str", Any]:
payload += padding
json_bytes = base64.urlsafe_b64decode(payload)
return json.loads(json_bytes.decode("utf-8"))
def is_member(chatmember: ResultChatMemberUnion):
return any(
[
isinstance(chatmember, ChatMemberOwner),
isinstance(chatmember, ChatMemberAdministrator),
isinstance(chatmember, ChatMemberMember),
]
)

View File

@@ -7,6 +7,35 @@ from db.models import Product
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(
self, session: AsyncSession, product_id: int, *, include_hidden: bool = True
) -> Product | None:
@@ -54,6 +83,40 @@ class ProductRepository:
await session.commit()
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(
self,
session: AsyncSession,

19
repositories/users.py Normal file
View 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

View File

@@ -47,6 +47,9 @@ class CatalogueService:
view.view_type = CatalogueType.PRODUCTS
view.products = 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
view.view_type = CatalogueType.EMPTY

BIN
static/welcome.mp4 Normal file

Binary file not shown.