Add Docker build workflow and enhance admin functionalities

- Introduced a new GitHub Actions workflow for building and pushing the development Docker image.
- Added inline mode handling for user interactions, allowing users to share referral links and view statistics.
- Enhanced admin functionalities with new sections for user management, statistics, and promo code management.
- Implemented CSV export for logs and improved notification services for various events, including new user registrations and payment notifications.
- Updated localization files to support new features and commands.
This commit is contained in:
machka-pasla
2025-08-03 15:59:37 +03:00
parent 35d798a429
commit f0bbe90f80
23 changed files with 2270 additions and 549 deletions
+67 -1
View File
@@ -6,7 +6,11 @@ from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.keyboards.inline.admin_keyboards import get_admin_panel_keyboard
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard, get_stats_monitoring_keyboard,
get_user_management_keyboard, get_ban_management_keyboard,
get_promo_marketing_keyboard, get_system_functions_keyboard
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
@@ -75,6 +79,9 @@ async def admin_panel_actions_callback_handler(
elif action == "create_promo":
await admin_promo_handlers.create_promo_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "create_bulk_promo":
await admin_promo_handlers.create_bulk_promo_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "manage_promos":
await admin_promo_handlers.manage_promo_codes_handler(
callback, i18n_data, settings, session)
@@ -87,6 +94,10 @@ async def admin_panel_actions_callback_handler(
elif action == "unban_user_prompt":
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "users_management":
from . import user_management as admin_user_management_handlers
await admin_user_management_handlers.user_management_menu_handler(
callback, state, i18n_data, settings, session)
elif action == "view_banned":
await admin_user_mgmnt_handlers.view_banned_users_handler(
@@ -121,3 +132,58 @@ async def admin_panel_actions_callback_handler(
f"Unknown admin_action received: {action} from callback {callback.data}"
)
await callback.answer(_("admin_unknown_action"), show_alert=True)
@router.callback_query(F.data.startswith("admin_section:"))
async def admin_section_handler(callback: types.CallbackQuery, state: FSMContext,
settings: Settings, i18n_data: dict, session: AsyncSession):
section = callback.data.split(":")[1]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await callback.answer("Language error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message:
await callback.answer("Error: message context lost.", show_alert=True)
return
try:
if section == "stats_monitoring":
await callback.message.edit_text(
_("admin_stats_and_monitoring_section"),
reply_markup=get_stats_monitoring_keyboard(i18n, current_lang)
)
elif section == "user_management":
await callback.message.edit_text(
_("admin_user_management_section"),
reply_markup=get_user_management_keyboard(i18n, current_lang)
)
elif section == "ban_management":
await callback.message.edit_text(
_("admin_ban_management_section"),
reply_markup=get_ban_management_keyboard(i18n, current_lang)
)
elif section == "promo_marketing":
await callback.message.edit_text(
_("admin_promo_marketing_section"),
reply_markup=get_promo_marketing_keyboard(i18n, current_lang)
)
elif section == "system_functions":
await callback.message.edit_text(
_("admin_system_functions_section"),
reply_markup=get_system_functions_keyboard(i18n, current_lang)
)
else:
await callback.answer(_("admin_unknown_action"), show_alert=True)
return
await callback.answer()
except Exception as e:
logging.error(f"Error handling admin section {section}: {e}")
await callback.message.answer(
_("error_occurred_try_again"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
)
await callback.answer()
+106
View File
@@ -1,6 +1,9 @@
import logging
import math
import re
import csv
import io
from datetime import datetime
from aiogram import Router, F, types, Bot
from aiogram.fsm.context import FSMContext
from typing import Optional, List, Dict, Any
@@ -321,3 +324,106 @@ async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery,
await state.clear()
await display_logs_menu(callback, i18n_data, settings, session)
@router.callback_query(F.data == "admin_logs:export_csv")
async def export_logs_csv_handler(callback: types.CallbackQuery,
settings: Settings, i18n_data: dict,
session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n or not callback.message:
await callback.answer("Error processing CSV export.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer(_(
"admin_logs_csv_export_started",
default="🔄 Начинаю экспорт логов в CSV..."
))
try:
# Get all logs (limit to 10000 for performance)
logs_models = await message_log_dal.get_all_message_logs(
session, limit=10000, offset=0)
if not logs_models:
await callback.message.answer(_(
"admin_logs_csv_no_data",
default="❌ Нет данных для экспорта"
))
return
# Create CSV content
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
# Write header
headers = [
_("admin_csv_header_log_id", default="Log ID"),
_("admin_csv_header_timestamp", default="Timestamp"),
_("admin_csv_header_user_id", default="User ID"),
_("admin_csv_header_telegram_username", default="Telegram Username"),
_("admin_csv_header_telegram_first_name", default="Telegram First Name"),
_("admin_csv_header_event_type", default="Event Type"),
_("admin_csv_header_content", default="Content"),
_("admin_csv_header_is_admin_event", default="Is Admin Event"),
_("admin_csv_header_target_user_id", default="Target User ID"),
_("admin_csv_header_raw_update_preview", default="Raw Update Preview")
]
csv_writer.writerow(headers)
# Write data rows
for log in logs_models:
# Format timestamp
timestamp_str = log.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if log.timestamp else ''
# Clean content and raw_update_preview (remove newlines and quotes for CSV)
content_clean = (log.content or '').replace('\n', ' ').replace('\r', ' ').strip()
raw_update_clean = (log.raw_update_preview or '').replace('\n', ' ').replace('\r', ' ').strip()
row = [
log.log_id or '',
timestamp_str,
log.user_id or '',
log.telegram_username or '',
log.telegram_first_name or '',
log.event_type or '',
content_clean,
'Yes' if log.is_admin_event else 'No',
log.target_user_id or '',
raw_update_clean
]
csv_writer.writerow(row)
# Create file
csv_content = csv_buffer.getvalue()
csv_buffer.close()
# Generate filename with current timestamp
now = datetime.now()
filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv"
# Send as document
csv_file = types.BufferedInputFile(
csv_content.encode('utf-8-sig'), # BOM for Excel compatibility
filename=filename
)
await callback.message.answer_document(
csv_file,
caption=_(
"admin_logs_csv_export_success",
default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}",
count=len(logs_models),
date=now.strftime('%Y-%m-%d %H:%M:%S')
)
)
except Exception as e:
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
await callback.message.answer(_(
"admin_logs_csv_export_failed",
default="❌ Ошибка при экспорте логов: {error}",
error=str(e)
))
+219
View File
@@ -1,4 +1,6 @@
import logging
import random
import string
from aiogram import Router, F, types, Bot
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
@@ -340,6 +342,7 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict,
StateFilter(
AdminStates.waiting_for_promo_details,
AdminStates.waiting_for_promo_edit_details,
AdminStates.waiting_for_bulk_promo_details,
),
)
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
@@ -365,3 +368,219 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
await callback.answer(_("admin_action_cancelled_default_alert"))
await state.clear()
async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error preparing bulk promo creation.",
show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
prompt_text = _(
"admin_bulk_promo_create_prompt",
default="📦 Массовое создание промокодов\n\nВведите данные в формате:\n<количество> <бонусные_дни> <максимальное_использование> [дни_действия]\n\nПример: 50 7 100 30\n(создаст 50 промокодов на 7 дней с лимитом 100 активаций каждый, действующих 30 дней)",
example_format="50 7 100 30"
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
except Exception as e:
logging.warning(
f"Could not edit message for bulk promo prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
await callback.answer()
await state.set_state(AdminStates.waiting_for_bulk_promo_details)
def generate_unique_promo_code(length: int = 8) -> str:
"""Generate a unique promotional code"""
characters = string.ascii_uppercase + string.digits
# Exclude confusing characters
characters = characters.replace('0', '').replace('O', '').replace('1', '').replace('I', '').replace('L', '')
return ''.join(random.choice(characters) for _ in range(length))
@router.message(AdminStates.waiting_for_bulk_promo_details, F.text)
async def process_bulk_promo_details_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not message.text:
await message.answer(_("admin_promo_invalid_format"))
return
parts = message.text.strip().split()
if not (3 <= len(parts) <= 4):
await message.answer(_(
"admin_bulk_promo_invalid_format",
default="❌ Неверный формат. Используйте: <количество> <бонусные_дни> <максимальное_использование> [дни_действия]"
))
return
try:
count = int(parts[0])
bonus_days = int(parts[1])
max_activations = int(parts[2])
if count <= 0 or count > 1000:
raise ValueError(_("admin_bulk_promo_invalid_count", default="Количество должно быть от 1 до 1000"))
if bonus_days <= 0 or max_activations <= 0:
raise ValueError(_("admin_promo_invalid_bonus_or_activations"))
valid_until_date: Optional[datetime] = None
valid_until_str_display = _("admin_promo_valid_indefinitely")
if len(parts) == 4:
valid_days_from_now = int(parts[3])
if valid_days_from_now <= 0:
raise ValueError(_("admin_promo_invalid_validity_days"))
valid_until_date = datetime.now(
timezone.utc) + timedelta(days=valid_days_from_now)
valid_until_str_display = _(
"admin_promo_valid_until_display",
date=valid_until_date.strftime('%Y-%m-%d'))
except ValueError as e:
await message.answer(_(
"admin_bulk_promo_invalid_values",
default="❌ Неверные значения: {error}",
error=str(e)
))
return
except Exception as e_parse:
logging.error(f"Error parsing bulk promo details '{message.text}': {e_parse}")
await message.answer(_("admin_promo_invalid_format_general"))
return
admin_id = message.from_user.id if message.from_user else 0
# Generate unique codes and create promo codes
created_codes = []
failed_codes = []
await message.answer(_(
"admin_bulk_promo_creating",
default="🔄 Создаю {count} промокодов...",
count=count
))
for i in range(count):
try:
# Generate unique code
attempts = 0
while attempts < 10: # Max 10 attempts to generate unique code
code = generate_unique_promo_code()
# Check if code already exists
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code)
if not existing_promo:
break
attempts += 1
if attempts >= 10:
failed_codes.append(f"Failed to generate unique code #{i+1}")
continue
promo_data_to_create = {
"code": code,
"bonus_days": bonus_days,
"max_activations": max_activations,
"created_by_admin_id": admin_id,
"valid_until": valid_until_date,
"is_active": True,
"current_activations": 0
}
created_promo = await promo_code_dal.create_promo_code(
session, promo_data_to_create)
if created_promo:
created_codes.append(created_promo.code)
else:
failed_codes.append(f"Failed to create code #{i+1}")
except Exception as e:
logging.error(f"Error creating bulk promo code #{i+1}: {e}")
failed_codes.append(f"Error creating code #{i+1}: {str(e)}")
try:
await session.commit()
# Prepare success message
success_text_parts = [
_(
"admin_bulk_promo_created_success",
default="✅ Массовое создание завершено!\n\n📦 Создано промокодов: {created_count}\n💎 Бонусных дней: {bonus_days}\n🔄 Макс. активаций каждого: {max_activations}\n⏰ Действительны до: {valid_until}",
created_count=len(created_codes),
bonus_days=bonus_days,
max_activations=max_activations,
valid_until=valid_until_str_display
)
]
if failed_codes:
success_text_parts.append(f"\n❌ Ошибок: {len(failed_codes)}")
if created_codes:
# Show first few codes as examples
codes_to_show = created_codes[:10] # Show first 10
success_text_parts.append(f"\n📝 Примеры созданных кодов:")
success_text_parts.append("\n".join([f"{code}" for code in codes_to_show]))
if len(created_codes) > 10:
success_text_parts.append(f"... и еще {len(created_codes) - 10} кодов")
# Send codes as a file if many were created
if len(created_codes) > 20:
try:
codes_text = "\n".join(created_codes)
codes_file = types.BufferedInputFile(
codes_text.encode('utf-8'),
filename=f"promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
)
await message.answer_document(
codes_file,
caption=_(
"admin_bulk_promo_codes_file",
default="📄 Файл со всеми созданными промокодами"
)
)
except Exception as e:
logging.error(f"Failed to send codes file: {e}")
final_text = "\n".join(success_text_parts)
await message.answer(
final_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
except Exception as e_db_commit:
await session.rollback()
logging.error(f"Failed to commit bulk promo codes creation: {e_db_commit}", exc_info=True)
await message.answer(_(
"admin_bulk_promo_creation_failed",
default="❌ Ошибка при сохранении промокодов в базу данных"
))
await state.clear()
+45 -6
View File
@@ -29,13 +29,52 @@ async def show_statistics_handler(callback: types.CallbackQuery,
stats_text_parts = [f"<b>{_('admin_stats_header')}</b>"]
user_stats_dict = await user_dal.get_user_count_stats_dal(session)
# Enhanced user statistics
user_stats = await user_dal.get_enhanced_user_statistics(session)
stats_text_parts.append(
_("admin_stats_users",
total_users=user_stats_dict.get("total_users", 0),
banned_users=user_stats_dict.get("banned_users", 0),
active_subs=user_stats_dict.get("users_with_active_subscriptions",
0)))
f"\n<b>👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}</b>"
)
stats_text_parts.append(
f"📊 Всего: <b>{user_stats['total_users']}</b>"
)
stats_text_parts.append(
f"📈 Активных сегодня: <b>{user_stats['active_today']}</b>"
)
stats_text_parts.append(
f"💳 С платной подпиской: <b>{user_stats['paid_subscriptions']}</b>"
)
stats_text_parts.append(
f"🆓 На пробном периоде: <b>{user_stats['trial_users']}</b>"
)
stats_text_parts.append(
f"😴 Неактивных: <b>{user_stats['inactive_users']}</b>"
)
stats_text_parts.append(
f"🚫 Заблокированных: <b>{user_stats['banned_users']}</b>"
)
stats_text_parts.append(
f"🎁 Привлечено по реферальной программе: <b>{user_stats['referral_users']}</b>"
)
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
stats_text_parts.append(
f"\n<b>💰 {_('admin_financial_stats_header', default='Финансовая статистика')}</b>"
)
stats_text_parts.append(
f"📅 За сегодня: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} платежей)"
)
stats_text_parts.append(
f"📅 За неделю: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"📅 За месяц: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"🏆 За все время: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
)
last_payments_models: List[
Payment] = await payment_dal.get_recent_payment_logs_with_user(session,
File diff suppressed because it is too large Load Diff