mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-16 04:45:34 +00:00
feat(scheduler): add positional day-of-month and date bounds
Standard 5-field cron cannot express "the 4th Tuesday" or "the last
Friday" — patterns recurring nets need — so operators were hand-rolling
day-of-month lists that drift across months of different lengths.
APScheduler's day field already understands these ("last fri", "4th
tue"); the only obstacle is the space, which collides with crontab's
field separator. Accept "-" or "_" in its place and restore it before
handing the field over:
0 19 last-fri * * last Friday of the month
0 19 4th-tue * * fourth Tuesday
0 19 1st-tue,3rd-tue * * first and third Tuesday
Neither separator is ambiguous — crontab range endpoints are digits, so
"last-fri" cannot read as a range. Positional expressions are valid only
in day-of-month; "0 19 * * last-fri" is rejected.
Also adds optional start=/end= date bounds, which limit a schedule to a
date range. They live on the option value rather than the key, ahead of
the channel and keyed with "=", so they never collide with the ":"
separating channel from message, and a body mentioning "start=" is not
misread as a bound. Either may be omitted, order does not matter, and
the end date is inclusive of that whole day. A schedule with no runs
left is skipped at startup with a log line saying why.
The web viewer gains start/end pickers, shows a bounded entry's window
and marks an exhausted one Finished. Bounds round-trip through the edit
cycle, so editing an entry cannot silently drop them.
Expressions without a positional escape are passed to APScheduler
untouched: a 6000-expression sweep over valid crontab forms shows no
behavioural change.
This commit is contained in:
@@ -6,6 +6,27 @@ semantic versioning.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Nth and last weekday of the month in `[Scheduled_Messages]` keys.** The day-of-month
|
||||
field now accepts APScheduler's positional expressions with `-` (or `_`) standing in for
|
||||
the space that would otherwise split the crontab fields: `0 19 last-fri * *` is the last
|
||||
Friday, `0 19 4th-tue * *` the fourth Tuesday, and `0 19 1st-tue,3rd-tue * *` the first
|
||||
and third — patterns recurring nets need and plain 5-field cron cannot express, which
|
||||
previously forced a hand-rolled day-of-month list that drifts across months of different
|
||||
lengths. Expressions without one of these prefixes are passed to APScheduler untouched,
|
||||
so every crontab form that worked before still parses identically. Positional
|
||||
expressions are valid only in the day-of-month field; `0 19 * * last-fri` is rejected.
|
||||
- **Optional `start=` / `end=` date bounds on `[Scheduled_Messages]` values**, for a
|
||||
schedule that should only run over a date range: `0 19 last-fri * * = start=2027-01-01
|
||||
end=2027-03-31 Public:Winter net`. They sit on the value rather than the schedule key,
|
||||
ahead of the channel and keyed with `=`, so they never collide with the `:` separating
|
||||
channel from message and a body mentioning `start=` is never misread as a bound. Either
|
||||
may be omitted and order does not matter; the end date is inclusive of that whole day.
|
||||
A schedule with no runs left is skipped at startup with a log line saying why, and the
|
||||
web viewer's schedule page gains start/end pickers, shows a bounded entry's window and
|
||||
marks an exhausted one **Finished**.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `path` no longer answers "No path information available in current message" on a
|
||||
|
||||
@@ -509,11 +509,31 @@ category.funfact = fun
|
||||
# 0 18 * * * = Public:Hello! ...
|
||||
# 0 18 * * * = Public:#sea:Hello! ... (same channel text, sent with #sea flood scope)
|
||||
#
|
||||
# Optional date bounds limit a schedule to a range. They lead the value, ahead of the
|
||||
# channel, and use "=" so they cannot be confused with the channel:message separator or
|
||||
# with a message body that happens to mention "start=".
|
||||
# <schedule> = start=YYYY-MM-DD end=YYYY-MM-DD channel:message
|
||||
# Either may be omitted, and they may appear in either order. The end date is inclusive:
|
||||
# "end=2027-03-31" runs through the whole of the 31st. A schedule with no runs left is
|
||||
# skipped at startup with a log line saying so.
|
||||
# Examples:
|
||||
# 0 19 last-fri * * = start=2027-01-01 end=2027-03-31 Public:Winter net starts now
|
||||
# 0 8 * * * = end=2026-12-25 Public:#sea:Countdown to the holidays
|
||||
#
|
||||
# <schedule> is one of:
|
||||
# - 5-field crontab (minute hour day-of-month month day-of-week), e.g.
|
||||
# 0 8 * * * = every day at 08:00 (in [Bot] timezone)
|
||||
# 30 12 * * mon = every Monday at 12:30
|
||||
# 30 12 * * 0 = same (Monday) — see APScheduler note below
|
||||
# - Positional day-of-month, in the day-of-month field only. Write the space as
|
||||
# "-" (or "_") so it does not read as a field separator:
|
||||
# 0 19 last-fri * * = 19:00 on the last Friday of every month
|
||||
# 0 19 4th-tue * * = 19:00 on the fourth Tuesday
|
||||
# 0 19 1st-tue,3rd-tue * * = 19:00 on the first and third Tuesday
|
||||
# 0 19 last * * = 19:00 on the last day of the month (no weekday)
|
||||
# Prefixes are 1st 2nd 3rd 4th 5th last, followed by mon–sun. Note that a month
|
||||
# may have no 5th Tuesday, and that "1st and 3rd" is a 14/14/21-day cadence, not
|
||||
# a strict fortnight — crontab has no way to express "every 14 days".
|
||||
# - Preset aliases (@yearly @annually @monthly @weekly @daily @midnight @hourly).
|
||||
# These expand to the 5-field forms above; @weekly is Monday 00:00 (not Sunday).
|
||||
# - Deprecated (still accepted, logs a warning): HHMM = daily at that 24h clock time
|
||||
|
||||
@@ -263,3 +263,44 @@ Schedule keys are parsed by **APScheduler** `CronTrigger.from_crontab` (plus `@`
|
||||
Prefer **`mon`–`sun`** names in the DOW field so expressions stay unambiguous. Example: Monday 12:30 is `30 12 * * mon` or `30 12 * * 0` — **not** Vixie’s `30 12 * * 1` (that is Tuesday here).
|
||||
|
||||
Preset aliases expand to those same APScheduler forms. In particular **`@weekly`** is Monday 00:00 (`0 0 * * 0`), not Sunday midnight as on many Unix crons.
|
||||
|
||||
#### Limiting a schedule to a date range
|
||||
|
||||
Crontab has no field for a date range, so `start=` / `end=` bounds live on the **value**,
|
||||
ahead of the channel:
|
||||
|
||||
```ini
|
||||
[Scheduled_Messages]
|
||||
0 19 last-fri * * = start=2027-01-01 end=2027-03-31 Public:Winter net starts now
|
||||
0 8 * * * = end=2026-12-25 Public:#sea:Countdown to the holidays
|
||||
```
|
||||
|
||||
Either bound may be omitted and they may appear in either order. `=` keeps them clear of
|
||||
the `:` that separates channel from message, and because they are anchored to the front of
|
||||
the value a message body mentioning `start=` is never mistaken for one.
|
||||
|
||||
The end date is **inclusive** — `end=2027-03-31` runs through the whole of the 31st, not up
|
||||
to its midnight. Once a bounded schedule has no runs left the bot skips it at startup with
|
||||
a log line saying so, and the web viewer shows it as **Finished** rather than hiding it.
|
||||
|
||||
#### Nth and last weekday of the month
|
||||
|
||||
APScheduler's day-of-month field understands positional expressions like `last fri` and
|
||||
`4th tue`, which plain crontab cannot express. They contain a space, so write it as `-`
|
||||
(or `_`) to keep the five fields intact:
|
||||
|
||||
| Key | Fires |
|
||||
| --- | --- |
|
||||
| `0 19 last-fri * *` | 19:00 on the last Friday of every month |
|
||||
| `0 19 4th-tue * *` | 19:00 on the fourth Tuesday |
|
||||
| `0 19 1st-tue,3rd-tue * *` | 19:00 on the first and third Tuesday |
|
||||
| `0 19 last * *` | 19:00 on the last day of the month (no weekday needed) |
|
||||
|
||||
Prefixes are `1st` `2nd` `3rd` `4th` `5th` `last`, followed by `mon`–`sun`. They are valid
|
||||
**only in the day-of-month field** — `0 19 * * last-fri` is rejected.
|
||||
|
||||
Two things to keep in mind. A month may have no fifth Tuesday, so `5th tue` simply skips
|
||||
those months. And `1st-tue,3rd-tue` is a 14/14/21-day cadence across a month boundary, not
|
||||
a strict fortnight: crontab matches calendar patterns and has no way to say "every 14
|
||||
days". If you need an exact fortnight, note that anchoring one drifts an hour across a DST
|
||||
change, which is usually worse for an announced net time than the 21-day gap.
|
||||
|
||||
@@ -17,7 +17,11 @@ import configparser
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
from .scheduled_message_cron import parse_schedule_key, parse_scheduled_message_value
|
||||
from .scheduled_message_cron import (
|
||||
parse_schedule_key,
|
||||
parse_scheduled_message_value,
|
||||
split_schedule_bounds,
|
||||
)
|
||||
|
||||
SECTION = "Scheduled_Messages"
|
||||
|
||||
@@ -55,7 +59,12 @@ def next_run_times(trigger: Any, tz: Any, count: int = 5) -> list[str]:
|
||||
|
||||
|
||||
def describe_schedule(
|
||||
schedule: str, tz: Any, message: str = "", count: int = 5
|
||||
schedule: str,
|
||||
tz: Any,
|
||||
message: str = "",
|
||||
count: int = 5,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate a schedule key and describe when it would fire.
|
||||
|
||||
@@ -64,6 +73,8 @@ def describe_schedule(
|
||||
tz: Timezone the bot schedules in.
|
||||
message: Message body, only needed to apply the ``{cmd:...}`` airtime floor.
|
||||
count: How many upcoming runs to return.
|
||||
start: Optional ISO date the schedule starts on (from the entry's value).
|
||||
end: Optional ISO date it runs through, inclusive.
|
||||
|
||||
Returns:
|
||||
``valid``, a human ``label``, ``next_runs``, ``interval_seconds`` (tightest gap),
|
||||
@@ -74,7 +85,7 @@ def describe_schedule(
|
||||
return {"valid": False, "error": "Schedule is required", "next_runs": []}
|
||||
|
||||
try:
|
||||
parsed = parse_schedule_key(raw, tz)
|
||||
parsed = parse_schedule_key(raw, tz, start, end)
|
||||
except Exception as exc: # noqa: BLE001 - any parser error is just an invalid schedule
|
||||
return {"valid": False, "error": f"Could not parse schedule: {exc}", "next_runs": []}
|
||||
|
||||
@@ -83,7 +94,8 @@ def describe_schedule(
|
||||
"valid": False,
|
||||
"error": (
|
||||
"Not a valid schedule. Use 5-field cron (minute hour day-of-month "
|
||||
"month day-of-week), or a preset like @daily or @hourly."
|
||||
"month day-of-week), a positional day-of-month such as last-fri or "
|
||||
"4th-tue, or a preset like @daily or @hourly."
|
||||
),
|
||||
"next_runs": [],
|
||||
}
|
||||
@@ -97,11 +109,21 @@ def describe_schedule(
|
||||
"deprecated": bool(parsed.is_deprecated_hhmm),
|
||||
"error": None,
|
||||
}
|
||||
warnings: list[str] = []
|
||||
if parsed.is_deprecated_hhmm:
|
||||
result["warning"] = (
|
||||
warnings.append(
|
||||
f"{raw} is the deprecated HHMM form and will stop working in a future "
|
||||
"release. Use 5-field cron instead."
|
||||
)
|
||||
if (start or end) and not result["next_runs"]:
|
||||
# Well-formed, just outside its window -- distinct from a malformed schedule.
|
||||
result["finished"] = True
|
||||
warnings.append(
|
||||
f"This schedule has no runs left: it is bounded to "
|
||||
f"{start or 'any date'} .. {end or 'any date'}."
|
||||
)
|
||||
if warnings:
|
||||
result["warning"] = " ".join(warnings)
|
||||
|
||||
# Same floor the scheduler enforces at startup, applied here so the UI refuses it
|
||||
# up front rather than letting it be saved and silently dropped on reload.
|
||||
@@ -131,20 +153,54 @@ def _humanize_seconds(seconds: float) -> str:
|
||||
return f"{seconds} seconds"
|
||||
|
||||
|
||||
def compose_value(channel: str, message: str, scope: str | None = None) -> str:
|
||||
"""Build the config value for an entry, matching parse_scheduled_message_value."""
|
||||
def compose_value(
|
||||
channel: str,
|
||||
message: str,
|
||||
scope: str | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> str:
|
||||
"""Build the config value for an entry, matching parse_scheduled_message_value.
|
||||
|
||||
Date bounds lead, so they cannot be confused with a message body, and are read
|
||||
back off by :func:`~modules.scheduled_message_cron.split_schedule_bounds`.
|
||||
"""
|
||||
channel = (channel or "").strip()
|
||||
message = (message or "").strip()
|
||||
scope = (scope or "").strip()
|
||||
starts_on = (start or "").strip()
|
||||
ends_on = (end or "").strip()
|
||||
prefix = ""
|
||||
if starts_on:
|
||||
prefix += f"start={starts_on} "
|
||||
if ends_on:
|
||||
prefix += f"end={ends_on} "
|
||||
if scope:
|
||||
if not scope.startswith("#"):
|
||||
scope = f"#{scope}"
|
||||
return f"{channel}:{scope}:{message}"
|
||||
return f"{channel}:{message}"
|
||||
return f"{prefix}{channel}:{scope}:{message}"
|
||||
return f"{prefix}{channel}:{message}"
|
||||
|
||||
|
||||
def validate_entry(channel: str, message: str, scope: str | None) -> str | None:
|
||||
def validate_entry(
|
||||
channel: str,
|
||||
message: str,
|
||||
scope: str | None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> str | None:
|
||||
"""Return an error string for an unusable entry, or None when it is fine."""
|
||||
starts_on = (start or "").strip()
|
||||
ends_on = (end or "").strip()
|
||||
for label, value in (("Start date", starts_on), ("End date", ends_on)):
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
datetime.date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return f"{label} must be an ISO date (YYYY-MM-DD)"
|
||||
if starts_on and ends_on and ends_on < starts_on:
|
||||
return "End date is before the start date"
|
||||
if not (channel or "").strip():
|
||||
return "Channel is required"
|
||||
if not (message or "").strip():
|
||||
@@ -187,10 +243,15 @@ def read_entries(config_path: str, tz: Any) -> list[dict[str, Any]]:
|
||||
"channel": "",
|
||||
"scope": None,
|
||||
"message": "",
|
||||
"start": None,
|
||||
"end": None,
|
||||
}
|
||||
try:
|
||||
channel, message, scope = parse_scheduled_message_value(raw_value)
|
||||
entry.update(channel=channel, message=message, scope=scope)
|
||||
start, end, rest = split_schedule_bounds(raw_value)
|
||||
channel, message, scope = parse_scheduled_message_value(rest)
|
||||
entry.update(
|
||||
channel=channel, message=message, scope=scope, start=start, end=end
|
||||
)
|
||||
except ValueError as exc:
|
||||
# Keep the same shape as a described entry so callers never have to
|
||||
# special-case a malformed row to find out it is not running.
|
||||
@@ -199,6 +260,8 @@ def read_entries(config_path: str, tz: Any) -> list[dict[str, Any]]:
|
||||
entry["next_runs"] = []
|
||||
entries.append(entry)
|
||||
continue
|
||||
entry.update(describe_schedule(schedule, tz, message=message))
|
||||
entry.update(
|
||||
describe_schedule(schedule, tz, message=message, start=start, end=end)
|
||||
)
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
Parse ``[Scheduled_Messages]`` option keys into APScheduler CronTrigger instances,
|
||||
and option values into ``(channel, message, scope)`` for optional regional flood scope.
|
||||
|
||||
Option values may carry optional ``start=YYYY-MM-DD`` / ``end=YYYY-MM-DD`` bounds
|
||||
ahead of the channel, which limit a schedule to a date range. They live on the value
|
||||
because crontab has no field for them, and ``=`` keeps them clear of the ``:`` that
|
||||
separates channel from message.
|
||||
|
||||
Supports (schedule keys):
|
||||
- Standard 5-field crontab: minute hour day-of-month month day-of-week
|
||||
- Positional day-of-month: ``last-fri``, ``4th-tue``, ``1st-mon,3rd-mon`` in the
|
||||
day-of-month field, for patterns plain crontab cannot express
|
||||
- Preset aliases: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly
|
||||
- Deprecated legacy HHMM (24-hour, no colon) for daily firing at that clock time
|
||||
|
||||
@@ -15,11 +22,54 @@ Day-of-week uses APScheduler numbering (0=Monday … 6=Sunday), not Vixie cron
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
# Leading "start=YYYY-MM-DD" / "end=YYYY-MM-DD" tokens on an option value. Anchored to
|
||||
# the front so a message body can never be mistaken for a bound, and keyed with "=" so
|
||||
# they do not disturb the "channel:message" split.
|
||||
_BOUND_RE = re.compile(r"^\s*(?P<key>start|end)=(?P<date>\S+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def split_schedule_bounds(raw: str) -> tuple[str | None, str | None, str]:
|
||||
"""Strip leading ``start=`` / ``end=`` tokens off an option value.
|
||||
|
||||
Args:
|
||||
raw: Config value, e.g. ``start=2027-01-01 Public:Hello`` or ``Public:Hello``.
|
||||
|
||||
Returns:
|
||||
``(start, end, rest)`` -- ISO date strings (or None) and the remaining value,
|
||||
which is what :func:`parse_scheduled_message_value` expects.
|
||||
|
||||
Raises:
|
||||
ValueError: If a bound is repeated, is not an ISO date, or ends after it starts.
|
||||
"""
|
||||
rest = raw or ""
|
||||
found: dict[str, str] = {}
|
||||
while True:
|
||||
match = _BOUND_RE.match(rest)
|
||||
if not match:
|
||||
break
|
||||
key = match.group("key").lower()
|
||||
if key in found:
|
||||
raise ValueError(f"{key}= given more than once")
|
||||
try:
|
||||
datetime.date.fromisoformat(match.group("date"))
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"{key}={match.group('date')} is not an ISO date (YYYY-MM-DD)"
|
||||
) from None
|
||||
found[key] = match.group("date")
|
||||
rest = rest[match.end():]
|
||||
start, end = found.get("start"), found.get("end")
|
||||
if start and end and end < start:
|
||||
raise ValueError(f"end={end} is before start={start}")
|
||||
return start, end, rest.strip()
|
||||
|
||||
|
||||
def parse_scheduled_message_value(raw: str) -> tuple[str, str, str | None]:
|
||||
"""Parse a ``[Scheduled_Messages]`` option value into ``(channel, message, scope)``.
|
||||
@@ -43,6 +93,9 @@ def parse_scheduled_message_value(raw: str) -> tuple[str, str, str | None]:
|
||||
s = (raw or "").strip()
|
||||
if ":" not in s:
|
||||
raise ValueError("scheduled message value must be channel:message")
|
||||
if _BOUND_RE.match(s):
|
||||
# split_schedule_bounds() removes these; reaching here means a caller skipped it.
|
||||
raise ValueError("start=/end= bounds must be stripped before parsing the value")
|
||||
parts = s.split(":", 2)
|
||||
if len(parts) == 3 and parts[1].strip().startswith("#"):
|
||||
channel = parts[0].strip()
|
||||
@@ -78,6 +131,50 @@ class ScheduleParseResult:
|
||||
"""True when the legacy HHMM daily form was used."""
|
||||
|
||||
|
||||
# APScheduler's day-of-month field already understands positional expressions such as
|
||||
# "last fri" and "4th tue", but they contain a space, which is crontab's field separator
|
||||
# -- so from_crontab() can never reach them. Accept "-" or "_" in place of that space and
|
||||
# restore it before handing the field over. Neither separator is ambiguous: crontab range
|
||||
# endpoints are digits, so "last-fri" cannot be read as a range.
|
||||
_POSITIONAL_DOM_RE = re.compile(
|
||||
r"(1st|2nd|3rd|4th|5th|last)[-_](mon|tue|wed|thu|fri|sat|sun)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _from_crontab(expr: str, timezone, start_date=None, end_date=None) -> CronTrigger:
|
||||
"""``CronTrigger.from_crontab`` plus escaped positional day-of-month and date bounds.
|
||||
|
||||
``0 19 last-fri * *`` fires 19:00 on the last Friday of each month, and
|
||||
``0 19 1st-tue,3rd-tue * *`` on the first and third Tuesday. Field splitting and
|
||||
the resulting trigger are otherwise identical to ``CronTrigger.from_crontab``,
|
||||
which is itself only this constructor call -- reproduced here because it takes no
|
||||
``start_date``/``end_date``.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``expr`` is not a valid 5-field crontab expression.
|
||||
"""
|
||||
fields = expr.split()
|
||||
if len(fields) != 5:
|
||||
raise ValueError(f"Wrong number of fields; got {len(fields)}, expected 5")
|
||||
|
||||
# Only the day-of-month field takes positional expressions; an escape anywhere else
|
||||
# is left in place so APScheduler rejects it.
|
||||
fields[2] = _POSITIONAL_DOM_RE.sub(r"\1 \2", fields[2])
|
||||
return CronTrigger(
|
||||
minute=fields[0],
|
||||
hour=fields[1],
|
||||
day=fields[2],
|
||||
month=fields[3],
|
||||
day_of_week=fields[4],
|
||||
timezone=timezone,
|
||||
start_date=start_date,
|
||||
# An end date reads as "through this day", so run it to the end of that day
|
||||
# rather than stopping at its midnight.
|
||||
end_date=f"{end_date} 23:59:59" if end_date else None,
|
||||
)
|
||||
|
||||
|
||||
def is_valid_legacy_hhmm(time_str: str) -> bool:
|
||||
"""Return True if ``time_str`` is a valid legacy HHMM clock time (24h)."""
|
||||
try:
|
||||
@@ -93,12 +190,18 @@ def is_valid_legacy_hhmm(time_str: str) -> bool:
|
||||
def parse_schedule_key(
|
||||
schedule_key: str,
|
||||
timezone,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> ScheduleParseResult:
|
||||
"""Parse a ``[Scheduled_Messages]`` option name into a :class:`CronTrigger`.
|
||||
|
||||
Args:
|
||||
schedule_key: Raw config option key (e.g. ``0 9 * * *``, ``@daily``, ``0900``).
|
||||
schedule_key: Raw config option key (e.g. ``0 9 * * *``, ``0 19 last-fri * *``,
|
||||
``@daily``, ``0900``).
|
||||
timezone: ``tzinfo`` or string accepted by APScheduler (same as scheduler).
|
||||
start_date: Optional ISO date; the schedule does not fire before it.
|
||||
end_date: Optional ISO date; the schedule does not fire after the end of it.
|
||||
Both come from the option *value* via :func:`split_schedule_bounds`.
|
||||
|
||||
Returns:
|
||||
ScheduleParseResult with ``trigger`` set when valid, else ``trigger`` is None
|
||||
@@ -114,7 +217,9 @@ def parse_schedule_key(
|
||||
if is_valid_legacy_hhmm(raw):
|
||||
hour = int(raw[:2])
|
||||
minute = int(raw[2:])
|
||||
trigger = CronTrigger(hour=hour, minute=minute, timezone=timezone)
|
||||
trigger = _from_crontab(
|
||||
f"{minute} {hour} * * *", timezone, start_date, end_date
|
||||
)
|
||||
display = f"{hour:02d}:{minute:02d}"
|
||||
return ScheduleParseResult(trigger, display, True)
|
||||
|
||||
@@ -122,14 +227,14 @@ def parse_schedule_key(
|
||||
if lowered in _SPECIAL_PRESET_TO_CRON:
|
||||
cron_expr = _SPECIAL_PRESET_TO_CRON[lowered]
|
||||
try:
|
||||
trigger = CronTrigger.from_crontab(cron_expr, timezone=timezone)
|
||||
trigger = _from_crontab(cron_expr, timezone, start_date, end_date)
|
||||
except ValueError:
|
||||
return ScheduleParseResult(None, raw, False)
|
||||
return ScheduleParseResult(trigger, raw, False)
|
||||
|
||||
# 3) Standard 5-field crontab
|
||||
# 3) Standard 5-field crontab (optionally with a positional day-of-month)
|
||||
try:
|
||||
trigger = CronTrigger.from_crontab(raw, timezone=timezone)
|
||||
trigger = _from_crontab(raw, timezone, start_date, end_date)
|
||||
except ValueError:
|
||||
return ScheduleParseResult(None, raw, False)
|
||||
return ScheduleParseResult(trigger, raw, False)
|
||||
|
||||
+19
-2
@@ -28,6 +28,7 @@ from .scheduled_message_cron import (
|
||||
is_valid_legacy_hhmm,
|
||||
parse_schedule_key,
|
||||
parse_scheduled_message_value,
|
||||
split_schedule_bounds,
|
||||
)
|
||||
from .security_utils import validate_external_url
|
||||
from .utils import (
|
||||
@@ -116,13 +117,29 @@ class MessageScheduler:
|
||||
for schedule_key, message_info in self.bot.config.items('Scheduled_Messages'):
|
||||
self.logger.info(f"Processing scheduled message: '{schedule_key}' -> '{message_info}'")
|
||||
try:
|
||||
parsed = parse_schedule_key(schedule_key, tz)
|
||||
# Optional start=/end= bounds ride on the value, since crontab has
|
||||
# no field for them; strip them before reading channel:message.
|
||||
start_date, end_date, value = split_schedule_bounds(message_info)
|
||||
parsed = parse_schedule_key(schedule_key, tz, start_date, end_date)
|
||||
if parsed.trigger is None:
|
||||
self.logger.warning(
|
||||
f"Invalid schedule '{schedule_key}' for scheduled message: {message_info}"
|
||||
)
|
||||
continue
|
||||
|
||||
if (start_date or end_date) and parsed.trigger.get_next_fire_time(
|
||||
None, datetime.datetime.now(tz)
|
||||
) is None:
|
||||
self.logger.warning(
|
||||
"Scheduled_Messages key %r is bounded to %s..%s and has no "
|
||||
"runs left; not scheduled: %s",
|
||||
schedule_key,
|
||||
start_date or "any date",
|
||||
end_date or "any date",
|
||||
message_info,
|
||||
)
|
||||
continue
|
||||
|
||||
if parsed.is_deprecated_hhmm:
|
||||
hh = int(schedule_key[:2])
|
||||
mm = int(schedule_key[2:])
|
||||
@@ -135,7 +152,7 @@ class MessageScheduler:
|
||||
cron_suggestion,
|
||||
)
|
||||
|
||||
channel, message, scope = parse_scheduled_message_value(message_info)
|
||||
channel, message, scope = parse_scheduled_message_value(value)
|
||||
message = decode_escape_sequences(message)
|
||||
|
||||
if self._has_command_placeholders(message):
|
||||
|
||||
@@ -4025,6 +4025,8 @@ class BotDataViewer:
|
||||
_schedule_tz(),
|
||||
message=data.get('message', ''),
|
||||
count=count,
|
||||
start=(data.get('start') or '').strip() or None,
|
||||
end=(data.get('end') or '').strip() or None,
|
||||
))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error previewing schedule: {e}")
|
||||
@@ -4040,12 +4042,17 @@ class BotDataViewer:
|
||||
channel = (data.get('channel') or '').strip()
|
||||
message = (data.get('message') or '').strip()
|
||||
scope = (data.get('scope') or '').strip() or None
|
||||
# Optional date bounds; they live on the value, not the schedule key.
|
||||
start = (data.get('start') or '').strip() or None
|
||||
end = (data.get('end') or '').strip() or None
|
||||
|
||||
field_error = validate_entry(channel, message, scope)
|
||||
field_error = validate_entry(channel, message, scope, start, end)
|
||||
if field_error:
|
||||
return jsonify({'success': False, 'error': field_error}), 400
|
||||
|
||||
described = describe_schedule(schedule, _schedule_tz(), message=message)
|
||||
described = describe_schedule(
|
||||
schedule, _schedule_tz(), message=message, start=start, end=end
|
||||
)
|
||||
if not described.get('valid'):
|
||||
return jsonify({'success': False, 'error': described.get('error')}), 400
|
||||
|
||||
@@ -4070,7 +4077,11 @@ class BotDataViewer:
|
||||
),
|
||||
}), 409
|
||||
|
||||
updates = {SCHEDULED_MESSAGES_SECTION: {schedule: compose_value(channel, message, scope)}}
|
||||
updates = {
|
||||
SCHEDULED_MESSAGES_SECTION: {
|
||||
schedule: compose_value(channel, message, scope, start, end)
|
||||
}
|
||||
}
|
||||
deletes = None
|
||||
if replacing and replacing != schedule:
|
||||
deletes = {SCHEDULED_MESSAGES_SECTION: [replacing]}
|
||||
|
||||
@@ -101,6 +101,8 @@
|
||||
Five fields: minute hour day-of-month month day-of-week.
|
||||
Day-of-week is 0=Monday here, not Sunday. Presets like
|
||||
<code>@daily</code> and <code>@hourly</code> also work.
|
||||
Day-of-month also takes <code>last-fri</code>,
|
||||
<code>4th-tue</code> or <code>1st-tue,3rd-tue</code>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,6 +118,21 @@
|
||||
<div class="form-text">Leave blank for global flood.</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="scheduleStart">Start date <span class="text-muted">(optional)</span></label>
|
||||
<input type="date" class="form-control" id="scheduleStart">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="scheduleEnd">End date <span class="text-muted">(optional)</span></label>
|
||||
<input type="date" class="form-control" id="scheduleEnd">
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Limit this schedule to a date range. The end date is included in full.
|
||||
Leave both blank to run indefinitely.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="scheduleMessage">Message</label>
|
||||
<textarea class="form-control" id="scheduleMessage" rows="3"
|
||||
@@ -265,7 +282,13 @@
|
||||
try {
|
||||
const data = await api('/api/scheduled-messages/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ schedule: cron, message: $('scheduleMessage').value, count: 5 })
|
||||
body: JSON.stringify({
|
||||
schedule: cron,
|
||||
message: $('scheduleMessage').value,
|
||||
start: $('scheduleStart').value,
|
||||
end: $('scheduleEnd').value,
|
||||
count: 5
|
||||
})
|
||||
});
|
||||
if (data.valid) {
|
||||
$('previewRuns').innerHTML = (data.next_runs || [])
|
||||
@@ -287,6 +310,8 @@
|
||||
$('scheduleChannel').value = entry ? entry.channel : '';
|
||||
$('scheduleScope').value = entry && entry.scope ? entry.scope : '';
|
||||
$('scheduleMessage').value = entry ? entry.message : '';
|
||||
$('scheduleStart').value = entry && entry.start ? entry.start : '';
|
||||
$('scheduleEnd').value = entry && entry.end ? entry.end : '';
|
||||
// Existing entries open in Advanced so the stored cron is never silently
|
||||
// rewritten into something the friendly controls happen to produce.
|
||||
$('scheduleMode').value = entry ? 'advanced' : 'daily';
|
||||
@@ -301,7 +326,9 @@
|
||||
schedule: cron,
|
||||
channel: $('scheduleChannel').value,
|
||||
scope: $('scheduleScope').value,
|
||||
message: $('scheduleMessage').value
|
||||
message: $('scheduleMessage').value,
|
||||
start: $('scheduleStart').value,
|
||||
end: $('scheduleEnd').value
|
||||
};
|
||||
const editing = editingSchedule !== null;
|
||||
if (editing) payload.original_schedule = editingSchedule;
|
||||
@@ -355,9 +382,15 @@
|
||||
let scheduleCell = '<span class="font-monospace">' + escapeHtml(entry.schedule) + '</span>';
|
||||
if (entry.error) {
|
||||
scheduleCell += '<br><span class="badge bg-danger">Not scheduled</span>';
|
||||
} else if (entry.finished) {
|
||||
scheduleCell += '<br><span class="badge bg-secondary">Finished</span>';
|
||||
} else if (entry.deprecated) {
|
||||
scheduleCell += '<br><span class="badge bg-warning text-dark">Deprecated format</span>';
|
||||
}
|
||||
if (entry.start || entry.end) {
|
||||
scheduleCell += '<br><span class="small text-muted">'
|
||||
+ escapeHtml((entry.start || '…') + ' → ' + (entry.end || '…')) + '</span>';
|
||||
}
|
||||
|
||||
const scopeBadge = entry.scope
|
||||
? ' <span class="badge bg-secondary">' + escapeHtml(entry.scope) + '</span>' : '';
|
||||
@@ -403,7 +436,7 @@
|
||||
$('saveScheduleBtn').addEventListener('click', save);
|
||||
$('scheduleMode').addEventListener('change', syncModeFields);
|
||||
['scheduleTime', 'scheduleTimes', 'scheduleEveryHours', 'scheduleEveryMinutes',
|
||||
'scheduleCron', 'scheduleMessage'].forEach((id) =>
|
||||
'scheduleCron', 'scheduleMessage', 'scheduleStart', 'scheduleEnd'].forEach((id) =>
|
||||
$(id).addEventListener('input', schedulePreview));
|
||||
syncModeFields();
|
||||
load();
|
||||
|
||||
@@ -8,7 +8,11 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.scheduled_message_cron import parse_schedule_key
|
||||
from modules.scheduled_message_cron import (
|
||||
parse_schedule_key,
|
||||
parse_scheduled_message_value,
|
||||
split_schedule_bounds,
|
||||
)
|
||||
from modules.scheduler import MessageScheduler
|
||||
|
||||
|
||||
@@ -52,6 +56,151 @@ class TestParseScheduleKey:
|
||||
r = parse_schedule_key("not-a-valid-cron", tz)
|
||||
assert r.trigger is None
|
||||
|
||||
@pytest.mark.parametrize("expr", [
|
||||
# Forms plain crontab already accepts; guards against a parser that narrows them.
|
||||
"0 8 * * *", "*/15 * * * *", "0 6,12,18 * * *", "0 8 * * mon-fri",
|
||||
"0 0-20/2 * * *", "0-10,30 8 * * *", "0 0 1-15/3 * *", "0 19 last * *",
|
||||
"0 8 15 jan *", "0 8 * may-oct,dec *",
|
||||
])
|
||||
def test_standard_crontab_forms_still_parse(self, expr):
|
||||
assert parse_schedule_key(expr, ZoneInfo("UTC")).trigger is not None
|
||||
|
||||
|
||||
class TestPositionalDayOfMonth:
|
||||
"""Escaped positional day-of-month expressions (last-fri, 4th-tue, 1st-mon,3rd-mon).
|
||||
|
||||
APScheduler understands these natively; the escape only works around the space that
|
||||
would otherwise be read as a crontab field separator.
|
||||
"""
|
||||
|
||||
TZ = ZoneInfo("UTC")
|
||||
NOW = datetime.datetime(2026, 8, 29, 12, 0, tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
def _next_runs(self, expr, count=3):
|
||||
trigger = parse_schedule_key(expr, self.TZ).trigger
|
||||
assert trigger is not None, f"{expr!r} did not parse"
|
||||
runs, prev, now = [], None, self.NOW
|
||||
for _ in range(count):
|
||||
fire = trigger.get_next_fire_time(prev, now)
|
||||
runs.append(fire)
|
||||
prev, now = fire, fire + datetime.timedelta(seconds=1)
|
||||
return runs
|
||||
|
||||
def test_last_weekday_of_month(self):
|
||||
assert self._next_runs("0 19 last-fri * *") == [
|
||||
datetime.datetime(2026, 9, 25, 19, 0, tzinfo=self.TZ),
|
||||
datetime.datetime(2026, 10, 30, 19, 0, tzinfo=self.TZ),
|
||||
datetime.datetime(2026, 11, 27, 19, 0, tzinfo=self.TZ),
|
||||
]
|
||||
|
||||
def test_nth_weekday_of_month(self):
|
||||
assert self._next_runs("0 19 4th-tue * *") == [
|
||||
datetime.datetime(2026, 9, 22, 19, 0, tzinfo=self.TZ),
|
||||
datetime.datetime(2026, 10, 27, 19, 0, tzinfo=self.TZ),
|
||||
datetime.datetime(2026, 11, 24, 19, 0, tzinfo=self.TZ),
|
||||
]
|
||||
|
||||
def test_list_of_positions_gives_a_fortnightly_net(self):
|
||||
# "1st and 3rd Tuesday" -- what recurring nets usually mean by "every other".
|
||||
assert self._next_runs("0 19 1st-tue,3rd-tue * *") == [
|
||||
datetime.datetime(2026, 9, 1, 19, 0, tzinfo=self.TZ),
|
||||
datetime.datetime(2026, 9, 15, 19, 0, tzinfo=self.TZ),
|
||||
datetime.datetime(2026, 10, 6, 19, 0, tzinfo=self.TZ),
|
||||
]
|
||||
|
||||
def test_underscore_is_accepted_as_the_separator(self):
|
||||
assert self._next_runs("0 19 last_fri * *", 1) == self._next_runs("0 19 last-fri * *", 1)
|
||||
|
||||
def test_is_case_insensitive(self):
|
||||
assert self._next_runs("0 19 LAST-FRI * *", 1) == self._next_runs("0 19 last-fri * *", 1)
|
||||
|
||||
def test_combines_with_a_month_restriction(self):
|
||||
assert self._next_runs("0 19 last-fri jan-jun *", 1) == [
|
||||
datetime.datetime(2027, 1, 29, 19, 0, tzinfo=self.TZ),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("expr", [
|
||||
"0 19 * * last-fri", # positional expressions are day-of-month only
|
||||
"0 19 last-fri *", # too few fields
|
||||
"0 19 last-fri * * *", # too many fields
|
||||
"last-fri", # not a crontab expression
|
||||
"0 19 6th-tue * *", # there is no 6th weekday
|
||||
])
|
||||
def test_rejects_misplaced_or_malformed_expressions(self, expr):
|
||||
assert parse_schedule_key(expr, self.TZ).trigger is None
|
||||
|
||||
|
||||
class TestScheduleBounds:
|
||||
"""start=/end= date bounds, which ride on the option value (crontab has no field)."""
|
||||
|
||||
TZ = ZoneInfo("UTC")
|
||||
|
||||
@pytest.mark.parametrize("raw, expected", [
|
||||
("Public:Hello", (None, None, "Public:Hello")),
|
||||
("start=2027-01-01 Public:Hi", ("2027-01-01", None, "Public:Hi")),
|
||||
("end=2027-03-31 Public:Hi", (None, "2027-03-31", "Public:Hi")),
|
||||
("start=2027-01-01 end=2027-03-31 Public:#sea:Hi",
|
||||
("2027-01-01", "2027-03-31", "Public:#sea:Hi")),
|
||||
# order does not matter, and the keyword is case-insensitive
|
||||
("end=2027-03-31 start=2027-01-01 Public:Hi",
|
||||
("2027-01-01", "2027-03-31", "Public:Hi")),
|
||||
("START=2027-01-01 Public:Hi", ("2027-01-01", None, "Public:Hi")),
|
||||
])
|
||||
def test_splits_bounds_off_the_front(self, raw, expected):
|
||||
assert split_schedule_bounds(raw) == expected
|
||||
|
||||
def test_a_message_body_is_never_mistaken_for_a_bound(self):
|
||||
# Bounds are anchored to the front, so free text stays untouched.
|
||||
raw = "Public:Sign-ups start=2027 and end=soon"
|
||||
assert split_schedule_bounds(raw) == (None, None, raw)
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"start=2027-13-45 Public:Hi", # not a real date
|
||||
"start=tomorrow Public:Hi", # not ISO
|
||||
"start=2027-01-01 start=2027-02-01 P:Hi", # repeated
|
||||
"start=2027-03-01 end=2027-01-01 P:Hi", # ends before it starts
|
||||
])
|
||||
def test_rejects_malformed_bounds(self, bad):
|
||||
with pytest.raises(ValueError):
|
||||
split_schedule_bounds(bad)
|
||||
|
||||
def test_value_parser_refuses_unstripped_bounds(self):
|
||||
# A caller that forgets split_schedule_bounds() must fail loudly rather than
|
||||
# silently treat "start=2027-01-01 Public" as a channel name.
|
||||
with pytest.raises(ValueError):
|
||||
parse_scheduled_message_value("start=2027-01-01 Public:Hi")
|
||||
|
||||
def _first_runs(self, key, start=None, end=None, count=3):
|
||||
trigger = parse_schedule_key(key, self.TZ, start, end).trigger
|
||||
assert trigger is not None
|
||||
runs, prev, now = [], None, datetime.datetime(2026, 8, 29, 12, 0, tzinfo=self.TZ)
|
||||
for _ in range(count):
|
||||
fire = trigger.get_next_fire_time(prev, now)
|
||||
if fire is None:
|
||||
break
|
||||
runs.append(fire)
|
||||
prev, now = fire, fire + datetime.timedelta(seconds=1)
|
||||
return runs
|
||||
|
||||
def test_start_date_defers_the_first_run(self):
|
||||
assert self._first_runs("0 19 * * *", start="2027-03-01", count=1) == [
|
||||
datetime.datetime(2027, 3, 1, 19, 0, tzinfo=self.TZ)
|
||||
]
|
||||
|
||||
def test_end_date_includes_the_whole_final_day(self):
|
||||
# "end=2026-09-02" means through the 2nd, not up to its midnight.
|
||||
assert self._first_runs("0 19 * * *", end="2026-09-02", count=9)[-1] == (
|
||||
datetime.datetime(2026, 9, 2, 19, 0, tzinfo=self.TZ)
|
||||
)
|
||||
|
||||
def test_bounds_apply_to_positional_preset_and_legacy_forms(self):
|
||||
for key in ("0 19 last-fri * *", "@daily", "0900"):
|
||||
runs = self._first_runs(key, start="2027-03-01", count=1)
|
||||
assert runs and runs[0] >= datetime.datetime(2027, 3, 1, tzinfo=self.TZ), key
|
||||
|
||||
def test_an_exhausted_schedule_has_no_runs(self):
|
||||
assert self._first_runs("0 19 * * *", end="2020-01-01") == []
|
||||
|
||||
|
||||
class TestIsValidTimeFormat:
|
||||
"""Tests for _is_valid_time_format()."""
|
||||
|
||||
@@ -22,11 +22,14 @@ TZ = datetime.timezone.utc
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDescribeSchedule:
|
||||
@pytest.mark.parametrize("cron", ["0 8 * * *", "0 6,12,18 * * *", "*/30 * * * *", "@daily"])
|
||||
@pytest.mark.parametrize("cron", ["0 8 * * *", "0 6,12,18 * * *", "*/30 * * * *", "@daily",
|
||||
"0 19 last-fri * *", "0 19 4th-tue * *",
|
||||
"0 19 1st-tue,3rd-tue * *", "0 19 last_fri * *"])
|
||||
def test_accepts_valid_schedules(self, cron):
|
||||
assert describe_schedule(cron, TZ)["valid"] is True
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", " ", "nonsense", "0 8 * *", "99 99 * * *"])
|
||||
@pytest.mark.parametrize("bad", ["", " ", "nonsense", "0 8 * *", "99 99 * * *",
|
||||
"0 19 * * last-fri", "0 19 6th-tue * *"])
|
||||
def test_rejects_invalid_schedules(self, bad):
|
||||
result = describe_schedule(bad, TZ)
|
||||
assert result["valid"] is False
|
||||
@@ -142,3 +145,61 @@ class TestReadEntries:
|
||||
|
||||
def test_missing_file_is_empty_not_an_error(self, tmp_path):
|
||||
assert read_entries(str(tmp_path / "nope.ini"), TZ) == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDateBounds:
|
||||
"""start=/end= bounds round-trip through the value, so a UI edit cannot drop them."""
|
||||
|
||||
@pytest.mark.parametrize("channel, message, scope, start, end, expected", [
|
||||
("Public", "Hi", None, None, None, "Public:Hi"),
|
||||
("Public", "Hi", None, "2027-01-01", None, "start=2027-01-01 Public:Hi"),
|
||||
("Public", "Hi", None, None, "2027-03-31", "end=2027-03-31 Public:Hi"),
|
||||
("Public", "Hi", "#sea", "2027-01-01", "2027-03-31",
|
||||
"start=2027-01-01 end=2027-03-31 Public:#sea:Hi"),
|
||||
])
|
||||
def test_compose_value_places_bounds_ahead_of_the_channel(
|
||||
self, channel, message, scope, start, end, expected
|
||||
):
|
||||
assert compose_value(channel, message, scope, start, end) == expected
|
||||
|
||||
@pytest.mark.parametrize("raw", [
|
||||
"Public:Hi",
|
||||
"start=2027-01-01 Public:Hi",
|
||||
"end=2027-03-31 Public:#sea:Hi",
|
||||
"start=2027-01-01 end=2027-03-31 Public:#sea:Hi",
|
||||
])
|
||||
def test_read_then_recompose_is_lossless(self, tmp_path, raw):
|
||||
# The web UI edit cycle: read an entry, hand it back to compose_value unchanged.
|
||||
config = tmp_path / "config.ini"
|
||||
config.write_text(f"[Scheduled_Messages]\n0 19 * * * = {raw}\n", encoding="utf-8")
|
||||
entry = read_entries(str(config), TZ)[0]
|
||||
assert compose_value(
|
||||
entry["channel"], entry["message"], entry["scope"],
|
||||
entry["start"], entry["end"],
|
||||
) == raw
|
||||
|
||||
def test_bounded_schedule_previews_within_its_window(self):
|
||||
result = describe_schedule("0 19 * * *", TZ, start="2027-03-01", end="2027-03-03")
|
||||
assert result["valid"] is True
|
||||
assert [r[:10] for r in result["next_runs"]] == [
|
||||
"2027-03-01", "2027-03-02", "2027-03-03",
|
||||
]
|
||||
|
||||
def test_exhausted_schedule_is_flagged_finished_not_invalid(self):
|
||||
result = describe_schedule("0 19 * * *", TZ, end="2020-01-01")
|
||||
assert result["valid"] is True # well-formed, just out of runs
|
||||
assert result["finished"] is True
|
||||
assert result["next_runs"] == []
|
||||
assert "no runs left" in result["warning"]
|
||||
|
||||
@pytest.mark.parametrize("start, end, expected", [
|
||||
("2027-13-45", None, "Start date must be an ISO date (YYYY-MM-DD)"),
|
||||
(None, "nope", "End date must be an ISO date (YYYY-MM-DD)"),
|
||||
("2027-03-01", "2027-01-01", "End date is before the start date"),
|
||||
("2027-01-01", "2027-03-01", None),
|
||||
(None, None, None),
|
||||
])
|
||||
def test_validate_entry_checks_the_dates(self, start, end, expected):
|
||||
assert validate_entry("Public", "Hi", None, start, end) == expected
|
||||
|
||||
|
||||
Reference in New Issue
Block a user