658b48f154
2610 lines
103 KiB
Python
2610 lines
103 KiB
Python
from datetime import datetime, time, timedelta
|
|
from enum import Enum
|
|
|
|
from sqlalchemy import (
|
|
JSON,
|
|
BigInteger,
|
|
Boolean,
|
|
Column,
|
|
Date,
|
|
DateTime,
|
|
Float,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Table,
|
|
Text,
|
|
Time,
|
|
UniqueConstraint,
|
|
)
|
|
from sqlalchemy.ext.declarative import declarative_base
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
from sqlalchemy.sql import func
|
|
|
|
|
|
Base = declarative_base()
|
|
|
|
|
|
server_squad_promo_groups = Table(
|
|
'server_squad_promo_groups',
|
|
Base.metadata,
|
|
Column(
|
|
'server_squad_id',
|
|
Integer,
|
|
ForeignKey('server_squads.id', ondelete='CASCADE'),
|
|
primary_key=True,
|
|
),
|
|
Column(
|
|
'promo_group_id',
|
|
Integer,
|
|
ForeignKey('promo_groups.id', ondelete='CASCADE'),
|
|
primary_key=True,
|
|
),
|
|
)
|
|
|
|
|
|
# M2M таблица для связи тарифов с промогруппами (доступ к тарифу)
|
|
tariff_promo_groups = Table(
|
|
'tariff_promo_groups',
|
|
Base.metadata,
|
|
Column(
|
|
'tariff_id',
|
|
Integer,
|
|
ForeignKey('tariffs.id', ondelete='CASCADE'),
|
|
primary_key=True,
|
|
),
|
|
Column(
|
|
'promo_group_id',
|
|
Integer,
|
|
ForeignKey('promo_groups.id', ondelete='CASCADE'),
|
|
primary_key=True,
|
|
),
|
|
)
|
|
|
|
|
|
class UserStatus(Enum):
|
|
ACTIVE = 'active'
|
|
BLOCKED = 'blocked'
|
|
DELETED = 'deleted'
|
|
|
|
|
|
class SubscriptionStatus(Enum):
|
|
TRIAL = 'trial'
|
|
ACTIVE = 'active'
|
|
EXPIRED = 'expired'
|
|
DISABLED = 'disabled'
|
|
PENDING = 'pending'
|
|
|
|
|
|
class TransactionType(Enum):
|
|
DEPOSIT = 'deposit'
|
|
WITHDRAWAL = 'withdrawal'
|
|
SUBSCRIPTION_PAYMENT = 'subscription_payment'
|
|
REFUND = 'refund'
|
|
REFERRAL_REWARD = 'referral_reward'
|
|
POLL_REWARD = 'poll_reward'
|
|
|
|
|
|
class PromoCodeType(Enum):
|
|
BALANCE = 'balance'
|
|
SUBSCRIPTION_DAYS = 'subscription_days'
|
|
TRIAL_SUBSCRIPTION = 'trial_subscription'
|
|
PROMO_GROUP = 'promo_group'
|
|
DISCOUNT = 'discount' # Одноразовая процентная скидка (balance_bonus_kopeks = процент, subscription_days = часы)
|
|
|
|
|
|
class PaymentMethod(Enum):
|
|
TELEGRAM_STARS = 'telegram_stars'
|
|
TRIBUTE = 'tribute'
|
|
YOOKASSA = 'yookassa'
|
|
CRYPTOBOT = 'cryptobot'
|
|
HELEKET = 'heleket'
|
|
MULENPAY = 'mulenpay'
|
|
PAL24 = 'pal24'
|
|
WATA = 'wata'
|
|
PLATEGA = 'platega'
|
|
CLOUDPAYMENTS = 'cloudpayments'
|
|
FREEKASSA = 'freekassa'
|
|
KASSA_AI = 'kassa_ai'
|
|
MANUAL = 'manual'
|
|
BALANCE = 'balance'
|
|
|
|
|
|
class MainMenuButtonActionType(Enum):
|
|
URL = 'url'
|
|
MINI_APP = 'mini_app'
|
|
|
|
|
|
class MainMenuButtonVisibility(Enum):
|
|
ALL = 'all'
|
|
ADMINS = 'admins'
|
|
SUBSCRIBERS = 'subscribers'
|
|
|
|
|
|
class WheelPrizeType(Enum):
|
|
"""Типы призов на колесе удачи."""
|
|
|
|
SUBSCRIPTION_DAYS = 'subscription_days'
|
|
BALANCE_BONUS = 'balance_bonus'
|
|
TRAFFIC_GB = 'traffic_gb'
|
|
PROMOCODE = 'promocode'
|
|
NOTHING = 'nothing'
|
|
|
|
|
|
class WheelSpinPaymentType(Enum):
|
|
"""Способы оплаты спина колеса."""
|
|
|
|
TELEGRAM_STARS = 'telegram_stars'
|
|
SUBSCRIPTION_DAYS = 'subscription_days'
|
|
|
|
|
|
class YooKassaPayment(Base):
|
|
__tablename__ = 'yookassa_payments'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
yookassa_payment_id = Column(String(255), unique=True, nullable=False, index=True)
|
|
amount_kopeks = Column(Integer, nullable=False)
|
|
currency = Column(String(3), default='RUB', nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
status = Column(String(50), nullable=False)
|
|
is_paid = Column(Boolean, default=False)
|
|
is_captured = Column(Boolean, default=False)
|
|
confirmation_url = Column(Text, nullable=True)
|
|
metadata_json = Column(JSON, nullable=True)
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
payment_method_type = Column(String(50), nullable=True)
|
|
refundable = Column(Boolean, default=False)
|
|
test_mode = Column(Boolean, default=False)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
yookassa_created_at = Column(DateTime, nullable=True)
|
|
captured_at = Column(DateTime, nullable=True)
|
|
user = relationship('User', backref='yookassa_payments')
|
|
transaction = relationship('Transaction', backref='yookassa_payment')
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
@property
|
|
def is_pending(self) -> bool:
|
|
return self.status == 'pending'
|
|
|
|
@property
|
|
def is_succeeded(self) -> bool:
|
|
return self.status == 'succeeded' and self.is_paid
|
|
|
|
@property
|
|
def is_failed(self) -> bool:
|
|
return self.status in ['canceled', 'failed']
|
|
|
|
@property
|
|
def can_be_captured(self) -> bool:
|
|
return self.status == 'waiting_for_capture'
|
|
|
|
def __repr__(self):
|
|
return f'<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
|
|
|
|
|
class CryptoBotPayment(Base):
|
|
__tablename__ = 'cryptobot_payments'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
invoice_id = Column(String(255), unique=True, nullable=False, index=True)
|
|
amount = Column(String(50), nullable=False)
|
|
asset = Column(String(10), nullable=False)
|
|
|
|
status = Column(String(50), nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
payload = Column(Text, nullable=True)
|
|
|
|
bot_invoice_url = Column(Text, nullable=True)
|
|
mini_app_invoice_url = Column(Text, nullable=True)
|
|
web_app_invoice_url = Column(Text, nullable=True)
|
|
|
|
paid_at = Column(DateTime, nullable=True)
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
user = relationship('User', backref='cryptobot_payments')
|
|
transaction = relationship('Transaction', backref='cryptobot_payment')
|
|
|
|
@property
|
|
def amount_float(self) -> float:
|
|
try:
|
|
return float(self.amount)
|
|
except (ValueError, TypeError):
|
|
return 0.0
|
|
|
|
@property
|
|
def is_paid(self) -> bool:
|
|
return self.status == 'paid'
|
|
|
|
@property
|
|
def is_pending(self) -> bool:
|
|
return self.status == 'active'
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
return self.status == 'expired'
|
|
|
|
def __repr__(self):
|
|
return f'<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>'
|
|
|
|
|
|
class HeleketPayment(Base):
|
|
__tablename__ = 'heleket_payments'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
uuid = Column(String(255), unique=True, nullable=False, index=True)
|
|
order_id = Column(String(128), unique=True, nullable=False, index=True)
|
|
|
|
amount = Column(String(50), nullable=False)
|
|
currency = Column(String(10), nullable=False)
|
|
payer_amount = Column(String(50), nullable=True)
|
|
payer_currency = Column(String(10), nullable=True)
|
|
exchange_rate = Column(Float, nullable=True)
|
|
discount_percent = Column(Integer, nullable=True)
|
|
|
|
status = Column(String(50), nullable=False)
|
|
payment_url = Column(Text, nullable=True)
|
|
metadata_json = Column(JSON, nullable=True)
|
|
|
|
paid_at = Column(DateTime, nullable=True)
|
|
expires_at = Column(DateTime, nullable=True)
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
user = relationship('User', backref='heleket_payments')
|
|
transaction = relationship('Transaction', backref='heleket_payment')
|
|
|
|
@property
|
|
def amount_float(self) -> float:
|
|
try:
|
|
return float(self.amount)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
@property
|
|
def amount_kopeks(self) -> int:
|
|
return int(round(self.amount_float * 100))
|
|
|
|
@property
|
|
def payer_amount_float(self) -> float:
|
|
try:
|
|
return float(self.payer_amount) if self.payer_amount is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
@property
|
|
def is_paid(self) -> bool:
|
|
return self.status in {'paid', 'paid_over'}
|
|
|
|
def __repr__(self):
|
|
return (
|
|
f'<HeleketPayment(id={self.id}, uuid={self.uuid}, order_id={self.order_id}, amount={self.amount}'
|
|
f' {self.currency}, status={self.status})>'
|
|
)
|
|
|
|
|
|
class MulenPayPayment(Base):
|
|
__tablename__ = 'mulenpay_payments'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
mulen_payment_id = Column(Integer, nullable=True, index=True)
|
|
uuid = Column(String(255), unique=True, nullable=False, index=True)
|
|
amount_kopeks = Column(Integer, nullable=False)
|
|
currency = Column(String(10), nullable=False, default='RUB')
|
|
description = Column(Text, nullable=True)
|
|
|
|
status = Column(String(50), nullable=False, default='created')
|
|
is_paid = Column(Boolean, default=False)
|
|
paid_at = Column(DateTime, nullable=True)
|
|
|
|
payment_url = Column(Text, nullable=True)
|
|
metadata_json = Column(JSON, nullable=True)
|
|
callback_payload = Column(JSON, nullable=True)
|
|
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
user = relationship('User', backref='mulenpay_payments')
|
|
transaction = relationship('Transaction', backref='mulenpay_payment')
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
return f'<MulenPayPayment(id={self.id}, mulen_id={self.mulen_payment_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
|
|
|
|
|
class Pal24Payment(Base):
|
|
__tablename__ = 'pal24_payments'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
bill_id = Column(String(255), unique=True, nullable=False, index=True)
|
|
order_id = Column(String(255), nullable=True, index=True)
|
|
amount_kopeks = Column(Integer, nullable=False)
|
|
currency = Column(String(10), nullable=False, default='RUB')
|
|
description = Column(Text, nullable=True)
|
|
type = Column(String(20), nullable=False, default='normal')
|
|
|
|
status = Column(String(50), nullable=False, default='NEW')
|
|
is_active = Column(Boolean, default=True)
|
|
is_paid = Column(Boolean, default=False)
|
|
paid_at = Column(DateTime, nullable=True)
|
|
last_status = Column(String(50), nullable=True)
|
|
last_status_checked_at = Column(DateTime, nullable=True)
|
|
|
|
link_url = Column(Text, nullable=True)
|
|
link_page_url = Column(Text, nullable=True)
|
|
metadata_json = Column(JSON, nullable=True)
|
|
callback_payload = Column(JSON, nullable=True)
|
|
|
|
payment_id = Column(String(255), nullable=True, index=True)
|
|
payment_status = Column(String(50), nullable=True)
|
|
payment_method = Column(String(50), nullable=True)
|
|
balance_amount = Column(String(50), nullable=True)
|
|
balance_currency = Column(String(10), nullable=True)
|
|
payer_account = Column(String(255), nullable=True)
|
|
|
|
ttl = Column(Integer, nullable=True)
|
|
expires_at = Column(DateTime, nullable=True)
|
|
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
user = relationship('User', backref='pal24_payments')
|
|
transaction = relationship('Transaction', backref='pal24_payment')
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
@property
|
|
def is_pending(self) -> bool:
|
|
return self.status in {'NEW', 'PROCESS'}
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
return (
|
|
f'<Pal24Payment(id={self.id}, bill_id={self.bill_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
|
)
|
|
|
|
|
|
class WataPayment(Base):
|
|
__tablename__ = 'wata_payments'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
payment_link_id = Column(String(64), unique=True, nullable=False, index=True)
|
|
order_id = Column(String(255), nullable=True, index=True)
|
|
amount_kopeks = Column(Integer, nullable=False)
|
|
currency = Column(String(10), nullable=False, default='RUB')
|
|
description = Column(Text, nullable=True)
|
|
type = Column(String(50), nullable=True)
|
|
|
|
status = Column(String(50), nullable=False, default='Opened')
|
|
is_paid = Column(Boolean, default=False)
|
|
paid_at = Column(DateTime, nullable=True)
|
|
last_status = Column(String(50), nullable=True)
|
|
terminal_public_id = Column(String(64), nullable=True)
|
|
|
|
url = Column(Text, nullable=True)
|
|
success_redirect_url = Column(Text, nullable=True)
|
|
fail_redirect_url = Column(Text, nullable=True)
|
|
metadata_json = Column(JSON, nullable=True)
|
|
callback_payload = Column(JSON, nullable=True)
|
|
|
|
expires_at = Column(DateTime, nullable=True)
|
|
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
user = relationship('User', backref='wata_payments')
|
|
transaction = relationship('Transaction', backref='wata_payment')
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
return f'<WataPayment(id={self.id}, link_id={self.payment_link_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
|
|
|
|
|
class PlategaPayment(Base):
|
|
__tablename__ = 'platega_payments'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
|
|
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
|
|
amount_kopeks = Column(Integer, nullable=False)
|
|
currency = Column(String(10), nullable=False, default='RUB')
|
|
description = Column(Text, nullable=True)
|
|
|
|
payment_method_code = Column(Integer, nullable=False)
|
|
status = Column(String(50), nullable=False, default='PENDING')
|
|
is_paid = Column(Boolean, default=False)
|
|
paid_at = Column(DateTime, nullable=True)
|
|
|
|
redirect_url = Column(Text, nullable=True)
|
|
return_url = Column(Text, nullable=True)
|
|
failed_url = Column(Text, nullable=True)
|
|
payload = Column(String(255), nullable=True)
|
|
metadata_json = Column(JSON, nullable=True)
|
|
callback_payload = Column(JSON, nullable=True)
|
|
|
|
expires_at = Column(DateTime, nullable=True)
|
|
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
user = relationship('User', backref='platega_payments')
|
|
transaction = relationship('Transaction', backref='platega_payment')
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
return f'<PlategaPayment(id={self.id}, transaction_id={self.platega_transaction_id}, amount={self.amount_rubles}₽, status={self.status}, method={self.payment_method_code})>'
|
|
|
|
|
|
class CloudPaymentsPayment(Base):
|
|
__tablename__ = 'cloudpayments_payments'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
# CloudPayments идентификаторы
|
|
transaction_id_cp = Column(BigInteger, unique=True, nullable=True, index=True) # TransactionId от CloudPayments
|
|
invoice_id = Column(String(255), unique=True, nullable=False, index=True) # Наш InvoiceId
|
|
|
|
amount_kopeks = Column(Integer, nullable=False)
|
|
currency = Column(String(10), nullable=False, default='RUB')
|
|
description = Column(Text, nullable=True)
|
|
|
|
status = Column(String(50), nullable=False, default='pending') # pending, completed, failed, authorized
|
|
is_paid = Column(Boolean, default=False)
|
|
paid_at = Column(DateTime, nullable=True)
|
|
|
|
# Данные карты (маскированные)
|
|
card_first_six = Column(String(6), nullable=True)
|
|
card_last_four = Column(String(4), nullable=True)
|
|
card_type = Column(String(50), nullable=True) # Visa, MasterCard, etc.
|
|
card_exp_date = Column(String(10), nullable=True) # MM/YY
|
|
|
|
# Токен для рекуррентных платежей
|
|
token = Column(String(255), nullable=True)
|
|
|
|
# URL для оплаты (виджет)
|
|
payment_url = Column(Text, nullable=True)
|
|
|
|
# Email плательщика
|
|
email = Column(String(255), nullable=True)
|
|
|
|
# Тестовый режим
|
|
test_mode = Column(Boolean, default=False)
|
|
|
|
# Дополнительные данные
|
|
metadata_json = Column(JSON, nullable=True)
|
|
callback_payload = Column(JSON, nullable=True)
|
|
|
|
# Связь с транзакцией в нашей системе
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
user = relationship('User', backref='cloudpayments_payments')
|
|
transaction = relationship('Transaction', backref='cloudpayments_payment')
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
@property
|
|
def is_pending(self) -> bool:
|
|
return self.status == 'pending'
|
|
|
|
@property
|
|
def is_completed(self) -> bool:
|
|
return self.status == 'completed' and self.is_paid
|
|
|
|
@property
|
|
def is_failed(self) -> bool:
|
|
return self.status == 'failed'
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
return f'<CloudPaymentsPayment(id={self.id}, invoice={self.invoice_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
|
|
|
|
|
class FreekassaPayment(Base):
|
|
__tablename__ = 'freekassa_payments'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
# Идентификаторы
|
|
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
|
|
freekassa_order_id = Column(String(64), unique=True, nullable=True, index=True) # intid от Freekassa
|
|
|
|
# Суммы
|
|
amount_kopeks = Column(Integer, nullable=False)
|
|
currency = Column(String(10), nullable=False, default='RUB')
|
|
description = Column(Text, nullable=True)
|
|
|
|
# Статусы
|
|
status = Column(String(32), nullable=False, default='pending') # pending, success, failed, expired
|
|
is_paid = Column(Boolean, default=False)
|
|
|
|
# Данные платежа
|
|
payment_url = Column(Text, nullable=True)
|
|
payment_system_id = Column(Integer, nullable=True) # ID платежной системы FK
|
|
|
|
# Метаданные
|
|
metadata_json = Column(JSON, nullable=True)
|
|
callback_payload = Column(JSON, nullable=True)
|
|
|
|
# Временные метки
|
|
paid_at = Column(DateTime, nullable=True)
|
|
expires_at = Column(DateTime, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
# Связь с транзакцией
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
|
|
# Relationships
|
|
user = relationship('User', backref='freekassa_payments')
|
|
transaction = relationship('Transaction', backref='freekassa_payment')
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
@property
|
|
def is_pending(self) -> bool:
|
|
return self.status == 'pending'
|
|
|
|
@property
|
|
def is_success(self) -> bool:
|
|
return self.status == 'success' and self.is_paid
|
|
|
|
@property
|
|
def is_failed(self) -> bool:
|
|
return self.status in ['failed', 'expired']
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
return f'<FreekassaPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
|
|
|
|
|
class KassaAiPayment(Base):
|
|
"""Платежи через KassaAI (api.fk.life)."""
|
|
|
|
__tablename__ = 'kassa_ai_payments'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
# Идентификаторы
|
|
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
|
|
kassa_ai_order_id = Column(String(64), unique=True, nullable=True, index=True) # orderId от KassaAI
|
|
|
|
# Суммы
|
|
amount_kopeks = Column(Integer, nullable=False)
|
|
currency = Column(String(10), nullable=False, default='RUB')
|
|
description = Column(Text, nullable=True)
|
|
|
|
# Статусы
|
|
status = Column(String(32), nullable=False, default='pending') # pending, success, failed, expired
|
|
is_paid = Column(Boolean, default=False)
|
|
|
|
# Данные платежа
|
|
payment_url = Column(Text, nullable=True)
|
|
payment_system_id = Column(Integer, nullable=True) # ID платежной системы (44=СБП, 36=Карты, 43=SberPay)
|
|
|
|
# Метаданные
|
|
metadata_json = Column(JSON, nullable=True)
|
|
callback_payload = Column(JSON, nullable=True)
|
|
|
|
# Временные метки
|
|
paid_at = Column(DateTime, nullable=True)
|
|
expires_at = Column(DateTime, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
# Связь с транзакцией
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
|
|
# Relationships
|
|
user = relationship('User', backref='kassa_ai_payments')
|
|
transaction = relationship('Transaction', backref='kassa_ai_payment')
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
@property
|
|
def is_pending(self) -> bool:
|
|
return self.status == 'pending'
|
|
|
|
@property
|
|
def is_success(self) -> bool:
|
|
return self.status == 'success' and self.is_paid
|
|
|
|
@property
|
|
def is_failed(self) -> bool:
|
|
return self.status in ['failed', 'expired']
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
return f'<KassaAiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
|
|
|
|
|
class PromoGroup(Base):
|
|
__tablename__ = 'promo_groups'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(255), unique=True, nullable=False)
|
|
priority = Column(Integer, nullable=False, default=0, index=True)
|
|
server_discount_percent = Column(Integer, nullable=False, default=0)
|
|
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
|
device_discount_percent = Column(Integer, nullable=False, default=0)
|
|
period_discounts = Column(JSON, nullable=True, default=dict)
|
|
auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None)
|
|
apply_discounts_to_addons = Column(Boolean, nullable=False, default=True)
|
|
is_default = Column(Boolean, nullable=False, default=False)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
users = relationship('User', back_populates='promo_group')
|
|
user_promo_groups = relationship('UserPromoGroup', back_populates='promo_group', cascade='all, delete-orphan')
|
|
server_squads = relationship(
|
|
'ServerSquad',
|
|
secondary=server_squad_promo_groups,
|
|
back_populates='allowed_promo_groups',
|
|
lazy='selectin',
|
|
)
|
|
|
|
def _get_period_discounts_map(self) -> dict[int, int]:
|
|
raw_discounts = self.period_discounts or {}
|
|
|
|
if isinstance(raw_discounts, dict):
|
|
items = raw_discounts.items()
|
|
else:
|
|
items = []
|
|
|
|
normalized: dict[int, int] = {}
|
|
|
|
for key, value in items:
|
|
try:
|
|
period = int(key)
|
|
percent = int(value)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
|
|
normalized[period] = max(0, min(100, percent))
|
|
|
|
return normalized
|
|
|
|
def _get_period_discount(self, period_days: int | None) -> int:
|
|
if not period_days:
|
|
return 0
|
|
|
|
discounts = self._get_period_discounts_map()
|
|
|
|
if period_days in discounts:
|
|
return discounts[period_days]
|
|
|
|
if self.is_default:
|
|
try:
|
|
from app.config import settings
|
|
|
|
if settings.is_base_promo_group_period_discount_enabled():
|
|
config_discounts = settings.get_base_promo_group_period_discounts()
|
|
return config_discounts.get(period_days, 0)
|
|
except Exception:
|
|
return 0
|
|
|
|
return 0
|
|
|
|
def get_discount_percent(self, category: str, period_days: int | None = None) -> int:
|
|
if category == 'period':
|
|
return max(0, min(100, self._get_period_discount(period_days)))
|
|
|
|
mapping = {
|
|
'servers': self.server_discount_percent,
|
|
'traffic': self.traffic_discount_percent,
|
|
'devices': self.device_discount_percent,
|
|
}
|
|
percent = mapping.get(category) or 0
|
|
|
|
if percent == 0 and self.is_default:
|
|
base_period_discount = self._get_period_discount(period_days)
|
|
percent = max(percent, base_period_discount)
|
|
|
|
return max(0, min(100, percent))
|
|
|
|
|
|
class UserPromoGroup(Base):
|
|
"""Таблица связи Many-to-Many между пользователями и промогруппами."""
|
|
|
|
__tablename__ = 'user_promo_groups'
|
|
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True)
|
|
promo_group_id = Column(Integer, ForeignKey('promo_groups.id', ondelete='CASCADE'), primary_key=True)
|
|
assigned_at = Column(DateTime, default=func.now())
|
|
assigned_by = Column(String(50), default='system')
|
|
|
|
user = relationship('User', back_populates='user_promo_groups')
|
|
promo_group = relationship('PromoGroup', back_populates='user_promo_groups')
|
|
|
|
def __repr__(self):
|
|
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
|
|
|
|
|
|
class Tariff(Base):
|
|
"""Тарифный план для режима продаж 'Тарифы'."""
|
|
|
|
__tablename__ = 'tariffs'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# Основная информация
|
|
name = Column(String(255), nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
display_order = Column(Integer, default=0, nullable=False)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
# Параметры тарифа
|
|
traffic_limit_gb = Column(Integer, nullable=False, default=100) # 0 = безлимит
|
|
device_limit = Column(Integer, nullable=False, default=1)
|
|
device_price_kopeks = Column(
|
|
Integer, nullable=True, default=None
|
|
) # Цена за доп. устройство (None = нельзя докупить)
|
|
max_device_limit = Column(Integer, nullable=True, default=None) # Макс. устройств (None = без ограничений)
|
|
|
|
# Сквады (серверы) доступные в тарифе
|
|
allowed_squads = Column(JSON, default=list) # список UUID сквадов
|
|
|
|
# Лимиты трафика по серверам (JSON: {"uuid": {"traffic_limit_gb": 100}, ...})
|
|
# Если сервер не указан - используется общий traffic_limit_gb
|
|
server_traffic_limits = Column(JSON, default=dict)
|
|
|
|
# Цены на периоды в копейках (JSON: {"14": 30000, "30": 50000, "90": 120000, ...})
|
|
period_prices = Column(JSON, nullable=False, default=dict)
|
|
|
|
# Уровень тарифа (для визуального отображения, 1 = базовый)
|
|
tier_level = Column(Integer, default=1, nullable=False)
|
|
|
|
# Дополнительные настройки
|
|
is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе
|
|
allow_traffic_topup = Column(Boolean, default=True, nullable=False) # Разрешена ли докупка трафика для этого тарифа
|
|
|
|
# Докупка трафика
|
|
traffic_topup_enabled = Column(Boolean, default=False, nullable=False) # Разрешена ли докупка трафика
|
|
# Пакеты трафика: JSON {"5": 5000, "10": 9000, "20": 15000} (ГБ: цена в копейках)
|
|
traffic_topup_packages = Column(JSON, default=dict)
|
|
# Максимальный лимит трафика после докупки (0 = без ограничений)
|
|
max_topup_traffic_gb = Column(Integer, default=0, nullable=False)
|
|
|
|
# Суточный тариф - ежедневное списание
|
|
is_daily = Column(Boolean, default=False, nullable=False) # Является ли тариф суточным
|
|
daily_price_kopeks = Column(Integer, default=0, nullable=False) # Цена за день в копейках
|
|
|
|
# Произвольное количество дней
|
|
custom_days_enabled = Column(Boolean, default=False, nullable=False) # Разрешить произвольное кол-во дней
|
|
price_per_day_kopeks = Column(Integer, default=0, nullable=False) # Цена за 1 день в копейках
|
|
min_days = Column(Integer, default=1, nullable=False) # Минимальное количество дней
|
|
max_days = Column(Integer, default=365, nullable=False) # Максимальное количество дней
|
|
|
|
# Произвольный трафик при покупке
|
|
custom_traffic_enabled = Column(Boolean, default=False, nullable=False) # Разрешить произвольный трафик
|
|
traffic_price_per_gb_kopeks = Column(Integer, default=0, nullable=False) # Цена за 1 ГБ в копейках
|
|
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
|
|
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
|
|
|
|
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
|
|
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
# M2M связь с промогруппами (какие промогруппы имеют доступ к тарифу)
|
|
allowed_promo_groups = relationship(
|
|
'PromoGroup',
|
|
secondary=tariff_promo_groups,
|
|
lazy='selectin',
|
|
)
|
|
|
|
# Подписки на этом тарифе
|
|
subscriptions = relationship('Subscription', back_populates='tariff')
|
|
|
|
@property
|
|
def is_unlimited_traffic(self) -> bool:
|
|
"""Проверяет, безлимитный ли трафик."""
|
|
return self.traffic_limit_gb == 0
|
|
|
|
def get_price_for_period(self, period_days: int) -> int | None:
|
|
"""Возвращает цену в копейках для указанного периода."""
|
|
prices = self.period_prices or {}
|
|
return prices.get(str(period_days))
|
|
|
|
def get_available_periods(self) -> list[int]:
|
|
"""Возвращает список доступных периодов в днях."""
|
|
prices = self.period_prices or {}
|
|
return sorted([int(p) for p in prices.keys()])
|
|
|
|
def get_price_rubles(self, period_days: int) -> float | None:
|
|
"""Возвращает цену в рублях для указанного периода."""
|
|
price_kopeks = self.get_price_for_period(period_days)
|
|
if price_kopeks is not None:
|
|
return price_kopeks / 100
|
|
return None
|
|
|
|
def get_traffic_limit_for_server(self, squad_uuid: str) -> int:
|
|
"""Возвращает лимит трафика для конкретного сервера.
|
|
|
|
Если для сервера настроен отдельный лимит - возвращает его,
|
|
иначе возвращает общий traffic_limit_gb тарифа.
|
|
"""
|
|
limits = self.server_traffic_limits or {}
|
|
if squad_uuid in limits:
|
|
server_limit = limits[squad_uuid]
|
|
if isinstance(server_limit, dict) and 'traffic_limit_gb' in server_limit:
|
|
return server_limit['traffic_limit_gb']
|
|
if isinstance(server_limit, int):
|
|
return server_limit
|
|
return self.traffic_limit_gb
|
|
|
|
def is_available_for_promo_group(self, promo_group_id: int | None) -> bool:
|
|
"""Проверяет, доступен ли тариф для указанной промогруппы."""
|
|
if not self.allowed_promo_groups:
|
|
return True # Если нет ограничений - доступен всем
|
|
if promo_group_id is None:
|
|
return True # Если у пользователя нет группы - доступен
|
|
return any(pg.id == promo_group_id for pg in self.allowed_promo_groups)
|
|
|
|
def get_traffic_topup_packages(self) -> dict[int, int]:
|
|
"""Возвращает пакеты трафика для докупки: {ГБ: цена в копейках}."""
|
|
packages = self.traffic_topup_packages or {}
|
|
return {int(gb): int(price) for gb, price in packages.items()}
|
|
|
|
def get_traffic_topup_price(self, gb: int) -> int | None:
|
|
"""Возвращает цену в копейках для указанного пакета трафика."""
|
|
packages = self.get_traffic_topup_packages()
|
|
return packages.get(gb)
|
|
|
|
def get_available_traffic_packages(self) -> list[int]:
|
|
"""Возвращает список доступных пакетов трафика в ГБ."""
|
|
packages = self.get_traffic_topup_packages()
|
|
return sorted(packages.keys())
|
|
|
|
def can_topup_traffic(self) -> bool:
|
|
"""Проверяет, можно ли докупить трафик на этом тарифе."""
|
|
return self.traffic_topup_enabled and bool(self.traffic_topup_packages) and not self.is_unlimited_traffic
|
|
|
|
def get_daily_price_rubles(self) -> float:
|
|
"""Возвращает суточную цену в рублях."""
|
|
return self.daily_price_kopeks / 100 if self.daily_price_kopeks else 0
|
|
|
|
def get_price_for_custom_days(self, days: int) -> int | None:
|
|
"""Возвращает цену для произвольного количества дней."""
|
|
if not self.custom_days_enabled or not self.price_per_day_kopeks:
|
|
return None
|
|
if days < self.min_days or days > self.max_days:
|
|
return None
|
|
return self.price_per_day_kopeks * days
|
|
|
|
def get_price_for_custom_traffic(self, gb: int) -> int | None:
|
|
"""Возвращает цену для произвольного количества трафика."""
|
|
if not self.custom_traffic_enabled or not self.traffic_price_per_gb_kopeks:
|
|
return None
|
|
if gb < self.min_traffic_gb or gb > self.max_traffic_gb:
|
|
return None
|
|
return self.traffic_price_per_gb_kopeks * gb
|
|
|
|
def can_purchase_custom_days(self) -> bool:
|
|
"""Проверяет, можно ли купить произвольное количество дней."""
|
|
return self.custom_days_enabled and self.price_per_day_kopeks > 0
|
|
|
|
def can_purchase_custom_traffic(self) -> bool:
|
|
"""Проверяет, можно ли купить произвольный трафик."""
|
|
return self.custom_traffic_enabled and self.traffic_price_per_gb_kopeks > 0
|
|
|
|
def __repr__(self):
|
|
return f"<Tariff(id={self.id}, name='{self.name}', tier={self.tier_level}, active={self.is_active})>"
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = 'users'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
telegram_id = Column(BigInteger, unique=True, index=True, nullable=True) # Nullable для email-only пользователей
|
|
auth_type = Column(String(20), default='telegram', nullable=False) # "telegram" или "email"
|
|
username = Column(String(255), nullable=True)
|
|
first_name = Column(String(255), nullable=True)
|
|
last_name = Column(String(255), nullable=True)
|
|
status = Column(String(20), default=UserStatus.ACTIVE.value)
|
|
language = Column(String(5), default='ru')
|
|
balance_kopeks = Column(Integer, default=0)
|
|
used_promocodes = Column(Integer, default=0)
|
|
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
|
|
referred_by_id = Column(Integer, ForeignKey('users.id'), nullable=True)
|
|
referral_code = Column(String(20), unique=True, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
last_activity = Column(DateTime, default=func.now())
|
|
remnawave_uuid = Column(String(255), nullable=True, unique=True)
|
|
|
|
# Cabinet authentication fields
|
|
email = Column(String(255), unique=True, nullable=True, index=True)
|
|
email_verified = Column(Boolean, default=False, nullable=False)
|
|
email_verified_at = Column(DateTime, nullable=True)
|
|
password_hash = Column(String(255), nullable=True)
|
|
email_verification_token = Column(String(255), nullable=True)
|
|
email_verification_expires = Column(DateTime, nullable=True)
|
|
password_reset_token = Column(String(255), nullable=True)
|
|
password_reset_expires = Column(DateTime, nullable=True)
|
|
cabinet_last_login = Column(DateTime, nullable=True)
|
|
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
|
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
|
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
|
transactions = relationship('Transaction', back_populates='user')
|
|
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
|
|
discount_offers = relationship('DiscountOffer', back_populates='user')
|
|
promo_offer_logs = relationship('PromoOfferLog', back_populates='user')
|
|
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
|
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
|
|
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
|
|
referral_commission_percent = Column(Integer, nullable=True)
|
|
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
|
|
promo_offer_discount_source = Column(String(100), nullable=True)
|
|
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
|
|
last_remnawave_sync = Column(DateTime, nullable=True)
|
|
trojan_password = Column(String(255), nullable=True)
|
|
vless_uuid = Column(String(255), nullable=True)
|
|
ss_password = Column(String(255), nullable=True)
|
|
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
promo_group_id = Column(Integer, ForeignKey('promo_groups.id', ondelete='RESTRICT'), nullable=True, index=True)
|
|
promo_group = relationship('PromoGroup', back_populates='users')
|
|
user_promo_groups = relationship('UserPromoGroup', back_populates='user', cascade='all, delete-orphan')
|
|
poll_responses = relationship('PollResponse', back_populates='user')
|
|
notification_settings = Column(JSON, nullable=True, default=dict)
|
|
last_pinned_message_id = Column(Integer, nullable=True)
|
|
|
|
# Ограничения пользователя
|
|
restriction_topup = Column(Boolean, default=False, nullable=False) # Запрет пополнения
|
|
restriction_subscription = Column(Boolean, default=False, nullable=False) # Запрет продления/покупки
|
|
restriction_reason = Column(String(500), nullable=True) # Причина ограничения
|
|
|
|
@property
|
|
def has_restrictions(self) -> bool:
|
|
"""Проверить, есть ли у пользователя активные ограничения."""
|
|
return self.restriction_topup or self.restriction_subscription
|
|
|
|
@property
|
|
def balance_rubles(self) -> float:
|
|
return self.balance_kopeks / 100
|
|
|
|
@property
|
|
def full_name(self) -> str:
|
|
"""Полное имя пользователя с поддержкой email-only юзеров."""
|
|
parts = [self.first_name, self.last_name]
|
|
name = ' '.join(filter(None, parts))
|
|
if name:
|
|
return name
|
|
if self.username:
|
|
return self.username
|
|
if self.telegram_id:
|
|
return f'ID{self.telegram_id}'
|
|
if self.email:
|
|
return self.email.split('@')[0]
|
|
return f'User{self.id}'
|
|
|
|
@property
|
|
def is_email_user(self) -> bool:
|
|
"""Пользователь зарегистрирован через email (без Telegram)."""
|
|
return self.auth_type == 'email' and self.telegram_id is None
|
|
|
|
def get_primary_promo_group(self):
|
|
"""Возвращает промогруппу с максимальным приоритетом."""
|
|
if not self.user_promo_groups:
|
|
return getattr(self, 'promo_group', None)
|
|
|
|
try:
|
|
# Сортируем по приоритету группы (убывание), затем по ID группы
|
|
# Используем getattr для защиты от ленивой загрузки
|
|
sorted_groups = sorted(
|
|
self.user_promo_groups,
|
|
key=lambda upg: (getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0, upg.promo_group_id),
|
|
reverse=True,
|
|
)
|
|
|
|
if sorted_groups and sorted_groups[0].promo_group:
|
|
return sorted_groups[0].promo_group
|
|
except Exception:
|
|
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
|
|
pass
|
|
|
|
# Fallback на старую связь если новая пустая или возникла ошибка
|
|
return getattr(self, 'promo_group', None)
|
|
|
|
def get_promo_discount(self, category: str, period_days: int | None = None) -> int:
|
|
primary_group = self.get_primary_promo_group()
|
|
if not primary_group:
|
|
return 0
|
|
return primary_group.get_discount_percent(category, period_days)
|
|
|
|
def add_balance(self, kopeks: int) -> None:
|
|
self.balance_kopeks += kopeks
|
|
|
|
def subtract_balance(self, kopeks: int) -> bool:
|
|
if self.balance_kopeks >= kopeks:
|
|
self.balance_kopeks -= kopeks
|
|
return True
|
|
return False
|
|
|
|
|
|
class Subscription(Base):
|
|
__tablename__ = 'subscriptions'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, unique=True)
|
|
|
|
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
|
|
is_trial = Column(Boolean, default=True)
|
|
|
|
start_date = Column(DateTime, default=func.now())
|
|
end_date = Column(DateTime, nullable=False)
|
|
|
|
traffic_limit_gb = Column(Integer, default=0)
|
|
traffic_used_gb = Column(Float, default=0.0)
|
|
purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик
|
|
traffic_reset_at = Column(
|
|
DateTime, nullable=True
|
|
) # Дата сброса докупленного трафика (30 дней после первой докупки)
|
|
|
|
subscription_url = Column(String, nullable=True)
|
|
subscription_crypto_link = Column(String, nullable=True)
|
|
|
|
device_limit = Column(Integer, default=1)
|
|
modem_enabled = Column(Boolean, default=False)
|
|
|
|
connected_squads = Column(JSON, default=list)
|
|
|
|
autopay_enabled = Column(Boolean, default=False)
|
|
autopay_days_before = Column(Integer, default=3)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
remnawave_short_uuid = Column(String(255), nullable=True)
|
|
|
|
# Тариф (для режима продаж "Тарифы")
|
|
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True, index=True)
|
|
|
|
# Суточная подписка
|
|
is_daily_paused = Column(
|
|
Boolean, default=False, nullable=False
|
|
) # Приостановлена ли суточная подписка пользователем
|
|
last_daily_charge_at = Column(DateTime, nullable=True) # Время последнего суточного списания
|
|
|
|
user = relationship('User', back_populates='subscription')
|
|
tariff = relationship('Tariff', back_populates='subscriptions')
|
|
discount_offers = relationship('DiscountOffer', back_populates='subscription')
|
|
temporary_accesses = relationship('SubscriptionTemporaryAccess', back_populates='subscription')
|
|
traffic_purchases = relationship('TrafficPurchase', back_populates='subscription', cascade='all, delete-orphan')
|
|
|
|
@property
|
|
def is_active(self) -> bool:
|
|
current_time = datetime.utcnow()
|
|
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date > current_time
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
"""Проверяет, истёк ли срок подписки"""
|
|
return self.end_date <= datetime.utcnow()
|
|
|
|
@property
|
|
def should_be_expired(self) -> bool:
|
|
current_time = datetime.utcnow()
|
|
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date <= current_time
|
|
|
|
@property
|
|
def actual_status(self) -> str:
|
|
current_time = datetime.utcnow()
|
|
|
|
if self.status == SubscriptionStatus.EXPIRED.value:
|
|
return 'expired'
|
|
|
|
if self.status == SubscriptionStatus.DISABLED.value:
|
|
return 'disabled'
|
|
|
|
if self.status == SubscriptionStatus.ACTIVE.value:
|
|
if self.end_date <= current_time:
|
|
return 'expired'
|
|
return 'active'
|
|
|
|
if self.status == SubscriptionStatus.TRIAL.value:
|
|
if self.end_date <= current_time:
|
|
return 'expired'
|
|
return 'trial'
|
|
|
|
return self.status
|
|
|
|
@property
|
|
def status_display(self) -> str:
|
|
actual_status = self.actual_status
|
|
datetime.utcnow()
|
|
|
|
if actual_status == 'expired':
|
|
return '🔴 Истекла'
|
|
if actual_status == 'active':
|
|
if self.is_trial:
|
|
return '🎯 Тестовая'
|
|
return '🟢 Активна'
|
|
if actual_status == 'disabled':
|
|
return '⚫ Отключена'
|
|
if actual_status == 'trial':
|
|
return '🎯 Тестовая'
|
|
|
|
return '❓ Неизвестно'
|
|
|
|
@property
|
|
def status_emoji(self) -> str:
|
|
actual_status = self.actual_status
|
|
|
|
if actual_status == 'expired':
|
|
return '🔴'
|
|
if actual_status == 'active':
|
|
if self.is_trial:
|
|
return '🎁'
|
|
return '💎'
|
|
if actual_status == 'disabled':
|
|
return '⚫'
|
|
if actual_status == 'trial':
|
|
return '🎁'
|
|
|
|
return '❓'
|
|
|
|
@property
|
|
def days_left(self) -> int:
|
|
current_time = datetime.utcnow()
|
|
if self.end_date <= current_time:
|
|
return 0
|
|
delta = self.end_date - current_time
|
|
return max(0, delta.days)
|
|
|
|
@property
|
|
def time_left_display(self) -> str:
|
|
current_time = datetime.utcnow()
|
|
if self.end_date <= current_time:
|
|
return 'истёк'
|
|
|
|
delta = self.end_date - current_time
|
|
days = delta.days
|
|
hours = delta.seconds // 3600
|
|
minutes = (delta.seconds % 3600) // 60
|
|
|
|
if days > 0:
|
|
return f'{days} дн.'
|
|
if hours > 0:
|
|
return f'{hours} ч.'
|
|
return f'{minutes} мин.'
|
|
|
|
@property
|
|
def traffic_used_percent(self) -> float:
|
|
if self.traffic_limit_gb == 0:
|
|
return 0.0
|
|
if self.traffic_limit_gb > 0:
|
|
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
|
|
return 0.0
|
|
|
|
def extend_subscription(self, days: int):
|
|
if self.end_date > datetime.utcnow():
|
|
self.end_date = self.end_date + timedelta(days=days)
|
|
else:
|
|
self.end_date = datetime.utcnow() + timedelta(days=days)
|
|
|
|
if self.status == SubscriptionStatus.EXPIRED.value:
|
|
self.status = SubscriptionStatus.ACTIVE.value
|
|
|
|
def add_traffic(self, gb: int):
|
|
if self.traffic_limit_gb == 0:
|
|
return
|
|
self.traffic_limit_gb += gb
|
|
|
|
@property
|
|
def is_daily_tariff(self) -> bool:
|
|
"""Проверяет, является ли тариф подписки суточным."""
|
|
if self.tariff:
|
|
return getattr(self.tariff, 'is_daily', False)
|
|
return False
|
|
|
|
@property
|
|
def daily_price_kopeks(self) -> int:
|
|
"""Возвращает суточную цену тарифа в копейках."""
|
|
if self.tariff:
|
|
return getattr(self.tariff, 'daily_price_kopeks', 0)
|
|
return 0
|
|
|
|
@property
|
|
def can_charge_daily(self) -> bool:
|
|
"""Проверяет, можно ли списать суточную оплату."""
|
|
if not self.is_daily_tariff:
|
|
return False
|
|
if self.is_daily_paused:
|
|
return False
|
|
if self.status != SubscriptionStatus.ACTIVE.value:
|
|
return False
|
|
return True
|
|
|
|
|
|
class TrafficPurchase(Base):
|
|
"""Докупка трафика с индивидуальной датой истечения."""
|
|
|
|
__tablename__ = 'traffic_purchases'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
|
|
traffic_gb = Column(Integer, nullable=False) # Количество ГБ в покупке
|
|
expires_at = Column(DateTime, nullable=False, index=True) # Дата истечения (покупка + 30 дней)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
subscription = relationship('Subscription', back_populates='traffic_purchases')
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
"""Проверяет, истекла ли докупка."""
|
|
return datetime.utcnow() >= self.expires_at
|
|
|
|
|
|
class Transaction(Base):
|
|
__tablename__ = 'transactions'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
type = Column(String(50), nullable=False)
|
|
amount_kopeks = Column(Integer, nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
|
|
payment_method = Column(String(50), nullable=True)
|
|
external_id = Column(String(255), nullable=True)
|
|
|
|
is_completed = Column(Boolean, default=True)
|
|
|
|
# NaloGO чек
|
|
receipt_uuid = Column(String(255), nullable=True, index=True)
|
|
receipt_created_at = Column(DateTime, nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
completed_at = Column(DateTime, nullable=True)
|
|
|
|
user = relationship('User', back_populates='transactions')
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
|
|
class SubscriptionConversion(Base):
|
|
__tablename__ = 'subscription_conversions'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
converted_at = Column(DateTime, default=func.now())
|
|
|
|
trial_duration_days = Column(Integer, nullable=True)
|
|
|
|
payment_method = Column(String(50), nullable=True)
|
|
|
|
first_payment_amount_kopeks = Column(Integer, nullable=True)
|
|
|
|
first_paid_period_days = Column(Integer, nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
user = relationship('User', backref='subscription_conversions')
|
|
|
|
@property
|
|
def first_payment_amount_rubles(self) -> float:
|
|
return (self.first_payment_amount_kopeks or 0) / 100
|
|
|
|
def __repr__(self):
|
|
return f'<SubscriptionConversion(user_id={self.user_id}, converted_at={self.converted_at})>'
|
|
|
|
|
|
class PromoCode(Base):
|
|
__tablename__ = 'promocodes'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
code = Column(String(50), unique=True, nullable=False, index=True)
|
|
type = Column(String(50), nullable=False)
|
|
|
|
balance_bonus_kopeks = Column(Integer, default=0)
|
|
subscription_days = Column(Integer, default=0)
|
|
|
|
max_uses = Column(Integer, default=1)
|
|
current_uses = Column(Integer, default=0)
|
|
|
|
valid_from = Column(DateTime, default=func.now())
|
|
valid_until = Column(DateTime, nullable=True)
|
|
|
|
is_active = Column(Boolean, default=True)
|
|
first_purchase_only = Column(Boolean, default=False) # Только для первой покупки
|
|
|
|
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
|
promo_group_id = Column(Integer, ForeignKey('promo_groups.id', ondelete='SET NULL'), nullable=True, index=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
uses = relationship('PromoCodeUse', back_populates='promocode')
|
|
promo_group = relationship('PromoGroup')
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
now = datetime.utcnow()
|
|
return (
|
|
self.is_active
|
|
and self.current_uses < self.max_uses
|
|
and self.valid_from <= now
|
|
and (self.valid_until is None or self.valid_until >= now)
|
|
)
|
|
|
|
@property
|
|
def uses_left(self) -> int:
|
|
return max(0, self.max_uses - self.current_uses)
|
|
|
|
|
|
class PromoCodeUse(Base):
|
|
__tablename__ = 'promocode_uses'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
promocode_id = Column(Integer, ForeignKey('promocodes.id'), nullable=False)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
used_at = Column(DateTime, default=func.now())
|
|
|
|
promocode = relationship('PromoCode', back_populates='uses')
|
|
user = relationship('User')
|
|
|
|
|
|
class ReferralEarning(Base):
|
|
__tablename__ = 'referral_earnings'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
referral_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
amount_kopeks = Column(Integer, nullable=False)
|
|
reason = Column(String(100), nullable=False)
|
|
|
|
referral_transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
user = relationship('User', foreign_keys=[user_id], back_populates='referral_earnings')
|
|
referral = relationship('User', foreign_keys=[referral_id])
|
|
referral_transaction = relationship('Transaction')
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
|
|
class WithdrawalRequestStatus(Enum):
|
|
"""Статусы заявки на вывод реферального баланса."""
|
|
|
|
PENDING = 'pending' # Ожидает рассмотрения
|
|
APPROVED = 'approved' # Одобрена
|
|
REJECTED = 'rejected' # Отклонена
|
|
COMPLETED = 'completed' # Выполнена (деньги переведены)
|
|
CANCELLED = 'cancelled' # Отменена пользователем
|
|
|
|
|
|
class WithdrawalRequest(Base):
|
|
"""Заявка на вывод реферального баланса."""
|
|
|
|
__tablename__ = 'withdrawal_requests'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
|
|
amount_kopeks = Column(Integer, nullable=False) # Сумма к выводу
|
|
status = Column(String(50), default=WithdrawalRequestStatus.PENDING.value, nullable=False)
|
|
|
|
# Данные для вывода (заполняет пользователь)
|
|
payment_details = Column(Text, nullable=True) # Реквизиты для перевода
|
|
|
|
# Анализ на отмывание
|
|
risk_score = Column(Integer, default=0) # 0-100, чем выше — тем подозрительнее
|
|
risk_analysis = Column(Text, nullable=True) # JSON с деталями анализа
|
|
|
|
# Обработка админом
|
|
processed_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
|
processed_at = Column(DateTime, nullable=True)
|
|
admin_comment = Column(Text, nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
user = relationship('User', foreign_keys=[user_id], backref='withdrawal_requests')
|
|
admin = relationship('User', foreign_keys=[processed_by])
|
|
|
|
@property
|
|
def amount_rubles(self) -> float:
|
|
return self.amount_kopeks / 100
|
|
|
|
|
|
class ReferralContest(Base):
|
|
__tablename__ = 'referral_contests'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
title = Column(String(255), nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
prize_text = Column(Text, nullable=True)
|
|
contest_type = Column(String(50), nullable=False, default='referral_paid')
|
|
start_at = Column(DateTime, nullable=False)
|
|
end_at = Column(DateTime, nullable=False)
|
|
daily_summary_time = Column(Time, nullable=False, default=time(hour=12, minute=0))
|
|
daily_summary_times = Column(String(255), nullable=True) # CSV HH:MM
|
|
timezone = Column(String(64), nullable=False, default='UTC')
|
|
is_active = Column(Boolean, nullable=False, default=True)
|
|
last_daily_summary_date = Column(Date, nullable=True)
|
|
last_daily_summary_at = Column(DateTime, nullable=True)
|
|
final_summary_sent = Column(Boolean, nullable=False, default=False)
|
|
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
creator = relationship('User', backref='created_referral_contests')
|
|
events = relationship(
|
|
'ReferralContestEvent',
|
|
back_populates='contest',
|
|
cascade='all, delete-orphan',
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ReferralContest id={self.id} title='{self.title}'>"
|
|
|
|
|
|
class ReferralContestEvent(Base):
|
|
__tablename__ = 'referral_contest_events'
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
'contest_id',
|
|
'referral_id',
|
|
name='uq_referral_contest_referral',
|
|
),
|
|
Index('idx_referral_contest_referrer', 'contest_id', 'referrer_id'),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
contest_id = Column(Integer, ForeignKey('referral_contests.id', ondelete='CASCADE'), nullable=False)
|
|
referrer_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
referral_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
event_type = Column(String(50), nullable=False)
|
|
amount_kopeks = Column(Integer, nullable=False, default=0)
|
|
occurred_at = Column(DateTime, nullable=False, default=func.now())
|
|
|
|
contest = relationship('ReferralContest', back_populates='events')
|
|
referrer = relationship('User', foreign_keys=[referrer_id])
|
|
referral = relationship('User', foreign_keys=[referral_id])
|
|
|
|
def __repr__(self):
|
|
return (
|
|
f'<ReferralContestEvent contest={self.contest_id} referrer={self.referrer_id} referral={self.referral_id}>'
|
|
)
|
|
|
|
|
|
class ContestTemplate(Base):
|
|
__tablename__ = 'contest_templates'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(100), nullable=False)
|
|
slug = Column(String(50), nullable=False, unique=True, index=True)
|
|
description = Column(Text, nullable=True)
|
|
prize_type = Column(String(20), nullable=False, default='days')
|
|
prize_value = Column(String(50), nullable=False, default='1')
|
|
max_winners = Column(Integer, nullable=False, default=1)
|
|
attempts_per_user = Column(Integer, nullable=False, default=1)
|
|
times_per_day = Column(Integer, nullable=False, default=1)
|
|
schedule_times = Column(String(255), nullable=True) # CSV of HH:MM in local TZ
|
|
cooldown_hours = Column(Integer, nullable=False, default=24)
|
|
payload = Column(JSON, nullable=True)
|
|
is_enabled = Column(Boolean, nullable=False, default=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
rounds = relationship('ContestRound', back_populates='template')
|
|
|
|
|
|
class ContestRound(Base):
|
|
__tablename__ = 'contest_rounds'
|
|
__table_args__ = (
|
|
Index('idx_contest_round_status', 'status'),
|
|
Index('idx_contest_round_template', 'template_id'),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
template_id = Column(Integer, ForeignKey('contest_templates.id', ondelete='CASCADE'), nullable=False)
|
|
starts_at = Column(DateTime, nullable=False)
|
|
ends_at = Column(DateTime, nullable=False)
|
|
status = Column(String(20), nullable=False, default='active') # active, finished
|
|
payload = Column(JSON, nullable=True)
|
|
winners_count = Column(Integer, nullable=False, default=0)
|
|
max_winners = Column(Integer, nullable=False, default=1)
|
|
attempts_per_user = Column(Integer, nullable=False, default=1)
|
|
message_id = Column(BigInteger, nullable=True)
|
|
chat_id = Column(BigInteger, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
template = relationship('ContestTemplate', back_populates='rounds')
|
|
attempts = relationship('ContestAttempt', back_populates='round', cascade='all, delete-orphan')
|
|
|
|
|
|
class ContestAttempt(Base):
|
|
__tablename__ = 'contest_attempts'
|
|
__table_args__ = (
|
|
UniqueConstraint('round_id', 'user_id', name='uq_round_user_attempt'),
|
|
Index('idx_contest_attempt_round', 'round_id'),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
round_id = Column(Integer, ForeignKey('contest_rounds.id', ondelete='CASCADE'), nullable=False)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
answer = Column(Text, nullable=True)
|
|
is_winner = Column(Boolean, nullable=False, default=False)
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
round = relationship('ContestRound', back_populates='attempts')
|
|
user = relationship('User')
|
|
|
|
|
|
class Squad(Base):
|
|
__tablename__ = 'squads'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
uuid = Column(String(255), unique=True, nullable=False)
|
|
name = Column(String(255), nullable=False)
|
|
country_code = Column(String(5), nullable=True)
|
|
|
|
is_available = Column(Boolean, default=True)
|
|
price_kopeks = Column(Integer, default=0)
|
|
|
|
description = Column(Text, nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
@property
|
|
def price_rubles(self) -> float:
|
|
return self.price_kopeks / 100
|
|
|
|
|
|
class ServiceRule(Base):
|
|
__tablename__ = 'service_rules'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
order = Column(Integer, default=0)
|
|
title = Column(String(255), nullable=False)
|
|
|
|
content = Column(Text, nullable=False)
|
|
|
|
is_active = Column(Boolean, default=True)
|
|
|
|
language = Column(String(5), default='ru')
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
|
|
class PrivacyPolicy(Base):
|
|
__tablename__ = 'privacy_policies'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
language = Column(String(10), nullable=False, unique=True)
|
|
content = Column(Text, nullable=False)
|
|
is_enabled = Column(Boolean, default=True, nullable=False)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
|
|
class PublicOffer(Base):
|
|
__tablename__ = 'public_offers'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
language = Column(String(10), nullable=False, unique=True)
|
|
content = Column(Text, nullable=False)
|
|
is_enabled = Column(Boolean, default=True, nullable=False)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
|
|
class FaqSetting(Base):
|
|
__tablename__ = 'faq_settings'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
language = Column(String(10), nullable=False, unique=True)
|
|
is_enabled = Column(Boolean, default=True, nullable=False)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
|
|
class FaqPage(Base):
|
|
__tablename__ = 'faq_pages'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
language = Column(String(10), nullable=False, index=True)
|
|
title = Column(String(255), nullable=False)
|
|
content = Column(Text, nullable=False)
|
|
display_order = Column(Integer, default=0, nullable=False)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
|
|
class SystemSetting(Base):
|
|
__tablename__ = 'system_settings'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
key = Column(String(255), unique=True, nullable=False)
|
|
value = Column(Text, nullable=True)
|
|
description = Column(Text, nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
|
|
class MonitoringLog(Base):
|
|
__tablename__ = 'monitoring_logs'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
event_type = Column(String(100), nullable=False)
|
|
|
|
message = Column(Text, nullable=False)
|
|
data = Column(JSON, nullable=True)
|
|
|
|
is_success = Column(Boolean, default=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
|
|
class SentNotification(Base):
|
|
__tablename__ = 'sent_notifications'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False)
|
|
notification_type = Column(String(50), nullable=False)
|
|
days_before = Column(Integer, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
user = relationship('User', backref='sent_notifications')
|
|
subscription = relationship('Subscription', backref='sent_notifications')
|
|
|
|
|
|
class SubscriptionEvent(Base):
|
|
__tablename__ = 'subscription_events'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
event_type = Column(String(50), nullable=False)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='SET NULL'), nullable=True)
|
|
transaction_id = Column(Integer, ForeignKey('transactions.id', ondelete='SET NULL'), nullable=True)
|
|
amount_kopeks = Column(Integer, nullable=True)
|
|
currency = Column(String(16), nullable=True)
|
|
message = Column(Text, nullable=True)
|
|
occurred_at = Column(DateTime, nullable=False, default=func.now())
|
|
extra = Column(JSON, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
user = relationship('User', backref='subscription_events')
|
|
subscription = relationship('Subscription', backref='subscription_events')
|
|
transaction = relationship('Transaction', backref='subscription_events')
|
|
|
|
|
|
class DiscountOffer(Base):
|
|
__tablename__ = 'discount_offers'
|
|
__table_args__ = (Index('ix_discount_offers_user_type', 'user_id', 'notification_type'),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='SET NULL'), nullable=True)
|
|
notification_type = Column(String(50), nullable=False)
|
|
discount_percent = Column(Integer, nullable=False, default=0)
|
|
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
|
|
expires_at = Column(DateTime, nullable=False)
|
|
claimed_at = Column(DateTime, nullable=True)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
effect_type = Column(String(50), nullable=False, default='percent_discount')
|
|
extra_data = Column(JSON, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
user = relationship('User', back_populates='discount_offers')
|
|
subscription = relationship('Subscription', back_populates='discount_offers')
|
|
logs = relationship('PromoOfferLog', back_populates='offer')
|
|
|
|
|
|
class PromoOfferTemplate(Base):
|
|
__tablename__ = 'promo_offer_templates'
|
|
__table_args__ = (Index('ix_promo_offer_templates_type', 'offer_type'),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(255), nullable=False)
|
|
offer_type = Column(String(50), nullable=False)
|
|
message_text = Column(Text, nullable=False)
|
|
button_text = Column(String(255), nullable=False)
|
|
valid_hours = Column(Integer, nullable=False, default=24)
|
|
discount_percent = Column(Integer, nullable=False, default=0)
|
|
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
|
|
active_discount_hours = Column(Integer, nullable=True)
|
|
test_duration_hours = Column(Integer, nullable=True)
|
|
test_squad_uuids = Column(JSON, default=list)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
creator = relationship('User')
|
|
|
|
|
|
class SubscriptionTemporaryAccess(Base):
|
|
__tablename__ = 'subscription_temporary_access'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False)
|
|
offer_id = Column(Integer, ForeignKey('discount_offers.id', ondelete='CASCADE'), nullable=False)
|
|
squad_uuid = Column(String(255), nullable=False)
|
|
expires_at = Column(DateTime, nullable=False)
|
|
created_at = Column(DateTime, default=func.now())
|
|
deactivated_at = Column(DateTime, nullable=True)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
was_already_connected = Column(Boolean, default=False, nullable=False)
|
|
|
|
subscription = relationship('Subscription', back_populates='temporary_accesses')
|
|
offer = relationship('DiscountOffer')
|
|
|
|
|
|
class PromoOfferLog(Base):
|
|
__tablename__ = 'promo_offer_logs'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
|
offer_id = Column(Integer, ForeignKey('discount_offers.id', ondelete='SET NULL'), nullable=True, index=True)
|
|
action = Column(String(50), nullable=False)
|
|
source = Column(String(100), nullable=True)
|
|
percent = Column(Integer, nullable=True)
|
|
effect_type = Column(String(50), nullable=True)
|
|
details = Column(JSON, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
user = relationship('User', back_populates='promo_offer_logs')
|
|
offer = relationship('DiscountOffer', back_populates='logs')
|
|
|
|
|
|
class BroadcastHistory(Base):
|
|
__tablename__ = 'broadcast_history'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
target_type = Column(String(100), nullable=False)
|
|
message_text = Column(Text, nullable=False)
|
|
has_media = Column(Boolean, default=False)
|
|
media_type = Column(String(20), nullable=True)
|
|
media_file_id = Column(String(255), nullable=True)
|
|
media_caption = Column(Text, nullable=True)
|
|
total_count = Column(Integer, default=0)
|
|
sent_count = Column(Integer, default=0)
|
|
failed_count = Column(Integer, default=0)
|
|
status = Column(String(50), default='in_progress')
|
|
admin_id = Column(Integer, ForeignKey('users.id'))
|
|
admin_name = Column(String(255))
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
completed_at = Column(DateTime(timezone=True), nullable=True)
|
|
admin = relationship('User', back_populates='broadcasts')
|
|
|
|
|
|
class Poll(Base):
|
|
__tablename__ = 'polls'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
title = Column(String(255), nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
reward_enabled = Column(Boolean, nullable=False, default=False)
|
|
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
|
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
|
created_at = Column(DateTime, default=func.now(), nullable=False)
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
|
|
|
|
creator = relationship('User', backref='created_polls', foreign_keys=[created_by])
|
|
questions = relationship(
|
|
'PollQuestion',
|
|
back_populates='poll',
|
|
cascade='all, delete-orphan',
|
|
order_by='PollQuestion.order',
|
|
)
|
|
responses = relationship(
|
|
'PollResponse',
|
|
back_populates='poll',
|
|
cascade='all, delete-orphan',
|
|
)
|
|
|
|
|
|
class PollQuestion(Base):
|
|
__tablename__ = 'poll_questions'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
poll_id = Column(Integer, ForeignKey('polls.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
text = Column(Text, nullable=False)
|
|
order = Column(Integer, nullable=False, default=0)
|
|
|
|
poll = relationship('Poll', back_populates='questions')
|
|
options = relationship(
|
|
'PollOption',
|
|
back_populates='question',
|
|
cascade='all, delete-orphan',
|
|
order_by='PollOption.order',
|
|
)
|
|
answers = relationship('PollAnswer', back_populates='question')
|
|
|
|
|
|
class PollOption(Base):
|
|
__tablename__ = 'poll_options'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
question_id = Column(Integer, ForeignKey('poll_questions.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
text = Column(Text, nullable=False)
|
|
order = Column(Integer, nullable=False, default=0)
|
|
|
|
question = relationship('PollQuestion', back_populates='options')
|
|
answers = relationship('PollAnswer', back_populates='option')
|
|
|
|
|
|
class PollResponse(Base):
|
|
__tablename__ = 'poll_responses'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
poll_id = Column(Integer, ForeignKey('polls.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
sent_at = Column(DateTime, default=func.now(), nullable=False)
|
|
started_at = Column(DateTime, nullable=True)
|
|
completed_at = Column(DateTime, nullable=True)
|
|
reward_given = Column(Boolean, nullable=False, default=False)
|
|
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
|
|
|
poll = relationship('Poll', back_populates='responses')
|
|
user = relationship('User', back_populates='poll_responses')
|
|
answers = relationship(
|
|
'PollAnswer',
|
|
back_populates='response',
|
|
cascade='all, delete-orphan',
|
|
)
|
|
|
|
__table_args__ = (UniqueConstraint('poll_id', 'user_id', name='uq_poll_user'),)
|
|
|
|
|
|
class PollAnswer(Base):
|
|
__tablename__ = 'poll_answers'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
response_id = Column(Integer, ForeignKey('poll_responses.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
question_id = Column(Integer, ForeignKey('poll_questions.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
option_id = Column(Integer, ForeignKey('poll_options.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
created_at = Column(DateTime, default=func.now(), nullable=False)
|
|
|
|
response = relationship('PollResponse', back_populates='answers')
|
|
question = relationship('PollQuestion', back_populates='answers')
|
|
option = relationship('PollOption', back_populates='answers')
|
|
|
|
__table_args__ = (UniqueConstraint('response_id', 'question_id', name='uq_poll_answer_unique'),)
|
|
|
|
|
|
class ServerSquad(Base):
|
|
__tablename__ = 'server_squads'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
squad_uuid = Column(String(255), unique=True, nullable=False, index=True)
|
|
|
|
display_name = Column(String(255), nullable=False)
|
|
|
|
original_name = Column(String(255), nullable=True)
|
|
|
|
country_code = Column(String(5), nullable=True)
|
|
|
|
is_available = Column(Boolean, default=True)
|
|
is_trial_eligible = Column(Boolean, default=False, nullable=False)
|
|
|
|
price_kopeks = Column(Integer, default=0)
|
|
|
|
description = Column(Text, nullable=True)
|
|
|
|
sort_order = Column(Integer, default=0)
|
|
|
|
max_users = Column(Integer, nullable=True)
|
|
current_users = Column(Integer, default=0)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
allowed_promo_groups = relationship(
|
|
'PromoGroup',
|
|
secondary=server_squad_promo_groups,
|
|
back_populates='server_squads',
|
|
lazy='selectin',
|
|
)
|
|
|
|
@property
|
|
def price_rubles(self) -> float:
|
|
return self.price_kopeks / 100
|
|
|
|
@property
|
|
def is_full(self) -> bool:
|
|
if self.max_users is None:
|
|
return False
|
|
return self.current_users >= self.max_users
|
|
|
|
@property
|
|
def availability_status(self) -> str:
|
|
if not self.is_available:
|
|
return 'Недоступен'
|
|
if self.is_full:
|
|
return 'Переполнен'
|
|
return 'Доступен'
|
|
|
|
|
|
class SubscriptionServer(Base):
|
|
__tablename__ = 'subscription_servers'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
subscription_id = Column(Integer, ForeignKey('subscriptions.id'), nullable=False)
|
|
server_squad_id = Column(Integer, ForeignKey('server_squads.id'), nullable=False)
|
|
|
|
connected_at = Column(DateTime, default=func.now())
|
|
|
|
paid_price_kopeks = Column(Integer, default=0)
|
|
|
|
subscription = relationship('Subscription', backref='subscription_servers')
|
|
server_squad = relationship('ServerSquad', backref='subscription_servers')
|
|
|
|
|
|
class SupportAuditLog(Base):
|
|
__tablename__ = 'support_audit_logs'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
actor_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
|
actor_telegram_id = Column(BigInteger, nullable=True) # Can be None for email-only users
|
|
is_moderator = Column(Boolean, default=False)
|
|
action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user
|
|
ticket_id = Column(Integer, ForeignKey('tickets.id', ondelete='SET NULL'), nullable=True)
|
|
target_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
|
details = Column(JSON, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
actor = relationship('User', foreign_keys=[actor_user_id])
|
|
ticket = relationship('Ticket', foreign_keys=[ticket_id])
|
|
|
|
|
|
class UserMessage(Base):
|
|
__tablename__ = 'user_messages'
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
message_text = Column(Text, nullable=False)
|
|
is_active = Column(Boolean, default=True)
|
|
sort_order = Column(Integer, default=0)
|
|
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
creator = relationship('User', backref='created_messages')
|
|
|
|
def __repr__(self):
|
|
return f"<UserMessage(id={self.id}, active={self.is_active}, text='{self.message_text[:50]}...')>"
|
|
|
|
|
|
class WelcomeText(Base):
|
|
__tablename__ = 'welcome_texts'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
text_content = Column(Text, nullable=False)
|
|
is_active = Column(Boolean, default=True)
|
|
is_enabled = Column(Boolean, default=True)
|
|
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
creator = relationship('User', backref='created_welcome_texts')
|
|
|
|
|
|
class PinnedMessage(Base):
|
|
__tablename__ = 'pinned_messages'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
content = Column(Text, nullable=False, default='')
|
|
media_type = Column(String(32), nullable=True)
|
|
media_file_id = Column(String(255), nullable=True)
|
|
send_before_menu = Column(Boolean, nullable=False, server_default='1', default=True)
|
|
send_on_every_start = Column(Boolean, nullable=False, server_default='1', default=True)
|
|
is_active = Column(Boolean, default=True)
|
|
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
creator = relationship('User', backref='pinned_messages')
|
|
|
|
|
|
class AdvertisingCampaign(Base):
|
|
__tablename__ = 'advertising_campaigns'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(255), nullable=False)
|
|
start_parameter = Column(String(64), nullable=False, unique=True, index=True)
|
|
bonus_type = Column(String(20), nullable=False)
|
|
|
|
balance_bonus_kopeks = Column(Integer, default=0)
|
|
|
|
subscription_duration_days = Column(Integer, nullable=True)
|
|
subscription_traffic_gb = Column(Integer, nullable=True)
|
|
subscription_device_limit = Column(Integer, nullable=True)
|
|
subscription_squads = Column(JSON, default=list)
|
|
|
|
# Поля для типа "tariff" - выдача тарифа
|
|
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True)
|
|
tariff_duration_days = Column(Integer, nullable=True)
|
|
|
|
is_active = Column(Boolean, default=True)
|
|
|
|
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
registrations = relationship('AdvertisingCampaignRegistration', back_populates='campaign')
|
|
tariff = relationship('Tariff', foreign_keys=[tariff_id])
|
|
|
|
@property
|
|
def is_balance_bonus(self) -> bool:
|
|
return self.bonus_type == 'balance'
|
|
|
|
@property
|
|
def is_subscription_bonus(self) -> bool:
|
|
return self.bonus_type == 'subscription'
|
|
|
|
@property
|
|
def is_none_bonus(self) -> bool:
|
|
"""Ссылка без награды - только для отслеживания."""
|
|
return self.bonus_type == 'none'
|
|
|
|
@property
|
|
def is_tariff_bonus(self) -> bool:
|
|
"""Выдача тарифа на определённое время."""
|
|
return self.bonus_type == 'tariff'
|
|
|
|
|
|
class AdvertisingCampaignRegistration(Base):
|
|
__tablename__ = 'advertising_campaign_registrations'
|
|
__table_args__ = (UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
campaign_id = Column(Integer, ForeignKey('advertising_campaigns.id', ondelete='CASCADE'), nullable=False)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
|
|
bonus_type = Column(String(20), nullable=False)
|
|
balance_bonus_kopeks = Column(Integer, default=0)
|
|
subscription_duration_days = Column(Integer, nullable=True)
|
|
|
|
# Поля для типа "tariff"
|
|
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True)
|
|
tariff_duration_days = Column(Integer, nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
campaign = relationship('AdvertisingCampaign', back_populates='registrations')
|
|
user = relationship('User')
|
|
tariff = relationship('Tariff')
|
|
|
|
@property
|
|
def balance_bonus_rubles(self) -> float:
|
|
return (self.balance_bonus_kopeks or 0) / 100
|
|
|
|
|
|
class TicketStatus(Enum):
|
|
OPEN = 'open'
|
|
ANSWERED = 'answered'
|
|
CLOSED = 'closed'
|
|
PENDING = 'pending'
|
|
|
|
|
|
class Ticket(Base):
|
|
__tablename__ = 'tickets'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
|
|
title = Column(String(255), nullable=False)
|
|
status = Column(String(20), default=TicketStatus.OPEN.value, nullable=False)
|
|
priority = Column(String(20), default='normal', nullable=False) # low, normal, high, urgent
|
|
# Блокировка ответов пользователя в этом тикете
|
|
user_reply_block_permanent = Column(Boolean, default=False, nullable=False)
|
|
user_reply_block_until = Column(DateTime, nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
closed_at = Column(DateTime, nullable=True)
|
|
# SLA reminders
|
|
last_sla_reminder_at = Column(DateTime, nullable=True)
|
|
|
|
# Связи
|
|
user = relationship('User', backref='tickets')
|
|
messages = relationship('TicketMessage', back_populates='ticket', cascade='all, delete-orphan')
|
|
|
|
@property
|
|
def is_open(self) -> bool:
|
|
return self.status == TicketStatus.OPEN.value
|
|
|
|
@property
|
|
def is_answered(self) -> bool:
|
|
return self.status == TicketStatus.ANSWERED.value
|
|
|
|
@property
|
|
def is_closed(self) -> bool:
|
|
return self.status == TicketStatus.CLOSED.value
|
|
|
|
@property
|
|
def is_pending(self) -> bool:
|
|
return self.status == TicketStatus.PENDING.value
|
|
|
|
@property
|
|
def is_user_reply_blocked(self) -> bool:
|
|
if self.user_reply_block_permanent:
|
|
return True
|
|
if self.user_reply_block_until:
|
|
try:
|
|
from datetime import datetime
|
|
|
|
return self.user_reply_block_until > datetime.utcnow()
|
|
except Exception:
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def status_emoji(self) -> str:
|
|
status_emojis = {
|
|
TicketStatus.OPEN.value: '🔴',
|
|
TicketStatus.ANSWERED.value: '🟡',
|
|
TicketStatus.CLOSED.value: '🟢',
|
|
TicketStatus.PENDING.value: '⏳',
|
|
}
|
|
return status_emojis.get(self.status, '❓')
|
|
|
|
@property
|
|
def priority_emoji(self) -> str:
|
|
priority_emojis = {'low': '🟢', 'normal': '🟡', 'high': '🟠', 'urgent': '🔴'}
|
|
return priority_emojis.get(self.priority, '🟡')
|
|
|
|
def __repr__(self):
|
|
return f"<Ticket(id={self.id}, user_id={self.user_id}, status={self.status}, title='{self.title[:30]}...')>"
|
|
|
|
|
|
class TicketMessage(Base):
|
|
__tablename__ = 'ticket_messages'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
ticket_id = Column(Integer, ForeignKey('tickets.id', ondelete='CASCADE'), nullable=False)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
|
|
message_text = Column(Text, nullable=False)
|
|
is_from_admin = Column(Boolean, default=False, nullable=False)
|
|
|
|
# Для медиа файлов
|
|
has_media = Column(Boolean, default=False)
|
|
media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc.
|
|
media_file_id = Column(String(255), nullable=True)
|
|
media_caption = Column(Text, nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
# Связи
|
|
ticket = relationship('Ticket', back_populates='messages')
|
|
user = relationship('User')
|
|
|
|
@property
|
|
def is_user_message(self) -> bool:
|
|
return not self.is_from_admin
|
|
|
|
@property
|
|
def is_admin_message(self) -> bool:
|
|
return self.is_from_admin
|
|
|
|
def __repr__(self):
|
|
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
|
|
|
|
|
class WebApiToken(Base):
|
|
__tablename__ = 'web_api_tokens'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(255), nullable=False)
|
|
token_hash = Column(String(128), nullable=False, unique=True, index=True)
|
|
token_prefix = Column(String(32), nullable=False, index=True)
|
|
description = Column(Text, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
expires_at = Column(DateTime, nullable=True)
|
|
last_used_at = Column(DateTime, nullable=True)
|
|
last_used_ip = Column(String(64), nullable=True)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
created_by = Column(String(255), nullable=True)
|
|
|
|
def __repr__(self) -> str:
|
|
status = 'active' if self.is_active else 'revoked'
|
|
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
|
|
|
|
|
class MainMenuButton(Base):
|
|
__tablename__ = 'main_menu_buttons'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
text = Column(String(64), nullable=False)
|
|
action_type = Column(String(20), nullable=False)
|
|
action_value = Column(Text, nullable=False)
|
|
visibility = Column(String(20), nullable=False, default=MainMenuButtonVisibility.ALL.value)
|
|
is_active = Column(Boolean, nullable=False, default=True)
|
|
display_order = Column(Integer, nullable=False, default=0)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
__table_args__ = (Index('ix_main_menu_buttons_order', 'display_order', 'id'),)
|
|
|
|
@property
|
|
def action_type_enum(self) -> MainMenuButtonActionType:
|
|
try:
|
|
return MainMenuButtonActionType(self.action_type)
|
|
except ValueError:
|
|
return MainMenuButtonActionType.URL
|
|
|
|
@property
|
|
def visibility_enum(self) -> MainMenuButtonVisibility:
|
|
try:
|
|
return MainMenuButtonVisibility(self.visibility)
|
|
except ValueError:
|
|
return MainMenuButtonVisibility.ALL
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"<MainMenuButton id={self.id} text='{self.text}' "
|
|
f'action={self.action_type} visibility={self.visibility} active={self.is_active}>'
|
|
)
|
|
|
|
|
|
class MenuLayoutHistory(Base):
|
|
"""История изменений конфигурации меню."""
|
|
|
|
__tablename__ = 'menu_layout_history'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
config_json = Column(Text, nullable=False) # Полная конфигурация в JSON
|
|
action = Column(String(50), nullable=False) # update, reset, import
|
|
changes_summary = Column(Text, nullable=True) # Краткое описание изменений
|
|
user_info = Column(String(255), nullable=True) # Информация о пользователе/токене
|
|
created_at = Column(DateTime, default=func.now(), index=True)
|
|
|
|
__table_args__ = (Index('ix_menu_layout_history_created', 'created_at'),)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<MenuLayoutHistory id={self.id} action='{self.action}' created_at={self.created_at}>"
|
|
|
|
|
|
class ButtonClickLog(Base):
|
|
"""Логи кликов по кнопкам меню."""
|
|
|
|
__tablename__ = 'button_click_logs'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
button_id = Column(String(100), nullable=False, index=True) # ID кнопки
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
|
callback_data = Column(String(255), nullable=True) # callback_data кнопки
|
|
clicked_at = Column(DateTime, default=func.now(), index=True)
|
|
|
|
# Дополнительная информация
|
|
button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app
|
|
button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика
|
|
|
|
__table_args__ = (
|
|
Index('ix_button_click_logs_button_date', 'button_id', 'clicked_at'),
|
|
Index('ix_button_click_logs_user_date', 'user_id', 'clicked_at'),
|
|
)
|
|
|
|
# Связи
|
|
user = relationship('User', foreign_keys=[user_id])
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<ButtonClickLog id={self.id} button='{self.button_id}' user={self.user_id} at={self.clicked_at}>"
|
|
|
|
|
|
class Webhook(Base):
|
|
"""Webhook конфигурация для подписки на события."""
|
|
|
|
__tablename__ = 'webhooks'
|
|
__table_args__ = (
|
|
Index('ix_webhooks_event_type', 'event_type'),
|
|
Index('ix_webhooks_is_active', 'is_active'),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(255), nullable=False)
|
|
url = Column(Text, nullable=False)
|
|
secret = Column(String(128), nullable=True) # Секрет для подписи payload
|
|
event_type = Column(String(50), nullable=False) # user.created, payment.completed, ticket.created, etc.
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
last_triggered_at = Column(DateTime, nullable=True)
|
|
failure_count = Column(Integer, default=0, nullable=False)
|
|
success_count = Column(Integer, default=0, nullable=False)
|
|
|
|
deliveries = relationship('WebhookDelivery', back_populates='webhook', cascade='all, delete-orphan')
|
|
|
|
def __repr__(self) -> str:
|
|
status = 'active' if self.is_active else 'inactive'
|
|
return f"<Webhook id={self.id} name='{self.name}' event='{self.event_type}' status={status}>"
|
|
|
|
|
|
class WebhookDelivery(Base):
|
|
"""История доставки webhooks."""
|
|
|
|
__tablename__ = 'webhook_deliveries'
|
|
__table_args__ = (
|
|
Index('ix_webhook_deliveries_webhook_created', 'webhook_id', 'created_at'),
|
|
Index('ix_webhook_deliveries_status', 'status'),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
webhook_id = Column(Integer, ForeignKey('webhooks.id', ondelete='CASCADE'), nullable=False)
|
|
event_type = Column(String(50), nullable=False)
|
|
payload = Column(JSON, nullable=False) # Отправленный payload
|
|
response_status = Column(Integer, nullable=True) # HTTP статус ответа
|
|
response_body = Column(Text, nullable=True) # Тело ответа (может быть обрезано)
|
|
status = Column(String(20), nullable=False) # pending, success, failed
|
|
error_message = Column(Text, nullable=True)
|
|
attempt_number = Column(Integer, default=1, nullable=False)
|
|
created_at = Column(DateTime, default=func.now())
|
|
delivered_at = Column(DateTime, nullable=True)
|
|
next_retry_at = Column(DateTime, nullable=True)
|
|
|
|
webhook = relationship('Webhook', back_populates='deliveries')
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<WebhookDelivery id={self.id} webhook_id={self.webhook_id} status='{self.status}' event='{self.event_type}'>"
|
|
|
|
|
|
class CabinetRefreshToken(Base):
|
|
"""Refresh tokens for cabinet JWT authentication."""
|
|
|
|
__tablename__ = 'cabinet_refresh_tokens'
|
|
__table_args__ = (Index('ix_cabinet_refresh_tokens_user', 'user_id'),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
token_hash = Column(String(255), unique=True, nullable=False, index=True)
|
|
device_info = Column(String(500), nullable=True)
|
|
expires_at = Column(DateTime, nullable=False)
|
|
created_at = Column(DateTime, default=func.now())
|
|
revoked_at = Column(DateTime, nullable=True)
|
|
|
|
user = relationship('User', backref='cabinet_tokens')
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
return datetime.utcnow() > self.expires_at
|
|
|
|
@property
|
|
def is_revoked(self) -> bool:
|
|
return self.revoked_at is not None
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
return not self.is_expired and not self.is_revoked
|
|
|
|
def __repr__(self) -> str:
|
|
status = 'valid' if self.is_valid else ('revoked' if self.is_revoked else 'expired')
|
|
return f'<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>'
|
|
|
|
|
|
# ==================== FORTUNE WHEEL ====================
|
|
|
|
|
|
class WheelConfig(Base):
|
|
"""Глобальная конфигурация колеса удачи."""
|
|
|
|
__tablename__ = 'wheel_configs'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# Основные настройки
|
|
is_enabled = Column(Boolean, default=False, nullable=False)
|
|
name = Column(String(255), default='Колесо удачи', nullable=False)
|
|
|
|
# Стоимость спина
|
|
spin_cost_stars = Column(Integer, default=10, nullable=False) # Стоимость в Stars
|
|
spin_cost_days = Column(Integer, default=1, nullable=False) # Стоимость в днях подписки
|
|
spin_cost_stars_enabled = Column(Boolean, default=True, nullable=False)
|
|
spin_cost_days_enabled = Column(Boolean, default=True, nullable=False)
|
|
|
|
# RTP настройки (Return to Player) - процент возврата 0-100
|
|
rtp_percent = Column(Integer, default=80, nullable=False)
|
|
|
|
# Лимиты
|
|
daily_spin_limit = Column(Integer, default=5, nullable=False) # 0 = без лимита
|
|
min_subscription_days_for_day_payment = Column(Integer, default=3, nullable=False)
|
|
|
|
# Генерация промокодов
|
|
promo_prefix = Column(String(20), default='WHEEL', nullable=False)
|
|
promo_validity_days = Column(Integer, default=7, nullable=False)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
prizes = relationship('WheelPrize', back_populates='config', cascade='all, delete-orphan')
|
|
|
|
def __repr__(self) -> str:
|
|
return f'<WheelConfig id={self.id} enabled={self.is_enabled} rtp={self.rtp_percent}%>'
|
|
|
|
|
|
class WheelPrize(Base):
|
|
"""Приз на колесе удачи."""
|
|
|
|
__tablename__ = 'wheel_prizes'
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
config_id = Column(Integer, ForeignKey('wheel_configs.id', ondelete='CASCADE'), nullable=False)
|
|
|
|
# Тип и значение приза
|
|
prize_type = Column(String(50), nullable=False) # WheelPrizeType
|
|
prize_value = Column(Integer, default=0, nullable=False) # Дни/копейки/GB в зависимости от типа
|
|
|
|
# Отображение
|
|
display_name = Column(String(100), nullable=False)
|
|
emoji = Column(String(10), default='🎁', nullable=False)
|
|
color = Column(String(20), default='#3B82F6', nullable=False) # HEX цвет сектора
|
|
|
|
# Стоимость приза для расчета RTP (в копейках)
|
|
prize_value_kopeks = Column(Integer, default=0, nullable=False)
|
|
|
|
# Порядок и вероятность
|
|
sort_order = Column(Integer, default=0, nullable=False)
|
|
manual_probability = Column(Float, nullable=True) # Если задано - игнорирует RTP расчет (0.0-1.0)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
# Настройки генерируемого промокода (только для prize_type=promocode)
|
|
promo_balance_bonus_kopeks = Column(Integer, default=0)
|
|
promo_subscription_days = Column(Integer, default=0)
|
|
promo_traffic_gb = Column(Integer, default=0)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
|
|
|
config = relationship('WheelConfig', back_populates='prizes')
|
|
spins = relationship('WheelSpin', back_populates='prize')
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<WheelPrize id={self.id} type={self.prize_type} name='{self.display_name}'>"
|
|
|
|
|
|
class WheelSpin(Base):
|
|
"""История спинов колеса удачи."""
|
|
|
|
__tablename__ = 'wheel_spins'
|
|
__table_args__ = (Index('ix_wheel_spins_user_created', 'user_id', 'created_at'),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
prize_id = Column(Integer, ForeignKey('wheel_prizes.id', ondelete='SET NULL'), nullable=True)
|
|
|
|
# Способ оплаты
|
|
payment_type = Column(String(50), nullable=False) # WheelSpinPaymentType
|
|
payment_amount = Column(Integer, nullable=False) # Stars или дни
|
|
payment_value_kopeks = Column(Integer, nullable=False) # Эквивалент в копейках для статистики
|
|
|
|
# Результат
|
|
prize_type = Column(String(50), nullable=False) # Копируем из WheelPrize на момент спина
|
|
prize_value = Column(Integer, nullable=False)
|
|
prize_display_name = Column(String(100), nullable=False)
|
|
prize_value_kopeks = Column(Integer, nullable=False) # Стоимость приза в копейках
|
|
|
|
# Сгенерированный промокод (если приз - промокод)
|
|
generated_promocode_id = Column(Integer, ForeignKey('promocodes.id'), nullable=True)
|
|
|
|
# Флаг успешного начисления
|
|
is_applied = Column(Boolean, default=False, nullable=False)
|
|
applied_at = Column(DateTime, nullable=True)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
user = relationship('User', backref='wheel_spins')
|
|
prize = relationship('WheelPrize', back_populates='spins')
|
|
generated_promocode = relationship('PromoCode')
|
|
|
|
@property
|
|
def prize_value_rubles(self) -> float:
|
|
"""Стоимость приза в рублях."""
|
|
return self.prize_value_kopeks / 100
|
|
|
|
@property
|
|
def payment_value_rubles(self) -> float:
|
|
"""Стоимость оплаты в рублях."""
|
|
return self.payment_value_kopeks / 100
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<WheelSpin id={self.id} user_id={self.user_id} prize='{self.prize_display_name}'>"
|
|
|
|
|
|
class TicketNotification(Base):
|
|
"""Уведомления о тикетах для кабинета (веб-интерфейс)."""
|
|
|
|
__tablename__ = 'ticket_notifications'
|
|
__table_args__ = (
|
|
Index('ix_ticket_notifications_user_read', 'user_id', 'is_read'),
|
|
Index('ix_ticket_notifications_admin_read', 'is_for_admin', 'is_read'),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
ticket_id = Column(Integer, ForeignKey('tickets.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
|
|
# Тип уведомления: new_ticket, admin_reply, user_reply
|
|
notification_type = Column(String(50), nullable=False)
|
|
|
|
# Текст уведомления
|
|
message = Column(Text, nullable=True)
|
|
|
|
# Для админа или для пользователя
|
|
is_for_admin = Column(Boolean, default=False, nullable=False)
|
|
|
|
# Прочитано ли уведомление
|
|
is_read = Column(Boolean, default=False, nullable=False)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
read_at = Column(DateTime, nullable=True)
|
|
|
|
ticket = relationship('Ticket', backref='notifications')
|
|
user = relationship('User', backref='ticket_notifications')
|
|
|
|
def __repr__(self) -> str:
|
|
return f'<TicketNotification id={self.id} type={self.notification_type} for_admin={self.is_for_admin}>'
|