mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
1187 lines
41 KiB
Python
1187 lines
41 KiB
Python
from datetime import datetime, timedelta
|
|
from typing import Optional, List, Dict
|
|
from enum import Enum
|
|
|
|
from sqlalchemy import (
|
|
Column,
|
|
Integer,
|
|
String,
|
|
DateTime,
|
|
Boolean,
|
|
Text,
|
|
ForeignKey,
|
|
Float,
|
|
JSON,
|
|
BigInteger,
|
|
UniqueConstraint,
|
|
Index,
|
|
Table,
|
|
)
|
|
from sqlalchemy.ext.declarative import declarative_base
|
|
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
|
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,
|
|
),
|
|
)
|
|
|
|
|
|
class UserStatus(Enum):
|
|
ACTIVE = "active"
|
|
BLOCKED = "blocked"
|
|
DELETED = "deleted"
|
|
|
|
|
|
class SubscriptionStatus(Enum):
|
|
TRIAL = "trial"
|
|
ACTIVE = "active"
|
|
EXPIRED = "expired"
|
|
DISABLED = "disabled"
|
|
|
|
|
|
class TransactionType(Enum):
|
|
DEPOSIT = "deposit"
|
|
WITHDRAWAL = "withdrawal"
|
|
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
|
REFUND = "refund"
|
|
REFERRAL_REWARD = "referral_reward"
|
|
|
|
|
|
class PromoCodeType(Enum):
|
|
BALANCE = "balance"
|
|
SUBSCRIPTION_DAYS = "subscription_days"
|
|
TRIAL_SUBSCRIPTION = "trial_subscription"
|
|
|
|
|
|
class PaymentMethod(Enum):
|
|
TELEGRAM_STARS = "telegram_stars"
|
|
TRIBUTE = "tribute"
|
|
YOOKASSA = "yookassa"
|
|
CRYPTOBOT = "cryptobot"
|
|
MULENPAY = "mulenpay"
|
|
PAL24 = "pal24"
|
|
MANUAL = "manual"
|
|
|
|
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 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 (
|
|
"<MulenPayPayment(id={0}, mulen_id={1}, amount={2}₽, status={3})>".format(
|
|
self.id,
|
|
self.mulen_payment_id,
|
|
self.amount_rubles,
|
|
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 (
|
|
"<Pal24Payment(id={0}, bill_id={1}, amount={2}₽, status={3})>".format(
|
|
self.id,
|
|
self.bill_id,
|
|
self.amount_rubles,
|
|
self.status,
|
|
)
|
|
)
|
|
|
|
|
|
class PromoGroup(Base):
|
|
__tablename__ = "promo_groups"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(255), unique=True, nullable=False)
|
|
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")
|
|
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: Optional[int]) -> 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: Optional[int] = 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, 0)
|
|
|
|
return max(0, min(100, percent))
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
|
|
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)
|
|
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")
|
|
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)
|
|
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=False, index=True)
|
|
promo_group = relationship("PromoGroup", back_populates="users")
|
|
|
|
@property
|
|
def balance_rubles(self) -> float:
|
|
return self.balance_kopeks / 100
|
|
|
|
@property
|
|
def full_name(self) -> str:
|
|
parts = [self.first_name, self.last_name]
|
|
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
|
|
|
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
|
|
if not self.promo_group:
|
|
return 0
|
|
return self.promo_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)
|
|
|
|
subscription_url = Column(String, nullable=True)
|
|
subscription_crypto_link = Column(String, nullable=True)
|
|
|
|
device_limit = Column(Integer, default=1)
|
|
|
|
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)
|
|
|
|
user = relationship("User", back_populates="subscription")
|
|
discount_offers = relationship("DiscountOffer", back_populates="subscription")
|
|
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
|
|
|
|
@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"
|
|
else:
|
|
return "active"
|
|
|
|
if self.status == SubscriptionStatus.TRIAL.value:
|
|
if self.end_date <= current_time:
|
|
return "expired"
|
|
else:
|
|
return "trial"
|
|
|
|
return self.status
|
|
|
|
@property
|
|
def status_display(self) -> str:
|
|
actual_status = self.actual_status
|
|
current_time = datetime.utcnow()
|
|
|
|
if actual_status == "expired":
|
|
return "🔴 Истекла"
|
|
elif actual_status == "active":
|
|
if self.is_trial:
|
|
return "🎯 Тестовая"
|
|
else:
|
|
return "🟢 Активна"
|
|
elif actual_status == "disabled":
|
|
return "⚫ Отключена"
|
|
elif actual_status == "trial":
|
|
return "🎯 Тестовая"
|
|
|
|
return "❓ Неизвестно"
|
|
|
|
@property
|
|
def status_emoji(self) -> str:
|
|
actual_status = self.actual_status
|
|
|
|
if actual_status == "expired":
|
|
return "🔴"
|
|
elif actual_status == "active":
|
|
if self.is_trial:
|
|
return "🎁"
|
|
else:
|
|
return "💎"
|
|
elif actual_status == "disabled":
|
|
return "⚫"
|
|
elif 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} дн."
|
|
elif hours > 0:
|
|
return f"{hours} ч."
|
|
else:
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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())
|
|
|
|
uses = relationship("PromoCodeUse", back_populates="promocode")
|
|
|
|
@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 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 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 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="balance_bonus")
|
|
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")
|
|
|
|
|
|
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)
|
|
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 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 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)
|
|
|
|
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 "Недоступен"
|
|
elif self.is_full:
|
|
return "Переполнен"
|
|
else:
|
|
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=False)
|
|
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 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)
|
|
|
|
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")
|
|
|
|
@property
|
|
def is_balance_bonus(self) -> bool:
|
|
return self.bonus_type == "balance"
|
|
|
|
@property
|
|
def is_subscription_bonus(self) -> bool:
|
|
return self.bonus_type == "subscription"
|
|
|
|
|
|
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)
|
|
|
|
created_at = Column(DateTime, default=func.now())
|
|
|
|
campaign = relationship("AdvertisingCampaign", back_populates="registrations")
|
|
user = relationship("User")
|
|
|
|
@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}>" |