diff --git a/data/service_images/meter_reminder.jpg b/data/service_images/meter_reminder.jpg new file mode 100644 index 0000000..47c6f57 Binary files /dev/null and b/data/service_images/meter_reminder.jpg differ diff --git a/database/migrate_add_meter_reminders.py b/database/migrate_add_meter_reminders.py new file mode 100644 index 0000000..5042a02 --- /dev/null +++ b/database/migrate_add_meter_reminders.py @@ -0,0 +1,29 @@ +""" +Миграция v1.16: добавить колонку meter_reminders_enabled в таблицу users +""" +import sqlite3 +import asyncio + +DB_PATH = 'database/domovoy.db' + +async def migrate(): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Проверяем, есть ли уже колонка meter_reminders_enabled + cursor.execute("PRAGMA table_info(users)") + columns = [col[1] for col in cursor.fetchall()] + + if 'meter_reminders_enabled' in columns: + print("ℹ️ Колонка meter_reminders_enabled уже существует") + else: + print("➕ Добавляем колонку meter_reminders_enabled в таблицу users...") + cursor.execute("ALTER TABLE users ADD COLUMN meter_reminders_enabled BOOLEAN DEFAULT 1") + print("✅ Колонка добавлена") + + conn.commit() + conn.close() + print("✅ Миграция v1.16 завершена!") + +if __name__ == '__main__': + asyncio.run(migrate()) diff --git a/database/models.py b/database/models.py index 4677172..7ec1929 100644 --- a/database/models.py +++ b/database/models.py @@ -53,6 +53,7 @@ class User(Base): staircase = Column(Integer, nullable=True) # Подъезд floor = Column(Integer, nullable=True) # Этаж has_ai_access = Column(Boolean, default=False) # Доступ к ИИ-консультанту + meter_reminders_enabled = Column(Boolean, default=True) # Напоминания о показаниях счетчиков # Для анти-спама spam_warning_count = Column(Integer, default=0) # Количество предупреждений за спам diff --git a/handlers/users.py b/handlers/users.py index 3987c84..cbb6212 100644 --- a/handlers/users.py +++ b/handlers/users.py @@ -205,7 +205,7 @@ async def cb_profile(event): f"Подъезд: {user.staircase or '—'} | Этаж: {user.floor or '—'}\n" f"Рейтинг: {user.rating} 🏆") - kb = get_profile_keyboard() + kb = get_profile_keyboard(user.meter_reminders_enabled if user else True) if isinstance(event, Message): await event.answer(text, reply_markup=kb, parse_mode='HTML') @@ -213,6 +213,39 @@ async def cb_profile(event): await event.answer() await event.message.edit_text(text, reply_markup=kb, parse_mode='HTML') +@router.callback_query(F.data == "toggle_meter_reminders") +async def cb_toggle_meter_reminders(callback: CallbackQuery): + user_id = callback.from_user.id + from utils.ui_styles import get_level_badge, get_user_level_key + + async with AsyncSessionLocal() as session: + user = await session.get(User, user_id) + if not user: + await callback.answer("Пользователь не найден", show_alert=True) + return + + new_val = not user.meter_reminders_enabled + user.meter_reminders_enabled = new_val + await session.commit() + + await callback.answer( + f"Напоминания о счетчиках {'включены' if new_val else 'отключены'}!" + ) + + ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == user_id, InitiativeGroup.is_active == True) + is_ig = (await session.execute(ig_stmt)).scalar_one_or_none() is not None + badge = get_level_badge(get_user_level_key(user_id, user.is_admin, user.verified, is_ig)) + + text = (f"👤 ТВОЙ ПРОФИЛЬ\n\n" + f"ID: {user_id}\n" + f"Статус: {badge}\n" + f"Квартира: {user.apartment or 'Не указана'}\n" + f"Подъезд: {user.staircase or '—'} | Этаж: {user.floor or '—'}\n" + f"Рейтинг: {user.rating} 🏆") + + kb = get_profile_keyboard(new_val) + await callback.message.edit_text(text, reply_markup=kb, parse_mode='HTML') + @router.callback_query(F.data == "edit_location") async def edit_location_start(callback: CallbackQuery): await callback.message.edit_text("Введите ваш подъезд и этаж через пробел\nНапример: '3 5' (3 подъезд, 5 этаж)", parse_mode='HTML') @@ -225,10 +258,15 @@ async def process_location(message: Message): async with AsyncSessionLocal() as session: await session.execute(update(User).where(User.user_id == message.from_user.id).values(staircase=staircase, floor=floor)) await session.commit() - await message.answer(f"✅ Данные обновлены: Подъезд {staircase}, Этаж {floor}", reply_markup=get_profile_keyboard(), parse_mode='HTML') + + user = await session.get(User, message.from_user.id) + meter_enabled = user.meter_reminders_enabled if user else True + + await message.answer(f"✅ Данные обновлены: Подъезд {staircase}, Этаж {floor}", reply_markup=get_profile_keyboard(meter_enabled), parse_mode='HTML') except Exception as e: await message.answer("❌ Ошибка формата. Введите два числа через пробел.") + @router.message(Command('help')) @router.callback_query(F.data == 'help') async def cb_help(event): diff --git a/keyboards/inline.py b/keyboards/inline.py index e7e10b0..25392e2 100644 --- a/keyboards/inline.py +++ b/keyboards/inline.py @@ -402,9 +402,15 @@ def get_poll_view_keyboard(poll_id: int, is_admin: bool = False) -> InlineKeyboa return builder.as_markup() -def get_profile_keyboard() -> InlineKeyboardMarkup: +def get_profile_keyboard(meter_reminders_enabled: bool = True) -> InlineKeyboardMarkup: """Клавиатура профиля""" builder = InlineKeyboardBuilder() + + # Кнопка переключения напоминаний о счетчиках + reminders_text = "🔔 Напоминания: ВКЛ" if meter_reminders_enabled else "🔕 Напоминания: ВЫКЛ" + builder.row(InlineKeyboardButton(text=reminders_text, callback_data="toggle_meter_reminders")) + builder.row(InlineKeyboardButton(text='🔄 Обновить', callback_data='my_profile')) builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu')) return builder.as_markup() + diff --git a/services/meter_reminder.py b/services/meter_reminder.py new file mode 100644 index 0000000..64bac7c --- /dev/null +++ b/services/meter_reminder.py @@ -0,0 +1,56 @@ +import logging +import asyncio +from aiogram import Bot +from aiogram.types import FSInputFile +from database.db import AsyncSessionLocal +from database.models import User +from sqlalchemy import select + +logger = logging.getLogger('domovoy.meter_reminder') + +METER_REMINDER_IMAGE = 'data/service_images/meter_reminder.jpg' +REMINDER_TEXT = ( + "⏰ Не забудьте отправить показания счетчиков!\n\n" + "Уважаемые жильцы, пришло время подать показания приборов учета воды и электричества. " + "Пожалуйста, передайте данные до 25-го числа включительно.\n\n" + "Вы можете отключить или включить эти напоминания в своем профиле в любой момент (команда /profile)." +) + +async def send_meter_reminders(bot: Bot): + """Рассылка напоминаний о счетчиках верифицированным жильцам""" + logger.info("🚀 Запуск ежемесячной рассылки напоминаний о счетчиках") + + async with AsyncSessionLocal() as session: + stmt = select(User).where( + User.verified == True, + User.is_banned == False, + User.meter_reminders_enabled == True + ) + users = (await session.execute(stmt)).scalars().all() + + logger.info(f"Найдено {len(users)} пользователей для отправки напоминания") + + success_count = 0 + fail_count = 0 + + photo = FSInputFile(METER_REMINDER_IMAGE) + + for user in users: + try: + await bot.send_photo( + chat_id=user.user_id, + photo=photo, + caption=REMINDER_TEXT, + parse_mode='HTML' + ) + success_count += 1 + logger.info(f"✅ Напоминание отправлено пользователю {user.user_id} (кв. {user.apartment})") + except Exception as e: + fail_count += 1 + logger.error(f"❌ Ошибка отправки пользователю {user.user_id}: {e}") + + # Небольшая задержка, чтобы не превысить лимиты Telegram API + await asyncio.sleep(0.05) + + logger.info(f"📊 Рассылка завершена. Успешно: {success_count}, Ошибок: {fail_count}") + return success_count, fail_count diff --git a/services/scheduler.py b/services/scheduler.py index 6ed8db0..052ab3a 100644 --- a/services/scheduler.py +++ b/services/scheduler.py @@ -89,6 +89,16 @@ class Scheduler: replace_existing=True ) + # Ежемесячная рассылка напоминаний о счетчиках (23 числа в 10:00) + self.scheduler.add_job( + self.monthly_meter_reminders, + CronTrigger.from_crontab('0 10 23 * *'), + id='monthly_meter_reminders', + name='Напоминания о показаниях счетчиков', + replace_existing=True + ) + + # Ежечасная проверка напоминаний о событиях (каждый час) self.scheduler.add_job( self.hourly_event_reminders, @@ -273,6 +283,15 @@ class Scheduler: except Exception as e: logger.error(f'Ошибка напоминаний о платежах: {e}') + async def monthly_meter_reminders(self): + """Ежемесячная рассылка напоминаний о показаниях счетчиков""" + try: + from services.meter_reminder import send_meter_reminders + await send_meter_reminders(self.bot) + except Exception as e: + logger.error(f'Ошибка рассылки напоминаний о счетчиках: {e}') + + async def hourly_event_reminders(self): """Ежечасная проверка напоминаний о событиях""" try: @@ -397,8 +416,10 @@ class Scheduler: async with http_session.post(url, json=payload) as r: return (await r.json())['result']['message_id'] if r.status == 200 else None + msg_id = None # 1. Отправка в основной чат - msg_id = await send_content(ADMIN_CHAT_ID, thread_id=post.topic_id) + if post.recipients in ['chat_only', 'all_and_chat']: + msg_id = await send_content(ADMIN_CHAT_ID, thread_id=post.topic_id) # 2. Если нужно, рассылка жильцам в личку if post.recipients in ['all_verified', 'all_and_chat']: @@ -415,14 +436,6 @@ class Scheduler: post.sent_at = datetime.utcnow() post.message_id = msg_id await session.commit() - logger.info(f"✅ Пост {post.id} отправлен") - - # Обновляем статус поста - post.status = 'sent' - post.sent_at = datetime.utcnow() - post.message_id = message_id - await session.commit() - logger.info(f"✅ Пост отправлен: id={post.id}, тема={post.topic_name}") # Уведомляем админа