diff options
| -rw-r--r-- | alembic/versions/c0c4d0fbcee2_init_database.py (renamed from alembic/versions/1627487324fd_init_database.py) | 13 | ||||
| -rw-r--r-- | codebook.toml | 2 | ||||
| -rw-r--r-- | handlers/admin/add_user.py | 2 | ||||
| -rw-r--r-- | handlers/user/__init__.py | 2 | ||||
| -rw-r--r-- | handlers/user/pay_invoice.py | 160 | ||||
| -rw-r--r-- | libs/__init__.py | 3 | ||||
| -rw-r--r-- | libs/msg.py | 4 | ||||
| -rw-r--r-- | libs/user.py | 5 | ||||
| -rw-r--r-- | models/__init__.py | 8 | ||||
| -rw-r--r-- | models/callback_data.py | 9 | ||||
| -rw-r--r-- | models/payment.py | 52 | ||||
| -rw-r--r-- | models/suggest.py | 3 |
12 files changed, 248 insertions, 15 deletions
diff --git a/alembic/versions/1627487324fd_init_database.py b/alembic/versions/c0c4d0fbcee2_init_database.py index f25277f..8f61846 100644 --- a/alembic/versions/1627487324fd_init_database.py +++ b/alembic/versions/c0c4d0fbcee2_init_database.py | |||
| @@ -1,8 +1,8 @@ | |||
| 1 | """init database | 1 | """init database |
| 2 | 2 | ||
| 3 | Revision ID: 1627487324fd | 3 | Revision ID: c0c4d0fbcee2 |
| 4 | Revises: | 4 | Revises: |
| 5 | Create Date: 2026-03-23 18:33:08.493629 | 5 | Create Date: 2026-03-23 21:19:28.195907 |
| 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 = "1627487324fd" | 16 | revision: str = "c0c4d0fbcee2" |
| 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 |
| @@ -49,7 +49,12 @@ def upgrade() -> None: | |||
| 49 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), | 49 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), |
| 50 | sa.Column("user_id", sa.Integer(), nullable=False), | 50 | sa.Column("user_id", sa.Integer(), nullable=False), |
| 51 | sa.Column("invoice_id", sa.Integer(), nullable=False), | 51 | sa.Column("invoice_id", sa.Integer(), nullable=False), |
| 52 | sa.Column("receipt_file_id", sa.String(), nullable=False), | 52 | sa.Column("receipt_file", sa.JSON(), nullable=False), |
| 53 | sa.Column( | ||
| 54 | "status", | ||
| 55 | sa.Enum("PENDING", "ACCEPTED", "REJECTED", name="paymentstatus"), | ||
| 56 | nullable=False, | ||
| 57 | ), | ||
| 53 | sa.Column("datetime", sa.DateTime(), nullable=False), | 58 | sa.Column("datetime", sa.DateTime(), nullable=False), |
| 54 | sa.ForeignKeyConstraint( | 59 | sa.ForeignKeyConstraint( |
| 55 | ["invoice_id"], ["invoice.id"], name=op.f("fk_payment_invoice_id_invoice") | 60 | ["invoice_id"], ["invoice.id"], name=op.f("fk_payment_invoice_id_invoice") |
diff --git a/codebook.toml b/codebook.toml index a599a2c..e1cb340 100644 --- a/codebook.toml +++ b/codebook.toml | |||
| @@ -3,10 +3,12 @@ words = [ | |||
| 3 | "aiohttp", | 3 | "aiohttp", |
| 4 | "aiosqlite", | 4 | "aiosqlite", |
| 5 | "asyncio", | 5 | "asyncio", |
| 6 | "clb", | ||
| 6 | "isort", | 7 | "isort", |
| 7 | "pycache", | 8 | "pycache", |
| 8 | "pydantic", | 9 | "pydantic", |
| 9 | "pyright", | 10 | "pyright", |
| 10 | "tablename", | 11 | "tablename", |
| 11 | "venv", | 12 | "venv", |
| 13 | "скриншот", | ||
| 12 | ] | 14 | ] |
diff --git a/handlers/admin/add_user.py b/handlers/admin/add_user.py index c58af2b..1d19834 100644 --- a/handlers/admin/add_user.py +++ b/handlers/admin/add_user.py | |||
| @@ -95,5 +95,5 @@ async def set_vpn_link( | |||
| 95 | session.add(User(id=data.user_id, vpn_link=msg.text, datetime=datetime.now(UTC))) | 95 | session.add(User(id=data.user_id, vpn_link=msg.text, datetime=datetime.now(UTC))) |
| 96 | await session.flush() | 96 | await session.flush() |
| 97 | 97 | ||
| 98 | await msg.answer("Пользователь добавлен.") | 98 | await msg.answer("Пользователь добавлен.", reply_markup=ReplyKeyboardRemove()) |
| 99 | await state.clear() | 99 | await state.clear() |
diff --git a/handlers/user/__init__.py b/handlers/user/__init__.py index ac4c0a3..4b43427 100644 --- a/handlers/user/__init__.py +++ b/handlers/user/__init__.py | |||
| @@ -3,10 +3,12 @@ from aiogram import Router | |||
| 3 | # isort: off | 3 | # isort: off |
| 4 | from . import info | 4 | from . import info |
| 5 | from . import vpn_link | 5 | from . import vpn_link |
| 6 | from . import pay_invoice | ||
| 6 | # isort: on | 7 | # isort: on |
| 7 | 8 | ||
| 8 | router = Router(name="user") | 9 | router = Router(name="user") |
| 9 | router.include_routers( | 10 | router.include_routers( |
| 10 | info.router, | 11 | info.router, |
| 11 | vpn_link.router, | 12 | vpn_link.router, |
| 13 | pay_invoice.router, | ||
| 12 | ) | 14 | ) |
diff --git a/handlers/user/pay_invoice.py b/handlers/user/pay_invoice.py new file mode 100644 index 0000000..98f80a6 --- /dev/null +++ b/handlers/user/pay_invoice.py | |||
| @@ -0,0 +1,160 @@ | |||
| 1 | from datetime import UTC, datetime | ||
| 2 | |||
| 3 | from aiogram import Bot, F, Router | ||
| 4 | from aiogram.enums import ButtonStyle | ||
| 5 | from aiogram.exceptions import TelegramAPIError | ||
| 6 | from aiogram.fsm.context import FSMContext | ||
| 7 | from aiogram.fsm.state import State, StatesGroup | ||
| 8 | from aiogram.types import ( | ||
| 9 | CallbackQuery, | ||
| 10 | InlineKeyboardButton, | ||
| 11 | InlineKeyboardMarkup, | ||
| 12 | KeyboardButton, | ||
| 13 | Message, | ||
| 14 | ReplyKeyboardMarkup, | ||
| 15 | ReplyKeyboardRemove, | ||
| 16 | ) | ||
| 17 | from pydantic import BaseModel | ||
| 18 | from sqlalchemy import and_, select | ||
| 19 | from sqlalchemy.ext.asyncio import AsyncSession | ||
| 20 | |||
| 21 | from libs.fsm import get_data, set_data | ||
| 22 | from libs.user import mention | ||
| 23 | from models import Payment, PaymentStatus, ReceiptFile, ReceiptFileType, User, UserRole | ||
| 24 | from models.callback_data import PayInvoiceClb, PaymentStatusClb | ||
| 25 | |||
| 26 | router = Router(name="pay_invoice") | ||
| 27 | |||
| 28 | |||
| 29 | class PayInvoiceStates(StatesGroup): | ||
| 30 | receipt = State() | ||
| 31 | |||
| 32 | |||
| 33 | class PayInvoiceData(BaseModel): | ||
| 34 | invoice_id: int | ||
| 35 | |||
| 36 | |||
| 37 | CANCEL_BUTTON = "Отмена оплаты" | ||
| 38 | |||
| 39 | |||
| 40 | @router.callback_query(PayInvoiceClb.filter()) | ||
| 41 | async def button( | ||
| 42 | clb: CallbackQuery, | ||
| 43 | bot: Bot, | ||
| 44 | state: FSMContext, | ||
| 45 | callback_data: PayInvoiceClb, | ||
| 46 | session: AsyncSession, | ||
| 47 | ) -> None: | ||
| 48 | payment = await session.scalar( | ||
| 49 | select(Payment).where( | ||
| 50 | and_( | ||
| 51 | Payment.user_id == clb.from_user.id, | ||
| 52 | Payment.invoice_id == callback_data.invoice_id, | ||
| 53 | Payment.status != PaymentStatus.REJECTED, | ||
| 54 | ) | ||
| 55 | ) | ||
| 56 | ) | ||
| 57 | |||
| 58 | if payment is not None: | ||
| 59 | await clb.answer( | ||
| 60 | "Вы уже оплатили данный счёт.", | ||
| 61 | show_alert=True, | ||
| 62 | ) | ||
| 63 | return | ||
| 64 | |||
| 65 | await bot.send_message( | ||
| 66 | clb.from_user.id, | ||
| 67 | "Укажите подтверждение оплаты (скриншот, pdf чека и т.п.)", | ||
| 68 | reply_markup=ReplyKeyboardMarkup( | ||
| 69 | keyboard=[[KeyboardButton(text=CANCEL_BUTTON, style=ButtonStyle.DANGER)]], | ||
| 70 | resize_keyboard=True, | ||
| 71 | ), | ||
| 72 | ) | ||
| 73 | |||
| 74 | await state.set_state(PayInvoiceStates.receipt) | ||
| 75 | await set_data(state, PayInvoiceData(invoice_id=callback_data.invoice_id)) | ||
| 76 | |||
| 77 | await clb.answer() | ||
| 78 | |||
| 79 | |||
| 80 | @router.message(PayInvoiceStates.receipt, F.text == CANCEL_BUTTON) | ||
| 81 | async def cancel(msg: Message, state: FSMContext) -> None: | ||
| 82 | await msg.answer( | ||
| 83 | "Отправка подтверждений оплаты отменена.", | ||
| 84 | reply_markup=ReplyKeyboardRemove(), | ||
| 85 | ) | ||
| 86 | await state.clear() | ||
| 87 | |||
| 88 | |||
| 89 | @router.message(PayInvoiceStates.receipt) | ||
| 90 | async def receipt( | ||
| 91 | msg: Message, | ||
| 92 | bot: Bot, | ||
| 93 | state: FSMContext, | ||
| 94 | session: AsyncSession, | ||
| 95 | ) -> None: | ||
| 96 | if msg.document is not None: | ||
| 97 | receipt_file = ReceiptFile( | ||
| 98 | type=ReceiptFileType.DOCUMENT, | ||
| 99 | file_id=msg.document.file_id, | ||
| 100 | ) | ||
| 101 | elif msg.photo is not None: | ||
| 102 | receipt_file = ReceiptFile( | ||
| 103 | type=ReceiptFileType.PHOTO, | ||
| 104 | file_id=max(msg.photo, key=lambda p: (p.width, p.height)).file_id, | ||
| 105 | ) | ||
| 106 | else: | ||
| 107 | await msg.answer("Вы должны прислать файл или фото.") | ||
| 108 | return | ||
| 109 | |||
| 110 | data = await get_data(state, PayInvoiceData) | ||
| 111 | payment = Payment( | ||
| 112 | user_id=msg.chat.id, | ||
| 113 | invoice_id=data.invoice_id, | ||
| 114 | receipt_file=receipt_file, | ||
| 115 | datetime=datetime.now(UTC), | ||
| 116 | ) | ||
| 117 | session.add(payment) | ||
| 118 | await session.flush() | ||
| 119 | |||
| 120 | await msg.answer( | ||
| 121 | "Файл подтверждения оплаты прикреплен.", | ||
| 122 | reply_markup=ReplyKeyboardRemove(), | ||
| 123 | ) | ||
| 124 | await state.clear() | ||
| 125 | |||
| 126 | admin_ids = await session.scalars( | ||
| 127 | select(User.id).where(User.role == UserRole.ADMIN) | ||
| 128 | ) | ||
| 129 | reply_markup = InlineKeyboardMarkup( | ||
| 130 | inline_keyboard=[ | ||
| 131 | [ | ||
| 132 | InlineKeyboardButton( | ||
| 133 | text="Подтвердить", | ||
| 134 | callback_data=PaymentStatusClb( | ||
| 135 | payment_id=payment.id, | ||
| 136 | payment_status=PaymentStatus.ACCEPTED, | ||
| 137 | ).pack(), | ||
| 138 | style=ButtonStyle.SUCCESS, | ||
| 139 | ), | ||
| 140 | InlineKeyboardButton( | ||
| 141 | text="Отклонить", | ||
| 142 | callback_data=PaymentStatusClb( | ||
| 143 | payment_id=payment.id, | ||
| 144 | payment_status=PaymentStatus.REJECTED, | ||
| 145 | ).pack(), | ||
| 146 | style=ButtonStyle.DANGER, | ||
| 147 | ), | ||
| 148 | ] | ||
| 149 | ] | ||
| 150 | ) | ||
| 151 | |||
| 152 | for admin_id in admin_ids: | ||
| 153 | try: | ||
| 154 | await bot.send_message( | ||
| 155 | admin_id, | ||
| 156 | f"Новое подтверждение оплаты:\nПользователь: {mention(msg.chat)}", | ||
| 157 | ) | ||
| 158 | await receipt_file.send(bot, admin_id, reply_markup=reply_markup) | ||
| 159 | except TelegramAPIError as e: | ||
| 160 | print(e) | ||
diff --git a/libs/__init__.py b/libs/__init__.py index d8ed122..65f8ab7 100644 --- a/libs/__init__.py +++ b/libs/__init__.py | |||
| @@ -1,7 +1,8 @@ | |||
| 1 | from . import fsm, msg, storage | 1 | from . import fsm, msg, storage, user |
| 2 | 2 | ||
| 3 | __all__ = [ | 3 | __all__ = [ |
| 4 | "storage", | 4 | "storage", |
| 5 | "fsm", | 5 | "fsm", |
| 6 | "msg", | 6 | "msg", |
| 7 | "user", | ||
| 7 | ] | 8 | ] |
diff --git a/libs/msg.py b/libs/msg.py index 2e9e16b..a00dd0c 100644 --- a/libs/msg.py +++ b/libs/msg.py | |||
| @@ -7,7 +7,7 @@ from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter | |||
| 7 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup | 7 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup |
| 8 | 8 | ||
| 9 | from models import RichText | 9 | from models import RichText |
| 10 | from models.callback_data import PayInvoiceData | 10 | from models.callback_data import PayInvoiceClb |
| 11 | 11 | ||
| 12 | 12 | ||
| 13 | async def publish_announcement( | 13 | async def publish_announcement( |
| @@ -34,7 +34,7 @@ async def send_invoice( | |||
| 34 | rich_text: RichText, | 34 | rich_text: RichText, |
| 35 | invoice_id: int, | 35 | invoice_id: int, |
| 36 | ) -> AsyncGenerator[int]: | 36 | ) -> AsyncGenerator[int]: |
| 37 | callback_data = PayInvoiceData(invoice_id=invoice_id).pack() | 37 | callback_data = PayInvoiceClb(invoice_id=invoice_id).pack() |
| 38 | reply_markup = InlineKeyboardMarkup( | 38 | reply_markup = InlineKeyboardMarkup( |
| 39 | inline_keyboard=[ | 39 | inline_keyboard=[ |
| 40 | [ | 40 | [ |
diff --git a/libs/user.py b/libs/user.py new file mode 100644 index 0000000..b201ce9 --- /dev/null +++ b/libs/user.py | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | from aiogram.types import Chat, User | ||
| 2 | |||
| 3 | |||
| 4 | def mention(user: User | Chat) -> str: | ||
| 5 | return f'<a href="tg://user?id={user.id}">{user.full_name}</a>' | ||
diff --git a/models/__init__.py b/models/__init__.py index 0547429..f26ee74 100644 --- a/models/__init__.py +++ b/models/__init__.py | |||
| @@ -1,9 +1,9 @@ | |||
| 1 | # isort: off | 1 | # isort: off |
| 2 | from .base import BaseTable | 2 | from .base import BaseTable |
| 3 | from .rich_text import RichText | 3 | from .rich_text import RichText |
| 4 | from .user import User | 4 | from .user import User, UserRole |
| 5 | from .invoce import Invoice | 5 | from .invoce import Invoice |
| 6 | from .payment import Payment | 6 | from .payment import Payment, PaymentStatus, ReceiptFile, ReceiptFileType |
| 7 | from .announcement import Announcement | 7 | from .announcement import Announcement |
| 8 | from . import callback_data | 8 | from . import callback_data |
| 9 | # isort: on | 9 | # isort: on |
| @@ -11,8 +11,12 @@ from . import callback_data | |||
| 11 | __all__ = [ | 11 | __all__ = [ |
| 12 | "BaseTable", | 12 | "BaseTable", |
| 13 | "User", | 13 | "User", |
| 14 | "UserRole", | ||
| 14 | "Invoice", | 15 | "Invoice", |
| 15 | "Payment", | 16 | "Payment", |
| 17 | "PaymentStatus", | ||
| 18 | "ReceiptFile", | ||
| 19 | "ReceiptFileType", | ||
| 16 | "RichText", | 20 | "RichText", |
| 17 | "Announcement", | 21 | "Announcement", |
| 18 | "callback_data", | 22 | "callback_data", |
diff --git a/models/callback_data.py b/models/callback_data.py index d3e6d61..137c4fa 100644 --- a/models/callback_data.py +++ b/models/callback_data.py | |||
| @@ -1,5 +1,12 @@ | |||
| 1 | from aiogram.filters.callback_data import CallbackData | 1 | from aiogram.filters.callback_data import CallbackData |
| 2 | 2 | ||
| 3 | from models import PaymentStatus | ||
| 3 | 4 | ||
| 4 | class PayInvoiceData(CallbackData, prefix="pay_invoice"): | 5 | |
| 6 | class PayInvoiceClb(CallbackData, prefix="pay_invoice"): | ||
| 5 | invoice_id: int | 7 | invoice_id: int |
| 8 | |||
| 9 | |||
| 10 | class PaymentStatusClb(CallbackData, prefix="payment_status"): | ||
| 11 | payment_id: int | ||
| 12 | payment_status: PaymentStatus | ||
diff --git a/models/payment.py b/models/payment.py index 2b1cb90..afae642 100644 --- a/models/payment.py +++ b/models/payment.py | |||
| @@ -1,16 +1,64 @@ | |||
| 1 | from datetime import datetime | 1 | from datetime import datetime |
| 2 | from enum import StrEnum, auto | ||
| 2 | 3 | ||
| 3 | from sqlalchemy import ForeignKey | 4 | from aiogram import Bot |
| 5 | from aiogram.types import Message, ReplyMarkupUnion | ||
| 6 | from pydantic import BaseModel | ||
| 7 | from sqlalchemy import JSON, ForeignKey | ||
| 4 | from sqlalchemy.orm import Mapped, mapped_column | 8 | from sqlalchemy.orm import Mapped, mapped_column |
| 5 | 9 | ||
| 6 | from models import BaseTable, Invoice, User | 10 | from models import BaseTable, Invoice, User |
| 7 | 11 | ||
| 8 | 12 | ||
| 13 | class ReceiptFileType(StrEnum): | ||
| 14 | PHOTO = auto() | ||
| 15 | DOCUMENT = auto() | ||
| 16 | |||
| 17 | |||
| 18 | class ReceiptFile(BaseModel): | ||
| 19 | type: ReceiptFileType | ||
| 20 | file_id: str | ||
| 21 | |||
| 22 | async def send( | ||
| 23 | self, | ||
| 24 | bot: Bot, | ||
| 25 | user_id: int, | ||
| 26 | reply_markup: ReplyMarkupUnion | None = None, | ||
| 27 | ) -> Message: | ||
| 28 | if self.type == ReceiptFileType.DOCUMENT: | ||
| 29 | return await bot.send_document( | ||
| 30 | user_id, | ||
| 31 | document=self.file_id, | ||
| 32 | reply_markup=reply_markup, | ||
| 33 | ) | ||
| 34 | else: | ||
| 35 | return await bot.send_photo( | ||
| 36 | user_id, | ||
| 37 | photo=self.file_id, | ||
| 38 | reply_markup=reply_markup, | ||
| 39 | ) | ||
| 40 | |||
| 41 | |||
| 42 | class PaymentStatus(StrEnum): | ||
| 43 | PENDING = auto() | ||
| 44 | ACCEPTED = auto() | ||
| 45 | REJECTED = auto() | ||
| 46 | |||
| 47 | |||
| 9 | class Payment(BaseTable): | 48 | class Payment(BaseTable): |
| 10 | __tablename__ = "payment" | 49 | __tablename__ = "payment" |
| 11 | 50 | ||
| 12 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) | 51 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) |
| 13 | user_id: Mapped[int] = mapped_column(ForeignKey(User.id)) | 52 | user_id: Mapped[int] = mapped_column(ForeignKey(User.id)) |
| 14 | invoice_id: Mapped[int] = mapped_column(ForeignKey(Invoice.id)) | 53 | invoice_id: Mapped[int] = mapped_column(ForeignKey(Invoice.id)) |
| 15 | receipt_file_id: Mapped[str] | 54 | __receipt_file: Mapped[str] = mapped_column("receipt_file", JSON()) |
| 55 | status: Mapped[PaymentStatus] = mapped_column(default=PaymentStatus.PENDING) | ||
| 16 | datetime: Mapped[datetime] | 56 | datetime: Mapped[datetime] |
| 57 | |||
| 58 | @property | ||
| 59 | def receipt_file(self) -> ReceiptFile: | ||
| 60 | return ReceiptFile.model_validate_json(self.__receipt_file) | ||
| 61 | |||
| 62 | @receipt_file.setter | ||
| 63 | def receipt_file(self, value: ReceiptFile) -> None: | ||
| 64 | self.__receipt_file = value.model_dump_json() | ||
diff --git a/models/suggest.py b/models/suggest.py index a76a004..bd628bb 100644 --- a/models/suggest.py +++ b/models/suggest.py | |||
| @@ -4,8 +4,7 @@ from sqlalchemy import JSON | |||
| 4 | from sqlalchemy.orm import Mapped, mapped_column | 4 | from sqlalchemy.orm import Mapped, mapped_column |
| 5 | from sqlalchemy.sql.schema import ForeignKey | 5 | from sqlalchemy.sql.schema import ForeignKey |
| 6 | 6 | ||
| 7 | from models import RichText, User | 7 | from models import BaseTable, RichText, User |
| 8 | from models.base import BaseTable | ||
| 9 | 8 | ||
| 10 | 9 | ||
| 11 | class Suggest(BaseTable): | 10 | class Suggest(BaseTable): |
