feat(journal): register per-user closeout job at day_rollover_hour
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,30 +89,65 @@ def _run_daily_prep_threadsafe(user_id: int) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop)
|
||||
|
||||
|
||||
async def _do_closeout(user_id: int) -> None:
|
||||
try:
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
now = datetime.datetime.now(tz)
|
||||
# We just rolled into a new day in user-local time. The day that
|
||||
# just ended is yesterday's calendar date regardless of whether
|
||||
# rollover_hour is 0 or 4 — APScheduler fires precisely at the
|
||||
# configured hour so no clock-skew correction is needed.
|
||||
yesterday = now.date() - datetime.timedelta(days=1)
|
||||
from fabledassistant.services.journal_closeout import run_for_user
|
||||
await run_for_user(user_id=user_id, yesterday=yesterday)
|
||||
except Exception:
|
||||
logger.exception("Closeout failed for user %d", user_id)
|
||||
|
||||
|
||||
def _run_closeout_threadsafe(user_id: int) -> None:
|
||||
if _loop is None:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(_do_closeout(user_id), _loop)
|
||||
|
||||
|
||||
async def update_user_schedule(user_id: int) -> None:
|
||||
"""Add or replace this user's daily-prep job using their current config."""
|
||||
"""Add or replace this user's daily-prep + closeout jobs from current config."""
|
||||
if _scheduler is None:
|
||||
return
|
||||
job_id = f"journal_prep_{user_id}"
|
||||
if _scheduler.get_job(job_id):
|
||||
_scheduler.remove_job(job_id)
|
||||
|
||||
config = await get_journal_config(user_id)
|
||||
if not config.get("prep_enabled", True):
|
||||
return
|
||||
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
prep_hour = int(config.get("prep_hour", 5))
|
||||
prep_minute = int(config.get("prep_minute", 0))
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_daily_prep_threadsafe,
|
||||
trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz),
|
||||
args=[user_id],
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
# ── Prep job ──────────────────────────────────────────────────────────
|
||||
prep_job_id = f"journal_prep_{user_id}"
|
||||
if _scheduler.get_job(prep_job_id):
|
||||
_scheduler.remove_job(prep_job_id)
|
||||
if config.get("prep_enabled", True):
|
||||
prep_hour = int(config.get("prep_hour", 5))
|
||||
prep_minute = int(config.get("prep_minute", 0))
|
||||
_scheduler.add_job(
|
||||
_run_daily_prep_threadsafe,
|
||||
trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz),
|
||||
args=[user_id],
|
||||
id=prep_job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# ── Closeout job ──────────────────────────────────────────────────────
|
||||
closeout_job_id = f"journal_closeout_{user_id}"
|
||||
if _scheduler.get_job(closeout_job_id):
|
||||
_scheduler.remove_job(closeout_job_id)
|
||||
if config.get("closeout_enabled", True):
|
||||
rollover_hour = int(config.get("day_rollover_hour", 4))
|
||||
_scheduler.add_job(
|
||||
_run_closeout_threadsafe,
|
||||
trigger=CronTrigger(hour=rollover_hour, minute=0, timezone=tz),
|
||||
args=[user_id],
|
||||
id=closeout_job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
|
||||
async def _register_all_user_jobs() -> None:
|
||||
|
||||
@@ -162,3 +162,43 @@ async def test_run_for_user_respects_nothing_to_note_sentinel():
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
||||
|
||||
mock_append.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_schedule_registers_closeout_when_enabled(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
fake_scheduler = MagicMock()
|
||||
fake_scheduler.get_job = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"prep_enabled": False, # isolate the closeout job
|
||||
"closeout_enabled": True,
|
||||
"day_rollover_hour": 4,
|
||||
}))
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
|
||||
await sched.update_user_schedule(user_id=7)
|
||||
|
||||
added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
|
||||
assert "journal_closeout_7" in added
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_schedule_does_not_register_closeout_when_disabled(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
fake_scheduler = MagicMock()
|
||||
fake_scheduler.get_job = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"prep_enabled": False,
|
||||
"closeout_enabled": False,
|
||||
"day_rollover_hour": 4,
|
||||
}))
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
|
||||
await sched.update_user_schedule(user_id=7)
|
||||
|
||||
added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
|
||||
assert "journal_closeout_7" not in added
|
||||
|
||||
Reference in New Issue
Block a user