diff --git a/telegram_bot/admin.py b/telegram_bot/admin.py
index 9ee523a..aa8346c 100644
--- a/telegram_bot/admin.py
+++ b/telegram_bot/admin.py
@@ -8,11 +8,37 @@ from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from .models import BotSettings, TelegramMessage, AccessRequest
from .localization import MessageLocalizer
+from vpn.models import User
import logging
logger = logging.getLogger(__name__)
+class BotSettingsAdminForm(forms.ModelForm):
+ """Custom form for BotSettings with Telegram admin selection"""
+
+ class Meta:
+ model = BotSettings
+ fields = '__all__'
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # Show all users for telegram_admins selection
+ if 'telegram_admins' in self.fields:
+ self.fields['telegram_admins'].queryset = User.objects.all().order_by('username')
+ self.fields['telegram_admins'].help_text = (
+ "Select users who will have admin access in the bot. "
+ "Users will get admin rights when they connect to the bot with their Telegram account."
+ )
+
+ def clean_telegram_admins(self):
+ """Validate that selected admins have telegram_user_id or telegram_username"""
+ admins = self.cleaned_data.get('telegram_admins')
+ # No validation needed - admins can be selected even without telegram connection
+ # They will get admin rights when they connect via bot
+ return admins
+
+
class AccessRequestAdminForm(forms.ModelForm):
"""Custom form for AccessRequest with existing user selection"""
@@ -20,10 +46,6 @@ class AccessRequestAdminForm(forms.ModelForm):
model = AccessRequest
fields = '__all__'
widgets = {
- 'selected_inbounds': FilteredSelectMultiple(
- verbose_name='Inbound Templates',
- is_stacked=False
- ),
'selected_subscription_groups': FilteredSelectMultiple(
verbose_name='Subscription Groups',
is_stacked=False
@@ -43,13 +65,7 @@ class AccessRequestAdminForm(forms.ModelForm):
telegram_user_id__isnull=True
).order_by('username')
- # Configure inbound and subscription group fields
- if 'selected_inbounds' in self.fields:
- from vpn.models_xray import Inbound
- self.fields['selected_inbounds'].queryset = Inbound.objects.all().order_by('name')
- self.fields['selected_inbounds'].label = 'Inbound Templates'
- self.fields['selected_inbounds'].help_text = 'Select inbound templates to assign to this user'
-
+ # Configure subscription group fields
if 'selected_subscription_groups' in self.fields:
from vpn.models_xray import SubscriptionGroup
self.fields['selected_subscription_groups'].queryset = SubscriptionGroup.objects.filter(
@@ -61,12 +77,17 @@ class AccessRequestAdminForm(forms.ModelForm):
@admin.register(BotSettings)
class BotSettingsAdmin(admin.ModelAdmin):
- list_display = ('__str__', 'enabled', 'bot_token_display', 'updated_at')
+ form = BotSettingsAdminForm
+ list_display = ('__str__', 'enabled', 'bot_token_display', 'admin_count_display', 'updated_at')
fieldsets = (
('Bot Configuration', {
'fields': ('bot_token', 'enabled', 'bot_status_display'),
'description': 'Configure bot settings and view current status'
}),
+ ('Admin Management', {
+ 'fields': ('telegram_admins', 'admin_info_display'),
+ 'description': 'Select users with linked Telegram accounts who will have admin access in the bot'
+ }),
('Connection Settings', {
'fields': ('api_base_url', 'connection_timeout', 'use_proxy', 'proxy_url'),
'classes': ('collapse',)
@@ -76,7 +97,8 @@ class BotSettingsAdmin(admin.ModelAdmin):
'classes': ('collapse',)
}),
)
- readonly_fields = ('created_at', 'updated_at', 'bot_status_display')
+ readonly_fields = ('created_at', 'updated_at', 'bot_status_display', 'admin_info_display')
+ filter_horizontal = ('telegram_admins',)
def bot_token_display(self, obj):
"""Mask bot token for security"""
@@ -88,6 +110,60 @@ class BotSettingsAdmin(admin.ModelAdmin):
return "No token set"
bot_token_display.short_description = "Bot Token"
+ def admin_count_display(self, obj):
+ """Display count of Telegram admins"""
+ count = obj.telegram_admins.count()
+ if count == 0:
+ return "No admins"
+ elif count == 1:
+ return "1 admin"
+ else:
+ return f"{count} admins"
+ admin_count_display.short_description = "Telegram Admins"
+
+ def admin_info_display(self, obj):
+ """Display detailed admin information"""
+ if not obj.pk:
+ return "Save settings first to manage admins"
+
+ admins = obj.telegram_admins.all()
+
+ if not admins.exists():
+ html = '
'
+ html += '
⚠️ No Telegram admins configured
'
+ html += '
Select users above to give them admin access in the Telegram bot.
'
+ html += '
'
+ else:
+ html = ''
+ html += f'
✅ {admins.count()} Telegram admin(s) configured
'
+ html += '
'
+
+ for admin in admins:
+ html += '
'
+ html += f'{admin.username}'
+
+ if admin.telegram_username:
+ html += f' (@{admin.telegram_username})'
+
+ html += f' ID: {admin.telegram_user_id}'
+
+ if admin.first_name or admin.last_name:
+ name_parts = []
+ if admin.first_name:
+ name_parts.append(admin.first_name)
+ if admin.last_name:
+ name_parts.append(admin.last_name)
+ html += f'
Name: {" ".join(name_parts)}'
+
+ html += '
'
+
+ html += '
'
+ html += '
These users will receive notifications about new access requests and can approve/reject them directly in Telegram.
'
+ html += '
'
+
+ return format_html(html)
+ admin_info_display.short_description = "Admin Configuration"
+
def bot_status_display(self, obj):
"""Display bot status with control buttons"""
from .bot import TelegramBotManager
@@ -365,10 +441,9 @@ class AccessRequestAdmin(admin.ModelAdmin):
}),
('VPN Access Configuration', {
'fields': (
- 'selected_inbounds',
'selected_subscription_groups',
),
- 'description': 'Select inbound templates and subscription groups to assign to the user'
+ 'description': 'Select subscription groups to assign to the user'
}),
('Telegram User', {
'fields': (
@@ -620,11 +695,12 @@ class AccessRequestAdmin(admin.ModelAdmin):
raise
def _assign_vpn_access(self, user, access_request):
- """Assign selected inbounds and subscription groups to the user"""
+ """Assign selected subscription groups to the user"""
try:
- from vpn.models_xray import UserSubscription, SubscriptionGroup
+ from vpn.models_xray import UserSubscription
# Assign subscription groups
+ group_count = 0
for subscription_group in access_request.selected_subscription_groups.all():
user_subscription, created = UserSubscription.objects.get_or_create(
user=user,
@@ -633,48 +709,16 @@ class AccessRequestAdmin(admin.ModelAdmin):
)
if created:
logger.info(f"Assigned subscription group '{subscription_group.name}' to user {user.username}")
+ group_count += 1
else:
# Ensure it's active if it already existed
if not user_subscription.active:
user_subscription.active = True
user_subscription.save()
logger.info(f"Re-activated subscription group '{subscription_group.name}' for user {user.username}")
+ group_count += 1
- # Handle individual inbounds - create a custom subscription group for them
- selected_inbounds = access_request.selected_inbounds.all()
- if selected_inbounds.exists():
- # Create a custom subscription group for this user's individual inbounds
- custom_group_name = f"Custom_{user.username}_{access_request.id}"
- custom_group, created = SubscriptionGroup.objects.get_or_create(
- name=custom_group_name,
- defaults={
- 'description': f'Custom inbounds for {user.username} from Telegram request',
- 'is_active': True
- }
- )
-
- if created:
- # Add selected inbounds to the custom group
- custom_group.inbounds.set(selected_inbounds)
- logger.info(f"Created custom subscription group '{custom_group_name}' with {selected_inbounds.count()} inbounds")
- else:
- # Update existing custom group
- custom_group.inbounds.add(*selected_inbounds)
- logger.info(f"Updated custom subscription group '{custom_group_name}' with additional inbounds")
-
- # Assign the custom group to the user
- user_subscription, created = UserSubscription.objects.get_or_create(
- user=user,
- subscription_group=custom_group,
- defaults={'active': True}
- )
- if created:
- logger.info(f"Assigned custom subscription group to user {user.username}")
-
- inbound_count = selected_inbounds.count()
- group_count = access_request.selected_subscription_groups.count()
-
- logger.info(f"Successfully assigned {group_count} subscription groups and {inbound_count} individual inbounds to user {user.username}")
+ logger.info(f"Successfully assigned {group_count} subscription groups to user {user.username}")
except Exception as e:
logger.error(f"Error assigning VPN access to user {user.username}: {e}")
diff --git a/telegram_bot/bot.py b/telegram_bot/bot.py
index 38274bf..1f8c869 100644
--- a/telegram_bot/bot.py
+++ b/telegram_bot/bot.py
@@ -6,7 +6,7 @@ import os
import fcntl
from typing import Optional
from telegram import Update, Bot
-from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
+from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes
from django.utils import timezone
from django.conf import settings
from asgiref.sync import sync_to_async
@@ -125,6 +125,619 @@ class TelegramBotManager:
self._release_lock()
raise e
+ async def _is_telegram_admin(self, telegram_user):
+ """Check if user is a Telegram admin"""
+ try:
+ from django.db.models import Q
+ bot_settings = await sync_to_async(BotSettings.get_settings)()
+
+ # Check by telegram_user_id first
+ if hasattr(telegram_user, 'id'):
+ telegram_user_id = telegram_user.id
+ telegram_username = telegram_user.username if hasattr(telegram_user, 'username') else None
+ else:
+ # If just an ID was passed
+ telegram_user_id = telegram_user
+ telegram_username = None
+
+ # Check if admin by telegram_user_id
+ admin_by_id = await sync_to_async(
+ bot_settings.telegram_admins.filter(telegram_user_id=telegram_user_id).exists
+ )()
+
+ if admin_by_id:
+ return True
+
+ # Also check by telegram_username if available
+ if telegram_username:
+ admin_by_username = await sync_to_async(
+ bot_settings.telegram_admins.filter(telegram_username=telegram_username).exists
+ )()
+
+ if admin_by_username:
+ # Update the user's telegram_user_id for future checks
+ admin_user = await sync_to_async(
+ bot_settings.telegram_admins.filter(telegram_username=telegram_username).first
+ )()
+ if admin_user and not admin_user.telegram_user_id:
+ admin_user.telegram_user_id = telegram_user_id
+ await sync_to_async(admin_user.save)()
+ logger.info(f"Linked telegram_user_id {telegram_user_id} to admin {admin_user.username}")
+ return True
+
+ return False
+ except Exception as e:
+ logger.error(f"Error checking admin status: {e}")
+ return False
+
+ async def _notify_admins_new_request(self, access_request):
+ """Notify all Telegram admins about new access request"""
+ try:
+ bot_settings = await sync_to_async(BotSettings.get_settings)()
+ admins = await sync_to_async(list)(bot_settings.telegram_admins.all())
+
+ if not admins:
+ logger.info("No Telegram admins configured, skipping notification")
+ return
+
+ # Prepare user info
+ user_info = access_request.display_name
+ telegram_info = f"@{access_request.telegram_username}" if access_request.telegram_username else f"ID: {access_request.telegram_user_id}"
+ date = access_request.created_at.strftime("%Y-%m-%d %H:%M")
+ message = access_request.message_text[:200] + "..." if len(access_request.message_text) > 200 else access_request.message_text
+
+ # Send notification to each admin
+ for admin in admins:
+ try:
+ if admin.telegram_user_id:
+ # Get admin language (default to English if not set)
+ admin_language = 'ru' if hasattr(admin, 'telegram_user_language') else 'en'
+
+ notification_text = MessageLocalizer.get_message(
+ 'admin_new_request_notification',
+ admin_language,
+ user_info=user_info,
+ telegram_info=telegram_info,
+ date=date,
+ message=message
+ )
+
+ await self._send_notification_to_admin(admin.telegram_user_id, notification_text)
+ logger.info(f"Sent new request notification to admin {admin.username}")
+
+ except Exception as e:
+ logger.error(f"Failed to notify admin {admin.username}: {e}")
+
+ except Exception as e:
+ logger.error(f"Error notifying admins about new request: {e}")
+
+ async def _send_notification_to_admin(self, telegram_user_id, message_text):
+ """Send notification message to specific admin"""
+ try:
+ bot_settings = await sync_to_async(BotSettings.get_settings)()
+ if not bot_settings.enabled or not bot_settings.bot_token:
+ logger.warning("Bot not configured, skipping admin notification")
+ return
+
+ # Create a simple Bot instance for sending notification
+ from telegram import Bot
+ from telegram.request import HTTPXRequest
+
+ request_kwargs = {
+ 'connection_pool_size': 1,
+ 'read_timeout': bot_settings.connection_timeout,
+ 'write_timeout': bot_settings.connection_timeout,
+ 'connect_timeout': bot_settings.connection_timeout,
+ }
+
+ if bot_settings.use_proxy and bot_settings.proxy_url:
+ request_kwargs['proxy'] = bot_settings.proxy_url
+
+ request = HTTPXRequest(**request_kwargs)
+ bot = Bot(token=bot_settings.bot_token, request=request)
+
+ await bot.send_message(
+ chat_id=telegram_user_id,
+ text=message_text,
+ parse_mode='Markdown'
+ )
+
+ # Clean up bot connection
+ try:
+ await request.shutdown()
+ except:
+ pass
+
+ except Exception as e:
+ logger.error(f"Failed to send notification to admin {telegram_user_id}: {e}")
+
+ async def _create_main_keyboard(self, telegram_user):
+ """Create main keyboard for existing users, with admin buttons if user is admin"""
+ from telegram import ReplyKeyboardMarkup, KeyboardButton
+
+ # Get basic buttons
+ access_button = get_localized_button(telegram_user, 'access')
+ guide_button = get_localized_button(telegram_user, 'guide')
+
+ # Check if user is admin
+ is_admin = await self._is_telegram_admin(telegram_user)
+
+ if is_admin:
+ # Admin keyboard with additional admin button
+ access_requests_button = get_localized_button(telegram_user, 'access_requests')
+ keyboard = [
+ [KeyboardButton(access_button), KeyboardButton(guide_button)],
+ [KeyboardButton(access_requests_button)]
+ ]
+ else:
+ # Regular user keyboard
+ keyboard = [
+ [KeyboardButton(access_button), KeyboardButton(guide_button)]
+ ]
+
+ return ReplyKeyboardMarkup(
+ keyboard,
+ resize_keyboard=True,
+ one_time_keyboard=False
+ )
+
+ async def _handle_access_requests_command(self, update: Update):
+ """Handle access requests button - show pending requests to admin"""
+ try:
+ # Get pending access requests
+ pending_requests = await sync_to_async(list)(
+ AccessRequest.objects.filter(approved=False).order_by('-created_at')
+ )
+
+ if not pending_requests:
+ # No pending requests
+ no_requests_msg = get_localized_message(update.message.from_user, 'admin_no_pending_requests')
+ reply_markup = await self._create_main_keyboard(update.message.from_user)
+
+ sent_message = await update.message.reply_text(
+ no_requests_msg,
+ reply_markup=reply_markup
+ )
+
+ await self._save_outgoing_message(sent_message, update.message.from_user)
+ return
+
+ # Show pending requests with inline keyboards for approve/reject
+ title_msg = get_localized_message(update.message.from_user, 'admin_access_requests_title')
+
+ # Send title message with main keyboard
+ reply_markup = await self._create_main_keyboard(update.message.from_user)
+ title_sent = await update.message.reply_text(
+ title_msg,
+ reply_markup=reply_markup,
+ parse_mode='Markdown'
+ )
+ await self._save_outgoing_message(title_sent, update.message.from_user)
+
+ # Send each request with inline keyboard for actions
+ from telegram import InlineKeyboardMarkup, InlineKeyboardButton
+
+ for request in pending_requests[:5]: # Show max 5 requests
+ # Format request info
+ user_info = request.display_name
+ date = request.created_at.strftime("%Y-%m-%d %H:%M")
+ message_preview = request.message_text[:100] + "..." if len(request.message_text) > 100 else request.message_text
+
+ request_text = get_localized_message(
+ update.message.from_user,
+ 'admin_request_item',
+ user_info=user_info,
+ date=date,
+ message_preview=message_preview
+ )
+
+ # Create inline keyboard for this request
+ approve_btn_text = get_localized_button(update.message.from_user, 'approve')
+ reject_btn_text = get_localized_button(update.message.from_user, 'reject')
+ details_btn_text = get_localized_button(update.message.from_user, 'details')
+
+ inline_keyboard = [
+ [
+ InlineKeyboardButton(approve_btn_text, callback_data=f"approve_{request.id}"),
+ InlineKeyboardButton(reject_btn_text, callback_data=f"reject_{request.id}")
+ ],
+ [InlineKeyboardButton(details_btn_text, callback_data=f"details_{request.id}")]
+ ]
+ inline_markup = InlineKeyboardMarkup(inline_keyboard)
+
+ request_sent = await update.message.reply_text(
+ request_text,
+ reply_markup=inline_markup,
+ parse_mode='Markdown'
+ )
+ await self._save_outgoing_message(request_sent, update.message.from_user)
+
+ if len(pending_requests) > 5:
+ remaining_msg = f"... и еще {len(pending_requests) - 5} заявок" if get_user_language(update.message.from_user) == 'ru' else f"... and {len(pending_requests) - 5} more requests"
+ await update.message.reply_text(remaining_msg)
+
+ logger.info(f"Showed {len(pending_requests)} pending requests to admin {update.message.from_user.username}")
+
+ except Exception as e:
+ logger.error(f"Error handling access requests command: {e}")
+ error_msg = get_localized_message(update.message.from_user, 'admin_error_processing', error=str(e))
+ await update.message.reply_text(error_msg)
+
+ async def _handle_callback_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Handle inline keyboard button presses (callback queries)"""
+ try:
+ query = update.callback_query
+ await query.answer() # Acknowledge the callback
+
+ # Check if user is admin
+ if not await self._is_telegram_admin(query.from_user):
+ await query.edit_message_text("❌ Access denied. Admin rights required.")
+ return
+
+ # Parse callback data
+ callback_data = query.data
+ action, request_id = callback_data.split('_', 1)
+
+ if action == "approve":
+ await self._handle_approve_callback(query, request_id)
+ elif action == "reject":
+ await self._handle_reject_callback(query, request_id)
+ elif action == "details":
+ await self._handle_details_callback(query, request_id)
+ elif action == "sg": # Subscription group selection
+ await self._handle_subscription_group_callback(query, callback_data)
+ elif action == "confirm": # Confirm approval with selected groups
+ await self._handle_confirm_approval_callback(query, request_id)
+ elif action == "cancel": # Cancel approval process
+ await self._handle_cancel_callback(query, request_id)
+ else:
+ await query.edit_message_text("❌ Unknown action")
+
+ except Exception as e:
+ logger.error(f"Error handling callback query: {e}")
+ try:
+ await query.edit_message_text(f"❌ Error: {str(e)}")
+ except:
+ pass
+
+ async def _handle_approve_callback(self, query, request_id):
+ """Handle approve button press - show subscription groups selection"""
+ try:
+ # Get the request
+ request = await sync_to_async(AccessRequest.objects.get)(id=request_id)
+
+ # Check if already processed
+ if request.approved:
+ already_processed_msg = get_localized_message(query.from_user, 'admin_request_already_processed')
+ await query.edit_message_text(already_processed_msg)
+ return
+
+ # Get available subscription groups
+ from vpn.models_xray import SubscriptionGroup
+ groups = await sync_to_async(list)(
+ SubscriptionGroup.objects.filter(is_active=True).order_by('name')
+ )
+
+ if not groups:
+ await query.edit_message_text("❌ No subscription groups available")
+ return
+
+ # Create inline keyboard with subscription groups
+ from telegram import InlineKeyboardMarkup, InlineKeyboardButton
+
+ user_info = request.display_name
+ choose_groups_msg = get_localized_message(
+ query.from_user,
+ 'admin_choose_subscription_groups',
+ user_info=user_info
+ )
+
+ # Create buttons for each group (max 2 per row)
+ keyboard = []
+ for i in range(0, len(groups), 2):
+ row = []
+ for j in range(2):
+ if i + j < len(groups):
+ group = groups[i + j]
+ button_text = f"⚪ {group.name}"
+ callback_data = f"sg_toggle_{group.id}_{request_id}"
+ row.append(InlineKeyboardButton(button_text, callback_data=callback_data))
+ keyboard.append(row)
+
+ # Add confirm and cancel buttons
+ confirm_btn_text = get_localized_button(query.from_user, 'confirm_approval')
+ cancel_btn_text = get_localized_button(query.from_user, 'cancel')
+
+ keyboard.append([
+ InlineKeyboardButton(confirm_btn_text, callback_data=f"confirm_{request_id}"),
+ InlineKeyboardButton(cancel_btn_text, callback_data=f"cancel_{request_id}")
+ ])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ await query.edit_message_text(choose_groups_msg, reply_markup=reply_markup, parse_mode='Markdown')
+
+ except Exception as e:
+ logger.error(f"Error handling approve callback: {e}")
+ error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e))
+ await query.edit_message_text(error_msg)
+
+ async def _handle_subscription_group_callback(self, query, callback_data):
+ """Handle subscription group toggle button"""
+ try:
+ # Parse callback data: sg_toggle_{group_id}_{request_id}
+ parts = callback_data.split('_')
+ group_id = parts[2]
+ request_id = parts[3]
+
+ # Get current message text and keyboard
+ current_text = query.message.text
+ current_keyboard = query.message.reply_markup.inline_keyboard
+
+ # Toggle the group selection
+ updated_keyboard = []
+ for row in current_keyboard:
+ updated_row = []
+ for button in row:
+ if button.callback_data and button.callback_data.startswith(f"sg_toggle_{group_id}_"):
+ # Toggle this button
+ if button.text.startswith("⚪"):
+ # Select it
+ new_text = button.text.replace("⚪", "✅")
+ else:
+ # Deselect it
+ new_text = button.text.replace("✅", "⚪")
+ from telegram import InlineKeyboardButton
+ updated_row.append(InlineKeyboardButton(new_text, callback_data=button.callback_data))
+ else:
+ updated_row.append(button)
+ updated_keyboard.append(updated_row)
+
+ from telegram import InlineKeyboardMarkup
+ updated_markup = InlineKeyboardMarkup(updated_keyboard)
+ await query.edit_message_reply_markup(reply_markup=updated_markup)
+
+ except Exception as e:
+ logger.error(f"Error handling subscription group callback: {e}")
+
+ async def _handle_confirm_approval_callback(self, query, request_id):
+ """Handle confirm approval button - create user and assign groups"""
+ try:
+ # Get the request
+ request = await sync_to_async(AccessRequest.objects.get)(id=request_id)
+
+ # Check if already processed
+ if request.approved:
+ already_processed_msg = get_localized_message(query.from_user, 'admin_request_already_processed')
+ await query.edit_message_text(already_processed_msg)
+ return
+
+ # Get selected groups from the keyboard
+ selected_groups = []
+ from vpn.models_xray import SubscriptionGroup
+
+ for row in query.message.reply_markup.inline_keyboard:
+ for button in row:
+ if button.callback_data and button.callback_data.startswith("sg_toggle_") and button.text.startswith("✅"):
+ # Extract group_id from callback_data
+ group_id = button.callback_data.split('_')[2]
+ group = await sync_to_async(SubscriptionGroup.objects.get)(id=group_id)
+ selected_groups.append(group)
+
+ if not selected_groups:
+ await query.edit_message_text("❌ Please select at least one subscription group")
+ return
+
+ # Save selected groups to the request
+ await sync_to_async(request.selected_subscription_groups.set)(selected_groups)
+
+ try:
+ # Create or get user
+ from vpn.models import User
+ from vpn.models_xray import UserSubscription
+ import secrets
+ import string
+
+ # Check if user already exists
+ existing_user = await sync_to_async(
+ User.objects.filter(telegram_user_id=request.telegram_user_id).first
+ )()
+
+ if existing_user:
+ user = existing_user
+ logger.info(f"Using existing user {user.username} for telegram_user_id {request.telegram_user_id}")
+ else:
+ # Check if selected_existing_user is set
+ if request.selected_existing_user:
+ # Link telegram to existing user
+ user = request.selected_existing_user
+ user.telegram_user_id = request.telegram_user_id
+ user.telegram_username = request.telegram_username
+ await sync_to_async(user.save)()
+ logger.info(f"Linked telegram account to existing user {user.username}")
+ else:
+ # Create new user
+ username = request.desired_username or request.telegram_username or f"tg_{request.telegram_user_id}"
+
+ # Ensure unique username
+ base_username = username
+ counter = 1
+ while await sync_to_async(User.objects.filter(username=username).exists)():
+ username = f"{base_username}_{counter}"
+ counter += 1
+
+ # Generate random password
+ alphabet = string.ascii_letters + string.digits
+ password = ''.join(secrets.choice(alphabet) for _ in range(16))
+
+ # Create user
+ user = await sync_to_async(User.objects.create_user)(
+ username=username,
+ password=password,
+ telegram_user_id=request.telegram_user_id,
+ telegram_username=request.telegram_username,
+ first_name=request.telegram_first_name or '',
+ last_name=request.telegram_last_name or '',
+ comment=f"Created from Telegram request #{request.id}"
+ )
+ logger.info(f"Created new user {user.username} from Telegram request")
+
+ # Link user to request
+ request.user = user
+ await sync_to_async(request.save)()
+
+ # Assign subscription groups to user
+ for subscription_group in selected_groups:
+ user_subscription, created = await sync_to_async(UserSubscription.objects.get_or_create)(
+ user=user,
+ subscription_group=subscription_group,
+ defaults={'active': True}
+ )
+ if created:
+ logger.info(f"Assigned subscription group '{subscription_group.name}' to user {user.username}")
+ else:
+ # Ensure it's active if it already existed
+ if not user_subscription.active:
+ user_subscription.active = True
+ await sync_to_async(user_subscription.save)()
+ logger.info(f"Re-activated subscription group '{subscription_group.name}' for user {user.username}")
+
+ # Mark as approved
+ request.approved = True
+ await sync_to_async(request.save)()
+
+ # Send success message to admin
+ groups_list = ", ".join([group.name for group in selected_groups])
+ success_msg = get_localized_message(
+ query.from_user,
+ 'admin_approval_success',
+ user_info=request.display_name,
+ groups=groups_list
+ )
+ await query.edit_message_text(success_msg, parse_mode='Markdown')
+
+ # Send approval notification to user
+ # Create a dummy user object for localization
+ class DummyUser:
+ def __init__(self, lang):
+ self.language_code = lang
+
+ user_lang = DummyUser(request.user_language if hasattr(request, 'user_language') else 'en')
+ approval_msg = get_localized_message(user_lang, 'approval_notification')
+ await self._send_notification_to_admin(request.telegram_user_id, approval_msg)
+
+ logger.info(f"Admin {query.from_user.username} approved request {request_id} with {len(selected_groups)} groups")
+
+ except Exception as e:
+ logger.error(f"Error in approval process: {e}")
+ error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e))
+ await query.edit_message_text(error_msg)
+
+ except Exception as e:
+ logger.error(f"Error handling confirm approval callback: {e}")
+ error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e))
+ await query.edit_message_text(error_msg)
+
+ async def _handle_reject_callback(self, query, request_id):
+ """Handle reject button press"""
+ try:
+ # Get the request
+ request = await sync_to_async(AccessRequest.objects.get)(id=request_id)
+
+ # Check if already processed
+ if request.approved:
+ already_processed_msg = get_localized_message(query.from_user, 'admin_request_already_processed')
+ await query.edit_message_text(already_processed_msg)
+ return
+
+ # Mark as rejected (we can add a rejected field or just delete)
+ await sync_to_async(request.delete)()
+
+ # Send success message to admin
+ success_msg = get_localized_message(
+ query.from_user,
+ 'admin_rejection_success',
+ user_info=request.display_name
+ )
+ await query.edit_message_text(success_msg, parse_mode='Markdown')
+
+ logger.info(f"Admin {query.from_user.username} rejected request {request_id}")
+
+ except Exception as e:
+ logger.error(f"Error handling reject callback: {e}")
+ error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e))
+ await query.edit_message_text(error_msg)
+
+ async def _handle_details_callback(self, query, request_id):
+ """Handle details button press - show full request info"""
+ try:
+ # Get the request
+ request = await sync_to_async(AccessRequest.objects.get)(id=request_id)
+
+ # Format detailed information
+ details = f"**📋 Request Details**\n\n"
+ details += f"**👤 User:** {request.display_name}\n"
+ details += f"**📱 Telegram:** @{request.telegram_username}" if request.telegram_username else f"**📱 Telegram ID:** {request.telegram_user_id}\n"
+ details += f"**📅 Date:** {request.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n"
+ details += f"**🆔 Request ID:** {request.id}\n"
+ details += f"**👤 Desired Username:** {request.desired_username}\n\n"
+ details += f"**💬 Full Message:**\n{request.message_text}"
+
+ # Create back button
+ from telegram import InlineKeyboardMarkup, InlineKeyboardButton
+ back_keyboard = [[
+ InlineKeyboardButton("⬅️ Back", callback_data=f"approve_{request_id}"),
+ InlineKeyboardButton("❌ Reject", callback_data=f"reject_{request_id}")
+ ]]
+ reply_markup = InlineKeyboardMarkup(back_keyboard)
+
+ await query.edit_message_text(details, reply_markup=reply_markup, parse_mode='Markdown')
+
+ except Exception as e:
+ logger.error(f"Error handling details callback: {e}")
+ error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e))
+ await query.edit_message_text(error_msg)
+
+ async def _handle_cancel_callback(self, query, request_id):
+ """Handle cancel button press - return to original request view"""
+ try:
+ # Get the request
+ request = await sync_to_async(AccessRequest.objects.get)(id=request_id)
+
+ # Recreate original request display
+ user_info = request.display_name
+ date = request.created_at.strftime("%Y-%m-%d %H:%M")
+ message_preview = request.message_text[:100] + "..." if len(request.message_text) > 100 else request.message_text
+
+ request_text = get_localized_message(
+ query.from_user,
+ 'admin_request_item',
+ user_info=user_info,
+ date=date,
+ message_preview=message_preview
+ )
+
+ # Create original inline keyboard
+ from telegram import InlineKeyboardMarkup, InlineKeyboardButton
+ approve_btn_text = get_localized_button(query.from_user, 'approve')
+ reject_btn_text = get_localized_button(query.from_user, 'reject')
+ details_btn_text = get_localized_button(query.from_user, 'details')
+
+ inline_keyboard = [
+ [
+ InlineKeyboardButton(approve_btn_text, callback_data=f"approve_{request.id}"),
+ InlineKeyboardButton(reject_btn_text, callback_data=f"reject_{request.id}")
+ ],
+ [InlineKeyboardButton(details_btn_text, callback_data=f"details_{request.id}")]
+ ]
+ inline_markup = InlineKeyboardMarkup(inline_keyboard)
+
+ await query.edit_message_text(request_text, reply_markup=inline_markup, parse_mode='Markdown')
+
+ except Exception as e:
+ logger.error(f"Error handling cancel callback: {e}")
+ error_msg = get_localized_message(query.from_user, 'admin_error_processing', error=str(e))
+ await query.edit_message_text(error_msg)
+
def stop(self):
"""Stop the bot"""
if not self._running:
@@ -217,6 +830,7 @@ class TelegramBotManager:
# Add handlers
self._application.add_handler(CommandHandler("start", self._handle_start))
+ self._application.add_handler(CallbackQueryHandler(self._handle_callback_query))
self._application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
self._application.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, self._handle_other))
@@ -276,19 +890,7 @@ class TelegramBotManager:
if user_response['action'] == 'existing_user':
# User already exists - show keyboard with options
- from telegram import ReplyKeyboardMarkup, KeyboardButton
-
- # Create keyboard for registered users with localized buttons
- access_button = get_localized_button(update.message.from_user, 'access')
- guide_button = get_localized_button(update.message.from_user, 'guide')
- keyboard = [
- [KeyboardButton(access_button), KeyboardButton(guide_button)],
- ]
- reply_markup = ReplyKeyboardMarkup(
- keyboard,
- resize_keyboard=True,
- one_time_keyboard=False
- )
+ reply_markup = await self._create_main_keyboard(update.message.from_user)
help_text = get_localized_message(update.message.from_user, 'help_text')
sent_message = await update.message.reply_text(
@@ -331,6 +933,7 @@ class TelegramBotManager:
# Get localized button texts for comparison
access_btn = get_localized_button(update.message.from_user, 'access')
guide_btn = get_localized_button(update.message.from_user, 'guide')
+ access_requests_btn = get_localized_button(update.message.from_user, 'access_requests')
all_in_one_btn = get_localized_button(update.message.from_user, 'all_in_one')
back_btn = get_localized_button(update.message.from_user, 'back')
group_prefix = get_localized_button(update.message.from_user, 'group_prefix')
@@ -340,6 +943,12 @@ class TelegramBotManager:
await self._handle_access_command(update, user_response['user'])
elif update.message.text == guide_btn:
await self._handle_guide_command(update)
+ elif update.message.text == access_requests_btn:
+ # Admin command - check permissions and handle
+ if await self._is_telegram_admin(update.message.from_user):
+ await self._handle_access_requests_command(update)
+ else:
+ await self._send_help_message(update)
elif update.message.text.startswith(group_prefix):
# Handle specific group selection
group_name = update.message.text.replace(group_prefix, "")
@@ -653,16 +1262,7 @@ class TelegramBotManager:
)
else:
# No active subscriptions - show main keyboard
- access_button = get_localized_button(update.message.from_user, 'access')
- guide_button = get_localized_button(update.message.from_user, 'guide')
- keyboard = [
- [KeyboardButton(access_button), KeyboardButton(guide_button)],
- ]
- reply_markup = ReplyKeyboardMarkup(
- keyboard,
- resize_keyboard=True,
- one_time_keyboard=False
- )
+ reply_markup = await self._create_main_keyboard(update.message.from_user)
no_subs_msg = get_localized_message(update.message.from_user, 'no_subscriptions')
sent_message = await update.message.reply_text(
@@ -711,6 +1311,10 @@ class TelegramBotManager:
)
logger.info(f"Created access request for user {telegram_user.username or telegram_user.id}")
+
+ # Notify admins about new request
+ await self._notify_admins_new_request(request)
+
return request
except Exception as e:
@@ -721,17 +1325,7 @@ class TelegramBotManager:
"""Send help message for unrecognized commands"""
try:
# Create main keyboard for existing users with localized buttons
- from telegram import ReplyKeyboardMarkup, KeyboardButton
- access_button = get_localized_button(update.message.from_user, 'access')
- guide_button = get_localized_button(update.message.from_user, 'guide')
- keyboard = [
- [KeyboardButton(access_button), KeyboardButton(guide_button)],
- ]
- reply_markup = ReplyKeyboardMarkup(
- keyboard,
- resize_keyboard=True,
- one_time_keyboard=False
- )
+ reply_markup = await self._create_main_keyboard(update.message.from_user)
help_text = get_localized_message(update.message.from_user, 'help_text')
sent_message = await update.message.reply_text(
@@ -978,19 +1572,8 @@ class TelegramBotManager:
async def _handle_back_to_main(self, update: Update, user):
"""Handle back button - return to main menu"""
try:
- from telegram import ReplyKeyboardMarkup, KeyboardButton
-
# Create main keyboard with localized buttons
- access_btn = get_localized_button(update.message.from_user, 'access')
- guide_btn = get_localized_button(update.message.from_user, 'guide')
- keyboard = [
- [KeyboardButton(access_btn), KeyboardButton(guide_btn)],
- ]
- reply_markup = ReplyKeyboardMarkup(
- keyboard,
- resize_keyboard=True,
- one_time_keyboard=False
- )
+ reply_markup = await self._create_main_keyboard(update.message.from_user)
help_text = get_localized_message(update.message.from_user, 'help_text')
sent_message = await update.message.reply_text(
diff --git a/telegram_bot/localization.py b/telegram_bot/localization.py
index c0532cf..2744e07 100644
--- a/telegram_bot/localization.py
+++ b/telegram_bot/localization.py
@@ -41,6 +41,18 @@ MESSAGES = {
'guide_choose_platform': "Select your device platform:",
'web_portal_description': "_Web portal shows your access list on one convenient page with some statistics._",
'servers_in_group': "🔒 **Servers in group:**",
+
+ # Admin messages
+ 'admin_new_request_notification': "🔔 **New Access Request**\n\n👤 **User:** {user_info}\n📱 **Telegram:** {telegram_info}\n📅 **Date:** {date}\n\n💬 **Message:** {message}",
+ 'admin_access_requests_title': "📋 **Pending Access Requests**",
+ 'admin_no_pending_requests': "✅ No pending access requests",
+ 'admin_request_item': "👤 **{user_info}**\n📅 {date}\n💬 _{message_preview}_",
+ 'admin_choose_subscription_groups': "📦 **Choose Subscription Groups for {user_info}:**\n\nSelect groups to assign to this user:",
+ 'admin_approval_success': "✅ **Request Approved!**\n\n👤 User: {user_info}\n📦 Groups: {groups}\n\nUser has been notified and given access.",
+ 'admin_rejection_success': "❌ **Request Rejected**\n\n👤 User: {user_info}\n\nUser has been notified.",
+ 'admin_request_already_processed': "⚠️ This request has already been processed by another admin.",
+ 'admin_error_processing': "❌ Error processing request: {error}",
+
'android_guide': "🤖 **Android Setup Guide**\n\n**Step 1: Install the app**\nDownload V2RayTUN from Google Play:\nhttps://play.google.com/store/apps/details?id=com.v2raytun.android\n\n**Step 2: Add subscription**\n• Open the app\n• Tap the **+** button in the top right corner\n• Paste your subscription link from the bot\n• The app will automatically load all VPN servers\n\n**Step 3: Connect**\n• Choose a server from the list\n• Tap **Connect**\n• All your traffic will now go through VPN\n\n**💡 Useful settings:**\n• In settings, enable direct access for banking apps and local sites\n• You can choose specific apps to use VPN while others use direct connection\n\n**🔄 If VPN stops working:**\nTap the refresh icon next to the server list to update your subscription.",
'ios_guide': " **iOS Setup Guide**\n\n**Step 1: Install the app**\nDownload V2RayTUN from App Store:\nhttps://apps.apple.com/us/app/v2raytun/id6476628951\n\n**Step 2: Add subscription**\n• Open the app\n• Tap the **+** button in the top right corner\n• Paste your subscription link from the bot\n• The app will automatically load all VPN servers\n\n**Step 3: Connect**\n• Choose a server from the list\n• Tap **Connect**\n• All your traffic will now go through VPN\n\n**⚠️ Note for iOS users:**\nCurrently, only VLESS protocol works reliably on iOS. Other protocols may have connectivity issues.\n\n**💡 Useful settings:**\n• In settings, enable direct access for banking apps and local sites to improve performance\n\n**🔄 If VPN stops working:**\nTap the refresh icon next to the server list to update your subscription.",
'buttons': {
@@ -52,7 +64,15 @@ MESSAGES = {
'all_in_one': "🌍 All-in-one",
'back': "⬅️ Back",
'group_prefix': "Group: ",
- 'request_access': "🔑 Request Access"
+ 'request_access': "🔑 Request Access",
+ # Admin buttons
+ 'access_requests': "📋 Access Requests",
+ 'approve': "✅ Approve",
+ 'reject': "❌ Reject",
+ 'details': "👁 Details",
+ 'confirm_approval': "✅ Confirm Approval",
+ 'confirm_rejection': "❌ Confirm Rejection",
+ 'cancel': "🚫 Cancel"
}
},
'ru': {
@@ -88,6 +108,18 @@ MESSAGES = {
'guide_choose_platform': "Выберите платформу вашего устройства:",
'web_portal_description': "_Веб-портал показывает список ваших доступов на одной удобной странице с некоторой статистикой._",
'servers_in_group': "🔒 **Серверы в группе:**",
+
+ # Admin messages
+ 'admin_new_request_notification': "🔔 **Новый запрос на доступ**\n\n👤 **Пользователь:** {user_info}\n📱 **Telegram:** {telegram_info}\n📅 **Дата:** {date}\n\n💬 **Сообщение:** {message}",
+ 'admin_access_requests_title': "📋 **Ожидающие запросы на доступ**",
+ 'admin_no_pending_requests': "✅ Нет ожидающих запросов на доступ",
+ 'admin_request_item': "👤 **{user_info}**\n📅 {date}\n💬 _{message_preview}_",
+ 'admin_choose_subscription_groups': "📦 **Выберите группы подписки для {user_info}:**\n\nВыберите группы для назначения этому пользователю:",
+ 'admin_approval_success': "✅ **Запрос одобрен!**\n\n👤 Пользователь: {user_info}\n📦 Группы: {groups}\n\nПользователь уведомлен и получил доступ.",
+ 'admin_rejection_success': "❌ **Запрос отклонен**\n\n👤 Пользователь: {user_info}\n\nПользователь уведомлен.",
+ 'admin_request_already_processed': "⚠️ Этот запрос уже обработан другим администратором.",
+ 'admin_error_processing': "❌ Ошибка обработки запроса: {error}",
+
'android_guide': "🤖 **Руководство для Android**\n\n**Шаг 1: Установите приложение**\nСкачайте V2RayTUN из Google Play:\nhttps://play.google.com/store/apps/details?id=com.v2raytun.android\n\n**Шаг 2: Добавьте подписку**\n• Откройте приложение\n• Нажмите кнопку **+** в правом верхнем углу\n• Вставьте ссылку на подписку из бота\n• Приложение автоматически загрузит список VPN серверов\n\n**Шаг 3: Подключитесь**\n• Выберите сервер из списка\n• Нажмите **Подключиться**\n• Весь ваш трафик будет проходить через VPN\n\n**💡 Полезные настройки:**\n• В настройках включите прямой доступ для банковских приложений и местных сайтов\n• Вы можете выбрать конкретные приложения для использования VPN, в то время как остальные будут работать напрямую\n\n**🔄 Если VPN перестал работать:**\nНажмите иконку обновления рядом со списком серверов для обновления подписки.",
'ios_guide': " **Руководство для iOS**\n\n**Шаг 1: Установите приложение**\nСкачайте V2RayTUN из App Store:\nhttps://apps.apple.com/us/app/v2raytun/id6476628951\n\n**Шаг 2: Добавьте подписку**\n• Откройте приложение\n• Нажмите кнопку **+** в правом верхнем углу\n• Вставьте ссылку на подписку из бота\n• Приложение автоматически загрузит список VPN серверов\n\n**Шаг 3: Подключитесь**\n• Выберите сервер из списка\n• Нажмите **Подключиться**\n• Весь ваш трафик будет проходить через VPN\n\n**⚠️ Важно для пользователей iOS:**\nВ настоящее время на iOS стабильно работает только протокол VLESS. Другие протоколы могут иметь проблемы с подключением.\n\n**💡 Полезные настройки:**\n• В настройках включите прямой доступ для банковских приложений и местных сайтов для улучшения производительности\n\n**🔄 Если VPN перестал работать:**\nНажмите иконку обновления рядом со списком серверов для обновления подписки.",
'buttons': {
@@ -99,7 +131,15 @@ MESSAGES = {
'all_in_one': "🌍 Все в одном",
'back': "⬅️ Назад",
'group_prefix': "Группа: ",
- 'request_access': "🔑 Запросить доступ"
+ 'request_access': "🔑 Запросить доступ",
+ # Admin buttons
+ 'access_requests': "📋 Запросы на доступ",
+ 'approve': "✅ Одобрить",
+ 'reject': "❌ Отклонить",
+ 'details': "👁 Подробности",
+ 'confirm_approval': "✅ Подтвердить одобрение",
+ 'confirm_rejection': "❌ Подтвердить отклонение",
+ 'cancel': "🚫 Отмена"
}
}
}
diff --git a/telegram_bot/migrations/0010_botsettings_telegram_admins.py b/telegram_bot/migrations/0010_botsettings_telegram_admins.py
new file mode 100644
index 0000000..ac441c4
--- /dev/null
+++ b/telegram_bot/migrations/0010_botsettings_telegram_admins.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.1.7 on 2025-08-15 13:00
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('telegram_bot', '0009_accessrequest_selected_inbounds_and_more'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='botsettings',
+ name='telegram_admins',
+ field=models.ManyToManyField(blank=True, help_text='Users with linked Telegram accounts who will have admin access in the bot', related_name='bot_admin_settings', to=settings.AUTH_USER_MODEL),
+ ),
+ ]
\ No newline at end of file
diff --git a/telegram_bot/models.py b/telegram_bot/models.py
index ef03672..6dbeb1b 100644
--- a/telegram_bot/models.py
+++ b/telegram_bot/models.py
@@ -33,6 +33,12 @@ class BotSettings(models.Model):
default=30,
help_text="Connection timeout in seconds"
)
+ telegram_admins = models.ManyToManyField(
+ User,
+ blank=True,
+ related_name='bot_admin_settings',
+ help_text="Users with linked Telegram accounts who will have admin access in the bot"
+ )
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
diff --git a/vpn/admin.py b/vpn/admin.py
index 970efbd..2b14fb2 100644
--- a/vpn/admin.py
+++ b/vpn/admin.py
@@ -157,12 +157,10 @@ except ImportError:
# Add subscription management to User admin
try:
- from vpn.admin.user import add_subscription_management_to_user
- from django.contrib.admin import site
- for model, admin_instance in site._registry.items():
- if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'):
- add_subscription_management_to_user(admin_instance.__class__)
- break
+ from vpn.admin_xray import add_subscription_management_to_user
+ from vpn.admin.user import UserAdmin
+ add_subscription_management_to_user(UserAdmin)
+ logger.info("✅ Successfully added subscription management to User admin")
except Exception as e:
logger.error(f"Failed to add subscription management: {e}")
diff --git a/vpn/admin/user.py b/vpn/admin/user.py
index 58d5531..ce99f46 100644
--- a/vpn/admin/user.py
+++ b/vpn/admin/user.py
@@ -23,8 +23,8 @@ class UserAdmin(BaseVPNAdmin):
form = UserForm
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
search_fields = ('username', 'hash', 'telegram_user_id', 'telegram_username')
- readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary', 'telegram_info_display')
- inlines = [] # All VPN access info is now in vpn_access_summary
+ readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary', 'telegram_info_display', 'subscription_management_info')
+ inlines = [] # Inlines will be added by subscription management function
fieldsets = (
('User Information', {
@@ -42,6 +42,11 @@ class UserAdmin(BaseVPNAdmin):
'fields': ('user_statistics_summary',),
'classes': ('wide',)
}),
+ ('Subscription Management', {
+ 'fields': ('subscription_management_info',),
+ 'classes': ('wide',),
+ 'description': 'Manage user\'s Xray subscription groups. Use the "User\'s Subscription Groups" section below to add/remove subscriptions.'
+ }),
)
@admin.display(description='VPN Access Summary')
@@ -450,6 +455,99 @@ class UserAdmin(BaseVPNAdmin):
html += ''
return mark_safe(html)
+ @admin.display(description='Subscription Management')
+ def subscription_management_info(self, obj):
+ """Display subscription management information and quick access"""
+ if not obj.pk:
+ return "Save user first to manage subscriptions"
+
+ try:
+ from vpn.models_xray import UserSubscription, SubscriptionGroup
+
+ # Get user's current subscriptions
+ user_subscriptions = UserSubscription.objects.filter(user=obj).select_related('subscription_group')
+ active_subs = user_subscriptions.filter(active=True)
+ inactive_subs = user_subscriptions.filter(active=False)
+
+ # Get available subscription groups
+ all_groups = SubscriptionGroup.objects.filter(is_active=True)
+ subscribed_group_ids = user_subscriptions.values_list('subscription_group_id', flat=True)
+ available_groups = all_groups.exclude(id__in=subscribed_group_ids)
+
+ html = ''
+ html += '
🚀 Xray Subscription Management
'
+
+ # Active subscriptions
+ if active_subs.exists():
+ html += '
'
+ html += '
✅ Active Subscriptions
'
+ for sub in active_subs:
+ html += f'
'
+ html += f'{sub.subscription_group.name}'
+ if sub.subscription_group.description:
+ html += f' - {sub.subscription_group.description[:50]}{"..." if len(sub.subscription_group.description) > 50 else ""}'
+ html += f''
+ html += f'Since: {sub.created_at.strftime("%Y-%m-%d")}'
+ html += f'
'
+ html += '
'
+
+ # Inactive subscriptions
+ if inactive_subs.exists():
+ html += '
'
+ html += '
❌ Inactive Subscriptions
'
+ for sub in inactive_subs:
+ html += f'
'
+ html += f'{sub.subscription_group.name}'
+ html += f'
'
+ html += '
'
+
+ # Available subscription groups
+ if available_groups.exists():
+ html += '
'
+ html += '
➕ Available Subscription Groups
'
+ html += '
'
+ for group in available_groups[:10]: # Limit to avoid clutter
+ html += f''
+ html += f'{group.name}'
+ html += f''
+ if available_groups.count() > 10:
+ html += f'+{available_groups.count() - 10} more...'
+ html += '
'
+ html += '
'
+
+ # Quick access links
+ html += '
'
+ html += '
🔗 Quick Access
'
+ html += '
'
+
+ # Link to standalone UserSubscription admin
+ subscription_admin_url = f"/admin/vpn/usersubscription/?user__id__exact={obj.id}"
+ html += f'
📋 Manage All Subscriptions'
+
+ # Link to add new subscription
+ add_subscription_url = f"/admin/vpn/usersubscription/add/?user={obj.id}"
+ html += f'
➕ Add New Subscription'
+
+ # Link to subscription groups admin
+ groups_admin_url = "/admin/vpn/subscriptiongroup/"
+ html += f'
⚙️ Manage Groups'
+
+ html += '
'
+ html += '
'
+
+ # Statistics
+ total_subs = user_subscriptions.count()
+ if total_subs > 0:
+ html += '
'
+ html += f'📊 Total: {total_subs} subscription(s) | Active: {active_subs.count()} | Inactive: {inactive_subs.count()}'
+ html += '
'
+
+ html += '
'
+ return mark_safe(html)
+
+ except Exception as e:
+ return mark_safe(f'❌ Error loading subscription management: {e}
')
+
@admin.display(description='Allowed servers', ordering='server_count')
def server_count(self, obj):
return obj.server_count
diff --git a/vpn/admin_xray.py b/vpn/admin_xray.py
index 8f62cbb..38c4254 100644
--- a/vpn/admin_xray.py
+++ b/vpn/admin_xray.py
@@ -614,14 +614,15 @@ class UserSubscriptionInline(admin.TabularInline):
# Extension for User admin
-def add_subscription_management_to_user(UserAdmin):
+def add_subscription_management_to_user(UserAdminClass):
"""Add subscription management to existing User admin"""
- # Add inline only - no fieldset or widget
- if hasattr(UserAdmin, 'inlines'):
- UserAdmin.inlines = list(UserAdmin.inlines) + [UserSubscriptionInline]
+ # Add inline to the User admin class
+ if hasattr(UserAdminClass, 'inlines'):
+ if UserSubscriptionInline not in UserAdminClass.inlines:
+ UserAdminClass.inlines = list(UserAdminClass.inlines) + [UserSubscriptionInline]
else:
- UserAdmin.inlines = [UserSubscriptionInline]
+ UserAdminClass.inlines = [UserSubscriptionInline]
# UserSubscription admin will be integrated into unified Subscriptions admin