diff options
| -rw-r--r-- | alembic/versions/1627487324fd_init_database.py (renamed from alembic/versions/940afc736a0f_init_database.py) | 8 | ||||
| -rw-r--r-- | handlers/admin/__init__.py | 2 | ||||
| -rw-r--r-- | handlers/admin/new_announcement.py | 9 | ||||
| -rw-r--r-- | handlers/admin/new_invoice.py | 109 | ||||
| -rw-r--r-- | libs/msg.py | 37 | ||||
| -rw-r--r-- | models/__init__.py | 2 | ||||
| -rw-r--r-- | models/callback_data.py | 5 | ||||
| -rw-r--r-- | models/invoce.py | 13 | ||||
| -rw-r--r-- | models/rich_text.py | 10 |
9 files changed, 182 insertions, 13 deletions
diff --git a/alembic/versions/940afc736a0f_init_database.py b/alembic/versions/1627487324fd_init_database.py index bfaa771..f25277f 100644 --- a/alembic/versions/940afc736a0f_init_database.py +++ b/alembic/versions/1627487324fd_init_database.py | |||
| @@ -1,8 +1,8 @@ | |||
| 1 | """init database | 1 | """init database |
| 2 | 2 | ||
| 3 | Revision ID: 940afc736a0f | 3 | Revision ID: 1627487324fd |
| 4 | Revises: | 4 | Revises: |
| 5 | Create Date: 2026-03-23 02:21:35.245634 | 5 | Create Date: 2026-03-23 18:33:08.493629 |
| 6 | 6 | ||
| 7 | """ | 7 | """ |
| 8 | 8 | ||
| @@ -13,7 +13,7 @@ import sqlalchemy as sa | |||
| 13 | from alembic import op | 13 | from alembic import op |
| 14 | 14 | ||
| 15 | # revision identifiers, used by Alembic. | 15 | # revision identifiers, used by Alembic. |
| 16 | revision: str = "940afc736a0f" | 16 | revision: str = "1627487324fd" |
| 17 | down_revision: Union[str, Sequence[str], None] = None | 17 | down_revision: Union[str, Sequence[str], None] = None |
| 18 | branch_labels: Union[str, Sequence[str], None] = None | 18 | branch_labels: Union[str, Sequence[str], None] = None |
| 19 | depends_on: Union[str, Sequence[str], None] = None | 19 | depends_on: Union[str, Sequence[str], None] = None |
| @@ -32,7 +32,7 @@ def upgrade() -> None: | |||
| 32 | op.create_table( | 32 | op.create_table( |
| 33 | "invoice", | 33 | "invoice", |
| 34 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), | 34 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), |
| 35 | sa.Column("amount", sa.Float(), nullable=False), | 35 | sa.Column("message", sa.JSON(), nullable=False), |
| 36 | sa.Column("datetime", sa.DateTime(), nullable=False), | 36 | sa.Column("datetime", sa.DateTime(), nullable=False), |
| 37 | sa.PrimaryKeyConstraint("id", name=op.f("pk_invoice")), | 37 | sa.PrimaryKeyConstraint("id", name=op.f("pk_invoice")), |
| 38 | ) | 38 | ) |
diff --git a/handlers/admin/__init__.py b/handlers/admin/__init__.py index 2f7c74f..98b127f 100644 --- a/handlers/admin/__init__.py +++ b/handlers/admin/__init__.py | |||
| @@ -3,6 +3,7 @@ from aiogram.filters import MagicData | |||
| 3 | 3 | ||
| 4 | # isort: off | 4 | # isort: off |
| 5 | from . import new_announcement | 5 | from . import new_announcement |
| 6 | from . import new_invoice | ||
| 6 | # isort: on | 7 | # isort: on |
| 7 | 8 | ||
| 8 | router = Router(name="admin") | 9 | router = Router(name="admin") |
| @@ -11,4 +12,5 @@ router.callback_query.filter(MagicData(F.user.is_admin())) | |||
| 11 | 12 | ||
| 12 | router.include_routers( | 13 | router.include_routers( |
| 13 | new_announcement.router, | 14 | new_announcement.router, |
| 15 | new_invoice.router, | ||
| 14 | ) | 16 | ) |
diff --git a/handlers/admin/new_announcement.py b/handlers/admin/new_announcement.py index 79cf8d4..0920c47 100644 --- a/handlers/admin/new_announcement.py +++ b/handlers/admin/new_announcement.py | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | from datetime import UTC, datetime | 1 | from datetime import UTC, datetime |
| 2 | 2 | ||
| 3 | from aiogram import Bot, F, Router | 3 | from aiogram import Bot, F, Router |
| 4 | from aiogram.enums import ButtonStyle | ||
| 4 | from aiogram.exceptions import TelegramAPIError | 5 | from aiogram.exceptions import TelegramAPIError |
| 5 | from aiogram.filters import Command | 6 | from aiogram.filters import Command |
| 6 | from aiogram.fsm.context import FSMContext | 7 | from aiogram.fsm.context import FSMContext |
| @@ -17,7 +18,7 @@ from sqlalchemy import select | |||
| 17 | from sqlalchemy.ext.asyncio import AsyncSession | 18 | from sqlalchemy.ext.asyncio import AsyncSession |
| 18 | 19 | ||
| 19 | from libs.fsm import get_data, set_data | 20 | from libs.fsm import get_data, set_data |
| 20 | from libs.msg import publish | 21 | from libs.msg import publish_announcement |
| 21 | from models import Announcement, RichText, User | 22 | from models import Announcement, RichText, User |
| 22 | 23 | ||
| 23 | router = Router(name="new_announcement") | 24 | router = Router(name="new_announcement") |
| @@ -42,8 +43,8 @@ async def new_announcement_command(msg: Message, state: FSMContext) -> None: | |||
| 42 | reply_markup=ReplyKeyboardMarkup( | 43 | reply_markup=ReplyKeyboardMarkup( |
| 43 | keyboard=[ | 44 | keyboard=[ |
| 44 | [ | 45 | [ |
| 45 | KeyboardButton(text=SEND_BUTTON), | 46 | KeyboardButton(text=SEND_BUTTON, style=ButtonStyle.SUCCESS), |
| 46 | KeyboardButton(text=CANCEL_BUTTON), | 47 | KeyboardButton(text=CANCEL_BUTTON, style=ButtonStyle.DANGER), |
| 47 | ] | 48 | ] |
| 48 | ], | 49 | ], |
| 49 | resize_keyboard=True, | 50 | resize_keyboard=True, |
| @@ -69,7 +70,7 @@ async def announcement_send( | |||
| 69 | status_template = "Публикация анонса...\nОпубликовано: {}" | 70 | status_template = "Публикация анонса...\nОпубликовано: {}" |
| 70 | status_msg = await msg.answer(status_template.format(0)) | 71 | status_msg = await msg.answer(status_template.format(0)) |
| 71 | 72 | ||
| 72 | async for n in publish(bot, users, data.rich_text): | 73 | async for n in publish_announcement(bot, users, data.rich_text): |
| 73 | try: | 74 | try: |
| 74 | await status_msg.edit_text(status_template.format(n)) | 75 | await status_msg.edit_text(status_template.format(n)) |
| 75 | except TelegramAPIError: | 76 | except TelegramAPIError: |
diff --git a/handlers/admin/new_invoice.py b/handlers/admin/new_invoice.py new file mode 100644 index 0000000..7e1a64d --- /dev/null +++ b/handlers/admin/new_invoice.py | |||
| @@ -0,0 +1,109 @@ | |||
| 1 | from datetime import UTC, datetime | ||
| 2 | |||
| 3 | from aiogram import Bot, F, Router | ||
| 4 | from aiogram.enums import ButtonStyle, ContentType | ||
| 5 | from aiogram.exceptions import TelegramAPIError | ||
| 6 | from aiogram.filters import Command | ||
| 7 | from aiogram.fsm.context import FSMContext | ||
| 8 | from aiogram.fsm.state import State, StatesGroup | ||
| 9 | from aiogram.types import ( | ||
| 10 | KeyboardButton, | ||
| 11 | Message, | ||
| 12 | ReplyKeyboardMarkup, | ||
| 13 | ReplyKeyboardRemove, | ||
| 14 | ) | ||
| 15 | from pydantic import BaseModel | ||
| 16 | from sqlalchemy import select | ||
| 17 | from sqlalchemy.ext.asyncio import AsyncSession | ||
| 18 | |||
| 19 | from libs.fsm import get_data, set_data | ||
| 20 | from libs.msg import send_invoice | ||
| 21 | from models import Invoice, RichText, User | ||
| 22 | |||
| 23 | router = Router(name="new_invoice") | ||
| 24 | |||
| 25 | |||
| 26 | class NewInvoiceStates(StatesGroup): | ||
| 27 | message = State() | ||
| 28 | |||
| 29 | |||
| 30 | class NewInvoiceData(BaseModel): | ||
| 31 | rich_text: RichText | None = None | ||
| 32 | |||
| 33 | |||
| 34 | CREATE_BUTTON = "Создать" | ||
| 35 | CANCEL_BUTTON = "Отменить создание" | ||
| 36 | |||
| 37 | |||
| 38 | @router.message(Command("new_invoice")) | ||
| 39 | async def new_invoice_command(msg: Message, state: FSMContext) -> None: | ||
| 40 | await msg.answer( | ||
| 41 | "Укажите сообщение для создания счёта", | ||
| 42 | reply_markup=ReplyKeyboardMarkup( | ||
| 43 | keyboard=[ | ||
| 44 | [ | ||
| 45 | KeyboardButton(text=CREATE_BUTTON, style=ButtonStyle.SUCCESS), | ||
| 46 | KeyboardButton(text=CANCEL_BUTTON, style=ButtonStyle.DANGER), | ||
| 47 | ] | ||
| 48 | ], | ||
| 49 | resize_keyboard=True, | ||
| 50 | ), | ||
| 51 | ) | ||
| 52 | await state.set_state(NewInvoiceStates.message) | ||
| 53 | |||
| 54 | |||
| 55 | @router.message(NewInvoiceStates.message, F.text == CREATE_BUTTON) | ||
| 56 | async def invoice_send( | ||
| 57 | msg: Message, | ||
| 58 | bot: Bot, | ||
| 59 | state: FSMContext, | ||
| 60 | session: AsyncSession, | ||
| 61 | ) -> None: | ||
| 62 | users = await session.scalars(select(User.id).where(User.id != msg.chat.id)) | ||
| 63 | data = await get_data(state, NewInvoiceData) | ||
| 64 | |||
| 65 | if data.rich_text is None: | ||
| 66 | await msg.answer("Для создания счёта укажите сообщение.") | ||
| 67 | return | ||
| 68 | |||
| 69 | status_template = "Рассылка счёта...\nОтправлено: {}" | ||
| 70 | status_msg = await msg.answer(status_template.format(0)) | ||
| 71 | |||
| 72 | invoice = Invoice(message=data, datetime=datetime.now(UTC)) | ||
| 73 | session.add(invoice) | ||
| 74 | await session.flush() | ||
| 75 | |||
| 76 | async for n in send_invoice(bot, users, data.rich_text, invoice.id): | ||
| 77 | try: | ||
| 78 | await status_msg.edit_text(status_template.format(n)) | ||
| 79 | except TelegramAPIError: | ||
| 80 | pass | ||
| 81 | |||
| 82 | await status_msg.delete() | ||
| 83 | await msg.answer( | ||
| 84 | "Счёт отправлен всем пользователям", | ||
| 85 | # reply_markup=ReplyKeyboardRemove(), | ||
| 86 | ) | ||
| 87 | # await state.clear() | ||
| 88 | |||
| 89 | |||
| 90 | @router.message(NewInvoiceStates.message, F.text == CANCEL_BUTTON) | ||
| 91 | async def invoice_cancel(msg: Message, state: FSMContext) -> None: | ||
| 92 | await msg.answer("Создание счёта отменено", reply_markup=ReplyKeyboardRemove()) | ||
| 93 | await state.clear() | ||
| 94 | |||
| 95 | |||
| 96 | @router.message(NewInvoiceStates.message) | ||
| 97 | async def invoice_message(msg: Message, bot: Bot, state: FSMContext) -> None: | ||
| 98 | if msg.content_type != ContentType.TEXT or msg.text is None: | ||
| 99 | await msg.answer( | ||
| 100 | "Неверный тип сообщения.\n" | ||
| 101 | "Бот поддерживает отправку только текстовых счетов." | ||
| 102 | ) | ||
| 103 | return | ||
| 104 | |||
| 105 | rich_text = RichText.from_message(msg) | ||
| 106 | await set_data(state, NewInvoiceData(rich_text=rich_text)) | ||
| 107 | |||
| 108 | msg_rich_text = RichText.from_text("Сообщение вашего счёта:\n", rich_text) | ||
| 109 | await msg_rich_text.send(bot, msg.chat.id) | ||
diff --git a/libs/msg.py b/libs/msg.py index 9bcc52a..05bddfc 100644 --- a/libs/msg.py +++ b/libs/msg.py | |||
| @@ -2,12 +2,15 @@ import asyncio | |||
| 2 | from typing import AsyncGenerator, Iterable | 2 | from typing import AsyncGenerator, Iterable |
| 3 | 3 | ||
| 4 | from aiogram import Bot | 4 | from aiogram import Bot |
| 5 | from aiogram.enums import ButtonStyle | ||
| 5 | from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter | 6 | from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter |
| 7 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup | ||
| 6 | 8 | ||
| 7 | from models import RichText | 9 | from models import RichText |
| 10 | from models.callback_data import PayInvoiceData | ||
| 8 | 11 | ||
| 9 | 12 | ||
| 10 | async def publish( | 13 | async def publish_announcement( |
| 11 | bot: Bot, | 14 | bot: Bot, |
| 12 | users: Iterable[int], | 15 | users: Iterable[int], |
| 13 | rich_text: RichText, | 16 | rich_text: RichText, |
| @@ -23,3 +26,35 @@ async def publish( | |||
| 23 | await asyncio.sleep(5) | 26 | await asyncio.sleep(5) |
| 24 | 27 | ||
| 25 | yield n | 28 | yield n |
| 29 | |||
| 30 | |||
| 31 | async def send_invoice( | ||
| 32 | bot: Bot, | ||
| 33 | users: Iterable[int], | ||
| 34 | rich_text: RichText, | ||
| 35 | invoice_id: int, | ||
| 36 | ) -> AsyncGenerator[int]: | ||
| 37 | callback_data = PayInvoiceData(invoice_id=invoice_id).pack() | ||
| 38 | reply_markup = InlineKeyboardMarkup( | ||
| 39 | inline_keyboard=[ | ||
| 40 | [ | ||
| 41 | InlineKeyboardButton( | ||
| 42 | text="Оплатить", | ||
| 43 | style=ButtonStyle.PRIMARY, | ||
| 44 | callback_data=callback_data, | ||
| 45 | ) | ||
| 46 | ] | ||
| 47 | ] | ||
| 48 | ) | ||
| 49 | |||
| 50 | for n, user_id in enumerate(users, start=1): | ||
| 51 | for _ in range(5): | ||
| 52 | try: | ||
| 53 | await rich_text.send(bot, user_id, reply_markup=reply_markup) | ||
| 54 | break | ||
| 55 | except TelegramRetryAfter as e: | ||
| 56 | await asyncio.sleep(e.retry_after + 1) | ||
| 57 | except TelegramAPIError: | ||
| 58 | await asyncio.sleep(5) | ||
| 59 | |||
| 60 | yield n | ||
diff --git a/models/__init__.py b/models/__init__.py index 9d56b33..0547429 100644 --- a/models/__init__.py +++ b/models/__init__.py | |||
| @@ -5,6 +5,7 @@ from .user import User | |||
| 5 | from .invoce import Invoice | 5 | from .invoce import Invoice |
| 6 | from .payment import Payment | 6 | from .payment import Payment |
| 7 | from .announcement import Announcement | 7 | from .announcement import Announcement |
| 8 | from . import callback_data | ||
| 8 | # isort: on | 9 | # isort: on |
| 9 | 10 | ||
| 10 | __all__ = [ | 11 | __all__ = [ |
| @@ -14,4 +15,5 @@ __all__ = [ | |||
| 14 | "Payment", | 15 | "Payment", |
| 15 | "RichText", | 16 | "RichText", |
| 16 | "Announcement", | 17 | "Announcement", |
| 18 | "callback_data", | ||
| 17 | ] | 19 | ] |
diff --git a/models/callback_data.py b/models/callback_data.py new file mode 100644 index 0000000..d3e6d61 --- /dev/null +++ b/models/callback_data.py | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | from aiogram.filters.callback_data import CallbackData | ||
| 2 | |||
| 3 | |||
| 4 | class PayInvoiceData(CallbackData, prefix="pay_invoice"): | ||
| 5 | invoice_id: int | ||
diff --git a/models/invoce.py b/models/invoce.py index 54a445e..66ff019 100644 --- a/models/invoce.py +++ b/models/invoce.py | |||
| @@ -1,13 +1,22 @@ | |||
| 1 | from datetime import datetime | 1 | from datetime import datetime |
| 2 | 2 | ||
| 3 | from sqlalchemy import JSON | ||
| 3 | from sqlalchemy.orm import Mapped, mapped_column | 4 | from sqlalchemy.orm import Mapped, mapped_column |
| 4 | 5 | ||
| 5 | from models import BaseTable | 6 | from models import BaseTable, RichText |
| 6 | 7 | ||
| 7 | 8 | ||
| 8 | class Invoice(BaseTable): | 9 | class Invoice(BaseTable): |
| 9 | __tablename__ = "invoice" | 10 | __tablename__ = "invoice" |
| 10 | 11 | ||
| 11 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) | 12 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) |
| 12 | amount: Mapped[float] | 13 | __message: Mapped[str] = mapped_column("message", JSON()) |
| 13 | datetime: Mapped[datetime] | 14 | datetime: Mapped[datetime] |
| 15 | |||
| 16 | @property | ||
| 17 | def message(self) -> RichText: | ||
| 18 | return RichText.model_validate_json(self.__message) | ||
| 19 | |||
| 20 | @message.setter | ||
| 21 | def message(self, value: RichText) -> None: | ||
| 22 | self.__message = value.model_dump_json() | ||
diff --git a/models/rich_text.py b/models/rich_text.py index e5441b6..6d1c1cb 100644 --- a/models/rich_text.py +++ b/models/rich_text.py | |||
| @@ -1,7 +1,7 @@ | |||
| 1 | from typing import Self | 1 | from typing import Self |
| 2 | 2 | ||
| 3 | from aiogram import Bot | 3 | from aiogram import Bot |
| 4 | from aiogram.types import Message, MessageEntity | 4 | from aiogram.types import Message, MessageEntity, ReplyMarkupUnion |
| 5 | from pydantic import BaseModel | 5 | from pydantic import BaseModel |
| 6 | 6 | ||
| 7 | 7 | ||
| @@ -34,10 +34,16 @@ class RichText(BaseModel): | |||
| 34 | 34 | ||
| 35 | return result | 35 | return result |
| 36 | 36 | ||
| 37 | async def send(self, bot: Bot, chat_id: int) -> Message: | 37 | async def send( |
| 38 | self, | ||
| 39 | bot: Bot, | ||
| 40 | chat_id: int, | ||
| 41 | reply_markup: ReplyMarkupUnion | None = None, | ||
| 42 | ) -> Message: | ||
| 38 | return await bot.send_message( | 43 | return await bot.send_message( |
| 39 | chat_id=chat_id, | 44 | chat_id=chat_id, |
| 40 | text=self.text, | 45 | text=self.text, |
| 41 | entities=self.entities, | 46 | entities=self.entities, |
| 42 | parse_mode=None, | 47 | parse_mode=None, |
| 48 | reply_markup=reply_markup, | ||
| 43 | ) | 49 | ) |
