RemnaWave-bot Update

This commit is contained in:
2026-05-06 15:41:56 +03:00
parent ce7a1f70b2
commit 2cc2f12a21
22 changed files with 3087 additions and 129 deletions

View File

@@ -5,15 +5,17 @@ import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from sqlalchemy import delete, func, or_, select
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import Settings
from app.db.base import utcnow
from app.db.models import (
InternalSquad,
PromoCode,
PromoCodeApplication,
ReferralCode,
ReferralInvite,
InternalSquad,
RemnawaveUser,
RemnawaveUserInternalSquad,
SubscriptionRequestLog,
@@ -36,9 +38,33 @@ class ReferralSummary:
total_invited: int = 0
referral_code: str = ""
applied_referral_code: str = ""
applied_promo_code: str = ""
applied_promo_discount_percent: int = 0
applied_promo_plan_code: str | None = None
applied_promo_expires_at: datetime | None = None
recent_names: list[str] = field(default_factory=list)
@dataclass(slots=True)
class PromoCodeView:
code: str
plan_code: str | None
discount_percent: int
expires_at: datetime | None
is_active: bool
created_by_telegram_id: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@property
def is_expired(self) -> bool:
return self.expires_at is not None and self.expires_at <= utcnow()
@property
def is_valid(self) -> bool:
return self.is_active and not self.is_expired and self.discount_percent > 0
@dataclass(slots=True)
class AdminStats:
total_telegram_users: int = 0
@@ -174,6 +200,124 @@ class SyncService:
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
await session.commit()
async def create_promo_code(
self,
*,
code: str,
plan_code: str | None,
discount_percent: int,
expires_at: datetime | None,
created_by_telegram_id: int,
) -> PromoCodeView:
normalized_code = self.normalize_discount_code(code)
if not normalized_code:
raise ValueError("Введите слово для промокода.")
if len(normalized_code) > 32:
raise ValueError("Промокод должен быть не длиннее 32 символов.")
if not all(ch.isalnum() or ch in "_-" for ch in normalized_code):
raise ValueError("Промокод может содержать только буквы, цифры, _ и -.")
resolved_discount = int(discount_percent)
if resolved_discount <= 0 or resolved_discount > 100:
raise ValueError("Скидка должна быть от 1 до 100%.")
resolved_plan_code = (plan_code or "").strip() or None
if resolved_plan_code and len(resolved_plan_code) > 64:
raise ValueError("Код тарифа слишком длинный.")
resolved_expires_at = self._normalize_datetime(expires_at)
if resolved_expires_at is not None and resolved_expires_at <= utcnow():
raise ValueError("Срок действия промокода должен быть в будущем.")
async with self._session_factory() as session:
duplicate = await session.scalar(
select(PromoCode.id).where(PromoCode.code == normalized_code)
)
if duplicate is not None:
raise ValueError("Такой промокод уже существует.")
promo = PromoCode(
code=normalized_code,
plan_code=resolved_plan_code,
discount_percent=resolved_discount,
expires_at=resolved_expires_at,
is_active=True,
created_by_telegram_id=created_by_telegram_id,
)
session.add(promo)
await session.commit()
await session.refresh(promo)
return self._promo_code_view(promo)
async def get_admin_promo_codes(self, *, limit: int = 20) -> list[PromoCodeView]:
resolved_limit = max(1, min(limit, 100))
async with self._session_factory() as session:
rows = list(
(
await session.scalars(
select(PromoCode)
.order_by(PromoCode.created_at.desc(), PromoCode.id.desc())
.limit(resolved_limit)
)
).all()
)
return [self._promo_code_view(row) for row in rows]
async def get_active_promo_for_user(self, telegram_id: int) -> PromoCodeView | None:
async with self._session_factory() as session:
promo = await self._get_active_promo_for_user(session, telegram_id=telegram_id)
return self._promo_code_view(promo) if promo is not None else None
async def consume_discount_for_user(self, telegram_id: int) -> None:
"""Burn the applied promo / referral discount so it cannot be reused.
- For promo codes: deletes the row from ``promo_code_applications``.
- For referral invites: sets ``discount_used = True`` so the referral
bonus logic (inviter days) is preserved while the invited user
no longer receives a price discount.
- Sets ``discount_ever_used = True`` on ``telegram_users`` to permanently
prevent any future discount code activation.
"""
async with self._session_factory() as session:
# 1. Burn promo-code application
await session.execute(
delete(PromoCodeApplication).where(
PromoCodeApplication.telegram_id == telegram_id
)
)
# 2. Mark referral invite discount as used
await session.execute(
update(ReferralInvite)
.where(
ReferralInvite.invited_telegram_id == telegram_id,
ReferralInvite.discount_used == False, # noqa: E712
)
.values(discount_used=True)
)
# 3. Permanently mark the user so no new codes can be activated
await session.execute(
update(TelegramUser)
.where(
TelegramUser.telegram_id == telegram_id,
TelegramUser.discount_ever_used == False, # noqa: E712
)
.values(discount_ever_used=True)
)
await session.commit()
async def is_referral_discount_used(self, telegram_id: int) -> bool:
"""Return True if the referral invite discount was already consumed."""
async with self._session_factory() as session:
discount_used = await session.scalar(
select(ReferralInvite.discount_used).where(
ReferralInvite.invited_telegram_id == telegram_id
)
)
return bool(discount_used)
async def apply_referral_code(
self,
*,
@@ -193,8 +337,28 @@ class SyncService:
last_name=last_name,
language_code=language_code,
)
# Block activation if the user has already used a discount
if invited.discount_ever_used:
raise ValueError(
"Вы уже использовали промокод или реферальный код. "
"Повторная активация невозможна."
)
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
raw_code = self.normalize_discount_code(referral_code)
promo = await self._find_promo_code(session, code=raw_code)
if promo is not None:
self._ensure_promo_can_be_applied(promo)
await self._upsert_promo_application(
session,
telegram_id=telegram_id,
promo_code_id=promo.id,
)
await session.commit()
return promo.code
existing = await session.scalar(
select(ReferralInvite).where(
ReferralInvite.invited_telegram_id == telegram_id
@@ -217,6 +381,11 @@ class SyncService:
if not invite_created:
raise ValueError("Не удалось применить реферальный код.")
await session.execute(
delete(PromoCodeApplication).where(
PromoCodeApplication.telegram_id == telegram_id
)
)
await session.commit()
return referral_code.strip().upper()
@@ -247,11 +416,21 @@ class SyncService:
)
.where(ReferralInvite.invited_telegram_id == telegram_id)
)
applied_promo = await self._get_active_promo_for_user(
session,
telegram_id=telegram_id,
)
return ReferralSummary(
total_invited=int(total_invited or 0),
referral_code=referral_code,
applied_referral_code=applied_referral_code or "",
applied_promo_code=applied_promo.code if applied_promo is not None else "",
applied_promo_discount_percent=(
int(applied_promo.discount_percent) if applied_promo is not None else 0
),
applied_promo_plan_code=applied_promo.plan_code if applied_promo is not None else None,
applied_promo_expires_at=applied_promo.expires_at if applied_promo is not None else None,
recent_names=recent_names,
)
@@ -623,6 +802,11 @@ class SyncService:
remote_user: RemoteRemnawaveUser,
owner: TelegramUser | None,
) -> RemnawaveUser:
owner_id = owner.id if owner is not None else None
if owner_id is None and remote_user.telegram_id is not None:
with session.no_autoflush:
owner_id = await self._find_owner_id(session, remote_user.telegram_id)
record = await session.scalar(
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
)
@@ -631,8 +815,6 @@ class SyncService:
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
session.add(record)
owner_id = owner.id if owner is not None else await self._find_owner_id(session, remote_user.telegram_id)
record.owner_telegram_user_id = owner_id
record.rw_id = remote_user.id
record.short_uuid = remote_user.short_uuid
@@ -781,6 +963,62 @@ class SyncService:
select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code)
)
async def _find_promo_code(
self,
session: AsyncSession,
*,
code: str,
) -> PromoCode | None:
raw_code = self.normalize_discount_code(code)
if not raw_code:
return None
return await session.scalar(
select(PromoCode).where(PromoCode.code == raw_code)
)
async def _get_active_promo_for_user(
self,
session: AsyncSession,
*,
telegram_id: int,
) -> PromoCode | None:
promo = await session.scalar(
select(PromoCode)
.join(
PromoCodeApplication,
PromoCodeApplication.promo_code_id == PromoCode.id,
)
.where(PromoCodeApplication.telegram_id == telegram_id)
)
if promo is None or not self._promo_is_valid(promo):
return None
return promo
async def _upsert_promo_application(
self,
session: AsyncSession,
*,
telegram_id: int,
promo_code_id: int,
) -> None:
application = await session.scalar(
select(PromoCodeApplication).where(
PromoCodeApplication.telegram_id == telegram_id
)
)
if application is None:
session.add(
PromoCodeApplication(
telegram_id=telegram_id,
promo_code_id=promo_code_id,
)
)
return
application.promo_code_id = promo_code_id
application.updated_at = utcnow()
async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None:
if telegram_id is None:
return None
@@ -801,6 +1039,40 @@ class SyncService:
def _maybe_str(value: object | None) -> str | None:
return str(value) if value is not None else None
@staticmethod
def normalize_discount_code(value: str | None) -> str:
return "".join((value or "").strip().upper().split())
@classmethod
def _ensure_promo_can_be_applied(cls, promo: PromoCode) -> None:
if not promo.is_active:
raise ValueError("Промокод отключён.")
if cls._promo_is_expired(promo):
raise ValueError("Срок действия промокода истёк.")
if promo.discount_percent <= 0:
raise ValueError("Промокод не даёт скидку.")
@classmethod
def _promo_is_valid(cls, promo: PromoCode) -> bool:
return bool(promo.is_active) and not cls._promo_is_expired(promo) and promo.discount_percent > 0
@staticmethod
def _promo_is_expired(promo: PromoCode) -> bool:
return promo.expires_at is not None and promo.expires_at <= utcnow()
@staticmethod
def _promo_code_view(promo: PromoCode) -> PromoCodeView:
return PromoCodeView(
code=promo.code,
plan_code=promo.plan_code,
discount_percent=int(promo.discount_percent),
expires_at=promo.expires_at,
is_active=bool(promo.is_active),
created_by_telegram_id=promo.created_by_telegram_id,
created_at=promo.created_at,
updated_at=promo.updated_at,
)
@staticmethod
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
view_map: dict[int, CachedUserView] = {}