feat(meter_reminder): add monthly utility meter reading reminders with toggle option in profile menu

This commit is contained in:
Admin 2026-06-23 12:21:53 +04:00
parent 1008ecaff9
commit c8d01adec5
7 changed files with 155 additions and 12 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

View file

@ -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())

View file

@ -53,6 +53,7 @@ class User(Base):
staircase = Column(Integer, nullable=True) # Подъезд staircase = Column(Integer, nullable=True) # Подъезд
floor = Column(Integer, nullable=True) # Этаж floor = Column(Integer, nullable=True) # Этаж
has_ai_access = Column(Boolean, default=False) # Доступ к ИИ-консультанту has_ai_access = Column(Boolean, default=False) # Доступ к ИИ-консультанту
meter_reminders_enabled = Column(Boolean, default=True) # Напоминания о показаниях счетчиков
# Для анти-спама # Для анти-спама
spam_warning_count = Column(Integer, default=0) # Количество предупреждений за спам spam_warning_count = Column(Integer, default=0) # Количество предупреждений за спам

View file

@ -205,7 +205,7 @@ async def cb_profile(event):
f"Подъезд: {user.staircase or ''} | Этаж: {user.floor or ''}\n" f"Подъезд: {user.staircase or ''} | Этаж: {user.floor or ''}\n"
f"Рейтинг: {user.rating} 🏆") f"Рейтинг: {user.rating} 🏆")
kb = get_profile_keyboard() kb = get_profile_keyboard(user.meter_reminders_enabled if user else True)
if isinstance(event, Message): if isinstance(event, Message):
await event.answer(text, reply_markup=kb, parse_mode='HTML') await event.answer(text, reply_markup=kb, parse_mode='HTML')
@ -213,6 +213,39 @@ async def cb_profile(event):
await event.answer() await event.answer()
await event.message.edit_text(text, reply_markup=kb, parse_mode='HTML') 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"👤 <b>ТВОЙ ПРОФИЛЬ</b>\n\n"
f"ID: <code>{user_id}</code>\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") @router.callback_query(F.data == "edit_location")
async def edit_location_start(callback: CallbackQuery): async def edit_location_start(callback: CallbackQuery):
await callback.message.edit_text("Введите ваш подъезд и этаж через пробел\n<i>Например: '3 5' (3 подъезд, 5 этаж)</i>", parse_mode='HTML') await callback.message.edit_text("Введите ваш подъезд и этаж через пробел\n<i>Например: '3 5' (3 подъезд, 5 этаж)</i>", parse_mode='HTML')
@ -225,10 +258,15 @@ async def process_location(message: Message):
async with AsyncSessionLocal() as session: 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.execute(update(User).where(User.user_id == message.from_user.id).values(staircase=staircase, floor=floor))
await session.commit() 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: except Exception as e:
await message.answer("❌ Ошибка формата. Введите два числа через пробел.") await message.answer("❌ Ошибка формата. Введите два числа через пробел.")
@router.message(Command('help')) @router.message(Command('help'))
@router.callback_query(F.data == 'help') @router.callback_query(F.data == 'help')
async def cb_help(event): async def cb_help(event):

View file

@ -402,9 +402,15 @@ def get_poll_view_keyboard(poll_id: int, is_admin: bool = False) -> InlineKeyboa
return builder.as_markup() return builder.as_markup()
def get_profile_keyboard() -> InlineKeyboardMarkup: def get_profile_keyboard(meter_reminders_enabled: bool = True) -> InlineKeyboardMarkup:
"""Клавиатура профиля""" """Клавиатура профиля"""
builder = InlineKeyboardBuilder() 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='my_profile'))
builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu')) builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'))
return builder.as_markup() return builder.as_markup()

View file

@ -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 = (
"⏰ <b>Не забудьте отправить показания счетчиков!</b>\n\n"
"Уважаемые жильцы, пришло время подать показания приборов учета воды и электричества. "
"Пожалуйста, передайте данные до 25-го числа включительно.\n\n"
"<i>Вы можете отключить или включить эти напоминания в своем профиле в любой момент (команда /profile).</i>"
)
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

View file

@ -89,6 +89,16 @@ class Scheduler:
replace_existing=True 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.scheduler.add_job(
self.hourly_event_reminders, self.hourly_event_reminders,
@ -273,6 +283,15 @@ class Scheduler:
except Exception as e: except Exception as e:
logger.error(f'Ошибка напоминаний о платежах: {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): async def hourly_event_reminders(self):
"""Ежечасная проверка напоминаний о событиях""" """Ежечасная проверка напоминаний о событиях"""
try: try:
@ -397,7 +416,9 @@ class Scheduler:
async with http_session.post(url, json=payload) as r: async with http_session.post(url, json=payload) as r:
return (await r.json())['result']['message_id'] if r.status == 200 else None return (await r.json())['result']['message_id'] if r.status == 200 else None
msg_id = None
# 1. Отправка в основной чат # 1. Отправка в основной чат
if post.recipients in ['chat_only', 'all_and_chat']:
msg_id = await send_content(ADMIN_CHAT_ID, thread_id=post.topic_id) msg_id = await send_content(ADMIN_CHAT_ID, thread_id=post.topic_id)
# 2. Если нужно, рассылка жильцам в личку # 2. Если нужно, рассылка жильцам в личку
@ -415,14 +436,6 @@ class Scheduler:
post.sent_at = datetime.utcnow() post.sent_at = datetime.utcnow()
post.message_id = msg_id post.message_id = msg_id
await session.commit() 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}") logger.info(f"✅ Пост отправлен: id={post.id}, тема={post.topic_name}")
# Уведомляем админа # Уведомляем админа