aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--alembic/versions/c0c4d0fbcee2_init_database.py (renamed from alembic/versions/1627487324fd_init_database.py)13
-rw-r--r--codebook.toml2
-rw-r--r--handlers/admin/add_user.py2
-rw-r--r--handlers/user/__init__.py2
-rw-r--r--handlers/user/pay_invoice.py160
-rw-r--r--libs/__init__.py3
-rw-r--r--libs/msg.py4
-rw-r--r--libs/user.py5
-rw-r--r--models/__init__.py8
-rw-r--r--models/callback_data.py9
-rw-r--r--models/payment.py52
-rw-r--r--models/suggest.py3
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
3Revision ID: 1627487324fd 3Revision ID: c0c4d0fbcee2
4Revises: 4Revises:
5Create Date: 2026-03-23 18:33:08.493629 5Create Date: 2026-03-23 21:19:28.195907
6 6
7""" 7"""
8 8
@@ -13,7 +13,7 @@ import sqlalchemy as sa
13from alembic import op 13from alembic import op
14 14
15# revision identifiers, used by Alembic. 15# revision identifiers, used by Alembic.
16revision: str = "1627487324fd" 16revision: str = "c0c4d0fbcee2"
17down_revision: Union[str, Sequence[str], None] = None 17down_revision: Union[str, Sequence[str], None] = None
18branch_labels: Union[str, Sequence[str], None] = None 18branch_labels: Union[str, Sequence[str], None] = None
19depends_on: Union[str, Sequence[str], None] = None 19depends_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
4from . import info 4from . import info
5from . import vpn_link 5from . import vpn_link
6from . import pay_invoice
6# isort: on 7# isort: on
7 8
8router = Router(name="user") 9router = Router(name="user")
9router.include_routers( 10router.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 @@
1from datetime import UTC, datetime
2
3from aiogram import Bot, F, Router
4from aiogram.enums import ButtonStyle
5from aiogram.exceptions import TelegramAPIError
6from aiogram.fsm.context import FSMContext
7from aiogram.fsm.state import State, StatesGroup
8from aiogram.types import (
9 CallbackQuery,
10 InlineKeyboardButton,
11 InlineKeyboardMarkup,
12 KeyboardButton,
13 Message,
14 ReplyKeyboardMarkup,
15 ReplyKeyboardRemove,
16)
17from pydantic import BaseModel
18from sqlalchemy import and_, select
19from sqlalchemy.ext.asyncio import AsyncSession
20
21from libs.fsm import get_data, set_data
22from libs.user import mention
23from models import Payment, PaymentStatus, ReceiptFile, ReceiptFileType, User, UserRole
24from models.callback_data import PayInvoiceClb, PaymentStatusClb
25
26router = Router(name="pay_invoice")
27
28
29class PayInvoiceStates(StatesGroup):
30 receipt = State()
31
32
33class PayInvoiceData(BaseModel):
34 invoice_id: int
35
36
37CANCEL_BUTTON = "Отмена оплаты"
38
39
40@router.callback_query(PayInvoiceClb.filter())
41async 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)
81async 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)
90async 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 @@
1from . import fsm, msg, storage 1from . 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
7from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 7from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
8 8
9from models import RichText 9from models import RichText
10from models.callback_data import PayInvoiceData 10from models.callback_data import PayInvoiceClb
11 11
12 12
13async def publish_announcement( 13async 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 @@
1from aiogram.types import Chat, User
2
3
4def 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
2from .base import BaseTable 2from .base import BaseTable
3from .rich_text import RichText 3from .rich_text import RichText
4from .user import User 4from .user import User, UserRole
5from .invoce import Invoice 5from .invoce import Invoice
6from .payment import Payment 6from .payment import Payment, PaymentStatus, ReceiptFile, ReceiptFileType
7from .announcement import Announcement 7from .announcement import Announcement
8from . import callback_data 8from . 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 @@
1from aiogram.filters.callback_data import CallbackData 1from aiogram.filters.callback_data import CallbackData
2 2
3from models import PaymentStatus
3 4
4class PayInvoiceData(CallbackData, prefix="pay_invoice"): 5
6class PayInvoiceClb(CallbackData, prefix="pay_invoice"):
5 invoice_id: int 7 invoice_id: int
8
9
10class 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 @@
1from datetime import datetime 1from datetime import datetime
2from enum import StrEnum, auto
2 3
3from sqlalchemy import ForeignKey 4from aiogram import Bot
5from aiogram.types import Message, ReplyMarkupUnion
6from pydantic import BaseModel
7from sqlalchemy import JSON, ForeignKey
4from sqlalchemy.orm import Mapped, mapped_column 8from sqlalchemy.orm import Mapped, mapped_column
5 9
6from models import BaseTable, Invoice, User 10from models import BaseTable, Invoice, User
7 11
8 12
13class ReceiptFileType(StrEnum):
14 PHOTO = auto()
15 DOCUMENT = auto()
16
17
18class 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
42class PaymentStatus(StrEnum):
43 PENDING = auto()
44 ACCEPTED = auto()
45 REJECTED = auto()
46
47
9class Payment(BaseTable): 48class 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
4from sqlalchemy.orm import Mapped, mapped_column 4from sqlalchemy.orm import Mapped, mapped_column
5from sqlalchemy.sql.schema import ForeignKey 5from sqlalchemy.sql.schema import ForeignKey
6 6
7from models import RichText, User 7from models import BaseTable, RichText, User
8from models.base import BaseTable
9 8
10 9
11class Suggest(BaseTable): 10class Suggest(BaseTable):