mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-04 18:22:04 +00:00
fix(portal-api): preserve shift and deployment invariants (DEV-287)
Co-authored-by: adam <adam@seahavenind.com>
This commit is contained in:
parent
1ab787308e
commit
7fdad3a6d5
4 changed files with 179 additions and 6 deletions
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from shared.schedule import WEEKEND_DAYS, ShiftSchedule
|
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
|
||||||
from shared.shift_clock import (
|
from shared.shift_clock import (
|
||||||
EASTERN,
|
EASTERN,
|
||||||
holiday_window_active,
|
holiday_window_active,
|
||||||
|
|
@ -113,7 +114,7 @@ def snapshot(schedule: ShiftSchedule, employee: dict, week: str = "this") -> dic
|
||||||
date_str = day.strftime(DATE_FMT)
|
date_str = day.strftime(DATE_FMT)
|
||||||
day_name = day.strftime("%A")
|
day_name = day.strftime("%A")
|
||||||
slots = []
|
slots = []
|
||||||
if day_name in WEEKEND_DAYS:
|
if day_name in WEEKEND_DAYS or schedule.get_holiday(date_str) is not None:
|
||||||
slots.append(_slot_payload(schedule, date_str, day_name, "day", my_ext))
|
slots.append(_slot_payload(schedule, date_str, day_name, "day", my_ext))
|
||||||
slots.append(_slot_payload(schedule, date_str, day_name, "night", my_ext))
|
slots.append(_slot_payload(schedule, date_str, day_name, "night", my_ext))
|
||||||
days.append(
|
days.append(
|
||||||
|
|
@ -317,6 +318,8 @@ def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str
|
||||||
target = target
|
target = target
|
||||||
else:
|
else:
|
||||||
raise ActionError(400, "INVALID_SHIFT", "Shift type must be day, night, or holiday.")
|
raise ActionError(400, "INVALID_SHIFT", "Shift type must be day, night, or holiday.")
|
||||||
|
if target == "day" and "holiday" in held:
|
||||||
|
target = "holiday"
|
||||||
if target not in held:
|
if target not in held:
|
||||||
raise ActionError(409, "NOT_YOURS", "You don't hold that shift.")
|
raise ActionError(409, "NOT_YOURS", "You don't hold that shift.")
|
||||||
elif not held:
|
elif not held:
|
||||||
|
|
@ -483,6 +486,16 @@ def respond_swap(
|
||||||
if holiday_window_active(date_str):
|
if holiday_window_active(date_str):
|
||||||
effects.set_holiday_queue_agents(schedule, date_str)
|
effects.set_holiday_queue_agents(schedule, date_str)
|
||||||
else:
|
else:
|
||||||
|
current_ext, _name, _source = schedule.resolve_shift(
|
||||||
|
date_str, date.strftime("%A"), shift_type
|
||||||
|
)
|
||||||
|
if current_ext != swap_row["requester_ext"]:
|
||||||
|
schedule.clear_swap(date_str, shift_type)
|
||||||
|
raise ActionError(
|
||||||
|
409,
|
||||||
|
"CONFLICT",
|
||||||
|
"The shift is no longer assigned to the person who requested the swap.",
|
||||||
|
)
|
||||||
moved = schedule.reassign_if_held_by(
|
moved = schedule.reassign_if_held_by(
|
||||||
date_str,
|
date_str,
|
||||||
swap_row["requester_ext"],
|
swap_row["requester_ext"],
|
||||||
|
|
@ -538,8 +551,13 @@ def admin_open(schedule, employee, date_str, shift_type) -> dict:
|
||||||
date_str = date.strftime(DATE_FMT)
|
date_str = date.strftime(DATE_FMT)
|
||||||
shift_type = _shift_type(shift_type)
|
shift_type = _shift_type(shift_type)
|
||||||
schedule.mark_open(date_str, shift_type)
|
schedule.mark_open(date_str, shift_type)
|
||||||
|
repointed = effects.maybe_repoint_today(date_str, shift_type, FALLBACK_EXTENSION)
|
||||||
effects.refresh_schedule_post(schedule, effects.slack_token())
|
effects.refresh_schedule_post(schedule, effects.slack_token())
|
||||||
return {"ok": True, "message": "Shift marked open."}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"repointed": repointed,
|
||||||
|
"message": "Shift marked open.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def admin_clear(schedule, employee, date_str, shift_type) -> dict:
|
def admin_clear(schedule, employee, date_str, shift_type) -> dict:
|
||||||
|
|
@ -548,8 +566,16 @@ def admin_clear(schedule, employee, date_str, shift_type) -> dict:
|
||||||
date_str = date.strftime(DATE_FMT)
|
date_str = date.strftime(DATE_FMT)
|
||||||
shift_type = _shift_type(shift_type)
|
shift_type = _shift_type(shift_type)
|
||||||
schedule.remove_override(date_str, shift_type)
|
schedule.remove_override(date_str, shift_type)
|
||||||
|
resolved_ext, _name, _source = schedule.resolve_shift(
|
||||||
|
date_str, date.strftime("%A"), shift_type
|
||||||
|
)
|
||||||
|
repointed = effects.maybe_repoint_today(date_str, shift_type, resolved_ext)
|
||||||
effects.refresh_schedule_post(schedule, effects.slack_token())
|
effects.refresh_schedule_post(schedule, effects.slack_token())
|
||||||
return {"ok": True, "message": "Override cleared."}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"repointed": repointed,
|
||||||
|
"message": "Override cleared.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) -> dict:
|
def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) -> dict:
|
||||||
|
|
@ -567,6 +593,16 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
|
||||||
name = (label or "").strip()
|
name = (label or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise ActionError(400, "INVALID_LABEL", "A holiday label is required.")
|
raise ActionError(400, "INVALID_LABEL", "A holiday label is required.")
|
||||||
|
multiplier_value = None
|
||||||
|
if multiplier is not None and str(multiplier).strip():
|
||||||
|
match = re.fullmatch(r"x?([0-9]+(?:\.[0-9]+)?)", str(multiplier).strip(), re.I)
|
||||||
|
if not match:
|
||||||
|
raise ActionError(
|
||||||
|
400,
|
||||||
|
"INVALID_MULTIPLIER",
|
||||||
|
"Multiplier must be a number like 2 or 1.5.",
|
||||||
|
)
|
||||||
|
multiplier_value = Decimal(match.group(1))
|
||||||
token = effects.slack_token()
|
token = effects.slack_token()
|
||||||
names = effects.create_holiday_schedules(date_str)
|
names = effects.create_holiday_schedules(date_str)
|
||||||
created = schedule.create_holiday(
|
created = schedule.create_holiday(
|
||||||
|
|
@ -574,7 +610,7 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
|
||||||
slots=slot_count,
|
slots=slot_count,
|
||||||
label=name,
|
label=name,
|
||||||
created_by=employee.get("slack_user_id") or employee.get("email") or "",
|
created_by=employee.get("slack_user_id") or employee.get("email") or "",
|
||||||
multiplier=multiplier,
|
multiplier=multiplier_value,
|
||||||
schedule_names=names,
|
schedule_names=names,
|
||||||
)
|
)
|
||||||
if not created:
|
if not created:
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,12 @@ variable "checkcomponents_queue_url" {
|
||||||
default = "https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents"
|
default = "https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "checkcomponents_queue_arn" {
|
||||||
|
description = "paychex-checkcomponents SQS ARN for WeeklyPost SendMessage."
|
||||||
|
type = string
|
||||||
|
default = "arn:aws:sqs:us-east-1:011934824531:paychex-checkcomponents"
|
||||||
|
}
|
||||||
|
|
||||||
variable "portal_cognito_issuer" {
|
variable "portal_cognito_issuer" {
|
||||||
description = "Trusted portal Cognito user-pool issuer for ID-token verification. Empty disables portal auth."
|
description = "Trusted portal Cognito user-pool issuer for ID-token verification. Empty disables portal auth."
|
||||||
type = string
|
type = string
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ HCP_IAM = (TERRAFORM / "hcp_iam.tf").read_text()
|
||||||
DEPLOY = (ROOT / ".github" / "workflows" / "deploy.yaml").read_text()
|
DEPLOY = (ROOT / ".github" / "workflows" / "deploy.yaml").read_text()
|
||||||
CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text()
|
CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text()
|
||||||
LOCALS = (TERRAFORM / "locals.tf").read_text()
|
LOCALS = (TERRAFORM / "locals.tf").read_text()
|
||||||
|
VARIABLES = (TERRAFORM / "variables.tf").read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_sam_template_removed():
|
def test_sam_template_removed():
|
||||||
|
|
@ -68,6 +69,13 @@ def test_ci_runs_pytest_and_terraform_validate():
|
||||||
assert "terraform validate" in CI
|
assert "terraform validate" in CI
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkcomponents_queue_arn_variable_matches_iam_references():
|
||||||
|
assert 'variable "checkcomponents_queue_arn"' in VARIABLES
|
||||||
|
assert "var.checkcomponents_queue_arn" in LAMBDA_TF
|
||||||
|
boundary = (TERRAFORM / "lambda_boundary.tf").read_text()
|
||||||
|
assert "var.checkcomponents_queue_arn" in boundary
|
||||||
|
|
||||||
|
|
||||||
def test_eight_functions_named():
|
def test_eight_functions_named():
|
||||||
for name in (
|
for name in (
|
||||||
"afterhours-shift-manager",
|
"afterhours-shift-manager",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,17 @@ from zoneinfo import ZoneInfo
|
||||||
import freezegun
|
import freezegun
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from shared.portal_ops import ActionError, drop, pick, snapshot, swap
|
from shared.portal_ops import (
|
||||||
|
ActionError,
|
||||||
|
admin_clear,
|
||||||
|
admin_holiday_add,
|
||||||
|
admin_open,
|
||||||
|
drop,
|
||||||
|
pick,
|
||||||
|
respond_swap,
|
||||||
|
snapshot,
|
||||||
|
swap,
|
||||||
|
)
|
||||||
from shared import side_effects as effects
|
from shared import side_effects as effects
|
||||||
|
|
||||||
ET = ZoneInfo("America/New_York")
|
ET = ZoneInfo("America/New_York")
|
||||||
|
|
@ -30,6 +40,12 @@ def _alice(schedule, seed):
|
||||||
return schedule.get_employee_by_extension("114")
|
return schedule.get_employee_by_extension("114")
|
||||||
|
|
||||||
|
|
||||||
|
def _admin(schedule, seed):
|
||||||
|
employee = _alice(schedule, seed)
|
||||||
|
seed.config(admin_users=["U_ALICE"])
|
||||||
|
return employee
|
||||||
|
|
||||||
|
|
||||||
def test_pick_open_shift(schedule, seed, quiet_slack):
|
def test_pick_open_shift(schedule, seed, quiet_slack):
|
||||||
employee = _alice(schedule, seed)
|
employee = _alice(schedule, seed)
|
||||||
seed.open_shift("2026-06-10")
|
seed.open_shift("2026-06-10")
|
||||||
|
|
@ -71,3 +87,110 @@ def test_late_pickup_creates_request(schedule, seed, quiet_slack):
|
||||||
assert result["latePickup"] is True
|
assert result["latePickup"] is True
|
||||||
req = schedule.get_pickup_request("2026-06-10", "night", "114")
|
req = schedule.get_pickup_request("2026-06-10", "night", "114")
|
||||||
assert req["status"] == "pending"
|
assert req["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_includes_weekday_holiday_day_shift(schedule, seed):
|
||||||
|
employee = _alice(schedule, seed)
|
||||||
|
seed.holiday(
|
||||||
|
"2026-06-10",
|
||||||
|
assignees={"114": {"name": "Alice", "claimed_at": "now"}},
|
||||||
|
)
|
||||||
|
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
|
||||||
|
snap = snapshot(schedule, employee)
|
||||||
|
wednesday = next(day for day in snap["days"] if day["date"] == "2026-06-10")
|
||||||
|
assert wednesday["slots"][0]["kind"] == "holiday"
|
||||||
|
assert wednesday["slots"][0]["shiftType"] == "day"
|
||||||
|
assert wednesday["slots"][0]["mine"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_accepts_day_for_holiday_slot(schedule, seed, quiet_slack):
|
||||||
|
employee = _alice(schedule, seed)
|
||||||
|
seed.holiday(
|
||||||
|
"2026-06-10",
|
||||||
|
assignees={"114": {"name": "Alice", "claimed_at": "now"}},
|
||||||
|
)
|
||||||
|
with freezegun.freeze_time("2026-06-08 06:00:00-04:00"):
|
||||||
|
result = drop(schedule, employee, "2026-06-10", "day")
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert schedule.get_holiday("2026-06-10")["assignees"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_swap_accept_rechecks_resolved_holder(schedule, seed, quiet_slack):
|
||||||
|
employee = _alice(schedule, seed)
|
||||||
|
seed.roster("116", "Charlie", slack_user_id="U_CHARLIE")
|
||||||
|
seed.weekly("Friday", "114", "Alice")
|
||||||
|
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
|
||||||
|
swap(schedule, employee, "2026-06-12", "115", "night")
|
||||||
|
seed.weekly("Friday", "116", "Charlie")
|
||||||
|
with pytest.raises(ActionError) as err:
|
||||||
|
respond_swap(
|
||||||
|
schedule,
|
||||||
|
schedule.get_employee_by_extension("115"),
|
||||||
|
"2026-06-12",
|
||||||
|
"night",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
assert err.value.code == "CONFLICT"
|
||||||
|
assert schedule.get_override("2026-06-12") is None
|
||||||
|
assert schedule.get_swap("2026-06-12", "night") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_open_repoints_active_shift_to_fallback(
|
||||||
|
schedule, seed, quiet_slack, monkeypatch
|
||||||
|
):
|
||||||
|
employee = _admin(schedule, seed)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
effects,
|
||||||
|
"maybe_repoint_today",
|
||||||
|
lambda date, shift_type, extension: calls.append(
|
||||||
|
(date, shift_type, extension)
|
||||||
|
)
|
||||||
|
or True,
|
||||||
|
)
|
||||||
|
with freezegun.freeze_time("2026-06-10 20:00:00-04:00"):
|
||||||
|
result = admin_open(schedule, employee, "2026-06-10", "night")
|
||||||
|
assert result["repointed"] is True
|
||||||
|
assert calls == [("2026-06-10", "night", "100")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_clear_repoints_active_shift_to_resolved_holder(
|
||||||
|
schedule, seed, quiet_slack, monkeypatch
|
||||||
|
):
|
||||||
|
employee = _admin(schedule, seed)
|
||||||
|
seed.weekly("Wednesday", "115", "Bob")
|
||||||
|
seed.override("2026-06-10", "114", "Alice")
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
effects,
|
||||||
|
"maybe_repoint_today",
|
||||||
|
lambda date, shift_type, extension: calls.append(
|
||||||
|
(date, shift_type, extension)
|
||||||
|
)
|
||||||
|
or True,
|
||||||
|
)
|
||||||
|
with freezegun.freeze_time("2026-06-10 20:00:00-04:00"):
|
||||||
|
result = admin_clear(schedule, employee, "2026-06-10", "night")
|
||||||
|
assert result["repointed"] is True
|
||||||
|
assert calls == [("2026-06-10", "night", "115")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_holiday_multiplier_rejected_before_schedules(
|
||||||
|
schedule, seed, quiet_slack, monkeypatch
|
||||||
|
):
|
||||||
|
employee = _admin(schedule, seed)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
effects, "create_holiday_schedules", lambda _date: pytest.fail("called")
|
||||||
|
)
|
||||||
|
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
|
||||||
|
with pytest.raises(ActionError) as err:
|
||||||
|
admin_holiday_add(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
"2026-12-25",
|
||||||
|
2,
|
||||||
|
"Christmas",
|
||||||
|
"not-a-number",
|
||||||
|
)
|
||||||
|
assert err.value.code == "INVALID_MULTIPLIER"
|
||||||
|
assert schedule.get_holiday("2026-12-25") is None
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue