Merge pull request 'Add a weekly cadence to the match schedule' (#15) from issue-9-advanced-cadence into main
All checks were successful
Test, Build and Publish / test (push) Successful in 33s
Test, Build and Publish / build-and-push-images (push) Successful in 1m9s

Reviewed-on: #15
This commit is contained in:
mdiluz 2024-09-22 14:26:39 +01:00
commit 9d3d57ef30
5 changed files with 140 additions and 42 deletions

View file

@ -92,8 +92,8 @@ class MatcherCog(commands.Cog):
msg += "\n" + strings.paused_matchees(mentions) + "\n" msg += "\n" + strings.paused_matchees(mentions) + "\n"
tasks = state.State.get_channel_match_tasks(interaction.channel.id) tasks = state.State.get_channel_match_tasks(interaction.channel.id)
for (day, hour, min) in tasks: for (day, hour, min, cadence, cadence_start) in tasks:
next_run = util.get_next_datetime(day, hour) next_run = util.get_next_datetime_with_cadence(day, hour, datetime.now(), cadence, cadence_start)
msg += "\n" + strings.scheduled(next_run, min) msg += "\n" + strings.scheduled(next_run, min)
if not msg: if not msg:
@ -110,7 +110,8 @@ class MatcherCog(commands.Cog):
interaction: discord.Interaction, interaction: discord.Interaction,
members_min: int | None = None, members_min: int | None = None,
weekday: int | None = None, weekday: int | None = None,
hour: int | None = None): hour: int | None = None,
cadence: int | None = None):
"""Schedule a match using the input parameters""" """Schedule a match using the input parameters"""
# Set all the defaults # Set all the defaults
@ -120,6 +121,8 @@ class MatcherCog(commands.Cog):
weekday = 0 weekday = 0
if hour is None: if hour is None:
hour = 9 hour = 9
if cadence is None or cadence == 0:
cadence = 1
channel_id = str(interaction.channel.id) channel_id = str(interaction.channel.id)
# Bail if not a matcher # Bail if not a matcher
@ -129,19 +132,19 @@ class MatcherCog(commands.Cog):
return return
# Add the scheduled task and save # Add the scheduled task and save
state.State.set_channel_match_task( (_, _, _, _, cadence_start) = state.State.set_channel_match_task(
channel_id, members_min, weekday, hour) channel_id, members_min, weekday, hour, cadence)
# Let the user know what happened # Let the user know what happened
logger.info("Scheduled new match task in %s with min %s weekday %s hour %s", logger.info("Scheduled new match task in %s with min %s weekday %s hour %s and cadence %s",
channel_id, members_min, weekday, hour) channel_id, members_min, weekday, hour, cadence)
next_run = util.get_next_datetime(weekday, hour) next_run = util.get_next_datetime_with_cadence(weekday, hour, datetime.now(), cadence, cadence_start)
view = discord.ui.View(timeout=None) view = discord.ui.View(timeout=None)
view.add_item(ScheduleButton()) view.add_item(ScheduleButton())
await interaction.response.send_message( await interaction.response.send_message(
strings.scheduled_success(next_run), strings.scheduled_success(next_run, cadence),
ephemeral=True, silent=True, view=view) ephemeral=True, silent=True, view=view)
@app_commands.command(description="Cancel all scheduled matches in this channel") @app_commands.command(description="Cancel all scheduled matches in this channel")
@ -304,8 +307,8 @@ class ScheduleButton(discord.ui.Button):
msg += strings.scheduled_matches() msg += strings.scheduled_matches()
if tasks: if tasks:
for (day, hour, min) in tasks: for (day, hour, min, cadence, cadence_start) in tasks:
next_run = util.get_next_datetime(day, hour) next_run = util.get_next_datetime(day, hour, datetime.now(), cadence, cadence_start)
msg += strings.scheduled(next_run, min) msg += strings.scheduled(next_run, min)
await interaction.channel.send(msg) await interaction.channel.send(msg)

View file

