smart-bot-factory 0.3.2__py3-none-any.whl → 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of smart-bot-factory might be problematic. Click here for more details.
- smart_bot_factory/admin/__init__.py +2 -0
- smart_bot_factory/admin/admin_events.py +932 -0
- smart_bot_factory/admin/admin_logic.py +59 -12
- smart_bot_factory/aiogram_calendar/__init__.py +6 -0
- smart_bot_factory/aiogram_calendar/common.py +70 -0
- smart_bot_factory/aiogram_calendar/dialog_calendar.py +197 -0
- smart_bot_factory/aiogram_calendar/schemas.py +78 -0
- smart_bot_factory/aiogram_calendar/simple_calendar.py +180 -0
- smart_bot_factory/analytics/analytics_manager.py +42 -5
- smart_bot_factory/cli.py +1 -1
- smart_bot_factory/core/bot_utils.py +17 -16
- smart_bot_factory/core/decorators.py +218 -4
- smart_bot_factory/core/states.py +12 -0
- smart_bot_factory/creation/bot_builder.py +54 -2
- smart_bot_factory/handlers/handlers.py +10 -3
- smart_bot_factory/integrations/supabase_client.py +422 -37
- smart_bot_factory/utm_link_generator.py +13 -3
- {smart_bot_factory-0.3.2.dist-info → smart_bot_factory-0.3.4.dist-info}/METADATA +3 -1
- {smart_bot_factory-0.3.2.dist-info → smart_bot_factory-0.3.4.dist-info}/RECORD +22 -18
- smart_bot_factory/table/database_structure.sql +0 -57
- smart_bot_factory/table/schema.sql +0 -1094
- {smart_bot_factory-0.3.2.dist-info → smart_bot_factory-0.3.4.dist-info}/WHEEL +0 -0
- {smart_bot_factory-0.3.2.dist-info → smart_bot_factory-0.3.4.dist-info}/entry_points.txt +0 -0
- {smart_bot_factory-0.3.2.dist-info → smart_bot_factory-0.3.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
# Обработчики для создания админских событий
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from dateutil.relativedelta import relativedelta
|
|
8
|
+
from aiogram import Router, F
|
|
9
|
+
from aiogram.filters import Command
|
|
10
|
+
from aiogram.fsm.context import FSMContext
|
|
11
|
+
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
|
12
|
+
from aiogram_media_group import media_group_handler
|
|
13
|
+
from ..aiogram_calendar import SimpleCalendar, SimpleCalendarCallback
|
|
14
|
+
import pytz
|
|
15
|
+
import uuid
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
TEMP_DIR = "temp_event_files"
|
|
19
|
+
|
|
20
|
+
def generate_file_id() -> str:
|
|
21
|
+
"""Генерирует уникальный ID для файла"""
|
|
22
|
+
return f"file_{uuid.uuid4().hex}"
|
|
23
|
+
|
|
24
|
+
def ensure_temp_dir():
|
|
25
|
+
"""Создает временную папку если её нет"""
|
|
26
|
+
if not os.path.exists(TEMP_DIR):
|
|
27
|
+
os.makedirs(TEMP_DIR)
|
|
28
|
+
logger.info(f"📁 Создана временная папка {TEMP_DIR}")
|
|
29
|
+
|
|
30
|
+
async def cleanup_temp_files(state: FSMContext = None):
|
|
31
|
+
"""Очистка временных файлов события"""
|
|
32
|
+
# Удаляем все файлы из временной папки
|
|
33
|
+
if os.path.exists(TEMP_DIR):
|
|
34
|
+
try:
|
|
35
|
+
shutil.rmtree(TEMP_DIR)
|
|
36
|
+
logger.info(f"🗑️ Удалена папка {TEMP_DIR}")
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"❌ Ошибка при удалении {TEMP_DIR}: {e}")
|
|
39
|
+
|
|
40
|
+
# Очищаем информацию о файлах в состоянии
|
|
41
|
+
if state:
|
|
42
|
+
try:
|
|
43
|
+
data = await state.get_data()
|
|
44
|
+
if 'files' in data:
|
|
45
|
+
data['files'] = []
|
|
46
|
+
await state.set_data(data)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error(f"❌ Ошибка при очистке состояния: {e}")
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
# Московская временная зона
|
|
53
|
+
MOSCOW_TZ = pytz.timezone('Europe/Moscow')
|
|
54
|
+
|
|
55
|
+
# Импортируем состояния
|
|
56
|
+
from ..core.states import AdminStates
|
|
57
|
+
|
|
58
|
+
# Создаем роутер для админских событий
|
|
59
|
+
admin_events_router = Router()
|
|
60
|
+
|
|
61
|
+
def setup_admin_events_handlers(dp):
|
|
62
|
+
"""Настройка обработчиков админских событий"""
|
|
63
|
+
dp.include_router(admin_events_router)
|
|
64
|
+
|
|
65
|
+
@admin_events_router.message(Command(commands=["создать_событие", "create_event"]))
|
|
66
|
+
async def create_event_start(message: Message, state: FSMContext):
|
|
67
|
+
"""Начало создания события"""
|
|
68
|
+
from ..handlers.handlers import get_global_var
|
|
69
|
+
admin_manager = get_global_var('admin_manager')
|
|
70
|
+
|
|
71
|
+
if not admin_manager.is_admin(message.from_user.id):
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
await state.set_state(AdminStates.create_event_name)
|
|
75
|
+
|
|
76
|
+
await message.answer(
|
|
77
|
+
"📝 **Введите название события**\n\n"
|
|
78
|
+
"💡 _По этому названию вы сможете:\n"
|
|
79
|
+
"• Найти событие в списке\n"
|
|
80
|
+
"• Отменить его при необходимости_",
|
|
81
|
+
parse_mode='Markdown'
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@admin_events_router.message(AdminStates.create_event_name)
|
|
85
|
+
async def process_event_name(message: Message, state: FSMContext):
|
|
86
|
+
"""Обработка названия события"""
|
|
87
|
+
from ..handlers.handlers import get_global_var
|
|
88
|
+
|
|
89
|
+
event_name = message.text.strip()
|
|
90
|
+
|
|
91
|
+
if not event_name:
|
|
92
|
+
await message.answer("❌ Название не может быть пустым. Попробуйте еще раз:")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Проверяем уникальность названия (только среди активных событий)
|
|
96
|
+
supabase_client = get_global_var('supabase_client')
|
|
97
|
+
name_exists = await supabase_client.check_event_name_exists(event_name)
|
|
98
|
+
|
|
99
|
+
if name_exists:
|
|
100
|
+
await message.answer(
|
|
101
|
+
f"⚠️ **Событие с названием «{event_name}» уже существует и находится в статусе ожидания!**\n\n"
|
|
102
|
+
f"Пожалуйста, выберите другое название или дождитесь выполнения/отмены существующего события.\n\n"
|
|
103
|
+
f"💡 _Вы можете использовать это же название после завершения или отмены текущего события._",
|
|
104
|
+
parse_mode='Markdown'
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# Сохраняем название
|
|
109
|
+
await state.update_data(event_name=event_name)
|
|
110
|
+
await state.set_state(AdminStates.create_event_date)
|
|
111
|
+
|
|
112
|
+
# Показываем календарь для выбора даты
|
|
113
|
+
calendar = SimpleCalendar(locale='ru', today_btn='Сегодня', cancel_btn='Отмена')
|
|
114
|
+
# Ограничиваем выбор датами от вчера до +12 месяцев (чтобы сегодня был доступен)
|
|
115
|
+
calendar.set_dates_range(datetime.now() + relativedelta(days=-1), datetime.now() + relativedelta(months=+12))
|
|
116
|
+
calendar_markup = await calendar.start_calendar()
|
|
117
|
+
|
|
118
|
+
await message.answer(
|
|
119
|
+
f"✅ Название события: **{event_name}**\n\n"
|
|
120
|
+
"📅 Выберите дату отправки:",
|
|
121
|
+
reply_markup=calendar_markup,
|
|
122
|
+
parse_mode='Markdown'
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@admin_events_router.callback_query(SimpleCalendarCallback.filter(), AdminStates.create_event_date)
|
|
126
|
+
async def process_event_date(callback_query: CallbackQuery, callback_data: dict, state: FSMContext):
|
|
127
|
+
"""Обработка выбора даты"""
|
|
128
|
+
calendar = SimpleCalendar(locale='ru', cancel_btn='Отмена', today_btn='Сегодня')
|
|
129
|
+
|
|
130
|
+
# Ограничиваем выбор датами от вчера до +12 месяцев (чтобы сегодня был доступен)
|
|
131
|
+
calendar.set_dates_range(datetime.now() + relativedelta(days=-1), datetime.now() + relativedelta(months=+12))
|
|
132
|
+
selected, date = await calendar.process_selection(callback_query, callback_data)
|
|
133
|
+
|
|
134
|
+
if selected == 'cancel':
|
|
135
|
+
# Нажата кнопка "Отмена"
|
|
136
|
+
await state.clear()
|
|
137
|
+
await callback_query.message.edit_text(
|
|
138
|
+
"❌ Создание события отменено",
|
|
139
|
+
parse_mode='Markdown'
|
|
140
|
+
)
|
|
141
|
+
elif selected:
|
|
142
|
+
# Дата выбрана успешно (True или обычный выбор)
|
|
143
|
+
await state.update_data(event_date=date.strftime('%Y-%m-%d'))
|
|
144
|
+
await state.set_state(AdminStates.create_event_time)
|
|
145
|
+
|
|
146
|
+
await callback_query.message.edit_text(
|
|
147
|
+
f"✅ Дата: **{date.strftime('%d.%m.%Y')}**\n\n"
|
|
148
|
+
"⏰ Введите время отправки в формате ЧЧ:ММ\n"
|
|
149
|
+
"_(Например: 14:30)_",
|
|
150
|
+
parse_mode='Markdown'
|
|
151
|
+
)
|
|
152
|
+
# Если selected is False/None - это навигация по календарю, ничего не делаем
|
|
153
|
+
# Календарь сам обновится при навигации
|
|
154
|
+
|
|
155
|
+
@admin_events_router.message(AdminStates.create_event_time)
|
|
156
|
+
async def process_event_time(message: Message, state: FSMContext):
|
|
157
|
+
"""Обработка времени события"""
|
|
158
|
+
time_str = message.text.strip()
|
|
159
|
+
|
|
160
|
+
# Валидация формата времени
|
|
161
|
+
try:
|
|
162
|
+
event_time = datetime.strptime(time_str, '%H:%M').time()
|
|
163
|
+
except ValueError:
|
|
164
|
+
await message.answer(
|
|
165
|
+
"❌ Неверный формат времени. Используйте формат HH:MM\n"
|
|
166
|
+
"_(Например: 14:30)_",
|
|
167
|
+
parse_mode='Markdown'
|
|
168
|
+
)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# Сохраняем время
|
|
172
|
+
await state.update_data(event_time=time_str)
|
|
173
|
+
await state.set_state(AdminStates.create_event_segment)
|
|
174
|
+
|
|
175
|
+
# Получаем все доступные сегменты
|
|
176
|
+
from ..handlers.handlers import get_global_var
|
|
177
|
+
supabase_client = get_global_var('supabase_client')
|
|
178
|
+
|
|
179
|
+
segments = await supabase_client.get_all_segments()
|
|
180
|
+
|
|
181
|
+
# Создаем клавиатуру с сегментами
|
|
182
|
+
keyboard = []
|
|
183
|
+
|
|
184
|
+
# Большая кнопка "Отправить всем" на два столбца
|
|
185
|
+
keyboard.append([
|
|
186
|
+
InlineKeyboardButton(text="📢 Отправить всем", callback_data="segment:all")
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
# Кнопки сегментов (по 2 в ряд)
|
|
190
|
+
if segments:
|
|
191
|
+
for i in range(0, len(segments), 2):
|
|
192
|
+
row = []
|
|
193
|
+
row.append(InlineKeyboardButton(
|
|
194
|
+
text=f"👥 {segments[i]}",
|
|
195
|
+
callback_data=f"segment:{segments[i]}"
|
|
196
|
+
))
|
|
197
|
+
if i + 1 < len(segments):
|
|
198
|
+
row.append(InlineKeyboardButton(
|
|
199
|
+
text=f"👥 {segments[i+1]}",
|
|
200
|
+
callback_data=f"segment:{segments[i+1]}"
|
|
201
|
+
))
|
|
202
|
+
keyboard.append(row)
|
|
203
|
+
|
|
204
|
+
markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
|
|
205
|
+
|
|
206
|
+
data = await state.get_data()
|
|
207
|
+
await message.answer(
|
|
208
|
+
f"✅ Время: **{time_str}**\n\n"
|
|
209
|
+
f"👥 Выберите сегмент пользователей для отправки:\n"
|
|
210
|
+
f"_(Найдено сегментов: {len(segments)})_",
|
|
211
|
+
reply_markup=markup,
|
|
212
|
+
parse_mode='Markdown'
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
@admin_events_router.callback_query(F.data.startswith("segment:"), AdminStates.create_event_segment)
|
|
216
|
+
async def process_event_segment(callback_query: CallbackQuery, state: FSMContext):
|
|
217
|
+
"""Обработка выбора сегмента"""
|
|
218
|
+
segment_data = callback_query.data.split(":", 1)[1]
|
|
219
|
+
|
|
220
|
+
# segment_data = "all" или название сегмента
|
|
221
|
+
segment_name = None if segment_data == "all" else segment_data
|
|
222
|
+
segment_display = "Все пользователи" if segment_data == "all" else segment_data
|
|
223
|
+
|
|
224
|
+
# Сохраняем сегмент
|
|
225
|
+
await state.update_data(segment=segment_name, segment_display=segment_display)
|
|
226
|
+
await state.set_state(AdminStates.create_event_message)
|
|
227
|
+
|
|
228
|
+
await callback_query.message.edit_text(
|
|
229
|
+
f"✅ Сегмент: **{segment_display}**\n\n"
|
|
230
|
+
"💬 **Введите сообщение для пользователей**\n\n"
|
|
231
|
+
"📸 _Вы можете прикрепить к сообщению **фото или видео** — они будут отправлены пользователям в том же порядке_\n\n"
|
|
232
|
+
"📄 _Если нужно добавить **PDF или другие документы**, вы сможете это сделать на следующем шаге_",
|
|
233
|
+
parse_mode='Markdown'
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
@admin_events_router.message(AdminStates.create_event_message, F.media_group_id, F.content_type.in_({'photo', 'video'}))
|
|
237
|
+
@media_group_handler
|
|
238
|
+
async def handle_album(messages: list[Message], state: FSMContext):
|
|
239
|
+
"""Обработка альбома фотографий/видео"""
|
|
240
|
+
if not messages:
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
# Берем текст из первого сообщения с подписью
|
|
244
|
+
event_message = next((msg.caption for msg in messages if msg.caption), None)
|
|
245
|
+
if not event_message:
|
|
246
|
+
await messages[0].answer(
|
|
247
|
+
"❌ **Добавьте подпись к альбому**\n\n"
|
|
248
|
+
"💡 _Отправьте альбом заново с текстом сообщения в подписи к любой фотографии_",
|
|
249
|
+
parse_mode='Markdown'
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
# Сохраняем сообщение
|
|
254
|
+
await state.update_data(event_message=event_message)
|
|
255
|
+
|
|
256
|
+
# Показываем сообщение о начале загрузки
|
|
257
|
+
await messages[0].answer(
|
|
258
|
+
"📸 **Загружаю файлы...**\n\n"
|
|
259
|
+
"💡 _Дождитесь загрузки всех файлов из альбома_",
|
|
260
|
+
parse_mode='Markdown'
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Сохраняем все файлы
|
|
264
|
+
from ..handlers.handlers import get_global_var
|
|
265
|
+
bot = get_global_var('bot')
|
|
266
|
+
ensure_temp_dir()
|
|
267
|
+
|
|
268
|
+
data = await state.get_data()
|
|
269
|
+
files = data.get('files', [])
|
|
270
|
+
|
|
271
|
+
for i, message in enumerate(messages, 1):
|
|
272
|
+
try:
|
|
273
|
+
if message.photo:
|
|
274
|
+
photo = message.photo[-1]
|
|
275
|
+
file = await bot.get_file(photo.file_id)
|
|
276
|
+
file_name = f"photo_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{i}.jpg"
|
|
277
|
+
file_path = os.path.join(TEMP_DIR, file_name)
|
|
278
|
+
await bot.download_file(file.file_path, file_path)
|
|
279
|
+
|
|
280
|
+
files.append({
|
|
281
|
+
'type': 'photo',
|
|
282
|
+
'file_path': file_path,
|
|
283
|
+
'name': file_name,
|
|
284
|
+
'stage': 'with_message',
|
|
285
|
+
'has_caption': bool(message.caption),
|
|
286
|
+
'order': i # Сохраняем порядок в альбоме
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
elif message.video:
|
|
290
|
+
file = await bot.get_file(message.video.file_id)
|
|
291
|
+
file_name = message.video.file_name or f"{message.video.file_id}.mp4"
|
|
292
|
+
file_path = os.path.join(TEMP_DIR, file_name)
|
|
293
|
+
await bot.download_file(file.file_path, file_path)
|
|
294
|
+
|
|
295
|
+
files.append({
|
|
296
|
+
'type': 'video',
|
|
297
|
+
'file_path': file_path,
|
|
298
|
+
'name': file_name,
|
|
299
|
+
'stage': 'with_message',
|
|
300
|
+
'has_caption': bool(message.caption),
|
|
301
|
+
'order': i # Сохраняем порядок в альбоме
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
# Показываем прогресс каждые 5 файлов
|
|
305
|
+
if i % 5 == 0:
|
|
306
|
+
await messages[0].answer(
|
|
307
|
+
f"📸 Загружено файлов: {i}/{len(messages)}",
|
|
308
|
+
parse_mode='Markdown'
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.error(f"❌ Ошибка загрузки файла {i}: {e}")
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
# Сохраняем файлы
|
|
316
|
+
await state.update_data(files=files)
|
|
317
|
+
|
|
318
|
+
# Переходим к следующему этапу
|
|
319
|
+
await state.set_state(AdminStates.create_event_files)
|
|
320
|
+
|
|
321
|
+
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
322
|
+
[InlineKeyboardButton(text="➡️ Продолжить без файлов", callback_data="files:skip")]
|
|
323
|
+
])
|
|
324
|
+
|
|
325
|
+
await messages[0].answer(
|
|
326
|
+
f"✅ **Сообщение и {len(files)} файлов сохранены!**\n\n"
|
|
327
|
+
"📎 **Дополнительные файлы**\n\n"
|
|
328
|
+
"Теперь вы можете отправить:\n"
|
|
329
|
+
"📄 PDF документы\n"
|
|
330
|
+
"📁 Файлы любых форматов\n"
|
|
331
|
+
"🎥 Дополнительные видео\n"
|
|
332
|
+
"🖼 Дополнительные фото\n\n"
|
|
333
|
+
"💡 _Можно отправить несколько файлов по очереди_\n\n"
|
|
334
|
+
"Или нажмите кнопку, если дополнительных файлов нет:",
|
|
335
|
+
reply_markup=keyboard,
|
|
336
|
+
parse_mode='Markdown'
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
@admin_events_router.message(AdminStates.create_event_message, F.text | F.photo | F.video)
|
|
340
|
+
async def process_event_message(message: Message, state: FSMContext):
|
|
341
|
+
"""Обработка одиночного сообщения с текстом/фото/видео"""
|
|
342
|
+
# Если это часть альбома - пропускаем, его обработает другой handler
|
|
343
|
+
if message.media_group_id:
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
event_message = message.text or message.caption or ""
|
|
347
|
+
|
|
348
|
+
# Проверяем текст
|
|
349
|
+
if not event_message.strip():
|
|
350
|
+
await message.answer("❌ Сообщение не может быть пустым. Попробуйте еще раз:")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
# Сохраняем сообщение
|
|
354
|
+
await state.update_data(event_message=event_message)
|
|
355
|
+
|
|
356
|
+
# Если есть медиа, сохраняем его
|
|
357
|
+
data = await state.get_data()
|
|
358
|
+
files = data.get('files', [])
|
|
359
|
+
|
|
360
|
+
if message.photo or message.video:
|
|
361
|
+
import os
|
|
362
|
+
from ..handlers.handlers import get_global_var
|
|
363
|
+
bot = get_global_var('bot')
|
|
364
|
+
|
|
365
|
+
# Создаем временную папку
|
|
366
|
+
ensure_temp_dir()
|
|
367
|
+
|
|
368
|
+
if message.photo:
|
|
369
|
+
# Скачиваем фото
|
|
370
|
+
photo = message.photo[-1] # Берем самое большое фото
|
|
371
|
+
file = await bot.get_file(photo.file_id)
|
|
372
|
+
file_name = f"photo_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jpg"
|
|
373
|
+
file_path = os.path.join(TEMP_DIR, file_name)
|
|
374
|
+
await bot.download_file(file.file_path, file_path)
|
|
375
|
+
|
|
376
|
+
files.append({
|
|
377
|
+
'type': 'photo',
|
|
378
|
+
'file_path': file_path,
|
|
379
|
+
'name': file_name,
|
|
380
|
+
'stage': 'with_message',
|
|
381
|
+
'has_caption': bool(message.caption)
|
|
382
|
+
})
|
|
383
|
+
logger.info(f"Фото сохранено: {file_path} (with_message)")
|
|
384
|
+
|
|
385
|
+
elif message.video:
|
|
386
|
+
# Скачиваем видео
|
|
387
|
+
file = await bot.get_file(message.video.file_id)
|
|
388
|
+
file_name = message.video.file_name or f"{message.video.file_id}.mp4"
|
|
389
|
+
file_path = os.path.join(TEMP_DIR, file_name)
|
|
390
|
+
await bot.download_file(file.file_path, file_path)
|
|
391
|
+
|
|
392
|
+
files.append({
|
|
393
|
+
'type': 'video',
|
|
394
|
+
'file_path': file_path,
|
|
395
|
+
'name': file_name,
|
|
396
|
+
'stage': 'with_message',
|
|
397
|
+
'has_caption': bool(message.caption)
|
|
398
|
+
})
|
|
399
|
+
logger.info(f"Видео сохранено: {file_path} (with_message)")
|
|
400
|
+
|
|
401
|
+
await state.update_data(files=files)
|
|
402
|
+
|
|
403
|
+
# Переходим к добавлению файлов
|
|
404
|
+
await state.set_state(AdminStates.create_event_files)
|
|
405
|
+
|
|
406
|
+
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
407
|
+
[InlineKeyboardButton(text="➡️ Продолжить без файлов", callback_data="files:skip")]
|
|
408
|
+
])
|
|
409
|
+
|
|
410
|
+
await message.answer(
|
|
411
|
+
"✅ **Сообщение сохранено!**\n\n"
|
|
412
|
+
"📎 **Дополнительные файлы**\n\n"
|
|
413
|
+
"Теперь вы можете отправить:\n"
|
|
414
|
+
"📄 PDF документы\n"
|
|
415
|
+
"📁 Файлы любых форматов\n"
|
|
416
|
+
"🎥 Дополнительные видео\n"
|
|
417
|
+
"🖼 Дополнительные фото\n\n"
|
|
418
|
+
"💡 _Можно отправить несколько файлов по очереди_\n\n"
|
|
419
|
+
"Или нажмите кнопку, если дополнительных файлов нет:",
|
|
420
|
+
reply_markup=keyboard,
|
|
421
|
+
parse_mode='Markdown'
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@admin_events_router.message(AdminStates.create_event_files, F.document | F.photo | F.video)
|
|
426
|
+
async def process_event_files(message: Message, state: FSMContext):
|
|
427
|
+
"""Обработка файлов для события"""
|
|
428
|
+
import os
|
|
429
|
+
from ..handlers.handlers import get_global_var
|
|
430
|
+
|
|
431
|
+
data = await state.get_data()
|
|
432
|
+
files = data.get('files', [])
|
|
433
|
+
bot = get_global_var('bot')
|
|
434
|
+
|
|
435
|
+
# Создаем временную папку
|
|
436
|
+
ensure_temp_dir()
|
|
437
|
+
|
|
438
|
+
# Скачиваем и добавляем файл в список
|
|
439
|
+
if message.document:
|
|
440
|
+
file = await bot.get_file(message.document.file_id)
|
|
441
|
+
file_path = os.path.join(TEMP_DIR, message.document.file_name)
|
|
442
|
+
await bot.download_file(file.file_path, file_path)
|
|
443
|
+
|
|
444
|
+
files.append({
|
|
445
|
+
'type': 'document',
|
|
446
|
+
'file_path': file_path,
|
|
447
|
+
'name': message.document.file_name,
|
|
448
|
+
'stage': 'after_message'
|
|
449
|
+
})
|
|
450
|
+
logger.info(f"Документ сохранен: {file_path} (after_message)")
|
|
451
|
+
|
|
452
|
+
elif message.photo:
|
|
453
|
+
photo = message.photo[-1]
|
|
454
|
+
file = await bot.get_file(photo.file_id)
|
|
455
|
+
file_name = f"photo_{datetime.now().strftime('%H%M%S')}.jpg"
|
|
456
|
+
file_path = os.path.join(TEMP_DIR, file_name)
|
|
457
|
+
await bot.download_file(file.file_path, file_path)
|
|
458
|
+
|
|
459
|
+
files.append({
|
|
460
|
+
'type': 'photo',
|
|
461
|
+
'file_path': file_path,
|
|
462
|
+
'name': file_name,
|
|
463
|
+
'stage': 'after_message'
|
|
464
|
+
})
|
|
465
|
+
logger.info(f"Фото сохранено: {file_path} (after_message)")
|
|
466
|
+
|
|
467
|
+
elif message.video:
|
|
468
|
+
file = await bot.get_file(message.video.file_id)
|
|
469
|
+
file_name = message.video.file_name or f"{message.video.file_id}.mp4" # Используем оригинальное имя или file_id
|
|
470
|
+
file_path = os.path.join(TEMP_DIR, file_name)
|
|
471
|
+
await bot.download_file(file.file_path, file_path)
|
|
472
|
+
|
|
473
|
+
files.append({
|
|
474
|
+
'type': 'video',
|
|
475
|
+
'file_path': file_path,
|
|
476
|
+
'name': file_name,
|
|
477
|
+
'stage': 'after_message'
|
|
478
|
+
})
|
|
479
|
+
logger.info(f"Видео сохранено: {file_path} (after_message)")
|
|
480
|
+
|
|
481
|
+
await state.update_data(files=files)
|
|
482
|
+
|
|
483
|
+
# Кнопка для завершения добавления файлов
|
|
484
|
+
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
485
|
+
[InlineKeyboardButton(text="✅ Завершить добавление файлов", callback_data="files:done")]
|
|
486
|
+
])
|
|
487
|
+
|
|
488
|
+
await message.answer(
|
|
489
|
+
f"✅ Файл добавлен (всего: {len(files)})\n\n"
|
|
490
|
+
"Отправьте еще файлы или нажмите кнопку для завершения:",
|
|
491
|
+
reply_markup=keyboard
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
@admin_events_router.callback_query(F.data.startswith("files:"), AdminStates.create_event_files)
|
|
495
|
+
async def process_files_action(callback_query: CallbackQuery, state: FSMContext):
|
|
496
|
+
"""Обработка действий с файлами"""
|
|
497
|
+
action = callback_query.data.split(":", 1)[1]
|
|
498
|
+
|
|
499
|
+
data = await state.get_data()
|
|
500
|
+
files = data.get('files', [])
|
|
501
|
+
|
|
502
|
+
if action == "skip" and not files:
|
|
503
|
+
# Если файлов нет и нажали "Продолжить без файлов" - очищаем
|
|
504
|
+
files = []
|
|
505
|
+
await state.update_data(files=files)
|
|
506
|
+
elif action == "skip":
|
|
507
|
+
# Если файлы уже есть - оставляем их
|
|
508
|
+
logger.info(f"Продолжаем с {len(files)} существующими файлами")
|
|
509
|
+
|
|
510
|
+
# Переход к подтверждению
|
|
511
|
+
await state.set_state(AdminStates.create_event_confirm)
|
|
512
|
+
|
|
513
|
+
# Формируем дату и время для отображения (московское время)
|
|
514
|
+
event_date = data.get('event_date')
|
|
515
|
+
event_time = data.get('event_time')
|
|
516
|
+
naive_datetime = datetime.strptime(f"{event_date} {event_time}", '%Y-%m-%d %H:%M')
|
|
517
|
+
moscow_datetime = MOSCOW_TZ.localize(naive_datetime)
|
|
518
|
+
|
|
519
|
+
# Отправляем сообщение с подтверждением
|
|
520
|
+
summary = (
|
|
521
|
+
f"📋 **Подтверждение создания события**\n\n"
|
|
522
|
+
f"📝 Название: **{data.get('event_name')}**\n"
|
|
523
|
+
f"📅 Дата и время: **{moscow_datetime.strftime('%d.%m.%Y %H:%M')} (МСК)**\n"
|
|
524
|
+
f"👥 Сегмент: **{data.get('segment_display')}**\n"
|
|
525
|
+
f"📎 Файлов: **{len(files)}**\n\n"
|
|
526
|
+
"Подтвердите создание события:"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
530
|
+
[
|
|
531
|
+
InlineKeyboardButton(text="✅ Создать", callback_data="confirm:yes"),
|
|
532
|
+
InlineKeyboardButton(text="❌ Отменить", callback_data="confirm:no")
|
|
533
|
+
],
|
|
534
|
+
[
|
|
535
|
+
InlineKeyboardButton(text="👁 Предпросмотр", callback_data="preview:show")
|
|
536
|
+
]
|
|
537
|
+
])
|
|
538
|
+
|
|
539
|
+
await callback_query.message.edit_text(
|
|
540
|
+
summary,
|
|
541
|
+
reply_markup=keyboard,
|
|
542
|
+
parse_mode='Markdown'
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
@admin_events_router.callback_query(F.data == "preview:show", AdminStates.create_event_confirm)
|
|
546
|
+
async def show_event_preview(callback_query: CallbackQuery, state: FSMContext):
|
|
547
|
+
"""Показываем предпросмотр сообщения"""
|
|
548
|
+
from aiogram.types import FSInputFile, InputMediaPhoto, InputMediaVideo
|
|
549
|
+
|
|
550
|
+
# Получаем данные
|
|
551
|
+
data = await state.get_data()
|
|
552
|
+
files = data.get('files', [])
|
|
553
|
+
logger.info(f"Предпросмотр: получено {len(files)} файлов из состояния")
|
|
554
|
+
|
|
555
|
+
# Удаляем сообщение с кнопкой предпросмотра
|
|
556
|
+
await callback_query.message.delete()
|
|
557
|
+
|
|
558
|
+
# Анализируем файлы
|
|
559
|
+
files_with_msg = [f for f in files if f.get('stage') == 'with_message']
|
|
560
|
+
files_after = [f for f in files if f.get('stage') == 'after_message']
|
|
561
|
+
logger.info(f"Предпросмотр: {len(files_with_msg)} файлов с сообщением, {len(files_after)} дополнительных файлов")
|
|
562
|
+
|
|
563
|
+
# 1. Отправляем сообщение с прикрепленными файлами
|
|
564
|
+
if files_with_msg:
|
|
565
|
+
media_group = []
|
|
566
|
+
first_file = True
|
|
567
|
+
|
|
568
|
+
# Сортируем файлы по порядку
|
|
569
|
+
sorted_files = sorted(files_with_msg, key=lambda x: x.get('order', 0))
|
|
570
|
+
|
|
571
|
+
# Добавляем файлы в том порядке, в котором они были загружены
|
|
572
|
+
for file_info in sorted_files:
|
|
573
|
+
logger.info(f"Добавляем в медиа-группу: {file_info['type']} файл {file_info['file_path']}")
|
|
574
|
+
try:
|
|
575
|
+
if file_info['type'] == 'photo':
|
|
576
|
+
media = InputMediaPhoto(
|
|
577
|
+
media=FSInputFile(file_info['file_path']),
|
|
578
|
+
caption=data.get('event_message') if first_file else None,
|
|
579
|
+
parse_mode='Markdown' if first_file else None
|
|
580
|
+
)
|
|
581
|
+
media_group.append(media)
|
|
582
|
+
first_file = False
|
|
583
|
+
elif file_info['type'] == 'video':
|
|
584
|
+
media = InputMediaVideo(
|
|
585
|
+
media=FSInputFile(file_info['file_path']),
|
|
586
|
+
caption=data.get('event_message') if first_file else None,
|
|
587
|
+
parse_mode='Markdown' if first_file else None
|
|
588
|
+
)
|
|
589
|
+
media_group.append(media)
|
|
590
|
+
first_file = False
|
|
591
|
+
logger.info("✅ Файл успешно добавлен в медиа-группу")
|
|
592
|
+
except Exception as e:
|
|
593
|
+
logger.error(f"❌ Ошибка добавления файла в медиа-группу: {e}")
|
|
594
|
+
|
|
595
|
+
if media_group:
|
|
596
|
+
try:
|
|
597
|
+
from ..handlers.handlers import get_global_var
|
|
598
|
+
bot = get_global_var('bot')
|
|
599
|
+
await bot.send_media_group(chat_id=callback_query.message.chat.id, media=media_group)
|
|
600
|
+
logger.info(f"✅ Отправлена медиа-группа из {len(media_group)} файлов")
|
|
601
|
+
except Exception as e:
|
|
602
|
+
logger.error(f"❌ Ошибка отправки медиа-группы: {e}")
|
|
603
|
+
# Если не удалось отправить группой, отправляем по одному
|
|
604
|
+
first_file = True
|
|
605
|
+
for media in media_group:
|
|
606
|
+
try:
|
|
607
|
+
if isinstance(media, InputMediaPhoto):
|
|
608
|
+
await callback_query.message.answer_photo(
|
|
609
|
+
photo=media.media,
|
|
610
|
+
caption=data.get('event_message') if first_file else None,
|
|
611
|
+
parse_mode='Markdown' if first_file else None
|
|
612
|
+
)
|
|
613
|
+
elif isinstance(media, InputMediaVideo):
|
|
614
|
+
await callback_query.message.answer_video(
|
|
615
|
+
video=media.media,
|
|
616
|
+
caption=data.get('event_message') if first_file else None,
|
|
617
|
+
parse_mode='Markdown' if first_file else None
|
|
618
|
+
)
|
|
619
|
+
first_file = False
|
|
620
|
+
except Exception as e2:
|
|
621
|
+
logger.error(f"❌ Ошибка отправки отдельного файла: {e2}")
|
|
622
|
+
else:
|
|
623
|
+
# Только текст
|
|
624
|
+
await callback_query.message.answer(
|
|
625
|
+
data.get('event_message'),
|
|
626
|
+
parse_mode='Markdown'
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# 2. Отправляем дополнительные файлы
|
|
630
|
+
for file_info in files_after:
|
|
631
|
+
if file_info['type'] == 'document':
|
|
632
|
+
await callback_query.message.answer_document(
|
|
633
|
+
FSInputFile(file_info['file_path'])
|
|
634
|
+
)
|
|
635
|
+
elif file_info['type'] == 'photo':
|
|
636
|
+
await callback_query.message.answer_photo(
|
|
637
|
+
FSInputFile(file_info['file_path'])
|
|
638
|
+
)
|
|
639
|
+
elif file_info['type'] == 'video':
|
|
640
|
+
await callback_query.message.answer_video(
|
|
641
|
+
FSInputFile(file_info['file_path'])
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
# 3. Отправляем сообщение с подтверждением (такое же как было)
|
|
645
|
+
event_date = data.get('event_date')
|
|
646
|
+
event_time = data.get('event_time')
|
|
647
|
+
naive_datetime = datetime.strptime(f"{event_date} {event_time}", '%Y-%m-%d %H:%M')
|
|
648
|
+
moscow_datetime = MOSCOW_TZ.localize(naive_datetime)
|
|
649
|
+
|
|
650
|
+
summary = (
|
|
651
|
+
f"📋 **Подтверждение создания события**\n\n"
|
|
652
|
+
f"📝 Название: **{data.get('event_name')}**\n"
|
|
653
|
+
f"📅 Дата и время: **{moscow_datetime.strftime('%d.%m.%Y %H:%M')} (МСК)**\n"
|
|
654
|
+
f"👥 Сегмент: **{data.get('segment_display')}**\n"
|
|
655
|
+
f"📎 Файлов: **{len(files)}**\n\n"
|
|
656
|
+
"Подтвердите создание события:"
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
660
|
+
[
|
|
661
|
+
InlineKeyboardButton(text="✅ Создать", callback_data="confirm:yes"),
|
|
662
|
+
InlineKeyboardButton(text="❌ Отменить", callback_data="confirm:no")
|
|
663
|
+
],
|
|
664
|
+
[
|
|
665
|
+
InlineKeyboardButton(text="👁 Предпросмотр", callback_data="preview:show")
|
|
666
|
+
]
|
|
667
|
+
])
|
|
668
|
+
|
|
669
|
+
await callback_query.message.answer(
|
|
670
|
+
summary,
|
|
671
|
+
reply_markup=keyboard,
|
|
672
|
+
parse_mode='Markdown'
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
@admin_events_router.callback_query(F.data.startswith("confirm:"), AdminStates.create_event_confirm)
|
|
676
|
+
async def process_event_confirmation(callback_query: CallbackQuery, state: FSMContext):
|
|
677
|
+
"""Обработка подтверждения создания события"""
|
|
678
|
+
action = callback_query.data.split(":", 1)[1]
|
|
679
|
+
|
|
680
|
+
if action == "no":
|
|
681
|
+
# Очищаем временные файлы
|
|
682
|
+
await cleanup_temp_files(state)
|
|
683
|
+
# Очищаем состояние
|
|
684
|
+
await state.clear()
|
|
685
|
+
await callback_query.message.edit_text(
|
|
686
|
+
"❌ Создание события отменено",
|
|
687
|
+
parse_mode='Markdown'
|
|
688
|
+
)
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
# Создаем событие
|
|
692
|
+
data = await state.get_data()
|
|
693
|
+
|
|
694
|
+
from ..handlers.handlers import get_global_var
|
|
695
|
+
supabase_client = get_global_var('supabase_client')
|
|
696
|
+
|
|
697
|
+
# Формируем datetime для планирования
|
|
698
|
+
event_date = data.get('event_date')
|
|
699
|
+
event_time = data.get('event_time')
|
|
700
|
+
|
|
701
|
+
# Создаем naive datetime из введенного московского времени
|
|
702
|
+
naive_datetime = datetime.strptime(f"{event_date} {event_time}", '%Y-%m-%d %H:%M')
|
|
703
|
+
|
|
704
|
+
# Привязываем к московской временной зоне
|
|
705
|
+
moscow_datetime = MOSCOW_TZ.localize(naive_datetime)
|
|
706
|
+
|
|
707
|
+
# Конвертируем в UTC для сохранения в БД
|
|
708
|
+
utc_datetime = moscow_datetime.astimezone(pytz.UTC)
|
|
709
|
+
|
|
710
|
+
logger.info(f"⏰ Время события: Москва={moscow_datetime.strftime('%d.%m.%Y %H:%M %Z')}, UTC={utc_datetime.strftime('%d.%m.%Y %H:%M %Z')}")
|
|
711
|
+
|
|
712
|
+
# Сначала сохраняем событие в БД чтобы получить ID
|
|
713
|
+
try:
|
|
714
|
+
# Создаем событие без файлов
|
|
715
|
+
event = await supabase_client.save_admin_event(
|
|
716
|
+
event_name=data.get('event_name'),
|
|
717
|
+
event_data={
|
|
718
|
+
'segment': data.get('segment'),
|
|
719
|
+
'message': data.get('event_message'),
|
|
720
|
+
'files': [] # Пока пустой список
|
|
721
|
+
},
|
|
722
|
+
scheduled_datetime=utc_datetime
|
|
723
|
+
)
|
|
724
|
+
event_id = event['id']
|
|
725
|
+
logger.info(f"✅ Создано событие с ID {event_id}")
|
|
726
|
+
|
|
727
|
+
# Теперь загружаем файлы в Storage
|
|
728
|
+
import os
|
|
729
|
+
files = data.get('files', [])
|
|
730
|
+
uploaded_files = []
|
|
731
|
+
|
|
732
|
+
for file_info in files:
|
|
733
|
+
try:
|
|
734
|
+
# Читаем локальный файл
|
|
735
|
+
with open(file_info['file_path'], 'rb') as f:
|
|
736
|
+
file_bytes = f.read()
|
|
737
|
+
|
|
738
|
+
# Генерируем уникальный ID для файла
|
|
739
|
+
file_id = generate_file_id()
|
|
740
|
+
|
|
741
|
+
# Загружаем в Storage
|
|
742
|
+
storage_info = await supabase_client.upload_event_file(
|
|
743
|
+
event_id=event_id,
|
|
744
|
+
file_data=file_bytes,
|
|
745
|
+
original_name=file_info['name'],
|
|
746
|
+
file_id=file_id
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# Сохраняем метаданные файла
|
|
750
|
+
uploaded_files.append({
|
|
751
|
+
'type': file_info['type'],
|
|
752
|
+
'storage_path': storage_info['storage_path'],
|
|
753
|
+
'original_name': file_info['name'], # Используем оригинальное имя из file_info
|
|
754
|
+
'stage': file_info['stage'],
|
|
755
|
+
'has_caption': file_info.get('has_caption', False),
|
|
756
|
+
'order': file_info.get('order', 0)
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
# Удаляем временный локальный файл
|
|
760
|
+
try:
|
|
761
|
+
os.remove(file_info['file_path'])
|
|
762
|
+
logger.info(f"🗑️ Удален временный файл: {file_info['file_path']}")
|
|
763
|
+
except Exception as e:
|
|
764
|
+
logger.warning(f"⚠️ Не удалось удалить временный файл: {e}")
|
|
765
|
+
|
|
766
|
+
except Exception as e:
|
|
767
|
+
logger.error(f"❌ Ошибка загрузки файла {file_info['name']}: {e}")
|
|
768
|
+
# Если ошибка - удаляем все файлы события и само событие
|
|
769
|
+
try:
|
|
770
|
+
await supabase_client.delete_event_files(event_id)
|
|
771
|
+
# TODO: добавить метод удаления события
|
|
772
|
+
except:
|
|
773
|
+
pass
|
|
774
|
+
raise
|
|
775
|
+
|
|
776
|
+
logger.info(f"✅ Загружено {len(uploaded_files)} файлов в Storage для события {event_id}")
|
|
777
|
+
|
|
778
|
+
# Обновляем событие с информацией о файлах
|
|
779
|
+
event_data = {
|
|
780
|
+
'segment': data.get('segment'),
|
|
781
|
+
'message': data.get('event_message'),
|
|
782
|
+
'files': uploaded_files
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
# TODO: добавить метод обновления события
|
|
786
|
+
response = supabase_client.client.table('scheduled_events').update({
|
|
787
|
+
'event_data': json.dumps(event_data, ensure_ascii=False)
|
|
788
|
+
}).eq('id', event_id).execute()
|
|
789
|
+
|
|
790
|
+
except Exception as e:
|
|
791
|
+
logger.error(f"❌ Ошибка создания события: {e}")
|
|
792
|
+
raise
|
|
793
|
+
|
|
794
|
+
# Показываем сообщение об успехе
|
|
795
|
+
await callback_query.message.edit_text(
|
|
796
|
+
f"✅ **Событие успешно создано!**\n\n"
|
|
797
|
+
f"📝 Название: `{data.get('event_name')}`\n"
|
|
798
|
+
f"📅 Запланировано на: **{moscow_datetime.strftime('%d.%m.%Y %H:%M')} (МСК)**\n"
|
|
799
|
+
f"👥 Сегмент: **{data.get('segment_display')}**\n\n"
|
|
800
|
+
f"💡 _Нажмите на название для копирования_",
|
|
801
|
+
parse_mode='Markdown'
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
# Очищаем временные файлы и состояние
|
|
805
|
+
await cleanup_temp_files(state)
|
|
806
|
+
await state.set_state(AdminStates.admin_mode)
|
|
807
|
+
|
|
808
|
+
@admin_events_router.message(Command(commands=["список_событий", "list_events"]))
|
|
809
|
+
async def list_events_command(message: Message, state: FSMContext):
|
|
810
|
+
"""Просмотр всех запланированных событий"""
|
|
811
|
+
from ..handlers.handlers import get_global_var
|
|
812
|
+
admin_manager = get_global_var('admin_manager')
|
|
813
|
+
supabase_client = get_global_var('supabase_client')
|
|
814
|
+
|
|
815
|
+
if not admin_manager.is_admin(message.from_user.id):
|
|
816
|
+
return
|
|
817
|
+
|
|
818
|
+
try:
|
|
819
|
+
# Получаем все pending события (незавершенные и неотмененные)
|
|
820
|
+
events = await supabase_client.get_admin_events(status='pending')
|
|
821
|
+
|
|
822
|
+
if not events:
|
|
823
|
+
await message.answer(
|
|
824
|
+
"📋 **Нет активных событий**\n\n"
|
|
825
|
+
"Используйте `/create_event` для создания нового события",
|
|
826
|
+
parse_mode='Markdown'
|
|
827
|
+
)
|
|
828
|
+
return
|
|
829
|
+
|
|
830
|
+
# Формируем список событий в красивом формате
|
|
831
|
+
text_parts = [
|
|
832
|
+
f"📋 **Активные события** ({len(events)})\n"
|
|
833
|
+
]
|
|
834
|
+
|
|
835
|
+
for idx, event in enumerate(events, 1):
|
|
836
|
+
event_name = event['event_type']
|
|
837
|
+
|
|
838
|
+
# Конвертируем UTC в московское время для отображения
|
|
839
|
+
utc_time = datetime.fromisoformat(event['scheduled_at'].replace('Z', '+00:00'))
|
|
840
|
+
moscow_time = utc_time.astimezone(MOSCOW_TZ)
|
|
841
|
+
|
|
842
|
+
# Красивый формат с эмодзи и структурой
|
|
843
|
+
text_parts.append(
|
|
844
|
+
f"📌 **{idx}.** `{event_name}`\n"
|
|
845
|
+
f" 🕐 {moscow_time.strftime('%d.%m.%Y в %H:%M')} МСК\n"
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
text_parts.append(
|
|
849
|
+
f"━━━━━━━━━━━━━━━━━━━━\n"
|
|
850
|
+
f"💡 _Нажмите на название для копирования_\n"
|
|
851
|
+
f"🗑️ Удалить: `/delete_event название`"
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
await message.answer(
|
|
855
|
+
"\n".join(text_parts),
|
|
856
|
+
parse_mode='Markdown'
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
except Exception as e:
|
|
860
|
+
logger.error(f"Ошибка получения событий: {e}")
|
|
861
|
+
await message.answer(
|
|
862
|
+
f"❌ Ошибка получения событий:\n`{str(e)}`",
|
|
863
|
+
parse_mode='Markdown'
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
@admin_events_router.message(Command(commands=["удалить_событие", "delete_event"]))
|
|
868
|
+
async def delete_event_command(message: Message, state: FSMContext):
|
|
869
|
+
"""Удаление события по названию"""
|
|
870
|
+
from ..handlers.handlers import get_global_var
|
|
871
|
+
admin_manager = get_global_var('admin_manager')
|
|
872
|
+
supabase_client = get_global_var('supabase_client')
|
|
873
|
+
|
|
874
|
+
if not admin_manager.is_admin(message.from_user.id):
|
|
875
|
+
return
|
|
876
|
+
|
|
877
|
+
# Парсим название из команды
|
|
878
|
+
parts = message.text.split(maxsplit=1)
|
|
879
|
+
if len(parts) < 2:
|
|
880
|
+
await message.answer(
|
|
881
|
+
"❌ Укажите название события:\n"
|
|
882
|
+
"`/delete_event название`\n\n"
|
|
883
|
+
"Используйте /list_events для просмотра списка событий",
|
|
884
|
+
parse_mode='Markdown'
|
|
885
|
+
)
|
|
886
|
+
return
|
|
887
|
+
|
|
888
|
+
event_name = parts[1].strip()
|
|
889
|
+
|
|
890
|
+
try:
|
|
891
|
+
# Сначала получаем событие чтобы узнать его ID
|
|
892
|
+
response = supabase_client.client.table('scheduled_events').select(
|
|
893
|
+
'id'
|
|
894
|
+
).eq('event_type', event_name).eq(
|
|
895
|
+
'event_category', 'admin_event'
|
|
896
|
+
).eq('status', 'pending').execute()
|
|
897
|
+
|
|
898
|
+
if response.data:
|
|
899
|
+
event_id = response.data[0]['id']
|
|
900
|
+
|
|
901
|
+
# Удаляем файлы из Storage
|
|
902
|
+
try:
|
|
903
|
+
await supabase_client.delete_event_files(event_id)
|
|
904
|
+
logger.info(f"🗑️ Удалены файлы события '{event_name}' (ID: {event_id}) из Storage")
|
|
905
|
+
except Exception as e:
|
|
906
|
+
logger.error(f"❌ Ошибка удаления файлов события из Storage: {e}")
|
|
907
|
+
|
|
908
|
+
# Отмечаем событие как отмененное
|
|
909
|
+
supabase_client.client.table('scheduled_events').update({
|
|
910
|
+
'status': 'cancelled'
|
|
911
|
+
}).eq('id', event_id).execute()
|
|
912
|
+
|
|
913
|
+
await message.answer(
|
|
914
|
+
f"✅ Событие `{event_name}` успешно отменено\n"
|
|
915
|
+
f"_(файлы удалены из Storage)_",
|
|
916
|
+
parse_mode='Markdown'
|
|
917
|
+
)
|
|
918
|
+
logger.info(f"Отменено событие '{event_name}' (ID: {event_id})")
|
|
919
|
+
else:
|
|
920
|
+
await message.answer(
|
|
921
|
+
f"❌ Активное событие с названием `{event_name}` не найдено\n\n"
|
|
922
|
+
f"Используйте /list_events для просмотра списка активных событий",
|
|
923
|
+
parse_mode='Markdown'
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
except Exception as e:
|
|
927
|
+
logger.error(f"Ошибка удаления события: {e}")
|
|
928
|
+
await message.answer(
|
|
929
|
+
f"❌ Ошибка удаления события:\n`{str(e)}`",
|
|
930
|
+
parse_mode='Markdown'
|
|
931
|
+
)
|
|
932
|
+
|