RemnaWave-bot Update
This commit is contained in:
@@ -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] = {}
|
||||
|
||||
Reference in New Issue
Block a user