feat: add Remnashop migration import
Add the Remnashop legacy importer, compatibility tables, admin toggles, referral and promo lookup compatibility, and tests for the migration flow.
This commit is contained in:
@@ -402,6 +402,38 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||
),
|
||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
|
||||
"bool",
|
||||
"migrations",
|
||||
"Старые ref-ссылки Remnashop",
|
||||
"Принимать импортированные ref-коды Remnashop вместе с текущими кодами пользователей.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED",
|
||||
"bool",
|
||||
"migrations",
|
||||
"Старые промокоды Remnashop",
|
||||
"Пробовать точное совпадение промокода перед обычной uppercase-нормализацией.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_IMPORTED_AT",
|
||||
"string",
|
||||
"migrations",
|
||||
"Последний импорт Remnashop",
|
||||
"Заполняется скриптом импорта. Можно очистить, если отметка больше не нужна.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_NOTES",
|
||||
"text",
|
||||
"migrations",
|
||||
"Заметки по миграции Remnashop",
|
||||
"Внутренние заметки оператора по перенесенному инстансу.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
# ─── Notifications ─────────────────────────────────────────────
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||
@@ -734,6 +766,7 @@ def manifest_payload() -> List[dict]:
|
||||
"devices": 10,
|
||||
"subscription_guides": 10,
|
||||
"system": 12,
|
||||
"migrations": 13,
|
||||
}
|
||||
exclusive_map = {
|
||||
key: opposite
|
||||
|
||||
@@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
@@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
@@ -1292,17 +1294,35 @@ async def _link_telegram_to_user(
|
||||
return current_user
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool:
|
||||
if settings is None:
|
||||
return False
|
||||
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||
|
||||
|
||||
def _strip_referral_param_prefix(
|
||||
raw: Optional[str],
|
||||
*,
|
||||
preserve_current_u_prefix: bool,
|
||||
) -> str:
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
return ""
|
||||
|
||||
value_lower = value.lower()
|
||||
if value_lower.startswith("ref_u"):
|
||||
if value_lower.startswith("ref_u") and not preserve_current_u_prefix:
|
||||
value = value[5:]
|
||||
elif value_lower.startswith("ref_"):
|
||||
value = value[4:]
|
||||
elif value and value[0].lower() == "u" and len(value) == 10:
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False)
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value and value[0].lower() == "u" and len(value) == 10:
|
||||
value = value[1:]
|
||||
|
||||
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
|
||||
@@ -1310,27 +1330,65 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
return value.upper()
|
||||
|
||||
|
||||
def _referral_param_lookup_candidates(
|
||||
raw: Optional[str],
|
||||
*,
|
||||
remnashop_compat: bool,
|
||||
) -> List[str]:
|
||||
if not remnashop_compat:
|
||||
normalized = _normalize_referral_param(raw)
|
||||
return [normalized] if normalized else []
|
||||
|
||||
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True)
|
||||
if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value):
|
||||
return []
|
||||
|
||||
candidates = [value]
|
||||
if value and value[0].lower() == "u":
|
||||
candidates.append(value[1:])
|
||||
|
||||
unique: List[str] = []
|
||||
for candidate in candidates:
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
async def _resolve_referrer_id(
|
||||
session: AsyncSession,
|
||||
raw_referral_param: Optional[str],
|
||||
*,
|
||||
current_user_id: Optional[int],
|
||||
settings: Optional[Settings] = None,
|
||||
) -> Optional[int]:
|
||||
normalized = _normalize_referral_param(raw_referral_param)
|
||||
if not normalized:
|
||||
remnashop_compat = _remnashop_referral_compat_enabled(settings)
|
||||
candidates = _referral_param_lookup_candidates(
|
||||
raw_referral_param,
|
||||
remnashop_compat=remnashop_compat,
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
for normalized in candidates:
|
||||
ref_user = None
|
||||
if normalized.isdigit():
|
||||
if normalized.isdigit() and not remnashop_compat:
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized)
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session,
|
||||
normalized,
|
||||
include_legacy=remnashop_compat,
|
||||
)
|
||||
if not ref_user and normalized.isdigit() and remnashop_compat:
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
return None
|
||||
continue
|
||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||
return None
|
||||
continue
|
||||
return int(ref_user.user_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _apply_referral_to_existing_user(
|
||||
request: web.Request,
|
||||
@@ -1345,6 +1403,7 @@ async def _apply_referral_to_existing_user(
|
||||
session,
|
||||
raw_referral_param,
|
||||
current_user_id=int(user.user_id),
|
||||
settings=request.app["settings"],
|
||||
)
|
||||
if not referred_by_id:
|
||||
return False
|
||||
@@ -1427,6 +1486,7 @@ async def _ensure_user_from_telegram(
|
||||
session,
|
||||
referral_param or telegram_user.get("start_param"),
|
||||
current_user_id=user_id,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, created = await user_dal.create_user(
|
||||
session,
|
||||
|
||||
@@ -40,6 +40,67 @@ from db.models import User
|
||||
router = Router(name="user_start_router")
|
||||
|
||||
|
||||
def _remnashop_referral_compat_enabled(settings: Settings) -> bool:
|
||||
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||
|
||||
|
||||
def _referral_code_lookup_candidates(
|
||||
raw_ref_value: str,
|
||||
*,
|
||||
remnashop_compat: bool,
|
||||
) -> list[str]:
|
||||
value = str(raw_ref_value or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
|
||||
candidates = [value]
|
||||
if value and value[0].lower() == "u":
|
||||
stripped_current_prefix = value[1:]
|
||||
if remnashop_compat:
|
||||
candidates.append(stripped_current_prefix)
|
||||
else:
|
||||
candidates = [stripped_current_prefix]
|
||||
|
||||
unique: list[str] = []
|
||||
for candidate in candidates:
|
||||
candidate = candidate.strip()
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
async def _resolve_referrer_from_start_ref(
|
||||
session: AsyncSession,
|
||||
raw_ref_value: str,
|
||||
*,
|
||||
settings: Settings,
|
||||
current_user_id: int,
|
||||
) -> Optional[int]:
|
||||
ref_user: Optional[User] = None
|
||||
if raw_ref_value.isdigit() and settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != current_user_id:
|
||||
ref_user = await user_dal.get_user_by_id(session, potential_referrer_id)
|
||||
|
||||
include_legacy = _remnashop_referral_compat_enabled(settings)
|
||||
if not ref_user:
|
||||
for code in _referral_code_lookup_candidates(
|
||||
raw_ref_value,
|
||||
remnashop_compat=include_legacy,
|
||||
):
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session,
|
||||
code,
|
||||
include_legacy=include_legacy,
|
||||
)
|
||||
if ref_user:
|
||||
break
|
||||
|
||||
if ref_user and ref_user.user_id != current_user_id:
|
||||
return int(ref_user.user_id)
|
||||
return None
|
||||
|
||||
|
||||
async def should_show_trial_button(
|
||||
settings: Settings,
|
||||
subscription_service: SubscriptionService,
|
||||
@@ -412,12 +473,10 @@ async def ensure_required_channel_subscription(
|
||||
@router.message(CommandStart())
|
||||
@router.message(
|
||||
CommandStart(
|
||||
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
|
||||
"ref_match"
|
||||
magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")
|
||||
)
|
||||
)
|
||||
)
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
||||
@@ -534,22 +593,12 @@ async def start_command_handler(
|
||||
|
||||
if ref_match:
|
||||
raw_ref_value = ref_match.group(1)
|
||||
if raw_ref_value.isdigit():
|
||||
if settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
||||
session, potential_referrer_id
|
||||
):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
else:
|
||||
normalized_code = raw_ref_value.strip()
|
||||
if normalized_code and normalized_code[0].lower() == "u":
|
||||
normalized_code = normalized_code[1:]
|
||||
ref_user = None
|
||||
if normalized_code:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized_code)
|
||||
if ref_user and ref_user.user_id != user_id:
|
||||
referred_by_user_id = ref_user.user_id
|
||||
referred_by_user_id = await _resolve_referrer_from_start_ref(
|
||||
session,
|
||||
raw_ref_value,
|
||||
settings=settings,
|
||||
current_user_id=user_id,
|
||||
)
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
|
||||
@@ -38,8 +38,12 @@ class PromoCodeService:
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, datetime | str]:
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = (code_input or "").strip().upper()[:100]
|
||||
code_display = html_escape(code_input_upper[:100], quote=False)
|
||||
preserve_case = bool(
|
||||
getattr(self.settings, "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", False)
|
||||
)
|
||||
code_input_clean = (code_input or "").strip()[:100]
|
||||
lookup_code = code_input_clean if preserve_case else code_input_clean.upper()
|
||||
code_display = html_escape(lookup_code[:100], quote=False)
|
||||
throttle_identifier = self._throttle_identifier(user_id)
|
||||
|
||||
throttle = await security_dal.check_throttle(
|
||||
@@ -54,7 +58,7 @@ class PromoCodeService:
|
||||
)
|
||||
|
||||
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
||||
session, code_input_upper
|
||||
session, lookup_code, preserve_case=preserve_case
|
||||
)
|
||||
|
||||
if not promo_data:
|
||||
@@ -74,6 +78,8 @@ class PromoCodeService:
|
||||
)
|
||||
return False, _("promo_code_not_found", code=code_display)
|
||||
|
||||
applied_code = str(promo_data.code or lookup_code)
|
||||
code_display = html_escape(applied_code[:100], quote=False)
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_data.promo_code_id, user_id
|
||||
)
|
||||
@@ -86,7 +92,7 @@ class PromoCodeService:
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
bonus_days=bonus_days,
|
||||
reason=f"promo code {code_input_upper}",
|
||||
reason=f"promo code {applied_code}",
|
||||
)
|
||||
|
||||
if new_end_date:
|
||||
@@ -109,7 +115,7 @@ class PromoCodeService:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_promo_activation(
|
||||
user_id=user_id,
|
||||
promo_code=code_input_upper,
|
||||
promo_code=applied_code,
|
||||
bonus_days=bonus_days,
|
||||
username=user.username if user else None,
|
||||
email=getattr(user, "email", None) if user else None,
|
||||
|
||||
@@ -286,6 +286,24 @@ class Settings(BaseSettings):
|
||||
default=True,
|
||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
|
||||
)
|
||||
MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Accept referral links imported from snoups/remnashop via legacy_referral_codes."
|
||||
),
|
||||
)
|
||||
MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Try exact legacy Remnashop promo codes before uppercase normalization.",
|
||||
)
|
||||
MIGRATION_REMNASHOP_IMPORTED_AT: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Timestamp of the latest Remnashop import run, managed by the import script.",
|
||||
)
|
||||
MIGRATION_REMNASHOP_NOTES: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Operator notes for instances migrated from Remnashop.",
|
||||
)
|
||||
|
||||
APP_RUNTIME_MODE: str = Field(
|
||||
default="production",
|
||||
|
||||
@@ -22,24 +22,46 @@ async def get_promo_code_by_id(session: AsyncSession, promo_code_id: int) -> Opt
|
||||
return await session.get(PromoCode, promo_code_id)
|
||||
|
||||
|
||||
async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
def _promo_lookup_candidates(code_str: str, *, preserve_case: bool) -> List[str]:
|
||||
code = str(code_str or "").strip()
|
||||
if not code:
|
||||
return []
|
||||
candidates = [code] if preserve_case else []
|
||||
upper_code = code.upper()
|
||||
if upper_code not in candidates:
|
||||
candidates.append(upper_code)
|
||||
return candidates
|
||||
|
||||
|
||||
async def get_promo_code_by_code(
|
||||
session: AsyncSession, code_str: str, *, preserve_case: bool = False
|
||||
) -> Optional[PromoCode]:
|
||||
"""Get promo code by code string (regardless of active status)"""
|
||||
stmt = select(PromoCode).where(PromoCode.code == code_str.upper())
|
||||
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||
stmt = select(PromoCode).where(PromoCode.code == candidate)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
promo = result.scalar_one_or_none()
|
||||
if promo:
|
||||
return promo
|
||||
return None
|
||||
|
||||
|
||||
async def get_active_promo_code_by_code_str(
|
||||
session: AsyncSession, code_str: str
|
||||
session: AsyncSession, code_str: str, *, preserve_case: bool = False
|
||||
) -> Optional[PromoCode]:
|
||||
now = datetime.now(timezone.utc)
|
||||
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == code_str.upper(),
|
||||
PromoCode.code == candidate,
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)),
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until > now),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
promo = result.scalar_one_or_none()
|
||||
if promo:
|
||||
return promo
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_active_promo_codes(
|
||||
|
||||
@@ -4,7 +4,7 @@ import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, case, delete, desc, func, or_, update
|
||||
from sqlalchemy import String, and_, case, cast, delete, desc, func, or_, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
@@ -14,6 +14,8 @@ from ..models import (
|
||||
AdAttribution,
|
||||
EmailVerificationCode,
|
||||
HwidDevicePurchase,
|
||||
LegacyImportMapping,
|
||||
LegacyReferralCode,
|
||||
MessageLog,
|
||||
Payment,
|
||||
PromoCodeActivation,
|
||||
@@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
||||
Returns the existing or newly generated code.
|
||||
"""
|
||||
if user.referral_code:
|
||||
normalized = user.referral_code.strip().upper()
|
||||
normalized = user.referral_code.strip()
|
||||
if normalized != user.referral_code:
|
||||
user.referral_code = normalized
|
||||
await session.flush()
|
||||
@@ -210,7 +212,7 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
||||
if not user_data.get("referral_code"):
|
||||
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||
else:
|
||||
user_data["referral_code"] = user_data["referral_code"].strip().upper()
|
||||
user_data["referral_code"] = user_data["referral_code"].strip()
|
||||
|
||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||
stmt = (
|
||||
@@ -567,6 +569,19 @@ async def merge_users(
|
||||
await session.execute(
|
||||
update(model).where(model.user_id == source_user_id).values(user_id=target_user_id)
|
||||
)
|
||||
await session.execute(
|
||||
update(LegacyReferralCode)
|
||||
.where(LegacyReferralCode.user_id == source_user_id)
|
||||
.values(user_id=target_user_id)
|
||||
)
|
||||
await session.execute(
|
||||
update(LegacyImportMapping)
|
||||
.where(
|
||||
LegacyImportMapping.target_table == "users",
|
||||
LegacyImportMapping.target_id == str(source_user_id),
|
||||
)
|
||||
.values(target_id=str(target_user_id))
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
update(MessageLog)
|
||||
@@ -590,13 +605,60 @@ async def merge_users(
|
||||
return target
|
||||
|
||||
|
||||
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
normalized = referral_code.strip().upper()
|
||||
async def get_user_by_referral_code(
|
||||
session: AsyncSession,
|
||||
referral_code: str,
|
||||
*,
|
||||
include_legacy: bool = False,
|
||||
) -> Optional[User]:
|
||||
normalized = referral_code.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
stmt = select(User).where(User.referral_code == normalized)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
upper_normalized = normalized.upper()
|
||||
if upper_normalized != normalized:
|
||||
stmt = select(User).where(User.referral_code == upper_normalized)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
if not include_legacy:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
|
||||
.where(LegacyReferralCode.code == normalized, LegacyReferralCode.is_active == True)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
if upper_normalized != normalized:
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
|
||||
.where(
|
||||
LegacyReferralCode.code == upper_normalized,
|
||||
LegacyReferralCode.is_active == True,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def update_user(
|
||||
@@ -1020,6 +1082,31 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
|
||||
await session.execute(delete(LegacyReferralCode).where(LegacyReferralCode.user_id == user_id))
|
||||
await session.execute(
|
||||
delete(LegacyImportMapping).where(
|
||||
or_(
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "users",
|
||||
LegacyImportMapping.target_id == str(user_id),
|
||||
),
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "subscriptions",
|
||||
LegacyImportMapping.target_id.in_(
|
||||
select(cast(Subscription.subscription_id, String)).where(
|
||||
Subscription.user_id == user_id
|
||||
)
|
||||
),
|
||||
),
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "payments",
|
||||
LegacyImportMapping.target_id.in_(
|
||||
select(cast(Payment.payment_id, String)).where(Payment.user_id == user_id)
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
|
||||
|
||||
@@ -1070,6 +1070,79 @@ def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -
|
||||
)
|
||||
|
||||
|
||||
def _migration_0034_add_legacy_import_compatibility(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
table_names = set(inspector.get_table_names())
|
||||
|
||||
if "users" in table_names:
|
||||
columns = {col["name"]: col for col in inspector.get_columns("users")}
|
||||
referral_column = columns.get("referral_code")
|
||||
length = getattr(referral_column.get("type"), "length", None) if referral_column else None
|
||||
if referral_column and (length is None or int(length) < 64):
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ALTER COLUMN referral_code TYPE VARCHAR(64)")
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS legacy_referral_codes (
|
||||
legacy_code_id SERIAL PRIMARY KEY,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'remnashop',
|
||||
code VARCHAR(128) NOT NULL,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_legacy_referral_source_code UNIQUE (source, code)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
for stmt in [
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_source "
|
||||
"ON legacy_referral_codes (source)"
|
||||
),
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_code ON legacy_referral_codes (code)",
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_user_id "
|
||||
"ON legacy_referral_codes (user_id)"
|
||||
),
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_is_active "
|
||||
"ON legacy_referral_codes (is_active)"
|
||||
),
|
||||
]:
|
||||
connection.execute(text(stmt))
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS legacy_import_mappings (
|
||||
source VARCHAR(64) NOT NULL,
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
source_id VARCHAR(128) NOT NULL,
|
||||
target_table VARCHAR(128) NOT NULL,
|
||||
target_id VARCHAR(128) NOT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
PRIMARY KEY (source, entity_type, source_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_legacy_import_mappings_target
|
||||
ON legacy_import_mappings (target_table, target_id)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1247,6 +1320,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Track admin resets of per-user trial eligibility without deleting history",
|
||||
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
|
||||
),
|
||||
Migration(
|
||||
id="0034_add_legacy_import_compatibility",
|
||||
description="Store legacy import mappings and referral codes for source-bot migrations",
|
||||
upgrade=_migration_0034_add_legacy_import_compatibility,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+32
-1
@@ -43,7 +43,7 @@ class User(Base):
|
||||
registration_date = Column(DateTime(timezone=True), server_default=func.now())
|
||||
is_banned = Column(Boolean, default=False)
|
||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(16), nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(64), nullable=True, unique=True, index=True)
|
||||
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
@@ -396,6 +396,37 @@ class PromoCodeActivation(Base):
|
||||
)
|
||||
|
||||
|
||||
class LegacyReferralCode(Base):
|
||||
__tablename__ = "legacy_referral_codes"
|
||||
|
||||
legacy_code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source = Column(String(64), nullable=False, default="remnashop", index=True)
|
||||
code = Column(String(128), nullable=False, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "code", name="uq_legacy_referral_source_code"),
|
||||
)
|
||||
|
||||
|
||||
class LegacyImportMapping(Base):
|
||||
__tablename__ = "legacy_import_mappings"
|
||||
|
||||
source = Column(String(64), primary_key=True)
|
||||
entity_type = Column(String(64), primary_key=True)
|
||||
source_id = Column(String(128), primary_key=True)
|
||||
target_table = Column(String(128), nullable=False)
|
||||
target_id = Column(String(128), nullable=False)
|
||||
metadata_json = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
|
||||
class MessageLog(Base):
|
||||
__tablename__ = "message_logs"
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Operational one-shot scripts shipped with the backend image."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -348,6 +348,7 @@
|
||||
devices: "Устройства",
|
||||
subscription_guides: "Connection guides",
|
||||
system: "Система",
|
||||
migrations: "Миграции",
|
||||
};
|
||||
return adminText(`settings_section_${id}`, {}, map[id] || id);
|
||||
}
|
||||
|
||||
@@ -1108,11 +1108,13 @@
|
||||
"admin_settings_section_devices": "Devices",
|
||||
"admin_settings_section_support": "Support",
|
||||
"admin_settings_section_system": "System",
|
||||
"admin_settings_section_migrations": "Migrations",
|
||||
"admin_settings_field_telemetry_enabled_label": "Anonymous install analytics",
|
||||
"admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge how many installs are active and which versions are in use. Toggling this off takes effect without a restart.",
|
||||
"admin_settings_subsection_common": "Common",
|
||||
"admin_settings_subsection_checkout": "Checkout",
|
||||
"admin_settings_subsection_remnawave": "Remnawave",
|
||||
"admin_settings_subsection_remnashop": "Remnashop",
|
||||
"admin_settings_subsection_telegram_stars": "Telegram Stars",
|
||||
"admin_settings_subsection_yookassa": "YooKassa",
|
||||
"admin_settings_subsection_freekassa": "FreeKassa",
|
||||
@@ -1127,6 +1129,14 @@
|
||||
"admin_settings_provider_webhook_base_missing": "Set WEBHOOK_BASE_URL in .env to show the full URL for {path}.",
|
||||
"admin_settings_provider_admin_only_label": "Only for admins",
|
||||
"admin_settings_provider_admin_only_description": "Shows this provider only to admins. Webhooks and payment status handling remain active for test payments.",
|
||||
"admin_settings_field_migration_remnashop_referral_code_compat_enabled_label": "Remnashop ref-code compatibility",
|
||||
"admin_settings_field_migration_remnashop_referral_code_compat_enabled_description": "Allows old Remnashop referral codes to resolve without changing their case or format. The importer enables it automatically.",
|
||||
"admin_settings_field_migration_remnashop_promo_code_compat_enabled_label": "Remnashop promo-code compatibility",
|
||||
"admin_settings_field_migration_remnashop_promo_code_compat_enabled_description": "Looks up promo codes in the original case first, then falls back to the current rules. Regular promo codes keep working as before.",
|
||||
"admin_settings_field_migration_remnashop_imported_at_label": "Remnashop import date",
|
||||
"admin_settings_field_migration_remnashop_imported_at_description": "Operational marker for the latest Remnashop import, stored as an ISO timestamp.",
|
||||
"admin_settings_field_migration_remnashop_notes_label": "Remnashop import notes",
|
||||
"admin_settings_field_migration_remnashop_notes_description": "Short importer summary: migrated entities and enabled compatibility modes.",
|
||||
"admin_settings_validation_errors": "Errors: {errors}",
|
||||
"admin_settings_save_error": "Error: {error}",
|
||||
"admin_sync_started": "Synchronization started",
|
||||
|
||||
@@ -1108,11 +1108,13 @@
|
||||
"admin_settings_section_devices": "Устройства",
|
||||
"admin_settings_section_support": "Поддержка",
|
||||
"admin_settings_section_system": "Система",
|
||||
"admin_settings_section_migrations": "Миграции",
|
||||
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
|
||||
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.",
|
||||
"admin_settings_subsection_common": "Общие",
|
||||
"admin_settings_subsection_checkout": "Оформление оплаты",
|
||||
"admin_settings_subsection_remnawave": "Remnawave",
|
||||
"admin_settings_subsection_remnashop": "Remnashop",
|
||||
"admin_settings_subsection_telegram_stars": "Telegram Stars",
|
||||
"admin_settings_subsection_yookassa": "YooKassa",
|
||||
"admin_settings_subsection_freekassa": "FreeKassa",
|
||||
@@ -1127,6 +1129,14 @@
|
||||
"admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.",
|
||||
"admin_settings_provider_admin_only_label": "Только для админов",
|
||||
"admin_settings_provider_admin_only_description": "Показывает провайдер только администраторам. Вебхуки и обработка статусов остаются активными для тестовых платежей.",
|
||||
"admin_settings_field_migration_remnashop_referral_code_compat_enabled_label": "Совместимость ref-кодов Remnashop",
|
||||
"admin_settings_field_migration_remnashop_referral_code_compat_enabled_description": "Разрешает вход по старым ref-кодам Remnashop без изменения их регистра и формата. Включается импортёром автоматически.",
|
||||
"admin_settings_field_migration_remnashop_promo_code_compat_enabled_label": "Совместимость промокодов Remnashop",
|
||||
"admin_settings_field_migration_remnashop_promo_code_compat_enabled_description": "Ищет промокоды сначала в исходном регистре, затем по текущим правилам. Обычные промокоды продолжают работать как раньше.",
|
||||
"admin_settings_field_migration_remnashop_imported_at_label": "Дата импорта Remnashop",
|
||||
"admin_settings_field_migration_remnashop_imported_at_description": "Служебная отметка последнего импорта Remnashop в ISO-формате.",
|
||||
"admin_settings_field_migration_remnashop_notes_label": "Заметки импорта Remnashop",
|
||||
"admin_settings_field_migration_remnashop_notes_description": "Краткая сводка импортёра: какие сущности перенесены и какие совместимые режимы включены.",
|
||||
"admin_settings_validation_errors": "Ошибки: {errors}",
|
||||
"admin_settings_save_error": "Ошибка: {error}",
|
||||
"admin_sync_started": "Синхронизация запущена",
|
||||
|
||||
@@ -44,6 +44,13 @@ BACKUP_SETTINGS = (
|
||||
"BACKUP_COMPOSE_ENABLED",
|
||||
)
|
||||
|
||||
REMNASHOP_MIGRATION_SETTINGS = (
|
||||
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
|
||||
"MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED",
|
||||
"MIGRATION_REMNASHOP_IMPORTED_AT",
|
||||
"MIGRATION_REMNASHOP_NOTES",
|
||||
)
|
||||
|
||||
ADMIN_TARIFF_SETTINGS_PAGE_KEYS = {
|
||||
"admin_tariffs_trial_title",
|
||||
"admin_tariffs_trial_subtitle",
|
||||
@@ -204,6 +211,27 @@ def test_backup_settings_i18n_keys_exist():
|
||||
assert field["i18n_description_key"] in messages
|
||||
|
||||
|
||||
def test_remnashop_migration_settings_i18n_keys_exist():
|
||||
manifest = _manifest_by_key()
|
||||
|
||||
for setting_key in REMNASHOP_MIGRATION_SETTINGS:
|
||||
field = manifest[setting_key]
|
||||
assert field["section"] == "migrations"
|
||||
assert field["section_order"] == 13
|
||||
assert field["subsection"] == "Remnashop"
|
||||
assert field["i18n_subsection_key"] == "admin_settings_subsection_remnashop"
|
||||
|
||||
for language in ("ru", "en"):
|
||||
messages = _locale(language)
|
||||
|
||||
assert "admin_settings_section_migrations" in messages
|
||||
assert "admin_settings_subsection_remnashop" in messages
|
||||
for setting_key in REMNASHOP_MIGRATION_SETTINGS:
|
||||
field = manifest[setting_key]
|
||||
assert field["i18n_label_key"] in messages
|
||||
assert field["i18n_description_key"] in messages
|
||||
|
||||
|
||||
def test_backup_required_numeric_settings_reject_empty_values():
|
||||
with pytest.raises(ValueError):
|
||||
coerce_value(get_field_by_key("BACKUP_INTERVAL_SECONDS"), "")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scripts.import_legacy import (
|
||||
remnashop_months_from_plan_snapshot,
|
||||
remnashop_pricing_amount,
|
||||
remnashop_pricing_currency,
|
||||
remnashop_sale_mode,
|
||||
remnashop_traffic_gb_to_bytes,
|
||||
remnashop_transaction_status,
|
||||
)
|
||||
|
||||
|
||||
def test_remnashop_pricing_helpers_read_final_amount_and_currency():
|
||||
pricing = {"final_amount": "199.50", "currency": "rub"}
|
||||
|
||||
assert remnashop_pricing_amount(pricing) == 199.5
|
||||
assert remnashop_pricing_currency(pricing) == "RUB"
|
||||
|
||||
|
||||
def test_remnashop_traffic_limit_is_converted_from_gib():
|
||||
assert remnashop_traffic_gb_to_bytes(10) == 10 * 1024**3
|
||||
assert remnashop_traffic_gb_to_bytes(None) is None
|
||||
|
||||
|
||||
def test_remnashop_status_and_sale_mode_mapping_matches_current_payment_model():
|
||||
assert remnashop_transaction_status("COMPLETED", "YOOKASSA") == "succeeded"
|
||||
assert remnashop_transaction_status("PENDING", "WATA") == "pending_wata"
|
||||
assert remnashop_transaction_status("CANCELED", "WATA") == "canceled"
|
||||
assert remnashop_sale_mode("NEW") == "subscription"
|
||||
assert remnashop_sale_mode("RENEW") == "subscription"
|
||||
assert remnashop_sale_mode("CHANGE") == "tariff_upgrade"
|
||||
|
||||
|
||||
def test_remnashop_plan_months_prefers_snapshot_then_dates():
|
||||
assert remnashop_months_from_plan_snapshot({"duration_days": 90}) == 3
|
||||
assert remnashop_months_from_plan_snapshot({"months": 12}) == 12
|
||||
assert (
|
||||
remnashop_months_from_plan_snapshot(
|
||||
{},
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
expire_at=datetime(2026, 4, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
== 3
|
||||
)
|
||||
@@ -1,5 +1,11 @@
|
||||
from db.migrator import MIGRATIONS
|
||||
from db.models import SupportTicket, SupportTicketMessage, User
|
||||
from db.models import (
|
||||
LegacyImportMapping,
|
||||
LegacyReferralCode,
|
||||
SupportTicket,
|
||||
SupportTicketMessage,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def test_support_migration_is_registered_after_existing_revisions():
|
||||
@@ -39,3 +45,18 @@ def test_trial_eligibility_reset_migration_and_model_are_registered():
|
||||
"0032_add_telegram_notification_status"
|
||||
)
|
||||
assert "trial_eligibility_reset_at" in User.__table__.columns
|
||||
|
||||
|
||||
def test_legacy_import_compatibility_migration_and_models_are_registered():
|
||||
ids = [migration.id for migration in MIGRATIONS]
|
||||
|
||||
assert "0034_add_legacy_import_compatibility" in ids
|
||||
assert ids.index("0034_add_legacy_import_compatibility") > ids.index(
|
||||
"0033_add_trial_eligibility_reset_marker"
|
||||
)
|
||||
assert User.__table__.columns["referral_code"].type.length == 64
|
||||
assert LegacyReferralCode.__tablename__ == "legacy_referral_codes"
|
||||
assert LegacyImportMapping.__tablename__ == "legacy_import_mappings"
|
||||
assert "uq_legacy_referral_source_code" in {
|
||||
constraint.name for constraint in LegacyReferralCode.__table__.constraints
|
||||
}
|
||||
|
||||
@@ -230,6 +230,8 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
self.assertIn("support_ticket_messages", update_tables)
|
||||
self.assertIn("email_verification_codes", delete_tables)
|
||||
self.assertIn("legacy_referral_codes", delete_tables)
|
||||
self.assertIn("legacy_import_mappings", delete_tables)
|
||||
session.delete.assert_awaited_once_with(user)
|
||||
session.flush.assert_awaited_once()
|
||||
|
||||
@@ -366,6 +368,8 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("payments", update_tables)
|
||||
self.assertIn("promo_code_activations", update_tables)
|
||||
self.assertIn("user_payment_methods", update_tables)
|
||||
self.assertIn("legacy_referral_codes", update_tables)
|
||||
self.assertIn("legacy_import_mappings", update_tables)
|
||||
self.assertIn("message_logs", update_tables)
|
||||
self.assertIn("users", update_tables)
|
||||
self.assertIn("user_payment_methods", delete_tables)
|
||||
|
||||
Reference in New Issue
Block a user