✅ Регистрация и верификация жильцов ✅ Рейтинг активности (уровни) ✅ Детект шпионов (Score 0-100) ✅ Анти-мат система (3 предупреждения → бан) ✅ Админ-панель с рассылкой ✅ Учёт квартир (несколько жильцов) ✅ Телефоны экстренных служб и мастеров ✅ Еженедельный экспорт JSON ✅ Прокси (socks5) для обхода РКН Бекап: backups/versions/v1.0_mvp_2026-02-26/ Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
374 lines
14 KiB
Python
374 lines
14 KiB
Python
"""
|
||
Расписание отключений — вода, свет, отопление
|
||
Только для администраторов
|
||
"""
|
||
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 <ID></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(
|
||
config.ADMIN_CHAT_ID,
|
||
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}')
|