from datetime import datetime, timedelta, time, date from typing import Optional, List, Dict, Any from enum import Enum from sqlalchemy import ( Column, Integer, String, DateTime, Date, Time, Boolean, Text, ForeignKey, Float, JSON, BigInteger, UniqueConstraint, Index, Table, SmallInteger, ) 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, ), ) # 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" 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"" 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"" 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 ( "" ).format( id=self.id, uuid=self.uuid, order_id=self.order_id, amount=self.amount, currency=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 ( "".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 ( "".format( self.id, self.bill_id, self.amount_rubles, 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 ( "".format( self.id, self.payment_link_id, self.amount_rubles, 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 ( "".format( self.id, self.platega_transaction_id, self.amount_rubles, self.status, 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(Integer, 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 ( "".format( self.id, self.invoice_id, self.amount_rubles, 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 ( "".format( self.id, self.order_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) 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: 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) 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"" 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) -> Optional[int]: """Возвращает цену в копейках для указанного периода.""" 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) -> Optional[float]: """Возвращает цену в рублях для указанного периода.""" 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'] elif isinstance(server_limit, int): return server_limit return self.traffic_limit_gb def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> 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) -> Optional[int]: """Возвращает цену в копейках для указанного пакета трафика.""" 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) -> Optional[int]: """Возвращает цену для произвольного количества дней.""" 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) -> Optional[int]: """Возвращает цену для произвольного количества трафика.""" 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"" 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) # 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: parts = [self.first_name, self.last_name] return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}" 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: Optional[int] = 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" 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 @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"" 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"" 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"" ) 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 "Недоступен" 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"" 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"" 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"" 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"" 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"" ) 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"" 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(BigInteger, ForeignKey("users.telegram_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"" 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"" 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"" 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"" # ==================== 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"" 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"" 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""