fix problems

This commit is contained in:
itqop 2026-02-17 12:52:46 +03:00
parent 30d43be793
commit acc3d50f28
22 changed files with 180 additions and 149 deletions

View File

@ -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()
# Commit all updates
await session.commit()
# Small delay to avoid rate limits
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
await session.commit()
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.

View File

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

View File

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

View File

@ -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"

View File

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

View File

@ -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("Пожалуйста, отправь текстовое сообщение.")

View File

@ -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.

View File

@ -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.

View File

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

View File

@ -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.

View File

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