diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index ea21254..e2cfa44 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -51,8 +51,8 @@ def _resolve_timezone(tz_str: str) -> str: return "UTC" -async def _get_briefing_enabled_users() -> list[tuple[int, str]]: - """Return [(user_id, iana_timezone)] for all users with briefing enabled.""" +async def _get_briefing_enabled_users() -> list[tuple[int, str, dict]]: + """Return [(user_id, iana_timezone, config)] for all users with briefing enabled.""" import json async with async_session() as session: result = await session.execute( @@ -71,7 +71,7 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]: if config.get("enabled"): tz_str = settings.get("user_timezone") or config.get("timezone", "UTC") tz = _resolve_timezone(tz_str) - enabled.append((user_id, tz)) + enabled.append((user_id, tz, config)) except Exception: pass return enabled @@ -81,16 +81,24 @@ def _job_id(user_id: int, slot: str) -> str: return f"briefing_{slot}_user_{user_id}" -def _add_user_jobs(user_id: int, tz: str) -> None: - """Add (or replace) all 4 slot jobs for a user in their timezone.""" +def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None: + """Add (or replace) slot jobs for a user, skipping disabled slots.""" if _scheduler is None or _loop is None: return + enabled_slots = (config or {}).get("slots", {}) for slot_name, hour, minute in SLOTS: + jid = _job_id(user_id, slot_name) + # compilation always runs; other slots default to True if not in config + slot_on = enabled_slots.get(slot_name, True) + if not slot_on: + if _scheduler.get_job(jid): + _scheduler.remove_job(jid) + continue _scheduler.add_job( _run_user_slot_sync, CronTrigger(hour=hour, minute=minute, timezone=tz), args=[user_id, slot_name], - id=_job_id(user_id, slot_name), + id=jid, replace_existing=True, misfire_grace_time=3600, ) @@ -119,7 +127,7 @@ def update_user_schedule(user_id: int, config: dict, tz_override: str | None = N if config.get("enabled"): tz_str = tz_override or config.get("timezone", "UTC") tz = _resolve_timezone(tz_str) - _add_user_jobs(user_id, tz) + _add_user_jobs(user_id, tz, config) else: _remove_user_jobs(user_id) @@ -135,6 +143,24 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None: from fabledassistant.services.settings import get_setting from fabledassistant.config import Config + # Morning slot: skip if today is not a configured work day + if slot == "morning": + from fabledassistant.services.user_profile import get_profile + tz_str = await get_setting(user_id, "user_timezone") or "UTC" + try: + user_tz = ZoneInfo(tz_str) + except Exception: + user_tz = ZoneInfo("UTC") + today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', … + profile = await get_profile(user_id) + work_days = (profile.work_schedule or {}).get("days", ["Mon", "Tue", "Wed", "Thu", "Fri"]) + if today_abbr not in work_days: + logger.info( + "Skipping morning slot for user %d — %s not a configured work day", + user_id, today_abbr, + ) + return + model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL) if slot == "compilation": @@ -255,7 +281,7 @@ async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None: (one catch-up per slot per user, evaluated in the user's local timezone). """ users = await _get_briefing_enabled_users() - for user_id, tz in users: + for user_id, tz, _config in users: user_tz = ZoneInfo(tz) now_local = datetime.now(user_tz) today_local = now_local.date() @@ -323,8 +349,8 @@ async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None: logger.exception("Failed to load briefing users at startup") users = [] - for user_id, tz in users: - _add_user_jobs(user_id, tz) + for user_id, tz, config in users: + _add_user_jobs(user_id, tz, config) from fabledassistant.services.recurrence import spawn_recurring_tasks as _spawn_recurring diff --git a/tests/test_briefing_scheduler.py b/tests/test_briefing_scheduler.py new file mode 100644 index 0000000..a509890 --- /dev/null +++ b/tests/test_briefing_scheduler.py @@ -0,0 +1,114 @@ +"""Tests for briefing_scheduler slot gating and work-day gate.""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + + +def _make_scheduler(): + """Return a mock APScheduler with tracked job IDs.""" + sched = MagicMock() + sched.get_job.return_value = None + return sched + + +def test_add_user_jobs_skips_disabled_slots(): + """_add_user_jobs should not register jobs for slots set to False in config.""" + from fabledassistant.services import briefing_scheduler as bs + + mock_sched = _make_scheduler() + bs._scheduler = mock_sched + bs._loop = MagicMock() + + config = {"slots": {"compilation": True, "morning": False, "midday": False, "afternoon": False}} + bs._add_user_jobs(user_id=1, tz="UTC", config=config) + + added_ids = [call.kwargs.get("id") or call.args[3] for call in mock_sched.add_job.call_args_list] + # Only compilation should be scheduled + assert any("compilation" in jid for jid in added_ids) + assert not any("morning" in jid for jid in added_ids) + assert not any("midday" in jid for jid in added_ids) + assert not any("afternoon" in jid for jid in added_ids) + + +def test_add_user_jobs_schedules_all_when_config_missing(): + """_add_user_jobs with no config should schedule all 4 slots (safe default).""" + from fabledassistant.services import briefing_scheduler as bs + + mock_sched = _make_scheduler() + bs._scheduler = mock_sched + bs._loop = MagicMock() + + bs._add_user_jobs(user_id=1, tz="UTC", config=None) + + added_ids = [ + call.kwargs.get("id") or call.args[3] + for call in mock_sched.add_job.call_args_list + ] + assert any("compilation" in jid for jid in added_ids) + assert any("morning" in jid for jid in added_ids) + assert any("midday" in jid for jid in added_ids) + assert any("afternoon" in jid for jid in added_ids) + + +@pytest.mark.asyncio +async def test_run_slot_skips_morning_on_non_work_day(): + """_run_slot_for_user should return early for morning slot when today is not a work day.""" + from fabledassistant.services import briefing_scheduler as bs + + fake_profile = MagicMock() + fake_profile.work_schedule = {"days": ["Mon", "Tue", "Wed", "Thu", "Fri"]} + + # Patch datetime at module level (it's imported at module level in briefing_scheduler) + # Patch get_setting and get_profile at their source modules (they're lazily imported) + with patch("fabledassistant.services.briefing_scheduler.datetime") as mock_dt, \ + patch("fabledassistant.services.user_profile.get_profile", new_callable=AsyncMock, return_value=fake_profile), \ + patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="UTC"), \ + patch("fabledassistant.services.briefing_pipeline.run_slot_injection", new_callable=AsyncMock) as mock_inject: + + mock_now = MagicMock() + mock_now.strftime.return_value = "Sat" + mock_dt.now.return_value = mock_now + + await bs._run_slot_for_user(user_id=1, slot="morning") + mock_inject.assert_not_called() + + +@pytest.mark.asyncio +async def test_run_slot_morning_runs_on_work_day(): + """_run_slot_for_user should proceed for morning slot when today is a work day.""" + from fabledassistant.services import briefing_scheduler as bs + + fake_profile = MagicMock() + fake_profile.work_schedule = {"days": ["Mon", "Tue", "Wed", "Thu", "Fri"]} + + with patch("fabledassistant.services.briefing_scheduler.datetime") as mock_dt, \ + patch("fabledassistant.services.user_profile.get_profile", new_callable=AsyncMock, return_value=fake_profile), \ + patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="UTC"), \ + patch("fabledassistant.services.briefing_conversations.get_or_create_today_conversation", new_callable=AsyncMock) as mock_conv, \ + patch("fabledassistant.services.briefing_pipeline.run_slot_injection", new_callable=AsyncMock, return_value="") as mock_inject, \ + patch("fabledassistant.services.briefing_conversations.post_message", new_callable=AsyncMock), \ + patch("fabledassistant.services.push.send_push_notification", new_callable=AsyncMock): + + mock_now = MagicMock() + mock_now.strftime.return_value = "Mon" + mock_dt.now.return_value = mock_now + mock_conv.return_value = MagicMock(id=1) + + await bs._run_slot_for_user(user_id=1, slot="morning") + mock_inject.assert_called_once() + + +@pytest.mark.asyncio +async def test_settings_put_timezone_updates_scheduler(): + """Saving user_timezone should call update_user_schedule when briefing is enabled.""" + import json + from fabledassistant.services.briefing_scheduler import update_user_schedule + + enabled_config = json.dumps({"enabled": True, "slots": {}, "locations": {}}) + config = json.loads(enabled_config) + + with patch("fabledassistant.services.briefing_scheduler._add_user_jobs") as mock_add: + update_user_schedule(1, config, tz_override="America/New_York") + mock_add.assert_called_once() + call_args = mock_add.call_args + assert call_args.args[0] == 1 # user_id + assert call_args.args[2] == config # config passed through