feat(scheduler): slot gating + morning work-day gate

- _add_user_jobs now accepts config dict and skips disabled slots
- _get_briefing_enabled_users returns 3-tuple (user_id, tz, config)
- update_user_schedule passes config through to _add_user_jobs
- _run_slot_for_user skips morning slot on non-work days via profile.work_schedule
- Add tests for slot gating and work-day gate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 21:34:29 -04:00
parent f4aca40562
commit 0ec030cb8f
2 changed files with 150 additions and 10 deletions
@@ -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
+114
View File
@@ -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