diff --git a/bot/core/__pycache__/scheduler.cpython-312.pyc b/bot/core/__pycache__/scheduler.cpython-312.pyc index 4c21641..5741813 100644 Binary files a/bot/core/__pycache__/scheduler.cpython-312.pyc and b/bot/core/__pycache__/scheduler.cpython-312.pyc differ diff --git a/bot/core/scheduler.py b/bot/core/scheduler.py index 583221b..0f80a17 100644 --- a/bot/core/scheduler.py +++ b/bot/core/scheduler.py @@ -1,14 +1,12 @@ """Scheduler for sending reminder notifications.""" import asyncio -from datetime import datetime from typing import Optional from aiogram import Bot from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger -from bot.db.base import get_session from bot.db.operations import get_due_reminders from bot.keyboards.reminders import get_reminder_notification_keyboard 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 """ from bot.db.base import async_session_maker - from bot.db.operations import update_reminder if not async_session_maker: logger.error("Session maker not initialized") @@ -60,7 +57,6 @@ async def check_and_send_reminders(bot: Bot) -> None: time_service = get_time_service() current_time = time_service.get_now() - # Get due reminders from database using proper async session async with async_session_maker() as session: 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") - # Send notifications for reminder in due_reminders: await send_reminder_notification( bot=bot, @@ -79,20 +74,17 @@ async def check_and_send_reminders(bot: Bot) -> None: text=reminder.text, ) - # Update next_run_at to prevent sending again - # (it will be properly updated when user clicks "Done" or by periodic update) - temp_next_run = time_service.calculate_next_occurrence( + next_run = time_service.calculate_next_occurrence( current_run=reminder.next_run_at, 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() - # Small delay to avoid rate limits - await asyncio.sleep(0.5) - except Exception as e: logger.error(f"Error in check_and_send_reminders: {e}", exc_info=True) diff --git a/bot/db/__pycache__/models.cpython-312.pyc b/bot/db/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..6834c04 Binary files /dev/null and b/bot/db/__pycache__/models.cpython-312.pyc differ diff --git a/bot/db/__pycache__/operations.cpython-312.pyc b/bot/db/__pycache__/operations.cpython-312.pyc new file mode 100644 index 0000000..e3ccf5e Binary files /dev/null and b/bot/db/__pycache__/operations.cpython-312.pyc differ diff --git a/bot/db/models.py b/bot/db/models.py index c608268..fde73ca 100644 --- a/bot/db/models.py +++ b/bot/db/models.py @@ -8,6 +8,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship 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): """User model - represents Telegram users.""" @@ -18,9 +23,9 @@ class User(Base): username: 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) - 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( - 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) @@ -44,9 +49,9 @@ class Reminder(Base): next_run_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) last_done_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) 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( - DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + DateTime, default=_now_local, onupdate=_now_local, nullable=False ) # Optional fields for statistics diff --git a/bot/db/operations.py b/bot/db/operations.py index 486a871..fc2ad2f 100644 --- a/bot/db/operations.py +++ b/bot/db/operations.py @@ -1,11 +1,11 @@ """CRUD operations for database models.""" -from datetime import datetime +from datetime import datetime, time from typing import Optional, List from sqlalchemy import select, update, delete 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 logger = get_logger(__name__) @@ -46,7 +46,7 @@ async def get_or_create_user( user.username = username user.first_name = first_name user.last_name = last_name - user.updated_at = datetime.utcnow() + user.updated_at = _now_local() await session.commit() logger.debug(f"Updated user info: {tg_user_id}") return user @@ -90,7 +90,7 @@ async def create_reminder( user_id: int, text: str, days_interval: int, - time_of_day: datetime.time, + time_of_day: time, next_run_at: datetime, ) -> Reminder: """ @@ -212,7 +212,7 @@ async def update_reminder( if hasattr(reminder, key): setattr(reminder, key, value) - reminder.updated_at = datetime.utcnow() + reminder.updated_at = _now_local() await session.commit() await session.refresh(reminder) logger.debug(f"Updated reminder {reminder_id}") @@ -261,10 +261,10 @@ async def mark_reminder_done( if not reminder: return None - reminder.last_done_at = datetime.utcnow() + reminder.last_done_at = _now_local() reminder.next_run_at = next_run_at reminder.total_done_count += 1 - reminder.updated_at = datetime.utcnow() + reminder.updated_at = _now_local() await session.commit() await session.refresh(reminder) @@ -294,7 +294,7 @@ async def snooze_reminder( reminder.next_run_at = next_run_at reminder.snooze_count += 1 - reminder.updated_at = datetime.utcnow() + reminder.updated_at = _now_local() await session.commit() await session.refresh(reminder) @@ -323,7 +323,7 @@ async def toggle_reminder_active( return None reminder.is_active = is_active - reminder.updated_at = datetime.utcnow() + reminder.updated_at = _now_local() await session.commit() await session.refresh(reminder) diff --git a/bot/handlers/__pycache__/callbacks.cpython-312.pyc b/bot/handlers/__pycache__/callbacks.cpython-312.pyc index ce2b300..2a70de5 100644 Binary files a/bot/handlers/__pycache__/callbacks.cpython-312.pyc and b/bot/handlers/__pycache__/callbacks.cpython-312.pyc differ diff --git a/bot/handlers/__pycache__/errors.cpython-312.pyc b/bot/handlers/__pycache__/errors.cpython-312.pyc index 558a073..8e9d386 100644 Binary files a/bot/handlers/__pycache__/errors.cpython-312.pyc and b/bot/handlers/__pycache__/errors.cpython-312.pyc differ diff --git a/bot/handlers/__pycache__/reminders_create.cpython-312.pyc b/bot/handlers/__pycache__/reminders_create.cpython-312.pyc index 267a881..30f51e0 100644 Binary files a/bot/handlers/__pycache__/reminders_create.cpython-312.pyc and b/bot/handlers/__pycache__/reminders_create.cpython-312.pyc differ diff --git a/bot/handlers/__pycache__/reminders_manage.cpython-312.pyc b/bot/handlers/__pycache__/reminders_manage.cpython-312.pyc index fc73270..d9e9c00 100644 Binary files a/bot/handlers/__pycache__/reminders_manage.cpython-312.pyc and b/bot/handlers/__pycache__/reminders_manage.cpython-312.pyc differ diff --git a/bot/handlers/callbacks.py b/bot/handlers/callbacks.py index 22606cb..21f6f16 100644 --- a/bot/handlers/callbacks.py +++ b/bot/handlers/callbacks.py @@ -20,10 +20,36 @@ logger = get_logger(__name__) router = Router(name="callbacks") 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")) @@ -32,21 +58,18 @@ async def handle_done( callback_data: ReminderActionCallback, session: AsyncSession, ) -> 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) - if not reminder: await callback.answer("Напоминание не найдено", show_alert=True) 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( 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}") -# ==================== Snooze Action ==================== - - @router.callback_query(ReminderActionCallback.filter(F.action == "snooze")) async def handle_snooze_select( callback: CallbackQuery, callback_data: ReminderActionCallback, + session: AsyncSession, ) -> 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( "На сколько отложить напоминание?", reply_markup=get_snooze_delay_keyboard(callback_data.reminder_id), @@ -85,23 +105,20 @@ async def handle_snooze_delay( callback_data: SnoozeDelayCallback, session: AsyncSession, ) -> 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( session, callback_data.reminder_id, callback_data.hours ) - if not reminder: await callback.answer("Напоминание не найдено", show_alert=True) 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( f"⏰ Напоминание отложено.\n\n" @@ -115,25 +132,19 @@ async def handle_snooze_delay( ) -# ==================== Pause/Resume Actions ==================== - - @router.callback_query(ReminderActionCallback.filter(F.action == "pause")) async def handle_pause( callback: CallbackQuery, callback_data: ReminderActionCallback, session: AsyncSession, ) -> 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) - if not reminder: await callback.answer("Напоминание не найдено", show_alert=True) return @@ -153,21 +164,18 @@ async def handle_resume( callback_data: ReminderActionCallback, session: AsyncSession, ) -> 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) - if not reminder: await callback.answer("Напоминание не найдено", show_alert=True) 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( f"▶️ Напоминание возобновлено!\n\n" @@ -178,21 +186,18 @@ async def handle_resume( logger.info(f"Reminder {reminder.id} resumed by user {callback.from_user.id}") -# ==================== Delete Action ==================== - - @router.callback_query(ReminderActionCallback.filter(F.action == "delete")) async def handle_delete_confirm( callback: CallbackQuery, callback_data: ReminderActionCallback, + session: AsyncSession, ) -> 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( "Точно удалить напоминание?", reply_markup=get_confirmation_keyboard( @@ -209,14 +214,7 @@ async def handle_delete_execute( callback_data: ConfirmCallback, session: AsyncSession, ) -> None: - """ - Execute reminder deletion after confirmation. - - Args: - callback: Callback query - callback_data: Parsed callback data - session: Database session - """ + """Execute reminder deletion after confirmation.""" if callback_data.action == "no": await callback.message.edit_text("Удаление отменено.") await callback.answer() @@ -238,17 +236,9 @@ async def handle_delete_execute( ) -# ==================== Settings Placeholder ==================== - - @router.message(F.text == "⚙️ Настройки") async def handle_settings(message: Message) -> None: - """ - Handle settings button (placeholder). - - Args: - message: Telegram message - """ + """Handle settings button (placeholder).""" await message.answer( "⚙️ Настройки\n\n" "Функционал настроек будет добавлен в следующих версиях.\n\n" diff --git a/bot/handlers/errors.py b/bot/handlers/errors.py index e47bcff..9669738 100644 --- a/bot/handlers/errors.py +++ b/bot/handlers/errors.py @@ -2,7 +2,6 @@ from aiogram import Router from aiogram.types import ErrorEvent -from aiogram.exceptions import TelegramBadRequest from bot.logging_config import get_logger @@ -12,7 +11,7 @@ router = Router(name="errors") @router.error() -async def error_handler(event: ErrorEvent) -> None: +async def error_handler(event: ErrorEvent) -> bool: """ Global error handler for all unhandled exceptions. @@ -41,3 +40,5 @@ async def error_handler(event: ErrorEvent) -> None: ) except Exception as e: logger.error(f"Failed to send error callback to user: {e}") + + return True diff --git a/bot/handlers/reminders_create.py b/bot/handlers/reminders_create.py index 7a3f4a3..9908703 100644 --- a/bot/handlers/reminders_create.py +++ b/bot/handlers/reminders_create.py @@ -24,7 +24,6 @@ logger = get_logger(__name__) router = Router(name="reminders_create") reminders_service = RemindersService() -time_service = get_time_service() @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: """ Process reminder text input. @@ -106,7 +105,7 @@ async def process_interval_button( 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: """ 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: """ Process reminder time input. @@ -225,7 +224,7 @@ async def process_create_confirmation( await state.clear() # 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( f"✅ Напоминание создано!\n\n" @@ -238,3 +237,11 @@ async def process_create_confirmation( await callback.answer("Напоминание создано!") 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("Пожалуйста, отправь текстовое сообщение.") diff --git a/bot/handlers/reminders_manage.py b/bot/handlers/reminders_manage.py index ead7264..76e6d4f 100644 --- a/bot/handlers/reminders_manage.py +++ b/bot/handlers/reminders_manage.py @@ -259,7 +259,7 @@ async def edit_text_start( await callback.answer() -@router.message(EditReminderStates.editing_text) +@router.message(EditReminderStates.editing_text, F.text) async def edit_text_process( message: Message, state: FSMContext, @@ -368,7 +368,7 @@ async def edit_interval_button( await callback.answer("Обновлено!") -@router.message(EditReminderStates.editing_interval) +@router.message(EditReminderStates.editing_interval, F.text) async def edit_interval_text( message: Message, state: FSMContext, @@ -429,7 +429,7 @@ async def edit_time_start( await callback.answer() -@router.message(EditReminderStates.editing_time) +@router.message(EditReminderStates.editing_time, F.text) async def edit_time_process( message: Message, state: FSMContext, @@ -466,3 +466,11 @@ async def edit_time_process( f"✅ Время обновлено!\n\nНовое время: {time_of_day.strftime('%H:%M')}", 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("Пожалуйста, отправь текстовое сообщение.") diff --git a/bot/keyboards/__pycache__/pagination.cpython-312.pyc b/bot/keyboards/__pycache__/pagination.cpython-312.pyc new file mode 100644 index 0000000..f983945 Binary files /dev/null and b/bot/keyboards/__pycache__/pagination.cpython-312.pyc differ diff --git a/bot/keyboards/pagination.py b/bot/keyboards/pagination.py index 50217e3..e6f3841 100644 --- a/bot/keyboards/pagination.py +++ b/bot/keyboards/pagination.py @@ -5,6 +5,7 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from aiogram.filters.callback_data import CallbackData from bot.db.models import Reminder +from bot.utils.formatting import truncate_text class PaginationCallback(CallbackData, prefix="page"): @@ -39,7 +40,7 @@ def get_reminders_list_keyboard( # Add buttons for reminders on current page for reminder in reminders[start_idx:end_idx]: 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([ InlineKeyboardButton( diff --git a/bot/services/__pycache__/reminders_service.cpython-312.pyc b/bot/services/__pycache__/reminders_service.cpython-312.pyc new file mode 100644 index 0000000..747f4a2 Binary files /dev/null and b/bot/services/__pycache__/reminders_service.cpython-312.pyc differ diff --git a/bot/services/__pycache__/time_service.cpython-312.pyc b/bot/services/__pycache__/time_service.cpython-312.pyc new file mode 100644 index 0000000..54d531f Binary files /dev/null and b/bot/services/__pycache__/time_service.cpython-312.pyc differ diff --git a/bot/services/reminders_service.py b/bot/services/reminders_service.py index 9a881ff..37c1cc9 100644 --- a/bot/services/reminders_service.py +++ b/bot/services/reminders_service.py @@ -25,9 +25,9 @@ logger = get_logger(__name__) class RemindersService: """Service for reminder-related operations.""" - def __init__(self): - """Initialize RemindersService.""" - self.time_service = get_time_service() + @property + def time_service(self): + return get_time_service() async def create_new_reminder( self, @@ -50,13 +50,10 @@ class RemindersService: Returns: Created Reminder instance """ - # Calculate next run time - next_run_at = self.time_service.calculate_next_run_time( + next_run_at = self.time_service.calculate_first_run_time( time_of_day=time_of_day, - days_interval=days_interval, ) - # Create reminder reminder = await create_reminder( session=session, user_id=user_id, @@ -134,7 +131,7 @@ class RemindersService: new_days_interval: int, ) -> Optional[Reminder]: """ - Update reminder interval. + Update reminder interval and recalculate next_run_at with new interval. Args: session: Database session @@ -148,8 +145,7 @@ class RemindersService: if not reminder: return None - # Recalculate next_run_at with new interval - next_run_at = self.time_service.calculate_next_run_time( + next_run_at = self.time_service.calculate_next_run_with_interval( time_of_day=reminder.time_of_day, days_interval=new_days_interval, ) @@ -168,7 +164,7 @@ class RemindersService: new_time_of_day: time, ) -> Optional[Reminder]: """ - Update reminder time. + Update reminder time and recalculate next_run_at. Args: session: Database session @@ -182,10 +178,8 @@ class RemindersService: if not reminder: return None - # Recalculate next_run_at with new time - next_run_at = self.time_service.calculate_next_run_time( + next_run_at = self.time_service.calculate_first_run_time( time_of_day=new_time_of_day, - days_interval=reminder.days_interval, ) return await update_reminder( @@ -231,7 +225,6 @@ class RemindersService: if not reminder: return None - # Calculate next occurrence next_run_at = self.time_service.calculate_next_occurrence( current_run=reminder.next_run_at, days_interval=reminder.days_interval, @@ -284,7 +277,7 @@ class RemindersService: reminder_id: int, ) -> Optional[Reminder]: """ - Resume (activate) a reminder. + Resume (activate) a reminder. Recalculates next_run_at atomically. Args: session: Database session @@ -297,12 +290,13 @@ class RemindersService: if not reminder: return None - # Recalculate next_run_at from now - next_run_at = self.time_service.calculate_next_run_time( + next_run_at = self.time_service.calculate_first_run_time( time_of_day=reminder.time_of_day, - days_interval=reminder.days_interval, ) - # Update and activate - await update_reminder(session, reminder_id, next_run_at=next_run_at) - return await toggle_reminder_active(session, reminder_id, is_active=True) + return await update_reminder( + session, + reminder_id, + next_run_at=next_run_at, + is_active=True, + ) diff --git a/bot/services/time_service.py b/bot/services/time_service.py index 68684cb..04691ea 100644 --- a/bot/services/time_service.py +++ b/bot/services/time_service.py @@ -46,14 +46,41 @@ class TimeService: """ 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, time_of_day: time, days_interval: int, from_datetime: Optional[datetime] = None, ) -> datetime: """ - Calculate next run datetime for a reminder. + Calculate next run datetime using interval offset from now. + Used when interval is changed. Args: time_of_day: Desired time of day @@ -61,19 +88,13 @@ class TimeService: from_datetime: Base datetime (defaults to now) Returns: - Next run datetime + Datetime at time_of_day, days_interval days from from_datetime """ if from_datetime is None: from_datetime = self.get_now() - # Start with today at the specified time - next_run = self.combine_date_time(from_datetime, 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 + target_date = from_datetime + timedelta(days=days_interval) + return self.combine_date_time(target_date, time_of_day) def calculate_next_occurrence( self, @@ -81,16 +102,23 @@ class TimeService: days_interval: int, ) -> datetime: """ - Calculate next occurrence after a completed reminder. + Calculate next occurrence after a completed/sent reminder. + Guarantees the result is in the future. Args: current_run: Current run datetime days_interval: Days between reminders 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: """ diff --git a/bot/utils/__pycache__/validators.cpython-312.pyc b/bot/utils/__pycache__/validators.cpython-312.pyc new file mode 100644 index 0000000..30b0c5e Binary files /dev/null and b/bot/utils/__pycache__/validators.cpython-312.pyc differ diff --git a/bot/utils/validators.py b/bot/utils/validators.py index 0482f29..b3e54a1 100644 --- a/bot/utils/validators.py +++ b/bot/utils/validators.py @@ -15,7 +15,9 @@ def validate_time_format(time_str: str) -> Optional[time]: Returns: time object if valid, None otherwise """ - # Remove extra whitespace + if not time_str: + return None + time_str = time_str.strip() # Check format with regex @@ -47,6 +49,9 @@ def validate_days_interval(days_str: str) -> Optional[int]: Returns: Integer days if valid (>0 and <=365), None otherwise """ + if not days_str: + return None + try: days = int(days_str.strip()) if 0 < days <= 365: # Max 1 year