Compare commits

..

4 Commits

Author SHA1 Message Date
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
51 changed files with 636 additions and 357 deletions

1
.gitignore vendored
View File

@@ -136,6 +136,7 @@ venv/
ENV/
env.bak/
venv.bak/
*.env
# Spyder project settings
.spyderproject

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.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql+asyncpg://postgres:HEXDEVFUCKINGSUCKS!@localhost:5432/postgres
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
[post_write_hooks]

View File

@@ -1,4 +1,5 @@
import asyncio
import os
from logging.config import fileConfig
from sqlalchemy import pool
@@ -6,14 +7,17 @@ from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from db.base import Base
from db.models import * # noqa: F403
from db.models import *
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
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.
# This line sets up loggers basically.
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

@@ -1,6 +1,7 @@
from dataclasses import dataclass
import os
import logging
import os
from dataclasses import dataclass
from dotenv import load_dotenv
@@ -26,9 +27,10 @@ POSTGRES_URL = os.getenv(
card_info = CardInfo(os.getenv("CARD_NUMBER", ""), os.getenv("CARD_HOLDER", ""))
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/1")
PROXY_URL = os.getenv("PROXY_URL")
### Constants ###
PAGE_SIZE = 8
VERIFIED_ACCOUNTS = [1026030711, 8480400744]
NOTIFICATION_CHANNEL = -1003849564110
VERIFIED_ACCOUNTS = [8480400744, 6551145249]
NOTIFICATION_CHANNEL = -1003836761482

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 config import POSTGRES_URL

View File

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

View File

@@ -1,8 +1,9 @@
from datetime import datetime
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.orm import Mapped, mapped_column
from db.base import Base

View File

@@ -1,8 +1,9 @@
import enum
from typing import List, Optional
from sqlalchemy import ForeignKey, BigInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
from db.models.products import Product
@@ -19,10 +20,10 @@ class Order(Base):
__tablename__ = "orders"
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"))
items: Mapped[List["OrderItem"]] = relationship("OrderItem", back_populates="order")
items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="order")
class OrderItem(Base):

View File

@@ -1,7 +1,6 @@
from typing import Optional
from sqlalchemy import ForeignKey, Text, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Boolean, ForeignKey, Index, Text
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
@@ -11,7 +10,7 @@ class Category(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
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])
@@ -20,15 +19,14 @@ class Product(Base):
__tablename__ = "products"
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)
description: Mapped[Optional[str]] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text)
price: Mapped[int] = mapped_column()
img_path: Mapped[str] = mapped_column(Text, nullable=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)
__table_args__ = (
Index("idx_products_search", "search_vector", postgresql_using="gin"),
)
__table_args__ = (Index("idx_products_search", "search_vector", postgresql_using="gin"),)

59
docker-compose.yml Normal file
View File

@@ -0,0 +1,59 @@
services:
postgres:
env_file: prod.env
image: postgres:16-alpine
container_name: shveitech_db
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
ports:
- "5432:5432"
redis:
image: redis:7-alpine
container_name: shveitech_redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "6379:6379"
bot:
env_file: prod.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
volumes:
postgres_data:

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
from dataclasses import dataclass
from typing import Optional
from aiogram.types import Message
@@ -8,6 +7,6 @@ from aiogram.types import Message
class CheckoutContext:
orig_msg: Message
order_id: int
name: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
name: str | None = None
phone: str | None = 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 .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 .menu import router as menu_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]

View File

