Team Hoa, EPIC R07 (Scheduling & Workflow Runtime) - Team Hoa scope only (R07-T01 -> T05; R07-T06 Co4EWorkflowService is Team Nam's). - R07-T01: infrastructure/persistence/json/task_repository_impl.py wraps core/tasks.py's CRUD; core/tasks.py::save_task now writes through atomic_write.write_json (same durability fix as R06-T02, save_task was still doing a plain write_text). - R07-T02: domain/tasks/schedule_calculator.py::ScheduleCalculator - the cron/interval/daily/weekly/monthly due-time math extracted from core/tasks.py, pure Python with is_holiday/make_cron injected so domain/ never imports core (ADR-001 I2). core/tasks.py keeps its old function names as thin wrappers so every existing caller is unchanged. This was previously untested; now has its own unit suite. - R07-T03: infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock wraps the QTimer TaskScheduler used to own directly, injected via a new `clock=` constructor param (defaults to a real one). Originally planned at platform/qt/... ; moved after confirming that name shadows the stdlib platform module (used by core/windows_sandbox_vm.py, core/appcontainer_sandbox.py) whenever the repo root is on sys.path. tests/fakes/fake_clock.py lets scheduler dispatch be tested tick-by-tick with no Qt event loop. - R07-T04: application/scheduling/task_application_service.py centralizes run_now/duplicate/pause/delete/bulk_delete and the Kanban drag-drop business rules (move_to_status), currently only reachable by driving the real ui/schedule_task_tab.py widget. - R07-T05: application/scheduling/ai_task_planner_service.py wraps core/ai_task_planner.py::plan_tasks and core/task_import.py::import_tasks as a seam, plus the attachment-stamping step that used to only exist inside the AI-create dialog's worker closure. pytest: 328 pass (same 4 pre-existing failures as the R05/R06 baseline, unrelated to this work - see docs/refactor/BaoCao_TeamHoa_R05_R06.md). scripts/check_imports.py: PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
166 lines
7.0 KiB
Python
166 lines
7.0 KiB
Python
"""ScheduleCalculator - due-time / cron / interval math for Schedule Task,
|
|
extracted from ``core/tasks.py``'s "schedule math" section (R07-T02).
|
|
|
|
``core/tasks.py`` is already Qt-free (its own docstring says so), but it
|
|
still lives under ``core/`` where nothing enforces that "pure" claim - and it
|
|
is the ONE piece of scheduling logic ``docs/refactor/plan.md`` calls out as
|
|
needing its own unit tests (none existed before this task; see
|
|
``tests/unit/test_schedule_calculator.py``). Moving it to ``domain/tasks/``
|
|
makes the purity a build-time guarantee (``scripts/check_imports.py`` fails
|
|
the build if this file ever imports Qt, ``core``, or anything with I/O) and
|
|
gives the date math a home that is trivially unit-testable without going
|
|
through ``core/tasks.py``'s file-repository concerns at all.
|
|
|
|
Two pieces of this math are themselves implemented elsewhere in ``core/`` -
|
|
``core/cron.py::Cron`` (5-field cron parsing) and
|
|
``core/holiday_calendar.py::is_holiday`` (VN public holidays). Importing
|
|
``core`` from ``domain`` is exactly what ADR-001 rule I2 forbids (domain must
|
|
not know infrastructure/core exists), so this class takes them as
|
|
constructor-injected callables instead of importing them - the same
|
|
dependency-inversion shape ``application/conversations/conversation_
|
|
application_service.py`` (R04-T03) already uses for its provider factory.
|
|
``core/tasks.py`` wires the real ``Cron``/``is_holiday`` in; tests can inject
|
|
plain stub functions with zero I/O.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import calendar
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Callable, Dict, List, Optional, Protocol
|
|
|
|
# Same on-disk format core/tasks.py::_TIME_FMT uses for schedule.run_at.
|
|
# Duplicated here (not imported - that would be a domain -> core edge) since
|
|
# it's a 1-line format string, not business logic.
|
|
_TIME_FMT = "%Y-%m-%d %H:%M"
|
|
|
|
_CRON_SEARCH_GUARD = 400 # matches the guard core/tasks.py used before extraction
|
|
|
|
|
|
class _CronLike(Protocol):
|
|
"""Structural shape this class needs from a cron object - satisfied by
|
|
``core/cron.py::Cron`` without this module importing it."""
|
|
|
|
def next_after(self, after: datetime) -> Optional[datetime]:
|
|
...
|
|
|
|
|
|
def _parse_run_at(value: Optional[str]) -> Optional[datetime]:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.strptime(value, _TIME_FMT)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
class ScheduleCalculator:
|
|
"""Pure due-time computation for one task's ``schedule`` dict.
|
|
|
|
``is_holiday``: ``Callable[[date, country_code], bool]`` or ``None`` -
|
|
when ``None``, a schedule with ``skip_holidays`` set simply never treats
|
|
any day as a holiday (degrades gracefully instead of raising, mirroring
|
|
how a caller who doesn't care about holidays can just not wire it up).
|
|
|
|
``make_cron``: ``Callable[[str], _CronLike]`` (raises on a malformed
|
|
expression) or ``None`` - when ``None``, ``repeat_type == "cron"``
|
|
schedules never produce a next run (same as an invalid expression today).
|
|
"""
|
|
|
|
def __init__(self,
|
|
is_holiday: Optional[Callable[[Any, str], bool]] = None,
|
|
make_cron: Optional[Callable[[str], _CronLike]] = None) -> None:
|
|
self._is_holiday = is_holiday
|
|
self._make_cron = make_cron
|
|
|
|
def is_excluded_day(self, dt: datetime, sched: Dict[str, Any]) -> bool:
|
|
"""True when ``dt`` falls on a day this schedule must skip: a
|
|
weekend (working_days_only) or a public holiday of the configured
|
|
country."""
|
|
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
|
|
return True
|
|
if sched.get("skip_holidays") and self._is_holiday is not None:
|
|
if self._is_holiday(dt.date(), sched.get("holiday_country", "")):
|
|
return True
|
|
return False
|
|
|
|
def add_month(self, dt: datetime) -> datetime:
|
|
"""Calendar-aware +1 month, clamping the day to the target month's
|
|
length (e.g. Jan 31 + 1 month -> Feb 28/29, not an overflow error)."""
|
|
year = dt.year + (1 if dt.month == 12 else 0)
|
|
month = 1 if dt.month == 12 else dt.month + 1
|
|
day = min(dt.day, calendar.monthrange(year, month)[1])
|
|
return dt.replace(year=year, month=month, day=day)
|
|
|
|
def shift_off_excluded_days(self, dt: datetime, sched: Dict[str, Any]) -> datetime:
|
|
"""Push ``dt`` forward one day at a time until it lands on an
|
|
allowed day (same time of day) - used for one-time schedules set on
|
|
a weekend/holiday."""
|
|
guard = 0
|
|
while self.is_excluded_day(dt, sched) and guard < _CRON_SEARCH_GUARD:
|
|
dt += timedelta(days=1)
|
|
guard += 1
|
|
return dt
|
|
|
|
def compute_next_run(self, task: Dict[str, Any], after: datetime) -> Optional[datetime]:
|
|
"""The next run time strictly after ``after`` for a repeating task
|
|
(daily / weekly / monthly / cron), or ``None`` for one-shot
|
|
schedules. Occurrences on excluded days are skipped forward."""
|
|
sched = task.get("schedule", {})
|
|
repeat = sched.get("repeat_type", "none")
|
|
|
|
if repeat == "cron":
|
|
if self._make_cron is None:
|
|
return None
|
|
try:
|
|
cron = self._make_cron(sched.get("cron_expression") or "")
|
|
except Exception:
|
|
# Any malformed-expression error the injected factory raises
|
|
# (core/cron.py::CronError, or a fake's own error type in
|
|
# tests) means "this schedule can't compute a next run" - not
|
|
# a domain-layer crash.
|
|
return None
|
|
nxt = cron.next_after(after)
|
|
guard = 0
|
|
while nxt is not None and self.is_excluded_day(nxt, sched) and guard < _CRON_SEARCH_GUARD:
|
|
nxt = cron.next_after(nxt)
|
|
guard += 1
|
|
return nxt
|
|
|
|
base = _parse_run_at(sched.get("run_at"))
|
|
if base is None:
|
|
return None
|
|
if repeat == "daily":
|
|
advance = lambda d: d + timedelta(days=1) # noqa: E731
|
|
elif repeat == "weekly":
|
|
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
|
|
elif repeat == "monthly":
|
|
advance = self.add_month
|
|
else:
|
|
return None
|
|
nxt = base
|
|
while nxt <= after:
|
|
nxt = advance(nxt)
|
|
guard = 0
|
|
while self.is_excluded_day(nxt, sched) and guard < _CRON_SEARCH_GUARD:
|
|
nxt = advance(nxt)
|
|
guard += 1
|
|
return nxt
|
|
|
|
def due_tasks(self, tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
|
|
"""Tasks that should start now: Scheduled + schedule enabled +
|
|
run_at due."""
|
|
due = []
|
|
for t in tasks:
|
|
if t.get("status") != "scheduled":
|
|
continue
|
|
sched = t.get("schedule", {})
|
|
if not sched.get("enabled"):
|
|
continue
|
|
run_at = _parse_run_at(sched.get("run_at"))
|
|
if run_at is not None and run_at <= now:
|
|
due.append(t)
|
|
return due
|
|
|
|
|
|
__all__ = ["ScheduleCalculator"]
|