@ -85,11 +85,11 @@ def need_matcher_scope(): return [
@randomised @randomised
def scheduled_success(d): return [ def scheduled_success(d, cadence): return [
f"Done :) Next run will be at {datetime_as_discord_time(d)}", f"Done :) Next run will be at {datetime_as_discord_time(d)} and every {cadence} week(s) after",
f"Woohoo! Scheduled for {datetime_as_discord_time(d)}", f"Woohoo! Scheduled for {datetime_as_discord_time(d)} plus each {cadence} week(s) after",
f"Yessir, will do a matcho at {datetime_as_discord_time(d)}", f"Yessir, will do a matcho every {cadence} week(s), first one is {datetime_as_discord_time(d)}",
f"Arf Arf! Bork bork bark {datetime_as_discord_time(d)}", f"Arf Arf! Bork bork bark {datetime_as_discord_time(d)} berk {cadence} week(s) arf",
] ]

View file

@ -16,7 +16,7 @@ logger = logging.getLogger("state")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
# Warning: Changing any of the below needs proper thought to ensure backwards compatibility # Warning: Changing any of the below needs proper thought to ensure backwards compatibility
_VERSION = 4 _VERSION = 5
def _migrate_to_v1(d: dict): def _migrate_to_v1(d: dict):
@ -64,12 +64,24 @@ def _migrate_to_v4(d: dict):
del d[_Key._HISTORY] del d[_Key._HISTORY]
def _migrate_to_v5(d: dict):
"""v5 added weekly cadence"""
tasks = d.get(_Key.TASKS, {})
for tasks in tasks.values():
match_tasks = tasks.get(_Key.MATCH_TASKS, [])
for match in match_tasks:
# All previous matches were every week starting from now
match[_Key.CADENCE] = 1
match[_Key.CADENCE_START] = datetime_to_ts(datetime.now())
# Set of migration functions to apply # Set of migration functions to apply
_MIGRATIONS = [ _MIGRATIONS = [
_migrate_to_v1, _migrate_to_v1,
_migrate_to_v2, _migrate_to_v2,
_migrate_to_v3, _migrate_to_v3,
_migrate_to_v4, _migrate_to_v4,
_migrate_to_v5
] ]
@ -94,6 +106,8 @@ class _Key(str):
MEMBERS_MIN = "members_min" MEMBERS_MIN = "members_min"
WEEKDAY = "weekdays" WEEKDAY = "weekdays"
HOUR = "hours" HOUR = "hours"
CADENCE = "cadence"
CADENCE_START = "cadence_start"
# Unused # Unused
_MATCHEES = "matchees" _MATCHEES = "matchees"
@ -139,6 +153,8 @@ _SCHEMA = Schema(
_Key.MEMBERS_MIN: Use(int), _Key.MEMBERS_MIN: Use(int),
_Key.WEEKDAY: Use(int), _Key.WEEKDAY: Use(int),
_Key.HOUR: Use(int), _Key.HOUR: Use(int),
_Key.CADENCE: Use(int),
_Key.CADENCE_START: Use(str),
} }
] ]
} }
@ -216,11 +232,12 @@ class _State():
@wraps(func) @wraps(func)
def inner(self, *args, **kwargs): def inner(self, *args, **kwargs):
tmp = _State(self._dict, self._file) tmp = _State(self._dict, self._file)
func(tmp, *args, **kwargs) ret = func(tmp, *args, **kwargs)
_SCHEMA.validate(tmp._dict) _SCHEMA.validate(tmp._dict)
if tmp._file: if tmp._file:
_save(tmp._file, tmp._dict) _save(tmp._file, tmp._dict)
self._dict = tmp._dict self._dict = tmp._dict
return ret
return inner return inner
@ -324,7 +341,10 @@ class _State():
for channel, tasks in self._tasks.items(): for channel, tasks in self._tasks.items():
for match in tasks.get(_Key.MATCH_TASKS, []): for match in tasks.get(_Key.MATCH_TASKS, []):
if match[_Key.WEEKDAY] == weekday and match[_Key.HOUR] == hour: # Take into account the weekly cadence
start = ts_to_datetime(match[_Key.CADENCE_START])
weeks = int((time - start).days / 7)
if match[_Key.WEEKDAY] == weekday and match[_Key.HOUR] == hour and weeks % match[_Key.CADENCE] == 0:
yield (channel, match[_Key.MEMBERS_MIN]) yield (channel, match[_Key.MEMBERS_MIN])
def get_channel_match_tasks(self, channel_id: str) -> Generator[int, int, int]: def get_channel_match_tasks(self, channel_id: str) -> Generator[int, int, int]:
@ -338,30 +358,35 @@ class _State():
) )
for tasks in all_tasks: for tasks in all_tasks:
for task in tasks: for task in tasks:
yield (task[_Key.WEEKDAY], task[_Key.HOUR], task[_Key.MEMBERS_MIN]) yield _task_to_tuple(task)
@safe_write @safe_write
def set_channel_match_task(self, channel_id: str, members_min: int, weekday: int, hour: int): def set_channel_match_task(self, channel_id: str, members_min: int, weekday: int, hour: int, cadence: int):
"""Set up a match task on a channel""" """Set up a match task on a channel"""
channel = self._tasks.setdefault(str(channel_id), {}) channel = self._tasks.setdefault(str(channel_id), {})
matches = channel.setdefault(_Key.MATCH_TASKS, []) matches = channel.setdefault(_Key.MATCH_TASKS, [])
found = False for match_task in matches:
for match in matches:
# Specifically check for the combination of weekday and hour # Specifically check for the combination of weekday and hour
if match[_Key.WEEKDAY] == weekday and match[_Key.HOUR] == hour: if match_task[_Key.WEEKDAY] == weekday and match_task[_Key.HOUR] == hour:
found = True match_task[_Key.MEMBERS_MIN] = members_min
match[_Key.MEMBERS_MIN] = members_min # If the cadence has changed, update it and reset the start
# Return true as we've successfully changed the data in place if cadence != match_task[_Key.CADENCE]:
return True match_task[_Key.CADENCE] = cadence
match_task[_Key.CADENCE_START] = datetime_to_ts(datetime.now())
# Return as we've successfully changed the data in place
return _task_to_tuple(match_task)
# If we didn't find it, add it to the schedule # If we didn't find it, add it to the schedule
if not found: match_task = {
matches.append({ _Key.MEMBERS_MIN: members_min,
_Key.MEMBERS_MIN: members_min, _Key.WEEKDAY: weekday,
_Key.WEEKDAY: weekday, _Key.HOUR: hour,
_Key.HOUR: hour, _Key.CADENCE: cadence,
}) _Key.CADENCE_START: datetime_to_ts(datetime.now())
}
matches.append(match_task)
return _task_to_tuple(match_task)
@safe_write @safe_write
def remove_channel_match_tasks(self, channel_id: str): def remove_channel_match_tasks(self, channel_id: str):
@ -379,6 +404,14 @@ class _State():
return self._dict[_Key.TASKS] return self._dict[_Key.TASKS]
def _task_to_tuple(task):
return (task[_Key.WEEKDAY],
task[_Key.HOUR],
task[_Key.MEMBERS_MIN],
task[_Key.CADENCE],
ts_to_datetime(task[_Key.CADENCE_START]))
def load_from_file(file: str) -> _State: def load_from_file(file: str) -> _State:
""" """
Load the state from a files Load the state from a files

