mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 10:13:11 +00:00
Lock shift drops within 24h of start (#84)
A user can no longer `/oncall drop` a shift inside the 24h window before it starts — inside that window coverage must be handed off via a verified swap (target accepts) or opened by an admin. - app.py: _within_drop_lock(date, shift_type) (24h before _shift_start); guard in _handle_drop after the ownership check. Admin `open` is a separate handler and is unaffected (bypasses the lock). - Removed the now-unreachable 3CX-repoint-on-drop branch: a same-day shift is always inside the lock, so a drop never reaches mark_open for today. - Help text + README note the 24h rule. - tests: rewritten test_handle_drop (outside/inside-24h per shift type, weekend day, admin bypass, plus the existing guard-precedence cases) and direct _shift_start/_shift_started/_within_drop_lock helper tests. 185 passed. Closes #84
This commit is contained in:
parent
060bd0bf3e
commit
ffed545e45
5 changed files with 146 additions and 44 deletions
|
|
@ -15,7 +15,7 @@ The weekly schedule post is updated live when shifts change, and the previous we
|
|||
| `/oncall` | Show this week's schedule |
|
||||
| `/oncall next` | Show next week's schedule |
|
||||
| `/oncall pick <date>` | Pick up an available shift |
|
||||
| `/oncall drop <date>` | Drop your shift (marks it available) |
|
||||
| `/oncall drop <date>` | Drop your shift (marks it available) — blocked within 24h of shift start; swap or ask an admin instead |
|
||||
| `/oncall swap <date> @person` | Request a swap — the other person gets an Accept/Decline DM and the shift only moves once they accept |
|
||||
| `/oncall register <ext>` | Link your Slack account to your phone extension |
|
||||
| `/oncall roster` | Show all employees and their link status |
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ def build_help_blocks(is_admin: bool = False) -> list[dict]:
|
|||
"`/oncall` — Show the two-week schedule\n"
|
||||
"`/oncall next` — Show the following two weeks\n"
|
||||
"`/oncall pick <date>` — Pick up a shift\n"
|
||||
"`/oncall drop <date>` — Drop your shift (marks it open)\n"
|
||||
"`/oncall drop <date>` — Drop your shift (marks it open; locked within 24h of start — swap instead)\n"
|
||||
"`/oncall swap <date> @person` — Swap your shift with someone\n"
|
||||
"`/oncall register <extension>` — Link your Slack account to your extension\n"
|
||||
"`/oncall pay` — Show last week's pay summary\n"
|
||||
|
|
|
|||
|
|
@ -115,6 +115,17 @@ def _shift_started(date_str: str, shift_type: str) -> bool:
|
|||
return datetime.now(EASTERN) >= _shift_start(date_str, shift_type)
|
||||
|
||||
|
||||
def _within_drop_lock(date_str: str, shift_type: str) -> bool:
|
||||
"""True inside the 24h-before-start window where dropping a shift is locked.
|
||||
|
||||
Within this window a shift can't be abandoned via drop — it must be handed
|
||||
off through a verified swap (target accepts) or opened by an admin.
|
||||
"""
|
||||
return datetime.now(EASTERN) >= _shift_start(date_str, shift_type) - timedelta(
|
||||
hours=24
|
||||
)
|
||||
|
||||
|
||||
def _shift_type_label(day_name: str, shift_type: str) -> str:
|
||||
if day_name not in WEEKEND_DAYS:
|
||||
return ""
|
||||
|
|
@ -511,10 +522,20 @@ def _handle_drop(
|
|||
return
|
||||
|
||||
ext, name, source, shift_type = found
|
||||
schedule.mark_open(date_str, shift_type)
|
||||
|
||||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||||
_update_3cx_routing(FALLBACK_EXTENSION)
|
||||
if _within_drop_lock(date_str, shift_type):
|
||||
respond(
|
||||
text=(
|
||||
"This shift starts in under 24 hours — you can't drop it now. "
|
||||
"Hand it off with `/oncall swap <date> @person` (they'll need to accept), "
|
||||
"or ask an admin to open it."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# No 3CX repoint here: a same-day shift is always inside the 24h lock above,
|
||||
# so a drop that reaches this point is never today's active shift.
|
||||
schedule.mark_open(date_str, shift_type)
|
||||
|
||||
date_label = date.strftime("%A, %b %-d")
|
||||
shift_label = _shift_type_label(day_name, shift_type)
|
||||
|
|
|
|||
|
|
@ -1,72 +1,124 @@
|
|||
"""Tests for slack-bot _handle_drop."""
|
||||
"""Tests for slack-bot _handle_drop, including the 24h drop lock (#84)."""
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
from shared.schedule import FALLBACK_EXTENSION
|
||||
|
||||
# Monday 2026-06-01 08:00 ET — weekday, active shift is night.
|
||||
MON = "2026-06-01 12:00:00"
|
||||
# Monday 2026-06-01 08:00 ET.
|
||||
MON_0800 = "2026-06-01 12:00:00"
|
||||
# Monday 2026-06-01 18:00 ET — past 17:00 (so tomorrow's night shift is <24h away).
|
||||
MON_1800 = "2026-06-01 22:00:00"
|
||||
# Friday 2026-06-05.
|
||||
FRI_0600 = "2026-06-05 10:00:00" # 06:00 ET
|
||||
FRI_0900 = "2026-06-05 13:00:00" # 09:00 ET
|
||||
|
||||
|
||||
def _register_on_monday(seed):
|
||||
"""Alice (114) is registered and assigned the Monday night shift."""
|
||||
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||
seed.weekly("Monday", "114", "Alice")
|
||||
|
||||
|
||||
@freeze_time(MON)
|
||||
def test_drop_today_marks_open_and_repoints_3cx(
|
||||
@freeze_time(MON_0800)
|
||||
def test_drop_outside_24h_marks_open(
|
||||
slackbot_app, schedule, seed, respond, client, routing_spy, text_of
|
||||
):
|
||||
_register_on_monday(seed)
|
||||
slackbot_app._handle_drop(
|
||||
respond, schedule, "U_ALICE", "drop today", "C1", client, None
|
||||
)
|
||||
|
||||
assert schedule.get_override("2026-06-01")["extension"] == "OPEN"
|
||||
assert "dropped" in text_of(respond)
|
||||
# Today + active night shift → queue falls back.
|
||||
routing_spy.assert_called_once_with(FALLBACK_EXTENSION)
|
||||
client.chat_postMessage.assert_called_once()
|
||||
|
||||
|
||||
@freeze_time(MON)
|
||||
def test_drop_future_does_not_repoint_3cx(
|
||||
slackbot_app, schedule, seed, respond, client, routing_spy
|
||||
):
|
||||
# Tomorrow's night shift starts 06-02 17:00; now is 06-01 08:00 → ~33h away.
|
||||
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||
seed.weekly("Tuesday", "114", "Alice")
|
||||
slackbot_app._handle_drop(
|
||||
respond, schedule, "U_ALICE", "drop tomorrow", "C1", client, None
|
||||
)
|
||||
|
||||
assert schedule.get_override("2026-06-02")["extension"] == "OPEN"
|
||||
assert "dropped" in text_of(respond)
|
||||
client.chat_postMessage.assert_called_once()
|
||||
routing_spy.assert_not_called()
|
||||
|
||||
|
||||
@freeze_time(MON)
|
||||
def test_drop_not_your_shift(
|
||||
@freeze_time(MON_0800)
|
||||
def test_drop_today_blocked(
|
||||
slackbot_app, schedule, seed, respond, client, routing_spy, text_of
|
||||
):
|
||||
# A same-day shift is always inside the 24h lock.
|
||||
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||
seed.weekly("Monday", "114", "Alice")
|
||||
slackbot_app._handle_drop(
|
||||
respond, schedule, "U_ALICE", "drop today", "C1", client, None
|
||||
)
|
||||
assert "under 24 hours" in text_of(respond)
|
||||
assert schedule.get_override("2026-06-01") is None # not opened
|
||||
routing_spy.assert_not_called()
|
||||
client.chat_postMessage.assert_not_called()
|
||||
|
||||
|
||||
@freeze_time(MON_1800)
|
||||
def test_drop_blocked_once_within_24h_of_start(
|
||||
slackbot_app, schedule, seed, respond, client, text_of
|
||||
):
|
||||
# At 06-01 18:00, tomorrow's 06-02 17:00 shift is <24h away.
|
||||
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||
seed.weekly("Tuesday", "114", "Alice")
|
||||
slackbot_app._handle_drop(
|
||||
respond, schedule, "U_ALICE", "drop tomorrow", "C1", client, None
|
||||
)
|
||||
assert "under 24 hours" in text_of(respond)
|
||||
assert schedule.get_override("2026-06-02") is None
|
||||
|
||||
|
||||
@freeze_time(FRI_0600)
|
||||
def test_drop_weekend_day_shift_outside_24h(
|
||||
slackbot_app, schedule, seed, respond, client, text_of
|
||||
):
|
||||
# Saturday day shift starts 06-06 08:00; now Fri 06:00 → >24h.
|
||||
seed.roster("200", "Alice", slack_user_id="U_ALICE")
|
||||
seed.weekly("Saturday", "200", "Alice", shift_type="day")
|
||||
slackbot_app._handle_drop(
|
||||
respond, schedule, "U_ALICE", "drop saturday", "C1", client, None
|
||||
)
|
||||
assert "dropped" in text_of(respond)
|
||||
assert schedule.get_override("2026-06-06", "day")["extension"] == "OPEN"
|
||||
|
||||
|
||||
@freeze_time(FRI_0900)
|
||||
def test_drop_weekend_day_shift_within_24h(
|
||||
slackbot_app, schedule, seed, respond, client, text_of
|
||||
):
|
||||
# Now Fri 09:00 → Saturday 08:00 day shift is <24h away.
|
||||
seed.roster("200", "Alice", slack_user_id="U_ALICE")
|
||||
seed.weekly("Saturday", "200", "Alice", shift_type="day")
|
||||
slackbot_app._handle_drop(
|
||||
respond, schedule, "U_ALICE", "drop saturday", "C1", client, None
|
||||
)
|
||||
assert "under 24 hours" in text_of(respond)
|
||||
assert schedule.get_override("2026-06-06", "day") is None
|
||||
|
||||
|
||||
@freeze_time(MON_0800)
|
||||
def test_admin_open_bypasses_24h_lock(
|
||||
slackbot_app, schedule, seed, respond, client, routing_spy, text_of
|
||||
):
|
||||
# Admins can still open a same-day shift — the lock only applies to user drop.
|
||||
seed.weekly("Monday", "114", "Alice")
|
||||
slackbot_app._handle_admin(
|
||||
respond, schedule, "U_ADMIN", "admin open today", True, client, None
|
||||
)
|
||||
assert schedule.get_override("2026-06-01")["extension"] == "OPEN"
|
||||
assert "marked as open" in text_of(respond).lower()
|
||||
|
||||
|
||||
@freeze_time(MON_0800)
|
||||
def test_drop_not_your_shift(slackbot_app, schedule, seed, respond, client, text_of):
|
||||
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||
seed.weekly("Monday", "115", "Bob") # Bob is on shift, not Alice
|
||||
slackbot_app._handle_drop(
|
||||
respond, schedule, "U_ALICE", "drop today", "C1", client, None
|
||||
)
|
||||
assert "not your shift" in text_of(respond).lower()
|
||||
routing_spy.assert_not_called()
|
||||
|
||||
|
||||
@freeze_time(MON)
|
||||
@freeze_time(MON_0800)
|
||||
def test_drop_past_date(slackbot_app, schedule, seed, respond, client, text_of):
|
||||
_register_on_monday(seed)
|
||||
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||
seed.weekly("Monday", "114", "Alice")
|
||||
slackbot_app._handle_drop(
|
||||
respond, schedule, "U_ALICE", "drop 2026-05-01", "C1", client, None
|
||||
)
|
||||
assert "past" in text_of(respond).lower()
|
||||
|
||||
|
||||
@freeze_time(MON)
|
||||
@freeze_time(MON_0800)
|
||||
def test_drop_unregistered(slackbot_app, schedule, respond, client, text_of):
|
||||
slackbot_app._handle_drop(
|
||||
respond, schedule, "U_NOBODY", "drop today", "C1", client, None
|
||||
|
|
@ -74,16 +126,17 @@ def test_drop_unregistered(slackbot_app, schedule, respond, client, text_of):
|
|||
assert "not registered" in text_of(respond).lower()
|
||||
|
||||
|
||||
@freeze_time(MON)
|
||||
@freeze_time(MON_0800)
|
||||
def test_drop_bad_date(slackbot_app, schedule, seed, respond, client, text_of):
|
||||
_register_on_monday(seed)
|
||||
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||
seed.weekly("Monday", "114", "Alice")
|
||||
slackbot_app._handle_drop(
|
||||
respond, schedule, "U_ALICE", "drop notaday", "C1", client, None
|
||||
)
|
||||
assert "couldn't parse" in text_of(respond).lower()
|
||||
|
||||
|
||||
@freeze_time(MON)
|
||||
@freeze_time(MON_0800)
|
||||
def test_drop_usage(slackbot_app, schedule, respond, client, text_of):
|
||||
slackbot_app._handle_drop(respond, schedule, "U_ALICE", "drop", "C1", client, None)
|
||||
assert "Usage" in text_of(respond)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,34 @@ MON = "2026-06-01 12:00:00"
|
|||
SAT_DATE = "2026-06-06"
|
||||
|
||||
|
||||
class TestShiftTiming:
|
||||
def test_shift_start_weekday_night_is_5pm(self, slackbot_app):
|
||||
assert slackbot_app._shift_start("2026-06-03", "night").hour == 17
|
||||
|
||||
def test_shift_start_weekend_day_is_8am(self, slackbot_app):
|
||||
assert slackbot_app._shift_start("2026-06-06", "day").hour == 8
|
||||
|
||||
@freeze_time("2026-06-01 22:00:00") # 18:00 ET
|
||||
def test_shift_started_true_after_start(self, slackbot_app):
|
||||
assert slackbot_app._shift_started("2026-06-01", "night") is True
|
||||
|
||||
@freeze_time(MON) # 08:00 ET
|
||||
def test_shift_started_false_before_start(self, slackbot_app):
|
||||
assert slackbot_app._shift_started("2026-06-01", "night") is False
|
||||
|
||||
@freeze_time(MON)
|
||||
def test_within_drop_lock_for_today(self, slackbot_app):
|
||||
assert slackbot_app._within_drop_lock("2026-06-01", "night") is True
|
||||
|
||||
@freeze_time(MON) # tomorrow 17:00 is ~33h away
|
||||
def test_not_within_lock_tomorrow_morning(self, slackbot_app):
|
||||
assert slackbot_app._within_drop_lock("2026-06-02", "night") is False
|
||||
|
||||
@freeze_time("2026-06-01 22:00:00") # 18:00 ET, tomorrow 17:00 now <24h
|
||||
def test_within_lock_once_under_24h(self, slackbot_app):
|
||||
assert slackbot_app._within_drop_lock("2026-06-02", "night") is True
|
||||
|
||||
|
||||
class TestIsToday:
|
||||
@freeze_time(MON)
|
||||
def test_true_for_today(self, slackbot_app):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue