Add files via upload
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>© {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()
|
||||
Reference in New Issue
Block a user