Compare commits

...

19 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
48600d7802 fix: renamed prod.env to .env 2026-08-14 12:30:23 +07:00
3ef80048a9 fix: relocated admins settings to .env 2026-08-14 12:27:49 +07:00
406356c240 feat: admin product deletion 2026-08-13 12:03:56 +07:00
1b78f2ba36 chore: clean up texts 2026-08-13 11:30:04 +07:00
104cf71969 feat: +Dockerfile 2026-08-13 11:10:33 +07:00
8444386885 fix: +products.is_hidden in db models 2026-08-13 11:10:06 +07:00
ca276cdd61 chore: minor fixes and formatting 2026-08-12 19:52:30 +07:00
56 changed files with 1099 additions and 368 deletions

5
.gitignore vendored
View File

@@ -136,6 +136,7 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
*.env
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
@@ -175,4 +176,6 @@ cython_debug/
.pypirc .pypirc
# Static / IMG # Static / IMG
static/img static/img
static/*
.env.prod

42
Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
FROM python:3.14-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN apt-get update \
&& apt-get install --yes --no-install-recommends build-essential gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --upgrade pip \
&& pip wheel --wheel-dir /wheels -r requirements.txt
FROM python:3.14-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY requirements.txt ./
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY . .
RUN mkdir -p /app/static/img /app/images \
&& chown -R app:app /app
USER app
CMD ["python", "main.py"]

View File

@@ -86,7 +86,7 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only. # database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py # other means of configuring database URLs may be customized within the env.py
# file. # file.
sqlalchemy.url = postgresql+asyncpg://postgres:HEXDEVFUCKINGSUCKS!@localhost:5432/postgres sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
[post_write_hooks] [post_write_hooks]

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import os
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import pool from sqlalchemy import pool
@@ -6,14 +7,17 @@ from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context from alembic import context
from db.base import Base from db.base import Base
from db.models import * # noqa: F403 from db.models import *
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
config = context.config config = context.config
postgres_url = os.getenv("POSTGRES_URL")
if postgres_url:
config.set_main_option("sqlalchemy.url", postgres_url)
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
# This line sets up loggers basically. # This line sets up loggers basically.
if config.config_file_name is not None: if config.config_file_name is not None:

View File

@@ -0,0 +1,32 @@
"""+products.is_hidden
Revision ID: 6621b149364e
Revises: 1d2e799ed0f3
Create Date: 2026-07-21 21:08:34.258587
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "6621b149364e"
down_revision: Union[str, Sequence[str], None] = "1d2e799ed0f3"
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.add_column("products", sa.Column("is_hidden", sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("products", "is_hidden")
# ### end Alembic commands ###

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

@@ -1,6 +1,7 @@
from dataclasses import dataclass
import os
import logging import logging
import os
from dataclasses import dataclass
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -13,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", "")
@@ -26,9 +26,20 @@ POSTGRES_URL = os.getenv(
card_info = CardInfo(os.getenv("CARD_NUMBER", ""), os.getenv("CARD_HOLDER", "")) 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")
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 = [1026030711, 8480400744] VERIFIED_ACCOUNTS: list[int] = [int(el) for el in os.getenv("ADMINS", "").split(",")]
NOTIFICATION_CHANNEL = -1003849564110 NOTIFICATION_CHANNEL = os.getenv("NOTIFICATION_CHANNEL")
if not NOTIFICATION_CHANNEL:
raise Exception("Specify Notification Channel in .env")

View File

@@ -1,4 +1,4 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from config import POSTGRES_URL from config import POSTGRES_URL

View File

@@ -1,13 +1,15 @@
from .orders import Order, OrderItem, OrderStatus
from .products import Product, Category
from .invoices import Invoice, InvoiceStatus from .invoices import Invoice, InvoiceStatus
from .orders import Order, OrderItem, OrderStatus
from .products import Category, Product
from .users import User
__all__ = [ __all__ = [
"Category",
"Invoice",
"InvoiceStatus",
"Order", "Order",
"OrderItem", "OrderItem",
"OrderStatus", "OrderStatus",
"Product", "Product",
"Category", "User",
"Invoice",
"InvoiceStatus",
] ]

View File

@@ -1,8 +1,9 @@
from datetime import datetime from datetime import datetime
from enum import Enum as E_cls from enum import Enum as E_cls
from sqlalchemy import DateTime, func, Enum
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy import DateTime, Enum, func
from sqlalchemy.dialects.postgresql import BIGINT from sqlalchemy.dialects.postgresql import BIGINT
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base from db.base import Base

View File

@@ -1,8 +1,9 @@
import enum import enum
from typing import List, Optional
from sqlalchemy import ForeignKey, BigInteger from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import ENUM from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base from db.base import Base
from db.models.products import Product from db.models.products import Product
@@ -19,10 +20,10 @@ class Order(Base):
__tablename__ = "orders" __tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
customer: Mapped[Optional[int]] = mapped_column(BigInteger) customer: Mapped[int | None] = mapped_column(BigInteger)
status: Mapped[OrderStatus] = mapped_column(ENUM(OrderStatus, name="order_status")) status: Mapped[OrderStatus] = mapped_column(ENUM(OrderStatus, name="order_status"))
items: Mapped[List["OrderItem"]] = relationship("OrderItem", back_populates="order") items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="order")
class OrderItem(Base): class OrderItem(Base):

View File

@@ -1,7 +1,6 @@
from typing import Optional from sqlalchemy import Boolean, ForeignKey, Index, Text
from sqlalchemy import ForeignKey, Text, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base from db.base import Base
@@ -11,7 +10,7 @@ class Category(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("categories.id")) parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"))
parent: Mapped["Category"] = relationship("Category", remote_side=[id]) parent: Mapped["Category"] = relationship("Category", remote_side=[id])
@@ -20,15 +19,14 @@ class Product(Base):
__tablename__ = "products" __tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
category_id: Mapped[Optional[int]] = mapped_column(ForeignKey("categories.id")) category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"))
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
price: Mapped[int] = mapped_column() price: Mapped[int] = mapped_column()
img_path: Mapped[str] = mapped_column(Text, nullable=True) img_path: Mapped[str] = mapped_column(Text, nullable=True)
file_id: Mapped[str] = mapped_column(Text, nullable=True, unique=True) file_id: Mapped[str] = mapped_column(Text, nullable=True, unique=True)
is_hidden: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
search_vector: Mapped[str] = mapped_column(TSVECTOR) search_vector: Mapped[str] = mapped_column(TSVECTOR)
__table_args__ = ( __table_args__ = (Index("idx_products_search", "search_vector", postgresql_using="gin"),)
Index("idx_products_search", "search_vector", postgresql_using="gin"),
)

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)

66
docker-compose.yml Normal file
View File

@@ -0,0 +1,66 @@
services:
postgres:
env_file: .env
image: postgres:16-alpine
container_name: shveitech_postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}",
]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
redis:
image: redis:7-alpine
container_name: shveitech_redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
bot:
env_file: .env
build:
dockerfile: Dockerfile
container_name: shveitech_bot
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
BOT_TOKEN: ${BOT_TOKEN}
ADMINS: ${ADMINS}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-postgres}
CARD_NUMBER: ${CARD_NUMBER}
CARD_HOLDER: ${CARD_HOLDER}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
PROXY_URL: ${PROXY_URL}
command: ["sh", "-c", "alembic upgrade head && python main.py"]
restart: unless-stopped
volumes:
- ./images:/app/images
networks:
- default
- internal
networks:
internal:
internal: true
volumes:
postgres_data:

View File

@@ -1,12 +1,12 @@
from .cart import CartItemDTO from .cart import CartItemDTO
from .control import EditProductContext from .catalogue import CatalogueType, CatalogueView
from .checkout import CheckoutContext from .checkout import CheckoutContext
from .catalogue import CatalogueView, CatalogueType from .control import EditProductContext
__all__ = [ __all__ = [
"CartItemDTO", "CartItemDTO",
"EditProductContext",
"CheckoutContext",
"CatalogueView",
"CatalogueType", "CatalogueType",
"CatalogueView",
"CheckoutContext",
"EditProductContext",
] ]

View File

@@ -1,5 +1,4 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -18,4 +17,4 @@ class CartItemDTO:
class CartDTO: class CartDTO:
items: list[CartItemDTO] items: list[CartItemDTO]
total: int total: int
order_id: Optional[int] = None order_id: int | None = None

View File

@@ -1,6 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Optional, Union
from aiogram.types import Message from aiogram.types import Message
@@ -16,12 +15,13 @@ class CatalogueType(Enum):
@dataclass @dataclass
class CatalogueView: class CatalogueView:
view_type: Optional[CatalogueType] = None view_type: CatalogueType | None = None
category: Optional[Category] = None category: Category | None = None
children: Optional[list[Category]] = None children: list[Category] | None = None
products: Optional[list[Product]] = None products: list[Product] | None = None
page: int = 0 page: int = 0
parent_id: Union[int, str, None] = None total: int = 0
parent_id: int | str | None = None
has_next: bool = False has_next: bool = False
show_menu: bool = False show_menu: bool = False
@@ -29,7 +29,7 @@ class CatalogueView:
@dataclass @dataclass
class NewCatalogueElement: class NewCatalogueElement:
msg: Message msg: Message
parent_id: Union[int, str] parent_id: int | str
@dataclass @dataclass

View File

@@ -1,5 +1,4 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
from aiogram.types import Message from aiogram.types import Message
@@ -8,6 +7,6 @@ from aiogram.types import Message
class CheckoutContext: class CheckoutContext:
orig_msg: Message orig_msg: Message
order_id: int order_id: int
name: Optional[str] = None name: str | None = None
phone: Optional[str] = None phone: str | None = None
address: Optional[str] = None address: str | None = None

View File

@@ -1,4 +1,4 @@
from .client import routers as client_routers
from .admins import routers as admins_routers from .admins import routers as admins_routers
from .client import routers as client_routers
__all__ = ["client_routers", "admins_routers"] __all__ = ["admins_routers", "client_routers"]

View File

@@ -1,6 +1,6 @@
from .creation import router as creation_router
from .inline_mode import router as inline_router from .inline_mode import router as inline_router
from .menu import router as menu_router from .menu import router as menu_router
from .product_mgmt import router as product_router from .product_mgmt import router as product_router
from .creation import router as creation_router
routers = [inline_router, menu_router, product_router, creation_router] routers = [inline_router, menu_router, product_router, creation_router]

View File

@@ -1,21 +1,40 @@
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
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.mapper import parse_cat_id from misc.mapper import parse_cat_id
from misc.states import AdminControlStorage from misc.states import AdminControlStorage
from misc.kb.common import return_menu
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)
@@ -67,6 +91,134 @@ async def create_new_category(
await ctx.msg.edit_text( await ctx.msg.edit_text(
f"✅ Категория {msg.text} создана успешно.", f"✅ Категория {msg.text} создана успешно.",
reply_markup=created_category( reply_markup=created_category(
category.id, f"🛒 {category.name}", category.parent_id 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

@@ -1,6 +1,6 @@
import logging import logging
from aiogram import Router, F from aiogram import F, Router
from aiogram.types import ( from aiogram.types import (
ChosenInlineResult, ChosenInlineResult,
InlineQuery, InlineQuery,
@@ -26,9 +26,7 @@ async def send_invoice(iq: InlineQuery, amount: int):
InlineQueryResultArticle( InlineQueryResultArticle(
id=dict_to_b64({"a": amount}), id=dict_to_b64({"a": amount}),
title=f"💸 Счёт на {amount}₽", title=f"💸 Счёт на {amount}₽",
input_message_content=InputTextMessageContent( input_message_content=InputTextMessageContent(message_text="⏳ Создаю счёт..."),
message_text="⏳ Создаю счёт..."
),
reply_markup=placeholder_kb, reply_markup=placeholder_kb,
description="Нажмите, чтобы создать счёт.", description="Нажмите, чтобы создать счёт.",
), ),

View File

@@ -1,9 +1,9 @@
import logging import logging
from aiogram import Router, F from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from misc.filters import IsVerified from misc.filters import IsVerified
from misc.kb.admins import main_menu from misc.kb.admins import main_menu

View File

@@ -1,17 +1,19 @@
import logging import logging
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from dto.catalogue import CategoryActionContext from dto.catalogue import CategoryActionContext
from dto.control import EditProductContext from dto.control import EditProductContext
from misc.filters import IsVerified from misc.filters import IsVerified
from misc.kb import admins
from misc.kb.admins import back_to_product_kb 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 product_editing_mapping from misc.texts import get_product_description, product_editing_mapping
from repositories.categories import CategoriesRepository from repositories.categories import CategoriesRepository
from repositories.products import ProductRepository from repositories.products import ProductRepository
@@ -19,7 +21,7 @@ logger = logging.getLogger(__name__)
router = Router() router = Router()
@router.callback_query(IsVerified(), F.data.startswith("edit_product:")) @router.callback_query(IsVerified(), F.data.regexp(r"^edit_product:\d+:(name|description|price)$"))
async def edit_product(cb: CallbackQuery, state: FSMContext): async def edit_product(cb: CallbackQuery, state: FSMContext):
await state.clear() await state.clear()
@@ -31,18 +33,14 @@ async def edit_product(cb: CallbackQuery, state: FSMContext):
await cb.message.delete() await cb.message.delete()
msg = await cb.message.answer( msg = await cb.message.answer(
f"<b>✍️ Введите {product_editing_mapping.get(mode)}</b>", f"<b>✍️ Введите {product_editing_mapping.get(mode)}</b>",
reply_markup=back_to_product_kb( reply_markup=back_to_product_kb(product_id, cb_factory=lambda p: f"product:{p}"),
product_id, cb_factory=lambda p: f"product:{p}"
),
) )
else: else:
msg = cb.message msg = cb.message
await cb.message.edit_text( await cb.message.edit_text(
f"<b>✍️ Введите {product_editing_mapping.get(mode)}</b>", f"<b>✍️ Введите {product_editing_mapping.get(mode)}</b>",
reply_markup=back_to_product_kb( reply_markup=back_to_product_kb(product_id, cb_factory=lambda p: f"product:{p}"),
product_id, cb_factory=lambda p: f"product:{p}"
),
) )
ctx = EditProductContext(int(product_id), mode, msg) ctx = EditProductContext(int(product_id), mode, msg)
@@ -68,13 +66,9 @@ async def edit_product_query(
try: try:
await ctx.msg.edit_text("⏳") await ctx.msg.edit_text("⏳")
if ctx.mode == "name": if ctx.mode == "name":
await products_repo.update_product_name_by_id( await products_repo.update_product_name_by_id(session, ctx.product_id, msg.text)
session, ctx.product_id, msg.text
)
elif ctx.mode == "description": elif ctx.mode == "description":
await products_repo.update_product_description_by_id( await products_repo.update_product_description_by_id(session, ctx.product_id, msg.text)
session, ctx.product_id, msg.text
)
elif ctx.mode == "price": elif ctx.mode == "price":
if msg.text.isdigit(): if msg.text.isdigit():
await products_repo.update_product_price_by_id( await products_repo.update_product_price_by_id(
@@ -99,6 +93,158 @@ async def edit_product_query(
await ctx.msg.edit_text(text="‼️ Что-то пошло не так.", reply_markup=kb) await ctx.msg.edit_text(text="‼️ Что-то пошло не так.", reply_markup=kb)
@router.callback_query(IsVerified(), F.data.startswith("edit_product:"), F.data.endswith(":hide"))
async def toggle_product_visibility(
cb: CallbackQuery,
state: FSMContext,
session: AsyncSession,
products_repo: ProductRepository,
):
await state.clear()
product_id = int(cb.data.split(":")[1])
product = await products_repo.toggle_product_hidden_by_id(session, product_id)
if not product:
await cb.answer("Товар не найден.", show_alert=True)
return
await cb.answer("Товар скрыт." if product.is_hidden else "Товар снова виден.")
if cb.message.photo:
await cb.message.edit_caption(
caption=cb.message.caption,
reply_markup=admins.edit_product(product),
)
return
await cb.message.edit_reply_markup(reply_markup=admins.edit_product(product))
@router.callback_query(IsVerified(), F.data.startswith("edit_product:"), F.data.endswith(":photo"))
async def request_product_photo(cb: CallbackQuery, state: FSMContext):
await state.clear()
product_id = int(cb.data.split(":")[1])
if cb.message.photo:
await cb.message.delete()
msg = await cb.message.answer(
"<b>📷 Отправьте фотографию товара.</b>",
reply_markup=back_to_product_kb(product_id, cb_factory=lambda p: f"product:{p}"),
)
else:
msg = cb.message
await cb.message.edit_text(
"<b>📷 Отправьте фотографию товара.</b>",
reply_markup=back_to_product_kb(product_id, cb_factory=lambda p: f"product:{p}"),
)
await state.set_state(AdminControlStorage.edit_product_photo)
await state.set_data({"ctx": EditProductContext(product_id, "photo", msg)})
@router.message(AdminControlStorage.edit_product_photo)
async def upload_product_photo(
msg: Message,
state: FSMContext,
session: AsyncSession,
products_repo: ProductRepository,
):
data = await state.get_data()
ctx: EditProductContext = data.get("ctx", EditProductContext)
kb = back_to_product_kb(ctx.product_id, cb_factory=lambda p: f"product:{p}")
if not msg.photo:
await msg.delete()
await ctx.msg.edit_text("❌ Отправьте именно фотографию.", reply_markup=kb)
await state.set_state(AdminControlStorage.edit_product_photo)
await state.set_data({"ctx": ctx})
return
await state.clear()
await msg.delete()
try:
await ctx.msg.edit_text("⏳")
await products_repo.add_product_file_id(
session, ctx.product_id, file_id=msg.photo[-1].file_id
)
await ctx.msg.edit_text("✅ Фотография обновлена.", reply_markup=kb)
except Exception as e:
logger.exception(e)
await ctx.msg.edit_text("‼️ Что-то пошло не так.", reply_markup=kb)
@router.callback_query(
IsVerified(), F.data.startswith("edit_product:"), F.data.endswith(":delete_photo")
)
async def delete_product_photo(
cb: CallbackQuery,
state: FSMContext,
session: AsyncSession,
products_repo: ProductRepository,
):
await state.clear()
product_id = int(cb.data.split(":")[1])
product = await products_repo.clear_product_photo(session, product_id)
if not product:
await cb.answer("Товар не найден.", show_alert=True)
return
await cb.answer("Фото удалено.")
text = get_product_description(product)
reply_markup = admins.edit_product(product)
if cb.message.photo:
await cb.message.delete()
await cb.message.answer(text, reply_markup=reply_markup)
return
if cb.message.caption:
await cb.message.edit_text(text, reply_markup=reply_markup)
return
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()
@@ -143,6 +289,4 @@ async def edit_category(
session=session, category_id=parse_cat_id(ctx.parent_id), value=msg.text session=session, category_id=parse_cat_id(ctx.parent_id), value=msg.text
) )
await ctx.msg.edit_text( await ctx.msg.edit_text("✅ Успешно.", reply_markup=get_back_to_catalogue(cat.parent_id))
"✅ Успешно.", reply_markup=get_back_to_catalogue(cat.parent_id)
)

View File

@@ -1,11 +1,11 @@
from .cart import router as cart_router
from .catalogue import router as catalogue_router
from .checkout import router as checkout_router
from .invoices import router as invoice_router from .invoices import router as invoice_router
from .menu import router as menu_router from .menu import router as menu_router
from .catalogue import router as catalogue_router
from .products import router as products_router from .products import router as products_router
from .cart import router as cart_router
from .checkout import router as checkout_router
from .security import router as security_router
from .search import router as search_router from .search import router as search_router
from .security import router as security_router
routers = [ routers = [
invoice_router, invoice_router,

View File

@@ -1,7 +1,7 @@
from aiogram import Router, F from aiogram import F, Router
from aiogram.types import CallbackQuery, Message
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 sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import PAGE_SIZE from config import PAGE_SIZE
@@ -21,15 +21,14 @@ async def cart_init(
): ):
await state.clear() await state.clear()
cart = await order_service.build_pagination_cart_dto( cart = await order_service.build_pagination_cart_dto(session, customer=cb.from_user.id)
session, customer=cb.from_user.id
)
if not len(cart.items): if not len(cart.items):
await cb.message.edit_text("🛒 Корзина пуста.", reply_markup=return_menu) await cb.message.edit_text("🛒 Корзина пуста.", reply_markup=return_menu)
return return
await cb.message.edit_text( await cb.message.edit_text(
f"total: {cart.total}₽ | {len(cart.items)}", "<b>🌸 Убедитесь в том, что все желаемые позиции есть в заказе ниже!</b>\n\n"
f"<b>Сумма:</b> <i>{cart.total}₽</i>",
reply_markup=render_cart( reply_markup=render_cart(
cart.items[:PAGE_SIZE], cart.items[:PAGE_SIZE],
show_next=len(cart.items) > PAGE_SIZE, show_next=len(cart.items) > PAGE_SIZE,
@@ -47,16 +46,15 @@ async def cart_cmd(
): ):
await state.clear() await state.clear()
cart = await order_service.build_pagination_cart_dto( cart = await order_service.build_pagination_cart_dto(session, customer=msg.from_user.id)
session, customer=msg.from_user.id
)
if not len(cart.items): if not len(cart.items):
await msg.answer("🛒 Корзина пуста.", reply_markup=return_menu) await msg.answer("🛒 Корзина пуста.", reply_markup=return_menu)
return return
await msg.answer( await msg.answer(
f"total: {cart.total}₽", "<b>🌸 Убедитесь в том, что все желаемые позиции есть в заказе ниже!</b>\n\n"
f"<b>Сумма:</b> <i>{cart.total}₽</i>",
reply_markup=render_cart( reply_markup=render_cart(
cart.items[:PAGE_SIZE], cart.items[:PAGE_SIZE],
show_next=len(cart.items) > PAGE_SIZE, show_next=len(cart.items) > PAGE_SIZE,

View File

@@ -1,6 +1,6 @@
from aiogram import Router, F from aiogram import F, Router
from aiogram.types import CallbackQuery
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import VERIFIED_ACCOUNTS from config import VERIFIED_ACCOUNTS
@@ -24,7 +24,7 @@ async def subcatalogue(
cat_id = cb.data.split(":")[1] cat_id = cb.data.split(":")[1]
is_admin = cb.from_user.id in VERIFIED_ACCOUNTS is_admin = cb.from_user.id in VERIFIED_ACCOUNTS
view = await catalogue_service.build_category_view(session, cat_id) view = await catalogue_service.build_category_view(session, cat_id, include_hidden=is_admin)
kb = render_catalogue(view, is_admin=is_admin) kb = render_catalogue(view, is_admin=is_admin)
path = await catalogue_service.get_path(session, cat_id) path = await catalogue_service.get_path(session, cat_id)

View File

@@ -1,19 +1,19 @@
import logging import logging
from typing import Optional
from aiogram import Router, F from aiogram import F, Router
from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
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
@@ -98,7 +98,7 @@ async def checkout_phone(msg: Message, state: FSMContext):
ctx.phone = msg.text ctx.phone = msg.text
await ctx.orig_msg.edit_text( await ctx.orig_msg.edit_text(
f"<i>👤 Имя: {ctx.name}</i>\n<i>📱 Номер телефона: {ctx.phone}</i>\n\n<b>📍 Укажите ваш адрес (город, улица):</b>", f"<i>👤 Имя: {ctx.name}</i>\n<i>📱 Номер телефона: {ctx.phone}</i>\n\n<b>📍 Укажите адрес ближайшего ПВЗ Ozon (город, улица):</b>",
reply_markup=return_menu, reply_markup=return_menu,
) )
await state.set_state(CheckoutStorage.address) await state.set_state(CheckoutStorage.address)
@@ -119,7 +119,7 @@ async def checkout_address(msg: Message, state: FSMContext):
"━━━━━━━━━━━━━━\n\n" "━━━━━━━━━━━━━━\n\n"
f"<i>👤 Имя: {ctx.name}</i>\n" f"<i>👤 Имя: {ctx.name}</i>\n"
f"<i>📱 Номер телефона: {ctx.phone}</i>\n" f"<i>📱 Номер телефона: {ctx.phone}</i>\n"
f"<i>📍 Ваш адрес: {ctx.address}</i>\n\n" f"<i>📍 Адрес ближайшего ПВЗ Ozon: {ctx.address}</i>\n\n"
"━━━━━━━━━━━━━━", "━━━━━━━━━━━━━━",
reply_markup=order_specs_confirmation(ctx.order_id), reply_markup=order_specs_confirmation(ctx.order_id),
) )
@@ -138,7 +138,7 @@ async def payment_create(
order_repo: OrderRepository, order_repo: OrderRepository,
): ):
data = await state.get_data() data = await state.get_data()
ctx: Optional[CheckoutContext] = data.get("ctx") ctx: CheckoutContext | None = data.get("ctx")
if not ctx: if not ctx:
await cb.message.edit_text( await cb.message.edit_text(
"🍃 Что-то пошло не так, повторите попытку позже...", "🍃 Что-то пошло не так, повторите попытку позже...",
@@ -157,16 +157,14 @@ async def payment_create(
"━━━━━━━━━━━━━━\n\n" "━━━━━━━━━━━━━━\n\n"
f"<i>👤 Имя: {ctx.name}</i>\n" f"<i>👤 Имя: {ctx.name}</i>\n"
f"<i>📱 Номер телефона: {ctx.phone}</i>\n" f"<i>📱 Номер телефона: {ctx.phone}</i>\n"
f"<i>📍 Адрес: {ctx.address}</i>\n\n" f"<i>📍 Адрес ближайшего ПВЗ Ozon: {ctx.address}</i>\n\n"
"━━━━━━━━━━━━━━\n" "━━━━━━━━━━━━━━\n"
f"{get_order_item_list(cart)}", f"{get_order_item_list(cart)}",
reply_markup=customer_contacts( reply_markup=customer_contacts(
cb.from_user.id, bool(cb.from_user.username), cb.from_user.username cb.from_user.id, bool(cb.from_user.username), cb.from_user.username
), ),
) )
await order_repo.update_order_status( await order_repo.update_order_status(session, cart.order_id, OrderStatus.CREATED)
session, cart.order_id, OrderStatus.CREATED
)
await cb.message.edit_text( await cb.message.edit_text(
"<b>🟢 Ваш заказ успешно отправлен!</b>\n" "<b>🟢 Ваш заказ успешно отправлен!</b>\n"
@@ -182,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,6 +1,6 @@
import logging import logging
from aiogram import Router, F from aiogram import F, Router
from aiogram.filters import CommandObject, CommandStart from aiogram.filters import CommandObject, CommandStart
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import ( from aiogram.types import (
@@ -9,12 +9,12 @@ from aiogram.types import (
) )
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import card_info
from db.models.invoices import InvoiceStatus from db.models.invoices import InvoiceStatus
from misc.kb.admins import verify_payment from misc.kb.admins import verify_payment
from misc.kb.client import confirm_payment from misc.kb.client import confirm_payment
from misc.kb.common import return_menu from misc.kb.common import return_menu
from repositories.invoices import InvoiceRepository from repositories.invoices import InvoiceRepository
from config import card_info
router = Router() router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,9 +33,7 @@ async def activate_invoice(
invoice = await invoice_repo.get_invoice_by_id(session, invoice_id) invoice = await invoice_repo.get_invoice_by_id(session, invoice_id)
if not invoice: if not invoice:
await msg.answer( await msg.answer("⏳ Счёт истёк. Повторите попытку позже.", reply_markup=return_menu)
"⏳ Счёт истёк. Повторите попытку позже.", reply_markup=return_menu
)
return return
if invoice.status != InvoiceStatus.PENDING: if invoice.status != InvoiceStatus.PENDING:
@@ -43,9 +41,7 @@ async def activate_invoice(
return return
if not (card_info.number and card_info.holder): if not (card_info.number and card_info.holder):
await msg.answer( await msg.answer("🍃 Оплата недоступна, повторите попытку позже.", reply_markup=return_menu)
"🍃 Оплата недоступна, повторите попытку позже.", reply_markup=return_menu
)
return return
await msg.answer( await msg.answer(

View File

@@ -1,11 +1,16 @@
from aiogram import Router, F import contextlib
from aiogram.types import CallbackQuery, Message import os
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, FSInputFile, Message
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from misc.kb import main_menu_kb from config import WELCOME_VIDEO_NOTE_FP
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()
@@ -20,12 +25,17 @@ async def user_main_menu(
): ):
await state.clear() await state.clear()
cart_items = await order_items_repo.get_items_count_by_customer( if not await get_user(session, msg.from_user.id):
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( await msg.answer(
"hii!", reply_markup=main_menu_kb(cart_items) "<b>Добрый день!</b>\n" 'Нажмите "Каталог" чтобы выбрать товар или воспользуйтесь поиском.',
) # TODO: Write a welcome message reply_markup=main_menu_kb(cart_items),
)
@router.callback_query(F.data.startswith("menu:")) @router.callback_query(F.data.startswith("menu:"))
@@ -37,14 +47,11 @@ async def user_main_menu_cb(
): ):
await state.clear() await state.clear()
try: with contextlib.suppress(Exception):
await cb.message.reply_to_message.delete() await cb.message.reply_to_message.delete()
except Exception:
...
cart_items = await order_items_repo.get_items_count_by_customer( cart_items = await order_items_repo.get_items_count_by_customer(session, cb.from_user.id)
session, cb.from_user.id
)
await cb.message.edit_text( await cb.message.edit_text(
"hii!", reply_markup=main_menu_kb(cart_items) "<b>Добрый день!</b>\n" 'Нажмите "Каталог" чтобы выбрать товар или воспользуйтесь поиском.',
) # TODO: Write a welcome message reply_markup=main_menu_kb(cart_items),
)

View File

@@ -1,10 +1,11 @@
import os import contextlib
import logging import logging
import os
from re import Match from re import Match
from aiogram import Router, F
from aiogram.types import CallbackQuery, FSInputFile from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import VERIFIED_ACCOUNTS from config import VERIFIED_ACCOUNTS
@@ -23,9 +24,7 @@ logger = logging.getLogger(__name__)
@router.callback_query( @router.callback_query(
F.data.regexp(r"products:(?P<category_id>.*):(?P<page_id>\d+)").as_( F.data.regexp(r"products:(?P<category_id>.*):(?P<page_id>\d+)").as_("pagination_match")
"pagination_match"
)
) )
async def products_pagination( async def products_pagination(
cb: CallbackQuery, cb: CallbackQuery,
@@ -39,11 +38,11 @@ async def products_pagination(
pagination_data = pagination_match.groupdict() pagination_data = pagination_match.groupdict()
category_id = int(pagination_data.get("category_id")) category_id = int(pagination_data.get("category_id"))
page_id = int(pagination_data.get("page_id", 0)) page_id = int(pagination_data.get("page_id", 0))
is_admin = cb.from_user.id in VERIFIED_ACCOUNTS
view = await catalogue_service.build_category_view( view = await catalogue_service.build_category_view(
session, category_id, page=page_id session, category_id, page=page_id, include_hidden=is_admin
) )
is_admin = cb.from_user.id in VERIFIED_ACCOUNTS
kb = render_catalogue(view, is_admin=is_admin) kb = render_catalogue(view, is_admin=is_admin)
@@ -61,10 +60,15 @@ async def product_card(
await state.clear() await state.clear()
product_id = int(cb.data.split(":")[1]) product_id = int(cb.data.split(":")[1])
product = await products_repo.get_product_by_id(session, product_id) is_admin = cb.from_user.id in VERIFIED_ACCOUNTS
product = await products_repo.get_product_by_id(session, product_id, include_hidden=is_admin)
if cb.from_user.id in VERIFIED_ACCOUNTS: if not product:
kb = admins.edit_product(product_id=product.id, category_id=product.category_id) await cb.answer("<b>❌ Товар недоступен.</b>", show_alert=True)
return
if is_admin:
kb = admins.edit_product(product)
else: else:
order_item = await order_service.get_cart_product_amount( order_item = await order_service.get_cart_product_amount(
session, customer=cb.from_user.id, product_id=product_id session, customer=cb.from_user.id, product_id=product_id
@@ -76,9 +80,7 @@ async def product_card(
cart_amount=order_item, cart_amount=order_item,
) )
if not product.file_id and not ( if not product.file_id and not (product.img_path and os.path.isfile(product.img_path)):
product.img_path and os.path.isfile(product.img_path)
):
await cb.message.edit_text( await cb.message.edit_text(
get_product_description(product), get_product_description(product),
reply_markup=kb, reply_markup=kb,
@@ -95,10 +97,8 @@ async def product_card(
) )
return return
try: with contextlib.suppress(Exception):
await cb.message.edit_text("<b>⏳ Загружаем карточку товара...</b>") await cb.message.edit_text("<b>⏳ Загружаем карточку товара...</b>")
except TelegramBadRequest:
...
msg = await cb.message.answer_photo( msg = await cb.message.answer_photo(
FSInputFile(product.img_path), FSInputFile(product.img_path),
@@ -108,11 +108,9 @@ async def product_card(
if not msg.photo: if not msg.photo:
logger.warning("didnt get a photo back wahhh :(") logger.warning("didnt get a photo back wahhh :(")
return # TODO: Add logging return
await products_repo.add_product_file_id( await products_repo.add_product_file_id(session, product_id, file_id=msg.photo[-1].file_id)
session, product_id, file_id=msg.photo[-1].file_id
)
@router.callback_query(F.data.startswith("cart_action:")) @router.callback_query(F.data.startswith("cart_action:"))
@@ -127,7 +125,13 @@ async def cart_action(
action = cb.data.split(":")[1] action = cb.data.split(":")[1]
product_id = int(cb.data.split(":")[2]) product_id = int(cb.data.split(":")[2])
product = await products_repo.get_product_by_id(session, product_id) is_admin = cb.from_user.id in VERIFIED_ACCOUNTS
product = await products_repo.get_product_by_id(session, product_id, include_hidden=is_admin)
if not product:
await cb.answer("❌ Товар недоступен.", show_alert=True)
return
show_cart = True show_cart = True
if action == "add": if action == "add":

View File

@@ -1,12 +1,11 @@
from typing import Optional
import hashlib import hashlib
from aiogram import Router, F 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, Message
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import PAGE_SIZE from config import PAGE_SIZE, VERIFIED_ACCOUNTS
from misc.kb.client import render_products from misc.kb.client import render_products
from misc.kb.common import return_menu from misc.kb.common import return_menu
from misc.redis import RedisClient from misc.redis import RedisClient
@@ -20,7 +19,7 @@ router = Router()
async def search_trigger(cb: CallbackQuery, state: FSMContext): async def search_trigger(cb: CallbackQuery, state: FSMContext):
await state.clear() await state.clear()
await cb.message.edit_text("🔍 Введите запрос:", reply_markup=return_menu) await cb.message.edit_text("<b>🔍 Введите запрос:</b>", reply_markup=return_menu)
await state.set_state(SearchStorage.query) await state.set_state(SearchStorage.query)
await state.set_data({"msg": cb.message}) await state.set_data({"msg": cb.message})
@@ -37,11 +36,13 @@ async def searching(
await state.clear() await state.clear()
await msg.delete() await msg.delete()
orig_msg: Optional[Message] = data.get("msg") or await msg.answer("⏳") orig_msg: Message | None = data.get("msg") or await msg.answer("⏳")
await orig_msg.edit_text("⏳ Поиск...") await orig_msg.edit_text("⏳ Поиск...")
is_admin = msg.from_user.id in VERIFIED_ACCOUNTS
results = await products_repo.search( results = await products_repo.search(
session, session,
msg.text, msg.text,
include_hidden=is_admin,
) )
next_cb = None next_cb = None
@@ -50,13 +51,19 @@ 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(
"here", "<b>🗒 Результаты поиска:</b>",
reply_markup=render_products( reply_markup=render_products(
results[:PAGE_SIZE], results[:PAGE_SIZE],
product_cb_factory=lambda p: f"product:{p.id}", product_cb_factory=lambda p: f"product:{p.id}",
back_cb="menu:main", back_cb="menu:main",
next_cb=next_cb, next_cb=next_cb,
is_admin=is_admin,
page=0,
total_pages=total_pages,
), ),
) )
@@ -79,7 +86,16 @@ async def search_pagination(
if not query: if not query:
await cb.message.edit_text("🍃 Попробуйте ещё раз...") await cb.message.edit_text("🍃 Попробуйте ещё раз...")
results = await products_repo.search(session, query, offset=PAGE_SIZE * page) is_admin = cb.from_user.id in VERIFIED_ACCOUNTS
results = await products_repo.search(
session,
query,
offset=PAGE_SIZE * page,
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(
@@ -88,5 +104,8 @@ async def search_pagination(
back_cb="menu:main", back_cb="menu:main",
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,
page=page,
total_pages=total_pages,
) )
) )

View File

@@ -1,4 +1,4 @@
from aiogram import Router, F from aiogram import F, Router
from aiogram.enums import MessageOriginType from aiogram.enums import MessageOriginType
from aiogram.types import Message from aiogram.types import Message

12
main.py
View File

@@ -1,14 +1,15 @@
import os
import asyncio import asyncio
import logging import logging
import os
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from redis.asyncio import Redis
from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.session.aiohttp import AiohttpSession
from redis.asyncio import Redis
from config import BOT_TOKEN, REDIS_URL from config import BOT_TOKEN, PROXY_URL, REDIS_URL
from db.base import async_session
from handlers import admins_routers, client_routers from handlers import admins_routers, client_routers
from middlewares.di import DIMiddleware from middlewares.di import DIMiddleware
from middlewares.repository import RepositoryMiddleware from middlewares.repository import RepositoryMiddleware
from middlewares.session import DBSessionMiddleware from middlewares.session import DBSessionMiddleware
@@ -19,7 +20,6 @@ from repositories.order_items import OrderItemRepository
from repositories.orders import OrderRepository from repositories.orders import OrderRepository
from repositories.products import ProductRepository from repositories.products import ProductRepository
from services import OrderService from services import OrderService
from db.base import async_session
from services.catalogue import CatalogueService from services.catalogue import CatalogueService
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
@@ -32,7 +32,7 @@ os.makedirs("static/img", exist_ok=True)
async def main(): async def main():
dp = Dispatcher() dp = Dispatcher()
aiohttp_session = AiohttpSession(proxy="http://127.0.0.1:2080") aiohttp_session = AiohttpSession(proxy=PROXY_URL)
bot = Bot( bot = Bot(
token=BOT_TOKEN, token=BOT_TOKEN,
default=DefaultBotProperties(parse_mode="HTML"), default=DefaultBotProperties(parse_mode="HTML"),

View File

@@ -2,4 +2,4 @@ from .di import DIMiddleware
from .repository import RepositoryMiddleware from .repository import RepositoryMiddleware
from .session import DBSessionMiddleware from .session import DBSessionMiddleware
__all__ = ["DIMiddleware", "RepositoryMiddleware", "DBSessionMiddleware"] __all__ = ["DBSessionMiddleware", "DIMiddleware", "RepositoryMiddleware"]

View File

@@ -1,10 +1,11 @@
from aiogram import BaseMiddleware from collections.abc import Callable
from typing import Any, Callable from typing import Any
from aiogram import BaseMiddleware
from redis.asyncio import Redis from redis.asyncio import Redis
from services.orders import OrderService
from services.catalogue import CatalogueService from services.catalogue import CatalogueService
from services.orders import OrderService
class DIMiddleware(BaseMiddleware): class DIMiddleware(BaseMiddleware):

View File

@@ -1,6 +1,6 @@
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from repositories import OrderRepository, CategoriesRepository from repositories import CategoriesRepository, OrderRepository
from repositories.invoices import InvoiceRepository from repositories.invoices import InvoiceRepository
from repositories.order_items import OrderItemRepository from repositories.order_items import OrderItemRepository
from repositories.products import ProductRepository from repositories.products import ProductRepository

View File

@@ -1,4 +0,0 @@
from misc.kb.admins import *
from misc.kb.client import *
__all__ = ["admins", "client"]

View File

@@ -1,7 +1,10 @@
from typing import Optional, Callable, Union 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
from db.models import Product
main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder( main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[ [
[InlineKeyboardButton(text="📦 Изменить каталог", callback_data="cat:root")], [InlineKeyboardButton(text="📦 Изменить каталог", callback_data="cat:root")],
@@ -10,22 +13,18 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
).as_markup() ).as_markup()
def back_to_product_kb( def back_to_product_kb(product_id: int | str, *, cb_factory: Callable[[int | str], str]):
product_id: Union[int, str], *, cb_factory: Callable[[int | str], str]
):
return InlineKeyboardBuilder( return InlineKeyboardBuilder(
[[InlineKeyboardButton(text="⬅️", callback_data=cb_factory(product_id))]] [[InlineKeyboardButton(text="⬅️", callback_data=cb_factory(product_id))]]
).as_markup() ).as_markup()
def customer_contacts( def customer_contacts(
user_id: int, has_mention: bool, mention: Optional[str] = None user_id: int, has_mention: bool, mention: str | None = None
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
btns: list[list[InlineKeyboardButton]] = [] btns: list[list[InlineKeyboardButton]] = []
( (
btns.append( btns.append([InlineKeyboardButton(text="👤 Перейти", url=f"https://t.me/{mention}")])
[InlineKeyboardButton(text="👤 Перейти", url=f"https://t.me/{mention}")]
)
if has_mention if has_mention
else None else None
) )
@@ -40,9 +39,7 @@ def customer_contacts(
def payment_link(url: str) -> InlineKeyboardMarkup: def payment_link(url: str) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder( return InlineKeyboardBuilder([[InlineKeyboardButton(text="💸 Оплатить", url=url)]]).as_markup()
[[InlineKeyboardButton(text="💸 Оплатить", url=url)]]
).as_markup()
def verify_payment(invoice_id: int): def verify_payment(invoice_id: int):
@@ -57,24 +54,41 @@ def verify_payment(invoice_id: int):
).as_markup() ).as_markup()
def edit_product(product_id: int, category_id: int): def edit_product(product: Product):
product_id = product.id
prefix = f"edit_product:{product_id}" prefix = f"edit_product:{product_id}"
visibility_btn = "🙈 Скрыт" if product.is_hidden else "👁️ Видим"
photo_btn = "🗑️ Удалить фото" if product.file_id or product.img_path else "📷 Добавить фото"
photo_action = "delete_photo" if product.file_id or product.img_path else "photo"
return InlineKeyboardBuilder( return InlineKeyboardBuilder(
[ [
[ [
InlineKeyboardButton( InlineKeyboardButton(text="✍️ Название", callback_data=f"{prefix}:name"),
text="✍️ Название", callback_data=f"{prefix}:name"
),
InlineKeyboardButton( InlineKeyboardButton(
text="✍️ Описание", text="✍️ Описание",
callback_data=f"{prefix}:description", callback_data=f"{prefix}:description",
), ),
], ],
[InlineKeyboardButton(text="💸 Цена", callback_data=f"{prefix}:price")], [InlineKeyboardButton(text="💸 Цена", callback_data=f"{prefix}:price")],
[InlineKeyboardButton(text=photo_btn, callback_data=f"{prefix}:{photo_action}")],
[ [
InlineKeyboardButton(text="👁️", callback_data=f"{prefix}:hide"), InlineKeyboardButton(text=visibility_btn, callback_data=f"{prefix}:hide"),
InlineKeyboardButton(text="❌", callback_data=f"{prefix}:delete"), InlineKeyboardButton(text="❌", callback_data=f"{prefix}:delete"),
], ],
[InlineKeyboardButton(text="⬅️", callback_data=f"cat:{category_id}")], [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() ).as_markup()

View File

@@ -1,20 +1,21 @@
from typing import Optional, Callable, Union 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
from dto.catalogue import CatalogueView, CatalogueType from dto.catalogue import CatalogueType, CatalogueView
from misc.mapper import serialize_cat_id from misc.mapper import serialize_cat_id
def pagination_row( def pagination_row(
*, *,
page: int, page: int,
prev_cb: Optional[str] = None, prev_cb: str | None = None,
next_cb: Optional[str] = None, next_cb: str | None = None,
) -> list[InlineKeyboardButton]: ) -> list[InlineKeyboardButton]:
row = [] row = []
@@ -39,22 +40,18 @@ def cart_btn(cart_items: int) -> InlineKeyboardButton:
def get_back_to_catalogue( def get_back_to_catalogue(
parent_id: int, parent_id: int,
*, *,
text: Optional[str] = "⬅️ Назад", text: str | None = "⬅️ Назад",
show_controls: bool = False, show_controls: bool = False,
cat_id: Optional[Union[str, int]] = None, cat_id: str | int | None = None,
fallback_cb: Optional[str] = "menu:main", fallback_cb: str | None = "menu:main",
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if show_controls and cat_id: if show_controls and cat_id:
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(text="➕ Категория", callback_data=f"new:category:{cat_id}"),
text="➕ Категория", callback_data=f"new:category:{cat_id}" InlineKeyboardButton(text="➕ Товар", callback_data=f"new:product:{cat_id}"),
),
InlineKeyboardButton(
text="➕ Товар", callback_data=f"new:product:{cat_id}"
),
) )
builder.row( builder.row(
@@ -82,19 +79,20 @@ def main_menu_kb(cart_items: int) -> InlineKeyboardMarkup:
[ [
[ [
InlineKeyboardButton(text="📦 Каталог", callback_data="cat:root"), InlineKeyboardButton(text="📦 Каталог", callback_data="cat:root"),
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()
def render_category( def render_category(
children: list[Category], children: list[Category],
parent_id: Optional[Union[str, int]] = None, parent_id: str | int | None = None,
show_menu: bool = False, show_menu: bool = False,
*, *,
cat_id: Optional[Union[str, int]] = None, cat_id: str | int | None = None,
category_cb_factory: Callable[[Category], str], category_cb_factory: Callable[[Category], str],
is_admin: bool = False, is_admin: bool = False,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
@@ -116,9 +114,7 @@ def render_category(
text="➕ Категория", text="➕ Категория",
callback_data=f"new:category:{cat_id}", callback_data=f"new:category:{cat_id}",
), ),
InlineKeyboardButton( InlineKeyboardButton(text="✍️", callback_data=f"edit:category:{cat_id}"),
text="✍️", callback_data=f"edit:category:{cat_id}"
),
] ]
) )
@@ -126,7 +122,10 @@ def render_category(
nav.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")]) nav.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")])
elif parent_id is not None: elif parent_id is not None:
nav.append( nav.append(
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}")] [
InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}"),
InlineKeyboardButton(text="🏠", callback_data="menu:main"),
]
) )
return InlineKeyboardBuilder( return InlineKeyboardBuilder(
@@ -138,22 +137,27 @@ def render_products(
products: list[Product], products: list[Product],
*, *,
product_cb_factory: Callable[[Product], str], product_cb_factory: Callable[[Product], str],
back_cb: Optional[str] = None, back_cb: str | None = None,
prev_cb: Optional[str] = None, prev_cb: str | None = None,
next_cb: Optional[str] = None, next_cb: str | None = None,
columns: int = 2, columns: int = 2,
is_admin: bool = False, is_admin: bool = False,
cat_id: Optional[int] = None, cat_id: int | None = None,
page: int = 0,
total_pages: int | None = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if back_cb: if back_cb:
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=back_cb)) builder.row(
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_cb),
InlineKeyboardButton(text="🏠", callback_data="menu:main"),
)
for i in range(0, len(products), columns): for i in range(0, len(products), columns):
row = [ row = [
InlineKeyboardButton( InlineKeyboardButton(
text=p.name, text=f"{p.name} {'🙈' if is_admin and p.is_hidden else ''}".strip(),
callback_data=product_cb_factory(p), callback_data=product_cb_factory(p),
) )
for p in products[i : i + columns] for p in products[i : i + columns]
@@ -161,14 +165,13 @@ def render_products(
builder.row(*row) builder.row(*row)
if is_admin: if is_admin:
builder.row( builder.row(InlineKeyboardButton(text="➕ Товар", callback_data=f"new:product:{cat_id}"))
InlineKeyboardButton(text="➕ Товар", callback_data=f"new:product:{cat_id}")
)
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))
@@ -189,18 +192,17 @@ 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}",
back_cb=f"cat:{view.parent_id}", back_cb=f"cat:{view.parent_id}",
prev_cb=( prev_cb=(f"products:{view.category}:{view.page - 1}" if view.page > 0 else None),
f"products:{view.category}:{view.page - 1}" if view.page > 0 else None 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(
@@ -216,13 +218,9 @@ def render_product_interactions(
if show_cart: if show_cart:
btns.append( btns.append(
[ [
InlineKeyboardButton( InlineKeyboardButton(text="➖", callback_data=f"cart_action:remove:{product_id}"),
text="➖", callback_data=f"cart_action:remove:{product_id}"
),
InlineKeyboardButton(text=f"🛒 {cart_amount}", callback_data="..."), InlineKeyboardButton(text=f"🛒 {cart_amount}", callback_data="..."),
InlineKeyboardButton( InlineKeyboardButton(text="➕", callback_data=f"cart_action:add:{product_id}"),
text="➕", callback_data=f"cart_action:add:{product_id}"
),
] ]
) )
btns.append( btns.append(
@@ -243,7 +241,12 @@ def render_product_interactions(
] ]
) )
btns.append([InlineKeyboardButton(text="⬅️", callback_data=f"cat:{category_id}")]) btns.append(
[
InlineKeyboardButton(text="⬅️", callback_data=f"cat:{category_id}"),
InlineKeyboardButton(text="🏠", callback_data="menu:main"),
]
)
return InlineKeyboardBuilder(btns).as_markup() return InlineKeyboardBuilder(btns).as_markup()
@@ -252,9 +255,9 @@ def render_cart(
cart_items: list[CartItemDTO], cart_items: list[CartItemDTO],
*, *,
page: int = 0, page: int = 0,
show_prev: Optional[bool] = False, show_prev: bool | None = False,
show_next: Optional[bool] = False, show_next: bool | None = False,
show_purchase: Optional[bool] = False, show_purchase: bool | None = False,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
btns = [ btns = [
InlineKeyboardButton( InlineKeyboardButton(
@@ -284,9 +287,7 @@ def render_cart(
) )
if show_purchase: if show_purchase:
markup.append( markup.append([InlineKeyboardButton(text="🔷 Оформить заказ", callback_data="checkout")])
[InlineKeyboardButton(text="🔷 Оформить заказ", callback_data="checkout")]
)
return InlineKeyboardBuilder(markup).as_markup() return InlineKeyboardBuilder(markup).as_markup()
@@ -297,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")],
@@ -305,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

@@ -1,5 +1,4 @@
import logging import logging
from typing import Optional
from redis.asyncio import Redis from redis.asyncio import Redis
@@ -18,7 +17,7 @@ class RedisClient:
logger.exception(e) logger.exception(e)
return False return False
async def get_search_query(self, query_hash: str) -> Optional[str]: async def get_search_query(self, query_hash: str) -> str | None:
try: try:
res = await self.r.get(f"search:{query_hash}") res = await self.r.get(f"search:{query_hash}")
return res.decode() return res.decode()

View File

@@ -1,4 +1,4 @@
from aiogram.fsm.state import StatesGroup, State from aiogram.fsm.state import State, StatesGroup
class CheckoutStorage(StatesGroup): class CheckoutStorage(StatesGroup):
@@ -18,5 +18,10 @@ class SearchStorage(StatesGroup):
class AdminControlStorage(StatesGroup): class AdminControlStorage(StatesGroup):
edit_product = State() edit_product = 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()

View File

@@ -1,7 +1,14 @@
import base64 import base64
import json 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
@@ -25,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

@@ -1,11 +1,56 @@
[tool.black] [tool.black]
line-length = 88 line-length = 100
target-version = ["py314"] target-version = ['py313']
include = '\.pyi?$'
extend-exclude = '''
/(
\.git
| venv
| build
| dist
| alembic
)/
'''
skip-string-normalization = false [tool.ruff]
skip-magic-trailing-comma = false line-length = 100
target-version = "py313"
preview = false exclude = [
".git",
"venv",
"build",
"dist",
"alembic",
]
[tool.ruff.lint] [tool.ruff.lint]
ignore=["F405", "F403"] select = [
"E",
"W",
"F",
"I",
"N",
"UP",
"B",
"SIM",
"PL",
"RUF",
"TID",
"PT",
]
ignore = [
"E501",
"D100",
"D104",
"G004",
"PLR0913",
"RUF001",
"RUF002",
"RUF003",
"B008"
]
[tool.ruff.lint.isort]
combine-as-imports = true

View File

@@ -1,11 +1,11 @@
from .orders import OrderRepository
from .categories import CategoriesRepository from .categories import CategoriesRepository
from .products import ProductRepository
from .invoices import InvoiceRepository from .invoices import InvoiceRepository
from .orders import OrderRepository
from .products import ProductRepository
__all__ = [ __all__ = [
"OrderRepository",
"CategoriesRepository", "CategoriesRepository",
"ProductRepository",
"InvoiceRepository", "InvoiceRepository",
"OrderRepository",
"ProductRepository",
] ]

View File

@@ -1,20 +1,17 @@
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from db.models import Category from db.models import Category
class CategoriesRepository: class CategoriesRepository:
async def get_category_by_id( async def get_category_by_id(self, session: AsyncSession, category_id: int) -> Category | None:
self, session: AsyncSession, category_id: int
) -> Optional[Category]:
stmt = select(Category).where(Category.id == category_id) stmt = select(Category).where(Category.id == category_id)
return await session.scalar(stmt) return await session.scalar(stmt)
async def get_categories_by_parent_id( async def get_categories_by_parent_id(
self, session: AsyncSession, parent_id: Optional[int] = None self, session: AsyncSession, parent_id: int | None = None
) -> list[Category]: ) -> list[Category]:
stmt = select(Category).where(Category.parent_id == parent_id) stmt = select(Category).where(Category.parent_id == parent_id)
result = await session.scalars(stmt) result = await session.scalars(stmt)
@@ -31,9 +28,7 @@ class CategoriesRepository:
parent = aliased(Category) parent = aliased(Category)
cte = cte.union_all( cte = cte.union_all(
select(parent.id, parent.parent_id, parent.name).join( select(parent.id, parent.parent_id, parent.name).join(cte, cte.c.parent_id == parent.id)
cte, cte.c.parent_id == parent.id
)
) )
stmt = select(cte) stmt = select(cte)
@@ -45,7 +40,7 @@ class CategoriesRepository:
return list(reversed(rows)) return list(reversed(rows))
async def add_category( async def add_category(
self, session: AsyncSession, *, name: str, parent_id: Optional[int] self, session: AsyncSession, *, name: str, parent_id: int | None
) -> Category: ) -> Category:
category = Category(name=name, parent_id=parent_id) category = Category(name=name, parent_id=parent_id)
@@ -54,9 +49,7 @@ class CategoriesRepository:
return category return category
async def update_category_name( async def update_category_name(self, session: AsyncSession, category_id: int, value: str):
self, session: AsyncSession, category_id: int, value: str
):
category = await self.get_category_by_id(session, category_id) category = await self.get_category_by_id(session, category_id)
category.name = value category.name = value

View File

@@ -1,9 +1,7 @@
from typing import Optional
from sqlalchemy import select from sqlalchemy import select
from db.models import Invoice
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Invoice
from db.models.invoices import InvoiceStatus from db.models.invoices import InvoiceStatus
@@ -14,7 +12,7 @@ class InvoiceRepository:
*, *,
amount: int, amount: int,
creator_id: int, creator_id: int,
inline_message_id: Optional[int] = None, inline_message_id: int | None = None,
status: InvoiceStatus = InvoiceStatus.PENDING, status: InvoiceStatus = InvoiceStatus.PENDING,
) -> Invoice: ) -> Invoice:
invoice = Invoice( invoice = Invoice(
@@ -28,9 +26,7 @@ class InvoiceRepository:
await session.commit() await session.commit()
return invoice return invoice
async def get_invoice_by_id( async def get_invoice_by_id(self, session: AsyncSession, invoice_id: int) -> Invoice | None:
self, session: AsyncSession, invoice_id: int
) -> Optional[Invoice]:
stmt = select(Invoice).where(Invoice.id == invoice_id) stmt = select(Invoice).where(Invoice.id == invoice_id)
return await session.scalar(stmt) return await session.scalar(stmt)

View File

@@ -1,26 +1,18 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession
from db.models.orders import Order, OrderItem, OrderStatus from db.models.orders import Order, OrderItem, OrderStatus
class OrderItemRepository: class OrderItemRepository:
async def get_item_by_id( async def get_item_by_id(self, session: AsyncSession, item_id: int) -> OrderItem | None:
self, session: AsyncSession, item_id: int
) -> Optional[OrderItem]:
stmt = select(OrderItem).where(OrderItem.id == item_id) stmt = select(OrderItem).where(OrderItem.id == item_id)
return await session.scalar(stmt) return await session.scalar(stmt)
async def get_items_by_order( async def get_items_by_order(
self, session: AsyncSession, *, order_id: int, limit: int, offset: int = 0 self, session: AsyncSession, *, order_id: int, limit: int, offset: int = 0
) -> list[OrderItem]: ) -> list[OrderItem]:
stmt = ( stmt = select(OrderItem).where(OrderItem.order_id == order_id).limit(limit).offset(offset)
select(OrderItem)
.where(OrderItem.order_id == order_id)
.limit(limit)
.offset(offset)
)
result = await session.scalars(stmt) result = await session.scalars(stmt)
return list(result) return list(result)
@@ -29,9 +21,7 @@ class OrderItemRepository:
stmt = select(func.count(OrderItem.id)).where(OrderItem.order_id == order_id) stmt = select(func.count(OrderItem.id)).where(OrderItem.order_id == order_id)
return await session.scalar(stmt) or 0 return await session.scalar(stmt) or 0
async def get_items_count_by_customer( async def get_items_count_by_customer(self, session: AsyncSession, customer: int) -> int:
self, session: AsyncSession, customer: int
) -> int:
count = await session.scalar( count = await session.scalar(
select(func.count(OrderItem.id)) select(func.count(OrderItem.id))
.join(Order) .join(Order)
@@ -44,7 +34,7 @@ class OrderItemRepository:
async def get_item_by_order_and_product( async def get_item_by_order_and_product(
self, session: AsyncSession, *, order_id: int, product_id: int self, session: AsyncSession, *, order_id: int, product_id: int
) -> Optional[OrderItem]: ) -> OrderItem | None:
stmt = ( stmt = (
select(OrderItem) select(OrderItem)
.where(OrderItem.order_id == order_id) .where(OrderItem.order_id == order_id)
@@ -68,9 +58,7 @@ class OrderItemRepository:
product_id: int, product_id: int,
quantity: int = 1, quantity: int = 1,
) -> OrderItem: ) -> OrderItem:
order_item = OrderItem( order_item = OrderItem(order_id=order_id, product_id=product_id, quantity=quantity)
order_id=order_id, product_id=product_id, quantity=quantity
)
session.add(order_item) session.add(order_item)
await session.commit() await session.commit()

View File

@@ -1,30 +1,22 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select from sqlalchemy import delete, select
from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from db.models import Order, OrderStatus, OrderItem from db.models import Order, OrderItem, OrderStatus
class OrderRepository: class OrderRepository:
async def get_order_by_id( async def get_order_by_id(self, session: AsyncSession, order_id: int) -> Order | None:
self, session: AsyncSession, order_id: int
) -> Optional[Order]:
stmt = select(Order).where(Order.id == order_id) stmt = select(Order).where(Order.id == order_id)
return await session.scalar(stmt) return await session.scalar(stmt)
async def get_orders_by_user( async def get_orders_by_user(self, session: AsyncSession, customer: int) -> list[Order]:
self, session: AsyncSession, customer: int
) -> list[Order]:
stmt = select(Order).where(Order.customer == customer) stmt = select(Order).where(Order.customer == customer)
result = await session.scalars(stmt) result = await session.scalars(stmt)
return list(result) return list(result)
async def get_draft_order_by_user( async def get_draft_order_by_user(self, session: AsyncSession, customer: int) -> Order | None:
self, session: AsyncSession, customer: int
) -> Optional[Order]:
stmt = ( stmt = (
select(Order) select(Order)
.where(Order.customer == customer) .where(Order.customer == customer)
@@ -59,9 +51,7 @@ class OrderRepository:
await session.execute(stmt) await session.execute(stmt)
await session.commit() await session.commit()
async def update_order_status( async def update_order_status(self, session: AsyncSession, order_id: int, status: OrderStatus):
self, session: AsyncSession, order_id: int, status: OrderStatus
):
order = await self.get_order_by_id(session, order_id) order = await self.get_order_by_id(session, order_id)
order.status = status order.status = status

View File

@@ -1,29 +1,64 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from typing import Optional
from config import PAGE_SIZE from config import PAGE_SIZE
from db.models import Product 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 self, session: AsyncSession, product_id: int, *, include_hidden: bool = True
) -> Optional[Product]: ) -> Product | None:
stmt = select(Product).where(Product.id == product_id) stmt = select(Product).where(Product.id == product_id)
if not include_hidden:
stmt = stmt.where(Product.is_hidden.is_(False))
return await session.scalar(stmt) return await session.scalar(stmt)
async def get_product_by_category( async def get_product_by_category(
self, session: AsyncSession, category_id: int, *, limit: int, offset: int self,
session: AsyncSession,
category_id: int,
*,
limit: int,
offset: int,
include_hidden: bool = False,
) -> list[Product]: ) -> list[Product]:
stmt = ( stmt = select(Product).where(Product.category_id == category_id)
select(Product)
.where(Product.category_id == category_id) if not include_hidden:
.order_by(Product.id) stmt = stmt.where(Product.is_hidden.is_(False))
.offset(offset)
.limit(limit) stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
)
result = await session.scalars(stmt) result = await session.scalars(stmt)
@@ -37,6 +72,51 @@ class ProductRepository:
await session.commit() await session.commit()
async def clear_product_photo(self, session: AsyncSession, product_id: int) -> Product | None:
product = await self.get_product_by_id(session, product_id)
if not product:
return None
product.file_id = None
product.img_path = None
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( async def search(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -44,13 +124,17 @@ class ProductRepository:
*, *,
limit: int = PAGE_SIZE + 1, limit: int = PAGE_SIZE + 1,
offset: int = 0, offset: int = 0,
) -> list[Optional[Product]]: include_hidden: bool = False,
) -> list[Product | None]:
ts_query = func.plainto_tsquery("simple", query) ts_query = func.plainto_tsquery("simple", query)
stmt = select(Product).where(Product.search_vector.op("@@")(ts_query))
if not include_hidden:
stmt = stmt.where(Product.is_hidden.is_(False))
stmt = ( stmt = (
select(Product) stmt.order_by(func.ts_rank(Product.search_vector, ts_query).desc())
.where(Product.search_vector.op("@@")(ts_query))
.order_by(func.ts_rank(Product.search_vector, ts_query).desc())
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
) )
@@ -59,9 +143,7 @@ class ProductRepository:
return list(res) return list(res)
async def update_product_name_by_id( async def update_product_name_by_id(self, session: AsyncSession, product_id: int, name: str):
self, session: AsyncSession, product_id: int, name: str
):
product = await self.get_product_by_id(session, product_id=product_id) product = await self.get_product_by_id(session, product_id=product_id)
product.name = name product.name = name
@@ -77,11 +159,21 @@ class ProductRepository:
await session.commit() await session.commit()
return product return product
async def update_product_price_by_id( async def update_product_price_by_id(self, session: AsyncSession, product_id: int, price: str):
self, session: AsyncSession, product_id: int, price: str
):
product = await self.get_product_by_id(session, product_id=product_id) product = await self.get_product_by_id(session, product_id=product_id)
product.price = price product.price = price
await session.commit() await session.commit()
return product return product
async def toggle_product_hidden_by_id(
self, session: AsyncSession, product_id: int
) -> Product | None:
product = await self.get_product_by_id(session, product_id=product_id)
if not product:
return None
product.is_hidden = not product.is_hidden
await session.commit()
return product

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

@@ -4,8 +4,7 @@ python-dotenv
black black
asyncpg asyncpg
sqlalchemy[asyncio] sqlalchemy[asyncio]
psycopg2
phonenumbers phonenumbers
ruff ruff
redis redis
aiohttp-socks aiohttp-socks

View File

@@ -1,11 +1,10 @@
from typing import Union, Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import PAGE_SIZE
from dto.catalogue import CatalogueType, CatalogueView from dto.catalogue import CatalogueType, CatalogueView
from misc.mapper import parse_cat_id, serialize_cat_id from misc.mapper import parse_cat_id, serialize_cat_id
from misc.texts import get_breadcrumps_path from misc.texts import get_breadcrumps_path
from repositories import ProductRepository, CategoriesRepository from repositories import CategoriesRepository, ProductRepository
from config import PAGE_SIZE
class CatalogueService: class CatalogueService:
@@ -16,14 +15,17 @@ class CatalogueService:
self.categories_repo = categories_repo self.categories_repo = categories_repo
async def build_category_view( async def build_category_view(
self, session: AsyncSession, cat_id: Optional[Union[int, str]], page: int = 0 self,
session: AsyncSession,
cat_id: int | str | None,
page: int = 0,
*,
include_hidden: bool = False,
) -> CatalogueView: ) -> CatalogueView:
cat_id = parse_cat_id(cat_id) cat_id = parse_cat_id(cat_id)
view = CatalogueView(page=page) view = CatalogueView(page=page)
children = await self.categories_repo.get_categories_by_parent_id( children = await self.categories_repo.get_categories_by_parent_id(session, cat_id)
session, cat_id
)
category = await self.categories_repo.get_category_by_id(session, cat_id) category = await self.categories_repo.get_category_by_id(session, cat_id)
if not category: # Root category if not category: # Root category
@@ -35,12 +37,19 @@ class CatalogueService:
if not children: # Products if not children: # Products
products = await self.product_repo.get_product_by_category( products = await self.product_repo.get_product_by_category(
session, cat_id, limit=PAGE_SIZE + 1, offset=view.page * PAGE_SIZE session,
cat_id,
limit=PAGE_SIZE + 1,
offset=view.page * PAGE_SIZE,
include_hidden=include_hidden,
) )
if products: if products:
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
@@ -51,7 +60,7 @@ class CatalogueService:
return view return view
async def get_path(self, session: AsyncSession, cat_id: Union[int, str]): async def get_path(self, session: AsyncSession, cat_id: int | str):
category = int(cat_id) if isinstance(cat_id, int) or cat_id.isdigit() else None category = int(cat_id) if isinstance(cat_id, int) or cat_id.isdigit() else None
products = await self.categories_repo.get_category_path(session, category) products = await self.categories_repo.get_category_path(session, category)

View File

@@ -1,5 +1,7 @@
import logging import logging
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import PAGE_SIZE from config import PAGE_SIZE
from db.models.orders import OrderStatus from db.models.orders import OrderStatus
from dto.cart import CartDTO, CartItemDTO from dto.cart import CartDTO, CartItemDTO
@@ -9,13 +11,11 @@ from repositories.orders import OrderRepository
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OrderServiceException(Exception): ... class OrderServiceError(Exception): ...
class OrderService: class OrderService:
def __init__( def __init__(self, order_repo: OrderRepository, order_items_repo: OrderItemRepository):
self, order_repo: OrderRepository, order_items_repo: OrderItemRepository
):
self.order_repo = order_repo self.order_repo = order_repo
self.order_items_repo = order_items_repo self.order_items_repo = order_items_repo
@@ -34,9 +34,7 @@ class OrderService:
order = await self.order_repo.get_draft_order_by_user(session, customer) order = await self.order_repo.get_draft_order_by_user(session, customer)
if not order: if not order:
order = await self.order_repo.create_order( order = await self.order_repo.create_order(session, customer, OrderStatus.DRAFT)
session, customer, OrderStatus.DRAFT
)
order_item = await self.order_items_repo.get_item_by_order_and_product( order_item = await self.order_items_repo.get_item_by_order_and_product(
session, order_id=order.id, product_id=product_id session, order_id=order.id, product_id=product_id
@@ -65,7 +63,7 @@ class OrderService:
order = await self.order_repo.get_draft_order_by_user(session, customer) order = await self.order_repo.get_draft_order_by_user(session, customer)
if not order: if not order:
raise OrderServiceException( raise OrderServiceError(
"attempt of removing product from non-existing cart, potential callback_query exploit, ignoring..." "attempt of removing product from non-existing cart, potential callback_query exploit, ignoring..."
) )
@@ -74,7 +72,7 @@ class OrderService:
) )
if not order_item: if not order_item:
raise OrderServiceException( raise OrderServiceError(
"attempt of decreasing quantity of non-existing order_item, potential callback_query exploit, ignoring..." "attempt of decreasing quantity of non-existing order_item, potential callback_query exploit, ignoring..."
) )
@@ -88,13 +86,11 @@ class OrderService:
return new_quantity return new_quantity
async def clear_from_cart( async def clear_from_cart(self, session: AsyncSession, *, customer: int, product_id: int):
self, session: AsyncSession, *, customer: int, product_id: int
):
order = await self.order_repo.get_draft_order_by_user(session, customer) order = await self.order_repo.get_draft_order_by_user(session, customer)
if not order: if not order:
raise OrderServiceException( raise OrderServiceError(
"attempt of removing product from non-existing cart, potential callback_query exploit, ignoring..." "attempt of removing product from non-existing cart, potential callback_query exploit, ignoring..."
) )
@@ -103,7 +99,7 @@ class OrderService:
) )
if not order_item: if not order_item:
raise OrderServiceException( raise OrderServiceError(
"attempt of decreasing quantity of non-existing order_item, potential callback_query exploit, ignoring..." "attempt of decreasing quantity of non-existing order_item, potential callback_query exploit, ignoring..."
) )
@@ -162,9 +158,7 @@ class OrderService:
return CartDTO(items=cart_items, total=total, order_id=order.id) return CartDTO(items=cart_items, total=total, order_id=order.id)
async def build_full_cart_dto( async def build_full_cart_dto(self, session: AsyncSession, customer: int) -> CartDTO:
self, session: AsyncSession, customer: int
) -> CartDTO:
order = await self.order_repo.get_draft_order_by_user(session, customer) order = await self.order_repo.get_draft_order_by_user(session, customer)
cart_items = [] cart_items = []

BIN
static/welcome.mp4 Normal file

Binary file not shown.