diff --git a/.gitignore b/.gitignore
index fb5aab0..96ea3a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -136,6 +136,7 @@ venv/
ENV/
env.bak/
venv.bak/
+*.env
# Spyder project settings
.spyderproject
diff --git a/alembic.ini b/alembic.ini
index cfc95b0..a3ae739 100644
--- a/alembic.ini
+++ b/alembic.ini
@@ -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]
diff --git a/alembic/env.py b/alembic/env.py
index ac8bc23..9fad4a8 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -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:
diff --git a/config.py b/config.py
index facaa60..338c5d2 100644
--- a/config.py
+++ b/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
@@ -26,9 +27,10 @@ POSTGRES_URL = os.getenv(
card_info = CardInfo(os.getenv("CARD_NUMBER", ""), os.getenv("CARD_HOLDER", ""))
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/1")
+PROXY_URL = os.getenv("PROXY_URL")
### Constants ###
PAGE_SIZE = 8
-VERIFIED_ACCOUNTS = [1026030711, 8480400744]
-NOTIFICATION_CHANNEL = -1003849564110
+VERIFIED_ACCOUNTS = [8480400744, 6551145249]
+NOTIFICATION_CHANNEL = -1003836761482
diff --git a/db/base.py b/db/base.py
index a248c95..1d5d08b 100644
--- a/db/base.py
+++ b/db/base.py
@@ -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
diff --git a/db/models/__init__.py b/db/models/__init__.py
index 5b5f54e..d71845b 100644
--- a/db/models/__init__.py
+++ b/db/models/__init__.py
@@ -1,13 +1,13 @@
-from .orders import Order, OrderItem, OrderStatus
-from .products import Product, Category
from .invoices import Invoice, InvoiceStatus
+from .orders import Order, OrderItem, OrderStatus
+from .products import Category, Product
__all__ = [
+ "Category",
+ "Invoice",
+ "InvoiceStatus",
"Order",
"OrderItem",
"OrderStatus",
"Product",
- "Category",
- "Invoice",
- "InvoiceStatus",
]
diff --git a/db/models/invoices.py b/db/models/invoices.py
index c27ca7a..405ed78 100644
--- a/db/models/invoices.py
+++ b/db/models/invoices.py
@@ -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
diff --git a/db/models/orders.py b/db/models/orders.py
index 7000f82..8306f90 100644
--- a/db/models/orders.py
+++ b/db/models/orders.py
@@ -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):
diff --git a/db/models/products.py b/db/models/products.py
index e941da4..269d7f3 100644
--- a/db/models/products.py
+++ b/db/models/products.py
@@ -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"),)
diff --git a/dto/__init__.py b/dto/__init__.py
index 50c9346..0103c00 100644
--- a/dto/__init__.py
+++ b/dto/__init__.py
@@ -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",
]
diff --git a/dto/cart.py b/dto/cart.py
index ff65736..dc3d874 100644
--- a/dto/cart.py
+++ b/dto/cart.py
@@ -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
diff --git a/dto/catalogue.py b/dto/catalogue.py
index 9748325..e58da31 100644
--- a/dto/catalogue.py
+++ b/dto/catalogue.py
@@ -1,6 +1,5 @@
from dataclasses import dataclass
from enum import Enum
-from typing import Optional, Union
from aiogram.types import Message
@@ -16,12 +15,12 @@ class CatalogueType(Enum):
@dataclass
class CatalogueView:
- view_type: Optional[CatalogueType] = None
- category: Optional[Category] = None
- children: Optional[list[Category]] = None
- products: Optional[list[Product]] = None
+ view_type: CatalogueType | None = None
+ category: Category | None = None
+ children: list[Category] | None = None
+ products: list[Product] | None = None
page: int = 0
- parent_id: Union[int, str, None] = None
+ parent_id: int | str | None = None
has_next: bool = False
show_menu: bool = False
@@ -29,7 +28,7 @@ class CatalogueView:
@dataclass
class NewCatalogueElement:
msg: Message
- parent_id: Union[int, str]
+ parent_id: int | str
@dataclass
diff --git a/dto/checkout.py b/dto/checkout.py
index 58de54a..9a10ae0 100644
--- a/dto/checkout.py
+++ b/dto/checkout.py
@@ -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
diff --git a/handlers/__init__.py b/handlers/__init__.py
index 964c004..d379ca1 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -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"]
diff --git a/handlers/admins/__init__.py b/handlers/admins/__init__.py
index 054f790..3bbc0e4 100644
--- a/handlers/admins/__init__.py
+++ b/handlers/admins/__init__.py
@@ -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]
diff --git a/handlers/admins/creation.py b/handlers/admins/creation.py
index 5c2414f..99ca112 100644
--- a/handlers/admins/creation.py
+++ b/handlers/admins/creation.py
@@ -1,14 +1,14 @@
from aiogram import F, Router
-from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from dto.catalogue import CategoryActionContext
from misc.filters import IsVerified
from misc.kb.client import created_category, get_back_to_catalogue
+from misc.kb.common import return_menu
from misc.mapper import parse_cat_id
from misc.states import AdminControlStorage
-from misc.kb.common import return_menu
from repositories.categories import CategoriesRepository
from services.catalogue import CatalogueService
@@ -67,6 +67,6 @@ async def create_new_category(
await ctx.msg.edit_text(
f"✅ Категория {msg.text} создана успешно.",
reply_markup=created_category(
- category.id, f"🛒 {category.name}", category.parent_id
+ category.id, f"🛒 {category.name}", category.parent_id or "root"
),
)
diff --git a/handlers/admins/inline_mode.py b/handlers/admins/inline_mode.py
index 3549bdd..420ecdd 100644
--- a/handlers/admins/inline_mode.py
+++ b/handlers/admins/inline_mode.py
@@ -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="Нажмите, чтобы создать счёт.",
),
diff --git a/handlers/admins/menu.py b/handlers/admins/menu.py
index e178d4e..46c5f10 100644
--- a/handlers/admins/menu.py
+++ b/handlers/admins/menu.py
@@ -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
diff --git a/handlers/admins/product_mgmt.py b/handlers/admins/product_mgmt.py
index e5b14c3..ec2c649 100644
--- a/handlers/admins/product_mgmt.py
+++ b/handlers/admins/product_mgmt.py
@@ -1,12 +1,14 @@
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
@@ -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"✍️ Введите {product_editing_mapping.get(mode)}",
- 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"✍️ Введите {product_editing_mapping.get(mode)}",
- 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,33 @@ 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(F.data.startswith("edit:"))
async def edit_entry(cb: CallbackQuery, state: FSMContext):
await state.clear()
@@ -143,6 +164,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))
diff --git a/handlers/client/__init__.py b/handlers/client/__init__.py
index 71b8f95..ff23f37 100644
--- a/handlers/client/__init__.py
+++ b/handlers/client/__init__.py
@@ -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,
diff --git a/handlers/client/cart.py b/handlers/client/cart.py
index 70a776c..ae6a1fa 100644
--- a/handlers/client/cart.py
+++ b/handlers/client/cart.py
@@ -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)}",
+ "🌸 Убедитесь в том, что все желаемые позиции есть в заказе ниже!\n\n"
+ f"Сумма: {cart.total}₽",
reply_markup=render_cart(
cart.items[:PAGE_SIZE],
show_next=len(cart.items) > PAGE_SIZE,
@@ -47,9 +46,7 @@ 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)
diff --git a/handlers/client/catalogue.py b/handlers/client/catalogue.py
index a02ffb4..4916d72 100644
--- a/handlers/client/catalogue.py
+++ b/handlers/client/catalogue.py
@@ -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)
diff --git a/handlers/client/checkout.py b/handlers/client/checkout.py
index 24dfcbe..8068109 100644
--- a/handlers/client/checkout.py
+++ b/handlers/client/checkout.py
@@ -1,8 +1,8 @@
import logging
-from typing import Optional
-from aiogram import Router, F
-from aiogram.types import CallbackQuery, Message
+
+from aiogram import F, Router
from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from config import NOTIFICATION_CHANNEL
@@ -98,7 +98,7 @@ async def checkout_phone(msg: Message, state: FSMContext):
ctx.phone = msg.text
await ctx.orig_msg.edit_text(
- f"👤 Имя: {ctx.name}\n📱 Номер телефона: {ctx.phone}\n\n📍 Укажите ваш адрес (город, улица):",
+ f"👤 Имя: {ctx.name}\n📱 Номер телефона: {ctx.phone}\n\n📍 Укажите адрес ближайшего ПВЗ Ozon (город, улица):",
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"👤 Имя: {ctx.name}\n"
f"📱 Номер телефона: {ctx.phone}\n"
- f"📍 Ваш адрес: {ctx.address}\n\n"
+ f"📍 Адрес ближайшего ПВЗ Ozon: {ctx.address}\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"👤 Имя: {ctx.name}\n"
f"📱 Номер телефона: {ctx.phone}\n"
- f"📍 Адрес: {ctx.address}\n\n"
+ f"📍 Адрес ближайшего ПВЗ Ozon: {ctx.address}\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(
"🟢 Ваш заказ успешно отправлен!\n"
diff --git a/handlers/client/invoices.py b/handlers/client/invoices.py
index b60a937..99d1483 100644
--- a/handlers/client/invoices.py
+++ b/handlers/client/invoices.py
@@ -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(
diff --git a/handlers/client/menu.py b/handlers/client/menu.py
index 836d110..3de67d9 100644
--- a/handlers/client/menu.py
+++ b/handlers/client/menu.py
@@ -1,7 +1,9 @@
-from aiogram import Router, F
-from aiogram.types import CallbackQuery, Message
+import contextlib
+
+from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from misc.kb import main_menu_kb
@@ -20,12 +22,11 @@ async def user_main_menu(
):
await state.clear()
- cart_items = await order_items_repo.get_items_count_by_customer(
- session, msg.from_user.id
- )
+ cart_items = await order_items_repo.get_items_count_by_customer(session, msg.from_user.id)
await msg.answer(
- "hii!", reply_markup=main_menu_kb(cart_items)
- ) # TODO: Write a welcome message
+ "Добрый день!\n" 'Нажмите "Каталог" чтобы выбрать товар или воспользуйтесь поиском.',
+ reply_markup=main_menu_kb(cart_items),
+ )
@router.callback_query(F.data.startswith("menu:"))
@@ -37,14 +38,11 @@ async def user_main_menu_cb(
):
await state.clear()
- try:
+ with contextlib.suppress(Exception):
await cb.message.reply_to_message.delete()
- except Exception:
- ...
- cart_items = await order_items_repo.get_items_count_by_customer(
- session, cb.from_user.id
- )
+ cart_items = await order_items_repo.get_items_count_by_customer(session, cb.from_user.id)
await cb.message.edit_text(
- "hii!", reply_markup=main_menu_kb(cart_items)
- ) # TODO: Write a welcome message
+ "Добрый день!\n" 'Нажмите "Каталог" чтобы выбрать товар или воспользуйтесь поиском.',
+ reply_markup=main_menu_kb(cart_items),
+ )
diff --git a/handlers/client/products.py b/handlers/client/products.py
index c92e6a2..cbcfd70 100644
--- a/handlers/client/products.py
+++ b/handlers/client/products.py
@@ -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.*):(?P\d+)").as_(
- "pagination_match"
- )
+ F.data.regexp(r"products:(?P.*):(?P\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("Товар недоступен.", 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("⏳ Загружаем карточку товара...")
- except TelegramBadRequest:
- ...
msg = await cb.message.answer_photo(
FSInputFile(product.img_path),
@@ -110,9 +110,7 @@ async def product_card(
logger.warning("didnt get a photo back wahhh :(")
return # TODO: Add logging
- 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":
diff --git a/handlers/client/search.py b/handlers/client/search.py
index b3f2fbf..c539be8 100644
--- a/handlers/client/search.py
+++ b/handlers/client/search.py
@@ -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("🔍 Введите запрос:", reply_markup=return_menu)
await state.set_state(SearchStorage.query)
await state.set_data({"msg": cb.message})
@@ -37,11 +36,13 @@ async def searching(
await state.clear()
await msg.delete()
- orig_msg: Optional[Message] = data.get("msg") or await msg.answer("⏳")
+ orig_msg: Message | None = data.get("msg") or await msg.answer("⏳")
await orig_msg.edit_text("⏳ Поиск...")
+ is_admin = msg.from_user.id in VERIFIED_ACCOUNTS
results = await products_repo.search(
session,
msg.text,
+ include_hidden=is_admin,
)
next_cb = None
@@ -51,12 +52,13 @@ async def searching(
next_cb = f"search:{query_hash}:1"
await orig_msg.edit_text(
- "here",
+ "🗒 Результаты поиска:",
reply_markup=render_products(
results[:PAGE_SIZE],
product_cb_factory=lambda p: f"product:{p.id}",
back_cb="menu:main",
next_cb=next_cb,
+ is_admin=is_admin,
),
)
@@ -79,7 +81,13 @@ async def search_pagination(
if not query:
await cb.message.edit_text("🍃 Попробуйте ещё раз...")
- results = await products_repo.search(session, query, offset=PAGE_SIZE * page)
+ is_admin = cb.from_user.id in VERIFIED_ACCOUNTS
+ results = await products_repo.search(
+ session,
+ query,
+ offset=PAGE_SIZE * page,
+ include_hidden=is_admin,
+ )
await cb.message.edit_reply_markup(
reply_markup=render_products(
@@ -88,5 +96,6 @@ async def search_pagination(
back_cb="menu:main",
next_cb=f"search:{q_hash}:{page + 1}" if len(results) > PAGE_SIZE else None,
prev_cb=f"search:{q_hash}:{page - 1}" if page > 0 else None,
+ is_admin=is_admin,
)
)
diff --git a/handlers/client/security.py b/handlers/client/security.py
index 303c14f..ff6c802 100644
--- a/handlers/client/security.py
+++ b/handlers/client/security.py
@@ -1,4 +1,4 @@
-from aiogram import Router, F
+from aiogram import F, Router
from aiogram.enums import MessageOriginType
from aiogram.types import Message
diff --git a/main.py b/main.py
index 7429300..c40b95f 100644
--- a/main.py
+++ b/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"),
diff --git a/middlewares/__init__.py b/middlewares/__init__.py
index be832ec..7d7c68f 100644
--- a/middlewares/__init__.py
+++ b/middlewares/__init__.py
@@ -2,4 +2,4 @@ from .di import DIMiddleware
from .repository import RepositoryMiddleware
from .session import DBSessionMiddleware
-__all__ = ["DIMiddleware", "RepositoryMiddleware", "DBSessionMiddleware"]
+__all__ = ["DBSessionMiddleware", "DIMiddleware", "RepositoryMiddleware"]
diff --git a/middlewares/di.py b/middlewares/di.py
index b0b1461..4b43acc 100644
--- a/middlewares/di.py
+++ b/middlewares/di.py
@@ -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):
diff --git a/middlewares/repository.py b/middlewares/repository.py
index 4558609..d6f3e36 100644
--- a/middlewares/repository.py
+++ b/middlewares/repository.py
@@ -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
diff --git a/misc/kb/__init__.py b/misc/kb/__init__.py
index ef89dd8..e69de29 100644
--- a/misc/kb/__init__.py
+++ b/misc/kb/__init__.py
@@ -1,4 +0,0 @@
-from misc.kb.admins import *
-from misc.kb.client import *
-
-__all__ = ["admins", "client"]
diff --git a/misc/kb/admins.py b/misc/kb/admins.py
index 4ae629e..76a8b1c 100644
--- a/misc/kb/admins.py
+++ b/misc/kb/admins.py
@@ -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,14 +54,15 @@ 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 "👁️ Видим"
+
return InlineKeyboardBuilder(
[
[
- InlineKeyboardButton(
- text="✍️ Название", callback_data=f"{prefix}:name"
- ),
+ InlineKeyboardButton(text="✍️ Название", callback_data=f"{prefix}:name"),
InlineKeyboardButton(
text="✍️ Описание",
callback_data=f"{prefix}:description",
@@ -72,9 +70,9 @@ def edit_product(product_id: int, category_id: int):
],
[InlineKeyboardButton(text="💸 Цена", callback_data=f"{prefix}:price")],
[
- 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()
diff --git a/misc/kb/client.py b/misc/kb/client.py
index bb26616..29ee0fb 100644
--- a/misc/kb/client.py
+++ b/misc/kb/client.py
@@ -1,4 +1,4 @@
-from typing import Optional, Callable, Union
+from collections.abc import Callable
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
@@ -6,15 +6,15 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import PAGE_SIZE
from db.models import Category, Product
from dto.cart import CartItemDTO
-from dto.catalogue import CatalogueView, CatalogueType
+from dto.catalogue import CatalogueType, CatalogueView
from misc.mapper import serialize_cat_id
def pagination_row(
*,
page: int,
- prev_cb: Optional[str] = None,
- next_cb: Optional[str] = None,
+ prev_cb: str | None = None,
+ next_cb: str | None = None,
) -> list[InlineKeyboardButton]:
row = []
@@ -39,22 +39,18 @@ def cart_btn(cart_items: int) -> InlineKeyboardButton:
def get_back_to_catalogue(
parent_id: int,
*,
- text: Optional[str] = "⬅️ Назад",
+ text: str | None = "⬅️ Назад",
show_controls: bool = False,
- cat_id: Optional[Union[str, int]] = None,
- fallback_cb: Optional[str] = "menu:main",
+ cat_id: str | int | None = None,
+ fallback_cb: str | None = "menu:main",
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if show_controls and cat_id:
builder.row(
- InlineKeyboardButton(
- text="➕ Категория", callback_data=f"new:category:{cat_id}"
- ),
- InlineKeyboardButton(
- text="➕ Товар", callback_data=f"new:product:{cat_id}"
- ),
+ InlineKeyboardButton(text="➕ Категория", callback_data=f"new:category:{cat_id}"),
+ InlineKeyboardButton(text="➕ Товар", callback_data=f"new:product:{cat_id}"),
)
builder.row(
@@ -82,7 +78,7 @@ def main_menu_kb(cart_items: int) -> InlineKeyboardMarkup:
[
[
InlineKeyboardButton(text="📦 Каталог", callback_data="cat:root"),
- InlineKeyboardButton(text="🔍", callback_data="find"),
+ InlineKeyboardButton(text="🔍 Поиск", callback_data="find"),
],
[cart_btn(cart_items)],
]
@@ -91,10 +87,10 @@ def main_menu_kb(cart_items: int) -> InlineKeyboardMarkup:
def render_category(
children: list[Category],
- parent_id: Optional[Union[str, int]] = None,
+ parent_id: str | int | None = None,
show_menu: bool = False,
*,
- cat_id: Optional[Union[str, int]] = None,
+ cat_id: str | int | None = None,
category_cb_factory: Callable[[Category], str],
is_admin: bool = False,
) -> InlineKeyboardMarkup:
@@ -116,9 +112,7 @@ def render_category(
text="➕ Категория",
callback_data=f"new:category:{cat_id}",
),
- InlineKeyboardButton(
- text="✍️", callback_data=f"edit:category:{cat_id}"
- ),
+ InlineKeyboardButton(text="✍️", callback_data=f"edit:category:{cat_id}"),
]
)
@@ -126,7 +120,10 @@ def render_category(
nav.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")])
elif parent_id is not None:
nav.append(
- [InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}")]
+ [
+ InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}"),
+ InlineKeyboardButton(text="🏠", callback_data="menu:main"),
+ ]
)
return InlineKeyboardBuilder(
@@ -138,22 +135,25 @@ def render_products(
products: list[Product],
*,
product_cb_factory: Callable[[Product], str],
- back_cb: Optional[str] = None,
- prev_cb: Optional[str] = None,
- next_cb: Optional[str] = None,
+ back_cb: str | None = None,
+ prev_cb: str | None = None,
+ next_cb: str | None = None,
columns: int = 2,
is_admin: bool = False,
- cat_id: Optional[int] = None,
+ cat_id: int | None = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if back_cb:
- builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=back_cb))
+ builder.row(
+ InlineKeyboardButton(text="⬅️ Назад", callback_data=back_cb),
+ InlineKeyboardButton(text="🏠", callback_data="menu:main"),
+ )
for i in range(0, len(products), columns):
row = [
InlineKeyboardButton(
- text=p.name,
+ text=f"{p.name} {'🙈' if is_admin and p.is_hidden else ''}".strip(),
callback_data=product_cb_factory(p),
)
for p in products[i : i + columns]
@@ -161,9 +161,7 @@ def render_products(
builder.row(*row)
if is_admin:
- builder.row(
- InlineKeyboardButton(text="➕ Товар", callback_data=f"new:product:{cat_id}")
- )
+ builder.row(InlineKeyboardButton(text="➕ Товар", callback_data=f"new:product:{cat_id}"))
nav_buttons = []
if prev_cb:
@@ -193,12 +191,8 @@ def render_catalogue(view: CatalogueView, *, is_admin: bool):
products=view.products[:PAGE_SIZE], # type: ignore
product_cb_factory=lambda p: f"product:{p.id}",
back_cb=f"cat:{view.parent_id}",
- prev_cb=(
- f"products:{view.category}:{view.page - 1}" if view.page > 0 else None
- ),
- next_cb=(
- f"products:{view.category}:{view.page + 1}" if view.has_next else None
- ),
+ prev_cb=(f"products:{view.category}:{view.page - 1}" if view.page > 0 else None),
+ next_cb=(f"products:{view.category}:{view.page + 1}" if view.has_next else None),
is_admin=is_admin,
cat_id=view.category,
)
@@ -216,13 +210,9 @@ def render_product_interactions(
if show_cart:
btns.append(
[
- InlineKeyboardButton(
- text="➖", callback_data=f"cart_action:remove:{product_id}"
- ),
+ InlineKeyboardButton(text="➖", callback_data=f"cart_action:remove:{product_id}"),
InlineKeyboardButton(text=f"🛒 {cart_amount}", callback_data="..."),
- InlineKeyboardButton(
- text="➕", callback_data=f"cart_action:add:{product_id}"
- ),
+ InlineKeyboardButton(text="➕", callback_data=f"cart_action:add:{product_id}"),
]
)
btns.append(
@@ -243,7 +233,12 @@ def render_product_interactions(
]
)
- btns.append([InlineKeyboardButton(text="⬅️", callback_data=f"cat:{category_id}")])
+ btns.append(
+ [
+ InlineKeyboardButton(text="⬅️", callback_data=f"cat:{category_id}"),
+ InlineKeyboardButton(text="🏠", callback_data="menu:main"),
+ ]
+ )
return InlineKeyboardBuilder(btns).as_markup()
@@ -252,9 +247,9 @@ def render_cart(
cart_items: list[CartItemDTO],
*,
page: int = 0,
- show_prev: Optional[bool] = False,
- show_next: Optional[bool] = False,
- show_purchase: Optional[bool] = False,
+ show_prev: bool | None = False,
+ show_next: bool | None = False,
+ show_purchase: bool | None = False,
) -> InlineKeyboardMarkup:
btns = [
InlineKeyboardButton(
@@ -284,9 +279,7 @@ def render_cart(
)
if show_purchase:
- markup.append(
- [InlineKeyboardButton(text="🔷 Оформить заказ", callback_data="checkout")]
- )
+ markup.append([InlineKeyboardButton(text="🔷 Оформить заказ", callback_data="checkout")])
return InlineKeyboardBuilder(markup).as_markup()
diff --git a/misc/redis.py b/misc/redis.py
index 03b74ae..03eb392 100644
--- a/misc/redis.py
+++ b/misc/redis.py
@@ -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()
diff --git a/misc/states.py b/misc/states.py
index 6e52511..b5f324e 100644
--- a/misc/states.py
+++ b/misc/states.py
@@ -1,4 +1,4 @@
-from aiogram.fsm.state import StatesGroup, State
+from aiogram.fsm.state import State, StatesGroup
class CheckoutStorage(StatesGroup):
diff --git a/misc/utils.py b/misc/utils.py
index 0b3b551..064a66b 100644
--- a/misc/utils.py
+++ b/misc/utils.py
@@ -1,6 +1,7 @@
import base64
import json
from typing import Any
+
import phonenumbers
from phonenumbers import NumberParseException
diff --git a/pyproject.toml b/pyproject.toml
index d93c300..28d418f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"]
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/repositories/__init__.py b/repositories/__init__.py
index 6fcc878..0b301e3 100644
--- a/repositories/__init__.py
+++ b/repositories/__init__.py
@@ -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",
]
diff --git a/repositories/categories.py b/repositories/categories.py
index f012128..5b49742 100644
--- a/repositories/categories.py
+++ b/repositories/categories.py
@@ -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
diff --git a/repositories/invoices.py b/repositories/invoices.py
index 3a2d20c..54d155e 100644
--- a/repositories/invoices.py
+++ b/repositories/invoices.py
@@ -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)
diff --git a/repositories/order_items.py b/repositories/order_items.py
index 2930e1c..2ab67e1 100644
--- a/repositories/order_items.py
+++ b/repositories/order_items.py
@@ -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()
diff --git a/repositories/orders.py b/repositories/orders.py
index f3d8bdb..feb2976 100644
--- a/repositories/orders.py
+++ b/repositories/orders.py
@@ -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
diff --git a/repositories/products.py b/repositories/products.py
index 347f680..7c343b5 100644
--- a/repositories/products.py
+++ b/repositories/products.py
@@ -1,7 +1,6 @@
-from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.expression import func
-from typing import Optional
from config import PAGE_SIZE
from db.models import Product
@@ -9,21 +8,28 @@ from db.models import Product
class ProductRepository:
async def get_product_by_id(
- self, session: AsyncSession, product_id: int
- ) -> Optional[Product]:
+ self, session: AsyncSession, product_id: int, *, include_hidden: bool = True
+ ) -> Product | None:
stmt = select(Product).where(Product.id == product_id)
+ if not include_hidden:
+ stmt = stmt.where(Product.is_hidden.is_(False))
return await session.scalar(stmt)
async def get_product_by_category(
- self, session: AsyncSession, category_id: int, *, limit: int, offset: int
+ self,
+ session: AsyncSession,
+ category_id: int,
+ *,
+ limit: int,
+ offset: int,
+ include_hidden: bool = False,
) -> list[Product]:
- stmt = (
- select(Product)
- .where(Product.category_id == category_id)
- .order_by(Product.id)
- .offset(offset)
- .limit(limit)
- )
+ stmt = select(Product).where(Product.category_id == category_id)
+
+ if not include_hidden:
+ stmt = stmt.where(Product.is_hidden.is_(False))
+
+ stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
result = await session.scalars(stmt)
@@ -44,13 +50,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 +69,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 +85,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
diff --git a/requirements.txt b/requirements.txt
index f0efaeb..54ab5bd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,8 +4,7 @@ python-dotenv
black
asyncpg
sqlalchemy[asyncio]
-psycopg2
phonenumbers
ruff
redis
-aiohttp-socks
\ No newline at end of file
+aiohttp-socks
diff --git a/services/catalogue.py b/services/catalogue.py
index d788b15..90ee4e1 100644
--- a/services/catalogue.py
+++ b/services/catalogue.py
@@ -1,11 +1,10 @@
-from typing import Union, Optional
from sqlalchemy.ext.asyncio import AsyncSession
+from config import PAGE_SIZE
from dto.catalogue import CatalogueType, CatalogueView
from misc.mapper import parse_cat_id, serialize_cat_id
from misc.texts import get_breadcrumps_path
-from repositories import ProductRepository, CategoriesRepository
-from config import PAGE_SIZE
+from repositories import CategoriesRepository, ProductRepository
class CatalogueService:
@@ -16,14 +15,17 @@ class CatalogueService:
self.categories_repo = categories_repo
async def build_category_view(
- self, session: AsyncSession, cat_id: Optional[Union[int, str]], page: int = 0
+ self,
+ session: AsyncSession,
+ cat_id: int | str | None,
+ page: int = 0,
+ *,
+ include_hidden: bool = False,
) -> CatalogueView:
cat_id = parse_cat_id(cat_id)
view = CatalogueView(page=page)
- children = await self.categories_repo.get_categories_by_parent_id(
- session, cat_id
- )
+ children = await self.categories_repo.get_categories_by_parent_id(session, cat_id)
category = await self.categories_repo.get_category_by_id(session, cat_id)
if not category: # Root category
@@ -35,7 +37,11 @@ class CatalogueService:
if not children: # Products
products = await self.product_repo.get_product_by_category(
- session, cat_id, limit=PAGE_SIZE + 1, offset=view.page * PAGE_SIZE
+ session,
+ cat_id,
+ limit=PAGE_SIZE + 1,
+ offset=view.page * PAGE_SIZE,
+ include_hidden=include_hidden,
)
if products:
view.view_type = CatalogueType.PRODUCTS
@@ -51,7 +57,7 @@ class CatalogueService:
return view
- async def get_path(self, session: AsyncSession, cat_id: Union[int, str]):
+ async def get_path(self, session: AsyncSession, cat_id: int | str):
category = int(cat_id) if isinstance(cat_id, int) or cat_id.isdigit() else None
products = await self.categories_repo.get_category_path(session, category)
diff --git a/services/orders.py b/services/orders.py
index 729c77f..347f801 100644
--- a/services/orders.py
+++ b/services/orders.py
@@ -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 = []