Compare commits

...

10 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
12 changed files with 156 additions and 17 deletions

1
.gitignore vendored
View File

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

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", "")
@@ -30,9 +29,17 @@ 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 = os.getenv("ADMINS", "").split(",")
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

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

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

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

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

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

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

View File

@@ -10,10 +10,9 @@ async def get_user(session: AsyncSession, user_id: int) -> User | None:
return r.scalar_one_or_none()
async def create_user(session: AsyncSession, user_id: int) -> User:
user = User(
id=user_id
)
user = User(id=user_id)
session.add(user)
await session.commit()

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