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 = []