- A30 — `require_scope()` câblé : 69 sites stricts de api_v2.py passent par la factory (Bearer + scope en 1 appel, contrôle manuel supprimé) ; sémantique alignée sur celle des handlers (pas de default "read" → 0 changement de comportement) ; 12 top-level morts supprimés (0 ref app ET tests) : unsync_block, find_referring, _b64url, strip_markdown, format_number, get_auto_property_value, get_next_unique_id, local_date_in_tz, verify_device_token, _get_dynamic_groups, _require_user_gitea, validate_upload_request - A37 — CORS sans `*` : origines = app_base_url + allow_origin_regex (localhost/dev, origines d'extension pour le Web Clipper), méthodes et entêtes minutées, allow_credentials explicite + test test_cors_no_star - A39 — htmx : décision « rien » documentée (32 attributs hx-* réels sur 6 templates, conversion = refonte du view-switching sans test E2E) - A40 — version d'assets à source unique : ENV.globals["asset_version"] lu au boot depuis le fichier VERSION ; littéraux `?v=` de base.html éliminés ; test test_asset_version_single_source - A41 — app.css : 91 règles mortes purgées (-10 274 octets, 121 618 → 111 344), scan templates/JS/CSS/Python à 0 référence suite **1031/1031** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.4.0 docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
271 lines
10 KiB
Python
271 lines
10 KiB
Python
"""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__": { "<date_prop_id>": {"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 _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)
|