domovoy_bot/handlers/schedule.py
Admin cef2ac3714 v1.19 ИГ рассылка + объявления 14 дней + настройки
🔥 ОБЪЯВЛЕНИЯ:
• Срок действия: 7 → 14 дней

🎯 ИНИЦИАТИВНАЯ ГРУППА:
• Добавлена кнопка 'Рассылка ИГ' в меню
• Команда /ig_broadcast для членов ИГ
• Поддержка текста + фото + файлов
• Настройка лимита в веб-панели

⚙️ ВЕБ-ПАНЕЛЬ:
• Новая страница /settings
• Настройка лимита рассылки ИГ (секунды)
• Кнопки статистики фильтруют пользователей
• Индикатор активного фильтра

🎨 UI:
• Исправлен <font> → emoji (Telegram не поддерживает)
• Цветные заголовки через emoji

📁 ФАЙЛЫ:
• handlers/ads.py: 14 дней
• handlers/initiative_group.py: рассылка ИГ
• web/app.py: API настроек
• web/templates/settings.html: страница настроек
• utils/ui_styles.py: без <font> тегов
• database: settings table

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-10 17:01:30 +00:00

375 lines
15 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Расписание отключений — вода, свет, отопление
Только для администраторов
"""
import logging
from datetime import datetime, timedelta
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command, StateFilter
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database.db import AsyncSessionLocal
from database.models import Schedule, User
from utils.decorators import admin_only
from keyboards.inline import get_schedule_menu, get_schedule_item_menu
import config
logger = logging.getLogger(__name__)
router = Router()
# =============================================================================
# FSM для создания расписания
# =============================================================================
class ScheduleState(StatesGroup):
waiting_for_type = State()
waiting_for_title = State()
waiting_for_start = State()
waiting_for_end = State()
waiting_for_description = State()
# =============================================================================
# СОЗДАНИЕ РАСПИСАНИЯ
# =============================================================================
@router.message(Command('schedule_add'))
@admin_only
async def cmd_schedule_add(message: Message, state: FSMContext):
"""
Добавить событие в расписание
Использование: /schedule_add
"""
await message.answer(
'📅 <b>Добавление события в расписание</b>\n\n'
'Выберите тип отключения:\n\n'
'1⃣ 💧 Вода\n'
'2⃣ ⚡ Электричество\n'
'3⃣ 🔥 Отопление\n'
'4⃣ 🔶 Газ\n'
'5⃣ 🛗 Лифт\n'
'6⃣ 📋 Общее\n\n'
'Или отправьте номер типа (1-6):',
reply_markup=get_schedule_menu(),
parse_mode='HTML'
)
await state.set_state(ScheduleState.waiting_for_type)
@router.callback_query(F.data.startswith('schedule_type_'))
async def callback_schedule_type(callback: CallbackQuery, state: FSMContext):
"""Выбор типа отключения"""
type_map = {
'schedule_type_water': ('water', '💧 Вода'),
'schedule_type_electricity': ('electricity', '⚡ Электричество'),
'schedule_type_heating': ('heating', '🔥 Отопление'),
'schedule_type_gas': ('gas', '🔶 Газ'),
'schedule_type_elevator': ('elevator', '🛗 Лифт'),
'schedule_type_general': ('general', '📋 Общее'),
}
type_key, type_name = type_map.get(callback.data, ('general', '📋 Общее'))
await state.update_data(schedule_type=type_key, type_name=type_name)
await callback.message.answer(
f'✅ Тип: {type_name}\n\n'
'Теперь отправьте название события:\n'
'<i>Например: "Плановое отключение воды"</i>',
parse_mode='HTML'
)
await state.set_state(ScheduleState.waiting_for_title)
await callback.answer()
@router.message(ScheduleState.waiting_for_type)
async def process_schedule_type(message: Message, state: FSMContext):
"""Выбор типа отключения текстом"""
type_map = {
'1': ('water', '💧 Вода'),
'2': ('electricity', '⚡ Электричество'),
'3': ('heating', '🔥 Отопление'),
'4': ('gas', '🔶 Газ'),
'5': ('elevator', '🛗 Лифт'),
'6': ('general', '📋 Общее'),
}
text = message.text.strip()
if text in type_map:
type_key, type_name = type_map[text]
await state.update_data(schedule_type=type_key, type_name=type_name)
await message.answer(
f'✅ Тип: {type_name}\n\n'
'Теперь отправьте название события:\n'
'<i>Например: "Плановое отключение воды"</i>',
parse_mode='HTML'
)
await state.set_state(ScheduleState.waiting_for_title)
else:
await message.answer('❌ Неверный тип. Выберите из меню или отправьте номер 1-6:')
@router.message(ScheduleState.waiting_for_title)
async def process_schedule_title(message: Message, state: FSMContext):
"""Получили название события"""
title = message.text
await state.update_data(title=title)
await message.answer(
f'✅ Название: {title}\n\n'
'Теперь отправьте дату и время НАЧАЛА в формате:\n'
'<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\n'
'<i>Например: 27.02.2026 10:00</i>',
parse_mode='HTML'
)
@router.message(ScheduleState.waiting_for_start)
async def process_schedule_start(message: Message, state: FSMContext):
"""Получили время начала"""
text = message.text.strip()
try:
start_time = datetime.strptime(text, '%d.%m.%Y %H:%M')
except ValueError:
await message.answer(
'❌ Неверный формат даты.\n'
'Используйте: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\n'
'<i>Например: 27.02.2026 10:00</i>',
parse_mode='HTML'
)
return
if start_time < datetime.utcnow():
await message.answer('❌ Время начала должно быть в будущем!')
return
await state.update_data(start_time=start_time)
await message.answer(
f'✅ Начало: {start_time.strftime("%d.%m.%Y %H:%M")}\n\n'
'Теперь отправьте дату и время ОКОНЧАНИЯ:\n'
'<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>',
parse_mode='HTML'
)
await state.set_state(ScheduleState.waiting_for_end)
@router.message(ScheduleState.waiting_for_end)
async def process_schedule_end(message: Message, state: FSMContext):
"""Получили время окончания"""
text = message.text.strip()
try:
end_time = datetime.strptime(text, '%d.%m.%Y %H:%M')
except ValueError:
await message.answer(
'❌ Неверный формат даты.\n'
'Используйте: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>',
parse_mode='HTML'
)
return
data = await state.get_data()
start_time = data.get('start_time')
if end_time <= start_time:
await message.answer('❌ Время окончания должно быть позже времени начала!')
return
await state.update_data(end_time=end_time)
await message.answer(
f'✅ Окончание: {end_time.strftime("%d.%m.%Y %H:%M")}\n\n'
'Отправьте описание (необязательно) или нажмите "Пропустить":',
reply_markup=get_schedule_item_menu(skip=True),
parse_mode='HTML'
)
await state.set_state(ScheduleState.waiting_for_description)
@router.message(ScheduleState.waiting_for_description)
async def process_schedule_description(message: Message, state: FSMContext):
"""Получили описание или пропустили"""
if message.text and message.text.lower() != 'пропустить':
description = message.text
else:
description = None
await state.update_data(description=description)
# Сохраняем событие в БД
data = await state.get_data()
async with AsyncSessionLocal() as session:
schedule = Schedule(
schedule_type=data['schedule_type'],
title=data['title'],
description=description,
start_time=data['start_time'],
end_time=data['end_time'],
created_by=message.from_user.id,
)
session.add(schedule)
await session.commit()
await session.refresh(schedule)
# Формируем сообщение о создании
type_name = data.get('type_name', '📋 Общее')
duration = (data['end_time'] - data['start_time']).total_seconds() / 3600
await message.answer(
f'✅ <b>Событие добавлено в расписание!</b>\n\n'
f'{type_name}{data["title"]}\n\n'
f'📅 Начало: {data["start_time"].strftime("%d.%m.%Y %H:%M")}\n'
f'📅 Окончание: {data["end_time"].strftime("%d.%m.%Y %H:%M")}\n'
f'⏱️ Длительность: {duration:.1f} ч.\n'
f'🔖 ID: {schedule.schedule_id}\n\n'
f'Для просмотра: /график',
parse_mode='HTML'
)
await state.clear()
# =============================================================================
# ПРОСМОТР РАСПИСАНИЯ
# =============================================================================
@router.message(Command('график'))
@router.message(Command('schedule'))
async def cmd_schedule(message: Message):
"""Показать расписание отключений"""
async with AsyncSessionLocal() as session:
# Получаем активные будущие события
stmt = (
select(Schedule)
.where(Schedule.is_active == True)
.where(Schedule.end_time > datetime.utcnow())
.order_by(Schedule.start_time)
.limit(10)
)
result = await session.execute(stmt)
schedules = list(result.scalars().all())
if not schedules:
await message.answer('✅ Нет запланированных отключений')
return
text = '📅 <b>Расписание отключений</b>\n\n'
for i, s in enumerate(schedules, 1):
emoji = s.get_type_emoji()
type_name = {
'water': 'Вода',
'electricity': 'Электричество',
'heating': 'Отопление',
'gas': 'Газ',
'elevator': 'Лифт',
'general': 'Общее'
}.get(s.schedule_type, 'Общее')
duration = (s.end_time - s.start_time).total_seconds() / 3600
text += f'{i}. {emoji} <b>{s.title}</b>\n'
text += f' └─ Тип: {type_name}\n'
text += f' └─ Начало: {s.start_time.strftime("%d.%m.%Y %H:%M")}\n'
text += f' └─ Окончание: {s.end_time.strftime("%d.%m.%Y %H:%M")}\n'
text += f' └─ Длительность: {duration:.1f} ч.\n'
if s.description:
text += f' └─ Описание: {s.description[:100]}\n'
text += '\n'
await message.answer(text, parse_mode='HTML')
# =============================================================================
# УДАЛЕНИЕ СОБЫТИЯ
# =============================================================================
@router.message(Command('schedule_del'))
@admin_only
async def cmd_schedule_del(message: Message):
"""
Удалить событие из расписания
Использование: /schedule_del <ID>
"""
args = message.text.split()
if len(args) < 2:
await message.answer(
'❌ <b>Использование:</b>\n'
'<code>/schedule_del &lt;ID&gt;</code>\n\n'
'ID можно узнать из /график',
parse_mode='HTML'
)
return
try:
schedule_id = int(args[1])
except ValueError:
await message.answer('❌ Неверный ID')
return
async with AsyncSessionLocal() as session:
schedule = await session.get(Schedule, schedule_id)
if not schedule:
await message.answer(f'❌ Событие {schedule_id} не найдено')
return
schedule.is_active = False
await session.commit()
await message.answer(f'✅ Событие "{schedule.title}" удалено из расписания')
# =============================================================================
# НАПОМИНАНИЯ
# =============================================================================
async def check_and_send_reminders(bot):
"""Проверить и отправить напоминания о предстоящих отключениях"""
async with AsyncSessionLocal() as session:
# Находим события которые начнутся через 1 час
stmt = (
select(Schedule)
.where(Schedule.is_active == True)
.where(Schedule.reminder_sent == False)
.where(Schedule.start_time > datetime.utcnow())
.where(Schedule.start_time <= datetime.utcnow() + timedelta(hours=1))
)
result = await session.execute(stmt)
schedules = list(result.scalars().all())
for schedule in schedules:
# Отправляем напоминание в чат в тему "Важные объявления"
try:
emoji = schedule.get_type_emoji()
duration = (schedule.end_time - schedule.start_time).total_seconds() / 3600
await bot.send_message(
chat_id=config.ADMIN_CHAT_ID,
message_thread_id=config.get_topic_id('general'), # В общую тему
text=f'⏰ <b>Напоминание: отключение через 1 час!</b>\n\n'
f'{emoji} <b>{schedule.title}</b>\n\n'
f'📅 Начало: {schedule.start_time.strftime("%d.%m.%Y %H:%M")}\n'
f'📅 Окончание: {schedule.end_time.strftime("%d.%m.%Y %H:%M")}\n'
f'⏱️ Длительность: {duration:.1f} ч.\n\n'
f'Не забудьте сделать запасы!',
parse_mode='HTML'
)
# Помечаем что напоминание отправлено
schedule.reminder_sent = True
await session.commit()
logger.info(f'Напоминание об отключении "{schedule.title}" отправлено')
except Exception as e:
logger.error(f'Не удалось отправить напоминание: {e}')