Compare commits
19 Commits
a011b2724b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09046ee882 | |||
| 69187bc6ec | |||
| 7031ed4d46 | |||
| 04be991865 | |||
| 981db23b6f | |||
| a6f06f3493 | |||
| 608447816e | |||
| d2fb817a44 | |||
| b02511e2b9 | |||
| 8ad3ecd804 | |||
| b30aa59e00 | |||
| fea3ceea87 | |||
| 48600d7802 | |||
| 3ef80048a9 | |||
| 406356c240 | |||
| 1b78f2ba36 | |||
| 104cf71969 | |||
| 8444386885 | |||
| ca276cdd61 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -136,6 +136,7 @@ venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
*.env
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
@@ -175,4 +176,6 @@ cython_debug/
|
||||
.pypirc
|
||||
|
||||
# Static / IMG
|
||||
static/img
|
||||
static/img
|
||||
static/*
|
||||
.env.prod
|
||||
42
Dockerfile
Normal file
42
Dockerfile
Normal 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"]
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
32
alembic/versions/6621b149364e_products_is_hidden.py
Normal file
32
alembic/versions/6621b149364e_products_is_hidden.py
Normal 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 ###
|
||||
36
alembic/versions/8011a3651239_users.py
Normal file
36
alembic/versions/8011a3651239_users.py
Normal 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
12
compose.yml.local
Normal file
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
networks:
|
||||
internal:
|
||||
internal: false
|
||||
21
config.py
21
config.py
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
@@ -13,7 +14,6 @@ class CardInfo:
|
||||
load_dotenv(override=True)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
### Environment Variables ###
|
||||
|
||||
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", ""))
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/1")
|
||||
PROXY_URL = os.getenv("PROXY_URL")
|
||||
|
||||
WELCOME_VIDEO_NOTE_FP = os.getenv("WELCOME_VIDEO_NOTE_FP", "static/welcome.mp4")
|
||||
CHANNEL_URL = os.getenv("CHANNEL_URL", "https://t.me/shveitech")
|
||||
CHANNEL_ID = os.getenv("CHANNEL_ID")
|
||||
|
||||
if not CHANNEL_ID:
|
||||
raise Exception("Specify Channel ID in .env")
|
||||
|
||||
### Constants ###
|
||||
|
||||
PAGE_SIZE = 8
|
||||
VERIFIED_ACCOUNTS = [1026030711, 8480400744]
|
||||
NOTIFICATION_CHANNEL = -1003849564110
|
||||
VERIFIED_ACCOUNTS: list[int] = [int(el) for el in os.getenv("ADMINS", "").split(",")]
|
||||
NOTIFICATION_CHANNEL = os.getenv("NOTIFICATION_CHANNEL")
|
||||
|
||||
if not NOTIFICATION_CHANNEL:
|
||||
raise Exception("Specify Notification Channel in .env")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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
|
||||
from .users import User
|
||||
|
||||
__all__ = [
|
||||
"Category",
|
||||
"Invoice",
|
||||
"InvoiceStatus",
|
||||
"Order",
|
||||
"OrderItem",
|
||||
"OrderStatus",
|
||||
"Product",
|
||||
"Category",
|
||||
"Invoice",
|
||||
"InvoiceStatus",
|
||||
"User",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),)
|
||||
|
||||
10
db/models/users.py
Normal file
10
db/models/users.py
Normal 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
66
docker-compose.yml
Normal 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:
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,13 @@ 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
|
||||
total: int = 0
|
||||
parent_id: int | str | None = None
|
||||
has_next: bool = False
|
||||
show_menu: bool = False
|
||||
|
||||
@@ -29,7 +29,7 @@ class CatalogueView:
|
||||
@dataclass
|
||||
class NewCatalogueElement:
|
||||
msg: Message
|
||||
parent_id: Union[int, str]
|
||||
parent_id: int | str
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dto.catalogue import CategoryActionContext
|
||||
from dto.catalogue import CategoryActionContext, ProductActionContext
|
||||
from misc.filters import IsVerified
|
||||
from misc.kb.client import created_category, get_back_to_catalogue
|
||||
from misc.kb.common import return_menu
|
||||
from misc.mapper import parse_cat_id
|
||||
from misc.states import AdminControlStorage
|
||||
from misc.kb.common import return_menu
|
||||
from repositories.categories import CategoriesRepository
|
||||
from repositories.products import ProductRepository
|
||||
from services.catalogue import CatalogueService
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(IsVerified(), F.data.startswith("new:"))
|
||||
def skip_product_description_kb(parent_id: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Пропустить", callback_data="new_product:skip_description")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def skip_product_photo_kb(parent_id: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Пропустить", callback_data="new_product:skip_photo")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(IsVerified(), F.data.regexp(r"^new:(category|product):.+$"))
|
||||
async def creation_init(
|
||||
cb: CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -41,7 +60,12 @@ async def creation_init(
|
||||
await state.set_data({"ctx": ctx})
|
||||
|
||||
if mode == "product":
|
||||
...
|
||||
await cb.message.edit_text(
|
||||
f"{path} <i>-> ...</i>\n\n<b>📦 Введите название товара.</b>",
|
||||
reply_markup=get_back_to_catalogue(cat_id),
|
||||
)
|
||||
await state.set_state(AdminControlStorage.new_product_name)
|
||||
await state.set_data({"ctx": ProductActionContext(cb.message, cat_id)})
|
||||
|
||||
|
||||
@router.message(AdminControlStorage.new_category)
|
||||
@@ -67,6 +91,134 @@ 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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
@@ -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="Нажмите, чтобы создать счёт.",
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,17 +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 product_editing_mapping
|
||||
from misc.texts import get_product_description, product_editing_mapping
|
||||
from repositories.categories import CategoriesRepository
|
||||
from repositories.products import ProductRepository
|
||||
|
||||
@@ -19,7 +21,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 +33,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 +66,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 +93,158 @@ 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(IsVerified(), F.data.regexp(r"^edit_product:\d+:delete$"))
|
||||
async def request_product_deletion(cb: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
product_id = int(cb.data.split(":")[1])
|
||||
await cb.message.edit_reply_markup(reply_markup=admins.confirm_product_deletion(product_id))
|
||||
await cb.answer("Подтвердите удаление товара.")
|
||||
|
||||
|
||||
@router.callback_query(IsVerified(), F.data.regexp(r"^delete_product:confirm:\d+$"))
|
||||
async def delete_product(
|
||||
cb: CallbackQuery,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
products_repo: ProductRepository,
|
||||
):
|
||||
await state.clear()
|
||||
product_id = int(cb.data.split(":")[2])
|
||||
product = await products_repo.get_product_by_id(session, product_id)
|
||||
if not product:
|
||||
await cb.answer("Товар уже удалён.", show_alert=True)
|
||||
return
|
||||
|
||||
category_id = product.category_id
|
||||
try:
|
||||
await products_repo.delete_product(session, product_id)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Failed to delete product %s", product_id)
|
||||
await cb.answer("Не удалось удалить товар: он используется в заказах.", show_alert=True)
|
||||
return
|
||||
|
||||
await cb.message.delete()
|
||||
await cb.message.answer("✅ Товар удалён.", reply_markup=get_back_to_catalogue(category_id))
|
||||
await cb.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit:"))
|
||||
async def edit_entry(cb: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
@@ -143,6 +289,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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
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
|
||||
from config import CHANNEL_ID, NOTIFICATION_CHANNEL
|
||||
from db.models.orders import OrderStatus
|
||||
from dto.checkout import CheckoutContext
|
||||
from misc.kb.admins import customer_contacts
|
||||
from misc.kb.client import order_confirmation, order_specs_confirmation
|
||||
from misc.kb.client import order_confirmation, order_specs_confirmation, subscription
|
||||
from misc.kb.common import return_menu
|
||||
from misc.states import CheckoutStorage
|
||||
from misc.texts import get_order_item_list
|
||||
from misc.utils import is_valid_phone
|
||||
from misc.utils import is_member, is_valid_phone
|
||||
from repositories.orders import OrderRepository
|
||||
from services.orders import OrderService
|
||||
|
||||
@@ -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"
|
||||
@@ -182,3 +180,30 @@ async def payment_create(
|
||||
)
|
||||
logger.exception(e)
|
||||
return
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("precheck_order:"))
|
||||
async def precheck_order(
|
||||
cb: CallbackQuery, state: FSMContext, session: AsyncSession, order_repo: OrderRepository
|
||||
):
|
||||
await state.clear()
|
||||
order_id = cb.data.split(":")[1]
|
||||
if not order_id.isdigit():
|
||||
await cb.message.edit_text("🍃 Что-то пошло не так.", reply_markup=return_menu)
|
||||
return
|
||||
|
||||
member = await cb.bot.get_chat_member(CHANNEL_ID, cb.from_user.id)
|
||||
|
||||
if not is_member(member):
|
||||
await cb.message.edit_text(
|
||||
"<b>📚 Мы заметили, что вы не подписаны на канал.</b>",
|
||||
reply_markup=subscription(order_id),
|
||||
)
|
||||
return
|
||||
|
||||
await order_confirm(
|
||||
cb=cb,
|
||||
state=state,
|
||||
session=session,
|
||||
order_repo=order_repo,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, FSInputFile, Message
|
||||
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.users import create_user, get_user
|
||||
from services.orders import OrderService
|
||||
|
||||
router = Router()
|
||||
@@ -20,12 +25,17 @@ async def user_main_menu(
|
||||
):
|
||||
await state.clear()
|
||||
|
||||
cart_items = await order_items_repo.get_items_count_by_customer(
|
||||
session, msg.from_user.id
|
||||
)
|
||||
if not await get_user(session, msg.from_user.id):
|
||||
if os.path.isfile(WELCOME_VIDEO_NOTE_FP):
|
||||
await msg.bot.send_chat_action(msg.from_user.id, "record_video")
|
||||
await msg.answer_video_note(FSInputFile(WELCOME_VIDEO_NOTE_FP))
|
||||
await create_user(session, msg.from_user.id)
|
||||
|
||||
cart_items = await order_items_repo.get_items_count_by_customer(session, msg.from_user.id)
|
||||
await msg.answer(
|
||||
"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 +47,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),
|
||||
)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
@@ -50,13 +51,19 @@ async def searching(
|
||||
await redis_client.set_search_query(query_hash, msg.text)
|
||||
next_cb = f"search:{query_hash}:1"
|
||||
|
||||
total = await products_repo.count_search(session, msg.text, include_hidden=is_admin)
|
||||
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total else None
|
||||
|
||||
await orig_msg.edit_text(
|
||||
"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,
|
||||
page=0,
|
||||
total_pages=total_pages,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -79,7 +86,16 @@ 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,
|
||||
)
|
||||
|
||||
total = await products_repo.count_search(session, query, include_hidden=is_admin)
|
||||
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total else None
|
||||
|
||||
await cb.message.edit_reply_markup(
|
||||
reply_markup=render_products(
|
||||
@@ -88,5 +104,8 @@ 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,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
12
main.py
@@ -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"),
|
||||
|
||||
@@ -2,4 +2,4 @@ from .di import DIMiddleware
|
||||
from .repository import RepositoryMiddleware
|
||||
from .session import DBSessionMiddleware
|
||||
|
||||
__all__ = ["DIMiddleware", "RepositoryMiddleware", "DBSessionMiddleware"]
|
||||
__all__ = ["DBSessionMiddleware", "DIMiddleware", "RepositoryMiddleware"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from misc.kb.admins import *
|
||||
from misc.kb.client import *
|
||||
|
||||
__all__ = ["admins", "client"]
|
||||
|
||||
@@ -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,41 @@ 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()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
from typing import Optional, Callable, Union
|
||||
from collections.abc import Callable
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
import config
|
||||
from config import PAGE_SIZE
|
||||
from db.models import Category, Product
|
||||
from dto.cart import CartItemDTO
|
||||
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 +40,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,19 +79,20 @@ 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)],
|
||||
[InlineKeyboardButton(text="🌸 Подписаться на канал", url=config.CHANNEL_URL)],
|
||||
]
|
||||
).as_markup()
|
||||
|
||||
|
||||
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 +114,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 +122,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 +137,27 @@ 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,
|
||||
page: int = 0,
|
||||
total_pages: 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,14 +165,13 @@ 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:
|
||||
nav_buttons.append(InlineKeyboardButton(text="◀️", callback_data=prev_cb))
|
||||
nav_buttons.append(InlineKeyboardButton(text="🔍", callback_data="find"))
|
||||
page_text = f"Стр. {page + 1}/{total_pages}" if total_pages is not None else f"Стр. {page + 1}"
|
||||
nav_buttons.append(InlineKeyboardButton(text=page_text, callback_data="..."))
|
||||
if next_cb:
|
||||
nav_buttons.append(InlineKeyboardButton(text="▶️", callback_data=next_cb))
|
||||
|
||||
@@ -189,18 +192,17 @@ def render_catalogue(view: CatalogueView, *, is_admin: bool):
|
||||
cat_id=view.category,
|
||||
)
|
||||
if view.view_type == CatalogueType.PRODUCTS:
|
||||
total_pages = (view.total + PAGE_SIZE - 1) // PAGE_SIZE if view.total else None
|
||||
return render_products(
|
||||
products=view.products[:PAGE_SIZE], # type: ignore
|
||||
product_cb_factory=lambda p: f"product:{p.id}",
|
||||
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,
|
||||
page=view.page,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
return get_back_to_catalogue(
|
||||
@@ -216,13 +218,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 +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()
|
||||
|
||||
@@ -252,9 +255,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 +287,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()
|
||||
|
||||
@@ -297,7 +298,7 @@ def order_confirmation(order_id: int) -> InlineKeyboardMarkup:
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🟢 Подтвердить заказ",
|
||||
callback_data=f"confirm_order:{order_id}",
|
||||
callback_data=f"precheck_order:{order_id}",
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text="❌ Отменить", callback_data="menu:main")],
|
||||
@@ -305,6 +306,20 @@ def order_confirmation(order_id: int) -> InlineKeyboardMarkup:
|
||||
).as_markup()
|
||||
|
||||
|
||||
def subscription(order_id: int) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardBuilder(
|
||||
[
|
||||
[InlineKeyboardButton(text="🌸 Подписаться на канал", url=config.CHANNEL_URL)],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="▶️ Продолжить оформление", callback_data=f"confirm_order:{order_id}"
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text="🏠", callback_data="menu:main")],
|
||||
]
|
||||
).as_markup()
|
||||
|
||||
|
||||
def order_specs_confirmation(order_id: int) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardBuilder(
|
||||
[
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from aiogram.fsm.state import StatesGroup, State
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class CheckoutStorage(StatesGroup):
|
||||
@@ -18,5 +18,10 @@ class SearchStorage(StatesGroup):
|
||||
|
||||
class AdminControlStorage(StatesGroup):
|
||||
edit_product = State()
|
||||
edit_product_photo = State()
|
||||
edit_category = State()
|
||||
new_category = State()
|
||||
new_product_name = State()
|
||||
new_product_description = State()
|
||||
new_product_price = State()
|
||||
new_product_photo = State()
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import base64
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import phonenumbers
|
||||
from aiogram.types import (
|
||||
ChatMemberAdministrator,
|
||||
ChatMemberMember,
|
||||
ChatMemberOwner,
|
||||
ResultChatMemberUnion,
|
||||
)
|
||||
from phonenumbers import NumberParseException
|
||||
|
||||
|
||||
@@ -25,3 +32,13 @@ def b64_to_dict(payload: str) -> dict["str", Any]:
|
||||
payload += padding
|
||||
json_bytes = base64.urlsafe_b64decode(payload)
|
||||
return json.loads(json_bytes.decode("utf-8"))
|
||||
|
||||
|
||||
def is_member(chatmember: ResultChatMemberUnion):
|
||||
return any(
|
||||
[
|
||||
isinstance(chatmember, ChatMemberOwner),
|
||||
isinstance(chatmember, ChatMemberAdministrator),
|
||||
isinstance(chatmember, ChatMemberMember),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,29 +1,64 @@
|
||||
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
|
||||
|
||||
|
||||
class ProductRepository:
|
||||
async def delete_product(self, session: AsyncSession, product_id: int) -> Product | None:
|
||||
product = await self.get_product_by_id(session, product_id)
|
||||
if not product:
|
||||
return None
|
||||
|
||||
await session.delete(product)
|
||||
await session.commit()
|
||||
return product
|
||||
|
||||
async def add_product(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
category_id: int | None,
|
||||
name: str,
|
||||
description: str,
|
||||
price: int,
|
||||
) -> Product:
|
||||
product = Product(
|
||||
category_id=category_id,
|
||||
name=name,
|
||||
description=description,
|
||||
price=price,
|
||||
)
|
||||
session.add(product)
|
||||
await session.commit()
|
||||
await session.refresh(product)
|
||||
return product
|
||||
|
||||
async def get_product_by_id(
|
||||
self, session: AsyncSession, product_id: int
|
||||
) -> 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 +72,51 @@ 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 count_products_by_category(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
category_id: int,
|
||||
*,
|
||||
include_hidden: bool = False,
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(Product).where(Product.category_id == category_id)
|
||||
|
||||
if not include_hidden:
|
||||
stmt = stmt.where(Product.is_hidden.is_(False))
|
||||
|
||||
return await session.scalar(stmt)
|
||||
|
||||
async def count_search(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
query: str,
|
||||
*,
|
||||
include_hidden: bool = False,
|
||||
) -> int:
|
||||
ts_query = func.plainto_tsquery("simple", query)
|
||||
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Product)
|
||||
.where(Product.search_vector.op("@@")(ts_query))
|
||||
)
|
||||
|
||||
if not include_hidden:
|
||||
stmt = stmt.where(Product.is_hidden.is_(False))
|
||||
|
||||
return await session.scalar(stmt)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -44,13 +124,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 +143,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 +159,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
|
||||
|
||||
19
repositories/users.py
Normal file
19
repositories/users.py
Normal 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
|
||||
@@ -4,8 +4,7 @@ python-dotenv
|
||||
black
|
||||
asyncpg
|
||||
sqlalchemy[asyncio]
|
||||
psycopg2
|
||||
phonenumbers
|
||||
ruff
|
||||
redis
|
||||
aiohttp-socks
|
||||
aiohttp-socks
|
||||
|
||||
@@ -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,12 +37,19 @@ 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
|
||||
view.products = products[:PAGE_SIZE]
|
||||
view.has_next = len(products) > PAGE_SIZE
|
||||
view.total = await self.product_repo.count_products_by_category(
|
||||
session, cat_id, include_hidden=include_hidden
|
||||
)
|
||||
|
||||
return view
|
||||
view.view_type = CatalogueType.EMPTY
|
||||
@@ -51,7 +60,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)
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
BIN
static/welcome.mp4
Normal file
BIN
static/welcome.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user