"""FlowDeck — Recurring events engine (v5.8.0 Calendrier & Rappels). A recurring row stores its rule in ``property_values_json`` under the special key ``__recurrence__``:: "__recurrence__": { "": {"freq": "weekly", "interval": 1, "count": null, "until": null, "byweekday": [0,2,4], "timezone": "Europe/Paris"} } Only a RRULE subset is supported (matching the roadmap: daily/weekly/monthly + custom interval, COUNT, UNTIL and BYDAY for weekly). Expansion is computed on the fly for a visible window — occurrences are virtual, never persisted. All pure-Python (stdlib only) so it is trivially testable. """ from __future__ import annotations import calendar as _cal import datetime as dt import logging logger = logging.getLogger(__name__) FREQS = ("daily", "weekly", "monthly") # Monday-first weekday numbering (matches the UI calendar grid). DOW_NAMES = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6} RECURRENCE_KEY = "__recurrence__" TIMEZONE_KEY = "__timezone__" try: # Python >= 3.9 ships zoneinfo; keep a graceful fallback anyway. from zoneinfo import ZoneInfo, available_timezones def _zone(tz_name: str): try: return ZoneInfo(tz_name) except Exception: return None def is_valid_timezone(tz_name: str) -> bool: if not tz_name: return True try: return tz_name in available_timezones() or tz_name == "UTC" except Exception: return False except Exception: # pragma: no cover - environment without tz database def _zone(tz_name: str): return None def is_valid_timezone(tz_name: str) -> bool: return True # no tz database → accept anything (zoneinfo absent) def parse_date(value) -> dt.date | None: """Parse a stored date property value ('YYYY-MM-DD[THH:MM]') into a date.""" s = str(value or "").strip() if not s: return None s = s.replace(" ", "T") for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d"): try: return dt.datetime.strptime(s[:19], fmt).date() except ValueError: continue try: return dt.date.fromisoformat(s[:10]) except ValueError: return None def validate_rule(rule) -> tuple[bool, str]: """Validate a recurrence rule dict. Returns (ok, error_message).""" if rule is None: return True, "" if not isinstance(rule, dict): return False, "Recurrence must be an object" freq = rule.get("freq") if freq not in FREQS: return False, f"freq must be one of {FREQS}" interval = rule.get("interval", 1) if not isinstance(interval, int) or isinstance(interval, bool) or interval < 1 or interval > 365: return False, "interval must be an integer between 1 and 365" count = rule.get("count") if count is not None: if not isinstance(count, int) or isinstance(count, bool) or count < 1 or count > 1000: return False, "count must be an integer between 1 and 1000" until = rule.get("until") if until not in (None, "") and parse_date(until) is None: return False, "until must be a YYYY-MM-DD date" if count is not None and until not in (None, ""): return False, "count and until are mutually exclusive" bywd = rule.get("byweekday") if bywd not in (None, []): if not isinstance(bywd, list) or any( (not isinstance(d, int) or isinstance(d, bool) or d < 0 or d > 6) for d in bywd ): return False, "byweekday must be a list of integers 0-6 (Monday=0)" if freq != "weekly": return False, "byweekday is only valid for weekly recurrence" tz = rule.get("timezone") if tz not in (None, "") and not is_valid_timezone(tz): return False, f"unknown timezone '{tz}'" return True, "" def _generate_occurrences(base: dt.date, rule: dict): """Yield every occurrence date in chronological order, honouring freq/interval/count/until/byweekday. Infinite rules yield forever — the caller must bound the consumption (see expand_rule).""" freq = rule.get("freq", "daily") interval = max(1, int(rule.get("interval") or 1)) count = rule.get("count") until = parse_date(rule.get("until")) if rule.get("until") else None weekdays = sorted(set(rule.get("byweekday") or [])) if freq == "weekly" else [] produced = 0 def limit_hit(d: dt.date) -> bool: return (count is not None and produced >= count) or bool(until and d > until) if freq == "daily": d = base while not limit_hit(d): yield d produced += 1 d += dt.timedelta(days=interval) elif freq == "weekly": # Monday=0 weekday numbering (matches the UI calendar grid). days = weekdays or [(base.isoweekday() - 1) % 7] week0 = base - dt.timedelta(days=(base.isoweekday() - 1) % 7) w = week0 while True: for off in days: d = w + dt.timedelta(days=off) if d < base or limit_hit(d): continue yield d produced += 1 w += dt.timedelta(weeks=interval) if limit_hit(w): return else: # monthly i = 0 while True: d = _nth_month_occurrence(base, i * interval) if limit_hit(d): return yield d produced += 1 i += 1 def expand_rule(base_value, rule: dict, window_start: dt.date, window_end: dt.date, max_occurrences: int = 500) -> list[str]: """Return ISO dates ('YYYY-MM-DD') of occurrences inside the inclusive window [window_start, window_end] for a rule anchored at ``base_value``. Occurrences before the window are skipped (but still count against ``count``); the expansion is hard-capped so pathological rules can never blow up a request. """ base = parse_date(base_value) ok, _err = validate_rule(rule) if base is None or rule is None or not ok: return [] out: list[str] = [] scanned = 0 for d in _generate_occurrences(base, rule or {}): if d > window_end: break scanned += 1 if scanned > 200000: # safety valve for old, very long-running rules break if d >= window_start: out.append(d.isoformat()) if len(out) >= max_occurrences: break return out def _nth_month_occurrence(base: dt.date, months_offset: int) -> dt.date: month_index = (base.year * 12 + (base.month - 1)) + months_offset year, month0 = divmod(month_index, 12) month = month0 + 1 day = min(base.day, _cal.monthrange(year, month)[1]) return dt.date(year, month, day) def now_in_tz(tz_name: str | None = None) -> dt.datetime: """Current time in the given IANA timezone (fallback: UTC).""" zone = _zone(tz_name) if tz_name else None if zone is not None: return dt.datetime.now(zone) return dt.datetime.now(dt.UTC) def local_date_in_tz(tz_name: str | None = None) -> dt.date: """'Today' from the point of view of ``tz_name`` (fallback UTC).""" return now_in_tz(tz_name).date() def _zone_dt(d: dt.date, time_str: str, tz_name: str | None): """Combine an occurrence date + 'HH:MM' time into an aware datetime in ``tz_name`` (fallback naive→UTC). Default time 09:00 when none stored.""" hh, mm = 9, 0 if time_str and len(time_str) >= 16: try: hh, mm = int(time_str[11:13]), int(time_str[14:16]) except ValueError: pass zone = _zone(tz_name) if tz_name else None naive = dt.datetime(d.year, d.month, d.day, hh, mm) if zone is not None: return naive.replace(tzinfo=zone) return naive.replace(tzinfo=dt.UTC) def next_occurrences(base_value, rule: dict, tz_name: str | None, after: dt.datetime, horizon_days: int = 365, max_count: int = 50) -> list[tuple[dt.date, dt.datetime]]: """Upcoming occurrences (date, aware event datetime) strictly on/after ``after`` for a row anchored at ``base_value``. Non-recurring rules (rule falsy) yield the single base occurrence. Bounded by horizon.""" base = parse_date(base_value) if base is None: return [] time_str = str(base_value or "") start = after.date() if not rule: event = _zone_dt(base, time_str, tz_name) return [(base, event)] if event >= after else [] ok, _ = validate_rule(rule) if not ok: return [] out: list[tuple[dt.date, dt.datetime]] = [] window_end = start + dt.timedelta(days=horizon_days) for iso in expand_rule(base_value, rule, start, window_end, max_occurrences=max_count): d = dt.date.fromisoformat(iso) event = _zone_dt(d, time_str, tz_name) if event >= after: out.append((d, event)) if len(out) >= max_count: break return out # A pragmatic list for pickers (zoneinfo's full list is ~600 entries). COMMON_TIMEZONES = [ "UTC", "Europe/Paris", "Europe/London", "Europe/Berlin", "Europe/Madrid", "Europe/Rome", "Europe/Amsterdam", "Europe/Lisbon", "Europe/Zurich", "Europe/Stockholm", "Europe/Warsaw", "Europe/Moscow", "Europe/Istanbul", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "America/Toronto", "America/Vancouver", "America/Mexico_City", "America/Sao_Paulo", "America/Buenos_Aires", "Asia/Dubai", "Asia/Karachi", "Asia/Kolkata", "Asia/Bangkok", "Asia/Singapore", "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul", "Australia/Sydney", "Australia/Perth", "Pacific/Auckland", "Africa/Cairo", "Africa/Lagos", "Africa/Johannesburg", "Africa/Algiers", "Africa/Casablanca", ] def common_timezones() -> list[str]: """Timezone list for pickers: curated common zones + all installed ones whose region matches, deduplicated and sorted.""" try: from zoneinfo import available_timezones all_zones = sorted(available_timezones()) # Merge: keep curated first, then everything else available. extra = [z for z in all_zones if z not in COMMON_TIMEZONES] return COMMON_TIMEZONES + extra except Exception: return list(COMMON_TIMEZONES)