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:
@@ -51,8 +51,8 @@ def _resolve_timezone(tz_str: str) -> str:
|
|||||||
return "UTC"
|
return "UTC"
|
||||||
|
|
||||||
|
|
||||||
async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
|
async def _get_briefing_enabled_users() -> list[tuple[int, str, dict]]:
|
||||||
"""Return [(user_id, iana_timezone)] for all users with briefing enabled."""
|
"""Return [(user_id, iana_timezone, config)] for all users with briefing enabled."""
|
||||||
import json
|
import json
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -71,7 +71,7 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
|
|||||||
if config.get("enabled"):
|
if config.get("enabled"):
|
||||||
tz_str = settings.get("user_timezone") or config.get("timezone", "UTC")
|
tz_str = settings.get("user_timezone") or config.get("timezone", "UTC")
|
||||||
tz = _resolve_timezone(tz_str)
|
tz = _resolve_timezone(tz_str)
|
||||||
enabled.append((user_id, tz))
|
enabled.append((user_id, tz, config))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return enabled
|
return enabled
|
||||||
@@ -81,16 +81,24 @@ def _job_id(user_id: int, slot: str) -> str:
|
|||||||
return f"briefing_{slot}_user_{user_id}"
|
return f"briefing_{slot}_user_{user_id}"
|
||||||
|
|
||||||
|
|
||||||
def _add_user_jobs(user_id: int, tz: str) -> None:
|
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
||||||
"""Add (or replace) all 4 slot jobs for a user in their timezone."""
|
"""Add (or replace) slot jobs for a user, skipping disabled slots."""
|
||||||
if _scheduler is None or _loop is None:
|
if _scheduler is None or _loop is None:
|
||||||
return
|
return
|
||||||
|
enabled_slots = (config or {}).get("slots", {})
|
||||||
for slot_name, hour, minute in 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(
|
_scheduler.add_job(
|
||||||
_run_user_slot_sync,
|
_run_user_slot_sync,
|
||||||
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
||||||
args=[user_id, slot_name],
|
args=[user_id, slot_name],
|
||||||
id=_job_id(user_id, slot_name),
|
id=jid,
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
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"):
|
if config.get("enabled"):
|
||||||
tz_str = tz_override or config.get("timezone", "UTC")
|
tz_str = tz_override or config.get("timezone", "UTC")
|
||||||
tz = _resolve_timezone(tz_str)
|
tz = _resolve_timezone(tz_str)
|
||||||
_add_user_jobs(user_id, tz)
|
_add_user_jobs(user_id, tz, config)
|
||||||
else:
|
else:
|
||||||
_remove_user_jobs(user_id)
|
_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.services.settings import get_setting
|
||||||
from fabledassistant.config import Config
|
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)
|
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||||
|
|
||||||
if slot == "compilation":
|
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).
|
(one catch-up per slot per user, evaluated in the user's local timezone).
|
||||||
"""
|
"""
|
||||||
users = await _get_briefing_enabled_users()
|
users = await _get_briefing_enabled_users()
|
||||||
for user_id, tz in users:
|
for user_id, tz, _config in users:
|
||||||
user_tz = ZoneInfo(tz)
|
user_tz = ZoneInfo(tz)
|
||||||
now_local = datetime.now(user_tz)
|
now_local = datetime.now(user_tz)
|
||||||
today_local = now_local.date()
|
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")
|
logger.exception("Failed to load briefing users at startup")
|
||||||
users = []
|
users = []
|
||||||
|
|
||||||
for user_id, tz in users:
|
for user_id, tz, config in users:
|
||||||
_add_user_jobs(user_id, tz)
|
_add_user_jobs(user_id, tz, config)
|
||||||
|
|
||||||
from fabledassistant.services.recurrence import spawn_recurring_tasks as _spawn_recurring
|
from fabledassistant.services.recurrence import spawn_recurring_tasks as _spawn_recurring
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user