@@ -1,14 +1,14 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from dto.catalogue import CategoryActionContext
from misc.filters import IsVerified
from misc.kb.client import created_category, get_back_to_catalogue
from misc.kb.common import return_menu
from misc.mapper import parse_cat_id
from misc.states import AdminControlStorage
from misc.kb.common import return_menu
from repositories.categories import CategoriesRepository
from services.catalogue import CatalogueService
@@ -67,6 +67,6 @@ async def create_new_category(
await ctx.msg.edit_text(
f"✅ Категория {msg.text} создана успешно.",
reply_markup=created_category(
category.id, f"🛒 {category.name}", category.parent_id
category.id, f"🛒 {category.name}", category.parent_id or "root"
),
)

View File

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

View File

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

View File

@@ -1,16 +1,19 @@
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.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from dto.catalogue import CategoryActionContext
from dto.control import EditProductContext
from misc.filters import IsVerified
from misc.kb import admins
from misc.kb.admins import back_to_product_kb
from misc.kb.client import get_back_to_catalogue
from misc.mapper import parse_cat_id
from misc.states import AdminControlStorage
from misc.texts import get_product_description
from misc.texts import product_editing_mapping
from repositories.categories import CategoriesRepository
from repositories.products import ProductRepository
@@ -19,7 +22,7 @@ logger = logging.getLogger(__name__)
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):
await state.clear()
@@ -31,18 +34,14 @@ async def edit_product(cb: CallbackQuery, state: FSMContext):
await cb.message.delete()
msg = await cb.message.answer(
f"<b>✍️ Введите {product_editing_mapping.get(mode)}</b>",
reply_markup=back_to_product_kb(
product_id, cb_factory=lambda p: f"product:{p}"
),
reply_markup=back_to_product_kb(product_id, cb_factory=lambda p: f"product:{p}"),
)
else:
msg = cb.message
await cb.message.edit_text(
f"<b>✍️ Введите {product_editing_mapping.get(mode)}</b>",
reply_markup=back_to_product_kb(
product_id, cb_factory=lambda p: f"product:{p}"
),
reply_markup=back_to_product_kb(product_id, cb_factory=lambda p: f"product:{p}"),
)
ctx = EditProductContext(int(product_id), mode, msg)
@@ -68,13 +67,9 @@ async def edit_product_query(
try:
await ctx.msg.edit_text("⏳")
if ctx.mode == "name":
await products_repo.update_product_name_by_id(
session, ctx.product_id, msg.text
)
await products_repo.update_product_name_by_id(session, ctx.product_id, msg.text)
elif ctx.mode == "description":
await products_repo.update_product_description_by_id(
session, ctx.product_id, msg.text
)
await products_repo.update_product_description_by_id(session, ctx.product_id, msg.text)
elif ctx.mode == "price":
if msg.text.isdigit():
await products_repo.update_product_price_by_id(
@@ -99,6 +94,122 @@ async def edit_product_query(
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(F.data.startswith("edit:"))
async def edit_entry(cb: CallbackQuery, state: FSMContext):
await state.clear()
@@ -143,6 +254,4 @@ async def edit_category(
session=session, category_id=parse_cat_id(ctx.parent_id), value=msg.text
)
await ctx.msg.edit_text(
"✅ Успешно.", reply_markup=get_back_to_catalogue(cat.parent_id)
)
await ctx.msg.edit_text("✅ Успешно.", 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 .menu import router as menu_router
from .catalogue import router as catalogue_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 .security import router as security_router
routers = [
invoice_router,

View File

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

View File

@@ -1,6 +1,6 @@
from aiogram import Router, F
from aiogram.types import CallbackQuery
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from config import VERIFIED_ACCOUNTS
@@ -24,7 +24,7 @@ async def subcatalogue(
cat_id = cb.data.split(":")[1]
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)
path = await catalogue_service.get_path(session, cat_id)

View File

@@ -1,8 +1,8 @@
import logging
from typing import Optional
from aiogram import Router, F
from aiogram.types import CallbackQuery, Message
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from config import NOTIFICATION_CHANNEL
@@ -98,7 +98,7 @@ async def checkout_phone(msg: Message, state: FSMContext):
ctx.phone = msg.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,
)
await state.set_state(CheckoutStorage.address)
@@ -119,7 +119,7 @@ async def checkout_address(msg: Message, state: FSMContext):
"━━━━━━━━━━━━━━\n\n"
f"<i>👤 Имя: {ctx.name}</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),
)
@@ -138,7 +138,7 @@ async def payment_create(
order_repo: OrderRepository,
):
data = await state.get_data()
ctx: Optional[CheckoutContext] = data.get("ctx")
ctx: CheckoutContext | None = data.get("ctx")
if not ctx:
await cb.message.edit_text(
"🍃 Что-то пошло не так, повторите попытку позже...",
@@ -157,16 +157,14 @@ async def payment_create(
"━━━━━━━━━━━━━━\n\n"
f"<i>👤 Имя: {ctx.name}</i>\n"
f"<i>📱 Номер телефона: {ctx.phone}</i>\n"
f"<i>📍 Адрес: {ctx.address}</i>\n\n"
f"<i>📍 Адрес ближайшего ПВЗ Ozon: {ctx.address}</i>\n\n"
"━━━━━━━━━━━━━━\n"
f"{get_order_item_list(cart)}",
reply_markup=customer_contacts(
cb.from_user.id, bool(cb.from_user.username), cb.from_user.username
),
)
await order_repo.update_order_status(
session, cart.order_id, OrderStatus.CREATED
)
await order_repo.update_order_status(session, cart.order_id, OrderStatus.CREATED)
await cb.message.edit_text(
"<b>🟢 Ваш заказ успешно отправлен!</b>\n"

View File

@@ -1,6 +1,6 @@
import logging
from aiogram import Router, F
from aiogram import F, Router
from aiogram.filters import CommandObject, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import (
@@ -9,12 +9,12 @@ from aiogram.types import (
)
from sqlalchemy.ext.asyncio import AsyncSession
from config import card_info
from db.models.invoices import InvoiceStatus
from misc.kb.admins import verify_payment
from misc.kb.client import confirm_payment
from misc.kb.common import return_menu
from repositories.invoices import InvoiceRepository
from config import card_info
router = Router()
logger = logging.getLogger(__name__)
@@ -33,9 +33,7 @@ async def activate_invoice(
invoice = await invoice_repo.get_invoice_by_id(session, invoice_id)
if not invoice:
await msg.answer(
"⏳ Счёт истёк. Повторите попытку позже.", reply_markup=return_menu
)
await msg.answer("⏳ Счёт истёк. Повторите попытку позже.", reply_markup=return_menu)
return
if invoice.status != InvoiceStatus.PENDING:
@@ -43,9 +41,7 @@ async def activate_invoice(
return
if not (card_info.number and card_info.holder):
await msg.answer(
"🍃 Оплата недоступна, повторите попытку позже.", reply_markup=return_menu
)
await msg.answer("🍃 Оплата недоступна, повторите попытку позже.", reply_markup=return_menu)
return
await msg.answer(

View File

@@ -1,10 +1,12 @@
from aiogram import Router, F
from aiogram.types import CallbackQuery, Message
import contextlib
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from misc.kb import main_menu_kb
from misc.kb.client import main_menu_kb
from repositories.order_items import OrderItemRepository
from services.orders import OrderService
@@ -20,12 +22,11 @@ async def user_main_menu(
):
await state.clear()
cart_items = await order_items_repo.get_items_count_by_customer(
session, msg.from_user.id
)
cart_items = await order_items_repo.get_items_count_by_customer(session, msg.from_user.id)
await msg.answer(
"hii!", reply_markup=main_menu_kb(cart_items)
) # TODO: Write a welcome message
"<b>Добрый день!</b>\n" 'Нажмите "Каталог" чтобы выбрать товар или воспользуйтесь поиском.',
reply_markup=main_menu_kb(cart_items),
)
@router.callback_query(F.data.startswith("menu:"))
@@ -37,14 +38,11 @@ async def user_main_menu_cb(
):
await state.clear()
try:
with contextlib.suppress(Exception):
await cb.message.reply_to_message.delete()
except Exception:
...
cart_items = await order_items_repo.get_items_count_by_customer(
session, cb.from_user.id
)
cart_items = await order_items_repo.get_items_count_by_customer(session, cb.from_user.id)
await cb.message.edit_text(
"hii!", reply_markup=main_menu_kb(cart_items)
) # TODO: Write a welcome message
"<b>Добрый день!</b>\n" 'Нажмите "Каталог" чтобы выбрать товар или воспользуйтесь поиском.',
reply_markup=main_menu_kb(cart_items),
)

View File

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

View File

@@ -1,12 +1,11 @@
from typing import Optional
import hashlib
from aiogram import Router, F
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
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.common import return_menu
from misc.redis import RedisClient
@@ -20,7 +19,7 @@ router = Router()
async def search_trigger(cb: CallbackQuery, state: FSMContext):
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_data({"msg": cb.message})
@@ -37,11 +36,13 @@ async def searching(
await state.clear()
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("⏳ Поиск...")
is_admin = msg.from_user.id in VERIFIED_ACCOUNTS
results = await products_repo.search(
session,
msg.text,
include_hidden=is_admin,
)
next_cb = None
@@ -51,12 +52,13 @@ async def searching(
next_cb = f"search:{query_hash}:1"
await orig_msg.edit_text(
"here",
"<b>🗒 Результаты поиска:</b>",
reply_markup=render_products(
results[:PAGE_SIZE],
product_cb_factory=lambda p: f"product:{p.id}",
back_cb="menu:main",
next_cb=next_cb,
is_admin=is_admin,
),
)
@@ -79,7 +81,13 @@ async def search_pagination(
if not query:
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,
)
await cb.message.edit_reply_markup(
reply_markup=render_products(
@@ -88,5 +96,6 @@ async def search_pagination(
back_cb="menu:main",
next_cb=f"search:{q_hash}:{page + 1}" if len(results) > PAGE_SIZE else None,
prev_cb=f"search:{q_hash}:{page - 1}" if page > 0 else None,
is_admin=is_admin,
)
)

View File

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

12
main.py
View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
from aiogram import BaseMiddleware
from repositories import OrderRepository, CategoriesRepository
from repositories import CategoriesRepository, OrderRepository
from repositories.invoices import InvoiceRepository
from repositories.order_items import OrderItemRepository
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.utils.keyboard import InlineKeyboardBuilder
from db.models import Product
main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="📦 Изменить каталог", callback_data="cat:root")],
@@ -10,22 +13,18 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
).as_markup()
def back_to_product_kb(
product_id: Union[int, str], *, cb_factory: Callable[[int | str], str]
):
def back_to_product_kb(product_id: int | str, *, cb_factory: Callable[[int | str], str]):
return InlineKeyboardBuilder(
[[InlineKeyboardButton(text="⬅️", callback_data=cb_factory(product_id))]]
).as_markup()
def customer_contacts(
user_id: int, has_mention: bool, mention: Optional[str] = None
user_id: int, has_mention: bool, mention: str | None = None
) -> InlineKeyboardMarkup:
btns: list[list[InlineKeyboardButton]] = []
(
btns.append(
[InlineKeyboardButton(text="👤 Перейти", url=f"https://t.me/{mention}")]
)
btns.append([InlineKeyboardButton(text="👤 Перейти", url=f"https://t.me/{mention}")])
if has_mention
else None
)
@@ -40,9 +39,7 @@ def customer_contacts(
def payment_link(url: str) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder(
[[InlineKeyboardButton(text="💸 Оплатить", url=url)]]
).as_markup()
return InlineKeyboardBuilder([[InlineKeyboardButton(text="💸 Оплатить", url=url)]]).as_markup()
def verify_payment(invoice_id: int):
@@ -57,24 +54,28 @@ def verify_payment(invoice_id: int):
).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}"
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(
[
[
InlineKeyboardButton(
text="✍️ Название", callback_data=f"{prefix}:name"
),
InlineKeyboardButton(text="✍️ Название", callback_data=f"{prefix}:name"),
InlineKeyboardButton(
text="✍️ Описание",
callback_data=f"{prefix}:description",
),
],
[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"cat:{category_id}")],
[InlineKeyboardButton(text="⬅️", callback_data=f"cat:{product.category_id}")],
]
).as_markup()

View File

@@ -1,4 +1,4 @@
from typing import Optional, Callable, Union
from collections.abc import Callable
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
@@ -6,15 +6,15 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import PAGE_SIZE
from db.models import Category, Product
from dto.cart import CartItemDTO
from dto.catalogue import CatalogueView, CatalogueType
from dto.catalogue import CatalogueType, CatalogueView
from misc.mapper import serialize_cat_id
def pagination_row(
*,
page: int,
prev_cb: Optional[str] = None,
next_cb: Optional[str] = None,
prev_cb: str | None = None,
next_cb: str | None = None,
) -> list[InlineKeyboardButton]:
row = []
@@ -39,22 +39,18 @@ def cart_btn(cart_items: int) -> InlineKeyboardButton:
def get_back_to_catalogue(
parent_id: int,
*,
text: Optional[str] = "⬅️ Назад",
text: str | None = "⬅️ Назад",
show_controls: bool = False,
cat_id: Optional[Union[str, int]] = None,
fallback_cb: Optional[str] = "menu:main",
cat_id: str | int | None = None,
fallback_cb: str | None = "menu:main",
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if show_controls and cat_id:
builder.row(
InlineKeyboardButton(
text="➕ Категория", callback_data=f"new:category:{cat_id}"
),
InlineKeyboardButton(
text="➕ Товар", callback_data=f"new:product:{cat_id}"
),
InlineKeyboardButton(text="➕ Категория", callback_data=f"new:category:{cat_id}"),
InlineKeyboardButton(text="➕ Товар", callback_data=f"new:product:{cat_id}"),
)
builder.row(
@@ -82,7 +78,7 @@ def main_menu_kb(cart_items: int) -> InlineKeyboardMarkup:
[
[
InlineKeyboardButton(text="📦 Каталог", callback_data="cat:root"),
InlineKeyboardButton(text="🔍", callback_data="find"),
InlineKeyboardButton(text="🔍 Поиск", callback_data="find"),
],
[cart_btn(cart_items)],
]
@@ -91,10 +87,10 @@ def main_menu_kb(cart_items: int) -> InlineKeyboardMarkup:
def render_category(
children: list[Category],
parent_id: Optional[Union[str, int]] = None,
parent_id: str | int | None = None,
show_menu: bool = False,
*,
cat_id: Optional[Union[str, int]] = None,
cat_id: str | int | None = None,
category_cb_factory: Callable[[Category], str],
is_admin: bool = False,
) -> InlineKeyboardMarkup:
@@ -116,9 +112,7 @@ def render_category(
text="➕ Категория",
callback_data=f"new:category:{cat_id}",
),
InlineKeyboardButton(
text="✍️", callback_data=f"edit:category:{cat_id}"
),
InlineKeyboardButton(text="✍️", callback_data=f"edit:category:{cat_id}"),
]
)
@@ -126,7 +120,10 @@ def render_category(
nav.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")])
elif parent_id is not None:
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(
@@ -138,22 +135,25 @@ def render_products(
products: list[Product],
*,
product_cb_factory: Callable[[Product], str],
back_cb: Optional[str] = None,
prev_cb: Optional[str] = None,
next_cb: Optional[str] = None,
back_cb: str | None = None,
prev_cb: str | None = None,
next_cb: str | None = None,
columns: int = 2,
is_admin: bool = False,
cat_id: Optional[int] = None,
cat_id: int | None = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
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):
row = [
InlineKeyboardButton(
text=p.name,
text=f"{p.name} {'🙈' if is_admin and p.is_hidden else ''}".strip(),
callback_data=product_cb_factory(p),
)
for p in products[i : i + columns]
@@ -161,9 +161,7 @@ def render_products(
builder.row(*row)
if is_admin:
builder.row(
InlineKeyboardButton(text="➕ Товар", callback_data=f"new:product:{cat_id}")
)
builder.row(InlineKeyboardButton(text="➕ Товар", callback_data=f"new:product:{cat_id}"))
nav_buttons = []
if prev_cb:
@@ -193,12 +191,8 @@ def render_catalogue(view: CatalogueView, *, is_admin: bool):
products=view.products[:PAGE_SIZE], # type: ignore
product_cb_factory=lambda p: f"product:{p.id}",
back_cb=f"cat:{view.parent_id}",
prev_cb=(
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
),
prev_cb=(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),
is_admin=is_admin,
cat_id=view.category,
)
@@ -216,13 +210,9 @@ def render_product_interactions(
if show_cart:
btns.append(
[
InlineKeyboardButton(
text="➖", callback_data=f"cart_action:remove:{product_id}"
),
InlineKeyboardButton(text="➖", callback_data=f"cart_action:remove:{product_id}"),
InlineKeyboardButton(text=f"🛒 {cart_amount}", callback_data="..."),
InlineKeyboardButton(
text="➕", callback_data=f"cart_action:add:{product_id}"
),
InlineKeyboardButton(text="➕", callback_data=f"cart_action:add:{product_id}"),
]
)
btns.append(
@@ -243,7 +233,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()
@@ -252,9 +247,9 @@ def render_cart(
cart_items: list[CartItemDTO],
*,
page: int = 0,
show_prev: Optional[bool] = False,
show_next: Optional[bool] = False,
show_purchase: Optional[bool] = False,
show_prev: bool | None = False,
show_next: bool | None = False,
show_purchase: bool | None = False,
) -> InlineKeyboardMarkup:
btns = [
InlineKeyboardButton(
@@ -284,9 +279,7 @@ def render_cart(
)
if show_purchase:
markup.append(
[InlineKeyboardButton(text="🔷 Оформить заказ", callback_data="checkout")]
)
markup.append([InlineKeyboardButton(text="🔷 Оформить заказ", callback_data="checkout")])
return InlineKeyboardBuilder(markup).as_markup()

View File

@@ -1,5 +1,4 @@
import logging
from typing import Optional
from redis.asyncio import Redis
@@ -18,7 +17,7 @@ class RedisClient:
logger.exception(e)
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:
res = await self.r.get(f"search:{query_hash}")
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):
@@ -18,5 +18,6 @@ class SearchStorage(StatesGroup):
class AdminControlStorage(StatesGroup):
edit_product = State()
edit_product_photo = State()
edit_category = State()
new_category = State()

View File

@@ -1,6 +1,7 @@
import base64
import json
from typing import Any
import phonenumbers
from phonenumbers import NumberParseException

View File

@@ -1,11 +1,56 @@
[tool.black]
line-length = 88
target-version = ["py314"]
line-length = 100
target-version = ['py313']
include = '\.pyi?$'
extend-exclude = '''
/(
\.git
| venv
| build
| dist
| alembic
)/
'''
skip-string-normalization = false
skip-magic-trailing-comma = false
[tool.ruff]
line-length = 100
target-version = "py313"
preview = false
exclude = [
".git",
"venv",
"build",
"dist",
"alembic",
]
[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 .products import ProductRepository
from .invoices import InvoiceRepository
from .orders import OrderRepository
from .products import ProductRepository
__all__ = [
"OrderRepository",
"CategoriesRepository",
"ProductRepository",
"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.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from db.models import Category
class CategoriesRepository:
async def get_category_by_id(
self, session: AsyncSession, category_id: int
) -> Optional[Category]:
async def get_category_by_id(self, session: AsyncSession, category_id: int) -> Category | None:
stmt = select(Category).where(Category.id == category_id)
return await session.scalar(stmt)
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]:
stmt = select(Category).where(Category.parent_id == parent_id)
result = await session.scalars(stmt)
@@ -31,9 +28,7 @@ class CategoriesRepository:
parent = aliased(Category)
cte = cte.union_all(
select(parent.id, parent.parent_id, parent.name).join(
cte, cte.c.parent_id == parent.id
)
select(parent.id, parent.parent_id, parent.name).join(cte, cte.c.parent_id == parent.id)
)
stmt = select(cte)
@@ -45,7 +40,7 @@ class CategoriesRepository:
return list(reversed(rows))
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(name=name, parent_id=parent_id)
@@ -54,9 +49,7 @@ class CategoriesRepository:
return category
async def update_category_name(
self, session: AsyncSession, category_id: int, value: str
):
async def update_category_name(self, session: AsyncSession, category_id: int, value: str):
category = await self.get_category_by_id(session, category_id)
category.name = value

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
from typing import Union, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config import PAGE_SIZE
from dto.catalogue import CatalogueType, CatalogueView
from misc.mapper import parse_cat_id, serialize_cat_id
from misc.texts import get_breadcrumps_path
from repositories import ProductRepository, CategoriesRepository
from config import PAGE_SIZE
from repositories import CategoriesRepository, ProductRepository
class CatalogueService:
@@ -16,14 +15,17 @@ class CatalogueService:
self.categories_repo = categories_repo
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:
cat_id = parse_cat_id(cat_id)
view = CatalogueView(page=page)
children = await self.categories_repo.get_categories_by_parent_id(
session, cat_id
)
children = await self.categories_repo.get_categories_by_parent_id(session, cat_id)
category = await self.categories_repo.get_category_by_id(session, cat_id)
if not category: # Root category
@@ -35,7 +37,11 @@ class CatalogueService:
if not children: # Products
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:
view.view_type = CatalogueType.PRODUCTS
@@ -51,7 +57,7 @@ class CatalogueService:
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
products = await self.categories_repo.get_category_path(session, category)

View File

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