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:
3252a8
2026-06-02 00:13:54 +03:00
parent 56796d9f22
commit 5e47a1496b
18 changed files with 1731 additions and 63 deletions
@@ -402,6 +402,38 @@ SETTINGS_MANIFEST: List[SettingField] = [
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0 "REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
), ),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"), 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 ───────────────────────────────────────────── # ─── Notifications ─────────────────────────────────────────────
SettingField( SettingField(
"SUBSCRIPTION_NOTIFICATIONS_ENABLED", "SUBSCRIPTION_NOTIFICATIONS_ENABLED",
@@ -734,6 +766,7 @@ def manifest_payload() -> List[dict]:
"devices": 10, "devices": 10,
"subscription_guides": 10, "subscription_guides": 10,
"system": 12, "system": 12,
"migrations": 13,
} }
exclusive_map = { exclusive_map = {
key: opposite key: opposite
+76 -16
View File
@@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
session, session,
referral_param, referral_param,
current_user_id=None, current_user_id=None,
settings=settings,
) )
db_user, _ = await user_dal.create_email_user( db_user, _ = await user_dal.create_email_user(
session, session,
@@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
session, session,
referral_param, referral_param,
current_user_id=None, current_user_id=None,
settings=settings,
) )
db_user, _ = await user_dal.create_email_user( db_user, _ = await user_dal.create_email_user(
session, session,
@@ -1292,17 +1294,35 @@ async def _link_telegram_to_user(
return current_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() value = (raw or "").strip()
if not value: if not value:
return None return ""
value_lower = value.lower() 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:] value = value[5:]
elif value_lower.startswith("ref_"): elif value_lower.startswith("ref_"):
value = value[4:] 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:] value = value[1:]
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value): if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
@@ -1310,26 +1330,64 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
return value.upper() 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( async def _resolve_referrer_id(
session: AsyncSession, session: AsyncSession,
raw_referral_param: Optional[str], raw_referral_param: Optional[str],
*, *,
current_user_id: Optional[int], current_user_id: Optional[int],
settings: Optional[Settings] = None,
) -> Optional[int]: ) -> Optional[int]:
normalized = _normalize_referral_param(raw_referral_param) remnashop_compat = _remnashop_referral_compat_enabled(settings)
if not normalized: candidates = _referral_param_lookup_candidates(
raw_referral_param,
remnashop_compat=remnashop_compat,
)
if not candidates:
return None return None
ref_user = None for normalized in candidates:
if normalized.isdigit(): ref_user = None
ref_user = await user_dal.get_user_by_id(session, int(normalized)) if normalized.isdigit() and not remnashop_compat:
if not ref_user: ref_user = await user_dal.get_user_by_id(session, int(normalized))
ref_user = await user_dal.get_user_by_referral_code(session, normalized) if not ref_user:
if not ref_user: ref_user = await user_dal.get_user_by_referral_code(
return None session,
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id): normalized,
return None include_legacy=remnashop_compat,
return int(ref_user.user_id) )
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:
continue
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
continue
return int(ref_user.user_id)
return None
async def _apply_referral_to_existing_user( async def _apply_referral_to_existing_user(
@@ -1345,6 +1403,7 @@ async def _apply_referral_to_existing_user(
session, session,
raw_referral_param, raw_referral_param,
current_user_id=int(user.user_id), current_user_id=int(user.user_id),
settings=request.app["settings"],
) )
if not referred_by_id: if not referred_by_id:
return False return False
@@ -1427,6 +1486,7 @@ async def _ensure_user_from_telegram(
session, session,
referral_param or telegram_user.get("start_param"), referral_param or telegram_user.get("start_param"),
current_user_id=user_id, current_user_id=user_id,
settings=settings,
) )
db_user, created = await user_dal.create_user( db_user, created = await user_dal.create_user(
session, session,
+69 -20
View File
@@ -40,6 +40,67 @@ from db.models import User
router = Router(name="user_start_router") 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( async def should_show_trial_button(
settings: Settings, settings: Settings,
subscription_service: SubscriptionService, subscription_service: SubscriptionService,
@@ -412,12 +473,10 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart()) @router.message(CommandStart())
@router.message( @router.message(
CommandStart( CommandStart(
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_( magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")
"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"^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"^ticket_(\d+)$").as_("ticket_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_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: if ref_match:
raw_ref_value = ref_match.group(1) raw_ref_value = ref_match.group(1)
if raw_ref_value.isdigit(): referred_by_user_id = await _resolve_referrer_from_start_ref(
if settings.LEGACY_REFS: session,
potential_referrer_id = int(raw_ref_value) raw_ref_value,
if potential_referrer_id != user_id and await user_dal.get_user_by_id( settings=settings,
session, potential_referrer_id current_user_id=user_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
elif promo_match: elif promo_match:
promo_code_to_apply = promo_match.group(1) promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
+12 -6
View File
@@ -38,8 +38,12 @@ class PromoCodeService:
user_lang: str, user_lang: str,
) -> Tuple[bool, datetime | str]: ) -> Tuple[bool, datetime | str]:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = (code_input or "").strip().upper()[:100] preserve_case = bool(
code_display = html_escape(code_input_upper[:100], quote=False) 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_identifier = self._throttle_identifier(user_id)
throttle = await security_dal.check_throttle( 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( 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: if not promo_data:
@@ -71,9 +75,11 @@ class PromoCodeService:
"promo_code_too_many_attempts", "promo_code_too_many_attempts",
seconds=throttle_result.retry_after seconds=throttle_result.retry_after
or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)), or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
) )
return False, _("promo_code_not_found", code=code_display) 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( existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id session, promo_data.promo_code_id, user_id
) )
@@ -86,7 +92,7 @@ class PromoCodeService:
session=session, session=session,
user_id=user_id, user_id=user_id,
bonus_days=bonus_days, bonus_days=bonus_days,
reason=f"promo code {code_input_upper}", reason=f"promo code {applied_code}",
) )
if new_end_date: if new_end_date:
@@ -109,7 +115,7 @@ class PromoCodeService:
user = await user_dal.get_user_by_id(session, user_id) user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_promo_activation( await notification_service.notify_promo_activation(
user_id=user_id, user_id=user_id,
promo_code=code_input_upper, promo_code=applied_code,
bonus_days=bonus_days, bonus_days=bonus_days,
username=user.username if user else None, username=user.username if user else None,
email=getattr(user, "email", None) if user else None, email=getattr(user, "email", None) if user else None,
+18
View File
@@ -286,6 +286,24 @@ class Settings(BaseSettings):
default=True, default=True,
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501 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( APP_RUNTIME_MODE: str = Field(
default="production", default="production",
+35 -13
View File
@@ -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) 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)""" """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):
result = await session.execute(stmt) stmt = select(PromoCode).where(PromoCode.code == candidate)
return result.scalar_one_or_none() result = await session.execute(stmt)
promo = result.scalar_one_or_none()
if promo:
return promo
return None
async def get_active_promo_code_by_code_str( 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]: ) -> Optional[PromoCode]:
stmt = select(PromoCode).where( now = datetime.now(timezone.utc)
PromoCode.code == code_str.upper(), for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
PromoCode.is_active == True, stmt = select(PromoCode).where(
PromoCode.current_activations < PromoCode.max_activations, PromoCode.code == candidate,
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)), PromoCode.is_active == True,
) PromoCode.current_activations < PromoCode.max_activations,
result = await session.execute(stmt) or_(PromoCode.valid_until == None, PromoCode.valid_until > now),
return result.scalar_one_or_none() )
result = await session.execute(stmt)
promo = result.scalar_one_or_none()
if promo:
return promo
return None
async def get_all_active_promo_codes( async def get_all_active_promo_codes(
+93 -6
View File
@@ -4,7 +4,7 @@ import string
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple 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.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
@@ -14,6 +14,8 @@ from ..models import (
AdAttribution, AdAttribution,
EmailVerificationCode, EmailVerificationCode,
HwidDevicePurchase, HwidDevicePurchase,
LegacyImportMapping,
LegacyReferralCode,
MessageLog, MessageLog,
Payment, Payment,
PromoCodeActivation, PromoCodeActivation,
@@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str:
Returns the existing or newly generated code. Returns the existing or newly generated code.
""" """
if user.referral_code: if user.referral_code:
normalized = user.referral_code.strip().upper() normalized = user.referral_code.strip()
if normalized != user.referral_code: if normalized != user.referral_code:
user.referral_code = normalized user.referral_code = normalized
await session.flush() 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"): if not user_data.get("referral_code"):
user_data["referral_code"] = await generate_unique_referral_code(session) user_data["referral_code"] = await generate_unique_referral_code(session)
else: 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 # Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
stmt = ( stmt = (
@@ -567,6 +569,19 @@ async def merge_users(
await session.execute( await session.execute(
update(model).where(model.user_id == source_user_id).values(user_id=target_user_id) 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( await session.execute(
update(MessageLog) update(MessageLog)
@@ -590,13 +605,60 @@ async def merge_users(
return target return target
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]: async def get_user_by_referral_code(
normalized = referral_code.strip().upper() session: AsyncSession,
referral_code: str,
*,
include_legacy: bool = False,
) -> Optional[User]:
normalized = referral_code.strip()
if not normalized: if not normalized:
return None return None
stmt = select(User).where(User.referral_code == normalized) stmt = select(User).where(User.referral_code == normalized)
result = await session.execute(stmt) 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( 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(UserBilling).where(UserBilling.user_id == user_id))
await session.execute(delete(AdAttribution).where(AdAttribution.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(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(Payment).where(Payment.user_id == user_id))
await session.execute(delete(Subscription).where(Subscription.user_id == user_id)) await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
+78
View File
@@ -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] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", 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", description="Track admin resets of per-user trial eligibility without deleting history",
upgrade=_migration_0033_add_trial_eligibility_reset_marker, 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
View File
@@ -43,7 +43,7 @@ class User(Base):
registration_date = Column(DateTime(timezone=True), server_default=func.now()) registration_date = Column(DateTime(timezone=True), server_default=func.now())
is_banned = Column(Boolean, default=False) is_banned = Column(Boolean, default=False)
panel_user_uuid = Column(String, nullable=True, unique=True, index=True) 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) referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True) lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), 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): class MessageLog(Base):
__tablename__ = "message_logs" __tablename__ = "message_logs"
+2
View File
@@ -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: "Устройства", devices: "Устройства",
subscription_guides: "Connection guides", subscription_guides: "Connection guides",
system: "Система", system: "Система",
migrations: "Миграции",
}; };
return adminText(`settings_section_${id}`, {}, map[id] || id); return adminText(`settings_section_${id}`, {}, map[id] || id);
} }
+10
View File
@@ -1108,11 +1108,13 @@
"admin_settings_section_devices": "Devices", "admin_settings_section_devices": "Devices",
"admin_settings_section_support": "Support", "admin_settings_section_support": "Support",
"admin_settings_section_system": "System", "admin_settings_section_system": "System",
"admin_settings_section_migrations": "Migrations",
"admin_settings_field_telemetry_enabled_label": "Anonymous install analytics", "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_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_common": "Common",
"admin_settings_subsection_checkout": "Checkout", "admin_settings_subsection_checkout": "Checkout",
"admin_settings_subsection_remnawave": "Remnawave", "admin_settings_subsection_remnawave": "Remnawave",
"admin_settings_subsection_remnashop": "Remnashop",
"admin_settings_subsection_telegram_stars": "Telegram Stars", "admin_settings_subsection_telegram_stars": "Telegram Stars",
"admin_settings_subsection_yookassa": "YooKassa", "admin_settings_subsection_yookassa": "YooKassa",
"admin_settings_subsection_freekassa": "FreeKassa", "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_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_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_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_validation_errors": "Errors: {errors}",
"admin_settings_save_error": "Error: {error}", "admin_settings_save_error": "Error: {error}",
"admin_sync_started": "Synchronization started", "admin_sync_started": "Synchronization started",
+10
View File
@@ -1108,11 +1108,13 @@
"admin_settings_section_devices": "Устройства", "admin_settings_section_devices": "Устройства",
"admin_settings_section_support": "Поддержка", "admin_settings_section_support": "Поддержка",
"admin_settings_section_system": "Система", "admin_settings_section_system": "Система",
"admin_settings_section_migrations": "Миграции",
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки", "admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.", "admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.",
"admin_settings_subsection_common": "Общие", "admin_settings_subsection_common": "Общие",
"admin_settings_subsection_checkout": "Оформление оплаты", "admin_settings_subsection_checkout": "Оформление оплаты",
"admin_settings_subsection_remnawave": "Remnawave", "admin_settings_subsection_remnawave": "Remnawave",
"admin_settings_subsection_remnashop": "Remnashop",
"admin_settings_subsection_telegram_stars": "Telegram Stars", "admin_settings_subsection_telegram_stars": "Telegram Stars",
"admin_settings_subsection_yookassa": "YooKassa", "admin_settings_subsection_yookassa": "YooKassa",
"admin_settings_subsection_freekassa": "FreeKassa", "admin_settings_subsection_freekassa": "FreeKassa",
@@ -1127,6 +1129,14 @@
"admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.", "admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.",
"admin_settings_provider_admin_only_label": "Только для админов", "admin_settings_provider_admin_only_label": "Только для админов",
"admin_settings_provider_admin_only_description": "Показывает провайдер только администраторам. Вебхуки и обработка статусов остаются активными для тестовых платежей.", "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_validation_errors": "Ошибки: {errors}",
"admin_settings_save_error": "Ошибка: {error}", "admin_settings_save_error": "Ошибка: {error}",
"admin_sync_started": "Синхронизация запущена", "admin_sync_started": "Синхронизация запущена",
@@ -44,6 +44,13 @@ BACKUP_SETTINGS = (
"BACKUP_COMPOSE_ENABLED", "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_TARIFF_SETTINGS_PAGE_KEYS = {
"admin_tariffs_trial_title", "admin_tariffs_trial_title",
"admin_tariffs_trial_subtitle", "admin_tariffs_trial_subtitle",
@@ -204,6 +211,27 @@ def test_backup_settings_i18n_keys_exist():
assert field["i18n_description_key"] in messages 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(): def test_backup_required_numeric_settings_reject_empty_values():
with pytest.raises(ValueError): with pytest.raises(ValueError):
coerce_value(get_field_by_key("BACKUP_INTERVAL_SECONDS"), "") coerce_value(get_field_by_key("BACKUP_INTERVAL_SECONDS"), "")
+44
View File
@@ -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
)
+22 -1
View File
@@ -1,5 +1,11 @@
from db.migrator import MIGRATIONS 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(): 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" "0032_add_telegram_notification_status"
) )
assert "trial_eligibility_reset_at" in User.__table__.columns 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
}
+4
View File
@@ -230,6 +230,8 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
) )
self.assertIn("support_ticket_messages", update_tables) self.assertIn("support_ticket_messages", update_tables)
self.assertIn("email_verification_codes", delete_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.delete.assert_awaited_once_with(user)
session.flush.assert_awaited_once() session.flush.assert_awaited_once()
@@ -366,6 +368,8 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("payments", update_tables) self.assertIn("payments", update_tables)
self.assertIn("promo_code_activations", update_tables) self.assertIn("promo_code_activations", update_tables)
self.assertIn("user_payment_methods", 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("message_logs", update_tables)
self.assertIn("users", update_tables) self.assertIn("users", update_tables)
self.assertIn("user_payment_methods", delete_tables) self.assertIn("user_payment_methods", delete_tables)