Add files via upload

This commit is contained in:
Egor
2026-01-23 11:26:42 +03:00
committed by GitHub
parent 334130e587
commit 37797ba3f6
13 changed files with 1401 additions and 62 deletions
+6 -3
View File
@@ -9,13 +9,13 @@ from app.config import settings
JWT_ALGORITHM = "HS256"
def create_access_token(user_id: int, telegram_id: int) -> str:
def create_access_token(user_id: int, telegram_id: Optional[int] = None) -> str:
"""
Create a short-lived access token.
Args:
user_id: Database user ID
telegram_id: Telegram user ID
telegram_id: Telegram user ID (optional for email-only users)
Returns:
Encoded JWT access token
@@ -25,12 +25,15 @@ def create_access_token(user_id: int, telegram_id: int) -> str:
payload = {
"sub": str(user_id),
"telegram_id": telegram_id,
"type": "access",
"exp": expires,
"iat": datetime.utcnow(),
}
# Добавляем telegram_id только если он есть
if telegram_id is not None:
payload["telegram_id"] = telegram_id
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
+42 -27
View File
@@ -103,7 +103,12 @@ async def get_current_cabinet_user(
# Check maintenance mode (allow admins to pass)
if maintenance_service.is_maintenance_active():
if not settings.is_admin(user.telegram_id):
# Проверяем админа по telegram_id ИЛИ email
is_admin = settings.is_admin(
telegram_id=user.telegram_id,
email=user.email if user.email_verified else None
)
if not is_admin:
status_info = maintenance_service.get_status_info()
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -114,32 +119,38 @@ async def get_current_cabinet_user(
},
)
# Check required channel subscription
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Skip check for admins
if not settings.is_admin(user.telegram_id):
try:
bot = _get_channel_check_bot()
chat_member = await bot.get_chat_member(
chat_id=settings.CHANNEL_SUB_ID,
user_id=user.telegram_id
)
# Не закрываем сессию - бот переиспользуется
if chat_member.status not in ["member", "administrator", "creator"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "channel_subscription_required",
"message": "Please subscribe to our channel to continue",
"channel_link": settings.CHANNEL_LINK,
},
# Пропускаем проверку для email-only юзеров (нет telegram_id)
if user.telegram_id is not None:
# Проверяем админа по telegram_id ИЛИ email
is_admin = settings.is_admin(
telegram_id=user.telegram_id,
email=user.email if user.email_verified else None
)
if not is_admin:
try:
bot = _get_channel_check_bot()
chat_member = await bot.get_chat_member(
chat_id=settings.CHANNEL_SUB_ID,
user_id=user.telegram_id
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Failed to check channel subscription for user {user.telegram_id}: {e}")
# Don't block user if check fails
# Не закрываем сессию - бот переиспользуется
if chat_member.status not in ["member", "administrator", "creator"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "channel_subscription_required",
"message": "Please subscribe to our channel to continue",
"channel_link": settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Failed to check channel subscription for user {user.telegram_id}: {e}")
# Don't block user if check fails
return user
@@ -181,7 +192,7 @@ async def get_current_admin_user(
"""
Get current authenticated admin user.
Checks if the user's telegram_id is in ADMIN_IDS from settings.
Checks if the user is admin by telegram_id or email.
Args:
user: Authenticated User object
@@ -192,7 +203,11 @@ async def get_current_admin_user(
Raises:
HTTPException: If user is not an admin
"""
if not settings.is_admin(user.telegram_id):
is_admin = settings.is_admin(
telegram_id=user.telegram_id,
email=user.email if user.email_verified else None
)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
+23 -3
View File
@@ -29,7 +29,7 @@ from app.database.crud.promo_offer_template import (
list_promo_offer_templates,
update_promo_offer_template,
)
from app.database.crud.user import get_user_by_telegram_id
from app.database.crud.user import get_user_by_telegram_id, get_user_by_email
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, User
from app.handlers.admin.messages import get_custom_users, get_target_users
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
@@ -45,7 +45,8 @@ router = APIRouter(prefix="/admin/promo-offers", tags=["Admin Promo Offers"])
class PromoOfferUserInfo(BaseModel):
id: int
telegram_id: int
telegram_id: Optional[int] = None # Can be None for email-only users
email: Optional[str] = None
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
@@ -121,6 +122,7 @@ class PromoOfferBroadcastRequest(BaseModel):
target: Optional[str] = None
user_id: Optional[int] = None
telegram_id: Optional[int] = None
email: Optional[str] = Field(None, description="User email (for email-only users)")
# Telegram notification options
send_notification: bool = Field(False, description="Send Telegram notification to users")
message_text: Optional[str] = Field(None, description="Custom message text (HTML)")
@@ -175,6 +177,7 @@ def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]:
return PromoOfferUserInfo(
id=user.id,
telegram_id=user.telegram_id,
email=user.email,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
@@ -422,6 +425,11 @@ async def _send_promo_notifications(
semaphore = asyncio.Semaphore(20)
async def send_single(user: User, offer: DiscountOffer) -> bool:
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug(f"Skipping promo notification for email-only user {user.id}")
return False
async with semaphore:
try:
keyboard = InlineKeyboardMarkup(
@@ -506,7 +514,7 @@ async def broadcast_offer(
if payload.telegram_id is not None:
user = await get_user_by_telegram_id(db, payload.telegram_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found by telegram_id")
if target_user_id and target_user_id != user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
@@ -514,6 +522,18 @@ async def broadcast_offer(
)
target_user_id = user.id
# Support email lookup for email-only users
if payload.email is not None and user is None:
user = await get_user_by_email(db, payload.email)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found by email")
if target_user_id and target_user_id != user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Provided user_id does not match email",
)
target_user_id = user.id
if target_user_id is not None:
if user is None:
user = await db.get(User, target_user_id)
+16 -4
View File
@@ -133,7 +133,8 @@ class DashboardStats(BaseModel):
class TopReferrerItem(BaseModel):
"""Single referrer in top list."""
user_id: int
telegram_id: int
telegram_id: Optional[int] = None # Can be None for email-only users
email: Optional[str] = None
username: Optional[str] = None
display_name: str
invited_count: int
@@ -182,7 +183,8 @@ class RecentPaymentItem(BaseModel):
"""Single recent payment."""
id: int
user_id: int
telegram_id: int
telegram_id: Optional[int] = None # Can be None for email-only users
email: Optional[str] = None
username: Optional[str] = None
display_name: str
amount_kopeks: int
@@ -750,12 +752,17 @@ async def get_top_referrers(
display_name += f" {user.last_name}"
elif user.username:
display_name = f"@{user.username}"
else:
elif user.telegram_id:
display_name = f"ID{user.telegram_id}"
elif user.email:
display_name = user.email.split('@')[0]
else:
display_name = f"User#{user.id}"
referrer_items.append(TopReferrerItem(
user_id=user.id,
telegram_id=user.telegram_id,
email=user.email,
username=user.username,
display_name=display_name,
invited_count=data.get('total_invited', 0),
@@ -908,13 +915,18 @@ async def get_recent_payments(
display_name += f" {user.last_name}"
elif user.username:
display_name = f"@{user.username}"
else:
elif user.telegram_id:
display_name = f"ID{user.telegram_id}"
elif user.email:
display_name = user.email.split('@')[0]
else:
display_name = f"User#{user.id}"
payment_items.append(RecentPaymentItem(
id=trans.id,
user_id=user.id,
telegram_id=user.telegram_id,
email=user.email,
username=user.username,
display_name=display_name,
amount_kopeks=trans.amount_kopeks,
+3 -1
View File
@@ -29,7 +29,8 @@ router = APIRouter(prefix="/admin/tickets", tags=["Cabinet Admin Tickets"])
class AdminTicketUserInfo(BaseModel):
"""User info for admin view."""
id: int
telegram_id: int
telegram_id: Optional[int] = None # Can be None for email-only users
email: Optional[str] = None
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
@@ -148,6 +149,7 @@ def _user_to_info(user: User) -> AdminTicketUserInfo:
return AdminTicketUserInfo(
id=user.id,
telegram_id=user.telegram_id,
email=user.email,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
+11 -3
View File
@@ -207,7 +207,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
panel_uuid = user.remnawave_uuid
# Try to find existing user
if not panel_uuid:
if not panel_uuid and user.telegram_id:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
panel_uuid = existing_users[0].uuid
@@ -1341,7 +1341,7 @@ async def get_user_sync_status(
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
if service.is_configured:
if service.is_configured and user.telegram_id:
async with service.get_api_client() as api:
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
if panel_users:
@@ -1464,6 +1464,14 @@ async def sync_user_from_panel(
errors = []
panel_info = None
# Email-only users cannot be synced from panel by telegram_id
if not user.telegram_id:
return SyncFromPanelResponse(
success=False,
message="Cannot sync email-only user",
errors=["Email-only users don't have telegram_id for panel lookup"],
)
async with service.get_api_client() as api:
# Find user in panel
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
@@ -1697,7 +1705,7 @@ async def sync_user_to_panel(
async with service.get_api_client() as api:
# Try to find existing user in panel
if not panel_uuid:
if not panel_uuid and user.telegram_id:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
panel_uuid = existing_users[0].uuid
+119 -8
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database.models import User, CabinetRefreshToken
from app.database.crud.user import get_user_by_telegram_id, get_user_by_id, create_user
from app.database.crud.user import get_user_by_telegram_id, get_user_by_id, create_user, create_user_by_email
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_cabinet_user
@@ -19,6 +19,7 @@ from ..schemas.auth import (
TelegramAuthRequest,
TelegramWidgetAuthRequest,
EmailRegisterRequest,
EmailRegisterStandaloneRequest,
EmailVerifyRequest,
EmailLoginRequest,
RefreshTokenRequest,
@@ -67,6 +68,7 @@ def _user_to_response(user: User) -> UserResponse:
referral_code=user.referral_code,
language=user.language,
created_at=user.created_at,
auth_type=getattr(user, 'auth_type', 'telegram'), # Поддержка старых записей
)
@@ -315,6 +317,90 @@ async def register_email(
}
@router.post("/email/register/standalone", response_model=AuthResponse)
async def register_email_standalone(
request: EmailRegisterStandaloneRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Register new account with email and password.
This endpoint creates a new user WITHOUT requiring Telegram authentication.
An email verification link will be sent to confirm the email address.
The user can login immediately but some features may be restricted
until email is verified.
If TEST_EMAIL is configured, test email accounts are auto-verified.
"""
# Check if this is a test email registration
is_test_email = settings.is_test_email(request.email)
if is_test_email:
# Validate test email password
if not settings.validate_test_email_password(request.email, request.password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid test email password",
)
logger.info(f"Test email registration: {request.email}")
# Проверить что email не занят
existing = await db.execute(
select(User).where(User.email == request.email)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This email is already registered",
)
# Хешировать пароль
password_hash = hash_password(request.password)
# Создать пользователя
user = await create_user_by_email(
db=db,
email=request.email,
password_hash=password_hash,
first_name=request.first_name,
language=request.language,
)
# Для тестового email - автоматически верифицировать
if is_test_email:
user.email_verified = True
user.email_verified_at = datetime.utcnow()
await db.commit()
logger.info(f"Test email auto-verified: {request.email}, user_id={user.id}")
else:
# Сгенерировать токен верификации
verification_token = generate_verification_token()
verification_expires = get_verification_expires_at()
user.email_verification_token = verification_token
user.email_verification_expires = verification_expires
await db.commit()
# Отправить email верификации
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
cabinet_url = getattr(settings, 'CABINET_URL', 'https://example.com/cabinet')
verification_url = f"{cabinet_url}/verify-email?token={verification_token}"
await asyncio.to_thread(
email_service.send_verification_email,
to_email=request.email,
verification_token=verification_token,
verification_url=verification_url,
username=user.first_name or "User",
)
# Создать токены и вернуть ответ
response = _create_auth_response(user)
await _store_refresh_token(db, user.id, response.refresh_token)
return response
@router.post("/email/verify")
async def verify_email(
request: EmailVerifyRequest,
@@ -406,7 +492,13 @@ async def login_email(
request: EmailLoginRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Login with email and password."""
"""Login with email and password.
Test email accounts (configured via TEST_EMAIL) bypass email verification.
"""
# Check if this is a test email login
is_test_email = settings.is_test_email(request.email)
# Find user by email
result = await db.execute(
select(User).where(User.email == request.email)
@@ -414,10 +506,25 @@ async def login_email(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# For test email - auto-create user if not exists
if is_test_email and settings.validate_test_email_password(request.email, request.password):
logger.info(f"Test email login - creating new user: {request.email}")
password_hash = hash_password(request.password)
user = await create_user_by_email(
db=db,
email=request.email,
password_hash=password_hash,
first_name="Test User",
language="ru",
)
user.email_verified = True
user.email_verified_at = datetime.utcnow()
await db.commit()
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.password_hash:
raise HTTPException(
@@ -431,7 +538,8 @@ async def login_email(
detail="Invalid email or password",
)
if not user.email_verified:
# Test email bypasses verification check
if not user.email_verified and not is_test_email:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Please verify your email first",
@@ -621,5 +729,8 @@ async def check_is_admin(
user: User = Depends(get_current_cabinet_user),
):
"""Check if current user is an admin."""
is_admin = settings.is_admin(user.telegram_id)
is_admin = settings.is_admin(
telegram_id=user.telegram_id,
email=user.email if user.email_verified else None
)
return {"is_admin": is_admin}
+17 -7
View File
@@ -1561,7 +1561,7 @@ async def purchase_tariff(
"description": f"Продление тарифа {tariff.name} на {period_days} дней",
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f"Tariff cart saved for auto-renewal (cabinet) user {user.telegram_id}")
logger.info(f"Tariff cart saved for auto-renewal (cabinet) user {user.id}")
except Exception as e:
logger.error(f"Error saving tariff cart (cabinet): {e}")
@@ -1717,7 +1717,7 @@ async def purchase_devices(
await db.refresh(user)
logger.info(
f"User {user.telegram_id} purchased {request.devices} devices for {price_kopeks} kopeks"
f"User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks"
)
return {
@@ -3318,9 +3318,12 @@ async def refresh_traffic(
detail="No active subscription",
)
# Используем user.id для rate limit и кеша (работает и для email-пользователей)
user_cache_id = user.id
# Check rate limit
is_limited = await RateLimitCache.is_rate_limited(
user.telegram_id,
user_cache_id,
"traffic_refresh",
TRAFFIC_REFRESH_RATE_LIMIT,
TRAFFIC_REFRESH_RATE_WINDOW,
@@ -3328,7 +3331,7 @@ async def refresh_traffic(
if is_limited:
# Check if we have cached data
traffic_cache_key = cache_key("traffic", user.telegram_id)
traffic_cache_key = cache_key("traffic", user_cache_id)
cached_data = await cache.get(traffic_cache_key)
if cached_data:
@@ -3349,7 +3352,14 @@ async def refresh_traffic(
# Fetch traffic from RemnaWave
try:
remnawave_service = RemnaWaveService()
traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id)
# Для email-пользователей (без telegram_id) используем UUID
if user.telegram_id:
traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id)
elif user.remnawave_uuid:
traffic_stats = await remnawave_service.get_user_traffic_stats_by_uuid(user.remnawave_uuid)
else:
traffic_stats = None
if not traffic_stats:
# Return current database values if RemnaWave unavailable
@@ -3399,7 +3409,7 @@ async def refresh_traffic(
}
# Cache the result
traffic_cache_key = cache_key("traffic", user.telegram_id)
traffic_cache_key = cache_key("traffic", user_cache_id)
await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL)
return {
@@ -3410,7 +3420,7 @@ async def refresh_traffic(
}
except Exception as e:
logger.error(f"Error refreshing traffic for user {user.telegram_id}: {e}")
logger.error(f"Error refreshing traffic for user {user.id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to refresh traffic data",
+239 -1
View File
@@ -147,7 +147,10 @@ async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
if not user or user.status != "active":
return None, False
is_admin = settings.is_admin(user.telegram_id)
is_admin = settings.is_admin(
telegram_id=user.telegram_id,
email=user.email if user.email_verified else None
)
return user_id, is_admin
@@ -249,3 +252,238 @@ async def notify_admins_ticket_reply(ticket_id: int, message: str, user_id: int)
"message": message,
"user_id": user_id,
})
# ============================================================================
# Уведомления о балансе
# ============================================================================
async def notify_user_balance_topup(
user_id: int,
amount_kopeks: int,
new_balance_kopeks: int,
description: str = "",
) -> None:
"""Уведомить пользователя о пополнении баланса."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "balance.topup",
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_kopeks / 100,
"new_balance_kopeks": new_balance_kopeks,
"new_balance_rubles": new_balance_kopeks / 100,
"description": description,
})
async def notify_user_balance_change(
user_id: int,
amount_kopeks: int,
new_balance_kopeks: int,
description: str = "",
) -> None:
"""Уведомить пользователя об изменении баланса."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "balance.change",
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_kopeks / 100,
"new_balance_kopeks": new_balance_kopeks,
"new_balance_rubles": new_balance_kopeks / 100,
"description": description,
})
# ============================================================================
# Уведомления о подписке
# ============================================================================
async def notify_user_subscription_activated(
user_id: int,
expires_at: str,
tariff_name: str = "",
) -> None:
"""Уведомить пользователя об активации подписки."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "subscription.activated",
"expires_at": expires_at,
"tariff_name": tariff_name,
})
async def notify_user_subscription_expiring(
user_id: int,
days_left: int,
expires_at: str,
) -> None:
"""Уведомить пользователя о скором истечении подписки."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "subscription.expiring",
"days_left": days_left,
"expires_at": expires_at,
})
async def notify_user_subscription_expired(user_id: int) -> None:
"""Уведомить пользователя об истечении подписки."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "subscription.expired",
})
async def notify_user_subscription_renewed(
user_id: int,
new_expires_at: str,
amount_kopeks: int = 0,
) -> None:
"""Уведомить пользователя о продлении подписки."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "subscription.renewed",
"new_expires_at": new_expires_at,
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_kopeks / 100,
})
# ============================================================================
# Уведомления об автопродлении
# ============================================================================
async def notify_user_autopay_success(
user_id: int,
amount_kopeks: int,
new_expires_at: str,
) -> None:
"""Уведомить пользователя об успешном автопродлении."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "autopay.success",
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_kopeks / 100,
"new_expires_at": new_expires_at,
})
async def notify_user_autopay_failed(
user_id: int,
reason: str = "",
) -> None:
"""Уведомить пользователя о неудачном автопродлении."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "autopay.failed",
"reason": reason,
})
async def notify_user_autopay_insufficient_funds(
user_id: int,
required_kopeks: int,
balance_kopeks: int,
) -> None:
"""Уведомить о недостатке средств для автопродления."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "autopay.insufficient_funds",
"required_kopeks": required_kopeks,
"required_rubles": required_kopeks / 100,
"balance_kopeks": balance_kopeks,
"balance_rubles": balance_kopeks / 100,
})
# ============================================================================
# Уведомления о бане/разбане
# ============================================================================
async def notify_user_ban(user_id: int, reason: str = "") -> None:
"""Уведомить пользователя о блокировке."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "account.banned",
"reason": reason,
})
async def notify_user_unban(user_id: int) -> None:
"""Уведомить пользователя о разблокировке."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "account.unbanned",
})
async def notify_user_warning(user_id: int, message: str) -> None:
"""Уведомить пользователя о предупреждении."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "account.warning",
"message": message,
})
# ============================================================================
# Уведомления о рефералах
# ============================================================================
async def notify_user_referral_bonus(
user_id: int,
bonus_kopeks: int,
referral_name: str = "",
) -> None:
"""Уведомить пользователя о реферальном бонусе."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "referral.bonus",
"bonus_kopeks": bonus_kopeks,
"bonus_rubles": bonus_kopeks / 100,
"referral_name": referral_name,
})
async def notify_user_referral_registered(
user_id: int,
referral_name: str = "",
) -> None:
"""Уведомить пользователя о регистрации нового реферала."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "referral.registered",
"referral_name": referral_name,
})
# ============================================================================
# Прочие уведомления
# ============================================================================
async def notify_user_daily_debit(
user_id: int,
amount_kopeks: int,
new_balance_kopeks: int,
) -> None:
"""Уведомить о ежедневном списании."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "subscription.daily_debit",
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_kopeks / 100,
"new_balance_kopeks": new_balance_kopeks,
"new_balance_rubles": new_balance_kopeks / 100,
})
async def notify_user_traffic_reset(user_id: int) -> None:
"""Уведомить о сбросе трафика."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "subscription.traffic_reset",
})
async def notify_user_payment_received(
user_id: int,
amount_kopeks: int,
payment_method: str = "",
) -> None:
"""Уведомить о полученном платеже."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "payment.received",
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_kopeks / 100,
"payment_method": payment_method,
})
+10 -1
View File
@@ -65,7 +65,7 @@ class TokenResponse(BaseModel):
class UserResponse(BaseModel):
"""User data response."""
id: int
telegram_id: int
telegram_id: Optional[int] = None # Nullable для email-only пользователей
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
@@ -76,11 +76,20 @@ class UserResponse(BaseModel):
referral_code: Optional[str] = None
language: str = "ru"
created_at: datetime
auth_type: str = "telegram" # "telegram" или "email"
class Config:
from_attributes = True
class EmailRegisterStandaloneRequest(BaseModel):
"""Request to register new account with email (no Telegram required)."""
email: EmailStr = Field(..., description="Email address")
password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars)")
first_name: Optional[str] = Field(None, max_length=64, description="First name")
language: str = Field("ru", description="Preferred language")
class AuthResponse(BaseModel):
"""Full authentication response with tokens and user."""
access_token: str
+1 -1
View File
@@ -144,7 +144,7 @@ class CampaignRegistrationItem(BaseModel):
"""Campaign registration item."""
id: int
user_id: int
telegram_id: int
telegram_id: Optional[int] = None
username: Optional[str] = None
first_name: Optional[str] = None
bonus_type: str
+3 -3
View File
@@ -63,7 +63,7 @@ class UserPromoGroupInfo(BaseModel):
class UserListItem(BaseModel):
"""User item in list."""
id: int
telegram_id: int
telegram_id: Optional[int] = None
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
@@ -129,7 +129,7 @@ class UserReferralInfo(BaseModel):
class UserDetailResponse(BaseModel):
"""Detailed user information."""
id: int
telegram_id: int
telegram_id: Optional[int] = None
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
@@ -429,7 +429,7 @@ class SyncToPanelResponse(BaseModel):
class PanelSyncStatusResponse(BaseModel):
"""Panel sync status for user."""
user_id: int
telegram_id: int
telegram_id: Optional[int] = None
remnawave_uuid: Optional[str] = None
last_sync: Optional[datetime] = None
+911
View File
@@ -0,0 +1,911 @@
"""
Email notification templates for different notification types.
Supports multiple languages: ru, en, zh, ua
"""
from typing import Any, Dict, Optional
from app.config import settings
class EmailNotificationTemplates:
"""HTML email templates for user notifications."""
def __init__(self):
self.service_name = settings.SMTP_FROM_NAME or "VPN Service"
self.cabinet_url = getattr(settings, "CABINET_URL", "")
def get_template(
self,
notification_type: "NotificationType",
language: str,
context: Dict[str, Any],
) -> Optional[Dict[str, str]]:
"""
Get email template for notification type.
Args:
notification_type: Type of notification
language: Language code (ru, en, zh, ua)
context: Context data for template rendering
Returns:
Dict with 'subject', 'body_html', and optionally 'body_text'
"""
# Import here to avoid circular imports
from app.services.notification_delivery_service import NotificationType
template_map = {
NotificationType.BALANCE_TOPUP: self._balance_topup_template,
NotificationType.BALANCE_CHANGE: self._balance_change_template,
NotificationType.SUBSCRIPTION_EXPIRING: self._subscription_expiring_template,
NotificationType.SUBSCRIPTION_EXPIRED: self._subscription_expired_template,
NotificationType.SUBSCRIPTION_RENEWED: self._subscription_renewed_template,
NotificationType.SUBSCRIPTION_ACTIVATED: self._subscription_activated_template,
NotificationType.AUTOPAY_SUCCESS: self._autopay_success_template,
NotificationType.AUTOPAY_FAILED: self._autopay_failed_template,
NotificationType.AUTOPAY_INSUFFICIENT_FUNDS: self._autopay_insufficient_funds_template,
NotificationType.DAILY_DEBIT: self._daily_debit_template,
NotificationType.DAILY_INSUFFICIENT_FUNDS: self._daily_insufficient_funds_template,
NotificationType.BAN_NOTIFICATION: self._ban_template,
NotificationType.UNBAN_NOTIFICATION: self._unban_template,
NotificationType.WARNING_NOTIFICATION: self._warning_template,
NotificationType.REFERRAL_BONUS: self._referral_bonus_template,
NotificationType.REFERRAL_REGISTERED: self._referral_registered_template,
NotificationType.TRAFFIC_RESET: self._traffic_reset_template,
NotificationType.PAYMENT_RECEIVED: self._payment_received_template,
}
template_func = template_map.get(notification_type)
if not template_func:
return None
return template_func(language, context)
def _get_base_template(self, content: str) -> str:
"""Wrap content in base HTML template."""
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
margin: 0;
padding: 0;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #ffffff;
}}
.header {{
text-align: center;
padding: 20px 0;
border-bottom: 2px solid #007bff;
}}
.header h1 {{
color: #007bff;
margin: 0;
font-size: 24px;
}}
.content {{
padding: 30px 20px;
}}
.highlight {{
background-color: #f8f9fa;
border-left: 4px solid #007bff;
padding: 15px;
margin: 20px 0;
}}
.success {{
border-left-color: #28a745;
}}
.warning {{
border-left-color: #ffc107;
}}
.danger {{
border-left-color: #dc3545;
}}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #007bff;
color: white !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
font-weight: bold;
}}
.button:hover {{
background-color: #0056b3;
}}
.footer {{
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 12px;
color: #666;
text-align: center;
}}
.amount {{
font-size: 24px;
font-weight: bold;
color: #28a745;
}}
.amount.negative {{
color: #dc3545;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{self.service_name}</h1>
</div>
<div class="content">
{content}
</div>
<div class="footer">
<p>&copy; {self.service_name}. All rights reserved.</p>
<p>This is an automated message. Please do not reply to this email.</p>
</div>
</div>
</body>
</html>
"""
def _get_cabinet_button(self, language: str) -> str:
"""Get cabinet link button HTML."""
if not self.cabinet_url:
return ""
texts = {
"ru": "Открыть личный кабинет",
"en": "Open Dashboard",
"zh": "打开控制面板",
"ua": "Відкрити особистий кабінет",
}
text = texts.get(language, texts["en"])
return f'<p style="text-align: center;"><a href="{self.cabinet_url}" class="button">{text}</a></p>'
# ============================================================================
# Balance Templates
# ============================================================================
def _balance_topup_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for balance top-up notification."""
amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f}")
balance = context.get("formatted_balance", f"{context.get('new_balance_rubles', 0):.2f}")
subjects = {
"ru": f"Баланс пополнен на {amount}",
"en": f"Balance topped up by {amount}",
"zh": f"余额已充值 {amount}",
"ua": f"Баланс поповнено на {amount}",
}
bodies = {
"ru": f"""
<h2>Баланс успешно пополнен!</h2>
<div class="highlight success">
<p>Сумма пополнения: <span class="amount">+{amount}</span></p>
<p>Текущий баланс: <strong>{balance}</strong></p>
</div>
<p>Спасибо за использование нашего сервиса!</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Balance Successfully Topped Up!</h2>
<div class="highlight success">
<p>Top-up amount: <span class="amount">+{amount}</span></p>
<p>Current balance: <strong>{balance}</strong></p>
</div>
<p>Thank you for using our service!</p>
{self._get_cabinet_button(language)}
""",
"zh": f"""
<h2>充值成功</h2>
<div class="highlight success">
<p>充值金额: <span class="amount">+{amount}</span></p>
<p>当前余额: <strong>{balance}</strong></p>
</div>
<p>感谢使用我们的服务</p>
{self._get_cabinet_button(language)}
""",
"ua": f"""
<h2>Баланс успішно поповнено!</h2>
<div class="highlight success">
<p>Сума поповнення: <span class="amount">+{amount}</span></p>
<p>Поточний баланс: <strong>{balance}</strong></p>
</div>
<p>Дякуємо за використання нашого сервісу!</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _balance_change_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for balance change notification."""
amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f}")
balance = context.get("formatted_balance", f"{context.get('new_balance_rubles', 0):.2f}")
description = context.get("description", "")
subjects = {
"ru": "Изменение баланса",
"en": "Balance Changed",
"zh": "余额变动",
"ua": "Зміна балансу",
}
bodies = {
"ru": f"""
<h2>Изменение баланса</h2>
<div class="highlight">
<p>Сумма: <strong>{amount}</strong></p>
<p>Текущий баланс: <strong>{balance}</strong></p>
{f'<p>Описание: {description}</p>' if description else ''}
</div>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Balance Changed</h2>
<div class="highlight">
<p>Amount: <strong>{amount}</strong></p>
<p>Current balance: <strong>{balance}</strong></p>
{f'<p>Description: {description}</p>' if description else ''}
</div>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
# ============================================================================
# Subscription Templates
# ============================================================================
def _subscription_expiring_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for subscription expiring notification."""
days_left = context.get("days_left", 0)
expires_at = context.get("expires_at", "")
subjects = {
"ru": f"Подписка истекает через {days_left} дн.",
"en": f"Subscription expires in {days_left} day(s)",
"zh": f"订阅将在 {days_left} 天后到期",
"ua": f"Підписка закінчується через {days_left} дн.",
}
bodies = {
"ru": f"""
<h2>Подписка скоро истекает</h2>
<div class="highlight warning">
<p>Ваша подписка истекает через <strong>{days_left}</strong> дн.</p>
<p>Дата истечения: <strong>{expires_at}</strong></p>
</div>
<p>Продлите подписку, чтобы не потерять доступ к сервису.</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Subscription Expiring Soon</h2>
<div class="highlight warning">
<p>Your subscription expires in <strong>{days_left}</strong> day(s).</p>
<p>Expiration date: <strong>{expires_at}</strong></p>
</div>
<p>Renew your subscription to maintain access to our service.</p>
{self._get_cabinet_button(language)}
""",
"zh": f"""
<h2>订阅即将到期</h2>
<div class="highlight warning">
<p>您的订阅将在 <strong>{days_left}</strong> 天后到期</p>
<p>到期日期: <strong>{expires_at}</strong></p>
</div>
<p>请续订以保持对服务的访问</p>
{self._get_cabinet_button(language)}
""",
"ua": f"""
<h2>Підписка скоро закінчується</h2>
<div class="highlight warning">
<p>Ваша підписка закінчується через <strong>{days_left}</strong> дн.</p>
<p>Дата закінчення: <strong>{expires_at}</strong></p>
</div>
<p>Продовжіть підписку, щоб не втратити доступ до сервісу.</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _subscription_expired_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for subscription expired notification."""
subjects = {
"ru": "Подписка истекла",
"en": "Subscription Expired",
"zh": "订阅已到期",
"ua": "Підписка закінчилась",
}
bodies = {
"ru": f"""
<h2>Подписка истекла</h2>
<div class="highlight danger">
<p>Ваша подписка истекла. Доступ к VPN отключён.</p>
</div>
<p>Оформите новую подписку, чтобы продолжить использование сервиса.</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Subscription Expired</h2>
<div class="highlight danger">
<p>Your subscription has expired. VPN access has been disabled.</p>
</div>
<p>Purchase a new subscription to continue using our service.</p>
{self._get_cabinet_button(language)}
""",
"zh": f"""
<h2>订阅已到期</h2>
<div class="highlight danger">
<p>您的订阅已到期VPN访问已被禁用</p>
</div>
<p>请购买新订阅以继续使用我们的服务</p>
{self._get_cabinet_button(language)}
""",
"ua": f"""
<h2>Підписка закінчилась</h2>
<div class="highlight danger">
<p>Ваша підписка закінчилась. Доступ до VPN вимкнено.</p>
</div>
<p>Оформіть нову підписку, щоб продовжити використання сервісу.</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _subscription_renewed_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for subscription renewed notification."""
new_expires_at = context.get("new_expires_at", "")
subjects = {
"ru": "Подписка продлена",
"en": "Subscription Renewed",
"zh": "订阅已续订",
"ua": "Підписку продовжено",
}
bodies = {
"ru": f"""
<h2>Подписка успешно продлена!</h2>
<div class="highlight success">
<p>Ваша подписка была успешно продлена.</p>
<p>Новая дата истечения: <strong>{new_expires_at}</strong></p>
</div>
<p>Спасибо за использование нашего сервиса!</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Subscription Successfully Renewed!</h2>
<div class="highlight success">
<p>Your subscription has been successfully renewed.</p>
<p>New expiration date: <strong>{new_expires_at}</strong></p>
</div>
<p>Thank you for using our service!</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _subscription_activated_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for subscription activated notification."""
expires_at = context.get("expires_at", "")
subjects = {
"ru": "Подписка активирована",
"en": "Subscription Activated",
"zh": "订阅已激活",
"ua": "Підписку активовано",
}
bodies = {
"ru": f"""
<h2>Подписка активирована!</h2>
<div class="highlight success">
<p>Ваша VPN подписка успешно активирована.</p>
<p>Действует до: <strong>{expires_at}</strong></p>
</div>
<p>Теперь вы можете пользоваться VPN сервисом.</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Subscription Activated!</h2>
<div class="highlight success">
<p>Your VPN subscription has been successfully activated.</p>
<p>Valid until: <strong>{expires_at}</strong></p>
</div>
<p>You can now use the VPN service.</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
# ============================================================================
# Autopay Templates
# ============================================================================
def _autopay_success_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for successful autopay notification."""
amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f}")
new_expires_at = context.get("new_expires_at", "")
subjects = {
"ru": "Автопродление выполнено",
"en": "Auto-renewal Successful",
"zh": "自动续订成功",
"ua": "Автопродовження виконано",
}
bodies = {
"ru": f"""
<h2>Автопродление выполнено</h2>
<div class="highlight success">
<p>Ваша подписка была автоматически продлена.</p>
<p>Списано с баланса: <strong>{amount}</strong></p>
<p>Новая дата истечения: <strong>{new_expires_at}</strong></p>
</div>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Auto-renewal Successful</h2>
<div class="highlight success">
<p>Your subscription has been automatically renewed.</p>
<p>Charged from balance: <strong>{amount}</strong></p>
<p>New expiration date: <strong>{new_expires_at}</strong></p>
</div>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _autopay_failed_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for failed autopay notification."""
reason = context.get("reason", "")
subjects = {
"ru": "Ошибка автопродления",
"en": "Auto-renewal Failed",
"zh": "自动续订失败",
"ua": "Помилка автопродовження",
}
bodies = {
"ru": f"""
<h2>Ошибка автопродления</h2>
<div class="highlight danger">
<p>Не удалось автоматически продлить подписку.</p>
{f'<p>Причина: {reason}</p>' if reason else ''}
</div>
<p>Пожалуйста, пополните баланс и продлите подписку вручную.</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Auto-renewal Failed</h2>
<div class="highlight danger">
<p>Failed to automatically renew your subscription.</p>
{f'<p>Reason: {reason}</p>' if reason else ''}
</div>
<p>Please top up your balance and renew manually.</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _autopay_insufficient_funds_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for autopay insufficient funds notification."""
required = context.get("required_amount", "")
balance = context.get("current_balance", "")
subjects = {
"ru": "Недостаточно средств для автопродления",
"en": "Insufficient Funds for Auto-renewal",
"zh": "余额不足无法自动续订",
"ua": "Недостатньо коштів для автопродовження",
}
bodies = {
"ru": f"""
<h2>Недостаточно средств</h2>
<div class="highlight warning">
<p>Недостаточно средств на балансе для автопродления подписки.</p>
{f'<p>Требуется: <strong>{required}</strong></p>' if required else ''}
{f'<p>На балансе: <strong>{balance}</strong></p>' if balance else ''}
</div>
<p>Пополните баланс, чтобы подписка была продлена автоматически.</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Insufficient Funds</h2>
<div class="highlight warning">
<p>Insufficient balance for subscription auto-renewal.</p>
{f'<p>Required: <strong>{required}</strong></p>' if required else ''}
{f'<p>Balance: <strong>{balance}</strong></p>' if balance else ''}
</div>
<p>Top up your balance for automatic renewal.</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
# ============================================================================
# Daily Subscription Templates
# ============================================================================
def _daily_debit_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for daily subscription debit notification."""
amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f}")
balance = context.get("formatted_balance", f"{context.get('new_balance_rubles', 0):.2f}")
subjects = {
"ru": f"Списание за подписку: {amount}",
"en": f"Subscription charge: {amount}",
"zh": f"订阅扣费: {amount}",
"ua": f"Списання за підписку: {amount}",
}
bodies = {
"ru": f"""
<h2>Ежедневное списание</h2>
<div class="highlight">
<p>С вашего баланса списано: <strong>{amount}</strong></p>
<p>Остаток на балансе: <strong>{balance}</strong></p>
</div>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Daily Charge</h2>
<div class="highlight">
<p>Charged from your balance: <strong>{amount}</strong></p>
<p>Remaining balance: <strong>{balance}</strong></p>
</div>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _daily_insufficient_funds_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for daily subscription insufficient funds."""
subjects = {
"ru": "Недостаточно средств для продления",
"en": "Insufficient Funds",
"zh": "余额不足",
"ua": "Недостатньо коштів",
}
bodies = {
"ru": f"""
<h2>Недостаточно средств</h2>
<div class="highlight danger">
<p>На балансе недостаточно средств для продления подписки.</p>
<p>Подписка будет приостановлена.</p>
</div>
<p>Пополните баланс, чтобы продолжить использование сервиса.</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Insufficient Funds</h2>
<div class="highlight danger">
<p>Insufficient balance to continue subscription.</p>
<p>Your subscription will be suspended.</p>
</div>
<p>Please top up your balance to continue using the service.</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _traffic_reset_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for traffic reset notification."""
subjects = {
"ru": "Трафик обновлён",
"en": "Traffic Reset",
"zh": "流量已重置",
"ua": "Трафік оновлено",
}
bodies = {
"ru": f"""
<h2>Трафик обновлён</h2>
<div class="highlight success">
<p>Ваш трафик был сброшен. Вы можете продолжить использование VPN.</p>
</div>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Traffic Reset</h2>
<div class="highlight success">
<p>Your traffic has been reset. You can continue using the VPN.</p>
</div>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
# ============================================================================
# Account Status Templates
# ============================================================================
def _ban_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for ban notification."""
reason = context.get("reason", "")
subjects = {
"ru": "Аккаунт заблокирован",
"en": "Account Suspended",
"zh": "账户已被封禁",
"ua": "Обліковий запис заблоковано",
}
bodies = {
"ru": f"""
<h2>Аккаунт заблокирован</h2>
<div class="highlight danger">
<p>Ваш аккаунт был заблокирован.</p>
{f'<p>Причина: {reason}</p>' if reason else ''}
</div>
<p>Если вы считаете, что это ошибка, обратитесь в поддержку.</p>
""",
"en": f"""
<h2>Account Suspended</h2>
<div class="highlight danger">
<p>Your account has been suspended.</p>
{f'<p>Reason: {reason}</p>' if reason else ''}
</div>
<p>If you believe this is an error, please contact support.</p>
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _unban_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for unban notification."""
subjects = {
"ru": "Аккаунт разблокирован",
"en": "Account Reactivated",
"zh": "账户已解封",
"ua": "Обліковий запис розблоковано",
}
bodies = {
"ru": f"""
<h2>Аккаунт разблокирован</h2>
<div class="highlight success">
<p>Ваш аккаунт был разблокирован.</p>
<p>Вы снова можете пользоваться сервисом.</p>
</div>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Account Reactivated</h2>
<div class="highlight success">
<p>Your account has been reactivated.</p>
<p>You can use the service again.</p>
</div>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _warning_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for warning notification."""
message = context.get("message", "")
subjects = {
"ru": "Предупреждение",
"en": "Warning",
"zh": "警告",
"ua": "Попередження",
}
bodies = {
"ru": f"""
<h2>Предупреждение</h2>
<div class="highlight warning">
{f'<p>{message}</p>' if message else '<p>Вы получили предупреждение от администрации.</p>'}
</div>
""",
"en": f"""
<h2>Warning</h2>
<div class="highlight warning">
{f'<p>{message}</p>' if message else '<p>You have received a warning from the administration.</p>'}
</div>
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
# ============================================================================
# Referral Templates
# ============================================================================
def _referral_bonus_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for referral bonus notification."""
bonus = context.get("formatted_bonus", f"{context.get('bonus_rubles', 0):.2f}")
referral_name = context.get("referral_name", "")
subjects = {
"ru": f"Реферальный бонус: +{bonus}",
"en": f"Referral bonus: +{bonus}",
"zh": f"推荐奖励: +{bonus}",
"ua": f"Реферальний бонус: +{bonus}",
}
bodies = {
"ru": f"""
<h2>Реферальный бонус!</h2>
<div class="highlight success">
<p>Вы получили реферальный бонус: <span class="amount">+{bonus}</span></p>
{f'<p>Благодаря пользователю: {referral_name}</p>' if referral_name else ''}
</div>
<p>Продолжайте приглашать друзей и зарабатывайте больше!</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Referral Bonus!</h2>
<div class="highlight success">
<p>You received a referral bonus: <span class="amount">+{bonus}</span></p>
{f'<p>Thanks to: {referral_name}</p>' if referral_name else ''}
</div>
<p>Keep inviting friends and earn more!</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
def _referral_registered_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for new referral registered notification."""
referral_name = context.get("referral_name", "")
subjects = {
"ru": "Новый реферал зарегистрирован",
"en": "New Referral Registered",
"zh": "新推荐用户已注册",
"ua": "Новий реферал зареєстрований",
}
bodies = {
"ru": f"""
<h2>Новый реферал!</h2>
<div class="highlight success">
<p>По вашей ссылке зарегистрировался новый пользователь{f': <strong>{referral_name}</strong>' if referral_name else ''}.</p>
</div>
<p>Вы будете получать бонусы с его пополнений!</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>New Referral!</h2>
<div class="highlight success">
<p>A new user registered using your link{f': <strong>{referral_name}</strong>' if referral_name else ''}.</p>
</div>
<p>You will receive bonuses from their top-ups!</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
# ============================================================================
# Payment Templates
# ============================================================================
def _payment_received_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]:
"""Template for payment received notification."""
amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f}")
payment_method = context.get("payment_method", "")
subjects = {
"ru": f"Платёж получен: {amount}",
"en": f"Payment received: {amount}",
"zh": f"收到付款: {amount}",
"ua": f"Платіж отримано: {amount}",
}
bodies = {
"ru": f"""
<h2>Платёж успешно обработан</h2>
<div class="highlight success">
<p>Сумма: <span class="amount">+{amount}</span></p>
{f'<p>Способ оплаты: {payment_method}</p>' if payment_method else ''}
</div>
<p>Спасибо за оплату!</p>
{self._get_cabinet_button(language)}
""",
"en": f"""
<h2>Payment Successfully Processed</h2>
<div class="highlight success">
<p>Amount: <span class="amount">+{amount}</span></p>
{f'<p>Payment method: {payment_method}</p>' if payment_method else ''}
</div>
<p>Thank you for your payment!</p>
{self._get_cabinet_button(language)}
""",
}
return {
"subject": subjects.get(language, subjects["en"]),
"body_html": self._get_base_template(bodies.get(language, bodies["en"])),
}
# Singleton instance
email_notification_templates = EmailNotificationTemplates()