feat: automatic merge two paid subs (email and tg)
This commit is contained in:
@@ -613,6 +613,11 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
|||||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
merge_notice: Optional[Dict[str, Any]] = None
|
||||||
|
source_panel_uuid: Optional[str] = None
|
||||||
|
final_user_id = user_id
|
||||||
|
final_email = email
|
||||||
|
final_panel_uuid: Optional[str] = None
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
try:
|
try:
|
||||||
@@ -643,15 +648,72 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
existing_email_user = await user_dal.get_user_by_email(session, email)
|
existing_email_user = await user_dal.get_user_by_email(session, email)
|
||||||
if existing_email_user and existing_email_user.user_id != current_user.user_id:
|
if existing_email_user and existing_email_user.user_id != current_user.user_id:
|
||||||
|
source_panel_uuid = existing_email_user.panel_user_uuid
|
||||||
current_user = await user_dal.merge_users(
|
current_user = await user_dal.merge_users(
|
||||||
session,
|
session,
|
||||||
source_user_id=existing_email_user.user_id,
|
source_user_id=existing_email_user.user_id,
|
||||||
target_user_id=current_user.user_id,
|
target_user_id=current_user.user_id,
|
||||||
)
|
)
|
||||||
|
merge_notice = await _build_account_merge_notice(
|
||||||
|
session,
|
||||||
|
merged_user=current_user,
|
||||||
|
source_user_id=existing_email_user.user_id,
|
||||||
|
source_panel_uuid=source_panel_uuid,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
current_user.email = email
|
current_user.email = email
|
||||||
current_user.email_verified_at = datetime.now(timezone.utc)
|
current_user.email_verified_at = datetime.now(timezone.utc)
|
||||||
await _sync_panel_identity_for_user(request, current_user)
|
await _sync_panel_identity_for_user(request, current_user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
final_user_id = int(current_user.user_id)
|
||||||
|
final_panel_uuid = current_user.panel_user_uuid
|
||||||
|
|
||||||
|
if merge_notice:
|
||||||
|
merge_end_date_raw = merge_notice.get("final_end_date")
|
||||||
|
merge_end_date = (
|
||||||
|
datetime.fromisoformat(merge_end_date_raw)
|
||||||
|
if merge_end_date_raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
await _sync_panel_identity_for_user(
|
||||||
|
request,
|
||||||
|
current_user,
|
||||||
|
expire_at=merge_end_date,
|
||||||
|
)
|
||||||
|
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||||
|
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||||
|
subscription_service: SubscriptionService = request.app.get("subscription_service")
|
||||||
|
if subscription_service and subscription_service.panel_service:
|
||||||
|
try:
|
||||||
|
await subscription_service.panel_service.delete_user_from_panel(
|
||||||
|
source_panel_uuid,
|
||||||
|
log_response=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to delete merged source panel user %s: %s",
|
||||||
|
source_panel_uuid,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||||
|
if email_service and final_email:
|
||||||
|
email_payload = _build_account_merge_email(
|
||||||
|
merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
|
||||||
|
merge_notice,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await email_service.send_custom_email(
|
||||||
|
email=final_email,
|
||||||
|
subject=email_payload["subject"],
|
||||||
|
body=email_payload["body"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to send account merge email to %s: %s",
|
||||||
|
final_email,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
except UserMergeConflictError as exc:
|
except UserMergeConflictError as exc:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
return _json_error(409, "account_merge_conflict", str(exc))
|
return _json_error(409, "account_merge_conflict", str(exc))
|
||||||
@@ -660,8 +722,12 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
|||||||
logger.error("Email account link failed: %s", exc, exc_info=True)
|
logger.error("Email account link failed: %s", exc, exc_info=True)
|
||||||
return _json_error(500, "link_failed", "Link failed")
|
return _json_error(500, "link_failed", "Link failed")
|
||||||
|
|
||||||
token = create_webapp_session_token(settings, int(current_user.user_id))
|
token = create_webapp_session_token(settings, int(final_user_id))
|
||||||
return web.json_response({"ok": True, "token": token})
|
response_payload: Dict[str, Any] = {"ok": True, "token": token}
|
||||||
|
if merge_notice:
|
||||||
|
response_payload["account_merge"] = merge_notice
|
||||||
|
response_payload["user_id"] = final_user_id
|
||||||
|
return web.json_response(response_payload)
|
||||||
|
|
||||||
|
|
||||||
async def account_telegram_link_route(request: web.Request) -> web.Response:
|
async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||||
@@ -687,8 +753,20 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
|||||||
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
|
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
|
||||||
|
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
merge_notice: Optional[Dict[str, Any]] = None
|
||||||
|
source_panel_uuid: Optional[str] = None
|
||||||
|
final_user_id = user_id
|
||||||
|
final_telegram_id: Optional[int] = None
|
||||||
|
final_email: Optional[str] = None
|
||||||
|
final_panel_uuid: Optional[str] = None
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
try:
|
try:
|
||||||
|
current_user_before_link = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not current_user_before_link or current_user_before_link.is_banned:
|
||||||
|
await session.rollback()
|
||||||
|
return _json_error(403, "access_denied", "Access denied")
|
||||||
|
source_panel_uuid = current_user_before_link.panel_user_uuid
|
||||||
|
|
||||||
db_user = await _link_telegram_to_user(
|
db_user = await _link_telegram_to_user(
|
||||||
request,
|
request,
|
||||||
session,
|
session,
|
||||||
@@ -699,7 +777,67 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
|||||||
if db_user.is_banned:
|
if db_user.is_banned:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
return _json_error(403, "banned", "Access denied")
|
return _json_error(403, "banned", "Access denied")
|
||||||
|
|
||||||
|
final_user_id = int(db_user.user_id)
|
||||||
|
final_telegram_id = _telegram_id_for_user(db_user)
|
||||||
|
final_email = db_user.email
|
||||||
|
final_panel_uuid = db_user.panel_user_uuid
|
||||||
|
if final_user_id != user_id:
|
||||||
|
merge_notice = await _build_account_merge_notice(
|
||||||
|
session,
|
||||||
|
merged_user=db_user,
|
||||||
|
source_user_id=user_id,
|
||||||
|
source_panel_uuid=source_panel_uuid,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
if merge_notice:
|
||||||
|
merge_end_date_raw = merge_notice.get("final_end_date")
|
||||||
|
merge_end_date = (
|
||||||
|
datetime.fromisoformat(merge_end_date_raw)
|
||||||
|
if merge_end_date_raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
await _sync_panel_identity_for_user(
|
||||||
|
request,
|
||||||
|
db_user,
|
||||||
|
expire_at=merge_end_date,
|
||||||
|
)
|
||||||
|
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||||
|
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||||
|
subscription_service: SubscriptionService = request.app.get("subscription_service")
|
||||||
|
if subscription_service and subscription_service.panel_service:
|
||||||
|
try:
|
||||||
|
await subscription_service.panel_service.delete_user_from_panel(
|
||||||
|
source_panel_uuid,
|
||||||
|
log_response=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to delete merged source panel user %s: %s",
|
||||||
|
source_panel_uuid,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||||
|
if email_service and final_email:
|
||||||
|
email_payload = _build_account_merge_email(
|
||||||
|
merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
|
||||||
|
merge_notice,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await email_service.send_custom_email(
|
||||||
|
email=final_email,
|
||||||
|
subject=email_payload["subject"],
|
||||||
|
body=email_payload["body"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to send account merge email to %s: %s",
|
||||||
|
final_email,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
except UserMergeConflictError as exc:
|
except UserMergeConflictError as exc:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
return _json_error(409, "account_merge_conflict", str(exc))
|
return _json_error(409, "account_merge_conflict", str(exc))
|
||||||
@@ -708,14 +846,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
|||||||
logger.error("Telegram account link failed: %s", exc, exc_info=True)
|
logger.error("Telegram account link failed: %s", exc, exc_info=True)
|
||||||
return _json_error(500, "link_failed", "Link failed")
|
return _json_error(500, "link_failed", "Link failed")
|
||||||
|
|
||||||
token = create_webapp_session_token(settings, int(db_user.user_id))
|
token = create_webapp_session_token(settings, int(final_user_id))
|
||||||
return web.json_response(
|
response_payload: Dict[str, Any] = {
|
||||||
{
|
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"token": token,
|
"token": token,
|
||||||
"user_id": int(db_user.user_id),
|
"user_id": int(final_user_id),
|
||||||
"telegram_id": _telegram_id_for_user(db_user),
|
"telegram_id": final_telegram_id,
|
||||||
}
|
}
|
||||||
|
if merge_notice:
|
||||||
|
response_payload["account_merge"] = merge_notice
|
||||||
|
return web.json_response(
|
||||||
|
response_payload
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -913,12 +1054,17 @@ def _panel_description_for_user(user: User) -> str:
|
|||||||
return "\n".join(line for line in lines if line).strip()
|
return "\n".join(line for line in lines if line).strip()
|
||||||
|
|
||||||
|
|
||||||
async def _sync_panel_identity_for_user(request: web.Request, user: User) -> None:
|
async def _sync_panel_identity_for_user(
|
||||||
|
request: web.Request,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
expire_at: Optional[datetime] = None,
|
||||||
|
) -> bool:
|
||||||
if not user.panel_user_uuid:
|
if not user.panel_user_uuid:
|
||||||
return
|
return False
|
||||||
subscription_service: SubscriptionService = request.app.get("subscription_service")
|
subscription_service: SubscriptionService = request.app.get("subscription_service")
|
||||||
if not subscription_service or not subscription_service.panel_service:
|
if not subscription_service or not subscription_service.panel_service:
|
||||||
return
|
return False
|
||||||
|
|
||||||
payload: Dict[str, Any] = {
|
payload: Dict[str, Any] = {
|
||||||
"description": _panel_description_for_user(user),
|
"description": _panel_description_for_user(user),
|
||||||
@@ -928,6 +1074,8 @@ async def _sync_panel_identity_for_user(request: web.Request, user: User) -> Non
|
|||||||
payload["telegramId"] = telegram_id
|
payload["telegramId"] = telegram_id
|
||||||
if user.email:
|
if user.email:
|
||||||
payload["email"] = user.email
|
payload["email"] = user.email
|
||||||
|
if expire_at is not None:
|
||||||
|
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await subscription_service.panel_service.update_user_details_on_panel(
|
await subscription_service.panel_service.update_user_details_on_panel(
|
||||||
@@ -935,12 +1083,91 @@ async def _sync_panel_identity_for_user(request: web.Request, user: User) -> Non
|
|||||||
payload,
|
payload,
|
||||||
log_response=False,
|
log_response=False,
|
||||||
)
|
)
|
||||||
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to sync linked identities to panel for user %s: %s",
|
"Failed to sync linked identities to panel for user %s: %s",
|
||||||
user.user_id,
|
user.user_id,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _format_webapp_datetime(value: Optional[datetime]) -> Optional[str]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_account_merge_notice(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
merged_user: User,
|
||||||
|
source_user_id: int,
|
||||||
|
source_panel_uuid: Optional[str],
|
||||||
|
settings: Settings,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
merged_subscription = None
|
||||||
|
if merged_user.panel_user_uuid:
|
||||||
|
merged_subscription = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session,
|
||||||
|
merged_user.user_id,
|
||||||
|
merged_user.panel_user_uuid,
|
||||||
|
)
|
||||||
|
if not merged_subscription:
|
||||||
|
merged_subscription = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session,
|
||||||
|
merged_user.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
final_end_date = merged_subscription.end_date if merged_subscription else None
|
||||||
|
if final_end_date and final_end_date.tzinfo is None:
|
||||||
|
final_end_date = final_end_date.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"merged": True,
|
||||||
|
"language": _normalize_language(merged_user.language_code or settings.DEFAULT_LANGUAGE),
|
||||||
|
"primary_user_id": int(merged_user.user_id),
|
||||||
|
"removed_user_id": int(source_user_id),
|
||||||
|
"primary_panel_user_uuid": merged_user.panel_user_uuid,
|
||||||
|
"removed_panel_user_uuid": source_panel_uuid,
|
||||||
|
"final_end_date": final_end_date.isoformat() if final_end_date else None,
|
||||||
|
"final_end_date_text": _format_webapp_datetime(final_end_date),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_account_merge_email(language: str, merge_info: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
lang = _normalize_language(language)
|
||||||
|
primary_user_id = merge_info.get("primary_user_id")
|
||||||
|
removed_user_id = merge_info.get("removed_user_id")
|
||||||
|
final_end_date_text = (
|
||||||
|
merge_info.get("final_end_date_text")
|
||||||
|
or merge_info.get("final_end_date")
|
||||||
|
or "N/A"
|
||||||
|
)
|
||||||
|
if lang == "en":
|
||||||
|
return {
|
||||||
|
"subject": "Accounts merged",
|
||||||
|
"body": (
|
||||||
|
"We merged your accounts into one profile.\n\n"
|
||||||
|
f"Kept account: #{primary_user_id}\n"
|
||||||
|
f"Removed account: #{removed_user_id}\n"
|
||||||
|
f"Paid periods were combined. New subscription end date: {final_end_date_text}.\n"
|
||||||
|
"Your subscription link stayed the same, and the later account was removed from Remnawave automatically."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"subject": "Аккаунты объединены",
|
||||||
|
"body": (
|
||||||
|
"Мы объединили ваши аккаунты в один профиль.\n\n"
|
||||||
|
f"Оставлен аккаунт: #{primary_user_id}\n"
|
||||||
|
f"Удалён аккаунт: #{removed_user_id}\n"
|
||||||
|
f"Оплаченные периоды сложились. Новая дата окончания подписки: {final_end_date_text}.\n"
|
||||||
|
"Ссылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||||
|
|||||||
@@ -190,6 +190,11 @@
|
|||||||
<section id="referral-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
<section id="referral-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="account-merge-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="account-merge-title" aria-describedby="account-merge-caption">
|
||||||
|
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeAccountMergeModal()"></button>
|
||||||
|
<section id="account-merge-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
|
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
|
||||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()"></button>
|
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()"></button>
|
||||||
<div class="relative z-[1] grid w-[min(100%,420px)] justify-items-stretch gap-2.5">
|
<div class="relative z-[1] grid w-[min(100%,420px)] justify-items-stretch gap-2.5">
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ const MOCK = (() => {
|
|||||||
paymentFlowOpen: false,
|
paymentFlowOpen: false,
|
||||||
promoModalOpen: false,
|
promoModalOpen: false,
|
||||||
referralModalOpen: false,
|
referralModalOpen: false,
|
||||||
|
accountMergeModalOpen: false,
|
||||||
|
accountMergeNotice: null,
|
||||||
creatingPayment: false,
|
creatingPayment: false,
|
||||||
authInProgress: false,
|
authInProgress: false,
|
||||||
authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'),
|
authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'),
|
||||||
@@ -200,6 +202,13 @@ const MOCK = (() => {
|
|||||||
email_rate_limited: 'Повторная отправка доступна через {seconds} сек.',
|
email_rate_limited: 'Повторная отправка доступна через {seconds} сек.',
|
||||||
email_linked: 'Email привязан',
|
email_linked: 'Email привязан',
|
||||||
telegram_linked: 'Telegram привязан',
|
telegram_linked: 'Telegram привязан',
|
||||||
|
account_merge_toast: 'Аккаунты объединены',
|
||||||
|
account_merge_title: 'Аккаунты объединены',
|
||||||
|
account_merge_caption: 'Оплаченные периоды сложились, и теперь у вас один аккаунт.',
|
||||||
|
account_merge_body: 'Мы объединили ваш Telegram и email в один профиль. Оплаченные периоды сложились, ссылка на подписку осталась прежней, а более поздний аккаунт был удалён автоматически.',
|
||||||
|
account_merge_primary_account_label: 'Оставлен аккаунт',
|
||||||
|
account_merge_removed_account_label: 'Удалён аккаунт',
|
||||||
|
account_merge_end_date_label: 'Новая дата окончания',
|
||||||
account_merge_conflict: 'Этот аккаунт уже связан с другими данными.',
|
account_merge_conflict: 'Этот аккаунт уже связан с другими данными.',
|
||||||
telegram_auth: 'Telegram auth',
|
telegram_auth: 'Telegram auth',
|
||||||
telegram_auth_verifying: 'Проверяю вход...',
|
telegram_auth_verifying: 'Проверяю вход...',
|
||||||
@@ -330,6 +339,13 @@ const MOCK = (() => {
|
|||||||
email_rate_limited: 'Try again in {seconds} sec.',
|
email_rate_limited: 'Try again in {seconds} sec.',
|
||||||
email_linked: 'Email linked',
|
email_linked: 'Email linked',
|
||||||
telegram_linked: 'Telegram linked',
|
telegram_linked: 'Telegram linked',
|
||||||
|
account_merge_toast: 'Accounts merged',
|
||||||
|
account_merge_title: 'Accounts merged',
|
||||||
|
account_merge_caption: 'Your paid periods were combined and you now have one account.',
|
||||||
|
account_merge_body: 'We merged your Telegram and email accounts into one profile. Your paid periods were combined, your subscription link stayed the same, and the later account was removed automatically.',
|
||||||
|
account_merge_primary_account_label: 'Kept account',
|
||||||
|
account_merge_removed_account_label: 'Removed account',
|
||||||
|
account_merge_end_date_label: 'New end date',
|
||||||
account_merge_conflict: 'This account is already linked to different data.',
|
account_merge_conflict: 'This account is already linked to different data.',
|
||||||
telegram_auth: 'Telegram auth',
|
telegram_auth: 'Telegram auth',
|
||||||
telegram_auth_verifying: 'Verifying login...',
|
telegram_auth_verifying: 'Verifying login...',
|
||||||
@@ -431,6 +447,8 @@ const MOCK = (() => {
|
|||||||
if (event.key !== 'Escape') return;
|
if (event.key !== 'Escape') return;
|
||||||
if (state.emailLoginCodeModalOpen) {
|
if (state.emailLoginCodeModalOpen) {
|
||||||
closeEmailLoginCodeModal();
|
closeEmailLoginCodeModal();
|
||||||
|
} else if (state.accountMergeModalOpen) {
|
||||||
|
closeAccountMergeModal();
|
||||||
} else if (state.promoModalOpen) {
|
} else if (state.promoModalOpen) {
|
||||||
closePromoModal();
|
closePromoModal();
|
||||||
} else if (state.referralModalOpen) {
|
} else if (state.referralModalOpen) {
|
||||||
@@ -904,6 +922,7 @@ const MOCK = (() => {
|
|||||||
renderUserMenu(state.data.user || {});
|
renderUserMenu(state.data.user || {});
|
||||||
renderAccount(state.data.user || {});
|
renderAccount(state.data.user || {});
|
||||||
renderReferral(state.data.referral || {});
|
renderReferral(state.data.referral || {});
|
||||||
|
renderAccountMergeModal();
|
||||||
renderPaymentFlow();
|
renderPaymentFlow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1299,13 +1318,82 @@ const MOCK = (() => {
|
|||||||
function closeToolModals() {
|
function closeToolModals() {
|
||||||
state.promoModalOpen = false;
|
state.promoModalOpen = false;
|
||||||
state.referralModalOpen = false;
|
state.referralModalOpen = false;
|
||||||
|
state.accountMergeModalOpen = false;
|
||||||
clearPromoStatus();
|
clearPromoStatus();
|
||||||
renderPromoModal();
|
renderPromoModal();
|
||||||
renderReferral(state.data && state.data.referral || {});
|
renderReferral(state.data && state.data.referral || {});
|
||||||
|
renderAccountMergeModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function openAccountMergeModal(notice = null) {
|
||||||
|
if (notice) {
|
||||||
|
state.accountMergeNotice = notice;
|
||||||
|
}
|
||||||
|
state.accountMergeModalOpen = true;
|
||||||
|
renderAccountMergeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAccountMergeModal() {
|
||||||
|
state.accountMergeModalOpen = false;
|
||||||
|
renderAccountMergeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccountMergeModal() {
|
||||||
|
const modal = document.getElementById('account-merge-modal');
|
||||||
|
const panel = document.getElementById('account-merge-panel');
|
||||||
|
if (!modal || !panel) return;
|
||||||
|
|
||||||
|
if (!state.accountMergeModalOpen) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
syncModalLock();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!state.accountMergeModalOpen) modal.classList.add('hidden');
|
||||||
|
}, 180);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
syncModalLock();
|
||||||
|
|
||||||
|
const notice = state.accountMergeNotice || {};
|
||||||
|
const primaryUserId = notice.primary_user_id != null ? String(notice.primary_user_id) : t('not_available');
|
||||||
|
const removedUserId = notice.removed_user_id != null ? String(notice.removed_user_id) : t('not_available');
|
||||||
|
const finalEndDateText = notice.final_end_date_text || t('not_available');
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="${TW.panelHead}">
|
||||||
|
<div>
|
||||||
|
<div id="account-merge-title" class="${TW.sectionTitle} text-[var(--accent)]">${escapeHtml(t('account_merge_title'))}</div>
|
||||||
|
<div id="account-merge-caption" class="${TW.flowCaption}">${escapeHtml(t('account_merge_caption'))}</div>
|
||||||
|
</div>
|
||||||
|
<button class="${TW.iconBtn}" type="button" data-title-i18n="close" onclick="closeAccountMergeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice grid gap-2">
|
||||||
|
<div class="text-[13px] leading-[1.5] text-[var(--text-secondary)]">${escapeHtml(t('account_merge_body'))}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||||
|
<div class="metric-label">${escapeHtml(t('account_merge_primary_account_label'))}</div>
|
||||||
|
<div class="metric-value">#${escapeHtml(primaryUserId)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||||
|
<div class="metric-label">${escapeHtml(t('account_merge_removed_account_label'))}</div>
|
||||||
|
<div class="metric-value">#${escapeHtml(removedUserId)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||||
|
<div class="metric-label">${escapeHtml(t('account_merge_end_date_label'))}</div>
|
||||||
|
<div class="metric-value">${escapeHtml(finalEndDateText)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
applyI18n(modal);
|
||||||
|
window.requestAnimationFrame(() => modal.classList.add('show'));
|
||||||
|
}
|
||||||
|
|
||||||
function renderReferral(referral) {
|
function renderReferral(referral) {
|
||||||
const modal = document.getElementById('referral-modal');
|
const modal = document.getElementById('referral-modal');
|
||||||
const panel = document.getElementById('referral-panel');
|
const panel = document.getElementById('referral-panel');
|
||||||
@@ -1481,6 +1569,7 @@ const MOCK = (() => {
|
|||||||
const email = state.emailLinkEmail || normalizeEmail(document.getElementById('email-link-input').value);
|
const email = state.emailLinkEmail || normalizeEmail(document.getElementById('email-link-input').value);
|
||||||
const code = document.getElementById('email-link-code-input').value;
|
const code = document.getElementById('email-link-code-input').value;
|
||||||
setButtonBusy('email-link-verify-btn', true);
|
setButtonBusy('email-link-verify-btn', true);
|
||||||
|
let mergeNotice = null;
|
||||||
try {
|
try {
|
||||||
const data = await api('/account/email/verify', {
|
const data = await api('/account/email/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1488,9 +1577,21 @@ const MOCK = (() => {
|
|||||||
});
|
});
|
||||||
if (!data.ok) throw data;
|
if (!data.ok) throw data;
|
||||||
if (data.token) setToken(data.token);
|
if (data.token) setToken(data.token);
|
||||||
showToast(t('email_linked'));
|
mergeNotice = data.account_merge && data.account_merge.merged ? data.account_merge : null;
|
||||||
|
state.accountMergeNotice = mergeNotice;
|
||||||
|
showToast(mergeNotice ? t('account_merge_toast') : t('email_linked'));
|
||||||
state.emailLinkEmail = '';
|
state.emailLinkEmail = '';
|
||||||
|
try {
|
||||||
await loadData();
|
await loadData();
|
||||||
|
if (mergeNotice) {
|
||||||
|
openAccountMergeModal(mergeNotice);
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.warn('Email account linked, but data refresh failed', refreshError);
|
||||||
|
if (mergeNotice) {
|
||||||
|
openAccountMergeModal(mergeNotice);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(emailErrorMessage(e, 'email_code_invalid'));
|
showToast(emailErrorMessage(e, 'email_code_invalid'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1557,15 +1658,23 @@ const MOCK = (() => {
|
|||||||
markTelegramLinked(data.telegram_id || fallbackTelegramId);
|
markTelegramLinked(data.telegram_id || fallbackTelegramId);
|
||||||
setTelegramLinkStatus('');
|
setTelegramLinkStatus('');
|
||||||
state.telegramLinkRendered = false;
|
state.telegramLinkRendered = false;
|
||||||
|
const mergeNotice = data.account_merge && data.account_merge.merged ? data.account_merge : null;
|
||||||
|
state.accountMergeNotice = mergeNotice;
|
||||||
try {
|
try {
|
||||||
showToast(t('telegram_linked'));
|
showToast(mergeNotice ? t('account_merge_toast') : t('telegram_linked'));
|
||||||
} catch (toastError) {
|
} catch (toastError) {
|
||||||
console.warn('Telegram link success toast failed', toastError);
|
console.warn('Telegram link success toast failed', toastError);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await loadData();
|
await loadData();
|
||||||
|
if (mergeNotice) {
|
||||||
|
openAccountMergeModal(mergeNotice);
|
||||||
|
}
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
console.warn('Telegram account linked, but data refresh failed', refreshError);
|
console.warn('Telegram account linked, but data refresh failed', refreshError);
|
||||||
|
if (mergeNotice) {
|
||||||
|
openAccountMergeModal(mergeNotice);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1636,6 +1745,7 @@ const MOCK = (() => {
|
|||||||
|| state.emailLoginCodeModalOpen
|
|| state.emailLoginCodeModalOpen
|
||||||
|| state.promoModalOpen
|
|| state.promoModalOpen
|
||||||
|| state.referralModalOpen
|
|| state.referralModalOpen
|
||||||
|
|| state.accountMergeModalOpen
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,20 @@ class EmailAuthService:
|
|||||||
language_code=language_code,
|
language_code=language_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def send_custom_email(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
) -> None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self._send_custom_email_sync,
|
||||||
|
email=email,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
def _send_code_email_sync(
|
def _send_code_email_sync(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -376,6 +390,66 @@ class EmailAuthService:
|
|||||||
if last_error:
|
if last_error:
|
||||||
raise last_error
|
raise last_error
|
||||||
|
|
||||||
|
def _send_custom_email_sync(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
) -> None:
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["From"] = formataddr(
|
||||||
|
(
|
||||||
|
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||||
|
self.settings.SMTP_FROM_EMAIL or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
message["To"] = email
|
||||||
|
message.set_content(body)
|
||||||
|
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
smtp_host = self.settings.SMTP_HOST
|
||||||
|
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
|
||||||
|
attempts = self._smtp_attempts()
|
||||||
|
last_error: Optional[BaseException] = None
|
||||||
|
|
||||||
|
for attempt_number, attempt in enumerate(attempts, start=1):
|
||||||
|
try:
|
||||||
|
self._send_message_via_smtp(
|
||||||
|
message=message,
|
||||||
|
smtp_host=smtp_host,
|
||||||
|
smtp_port=attempt.port,
|
||||||
|
timeout=timeout,
|
||||||
|
context=context,
|
||||||
|
use_ssl=attempt.use_ssl,
|
||||||
|
starttls=attempt.starttls,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Custom email sent to %s via %s:%s",
|
||||||
|
email,
|
||||||
|
smtp_host,
|
||||||
|
attempt.port,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
|
||||||
|
last_error = exc
|
||||||
|
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
|
||||||
|
logger.log(
|
||||||
|
log_level,
|
||||||
|
"SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s",
|
||||||
|
attempt_number,
|
||||||
|
len(attempts),
|
||||||
|
smtp_host,
|
||||||
|
attempt.port,
|
||||||
|
attempt.use_ssl,
|
||||||
|
attempt.starttls,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
|
||||||
def _send_message_via_smtp(
|
def _send_message_via_smtp(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
+105
-18
@@ -7,7 +7,7 @@ from sqlalchemy.future import select
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy import update, delete, func, and_, or_, desc
|
from sqlalchemy import update, delete, func, and_, or_, desc
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
@@ -205,6 +205,39 @@ async def _has_active_panel_subscription(
|
|||||||
return result.scalar_one_or_none() is not None
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_latest_subscription_for_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
panel_user_uuid: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
active_only: bool = False,
|
||||||
|
) -> Optional[Subscription]:
|
||||||
|
stmt = select(Subscription).where(Subscription.user_id == user_id)
|
||||||
|
if panel_user_uuid is not None:
|
||||||
|
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
|
||||||
|
if active_only:
|
||||||
|
stmt = stmt.where(
|
||||||
|
Subscription.is_active == True,
|
||||||
|
Subscription.end_date > datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(Subscription.end_date.desc(), Subscription.subscription_id.desc()).limit(1)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_active_subscription_for_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
panel_user_uuid: Optional[str] = None,
|
||||||
|
) -> Optional[Subscription]:
|
||||||
|
return await _get_latest_subscription_for_user(
|
||||||
|
session,
|
||||||
|
user_id,
|
||||||
|
panel_user_uuid,
|
||||||
|
active_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def merge_users(
|
async def merge_users(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -235,22 +268,60 @@ async def merge_users(
|
|||||||
|
|
||||||
source_panel_uuid = source.panel_user_uuid
|
source_panel_uuid = source.panel_user_uuid
|
||||||
target_panel_uuid = target.panel_user_uuid
|
target_panel_uuid = target.panel_user_uuid
|
||||||
panel_uuid_to_move = None
|
panel_uuid_to_keep = target_panel_uuid or source_panel_uuid
|
||||||
if source_panel_uuid and not target_panel_uuid:
|
|
||||||
panel_uuid_to_move = source_panel_uuid
|
now = datetime.now(timezone.utc)
|
||||||
elif source_panel_uuid and target_panel_uuid and source_panel_uuid != target_panel_uuid:
|
source_active_sub = await _get_active_subscription_for_user(
|
||||||
source_has_active = await _has_active_panel_subscription(
|
|
||||||
session, source_user_id, source_panel_uuid
|
session, source_user_id, source_panel_uuid
|
||||||
)
|
)
|
||||||
target_has_active = await _has_active_panel_subscription(
|
target_active_sub = await _get_active_subscription_for_user(
|
||||||
session, target_user_id, target_panel_uuid
|
session, target_user_id, target_panel_uuid
|
||||||
)
|
)
|
||||||
if source_has_active and target_has_active:
|
target_anchor_sub = target_active_sub
|
||||||
raise UserMergeConflictError(
|
if not target_anchor_sub and target_panel_uuid:
|
||||||
"Both accounts have active subscriptions on different panel users."
|
target_anchor_sub = await _get_latest_subscription_for_user(
|
||||||
|
session, target_user_id, target_panel_uuid
|
||||||
)
|
)
|
||||||
if source_has_active and not target_has_active:
|
if not target_anchor_sub and not target_panel_uuid:
|
||||||
panel_uuid_to_move = source_panel_uuid
|
target_anchor_sub = await _get_latest_subscription_for_user(session, target_user_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
source_active_sub
|
||||||
|
and target_anchor_sub
|
||||||
|
and source_panel_uuid
|
||||||
|
and target_panel_uuid
|
||||||
|
and source_panel_uuid != target_panel_uuid
|
||||||
|
):
|
||||||
|
source_end = source_active_sub.end_date
|
||||||
|
if source_end.tzinfo is None:
|
||||||
|
source_end = source_end.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
target_end = target_anchor_sub.end_date
|
||||||
|
if target_end.tzinfo is None:
|
||||||
|
target_end = target_end.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
source_remaining = max(timedelta(0), source_end - now)
|
||||||
|
if source_remaining > timedelta(0):
|
||||||
|
base_end = target_end if target_end > now else now
|
||||||
|
target_anchor_sub.end_date = base_end + source_remaining
|
||||||
|
target_anchor_sub.last_notification_sent = None
|
||||||
|
target_anchor_sub.is_active = True
|
||||||
|
target_anchor_sub.status_from_panel = "ACTIVE_EXTENDED_BY_MERGE"
|
||||||
|
|
||||||
|
source_active_sub.is_active = False
|
||||||
|
source_active_sub.skip_notifications = True
|
||||||
|
source_active_sub.last_notification_sent = None
|
||||||
|
source_active_sub.status_from_panel = "MERGED_INTO_ACCOUNT"
|
||||||
|
elif (
|
||||||
|
source_active_sub
|
||||||
|
and target_panel_uuid
|
||||||
|
and source_panel_uuid
|
||||||
|
and source_panel_uuid != target_panel_uuid
|
||||||
|
and not target_anchor_sub
|
||||||
|
):
|
||||||
|
source_active_sub.panel_user_uuid = target_panel_uuid
|
||||||
|
source_active_sub.last_notification_sent = None
|
||||||
|
source_active_sub.status_from_panel = "ACTIVE_EXTENDED_BY_MERGE"
|
||||||
|
|
||||||
email_to_move = source.email if source.email and not target.email else None
|
email_to_move = source.email if source.email and not target.email else None
|
||||||
email_verified_at_to_move = (
|
email_verified_at_to_move = (
|
||||||
@@ -267,13 +338,11 @@ async def merge_users(
|
|||||||
|
|
||||||
if email_to_move:
|
if email_to_move:
|
||||||
source.email = None
|
source.email = None
|
||||||
if panel_uuid_to_move:
|
|
||||||
source.panel_user_uuid = None
|
|
||||||
if telegram_id_to_move:
|
if telegram_id_to_move:
|
||||||
source.telegram_id = None
|
source.telegram_id = None
|
||||||
if referral_code_to_move:
|
if referral_code_to_move:
|
||||||
source.referral_code = None
|
source.referral_code = None
|
||||||
if email_to_move or panel_uuid_to_move or telegram_id_to_move or referral_code_to_move:
|
if email_to_move or source_panel_uuid or telegram_id_to_move or referral_code_to_move:
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
if email_to_move:
|
if email_to_move:
|
||||||
@@ -282,14 +351,24 @@ async def merge_users(
|
|||||||
target.email_verified_at = email_verified_at_to_move
|
target.email_verified_at = email_verified_at_to_move
|
||||||
if telegram_id_to_move:
|
if telegram_id_to_move:
|
||||||
target.telegram_id = telegram_id_to_move
|
target.telegram_id = telegram_id_to_move
|
||||||
if panel_uuid_to_move:
|
if panel_uuid_to_keep and not target.panel_user_uuid:
|
||||||
target.panel_user_uuid = panel_uuid_to_move
|
target.panel_user_uuid = panel_uuid_to_keep
|
||||||
if referral_code_to_move:
|
if referral_code_to_move:
|
||||||
target.referral_code = referral_code_to_move
|
target.referral_code = referral_code_to_move
|
||||||
|
|
||||||
for attr in ("username", "first_name", "last_name", "language_code", "telegram_photo_url"):
|
for attr in ("username", "first_name", "last_name", "language_code", "telegram_photo_url"):
|
||||||
if not getattr(target, attr) and getattr(source, attr):
|
if not getattr(target, attr) and getattr(source, attr):
|
||||||
setattr(target, attr, getattr(source, attr))
|
setattr(target, attr, getattr(source, attr))
|
||||||
|
if not target.channel_subscription_verified and source.channel_subscription_verified is not None:
|
||||||
|
target.channel_subscription_verified = source.channel_subscription_verified
|
||||||
|
if not target.channel_subscription_checked_at and source.channel_subscription_checked_at:
|
||||||
|
target.channel_subscription_checked_at = source.channel_subscription_checked_at
|
||||||
|
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
|
||||||
|
target.channel_subscription_verified_for = source.channel_subscription_verified_for
|
||||||
|
if source.lifetime_used_traffic_bytes is not None:
|
||||||
|
target.lifetime_used_traffic_bytes = (
|
||||||
|
(target.lifetime_used_traffic_bytes or 0) + source.lifetime_used_traffic_bytes
|
||||||
|
)
|
||||||
if not target.referred_by_id and source.referred_by_id != target_user_id:
|
if not target.referred_by_id and source.referred_by_id != target_user_id:
|
||||||
target.referred_by_id = source.referred_by_id
|
target.referred_by_id = source.referred_by_id
|
||||||
if target.referred_by_id == source_user_id:
|
if target.referred_by_id == source_user_id:
|
||||||
@@ -347,7 +426,15 @@ async def merge_users(
|
|||||||
.values(user_id=target_user_id)
|
.values(user_id=target_user_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
for model in (Subscription, Payment, PromoCodeActivation, UserPaymentMethod):
|
subscription_update_values: Dict[str, Any] = {"user_id": target_user_id}
|
||||||
|
if panel_uuid_to_keep:
|
||||||
|
subscription_update_values["panel_user_uuid"] = panel_uuid_to_keep
|
||||||
|
await session.execute(
|
||||||
|
update(Subscription)
|
||||||
|
.where(Subscription.user_id == source_user_id)
|
||||||
|
.values(**subscription_update_values)
|
||||||
|
)
|
||||||
|
for model in (Payment, PromoCodeActivation, UserPaymentMethod):
|
||||||
await session.execute(
|
await session.execute(
|
||||||
update(model)
|
update(model)
|
||||||
.where(model.user_id == source_user_id)
|
.where(model.user_id == source_user_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user