Files
cowork-local/domain/tasks/schedule_calculator.py
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

171 lines
7.4 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]:
"""Lần chạy kế tiếp sau một mốc thời gian; ``None`` nếu không bao giờ."""
...
def _parse_run_at(value: Optional[str]) -> Optional[datetime]:
"""Đọc chuỗi thời gian chạy thành ``datetime``; sai định dạng thì trả ``None``."""
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:
"""``is_holiday``/``make_cron`` tiêm được nên lớp này không phụ thuộc vào lịch
nghỉ hay bộ phân tích cron nào cụ thể — test truyền hàm giả vào.
"""
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"]