View file

@ -18,20 +18,32 @@ def format_list(list: list) -> str:
return list[0] if list else '' return list[0] if list else ''
def get_next_datetime(weekday, hour) -> datetime: def get_next_datetime(weekday: int, hour: int, start: datetime) -> datetime:
"""Get the next datetime for the given weekday and hour""" """Get the next datetime for the given weekday and hour"""
now = datetime.now()
days_until_next_week = (weekday - now.weekday() + 7) % 7 days_until_next_week_run = (weekday - start.weekday() + 7) % 7
# Account for when we're already beyond the time now # Account for when we're already beyond the time now
if days_until_next_week == 0 and now.hour >= hour: if days_until_next_week_run == 0 and start.hour >= hour:
days_until_next_week = 7 days_until_next_week_run = 7
# Calculate the next datetime # Calculate the next datetime
next_date = now + timedelta(days=days_until_next_week) next_date = start + timedelta(days=days_until_next_week_run)
next_date = next_date.replace(hour=hour, minute=0, second=0, microsecond=0)
return next_date return next_date.replace(hour=hour, minute=0, second=0, microsecond=0)
def get_next_datetime_with_cadence(weekday: int, hour: int, start: datetime, cadence: int, cadence_start: datetime):
"""Get the next datetime for given weekday, hour and cadence values"""
# Get the first run based on the cadence
next_time = get_next_datetime(weekday, hour, cadence_start)
# Walk forwards until we get the actual time
while next_time < start:
next_time += timedelta(weeks=cadence)
return next_time
def datetime_as_discord_time(time: datetime) -> str: def datetime_as_discord_time(time: datetime) -> str:

View file

@ -1,4 +1,6 @@
import matchy.util as util import matchy.util as util
from datetime import datetime
import pytest
def test_iterate_all_shifts(): def test_iterate_all_shifts():
@ -50,3 +52,51 @@ def test_randomized():
assert util.randomised(string)() == "foo" assert util.randomised(string)() == "foo"
assert util.randomised(list)() in list() assert util.randomised(list)() in list()
@pytest.mark.parametrize(
"weekday, hour, start, expected",
[
pytest.param(
0, 0, datetime(2024, 9, 22),
datetime(2024, 9, 23), id="tomorrow"
),
pytest.param(
4, 16, datetime(2024, 9, 22),
datetime(2024, 9, 27, 16), id="complicated"
),
],
)
def test_get_next_datetime(weekday, hour, start, expected):
value = util.get_next_datetime(weekday, hour, start)
assert value == expected
@pytest.mark.parametrize(
"weekday, hour, start, cadence, cadence_start, expected",
[
pytest.param(
0, 0, datetime(2024, 9, 22),
1, datetime(2024, 9, 22),
datetime(2024, 9, 23), id="tomorrow"
),
pytest.param(
0, 0, datetime(2024, 9, 22),
2, datetime(2024, 9, 22),
datetime(2024, 9, 23), id="every-other"
),
pytest.param(
0, 0, datetime(2024, 9, 22),
2, datetime(2024, 9, 14),
datetime(2024, 9, 30), id="every-other-before"
),
pytest.param(
0, 0, datetime(2024, 9, 22),
3, datetime(2024, 9, 14),
datetime(2024, 10, 7), id="every-third"
),
],
)
def test_get_next_datetime_with_cadence(weekday, hour, start, expected, cadence, cadence_start):
value = util.get_next_datetime_with_cadence(weekday, hour, start, cadence, cadence_start)
assert value == expected