🔥 ОБЪЯВЛЕНИЯ: • Срок действия: 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>
394 lines
15 KiB
Python
394 lines
15 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 PaymentReminder, User
|
||
from utils.decorators import admin_only
|
||
from keyboards.inline import get_payment_type_menu
|
||
import config
|
||
import json
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = Router()
|
||
|
||
|
||
# =============================================================================
|
||
# FSM для создания напоминания
|
||
# =============================================================================
|
||
|
||
class PaymentState(StatesGroup):
|
||
waiting_for_type = State()
|
||
waiting_for_title = State()
|
||
waiting_for_amount = State()
|
||
waiting_for_due_date = State()
|
||
waiting_for_description = State()
|
||
|
||
|
||
# =============================================================================
|
||
# СОЗДАНИЕ НАПОМИНАНИЯ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('payment_add'))
|
||
@admin_only
|
||
async def cmd_payment_add(message: Message, state: FSMContext):
|
||
"""
|
||
Добавить напоминание о платеже
|
||
Использование: /payment_add
|
||
"""
|
||
# Загружаем типы платежей
|
||
try:
|
||
with open('data/payment_links.json', 'r', encoding='utf-8') as f:
|
||
payment_types = json.load(f).get('payment_types', {})
|
||
except Exception:
|
||
payment_types = {}
|
||
|
||
await message.answer(
|
||
'💳 <b>Добавление напоминания о платеже</b>\n\n'
|
||
'Выберите тип платежа:\n\n'
|
||
'1️⃣ ⚡ Электроэнергия\n'
|
||
'2️⃣ 💧 Вода\n'
|
||
'3️⃣ 🔥 Отопление\n'
|
||
'4️⃣ 🔶 Газ\n'
|
||
'5️⃣ 🏠 Содержание жилья\n'
|
||
'6️⃣ 🗑️ Вывоз мусора\n'
|
||
'7️⃣ 📡 Интернет\n'
|
||
'8️⃣ 📋 Другое\n\n'
|
||
'Или отправьте номер типа (1-8):',
|
||
reply_markup=get_payment_type_menu(),
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state(PaymentState.waiting_for_type)
|
||
|
||
|
||
@router.callback_query(F.data.startswith('payment_type_'))
|
||
async def callback_payment_type(callback: CallbackQuery, state: FSMContext):
|
||
"""Выбор типа платежа"""
|
||
type_map = {
|
||
'payment_type_electricity': ('electricity', '⚡ Электроэнергия'),
|
||
'payment_type_water': ('water', '💧 Вода'),
|
||
'payment_type_heating': ('heating', '🔥 Отопление'),
|
||
'payment_type_gas': ('gas', '🔶 Газ'),
|
||
'payment_type_maintenance': ('maintenance', '🏠 Содержание жилья'),
|
||
'payment_type_trash': ('trash', '🗑️ Вывоз мусора'),
|
||
'payment_type_internet': ('internet', '📡 Интернет'),
|
||
'payment_type_other': ('other', '📋 Другое'),
|
||
}
|
||
|
||
type_key, type_name = type_map.get(callback.data, ('other', '📋 Другое'))
|
||
await state.update_data(payment_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(PaymentState.waiting_for_title)
|
||
await callback.answer()
|
||
|
||
|
||
@router.message(PaymentState.waiting_for_type)
|
||
async def process_payment_type(message: Message, state: FSMContext):
|
||
"""Выбор типа платежа текстом"""
|
||
type_map = {
|
||
'1': ('electricity', '⚡ Электроэнергия'),
|
||
'2': ('water', '💧 Вода'),
|
||
'3': ('heating', '🔥 Отопление'),
|
||
'4': ('gas', '🔶 Газ'),
|
||
'5': ('maintenance', '🏠 Содержание жилья'),
|
||
'6': ('trash', '🗑️ Вывоз мусора'),
|
||
'7': ('internet', '📡 Интернет'),
|
||
'8': ('other', '📋 Другое'),
|
||
}
|
||
|
||
text = message.text.strip()
|
||
if text in type_map:
|
||
type_key, type_name = type_map[text]
|
||
await state.update_data(payment_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(PaymentState.waiting_for_title)
|
||
else:
|
||
await message.answer('❌ Неверный тип. Выберите из меню или отправьте номер 1-8:')
|
||
|
||
|
||
@router.message(PaymentState.waiting_for_title)
|
||
async def process_payment_title(message: Message, state: FSMContext):
|
||
"""Получили название платежа"""
|
||
title = message.text
|
||
await state.update_data(title=title)
|
||
|
||
await message.answer(
|
||
f'✅ Название: {title}\n\n'
|
||
'Укажите сумму платежа (в рублях) или отправьте "0" если переменная:\n'
|
||
'<i>Например: 3500</i>',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state(PaymentState.waiting_for_amount)
|
||
|
||
|
||
@router.message(PaymentState.waiting_for_amount)
|
||
async def process_payment_amount(message: Message, state: FSMContext):
|
||
"""Получили сумму"""
|
||
text = message.text.strip()
|
||
|
||
try:
|
||
amount = int(text)
|
||
if amount < 0:
|
||
raise ValueError
|
||
except ValueError:
|
||
await message.answer('❌ Неверный формат суммы. Отправьте число:')
|
||
return
|
||
|
||
await state.update_data(amount=amount if amount > 0 else None)
|
||
|
||
await message.answer(
|
||
f'✅ Сумма: {amount if amount > 0 else "Переменная"}\n\n'
|
||
'Укажите дату оплаты (число месяца):\n'
|
||
'<i>Например: 10 (ежемесячно до 10 числа)</i>',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state(PaymentState.waiting_for_due_date)
|
||
|
||
|
||
@router.message(PaymentState.waiting_for_due_date)
|
||
async def process_payment_due_date(message: Message, state: FSMContext):
|
||
"""Получили дату оплаты"""
|
||
text = message.text.strip()
|
||
|
||
try:
|
||
due_date = int(text)
|
||
if due_date < 1 or due_date > 31:
|
||
raise ValueError
|
||
except ValueError:
|
||
await message.answer('❌ Неверный формат даты. Отправьте число от 1 до 31:')
|
||
return
|
||
|
||
await state.update_data(due_date=due_date)
|
||
|
||
await message.answer(
|
||
f'✅ Дата оплаты: {due_date} число каждого месяца\n\n'
|
||
'Отправьте описание (необязательно) или нажмите "Пропустить":',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state(PaymentState.waiting_for_description)
|
||
|
||
|
||
@router.message(PaymentState.waiting_for_description)
|
||
async def process_payment_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()
|
||
|
||
# Создаём дату с текущим месяцем
|
||
now = datetime.utcnow()
|
||
due_date = now.replace(day=data['due_date'])
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
reminder = PaymentReminder(
|
||
title=data['title'],
|
||
amount=data.get('amount'),
|
||
due_date=due_date,
|
||
description=description,
|
||
is_recurring=True,
|
||
created_by=message.from_user.id,
|
||
)
|
||
session.add(reminder)
|
||
await session.commit()
|
||
await session.refresh(reminder)
|
||
|
||
# Формируем сообщение о создании
|
||
type_name = data.get('type_name', '📋 Платёж')
|
||
amount_text = f'{data["amount"]} ₽' if data.get('amount') else 'Переменная'
|
||
|
||
await message.answer(
|
||
f'✅ <b>Напоминание о платеже добавлено!</b>\n\n'
|
||
f'{type_name} — {data["title"]}\n\n'
|
||
f'💰 Сумма: {amount_text}\n'
|
||
f'📅 Срок оплаты: {data["due_date"]} число каждого месяца\n'
|
||
f'🔖 ID: {reminder.reminder_id}\n\n'
|
||
f'Для просмотра: /payments',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.clear()
|
||
|
||
|
||
# =============================================================================
|
||
# ПРОСМОТР НАПОМИНАНИЙ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('payments'))
|
||
@router.message(Command('платежи'))
|
||
async def cmd_payments_list(message: Message):
|
||
"""Показать все активные напоминания о платежах"""
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = (
|
||
select(PaymentReminder)
|
||
.where(PaymentReminder.is_active == True)
|
||
.order_by(PaymentReminder.due_date)
|
||
)
|
||
result = await session.execute(stmt)
|
||
reminders = list(result.scalars().all())
|
||
|
||
if not reminders:
|
||
await message.answer('✅ Нет активных напоминаний о платежах')
|
||
return
|
||
|
||
text = '💳 <b>Напоминания о платежах ЖКХ</b>\n\n'
|
||
|
||
current_day = datetime.utcnow().day
|
||
|
||
for i, r in enumerate(reminders, 1):
|
||
due_day = r.due_date.day if hasattr(r.due_date, 'day') else 10
|
||
days_left = (due_day - current_day) % 30
|
||
|
||
amount_text = f'{r.amount} ₽' if r.amount else 'Переменная'
|
||
|
||
text += f'{i}. {r.title}\n'
|
||
text += f' └─ Сумма: {amount_text}\n'
|
||
text += f' └─ Срок: {due_day} число\n'
|
||
text += f' └─ Осталось дней: {days_left}\n\n'
|
||
|
||
text += '💡 Для добавления: /payment_add'
|
||
|
||
await message.answer(text, parse_mode='HTML')
|
||
|
||
|
||
# =============================================================================
|
||
# УДАЛЕНИЕ НАПОМИНАНИЯ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('payment_del'))
|
||
@admin_only
|
||
async def cmd_payment_del(message: Message):
|
||
"""
|
||
Удалить напоминание
|
||
Использование: /payment_del <ID>
|
||
"""
|
||
args = message.text.split()
|
||
if len(args) < 2:
|
||
await message.answer(
|
||
'❌ <b>Использование:</b>\n'
|
||
'<code>/payment_del <ID></code>',
|
||
parse_mode='HTML'
|
||
)
|
||
return
|
||
|
||
try:
|
||
reminder_id = int(args[1])
|
||
except ValueError:
|
||
await message.answer('❌ Неверный ID')
|
||
return
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
reminder = await session.get(PaymentReminder, reminder_id)
|
||
|
||
if not reminder:
|
||
await message.answer(f'❌ Напоминание {reminder_id} не найдено')
|
||
return
|
||
|
||
reminder.is_active = False
|
||
await session.commit()
|
||
|
||
await message.answer(f'✅ Напоминание "{reminder.title}" удалено')
|
||
|
||
|
||
# =============================================================================
|
||
# РАССЫЛКА НАПОМИНАНИЙ
|
||
# =============================================================================
|
||
|
||
async def send_payment_reminders(bot):
|
||
"""Отправить напоминания о платежах (5-го и 9-го числа)"""
|
||
current_day = datetime.utcnow().day
|
||
|
||
# Проверяем нужно ли отправлять (5 или 9 число)
|
||
if current_day not in [5, 9]:
|
||
return
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
# Получаем все активные напоминания
|
||
stmt = select(PaymentReminder).where(PaymentReminder.is_active == True)
|
||
result = await session.execute(stmt)
|
||
reminders = list(result.scalars().all())
|
||
|
||
if not reminders:
|
||
return
|
||
|
||
# Формируем сообщение
|
||
text = f'⏰ <b>Напоминание об оплате ЖКХ!</b>\n\n'
|
||
text += f'Сегодня {current_day} число. Не забудьте оплатить:\n\n'
|
||
|
||
for r in reminders:
|
||
due_day = r.due_date.day if hasattr(r.due_date, 'day') else 10
|
||
days_left = due_day - current_day
|
||
|
||
if days_left < 0:
|
||
days_left += 30
|
||
|
||
amount_text = f'{r.amount} ₽' if r.amount else ''
|
||
emoji = '⚠️' if days_left <= 2 else '✅'
|
||
|
||
text += f'{emoji} <b>{r.title}</b>\n'
|
||
text += f' └─ Срок: {due_day} число (осталось {days_left} дн.)\n'
|
||
if amount_text:
|
||
text += f' └─ Сумма: {amount_text}\n'
|
||
text += '\n'
|
||
|
||
# Загружаем ссылки на оплату
|
||
try:
|
||
with open('data/payment_links.json', 'r', encoding='utf-8') as f:
|
||
payment_data = json.load(f)
|
||
links = payment_data.get('payment_links', [])
|
||
|
||
if links:
|
||
text += '💳 <b>Ссылки на оплату:</b>\n'
|
||
for link in links[:3]:
|
||
text += f'• [{link["name"]}]({link["url"]})\n'
|
||
except Exception:
|
||
pass
|
||
|
||
text += '\n_Оплатить можно через Госуслуги, Сбербанк или Тинькофф_'
|
||
|
||
# Отправляем в чат в тему "Важные объявления"
|
||
try:
|
||
await bot.send_message(
|
||
chat_id=config.ADMIN_CHAT_ID,
|
||
message_thread_id=config.get_topic_id('general'), # В общую тему
|
||
text=text,
|
||
parse_mode='HTML',
|
||
disable_web_page_preview=True
|
||
)
|
||
|
||
# Обновляем last_reminder_sent
|
||
for r in reminders:
|
||
r.last_reminder_sent = datetime.utcnow()
|
||
|
||
await session.commit()
|
||
|
||
logger.info(f'Напоминания о платежах отправлены ({len(reminders)} платежей)')
|
||
|
||
except Exception as e:
|
||
logger.error(f'Не удалось отправить напоминания о платежах: {e}')
|