1301 lines
48 KiB
Python
1301 lines
48 KiB
Python
from __future__ import annotations
|
|
|
|
import secrets
|
|
import math
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
|
|
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 (
|
|
BroadcastPoll,
|
|
BroadcastPollAnswer,
|
|
Guide,
|
|
GuidePhoto,
|
|
InternalSquad,
|
|
PromoCode,
|
|
PromoCodeApplication,
|
|
ReferralCode,
|
|
ReferralInvite,
|
|
RemnawaveUser,
|
|
RemnawaveUserInternalSquad,
|
|
SubscriptionRequestLog,
|
|
TelegramUser,
|
|
)
|
|
from app.schemas.remnawave import RemnawaveUser as RemoteRemnawaveUser
|
|
from app.schemas.remnawave import SubscriptionRequestHistory
|
|
from app.services.remnawave_client import RemnawaveApiClient
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CachedUserView:
|
|
record: RemnawaveUser
|
|
internal_squads: list[str] = field(default_factory=list)
|
|
recent_requests: list[SubscriptionRequestLog] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
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 GuideView:
|
|
id: int
|
|
title: str
|
|
short_description: str
|
|
body_text: str
|
|
photo_file_ids: list[str] = field(default_factory=list)
|
|
is_active: bool = True
|
|
created_by_telegram_id: int | None = None
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AdminStats:
|
|
total_telegram_users: int = 0
|
|
total_cached_users: int = 0
|
|
active_cached_users: int = 0
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AdminTelegramUserListItem:
|
|
telegram_id: int
|
|
username: str | None
|
|
first_name: str | None
|
|
last_name: str | None
|
|
language_code: str | None
|
|
is_admin: bool
|
|
is_blocked: bool
|
|
last_seen_at: datetime
|
|
created_at: datetime
|
|
|
|
@property
|
|
def display_name(self) -> str:
|
|
value = " ".join(part for part in [self.first_name, self.last_name] if part).strip()
|
|
return value or self.username or str(self.telegram_id)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AdminTelegramUsersPage:
|
|
items: list[AdminTelegramUserListItem] = field(default_factory=list)
|
|
page: int = 1
|
|
page_size: int = 8
|
|
total_items: int = 0
|
|
total_pages: int = 1
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AdminTelegramUserProfile:
|
|
user: TelegramUser
|
|
referral_code: str = ""
|
|
applied_referral_code: str = ""
|
|
invited_count: int = 0
|
|
recent_invited_names: list[str] = field(default_factory=list)
|
|
accesses: list[CachedUserView] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class BroadcastPollAnswerResult:
|
|
poll: BroadcastPoll
|
|
answer: BroadcastPollAnswer
|
|
previous_answer: str | None = None
|
|
|
|
|
|
class SyncService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
settings: Settings,
|
|
session_factory: async_sessionmaker[AsyncSession],
|
|
remnawave_client: RemnawaveApiClient,
|
|
) -> None:
|
|
self._settings = settings
|
|
self._session_factory = session_factory
|
|
self._remnawave_client = remnawave_client
|
|
|
|
async def is_user_blocked(self, telegram_id: int) -> bool:
|
|
async with self._session_factory() as session:
|
|
is_blocked = await session.scalar(
|
|
select(TelegramUser.is_blocked).where(
|
|
TelegramUser.telegram_id == telegram_id
|
|
)
|
|
)
|
|
return bool(is_blocked)
|
|
|
|
async def block_telegram_user(self, telegram_id: int) -> None:
|
|
async with self._session_factory() as session:
|
|
user = await session.scalar(
|
|
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
|
)
|
|
if user is not None:
|
|
user.is_blocked = True
|
|
user.updated_at = utcnow()
|
|
await session.commit()
|
|
|
|
async def unblock_telegram_user(self, telegram_id: int) -> None:
|
|
async with self._session_factory() as session:
|
|
user = await session.scalar(
|
|
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
|
)
|
|
if user is not None:
|
|
user.is_blocked = False
|
|
user.updated_at = utcnow()
|
|
await session.commit()
|
|
|
|
async def get_expiring_users(
|
|
self,
|
|
*,
|
|
days_before: int,
|
|
tolerance_hours: int = 6,
|
|
) -> list[tuple[int, int, datetime]]:
|
|
"""Return (remnawave_user.id, telegram_id, expire_at) for users expiring within window."""
|
|
from datetime import timedelta
|
|
|
|
now = utcnow()
|
|
window_start = now + timedelta(days=days_before) - timedelta(hours=tolerance_hours)
|
|
window_end = now + timedelta(days=days_before) + timedelta(hours=tolerance_hours)
|
|
|
|
async with self._session_factory() as session:
|
|
rows = await session.execute(
|
|
select(
|
|
RemnawaveUser.id,
|
|
RemnawaveUser.telegram_id,
|
|
RemnawaveUser.expire_at,
|
|
)
|
|
.where(
|
|
RemnawaveUser.status == "ACTIVE",
|
|
RemnawaveUser.telegram_id.isnot(None),
|
|
RemnawaveUser.expire_at >= window_start,
|
|
RemnawaveUser.expire_at <= window_end,
|
|
)
|
|
)
|
|
return [(row[0], row[1], row[2]) for row in rows.all()]
|
|
|
|
async def register_telegram_user(
|
|
self,
|
|
*,
|
|
telegram_id: int,
|
|
username: str | None,
|
|
first_name: str | None,
|
|
last_name: str | None,
|
|
language_code: str | None,
|
|
) -> None:
|
|
async with self._session_factory() as session:
|
|
await self._upsert_telegram_user(
|
|
session,
|
|
telegram_id=telegram_id,
|
|
username=username,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
language_code=language_code,
|
|
)
|
|
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 create_guide(
|
|
self,
|
|
*,
|
|
title: str,
|
|
short_description: str,
|
|
body_text: str,
|
|
photo_file_ids: list[str],
|
|
created_by_telegram_id: int,
|
|
) -> GuideView:
|
|
resolved_title = " ".join((title or "").split()).strip()
|
|
if not resolved_title:
|
|
raise ValueError("Введите название гайда.")
|
|
if len(resolved_title) > 255:
|
|
raise ValueError("Название гайда должно быть не длиннее 255 символов.")
|
|
|
|
resolved_description = " ".join((short_description or "").split()).strip()
|
|
if len(resolved_description) > 512:
|
|
raise ValueError("Короткое описание должно быть не длиннее 512 символов.")
|
|
|
|
resolved_body = (body_text or "").strip()
|
|
if not resolved_body:
|
|
raise ValueError("Введите текст гайда.")
|
|
|
|
resolved_photos: list[str] = []
|
|
for file_id in photo_file_ids:
|
|
normalized_file_id = str(file_id or "").strip()
|
|
if normalized_file_id and normalized_file_id not in resolved_photos:
|
|
resolved_photos.append(normalized_file_id)
|
|
|
|
async with self._session_factory() as session:
|
|
guide = Guide(
|
|
title=resolved_title,
|
|
short_description=resolved_description,
|
|
body_text=resolved_body,
|
|
is_active=True,
|
|
created_by_telegram_id=created_by_telegram_id,
|
|
)
|
|
session.add(guide)
|
|
await session.flush()
|
|
|
|
for position, file_id in enumerate(resolved_photos):
|
|
session.add(
|
|
GuidePhoto(
|
|
guide_id=guide.id,
|
|
file_id=file_id,
|
|
position=position,
|
|
)
|
|
)
|
|
|
|
await session.commit()
|
|
return self._guide_view(guide, resolved_photos)
|
|
|
|
async def get_guides(self, *, active_only: bool = True, limit: int = 50) -> list[GuideView]:
|
|
resolved_limit = max(1, min(limit, 100))
|
|
async with self._session_factory() as session:
|
|
query = (
|
|
select(Guide, GuidePhoto.file_id)
|
|
.outerjoin(GuidePhoto, GuidePhoto.guide_id == Guide.id)
|
|
.order_by(Guide.created_at.desc(), Guide.id.desc(), GuidePhoto.position.asc())
|
|
.limit(resolved_limit * 10)
|
|
)
|
|
if active_only:
|
|
query = query.where(Guide.is_active == True) # noqa: E712
|
|
rows = (await session.execute(query)).all()
|
|
|
|
guides = self._build_guide_views(rows)
|
|
return guides[:resolved_limit]
|
|
|
|
async def get_guide(self, guide_id: int, *, active_only: bool = True) -> GuideView | None:
|
|
async with self._session_factory() as session:
|
|
query = (
|
|
select(Guide, GuidePhoto.file_id)
|
|
.outerjoin(GuidePhoto, GuidePhoto.guide_id == Guide.id)
|
|
.where(Guide.id == guide_id)
|
|
.order_by(GuidePhoto.position.asc())
|
|
)
|
|
if active_only:
|
|
query = query.where(Guide.is_active == True) # noqa: E712
|
|
rows = (await session.execute(query)).all()
|
|
|
|
guides = self._build_guide_views(rows)
|
|
return guides[0] if guides else None
|
|
|
|
async def delete_guide(self, guide_id: int) -> GuideView | None:
|
|
guide = await self.get_guide(guide_id, active_only=False)
|
|
if guide is None:
|
|
return None
|
|
|
|
async with self._session_factory() as session:
|
|
await session.execute(delete(GuidePhoto).where(GuidePhoto.guide_id == guide_id))
|
|
await session.execute(delete(Guide).where(Guide.id == guide_id))
|
|
await session.commit()
|
|
|
|
return guide
|
|
|
|
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,
|
|
*,
|
|
telegram_id: int,
|
|
username: str | None,
|
|
first_name: str | None,
|
|
last_name: str | None,
|
|
language_code: str | None,
|
|
referral_code: str,
|
|
) -> str:
|
|
async with self._session_factory() as session:
|
|
invited = await self._upsert_telegram_user(
|
|
session,
|
|
telegram_id=telegram_id,
|
|
username=username,
|
|
first_name=first_name,
|
|
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
|
|
)
|
|
)
|
|
if existing is not None:
|
|
raise ValueError("Реферальный код уже применен к вашему аккаунту.")
|
|
|
|
inviter_telegram_id = await self._find_inviter_by_referral_code(session, referral_code)
|
|
if inviter_telegram_id is None:
|
|
raise ValueError("Реферальный код не найден.")
|
|
if inviter_telegram_id == telegram_id:
|
|
raise ValueError("Нельзя использовать собственный реферальный код.")
|
|
|
|
invite_created = await self._upsert_referral_invite(
|
|
session,
|
|
invited=invited,
|
|
referred_by_telegram_id=inviter_telegram_id,
|
|
)
|
|
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()
|
|
|
|
async def get_referral_summary(self, telegram_id: int) -> ReferralSummary:
|
|
async with self._session_factory() as session:
|
|
referral_code = await self._get_or_create_referral_code(
|
|
session,
|
|
telegram_id=telegram_id,
|
|
)
|
|
await session.commit()
|
|
total_invited = await session.scalar(
|
|
select(func.count(ReferralInvite.id)).where(
|
|
ReferralInvite.inviter_telegram_id == telegram_id
|
|
)
|
|
)
|
|
recent_rows = await session.execute(
|
|
select(ReferralInvite.invited_display_name)
|
|
.where(ReferralInvite.inviter_telegram_id == telegram_id)
|
|
.order_by(ReferralInvite.created_at.desc())
|
|
.limit(5)
|
|
)
|
|
recent_names = list(recent_rows.scalars().all())
|
|
applied_referral_code = await session.scalar(
|
|
select(ReferralCode.code)
|
|
.join(
|
|
ReferralInvite,
|
|
ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
|
|
)
|
|
.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,
|
|
)
|
|
|
|
async def get_admin_stats(self) -> AdminStats:
|
|
async with self._session_factory() as session:
|
|
total_telegram_users = await session.scalar(
|
|
select(func.count(TelegramUser.id))
|
|
)
|
|
total_cached_users = await session.scalar(
|
|
select(func.count(RemnawaveUser.id))
|
|
)
|
|
active_cached_users = await session.scalar(
|
|
select(func.count(RemnawaveUser.id)).where(RemnawaveUser.status == "ACTIVE")
|
|
)
|
|
|
|
return AdminStats(
|
|
total_telegram_users=int(total_telegram_users or 0),
|
|
total_cached_users=int(total_cached_users or 0),
|
|
active_cached_users=int(active_cached_users or 0),
|
|
)
|
|
|
|
async def get_broadcast_telegram_ids(self) -> list[int]:
|
|
async with self._session_factory() as session:
|
|
rows = await session.scalars(
|
|
select(TelegramUser.telegram_id)
|
|
.where(TelegramUser.is_blocked == False) # noqa: E712
|
|
.order_by(TelegramUser.id.asc())
|
|
)
|
|
return list(rows.all())
|
|
|
|
async def create_broadcast_poll(
|
|
self,
|
|
*,
|
|
question_text: str,
|
|
created_by_telegram_id: int,
|
|
) -> BroadcastPoll:
|
|
async with self._session_factory() as session:
|
|
poll = BroadcastPoll(
|
|
question_text=question_text,
|
|
created_by_telegram_id=created_by_telegram_id,
|
|
)
|
|
session.add(poll)
|
|
await session.commit()
|
|
return poll
|
|
|
|
async def record_broadcast_poll_answer(
|
|
self,
|
|
*,
|
|
poll_id: int,
|
|
telegram_id: int,
|
|
username: str | None,
|
|
first_name: str | None,
|
|
last_name: str | None,
|
|
answer: str,
|
|
) -> BroadcastPollAnswerResult | None:
|
|
normalized_answer = answer.strip().lower()
|
|
if normalized_answer not in {"yes", "no"}:
|
|
raise ValueError("Unsupported answer")
|
|
|
|
async with self._session_factory() as session:
|
|
poll = await session.scalar(
|
|
select(BroadcastPoll).where(BroadcastPoll.id == poll_id)
|
|
)
|
|
if poll is None:
|
|
return None
|
|
|
|
record = await session.scalar(
|
|
select(BroadcastPollAnswer).where(
|
|
BroadcastPollAnswer.poll_id == poll_id,
|
|
BroadcastPollAnswer.telegram_id == telegram_id,
|
|
)
|
|
)
|
|
previous_answer = record.answer if record is not None else None
|
|
if record is None:
|
|
record = BroadcastPollAnswer(
|
|
poll_id=poll_id,
|
|
telegram_id=telegram_id,
|
|
)
|
|
session.add(record)
|
|
|
|
record.username = username
|
|
record.first_name = first_name
|
|
record.last_name = last_name
|
|
record.answer = normalized_answer
|
|
record.updated_at = utcnow()
|
|
|
|
await session.commit()
|
|
return BroadcastPollAnswerResult(
|
|
poll=poll,
|
|
answer=record,
|
|
previous_answer=previous_answer,
|
|
)
|
|
|
|
async def get_admin_telegram_users_page(
|
|
self,
|
|
*,
|
|
page: int,
|
|
page_size: int = 8,
|
|
) -> AdminTelegramUsersPage:
|
|
resolved_page_size = max(1, min(page_size, 20))
|
|
|
|
async with self._session_factory() as session:
|
|
total_items = int(
|
|
await session.scalar(select(func.count(TelegramUser.id))) or 0
|
|
)
|
|
total_pages = max(1, math.ceil(total_items / resolved_page_size)) if total_items else 1
|
|
resolved_page = min(max(page, 1), total_pages)
|
|
|
|
rows = list(
|
|
(
|
|
await session.scalars(
|
|
select(TelegramUser)
|
|
.order_by(
|
|
TelegramUser.last_seen_at.desc(),
|
|
TelegramUser.created_at.desc(),
|
|
TelegramUser.id.desc(),
|
|
)
|
|
.offset((resolved_page - 1) * resolved_page_size)
|
|
.limit(resolved_page_size)
|
|
)
|
|
).all()
|
|
)
|
|
|
|
return AdminTelegramUsersPage(
|
|
items=[
|
|
AdminTelegramUserListItem(
|
|
telegram_id=row.telegram_id,
|
|
username=row.username,
|
|
first_name=row.first_name,
|
|
last_name=row.last_name,
|
|
language_code=row.language_code,
|
|
is_admin=row.is_admin,
|
|
is_blocked=row.is_blocked,
|
|
last_seen_at=row.last_seen_at,
|
|
created_at=row.created_at,
|
|
)
|
|
for row in rows
|
|
],
|
|
page=resolved_page,
|
|
page_size=resolved_page_size,
|
|
total_items=total_items,
|
|
total_pages=total_pages,
|
|
)
|
|
|
|
async def get_admin_telegram_user_profile(
|
|
self,
|
|
*,
|
|
telegram_id: int,
|
|
) -> AdminTelegramUserProfile | None:
|
|
async with self._session_factory() as session:
|
|
user = await session.scalar(
|
|
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
|
)
|
|
if user is None:
|
|
return None
|
|
|
|
referral_code = await session.scalar(
|
|
select(ReferralCode.code).where(ReferralCode.telegram_id == telegram_id)
|
|
)
|
|
invited_count = await session.scalar(
|
|
select(func.count(ReferralInvite.id)).where(
|
|
ReferralInvite.inviter_telegram_id == telegram_id
|
|
)
|
|
)
|
|
recent_rows = await session.execute(
|
|
select(ReferralInvite.invited_display_name)
|
|
.where(ReferralInvite.inviter_telegram_id == telegram_id)
|
|
.order_by(ReferralInvite.created_at.desc())
|
|
.limit(5)
|
|
)
|
|
recent_invited_names = list(recent_rows.scalars().all())
|
|
applied_referral_code = await session.scalar(
|
|
select(ReferralCode.code)
|
|
.join(
|
|
ReferralInvite,
|
|
ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
|
|
)
|
|
.where(ReferralInvite.invited_telegram_id == telegram_id)
|
|
)
|
|
|
|
accesses = await self.get_cached_users_for_telegram(telegram_id)
|
|
|
|
return AdminTelegramUserProfile(
|
|
user=user,
|
|
referral_code=referral_code or "",
|
|
applied_referral_code=applied_referral_code or "",
|
|
invited_count=int(invited_count or 0),
|
|
recent_invited_names=recent_invited_names,
|
|
accesses=accesses,
|
|
)
|
|
|
|
async def sync_users_for_telegram(
|
|
self,
|
|
*,
|
|
telegram_id: int,
|
|
username: str | None,
|
|
first_name: str | None,
|
|
last_name: str | None,
|
|
language_code: str | None,
|
|
) -> list[CachedUserView]:
|
|
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
|
|
|
async with self._session_factory() as session:
|
|
owner = await self._upsert_telegram_user(
|
|
session,
|
|
telegram_id=telegram_id,
|
|
username=username,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
language_code=language_code,
|
|
)
|
|
await self._sync_remote_users_for_telegram(
|
|
session,
|
|
telegram_id=telegram_id,
|
|
remote_users=remote_users,
|
|
owner=owner,
|
|
)
|
|
|
|
await session.commit()
|
|
|
|
return await self.get_cached_users_for_telegram(telegram_id)
|
|
|
|
async def refresh_cached_users_for_telegram(self, *, telegram_id: int) -> list[CachedUserView]:
|
|
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
|
|
|
async with self._session_factory() as session:
|
|
owner = await session.scalar(
|
|
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
|
)
|
|
await self._sync_remote_users_for_telegram(
|
|
session,
|
|
telegram_id=telegram_id,
|
|
remote_users=remote_users,
|
|
owner=owner,
|
|
)
|
|
await session.commit()
|
|
|
|
return await self.get_cached_users_for_telegram(telegram_id)
|
|
|
|
async def link_user_by_short_uuid(
|
|
self,
|
|
*,
|
|
short_uuid: str,
|
|
telegram_id: int,
|
|
username: str | None,
|
|
first_name: str | None,
|
|
last_name: str | None,
|
|
language_code: str | None,
|
|
) -> CachedUserView:
|
|
remote_user = await self._remnawave_client.get_user_by_short_uuid(short_uuid)
|
|
|
|
if remote_user.telegram_id and remote_user.telegram_id != telegram_id:
|
|
raise ValueError("Этот аккаунт уже привязан к другому Telegram ID.")
|
|
|
|
updated_user = await self._remnawave_client.update_user(
|
|
{
|
|
"uuid": str(remote_user.uuid),
|
|
"telegramId": telegram_id,
|
|
}
|
|
)
|
|
|
|
async with self._session_factory() as session:
|
|
owner = await self._upsert_telegram_user(
|
|
session,
|
|
telegram_id=telegram_id,
|
|
username=username,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
language_code=language_code,
|
|
)
|
|
await self._upsert_remnawave_user(session, updated_user, owner)
|
|
if self._settings.sync_subscription_history:
|
|
history = await self._remnawave_client.get_user_subscription_history(
|
|
str(updated_user.uuid)
|
|
)
|
|
await self._upsert_history(session, str(updated_user.uuid), history)
|
|
await session.commit()
|
|
|
|
user = await self.get_cached_user_by_uuid(str(updated_user.uuid), include_history=True)
|
|
if user is None:
|
|
raise ValueError("Не удалось получить локальный кэш пользователя после привязки.")
|
|
return user
|
|
|
|
async def sync_user_by_identifier(
|
|
self,
|
|
*,
|
|
identifier: str,
|
|
include_history: bool = True,
|
|
) -> CachedUserView:
|
|
resolved = await self._remnawave_client.resolve_user(identifier)
|
|
remote_user = await self._remnawave_client.get_user_by_uuid(str(resolved.uuid))
|
|
history = None
|
|
|
|
if include_history and self._settings.sync_subscription_history:
|
|
history = await self._remnawave_client.get_user_subscription_history(str(resolved.uuid))
|
|
|
|
async with self._session_factory() as session:
|
|
await self._upsert_remnawave_user(session, remote_user, owner=None)
|
|
if history is not None:
|
|
await self._upsert_history(session, str(resolved.uuid), history)
|
|
await session.commit()
|
|
|
|
user = await self.get_cached_user_by_uuid(str(resolved.uuid), include_history=include_history)
|
|
if user is None:
|
|
raise ValueError("Не удалось получить локальный кэш пользователя после синхронизации.")
|
|
return user
|
|
|
|
async def sync_all_users(self, batch_size: int) -> int:
|
|
start = 0
|
|
total_synced = 0
|
|
|
|
while True:
|
|
page = await self._remnawave_client.get_all_users(start=start, size=batch_size)
|
|
if not page.users:
|
|
break
|
|
|
|
async with self._session_factory() as session:
|
|
for remote_user in page.users:
|
|
await self._upsert_remnawave_user(session, remote_user, owner=None)
|
|
total_synced += 1
|
|
await session.commit()
|
|
|
|
start += len(page.users)
|
|
if start >= page.total:
|
|
break
|
|
|
|
return total_synced
|
|
|
|
async def get_cached_users_for_telegram(self, telegram_id: int) -> list[CachedUserView]:
|
|
async with self._session_factory() as session:
|
|
rows = await session.execute(
|
|
select(RemnawaveUser, InternalSquad.name)
|
|
.outerjoin(
|
|
RemnawaveUserInternalSquad,
|
|
RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
|
|
)
|
|
.outerjoin(
|
|
InternalSquad,
|
|
InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
|
|
)
|
|
.where(RemnawaveUser.telegram_id == telegram_id)
|
|
.order_by(RemnawaveUser.username.asc())
|
|
)
|
|
return self._build_views(rows.all())
|
|
|
|
async def get_cached_user_by_uuid(
|
|
self,
|
|
user_uuid: str,
|
|
*,
|
|
include_history: bool = False,
|
|
) -> CachedUserView | None:
|
|
async with self._session_factory() as session:
|
|
rows = await session.execute(
|
|
select(RemnawaveUser, InternalSquad.name)
|
|
.outerjoin(
|
|
RemnawaveUserInternalSquad,
|
|
RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
|
|
)
|
|
.outerjoin(
|
|
InternalSquad,
|
|
InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
|
|
)
|
|
.where(RemnawaveUser.rw_uuid == user_uuid)
|
|
)
|
|
views = self._build_views(rows.all())
|
|
if not views:
|
|
return None
|
|
|
|
view = views[0]
|
|
if include_history:
|
|
history_rows = await session.execute(
|
|
select(SubscriptionRequestLog)
|
|
.where(SubscriptionRequestLog.user_uuid == user_uuid)
|
|
.order_by(SubscriptionRequestLog.request_at.desc())
|
|
.limit(5)
|
|
)
|
|
view.recent_requests = list(history_rows.scalars().all())
|
|
return view
|
|
|
|
async def _upsert_telegram_user(
|
|
self,
|
|
session: AsyncSession,
|
|
*,
|
|
telegram_id: int,
|
|
username: str | None,
|
|
first_name: str | None,
|
|
last_name: str | None,
|
|
language_code: str | None,
|
|
) -> TelegramUser:
|
|
record = await session.scalar(
|
|
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
|
)
|
|
|
|
if record is None:
|
|
record = TelegramUser(telegram_id=telegram_id)
|
|
session.add(record)
|
|
|
|
record.username = username
|
|
record.first_name = first_name
|
|
record.last_name = last_name
|
|
record.language_code = language_code
|
|
record.is_admin = self._settings.is_admin(telegram_id)
|
|
record.last_seen_at = utcnow()
|
|
record.updated_at = utcnow()
|
|
|
|
await session.flush()
|
|
return record
|
|
|
|
async def _sync_remote_users_for_telegram(
|
|
self,
|
|
session: AsyncSession,
|
|
*,
|
|
telegram_id: int,
|
|
remote_users: list[RemoteRemnawaveUser],
|
|
owner: TelegramUser | None,
|
|
) -> None:
|
|
active_uuids = {str(remote_user.uuid) for remote_user in remote_users}
|
|
for remote_user in remote_users:
|
|
await self._upsert_remnawave_user(session, remote_user, owner)
|
|
|
|
filters = [RemnawaveUser.telegram_id == telegram_id]
|
|
if owner is not None:
|
|
filters.append(RemnawaveUser.owner_telegram_user_id == owner.id)
|
|
|
|
stale_records = await session.scalars(
|
|
select(RemnawaveUser).where(or_(*filters))
|
|
)
|
|
for record in stale_records:
|
|
if record.rw_uuid in active_uuids:
|
|
continue
|
|
|
|
if record.telegram_id == telegram_id:
|
|
record.telegram_id = None
|
|
if owner is not None and record.owner_telegram_user_id == owner.id:
|
|
record.owner_telegram_user_id = None
|
|
record.synced_at = utcnow()
|
|
|
|
async def _upsert_remnawave_user(
|
|
self,
|
|
session: AsyncSession,
|
|
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))
|
|
)
|
|
|
|
if record is None:
|
|
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
|
|
session.add(record)
|
|
|
|
record.owner_telegram_user_id = owner_id
|
|
record.rw_id = remote_user.id
|
|
record.short_uuid = remote_user.short_uuid
|
|
record.username = remote_user.username
|
|
record.status = remote_user.status
|
|
record.traffic_limit_bytes = remote_user.traffic_limit_bytes
|
|
record.used_traffic_bytes = remote_user.user_traffic.used_traffic_bytes
|
|
record.lifetime_used_traffic_bytes = remote_user.user_traffic.lifetime_used_traffic_bytes
|
|
record.expire_at = self._normalize_datetime(remote_user.expire_at)
|
|
record.telegram_id = remote_user.telegram_id
|
|
record.email = remote_user.email
|
|
record.description = remote_user.description
|
|
record.tag = remote_user.tag
|
|
record.hwid_device_limit = remote_user.hwid_device_limit
|
|
record.external_squad_uuid = self._maybe_str(remote_user.external_squad_uuid)
|
|
record.trojan_password = remote_user.trojan_password
|
|
record.vless_uuid = str(remote_user.vless_uuid)
|
|
record.ss_password = remote_user.ss_password
|
|
record.last_triggered_threshold = remote_user.last_triggered_threshold
|
|
record.sub_revoked_at = self._normalize_datetime(remote_user.sub_revoked_at)
|
|
record.sub_last_user_agent = remote_user.sub_last_user_agent
|
|
record.sub_last_opened_at = self._normalize_datetime(remote_user.sub_last_opened_at)
|
|
record.last_traffic_reset_at = self._normalize_datetime(remote_user.last_traffic_reset_at)
|
|
record.subscription_url = remote_user.subscription_url
|
|
record.online_at = self._normalize_datetime(remote_user.user_traffic.online_at)
|
|
record.first_connected_at = self._normalize_datetime(remote_user.user_traffic.first_connected_at)
|
|
record.last_connected_node_uuid = self._maybe_str(remote_user.user_traffic.last_connected_node_uuid)
|
|
record.remote_created_at = self._normalize_datetime(remote_user.created_at)
|
|
record.remote_updated_at = self._normalize_datetime(remote_user.updated_at)
|
|
record.synced_at = utcnow()
|
|
|
|
await session.flush()
|
|
await session.execute(
|
|
delete(RemnawaveUserInternalSquad).where(
|
|
RemnawaveUserInternalSquad.user_id == record.id
|
|
)
|
|
)
|
|
|
|
for squad in remote_user.active_internal_squads:
|
|
squad_uuid = str(squad.uuid)
|
|
squad_record = await session.get(InternalSquad, squad_uuid)
|
|
if squad_record is None:
|
|
squad_record = InternalSquad(uuid=squad_uuid, name=squad.name, synced_at=utcnow())
|
|
session.add(squad_record)
|
|
else:
|
|
squad_record.name = squad.name
|
|
squad_record.synced_at = utcnow()
|
|
|
|
session.add(
|
|
RemnawaveUserInternalSquad(
|
|
user_id=record.id,
|
|
squad_uuid=squad_uuid,
|
|
)
|
|
)
|
|
|
|
await session.flush()
|
|
return record
|
|
|
|
async def _upsert_history(
|
|
self,
|
|
session: AsyncSession,
|
|
user_uuid: str,
|
|
history: SubscriptionRequestHistory,
|
|
) -> None:
|
|
for item in history.records:
|
|
existing = await session.scalar(
|
|
select(SubscriptionRequestLog).where(
|
|
SubscriptionRequestLog.remote_id == item.id
|
|
)
|
|
)
|
|
if existing is None:
|
|
existing = SubscriptionRequestLog(remote_id=item.id, user_uuid=user_uuid)
|
|
session.add(existing)
|
|
|
|
existing.user_uuid = user_uuid
|
|
existing.request_at = self._normalize_datetime(item.request_at)
|
|
existing.request_ip = item.request_ip
|
|
existing.user_agent = item.user_agent
|
|
existing.synced_at = utcnow()
|
|
|
|
async def _upsert_referral_invite(
|
|
self,
|
|
session: AsyncSession,
|
|
*,
|
|
invited: TelegramUser,
|
|
referred_by_telegram_id: int | None,
|
|
) -> bool:
|
|
if referred_by_telegram_id is None or referred_by_telegram_id == invited.telegram_id:
|
|
return False
|
|
|
|
existing = await session.scalar(
|
|
select(ReferralInvite).where(
|
|
ReferralInvite.invited_telegram_id == invited.telegram_id
|
|
)
|
|
)
|
|
if existing is not None:
|
|
return False
|
|
|
|
display_name = " ".join(
|
|
part for part in [invited.first_name, invited.last_name] if part
|
|
).strip() or invited.username or str(invited.telegram_id)
|
|
|
|
session.add(
|
|
ReferralInvite(
|
|
inviter_telegram_id=referred_by_telegram_id,
|
|
invited_telegram_id=invited.telegram_id,
|
|
invited_username=invited.username,
|
|
invited_display_name=display_name,
|
|
)
|
|
)
|
|
return True
|
|
|
|
async def _get_or_create_referral_code(
|
|
self,
|
|
session: AsyncSession,
|
|
*,
|
|
telegram_id: int,
|
|
) -> str:
|
|
existing = await session.scalar(
|
|
select(ReferralCode).where(ReferralCode.telegram_id == telegram_id)
|
|
)
|
|
if existing is not None:
|
|
return existing.code
|
|
|
|
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
while True:
|
|
code = "".join(secrets.choice(alphabet) for _ in range(8))
|
|
duplicate = await session.scalar(
|
|
select(ReferralCode.id).where(ReferralCode.code == code)
|
|
)
|
|
if duplicate is None:
|
|
session.add(ReferralCode(telegram_id=telegram_id, code=code))
|
|
await session.flush()
|
|
return code
|
|
|
|
async def _find_inviter_by_referral_code(
|
|
self,
|
|
session: AsyncSession,
|
|
referred_by_referral_code: str | None,
|
|
) -> int | None:
|
|
raw_code = (referred_by_referral_code or "").strip().upper()
|
|
if not raw_code:
|
|
return None
|
|
|
|
return await session.scalar(
|
|
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
|
|
|
|
return await session.scalar(
|
|
select(TelegramUser.id).where(TelegramUser.telegram_id == telegram_id)
|
|
)
|
|
|
|
@staticmethod
|
|
def _normalize_datetime(value: datetime | None) -> datetime | None:
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
return value
|
|
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
|
|
|
@staticmethod
|
|
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 _guide_view(guide: Guide, photo_file_ids: list[str]) -> GuideView:
|
|
return GuideView(
|
|
id=guide.id,
|
|
title=guide.title,
|
|
short_description=guide.short_description,
|
|
body_text=guide.body_text,
|
|
photo_file_ids=photo_file_ids,
|
|
is_active=bool(guide.is_active),
|
|
created_by_telegram_id=guide.created_by_telegram_id,
|
|
created_at=guide.created_at,
|
|
updated_at=guide.updated_at,
|
|
)
|
|
|
|
@classmethod
|
|
def _build_guide_views(cls, rows: list[tuple[Guide, str | None]]) -> list[GuideView]:
|
|
guide_map: dict[int, GuideView] = {}
|
|
for guide, file_id in rows:
|
|
view = guide_map.get(guide.id)
|
|
if view is None:
|
|
view = cls._guide_view(guide, [])
|
|
guide_map[guide.id] = view
|
|
if file_id and file_id not in view.photo_file_ids:
|
|
view.photo_file_ids.append(file_id)
|
|
return list(guide_map.values())
|
|
|
|
@staticmethod
|
|
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
|
|
view_map: dict[int, CachedUserView] = {}
|
|
for user, squad_name in rows:
|
|
view = view_map.setdefault(user.id, CachedUserView(record=user))
|
|
if squad_name and squad_name not in view.internal_squads:
|
|
view.internal_squads.append(squad_name)
|
|
return list(view_map.values())
|