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
),
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
+70 -10
View File
@@ -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,
+69 -20
View File
@@ -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}")
+11 -5
View File
@@ -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,
+18
View File
@@ -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",
+29 -7
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)
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(
+93 -6
View File
@@ -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))
+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] = [
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
View File
@@ -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"
+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: "Устройства",
subscription_guides: "Connection guides",
system: "Система",
migrations: "Миграции",
};
return adminText(`settings_section_${id}`, {}, map[id] || id);
}
+10
View File
@@ -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",
+10
View File
@@ -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"), "")
+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.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
}
+4
View File
@@ -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)