Compare commits

..

12 Commits

Author SHA1 Message Date
09046ee882 chore: formatting 2026-08-20 11:00:25 +07:00
69187bc6ec feat: less ambigous pagination 2026-08-20 10:59:55 +07:00
7031ed4d46 feat: channel sub 2026-08-20 10:52:40 +07:00
04be991865 feat: pagination hints 2026-08-19 21:33:22 +07:00
981db23b6f fix: id is now int for everyone 2026-08-18 14:25:36 +07:00
a6f06f3493 fix: standardized str for id comparison 2026-08-18 14:21:59 +07:00
608447816e chore: formatting + cleaning up 2026-08-18 14:16:52 +07:00
d2fb817a44 feat(docker): internal network and overrides 2026-08-18 14:13:14 +07:00
b02511e2b9 chore: small fixes in compose.yml for prod 2026-08-18 14:06:54 +07:00
8ad3ecd804 chore: move channel settings to .env 2026-08-18 14:02:35 +07:00
b30aa59e00 fix: added static/* to .gitignore 2026-08-18 13:40:02 +07:00
fea3ceea87 feat: users table and welcome videonote 2026-08-18 13:37:59 +07:00
17 changed files with 234 additions and 15 deletions

4
.gitignore vendored
View File

@@ -176,4 +176,6 @@ cython_debug/
.pypirc .pypirc
# Static / IMG # Static / IMG
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) 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 = os.getenv("ADMINS", "").split(",") 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")

View File

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

@@ -2,7 +2,7 @@ services:
postgres: postgres:
env_file: .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,8 +29,8 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
ports: networks:
- "6379:6379" - internal
bot: bot:
env_file: .env env_file: .env
@@ -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:

View File

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

View File

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

View File

@@ -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" 'Нажмите "Каталог" чтобы выбрать товар или воспользуйтесь поиском.',

View File

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

View File

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

View File

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

View File

@@ -83,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
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.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

Binary file not shown.