fix problems
This commit is contained in:
parent
30d43be793
commit
acc3d50f28
Binary file not shown.
|
|
@ -1,14 +1,12 @@
|
||||||
"""Scheduler for sending reminder notifications."""
|
"""Scheduler for sending reminder notifications."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
from bot.db.base import get_session
|
|
||||||
from bot.db.operations import get_due_reminders
|
from bot.db.operations import get_due_reminders
|
||||||
from bot.keyboards.reminders import get_reminder_notification_keyboard
|
from bot.keyboards.reminders import get_reminder_notification_keyboard
|
||||||
from bot.services.time_service import get_time_service
|
from bot.services.time_service import get_time_service
|
||||||
|
|
@ -50,7 +48,6 @@ async def check_and_send_reminders(bot: Bot) -> None:
|
||||||
bot: Bot instance
|
bot: Bot instance
|
||||||
"""
|
"""
|
||||||
from bot.db.base import async_session_maker
|
from bot.db.base import async_session_maker
|
||||||
from bot.db.operations import update_reminder
|
|
||||||
|
|
||||||
if not async_session_maker:
|
if not async_session_maker:
|
||||||
logger.error("Session maker not initialized")
|
logger.error("Session maker not initialized")
|
||||||
|
|
@ -60,7 +57,6 @@ async def check_and_send_reminders(bot: Bot) -> None:
|
||||||
time_service = get_time_service()
|
time_service = get_time_service()
|
||||||
current_time = time_service.get_now()
|
current_time = time_service.get_now()
|
||||||
|
|
||||||
# Get due reminders from database using proper async session
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
due_reminders = await get_due_reminders(session, current_time)
|
due_reminders = await get_due_reminders(session, current_time)
|
||||||
|
|
||||||
|
|
@ -70,7 +66,6 @@ async def check_and_send_reminders(bot: Bot) -> None:
|
||||||
|
|
||||||
logger.info(f"Found {len(due_reminders)} due reminders")
|
logger.info(f"Found {len(due_reminders)} due reminders")
|
||||||
|
|
||||||
# Send notifications
|
|
||||||
for reminder in due_reminders:
|
for reminder in due_reminders:
|
||||||
await send_reminder_notification(
|
await send_reminder_notification(
|
||||||
bot=bot,
|
bot=bot,
|
||||||
|
|
@ -79,20 +74,17 @@ async def check_and_send_reminders(bot: Bot) -> None:
|
||||||
text=reminder.text,
|
text=reminder.text,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update next_run_at to prevent sending again
|
next_run = time_service.calculate_next_occurrence(
|
||||||
# (it will be properly updated when user clicks "Done" or by periodic update)
|
|
||||||
temp_next_run = time_service.calculate_next_occurrence(
|
|
||||||
current_run=reminder.next_run_at,
|
current_run=reminder.next_run_at,
|
||||||
days_interval=reminder.days_interval,
|
days_interval=reminder.days_interval,
|
||||||
)
|
)
|
||||||
await update_reminder(session, reminder.id, next_run_at=temp_next_run)
|
reminder.next_run_at = next_run
|
||||||
|
reminder.updated_at = time_service.get_now()
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
# Commit all updates
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Small delay to avoid rate limits
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in check_and_send_reminders: {e}", exc_info=True)
|
logger.error(f"Error in check_and_send_reminders: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -8,6 +8,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from bot.db.base import Base
|
from bot.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now_local() -> datetime:
|
||||||
|
from bot.services.time_service import get_time_service
|
||||||
|
return get_time_service().get_now()
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""User model - represents Telegram users."""
|
"""User model - represents Telegram users."""
|
||||||
|
|
||||||
|
|
@ -18,9 +23,9 @@ class User(Base):
|
||||||
username: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
username: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
first_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
first_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
last_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
last_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_local, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
DateTime, default=_now_local, onupdate=_now_local, nullable=False
|
||||||
)
|
)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
|
@ -44,9 +49,9 @@ class Reminder(Base):
|
||||||
next_run_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
next_run_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
last_done_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
last_done_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_local, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
DateTime, default=_now_local, onupdate=_now_local, nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optional fields for statistics
|
# Optional fields for statistics
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""CRUD operations for database models."""
|
"""CRUD operations for database models."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, time
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from sqlalchemy import select, update, delete
|
from sqlalchemy import select, update, delete
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.db.models import User, Reminder
|
from bot.db.models import User, Reminder, _now_local
|
||||||
from bot.logging_config import get_logger
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
@ -46,7 +46,7 @@ async def get_or_create_user(
|
||||||
user.username = username
|
user.username = username
|
||||||
user.first_name = first_name
|
user.first_name = first_name
|
||||||
user.last_name = last_name
|
user.last_name = last_name
|
||||||
user.updated_at = datetime.utcnow()
|
user.updated_at = _now_local()
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.debug(f"Updated user info: {tg_user_id}")
|
logger.debug(f"Updated user info: {tg_user_id}")
|
||||||
return user
|
return user
|
||||||
|
|
@ -90,7 +90,7 @@ async def create_reminder(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
text: str,
|
text: str,
|
||||||
days_interval: int,
|
days_interval: int,
|
||||||
time_of_day: datetime.time,
|
time_of_day: time,
|
||||||
next_run_at: datetime,
|
next_run_at: datetime,
|
||||||
) -> Reminder:
|
) -> Reminder:
|
||||||
"""
|
"""
|
||||||
|
|
@ -212,7 +212,7 @@ async def update_reminder(
|
||||||
if hasattr(reminder, key):
|
if hasattr(reminder, key):
|
||||||
setattr(reminder, key, value)
|
setattr(reminder, key, value)
|
||||||
|
|
||||||
reminder.updated_at = datetime.utcnow()
|
reminder.updated_at = _now_local()
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(reminder)
|
await session.refresh(reminder)
|
||||||
logger.debug(f"Updated reminder {reminder_id}")
|
logger.debug(f"Updated reminder {reminder_id}")
|
||||||
|
|
@ -261,10 +261,10 @@ async def mark_reminder_done(
|
||||||
if not reminder:
|
if not reminder:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
reminder.last_done_at = datetime.utcnow()
|
reminder.last_done_at = _now_local()
|
||||||
reminder.next_run_at = next_run_at
|
reminder.next_run_at = next_run_at
|
||||||
reminder.total_done_count += 1
|
reminder.total_done_count += 1
|
||||||
reminder.updated_at = datetime.utcnow()
|
reminder.updated_at = _now_local()
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(reminder)
|
await session.refresh(reminder)
|
||||||
|
|
@ -294,7 +294,7 @@ async def snooze_reminder(
|
||||||
|
|
||||||
reminder.next_run_at = next_run_at
|
reminder.next_run_at = next_run_at
|
||||||
reminder.snooze_count += 1
|
reminder.snooze_count += 1
|
||||||
reminder.updated_at = datetime.utcnow()
|
reminder.updated_at = _now_local()
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(reminder)
|
await session.refresh(reminder)
|
||||||
|
|
@ -323,7 +323,7 @@ async def toggle_reminder_active(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
reminder.is_active = is_active
|
reminder.is_active = is_active
|
||||||
reminder.updated_at = datetime.utcnow()
|
reminder.updated_at = _now_local()
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(reminder)
|
await session.refresh(reminder)
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -20,10 +20,36 @@ logger = get_logger(__name__)
|
||||||
|
|
||||||
router = Router(name="callbacks")
|
router = Router(name="callbacks")
|
||||||
reminders_service = RemindersService()
|
reminders_service = RemindersService()
|
||||||
time_service = get_time_service()
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Done Action ====================
|
async def _verify_owner(
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
tg_user_id: int,
|
||||||
|
) -> bool:
|
||||||
|
"""Fetch reminder and verify it belongs to tg_user_id."""
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy import select
|
||||||
|
from bot.db.models import Reminder
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Reminder)
|
||||||
|
.options(selectinload(Reminder.user))
|
||||||
|
.where(Reminder.id == reminder_id)
|
||||||
|
)
|
||||||
|
reminder = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
return False
|
||||||
|
if reminder.user.tg_user_id != tg_user_id:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "noop")
|
||||||
|
async def handle_noop(callback: CallbackQuery) -> None:
|
||||||
|
"""Handle noop callback (page counter button)."""
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(ReminderActionCallback.filter(F.action == "done"))
|
@router.callback_query(ReminderActionCallback.filter(F.action == "done"))
|
||||||
|
|
@ -32,21 +58,18 @@ async def handle_done(
|
||||||
callback_data: ReminderActionCallback,
|
callback_data: ReminderActionCallback,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Handle 'Done' button - mark reminder as completed."""
|
||||||
Handle 'Done' button - mark reminder as completed.
|
owner_check = await _verify_owner(session, callback_data.reminder_id, callback.from_user.id)
|
||||||
|
if not owner_check:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Callback query
|
|
||||||
callback_data: Parsed callback data
|
|
||||||
session: Database session
|
|
||||||
"""
|
|
||||||
reminder = await reminders_service.mark_as_done(session, callback_data.reminder_id)
|
reminder = await reminders_service.mark_as_done(session, callback_data.reminder_id)
|
||||||
|
|
||||||
if not reminder:
|
if not reminder:
|
||||||
await callback.answer("Напоминание не найдено", show_alert=True)
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
next_run_str = time_service.format_next_run(reminder.next_run_at)
|
next_run_str = get_time_service().format_next_run(reminder.next_run_at)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
f"✅ Отлично! Отметил как выполненное.\n\n"
|
f"✅ Отлично! Отметил как выполненное.\n\n"
|
||||||
|
|
@ -57,21 +80,18 @@ async def handle_done(
|
||||||
logger.info(f"Reminder {reminder.id} marked as done by user {callback.from_user.id}")
|
logger.info(f"Reminder {reminder.id} marked as done by user {callback.from_user.id}")
|
||||||
|
|
||||||
|
|
||||||
# ==================== Snooze Action ====================
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(ReminderActionCallback.filter(F.action == "snooze"))
|
@router.callback_query(ReminderActionCallback.filter(F.action == "snooze"))
|
||||||
async def handle_snooze_select(
|
async def handle_snooze_select(
|
||||||
callback: CallbackQuery,
|
callback: CallbackQuery,
|
||||||
callback_data: ReminderActionCallback,
|
callback_data: ReminderActionCallback,
|
||||||
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Handle 'Snooze' button - show delay options."""
|
||||||
Handle 'Snooze' button - show delay options.
|
owner_check = await _verify_owner(session, callback_data.reminder_id, callback.from_user.id)
|
||||||
|
if not owner_check:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Callback query
|
|
||||||
callback_data: Parsed callback data
|
|
||||||
"""
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
"На сколько отложить напоминание?",
|
"На сколько отложить напоминание?",
|
||||||
reply_markup=get_snooze_delay_keyboard(callback_data.reminder_id),
|
reply_markup=get_snooze_delay_keyboard(callback_data.reminder_id),
|
||||||
|
|
@ -85,23 +105,20 @@ async def handle_snooze_delay(
|
||||||
callback_data: SnoozeDelayCallback,
|
callback_data: SnoozeDelayCallback,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Handle snooze delay selection."""
|
||||||
Handle snooze delay selection.
|
owner_check = await _verify_owner(session, callback_data.reminder_id, callback.from_user.id)
|
||||||
|
if not owner_check:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Callback query
|
|
||||||
callback_data: Parsed callback data
|
|
||||||
session: Database session
|
|
||||||
"""
|
|
||||||
reminder = await reminders_service.snooze(
|
reminder = await reminders_service.snooze(
|
||||||
session, callback_data.reminder_id, callback_data.hours
|
session, callback_data.reminder_id, callback_data.hours
|
||||||
)
|
)
|
||||||
|
|
||||||
if not reminder:
|
if not reminder:
|
||||||
await callback.answer("Напоминание не найдено", show_alert=True)
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
next_run_str = time_service.format_next_run(reminder.next_run_at)
|
next_run_str = get_time_service().format_next_run(reminder.next_run_at)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
f"⏰ Напоминание отложено.\n\n"
|
f"⏰ Напоминание отложено.\n\n"
|
||||||
|
|
@ -115,25 +132,19 @@ async def handle_snooze_delay(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==================== Pause/Resume Actions ====================
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(ReminderActionCallback.filter(F.action == "pause"))
|
@router.callback_query(ReminderActionCallback.filter(F.action == "pause"))
|
||||||
async def handle_pause(
|
async def handle_pause(
|
||||||
callback: CallbackQuery,
|
callback: CallbackQuery,
|
||||||
callback_data: ReminderActionCallback,
|
callback_data: ReminderActionCallback,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Handle 'Pause' button - deactivate reminder."""
|
||||||
Handle 'Pause' button - deactivate reminder.
|
owner_check = await _verify_owner(session, callback_data.reminder_id, callback.from_user.id)
|
||||||
|
if not owner_check:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Callback query
|
|
||||||
callback_data: Parsed callback data
|
|
||||||
session: Database session
|
|
||||||
"""
|
|
||||||
reminder = await reminders_service.pause_reminder(session, callback_data.reminder_id)
|
reminder = await reminders_service.pause_reminder(session, callback_data.reminder_id)
|
||||||
|
|
||||||
if not reminder:
|
if not reminder:
|
||||||
await callback.answer("Напоминание не найдено", show_alert=True)
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
@ -153,21 +164,18 @@ async def handle_resume(
|
||||||
callback_data: ReminderActionCallback,
|
callback_data: ReminderActionCallback,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Handle 'Resume' button - reactivate reminder."""
|
||||||
Handle 'Resume' button - reactivate reminder.
|
owner_check = await _verify_owner(session, callback_data.reminder_id, callback.from_user.id)
|
||||||
|
if not owner_check:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Callback query
|
|
||||||
callback_data: Parsed callback data
|
|
||||||
session: Database session
|
|
||||||
"""
|
|
||||||
reminder = await reminders_service.resume_reminder(session, callback_data.reminder_id)
|
reminder = await reminders_service.resume_reminder(session, callback_data.reminder_id)
|
||||||
|
|
||||||
if not reminder:
|
if not reminder:
|
||||||
await callback.answer("Напоминание не найдено", show_alert=True)
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
next_run_str = time_service.format_next_run(reminder.next_run_at)
|
next_run_str = get_time_service().format_next_run(reminder.next_run_at)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
f"▶️ Напоминание возобновлено!\n\n"
|
f"▶️ Напоминание возобновлено!\n\n"
|
||||||
|
|
@ -178,21 +186,18 @@ async def handle_resume(
|
||||||
logger.info(f"Reminder {reminder.id} resumed by user {callback.from_user.id}")
|
logger.info(f"Reminder {reminder.id} resumed by user {callback.from_user.id}")
|
||||||
|
|
||||||
|
|
||||||
# ==================== Delete Action ====================
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(ReminderActionCallback.filter(F.action == "delete"))
|
@router.callback_query(ReminderActionCallback.filter(F.action == "delete"))
|
||||||
async def handle_delete_confirm(
|
async def handle_delete_confirm(
|
||||||
callback: CallbackQuery,
|
callback: CallbackQuery,
|
||||||
callback_data: ReminderActionCallback,
|
callback_data: ReminderActionCallback,
|
||||||
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Handle 'Delete' button - ask for confirmation."""
|
||||||
Handle 'Delete' button - ask for confirmation.
|
owner_check = await _verify_owner(session, callback_data.reminder_id, callback.from_user.id)
|
||||||
|
if not owner_check:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Callback query
|
|
||||||
callback_data: Parsed callback data
|
|
||||||
"""
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
"Точно удалить напоминание?",
|
"Точно удалить напоминание?",
|
||||||
reply_markup=get_confirmation_keyboard(
|
reply_markup=get_confirmation_keyboard(
|
||||||
|
|
@ -209,14 +214,7 @@ async def handle_delete_execute(
|
||||||
callback_data: ConfirmCallback,
|
callback_data: ConfirmCallback,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Execute reminder deletion after confirmation."""
|
||||||
Execute reminder deletion after confirmation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Callback query
|
|
||||||
callback_data: Parsed callback data
|
|
||||||
session: Database session
|
|
||||||
"""
|
|
||||||
if callback_data.action == "no":
|
if callback_data.action == "no":
|
||||||
await callback.message.edit_text("Удаление отменено.")
|
await callback.message.edit_text("Удаление отменено.")
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
@ -238,17 +236,9 @@ async def handle_delete_execute(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==================== Settings Placeholder ====================
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "⚙️ Настройки")
|
@router.message(F.text == "⚙️ Настройки")
|
||||||
async def handle_settings(message: Message) -> None:
|
async def handle_settings(message: Message) -> None:
|
||||||
"""
|
"""Handle settings button (placeholder)."""
|
||||||
Handle settings button (placeholder).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: Telegram message
|
|
||||||
"""
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"⚙️ Настройки\n\n"
|
"⚙️ Настройки\n\n"
|
||||||
"Функционал настроек будет добавлен в следующих версиях.\n\n"
|
"Функционал настроек будет добавлен в следующих версиях.\n\n"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
from aiogram.types import ErrorEvent
|
from aiogram.types import ErrorEvent
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
|
||||||
|
|
||||||
from bot.logging_config import get_logger
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
|
@ -12,7 +11,7 @@ router = Router(name="errors")
|
||||||
|
|
||||||
|
|
||||||
@router.error()
|
@router.error()
|
||||||
async def error_handler(event: ErrorEvent) -> None:
|
async def error_handler(event: ErrorEvent) -> bool:
|
||||||
"""
|
"""
|
||||||
Global error handler for all unhandled exceptions.
|
Global error handler for all unhandled exceptions.
|
||||||
|
|
||||||
|
|
@ -41,3 +40,5 @@ async def error_handler(event: ErrorEvent) -> None:
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send error callback to user: {e}")
|
logger.error(f"Failed to send error callback to user: {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ logger = get_logger(__name__)
|
||||||
|
|
||||||
router = Router(name="reminders_create")
|
router = Router(name="reminders_create")
|
||||||
reminders_service = RemindersService()
|
reminders_service = RemindersService()
|
||||||
time_service = get_time_service()
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "➕ Новое напоминание")
|
@router.message(F.text == "➕ Новое напоминание")
|
||||||
|
|
@ -42,7 +41,7 @@ async def start_create_reminder(message: Message, state: FSMContext) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(CreateReminderStates.waiting_for_text)
|
@router.message(CreateReminderStates.waiting_for_text, F.text)
|
||||||
async def process_reminder_text(message: Message, state: FSMContext) -> None:
|
async def process_reminder_text(message: Message, state: FSMContext) -> None:
|
||||||
"""
|
"""
|
||||||
Process reminder text input.
|
Process reminder text input.
|
||||||
|
|
@ -106,7 +105,7 @@ async def process_interval_button(
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@router.message(CreateReminderStates.waiting_for_interval)
|
@router.message(CreateReminderStates.waiting_for_interval, F.text)
|
||||||
async def process_interval_text(message: Message, state: FSMContext) -> None:
|
async def process_interval_text(message: Message, state: FSMContext) -> None:
|
||||||
"""
|
"""
|
||||||
Process interval input as text.
|
Process interval input as text.
|
||||||
|
|
@ -134,7 +133,7 @@ async def process_interval_text(message: Message, state: FSMContext) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(CreateReminderStates.waiting_for_time)
|
@router.message(CreateReminderStates.waiting_for_time, F.text)
|
||||||
async def process_reminder_time(message: Message, state: FSMContext) -> None:
|
async def process_reminder_time(message: Message, state: FSMContext) -> None:
|
||||||
"""
|
"""
|
||||||
Process reminder time input.
|
Process reminder time input.
|
||||||
|
|
@ -225,7 +224,7 @@ async def process_create_confirmation(
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
# Format next run time
|
# Format next run time
|
||||||
next_run_str = time_service.format_next_run(reminder.next_run_at)
|
next_run_str = get_time_service().format_next_run(reminder.next_run_at)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
f"✅ Напоминание создано!\n\n"
|
f"✅ Напоминание создано!\n\n"
|
||||||
|
|
@ -238,3 +237,11 @@ async def process_create_confirmation(
|
||||||
await callback.answer("Напоминание создано!")
|
await callback.answer("Напоминание создано!")
|
||||||
|
|
||||||
logger.info(f"User {user.tg_user_id} created reminder {reminder.id}")
|
logger.info(f"User {user.tg_user_id} created reminder {reminder.id}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CreateReminderStates.waiting_for_text)
|
||||||
|
@router.message(CreateReminderStates.waiting_for_interval)
|
||||||
|
@router.message(CreateReminderStates.waiting_for_time)
|
||||||
|
async def fsm_non_text_fallback(message: Message) -> None:
|
||||||
|
"""Fallback for non-text messages during FSM creation flow."""
|
||||||
|
await message.answer("Пожалуйста, отправь текстовое сообщение.")
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ async def edit_text_start(
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@router.message(EditReminderStates.editing_text)
|
@router.message(EditReminderStates.editing_text, F.text)
|
||||||
async def edit_text_process(
|
async def edit_text_process(
|
||||||
message: Message,
|
message: Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
|
|
@ -368,7 +368,7 @@ async def edit_interval_button(
|
||||||
await callback.answer("Обновлено!")
|
await callback.answer("Обновлено!")
|
||||||
|
|
||||||
|
|
||||||
@router.message(EditReminderStates.editing_interval)
|
@router.message(EditReminderStates.editing_interval, F.text)
|
||||||
async def edit_interval_text(
|
async def edit_interval_text(
|
||||||
message: Message,
|
message: Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
|
|
@ -429,7 +429,7 @@ async def edit_time_start(
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@router.message(EditReminderStates.editing_time)
|
@router.message(EditReminderStates.editing_time, F.text)
|
||||||
async def edit_time_process(
|
async def edit_time_process(
|
||||||
message: Message,
|
message: Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
|
|
@ -466,3 +466,11 @@ async def edit_time_process(
|
||||||
f"✅ Время обновлено!\n\nНовое время: {time_of_day.strftime('%H:%M')}",
|
f"✅ Время обновлено!\n\nНовое время: {time_of_day.strftime('%H:%M')}",
|
||||||
reply_markup=get_main_menu_keyboard(),
|
reply_markup=get_main_menu_keyboard(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(EditReminderStates.editing_text)
|
||||||
|
@router.message(EditReminderStates.editing_interval)
|
||||||
|
@router.message(EditReminderStates.editing_time)
|
||||||
|
async def edit_non_text_fallback(message: Message) -> None:
|
||||||
|
"""Fallback for non-text messages during FSM edit flow."""
|
||||||
|
await message.answer("Пожалуйста, отправь текстовое сообщение.")
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -5,6 +5,7 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.filters.callback_data import CallbackData
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
|
||||||
from bot.db.models import Reminder
|
from bot.db.models import Reminder
|
||||||
|
from bot.utils.formatting import truncate_text
|
||||||
|
|
||||||
|
|
||||||
class PaginationCallback(CallbackData, prefix="page"):
|
class PaginationCallback(CallbackData, prefix="page"):
|
||||||
|
|
@ -39,7 +40,7 @@ def get_reminders_list_keyboard(
|
||||||
# Add buttons for reminders on current page
|
# Add buttons for reminders on current page
|
||||||
for reminder in reminders[start_idx:end_idx]:
|
for reminder in reminders[start_idx:end_idx]:
|
||||||
status_icon = "✅" if reminder.is_active else "⏸"
|
status_icon = "✅" if reminder.is_active else "⏸"
|
||||||
button_text = f"{status_icon} #{reminder.id} {reminder.text[:30]}..."
|
button_text = f"{status_icon} #{reminder.id} {truncate_text(reminder.text, 30)}"
|
||||||
|
|
||||||
buttons.append([
|
buttons.append([
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -25,9 +25,9 @@ logger = get_logger(__name__)
|
||||||
class RemindersService:
|
class RemindersService:
|
||||||
"""Service for reminder-related operations."""
|
"""Service for reminder-related operations."""
|
||||||
|
|
||||||
def __init__(self):
|
@property
|
||||||
"""Initialize RemindersService."""
|
def time_service(self):
|
||||||
self.time_service = get_time_service()
|
return get_time_service()
|
||||||
|
|
||||||
async def create_new_reminder(
|
async def create_new_reminder(
|
||||||
self,
|
self,
|
||||||
|
|
@ -50,13 +50,10 @@ class RemindersService:
|
||||||
Returns:
|
Returns:
|
||||||
Created Reminder instance
|
Created Reminder instance
|
||||||
"""
|
"""
|
||||||
# Calculate next run time
|
next_run_at = self.time_service.calculate_first_run_time(
|
||||||
next_run_at = self.time_service.calculate_next_run_time(
|
|
||||||
time_of_day=time_of_day,
|
time_of_day=time_of_day,
|
||||||
days_interval=days_interval,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create reminder
|
|
||||||
reminder = await create_reminder(
|
reminder = await create_reminder(
|
||||||
session=session,
|
session=session,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|
@ -134,7 +131,7 @@ class RemindersService:
|
||||||
new_days_interval: int,
|
new_days_interval: int,
|
||||||
) -> Optional[Reminder]:
|
) -> Optional[Reminder]:
|
||||||
"""
|
"""
|
||||||
Update reminder interval.
|
Update reminder interval and recalculate next_run_at with new interval.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
|
|
@ -148,8 +145,7 @@ class RemindersService:
|
||||||
if not reminder:
|
if not reminder:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Recalculate next_run_at with new interval
|
next_run_at = self.time_service.calculate_next_run_with_interval(
|
||||||
next_run_at = self.time_service.calculate_next_run_time(
|
|
||||||
time_of_day=reminder.time_of_day,
|
time_of_day=reminder.time_of_day,
|
||||||
days_interval=new_days_interval,
|
days_interval=new_days_interval,
|
||||||
)
|
)
|
||||||
|
|
@ -168,7 +164,7 @@ class RemindersService:
|
||||||
new_time_of_day: time,
|
new_time_of_day: time,
|
||||||
) -> Optional[Reminder]:
|
) -> Optional[Reminder]:
|
||||||
"""
|
"""
|
||||||
Update reminder time.
|
Update reminder time and recalculate next_run_at.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
|
|
@ -182,10 +178,8 @@ class RemindersService:
|
||||||
if not reminder:
|
if not reminder:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Recalculate next_run_at with new time
|
next_run_at = self.time_service.calculate_first_run_time(
|
||||||
next_run_at = self.time_service.calculate_next_run_time(
|
|
||||||
time_of_day=new_time_of_day,
|
time_of_day=new_time_of_day,
|
||||||
days_interval=reminder.days_interval,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return await update_reminder(
|
return await update_reminder(
|
||||||
|
|
@ -231,7 +225,6 @@ class RemindersService:
|
||||||
if not reminder:
|
if not reminder:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Calculate next occurrence
|
|
||||||
next_run_at = self.time_service.calculate_next_occurrence(
|
next_run_at = self.time_service.calculate_next_occurrence(
|
||||||
current_run=reminder.next_run_at,
|
current_run=reminder.next_run_at,
|
||||||
days_interval=reminder.days_interval,
|
days_interval=reminder.days_interval,
|
||||||
|
|
@ -284,7 +277,7 @@ class RemindersService:
|
||||||
reminder_id: int,
|
reminder_id: int,
|
||||||
) -> Optional[Reminder]:
|
) -> Optional[Reminder]:
|
||||||
"""
|
"""
|
||||||
Resume (activate) a reminder.
|
Resume (activate) a reminder. Recalculates next_run_at atomically.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
|
|
@ -297,12 +290,13 @@ class RemindersService:
|
||||||
if not reminder:
|
if not reminder:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Recalculate next_run_at from now
|
next_run_at = self.time_service.calculate_first_run_time(
|
||||||
next_run_at = self.time_service.calculate_next_run_time(
|
|
||||||
time_of_day=reminder.time_of_day,
|
time_of_day=reminder.time_of_day,
|
||||||
days_interval=reminder.days_interval,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update and activate
|
return await update_reminder(
|
||||||
await update_reminder(session, reminder_id, next_run_at=next_run_at)
|
session,
|
||||||
return await toggle_reminder_active(session, reminder_id, is_active=True)
|
reminder_id,
|
||||||
|
next_run_at=next_run_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -46,14 +46,41 @@ class TimeService:
|
||||||
"""
|
"""
|
||||||
return datetime.combine(date.date(), time_of_day)
|
return datetime.combine(date.date(), time_of_day)
|
||||||
|
|
||||||
def calculate_next_run_time(
|
def calculate_first_run_time(
|
||||||
|
self,
|
||||||
|
time_of_day: time,
|
||||||
|
from_datetime: Optional[datetime] = None,
|
||||||
|
) -> datetime:
|
||||||
|
"""
|
||||||
|
Calculate the nearest future occurrence of time_of_day (today or tomorrow).
|
||||||
|
Used for new reminders and resume.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_of_day: Desired time of day
|
||||||
|
from_datetime: Base datetime (defaults to now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Next run datetime (today if time hasn't passed, otherwise tomorrow)
|
||||||
|
"""
|
||||||
|
if from_datetime is None:
|
||||||
|
from_datetime = self.get_now()
|
||||||
|
|
||||||
|
next_run = self.combine_date_time(from_datetime, time_of_day)
|
||||||
|
|
||||||
|
if next_run <= from_datetime:
|
||||||
|
next_run += timedelta(days=1)
|
||||||
|
|
||||||
|
return next_run
|
||||||
|
|
||||||
|
def calculate_next_run_with_interval(
|
||||||
self,
|
self,
|
||||||
time_of_day: time,
|
time_of_day: time,
|
||||||
days_interval: int,
|
days_interval: int,
|
||||||
from_datetime: Optional[datetime] = None,
|
from_datetime: Optional[datetime] = None,
|
||||||
) -> datetime:
|
) -> datetime:
|
||||||
"""
|
"""
|
||||||
Calculate next run datetime for a reminder.
|
Calculate next run datetime using interval offset from now.
|
||||||
|
Used when interval is changed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
time_of_day: Desired time of day
|
time_of_day: Desired time of day
|
||||||
|
|
@ -61,19 +88,13 @@ class TimeService:
|
||||||
from_datetime: Base datetime (defaults to now)
|
from_datetime: Base datetime (defaults to now)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Next run datetime
|
Datetime at time_of_day, days_interval days from from_datetime
|
||||||
"""
|
"""
|
||||||
if from_datetime is None:
|
if from_datetime is None:
|
||||||
from_datetime = self.get_now()
|
from_datetime = self.get_now()
|
||||||
|
|
||||||
# Start with today at the specified time
|
target_date = from_datetime + timedelta(days=days_interval)
|
||||||
next_run = self.combine_date_time(from_datetime, time_of_day)
|
return self.combine_date_time(target_date, time_of_day)
|
||||||
|
|
||||||
# If the time has already passed today, start from tomorrow
|
|
||||||
if next_run <= from_datetime:
|
|
||||||
next_run += timedelta(days=1)
|
|
||||||
|
|
||||||
return next_run
|
|
||||||
|
|
||||||
def calculate_next_occurrence(
|
def calculate_next_occurrence(
|
||||||
self,
|
self,
|
||||||
|
|
@ -81,16 +102,23 @@ class TimeService:
|
||||||
days_interval: int,
|
days_interval: int,
|
||||||
) -> datetime:
|
) -> datetime:
|
||||||
"""
|
"""
|
||||||
Calculate next occurrence after a completed reminder.
|
Calculate next occurrence after a completed/sent reminder.
|
||||||
|
Guarantees the result is in the future.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
current_run: Current run datetime
|
current_run: Current run datetime
|
||||||
days_interval: Days between reminders
|
days_interval: Days between reminders
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Next occurrence datetime
|
Next occurrence datetime (always in the future)
|
||||||
"""
|
"""
|
||||||
return current_run + timedelta(days=days_interval)
|
now = self.get_now()
|
||||||
|
next_run = current_run + timedelta(days=days_interval)
|
||||||
|
|
||||||
|
while next_run <= now:
|
||||||
|
next_run += timedelta(days=days_interval)
|
||||||
|
|
||||||
|
return next_run
|
||||||
|
|
||||||
def add_hours(self, dt: datetime, hours: int) -> datetime:
|
def add_hours(self, dt: datetime, hours: int) -> datetime:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -15,7 +15,9 @@ def validate_time_format(time_str: str) -> Optional[time]:
|
||||||
Returns:
|
Returns:
|
||||||
time object if valid, None otherwise
|
time object if valid, None otherwise
|
||||||
"""
|
"""
|
||||||
# Remove extra whitespace
|
if not time_str:
|
||||||
|
return None
|
||||||
|
|
||||||
time_str = time_str.strip()
|
time_str = time_str.strip()
|
||||||
|
|
||||||
# Check format with regex
|
# Check format with regex
|
||||||
|
|
@ -47,6 +49,9 @@ def validate_days_interval(days_str: str) -> Optional[int]:
|
||||||
Returns:
|
Returns:
|
||||||
Integer days if valid (>0 and <=365), None otherwise
|
Integer days if valid (>0 and <=365), None otherwise
|
||||||
"""
|
"""
|
||||||
|
if not days_str:
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
days = int(days_str.strip())
|
days = int(days_str.strip())
|
||||||
if 0 < days <= 365: # Max 1 year
|
if 0 < days <= 365: # Max 1 year
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue