Initial local state

This commit is contained in:
2026-07-15 07:17:25 +05:00
commit 68221821a5
78 changed files with 10318 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
from .db_category import CategoryModel, Categoryx
from .db_item import ItemModel, Itemx
from .db_payments import PaymentsModel, Paymentsx
from .db_position import PositionModel, Positionx
from .db_purchases import PurchaseModel, Purchasesx
from .db_refill import RefillModel, Refillx
from .db_settings import SettingsModel, Settingsx
from .db_users import UserModel, UsersRepository, Userx
from .entities import Category, Item, Payments, Position, Purchase, Refill, Settings, User
ModelCategory = Category
ModelItem = Item
ModelPayments = Payments
ModelPosition = Position
ModelPurchases = Purchase
ModelRefill = Refill
ModelSettings = Settings
ModelUser = User
ModelUsers = User
SettingsRepository = Settingsx
+47
View File
@@ -0,0 +1,47 @@
# - *- coding: utf- 8 - *-
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from tgbot.data.config import PATH_DATABASE
database_path = Path(PATH_DATABASE)
database_url = f"sqlite+aiosqlite:///{database_path.as_posix()}"
engine = create_async_engine(database_url, echo=False)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
# Общая база для всех SQLAlchemy-моделей
class Base(AsyncAttrs, DeclarativeBase):
pass
# Включение SQLite-настроек на каждом соединении
@event.listens_for(engine.sync_engine, "connect")
def _configure_sqlite(dbapi_connection, connection_record) -> None:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
# Открытие сессии с автокоммитом или откатом
@asynccontextmanager
async def session_scope() -> AsyncIterator[AsyncSession]:
async with session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
# Закрытие пула соединений БД
async def close_database() -> None:
await engine.dispose()
+47
View File
@@ -0,0 +1,47 @@
# - *- coding: utf- 8 - *-
from typing import Any, Dict, Optional, Union
from sqlalchemy import BigInteger, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from tgbot.database.core import Base
from tgbot.database.entities import Category
from tgbot.database.repository import BaseRepository
from tgbot.utils.const_functions import get_unix
class CategoryModel(Base):
__tablename__ = "storage_category"
increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
category_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
category_name: Mapped[str] = mapped_column(String(255), nullable=False)
category_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix)
ModelBase = Category
BaseModel = Category
class Categoryx(BaseRepository[CategoryModel, Category]):
# Подключение модели категорий
def __init__(self):
super().__init__()
self.table_model = CategoryModel
self.entity_model = Category
self.storage_name = CategoryModel.__tablename__
# Добавление категории товара
async def add(self, category_id: int, category_name: str) -> Category:
return await self._insert(
category_id=category_id,
category_name=category_name,
category_unix=get_unix(),
)
# Обновление категории по ID или фильтру
async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int:
if isinstance(where, int):
where = {"category_id": where}
return await self._update(where=where, **kwargs)
+73
View File
@@ -0,0 +1,73 @@
# - *- coding: utf- 8 - *-
from typing import Any, Dict, List, Optional, Union
from sqlalchemy import BigInteger, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from tgbot.database.core import Base, session_scope
from tgbot.database.entities import Item
from tgbot.database.repository import BaseRepository
from tgbot.utils.const_functions import clear_list, gen_id, get_unix
class ItemModel(Base):
__tablename__ = "storage_item"
increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
category_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
position_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
item_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
item_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix)
item_data: Mapped[str] = mapped_column(Text, nullable=False)
ModelBase = Item
BaseModel = Item
class Itemx(BaseRepository[ItemModel, Item]):
# Подключение модели товаров
def __init__(self):
super().__init__()
self.table_model = ItemModel
self.entity_model = Item
self.storage_name = ItemModel.__tablename__
# Добавление товаров пачкой
async def add(
self,
user_id: int,
category_id: int,
position_id: int,
item_datas: List[str],
) -> Optional[List[Item]]:
item_unix = get_unix()
item_datas = clear_list(item_datas)
if len(item_datas) == 0:
return None
rows = [
ItemModel(
user_id=user_id,
category_id=category_id,
position_id=position_id,
item_id=gen_id(17),
item_unix=item_unix,
item_data=item_data.strip(),
)
for item_data in item_datas
]
async with session_scope() as session:
session.add_all(rows)
return [self._to_entity(row) for row in rows]
# Обновление товара по ID или фильтру
async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int:
if isinstance(where, int):
where = {"item_id": where}
return await self._update(where=where, **kwargs)
+64
View File
@@ -0,0 +1,64 @@
# - *- coding: utf- 8 - *-
from typing import Any, Dict, Optional
from sqlalchemy import Integer, String, Float
from sqlalchemy.orm import Mapped, mapped_column
from tgbot.database.core import Base
from tgbot.database.entities import Payments
from tgbot.database.repository import BaseRepository
class PaymentsModel(Base):
__tablename__ = "storage_payments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
cryptobot_token: Mapped[str] = mapped_column(String, nullable=False, default="None")
yoomoney_token: Mapped[str] = mapped_column(String, nullable=False, default="None")
stars_course: Mapped[float] = mapped_column(Float, nullable=False, default=1.5)
status_cryptobot: Mapped[str] = mapped_column(String(16), nullable=False, default="False")
status_yoomoney: Mapped[str] = mapped_column(String(16), nullable=False, default="False")
status_stars: Mapped[str] = mapped_column(String(16), nullable=False, default="False")
ModelBase = Payments
BaseModel = Payments
class Paymentsx(BaseRepository[PaymentsModel, Payments]):
# Подключение модели платежных настроек
def __init__(self):
super().__init__()
self.table_model = PaymentsModel
self.entity_model = Payments
self.storage_name = PaymentsModel.__tablename__
# Создание дефолтной строки платежей
async def ensure_default(self) -> None:
payments = await BaseRepository.get(self, id=1)
if payments is None:
await self._insert(id=1)
# Получение платежных настроек
async def get(self, **kwargs) -> Payments:
if not kwargs:
kwargs = {"id": 1}
payments = await BaseRepository.get(self, **kwargs)
if payments is None and kwargs == {"id": 1}:
await self.ensure_default()
payments = await BaseRepository.get(self, id=1)
if payments is None:
raise RuntimeError("Настройки платежных систем по умолчанию не сохранились")
return payments
# Обновление платежных настроек
async def update(self, where: Optional[Dict[str, Any]] = None, **kwargs) -> int:
if where is None:
where = {"id": 1}
return await self._update(where=where, **kwargs)
+79
View File
@@ -0,0 +1,79 @@
# - *- coding: utf- 8 - *-
from typing import Any, Dict, Optional, Union
from sqlalchemy import BigInteger, Float, Integer, String, Text, func, select
from sqlalchemy.orm import Mapped, mapped_column
from tgbot.database.core import Base, session_scope
from tgbot.database.entities import Position
from tgbot.database.repository import BaseRepository
from tgbot.utils.const_functions import get_unix
class PositionModel(Base):
__tablename__ = "storage_position"
increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
category_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
position_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
position_name: Mapped[str] = mapped_column(String(255), nullable=False)
position_price: Mapped[float] = mapped_column(Float, nullable=False, default=0)
position_desc: Mapped[str] = mapped_column(Text, nullable=False, default="None")
position_photo: Mapped[str] = mapped_column(Text, nullable=False, default="None")
position_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix)
ModelBase = Position
BaseModel = Position
class Positionx(BaseRepository[PositionModel, Position]):
# Подключение модели позиций
def __init__(self):
super().__init__()
self.table_model = PositionModel
self.entity_model = Position
self.storage_name = PositionModel.__tablename__
# Добавление позиции товара
async def add(
self,
category_id: int,
position_id: int,
position_name: str,
position_price: float,
position_desc: str,
position_photo: str,
) -> Position:
return await self._insert(
category_id=category_id,
position_id=position_id,
position_name=position_name,
position_price=position_price,
position_desc=position_desc,
position_photo=position_photo,
position_unix=get_unix(),
)
# Обновление позиции по ID или фильтру
async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int:
if isinstance(where, int):
where = {"position_id": where}
return await self._update(where=where, **kwargs)
# Получение остатков товаров по позициям
@staticmethod
async def item_counts() -> Dict[int, int]:
from tgbot.database.db_item import ItemModel
item_table = ItemModel.__table__
statement = (
select(item_table.c.position_id, func.count(item_table.c.increment).label("item_count"))
.group_by(item_table.c.position_id)
)
async with session_scope() as session:
rows = await session.execute(statement)
return {int(position_id): int(item_count) for position_id, item_count in rows.all()}
+331
View File
@@ -0,0 +1,331 @@
# - *- coding: utf- 8 - *-
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Union
from sqlalchemy import BigInteger, Float, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from tgbot.database.core import Base, session_factory
from tgbot.database.entities import Purchase
from tgbot.database.repository import BaseRepository
from tgbot.utils.const_functions import gen_id, get_unix
TELEGRAM_LIMIT = 4000
@dataclass
class PurchaseResult:
receipt: int
items: List[List[str]]
purchase_count: int
purchase_unix: int
purchase_price: float
new_balance: float
position_name: str
class PurchaseModel(Base):
__tablename__ = "storage_purchases"
increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
user_balance_before: Mapped[float] = mapped_column(Float, nullable=False, default=0)
user_balance_after: Mapped[float] = mapped_column(Float, nullable=False, default=0)
purchase_receipt: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
purchase_data: Mapped[str] = mapped_column(Text, nullable=False)
purchase_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
purchase_price: Mapped[float] = mapped_column(Float, nullable=False, default=0)
purchase_price_one: Mapped[float] = mapped_column(Float, nullable=False, default=0)
purchase_position_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
purchase_position_name: Mapped[str] = mapped_column(String(255), nullable=False)
purchase_category_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
purchase_category_name: Mapped[str] = mapped_column(String(255), nullable=False)
purchase_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix)
ModelBase = Purchase
BaseModel = Purchase
class Purchasesx(BaseRepository[PurchaseModel, Purchase]):
# Подключение модели покупок
def __init__(self):
super().__init__()
self.table_model = PurchaseModel
self.entity_model = Purchase
self.storage_name = PurchaseModel.__tablename__
# Добавление записи покупки
async def add(
self,
user_id: int,
user_balance_before: float,
user_balance_after: float,
purchase_receipt: int,
purchase_data: str,
purchase_count: int,
purchase_price: float,
purchase_price_one: float,
purchase_position_id: int,
purchase_position_name: str,
purchase_category_id: int,
purchase_category_name: str,
) -> Purchase:
return await self._insert(
user_id=user_id,
user_balance_before=user_balance_before,
user_balance_after=user_balance_after,
purchase_receipt=purchase_receipt,
purchase_data=purchase_data,
purchase_count=purchase_count,
purchase_price=purchase_price,
purchase_price_one=purchase_price_one,
purchase_position_id=purchase_position_id,
purchase_position_name=purchase_position_name,
purchase_category_id=purchase_category_id,
purchase_category_name=purchase_category_name,
purchase_unix=get_unix(),
)
# Обновление покупки по чеку или фильтру
async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int:
if isinstance(where, int):
where = {"purchase_receipt": where}
return await self._update(where=where, **kwargs)
# Атомарная покупка товаров
@staticmethod
async def buy(*, user_id: int, position_id: int, requested_count: int) -> Union[PurchaseResult, str]:
async with session_factory() as session:
try:
await session.execute(text("BEGIN IMMEDIATE"))
user_result = await session.execute(
text(
"""
SELECT user_balance
FROM storage_users
WHERE user_id = :user_id
"""
),
{"user_id": user_id},
)
get_user = user_result.mappings().first()
if get_user is None:
await session.rollback()
return "USER_NOT_FOUND"
position_result = await session.execute(
text(
"""
SELECT position_price, position_name, category_id
FROM storage_position
WHERE position_id = :position_id
"""
),
{"position_id": position_id},
)
get_position = position_result.mappings().first()
if get_position is None:
await session.rollback()
return "POSITION_NOT_FOUND"
items_result = await session.execute(
text(
"""
SELECT increment, item_id, item_data
FROM storage_item
WHERE position_id = :position_id
ORDER BY increment
LIMIT :requested_count
"""
),
{"position_id": position_id, "requested_count": requested_count},
)
items = items_result.mappings().all()
if len(items) < requested_count:
await session.rollback()
return "NOT_ENOUGH_ITEMS"
purchase_price = round(float(get_position["position_price"]) * requested_count, 2)
if float(get_user["user_balance"]) < purchase_price:
await session.rollback()
return "NOT_ENOUGH_BALANCE"
item_ids = [row["item_id"] for row in items]
delete_params = {f"item_id_{index}": item_id for index, item_id in enumerate(item_ids)}
delete_marks = ", ".join(f":item_id_{index}" for index in range(len(item_ids)))
await session.execute(
text(f"DELETE FROM storage_item WHERE item_id IN ({delete_marks})"),
delete_params,
)
new_balance = round(float(get_user["user_balance"]) - purchase_price, 2)
await session.execute(
text(
"""
UPDATE storage_users
SET user_balance = :new_balance
WHERE user_id = :user_id
"""
),
{"new_balance": new_balance, "user_id": user_id},
)
receipt = gen_id()
purchase_data = "\n".join([row["item_data"] for row in items])
purchase_unix = get_unix()
category_result = await session.execute(
text("SELECT category_name FROM storage_category WHERE category_id = :category_id"),
{"category_id": get_position["category_id"]},
)
category = category_result.mappings().first()
category_name = category["category_name"] if category else "Unknown"
await session.execute(
text(
"""
INSERT INTO storage_purchases (user_id,
user_balance_before,
user_balance_after,
purchase_receipt,
purchase_data,
purchase_count,
purchase_price,
purchase_price_one,
purchase_position_id,
purchase_position_name,
purchase_category_id,
purchase_category_name,
purchase_unix)
VALUES (:user_id,
:balance_before,
:balance_after,
:receipt,
:purchase_data,
:purchase_count,
:purchase_price,
:purchase_price_one,
:position_id,
:position_name,
:category_id,
:category_name,
:purchase_unix)
"""
),
{
"user_id": user_id,
"balance_before": float(get_user["user_balance"]),
"balance_after": new_balance,
"receipt": receipt,
"purchase_data": purchase_data,
"purchase_count": requested_count,
"purchase_price": purchase_price,
"purchase_price_one": float(get_position["position_price"]),
"position_id": position_id,
"position_name": get_position["position_name"],
"category_id": get_position["category_id"],
"category_name": category_name,
"purchase_unix": purchase_unix,
},
)
await session.commit()
except Exception:
await session.rollback()
raise
purchase_items = [row["item_data"] for row in items]
purchase_items_parts = chunk_items_by_len(purchase_items)
return PurchaseResult(
receipt=receipt,
items=purchase_items_parts,
purchase_count=requested_count,
purchase_unix=purchase_unix,
purchase_price=purchase_price,
position_name=get_position["position_name"],
new_balance=new_balance,
)
# Разделение товаров на сообщения под лимит телеграма
def chunk_items_by_len(items: List[str], limit: int = TELEGRAM_LIMIT, separator: str = "\n\n") -> List[List[str]]:
chunks: List[List[str]] = []
current_chunk: List[str] = []
current_len = 0
sep_len = len(separator)
for raw_item in items:
item = str(raw_item)
if len(item) > limit:
if current_chunk:
chunks.append(current_chunk)
current_chunk = []
current_len = 0
for part in split_long_item(item, limit):
chunks.append([part])
continue
add_len = len(item) if not current_chunk else sep_len + len(item)
if current_len + add_len <= limit:
current_chunk.append(item)
current_len += add_len
else:
chunks.append(current_chunk)
current_chunk = [item]
current_len = len(item)
if current_chunk:
chunks.append(current_chunk)
return chunks
# Разделение длинного товара без разрыва строк
def split_long_item(item: str, limit: int = TELEGRAM_LIMIT) -> List[str]:
chunks: List[str] = []
current_lines: List[str] = []
current_len = 0
for line in item.splitlines():
line_len = len(line)
add_len = line_len if not current_lines else 1 + line_len
if current_lines and current_len + add_len > limit:
chunks.append("\n".join(current_lines))
current_lines = []
current_len = 0
add_len = line_len
if line_len > limit:
if current_lines:
chunks.append("\n".join(current_lines))
current_lines = []
current_len = 0
start = 0
while start < line_len:
chunks.append(line[start:start + limit])
start += limit
continue
current_lines.append(line)
current_len += add_len
if current_lines:
chunks.append("\n".join(current_lines))
return chunks
+146
View File
@@ -0,0 +1,146 @@
# - *- coding: utf- 8 - *-
from typing import Any, Dict, Optional, Union
from sqlalchemy import BigInteger, Float, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from tgbot.database.core import Base, session_factory
from tgbot.database.entities import Refill
from tgbot.database.repository import BaseRepository
from tgbot.utils.const_functions import get_unix
class RefillModel(Base):
__tablename__ = "storage_refill"
increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
refill_receipt: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
refill_comment: Mapped[str] = mapped_column(String(255), nullable=False, default="")
refill_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
refill_method: Mapped[str] = mapped_column(String(64), nullable=False)
refill_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix)
ModelBase = Refill
BaseModel = Refill
class Refillx(BaseRepository[RefillModel, Refill]):
# Подключение модели пополнений
def __init__(self):
super().__init__()
self.table_model = RefillModel
self.entity_model = Refill
self.storage_name = RefillModel.__tablename__
# Добавление записи пополнения
async def add(
self,
user_id: int,
refill_receipt: int,
refill_comment: str,
refill_amount: float,
refill_method: str,
) -> Refill:
return await self._insert(
user_id=user_id,
refill_comment=refill_comment,
refill_amount=refill_amount,
refill_receipt=refill_receipt,
refill_method=refill_method,
refill_unix=get_unix(),
)
# Обновление пополнения по чеку или фильтру
async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int:
if isinstance(where, int):
where = {"refill_receipt": where}
return await self._update(where=where, **kwargs)
# Успешное зачисление пополнения
@staticmethod
async def success(
user_id: int,
pay_receipt: int,
pay_comment: str,
pay_amount: float,
pay_method: str,
) -> str:
async with session_factory() as session:
try:
await session.execute(text("BEGIN IMMEDIATE"))
if pay_comment != "":
duplicate_refill = await session.execute(
text("SELECT increment FROM storage_refill WHERE refill_comment = :comment LIMIT 1"),
{"comment": pay_comment},
)
else:
duplicate_refill = await session.execute(
text("SELECT increment FROM storage_refill WHERE refill_receipt = :receipt LIMIT 1"),
{"receipt": pay_receipt},
)
if duplicate_refill.mappings().first() is not None:
await session.rollback()
return "ALREADY"
user_result = await session.execute(
text(
"""
SELECT user_balance, user_refill
FROM storage_users
WHERE user_id = :user_id
"""
),
{"user_id": user_id},
)
get_user = user_result.mappings().first()
if get_user is None:
await session.rollback()
return "USER_NOT_FOUND"
new_balance = round(float(get_user["user_balance"]) + float(pay_amount), 2)
new_refill = round(float(get_user["user_refill"]) + float(pay_amount), 2)
await session.execute(
text(
"""
INSERT INTO storage_refill (user_id,
refill_receipt,
refill_comment,
refill_amount,
refill_method,
refill_unix)
VALUES (:user_id, :receipt, :comment, :amount, :method, :unix)
"""
),
{
"user_id": user_id,
"receipt": pay_receipt,
"comment": pay_comment,
"amount": pay_amount,
"method": pay_method,
"unix": get_unix(),
},
)
await session.execute(
text(
"""
UPDATE storage_users
SET user_balance = :balance,
user_refill = :refill
WHERE user_id = :user_id
"""
),
{"balance": new_balance, "refill": new_refill, "user_id": user_id},
)
await session.commit()
except Exception:
await session.rollback()
raise
return "ok"
+76
View File
@@ -0,0 +1,76 @@
# - *- coding: utf- 8 - *-
from typing import Any, Dict, Optional
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from tgbot.database.core import Base
from tgbot.database.entities import Settings
from tgbot.database.repository import BaseRepository
class SettingsModel(Base):
__tablename__ = "storage_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
status_work: Mapped[str] = mapped_column(String(16), nullable=False, default="True")
status_refill: Mapped[str] = mapped_column(String(16), nullable=False, default="False")
status_buy: Mapped[str] = mapped_column(String(16), nullable=False, default="False")
notification_refill: Mapped[str] = mapped_column(String(16), nullable=False, default="True")
notification_buy: Mapped[str] = mapped_column(String(16), nullable=False, default="False")
misc_faq: Mapped[str] = mapped_column(String, nullable=False, default="None")
misc_support: Mapped[str] = mapped_column(String, nullable=False, default="None")
misc_bot: Mapped[str] = mapped_column(String(255), nullable=False, default="None")
misc_hosting_text: Mapped[str] = mapped_column(String(64), nullable=False, default="telegraph")
misc_token_telegraph: Mapped[str] = mapped_column(String, nullable=False, default="None")
misc_discord_webhook_url: Mapped[str] = mapped_column(String, nullable=False, default="None")
misc_discord_webhook_name: Mapped[str] = mapped_column(String(255), nullable=False, default="None")
misc_hide_category: Mapped[str] = mapped_column(String(16), nullable=False, default="False")
misc_hide_position: Mapped[str] = mapped_column(String(16), nullable=False, default="False")
misc_method_prod: Mapped[str] = mapped_column(String(16), nullable=False, default="skip")
misc_profit_day: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
misc_profit_week: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
misc_profit_month: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
ModelBase = Settings
BaseModel = Settings
class Settingsx(BaseRepository[SettingsModel, Settings]):
# Подключение модели настроек
def __init__(self):
super().__init__()
self.table_model = SettingsModel
self.entity_model = Settings
self.storage_name = SettingsModel.__tablename__
# Создание дефолтной строки настроек
async def ensure_default(self) -> None:
settings = await BaseRepository.get(self, id=1)
if settings is None:
await self._insert(id=1)
# Получение настроек бота
async def get(self, **kwargs) -> Settings:
if not kwargs:
kwargs = {"id": 1}
settings = await BaseRepository.get(self, **kwargs)
if settings is None and kwargs == {"id": 1}:
await self.ensure_default()
settings = await BaseRepository.get(self, id=1)
if settings is None:
raise RuntimeError("Настройки бота по умолчанию не сохранились")
return settings
# Обновление настроек бота
async def update(self, where: Optional[Dict[str, Any]] = None, **kwargs) -> int:
if where is None:
where = {"id": 1}
return await self._update(where=where, **kwargs)
+128
View File
@@ -0,0 +1,128 @@
# - *- coding: utf- 8 - *-
from typing import Any, Dict, Optional, Union
from sqlalchemy import BigInteger, Float, Integer, String, or_
from sqlalchemy import update as sqlalchemy_update
from sqlalchemy.dialects.sqlite import insert
from sqlalchemy.orm import Mapped, mapped_column
from tgbot.database.core import Base, session_scope
from tgbot.database.entities import User
from tgbot.database.repository import BaseRepository
from tgbot.utils.const_functions import get_unix
class UserModel(Base):
__tablename__ = "storage_users"
increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
user_login: Mapped[str] = mapped_column(String(255), nullable=False, default="")
user_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
user_surname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
user_fullname: Mapped[str] = mapped_column(String(511), nullable=False, default="")
user_balance: Mapped[float] = mapped_column(Float, nullable=False, default=0)
user_refill: Mapped[float] = mapped_column(Float, nullable=False, default=0)
user_give: Mapped[float] = mapped_column(Float, nullable=False, default=0)
user_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix)
ModelBase = User
BaseModel = User
class UsersRepository(BaseRepository[UserModel, User]):
# Подключение модели пользователей
def __init__(self):
super().__init__()
self.table_model = UserModel
self.entity_model = User
self.storage_name = UserModel.__tablename__
# Добавление или обновление пользователя
async def add(
self,
user_id: int,
user_login: str,
user_name: str,
user_surname: str = "",
user_fullname: str = "",
) -> User:
return await self.upsert(
user_id=user_id,
user_login=user_login,
user_name=user_name,
user_surname=user_surname,
user_fullname=user_fullname or " ".join(filter(None, [user_name, user_surname])),
)
# Выполнение upsert пользователя по телеграм ID
async def upsert(
self,
user_id: int,
user_login: str,
user_name: str,
user_surname: str = "",
user_fullname: str = "",
) -> User:
user_fullname = user_fullname or " ".join(filter(None, [user_name, user_surname]))
user_table = UserModel.__table__
statement = insert(UserModel).values(
user_id=user_id,
user_login=(user_login or "").lower(),
user_name=user_name or "",
user_surname=user_surname or "",
user_fullname=user_fullname or "",
user_balance=0,
user_refill=0,
user_give=0,
user_unix=get_unix(),
)
statement = statement.on_conflict_do_update(
index_elements=[user_table.c.user_id],
set_={
"user_login": statement.excluded.user_login,
"user_name": statement.excluded.user_name,
"user_surname": statement.excluded.user_surname,
"user_fullname": statement.excluded.user_fullname,
},
where=or_(
user_table.c.user_login != statement.excluded.user_login,
user_table.c.user_name != statement.excluded.user_name,
user_table.c.user_surname != statement.excluded.user_surname,
user_table.c.user_fullname != statement.excluded.user_fullname,
),
)
async with session_scope() as session:
await session.execute(statement)
user = await self.get(user_id=user_id)
if user is None:
raise RuntimeError("Пользователь не сохранился")
return user
# Обновление пользователя по ID или фильтру
async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int:
if isinstance(where, int):
where = {"user_id": where}
return await self._update(where=where, **kwargs)
# Обновление пользователя по телеграм ID
@staticmethod
async def update_by_user_id(user_id: int, **kwargs) -> None:
if not kwargs:
return
async with session_scope() as session:
await session.execute(
sqlalchemy_update(UserModel)
.where(UserModel.__table__.c.user_id == user_id)
.values(**kwargs)
)
Userx = UsersRepository
+110
View File
@@ -0,0 +1,110 @@
# - *- coding: utf- 8 - *-
from dataclasses import dataclass
@dataclass
class Category:
increment: int
category_id: int
category_name: str
category_unix: int
@dataclass
class Position:
increment: int
category_id: int
position_id: int
position_name: str
position_price: float
position_desc: str
position_photo: str
position_unix: int
@dataclass
class Item:
increment: int
user_id: int
category_id: int
position_id: int
item_id: int
item_unix: int
item_data: str
@dataclass
class Payments:
id: int
cryptobot_token: str
yoomoney_token: str
stars_course: float
status_cryptobot: str
status_yoomoney: str
status_stars: str
@dataclass
class Purchase:
increment: int
user_id: int
user_balance_before: float
user_balance_after: float
purchase_receipt: int
purchase_data: str
purchase_count: int
purchase_price: float
purchase_price_one: float
purchase_position_id: int
purchase_position_name: str
purchase_category_id: int
purchase_category_name: str
purchase_unix: int
@dataclass
class Refill:
increment: int
user_id: int
refill_receipt: int
refill_comment: str
refill_amount: float
refill_method: str
refill_unix: int
@dataclass
class Settings:
id: int
status_work: str
status_refill: str
status_buy: str
notification_refill: str
notification_buy: str
misc_faq: str
misc_support: str
misc_bot: str
misc_hosting_text: str
misc_token_telegraph: str
misc_discord_webhook_url: str
misc_discord_webhook_name: str
misc_hide_category: str
misc_hide_position: str
misc_method_prod: str
misc_profit_day: int
misc_profit_week: int
misc_profit_month: int
@dataclass
class User:
increment: int
user_id: int
user_login: str
user_name: str
user_surname: str
user_fullname: str
user_balance: float
user_refill: float
user_give: float
user_unix: int
+55
View File
@@ -0,0 +1,55 @@
# - *- coding: utf- 8 - *-
import asyncio
from pathlib import Path
from typing import Optional
from alembic import command
from alembic.config import Config
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine
from tgbot.database.core import database_url
PROJECT_ROOT = Path(__file__).resolve().parents[2]
ALEMBIC_INI = PROJECT_ROOT / "alembic.ini"
MIGRATIONS_DIR = PROJECT_ROOT / "migrations"
# Сбор Alembic-конфига от корня проекта
def get_alembic_config(url: str = database_url) -> Config:
config = Config(str(ALEMBIC_INI))
config.set_main_option("script_location", str(MIGRATIONS_DIR))
config.set_main_option("sqlalchemy.url", url)
return config
# Применение миграций до последней версии
async def run_migrations(engine: Optional[AsyncEngine] = None) -> None:
if engine is None:
config = get_alembic_config()
await asyncio.to_thread(command.upgrade, config, "head")
return
config = get_alembic_config(str(engine.url))
connection = engine.connect()
await connection.start()
transaction = connection.begin()
await transaction.start()
try:
await connection.run_sync(_upgrade_with_connection, config)
except Exception:
await transaction.rollback()
raise
else:
await transaction.commit()
finally:
await connection.close()
# Запуск Alembic на готовом соединении
def _upgrade_with_connection(connection: Connection, config: Config) -> None:
config.attributes["connection"] = connection
command.upgrade(config, "head")
+162
View File
@@ -0,0 +1,162 @@
# - *- coding: utf- 8 - *-
from dataclasses import fields
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar
from sqlalchemy import delete as sqlalchemy_delete
from sqlalchemy import select
from sqlalchemy import update as sqlalchemy_update
from tgbot.database.core import Base, database_path, session_scope
from tgbot.database.migration_runner import run_migrations
from tgbot.utils.misc.bot_logging import bot_logger
ModelTranslator = TypeVar("ModelTranslator", bound=Base)
EntityTranslator = TypeVar("EntityTranslator")
# Базовый репозиторий для SQLAlchemy-моделей
class BaseRepository(Generic[ModelTranslator, EntityTranslator]):
# Настройка модели репозитория
def __init__(self):
self.storage_name = "storage"
self.table_model: Optional[Type[ModelTranslator]] = None
self.entity_model: Optional[Type[EntityTranslator]] = None
# Получение SQLAlchemy-модели репозитория
def _model(self) -> Type[ModelTranslator]:
if self.table_model is None:
raise RuntimeError("Модель базы данных не настроена")
return self.table_model
# Преобразование ORM-строки в безопасный DTO
def _to_entity(self, instance: ModelTranslator) -> EntityTranslator:
if self.entity_model is None:
raise RuntimeError("DTO-модель базы данных не настроена")
values = {
field.name: getattr(instance, field.name)
for field in fields(self.entity_model)
}
return self.entity_model(**values)
# Добавление записи через текущую модель
async def _insert(self, **kwargs) -> EntityTranslator:
model = self._model()
instance = model(**kwargs)
async with session_scope() as session:
session.add(instance)
await session.flush()
await session.refresh(instance)
return self._to_entity(instance)
# Добавление записи с возвратом DTO
async def add(self, **kwargs) -> EntityTranslator:
return await self._insert(**kwargs)
# Удаление записи по явному фильтру
async def delete(self, **kwargs) -> None:
if not kwargs:
raise ValueError("Для удаления нужен хотя бы один фильтр")
model = self._model()
async with session_scope() as session:
await session.execute(sqlalchemy_delete(model).filter_by(**kwargs))
# Очистка текущей таблицы
async def clear(self) -> None:
await self.delete_all_rows()
# Удаление всех строк текущей таблицы
async def delete_all_rows(self) -> None:
model = self._model()
async with session_scope() as session:
await session.execute(sqlalchemy_delete(model))
# Получение первой записи по фильтру
async def get(self, **kwargs) -> Optional[EntityTranslator]:
model = self._model()
statement = select(model)
if kwargs:
statement = statement.filter_by(**kwargs)
if hasattr(model, "increment"):
statement = statement.order_by(getattr(model, "increment"))
async with session_scope() as session:
response = await session.execute(statement)
instance = response.scalars().first()
if instance is None:
return None
return self._to_entity(instance)
# Получение записи или явно падает
async def get_required(self, **kwargs) -> EntityTranslator:
entity = await self.get(**kwargs)
if entity is None:
raise LookupError(f"Запись не найдена в {self.storage_name}: {kwargs}")
return entity
# Получение списка записей по фильтру
async def gets(self, **kwargs) -> List[EntityTranslator]:
model = self._model()
statement = select(model)
if kwargs:
statement = statement.filter_by(**kwargs)
if hasattr(model, "increment"):
statement = statement.order_by(getattr(model, "increment"))
async with session_scope() as session:
response = await session.execute(statement)
return [self._to_entity(instance) for instance in response.scalars().all()]
# Получение всех записей таблицы
async def get_all(self) -> List[EntityTranslator]:
return await self.gets()
# Обновление записи через текущую модель
async def _update(self, where: Optional[Dict[str, Any]] = None, **kwargs) -> int:
if not kwargs:
return 0
model = self._model()
statement = sqlalchemy_update(model).values(**kwargs)
if where:
statement = statement.filter_by(**where)
async with session_scope() as session:
result = await session.execute(statement)
rowcount = getattr(result, "rowcount", 0)
return int(rowcount or 0)
# Обновление записи по фильтру
async def update(self, where: Optional[Dict[str, Any]] = None, **kwargs) -> int:
return await self._update(where=where, **kwargs)
# Применение миграций и создание дефолтных строк
async def prepare_database() -> None:
database_path.parent.mkdir(parents=True, exist_ok=True)
await run_migrations()
from tgbot.database.db_payments import Paymentsx
from tgbot.database.db_settings import Settingsx
await Settingsx().ensure_default()
await Paymentsx().ensure_default()
bot_logger.info("База данных готова")