mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 09:03:11 +00:00
Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54ad5a7e34 | ||
|
|
3030b134fa | ||
|
|
9ca1ef48bd | ||
|
|
e9893a6f7b | ||
|
|
edfa34bfbf | ||
|
|
470e00affb | ||
|
|
7b5009fb55 | ||
|
|
b3fa705d8c |
45 changed files with 696 additions and 141 deletions
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
|
|
@ -13,7 +13,7 @@ permissions:
|
||||||
jobs:
|
jobs:
|
||||||
autofix:
|
autofix:
|
||||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
lint:
|
lint:
|
||||||
needs: autofix
|
needs: autofix
|
||||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ jobs:
|
||||||
terraform:
|
terraform:
|
||||||
needs: autofix
|
needs: autofix
|
||||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
with:
|
with:
|
||||||
terraform-version: "1.16.0"
|
terraform-version: "1.16.0"
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -7,4 +7,4 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
review:
|
review:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
|
|
|
||||||
4
.github/workflows/deploy-api.yaml
vendored
4
.github/workflows/deploy-api.yaml
vendored
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
name: Deploy API to dev
|
name: Deploy API to dev
|
||||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
deploy-prod:
|
deploy-prod:
|
||||||
name: Deploy API to prod
|
name: Deploy API to prod
|
||||||
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
|
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
|
||||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
|
|
@ -10,4 +10,4 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
label:
|
label:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,6 @@ Use one of: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `rele
|
||||||
- **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty.
|
- **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty.
|
||||||
- State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers.
|
- State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers.
|
||||||
|
|
||||||
## Security and Cross-Review
|
|
||||||
- Sensitive surfaces (payment flows, authentication, secrets handling, untrusted input) require security review.
|
|
||||||
- IAM role, policy, or resource-permission changes require cross-family review. Lambda handler signature changes alone do not.
|
|
||||||
|
|
||||||
## CI and Workflow References
|
## CI and Workflow References
|
||||||
- CI must pass before merge.
|
- CI must pass before merge.
|
||||||
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.
|
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ fine and still supported.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v1.17.0 — September 25, 2026
|
||||||
|
|
||||||
|
**The work week now runs Sunday through Saturday, matching payroll.** The Monday
|
||||||
|
7am schedule post and pay summary use that week. A Saturday night shift (5pm
|
||||||
|
Saturday through 8am Sunday) stays in the Saturday week. Sunday day and Sunday
|
||||||
|
night open the next week. The first pay close after this change skips any date
|
||||||
|
already sent to Flex, so that Sunday is not paid twice.
|
||||||
|
|
||||||
## v1.16.0 — September 21, 2026
|
## v1.16.0 — September 21, 2026
|
||||||
|
|
||||||
**After Hours is available in the employee portal, and Slack still works.** Employees
|
**After Hours is available in the employee portal, and Slack still works.** Employees
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -11,6 +11,8 @@ Slack bot for managing after-hours on-call shifts at Sea Haven Industries. Emplo
|
||||||
|
|
||||||
A recurring weekly schedule assigns employees to after-hours phone duty. Weekend shifts are split into Day (8am-5pm) and Night (5pm-8am). Any unassigned shift shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX queue is updated immediately. Future changes take effect when the ring scheduler runs at 8am daily and 5pm on weekends.
|
A recurring weekly schedule assigns employees to after-hours phone duty. Weekend shifts are split into Day (8am-5pm) and Night (5pm-8am). Any unassigned shift shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX queue is updated immediately. Future changes take effect when the ring scheduler runs at 8am daily and 5pm on weekends.
|
||||||
|
|
||||||
|
The work week runs Sunday through Saturday, matching payroll. A Saturday night shift (5pm Saturday through 8am Sunday) stays in the Saturday week. Sunday day and Sunday night open the next week. The Monday 7am post shows the week that started the day before, plus the following week, and the pay summary covers the previous Sunday through Saturday.
|
||||||
|
|
||||||
The weekly schedule post is updated live when shifts change, and the previous week's post is automatically deleted when the new one goes out.
|
The weekly schedule post is updated live when shifts change, and the previous week's post is automatically deleted when the new one goes out.
|
||||||
|
|
||||||
The bot also has an **About** page: open the bot in Slack and click its **Home** tab to see what it does, the full command list, and the latest "What's New" (see [Releases & Versioning](#releases--versioning)).
|
The bot also has an **About** page: open the bot in Slack and click its **Home** tab to see what it does, the full command list, and the latest "What's New" (see [Releases & Versioning](#releases--versioning)).
|
||||||
|
|
@ -28,7 +30,7 @@ Employees can do the same pick, drop, swap, and admin work from the internal por
|
||||||
| `/oncall swap <date> @person` | Request a swap — the other person gets an Accept/Decline DM and the shift only moves once they accept |
|
| `/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 register <ext>` | Link your Slack account to your phone extension |
|
||||||
| `/oncall roster` | Show all employees and their link status |
|
| `/oncall roster` | Show all employees and their link status |
|
||||||
| `/oncall pay` | Show last week's bonus pay summary |
|
| `/oncall pay` | Show last week's bonus pay summary (Sunday through Saturday) |
|
||||||
| `/oncall rate` | Show current shift pay rates |
|
| `/oncall rate` | Show current shift pay rates |
|
||||||
| `/oncall rate default <amount>` | Set the default per-shift rate |
|
| `/oncall rate default <amount>` | Set the default per-shift rate |
|
||||||
| `/oncall rate <ext> <amount>` | Set a per-person shift rate |
|
| `/oncall rate <ext> <amount>` | Set a per-person shift rate |
|
||||||
|
|
@ -74,7 +76,7 @@ Fargate is the live path. These `src/*/app.py` zip sources remain for `scripts/p
|
||||||
| Handler | Former trigger | Purpose |
|
| Handler | Former trigger | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons |
|
| `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons |
|
||||||
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email |
|
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts the Sunday-Saturday schedule and the previous week's pay summary |
|
||||||
| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX |
|
| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX |
|
||||||
| `afterhours-portal-api` | API Gateway (ANY /api/shifts, ANY /api/shifts/{proxy+}) | Cognito-authenticated employee/admin shift API for the internal portal |
|
| `afterhours-portal-api` | API Gateway (ANY /api/shifts, ANY /api/shifts/{proxy+}) | Cognito-authenticated employee/admin shift API for the internal portal |
|
||||||
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
|
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
|
||||||
|
|
@ -86,7 +88,7 @@ Fargate is the live path. These `src/*/app.py` zip sources remain for `scripts/p
|
||||||
src/
|
src/
|
||||||
server/ Flask + gunicorn + SQS worker (Fargate)
|
server/ Flask + gunicorn + SQS worker (Fargate)
|
||||||
slack-bot/ Slack Bolt app; leftover zip source for packaging; ships CHANGELOG.md for App Home
|
slack-bot/ Slack Bolt app; leftover zip source for packaging; ships CHANGELOG.md for App Home
|
||||||
weekly-post/ Monday schedule + pay post (leftover zip source for packaging)
|
weekly-post/ Monday 7am schedule post and prior Sunday-Saturday pay (leftover zip source)
|
||||||
roster-sync/ Daily 3CX roster sync (leftover zip source for packaging)
|
roster-sync/ Daily 3CX roster sync (leftover zip source for packaging)
|
||||||
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard (leftover zip source for packaging)
|
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard (leftover zip source for packaging)
|
||||||
portal-api/ Cognito employee/admin shift API for the internal portal (leftover zip source for packaging)
|
portal-api/ Cognito employee/admin shift API for the internal portal (leftover zip source for packaging)
|
||||||
|
|
@ -111,7 +113,7 @@ Single table with `PK` / `SK` keys:
|
||||||
| `HOLIDAY` | `<YYYY-MM-DD>` | Holiday day shift (one per date): `slots` (int), `assignees` (MAP keyed by extension — `{"114": {name, claimed_at}}`), `multiplier` (Decimal, defaults to `CONFIG.holiday_multiplier` = 1.5, overridable per holiday), `label`, `created_at`, `created_by`, `activated` (bool), `schedule_names` (list) |
|
| `HOLIDAY` | `<YYYY-MM-DD>` | Holiday day shift (one per date): `slots` (int), `assignees` (MAP keyed by extension — `{"114": {name, claimed_at}}`), `multiplier` (Decimal, defaults to `CONFIG.holiday_multiplier` = 1.5, overridable per holiday), `label`, `created_at`, `created_by`, `activated` (bool), `schedule_names` (list) |
|
||||||
| `PICKUP_REQUEST` | `<YYYY-MM-DD>[-DAY]#<ext>` | Pending late-pickup awaiting admin approval: `requester_ext`, `requester_name`, `requester_slack`, `shift_type`, `is_holiday`, `status`, `created_at`, `expires_at` (TTL = shift end) |
|
| `PICKUP_REQUEST` | `<YYYY-MM-DD>[-DAY]#<ext>` | Pending late-pickup awaiting admin approval: `requester_ext`, `requester_name`, `requester_slack`, `shift_type`, `is_holiday`, `status`, `created_at`, `expires_at` (TTL = shift end) |
|
||||||
| `SCHEDULE_POST` | `<channel_id>` | Current schedule message timestamp |
|
| `SCHEDULE_POST` | `<channel_id>` | Current schedule message timestamp |
|
||||||
| `PAY` | `<YYYY-MM-DD>` | Weekly pay record (Monday date key) |
|
| `PAY` | `<YYYY-MM-DD>` | Weekly pay record (Sunday date key; older rows may use Monday) |
|
||||||
| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension, admin_users, `ring_group`, `holiday_multiplier` (default holiday pay multiplier, 1.5), `holiday_queue` (3CX queue repointed during holidays, default 802), `ivr_number` (3CX IVR repointed during holidays, default 800), `captured_ivr_routes` (original IVR routes saved at holiday activation, restored at deactivation) |
|
| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension, admin_users, `ring_group`, `holiday_multiplier` (default holiday pay multiplier, 1.5), `holiday_queue` (3CX queue repointed during holidays, default 802), `ivr_number` (3CX IVR repointed during holidays, default 800), `captured_ivr_routes` (original IVR routes saved at holiday activation, restored at deactivation) |
|
||||||
|
|
||||||
Weekend day-shift rows use a `-DAY` suffix on the SK (e.g. `OVERRIDE` / `2026-04-05-DAY`). The table has TTL enabled on `expires_at` so abandoned pending swaps and pickup requests self-clean.
|
Weekend day-shift rows use a `-DAY` suffix on the SK (e.g. `OVERRIDE` / `2026-04-05-DAY`). The table has TTL enabled on `expires_at` so abandoned pending swaps and pickup requests self-clean.
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,9 @@ components:
|
||||||
targetExtension:
|
targetExtension:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
description: Optional. Stored after trim, and the trimmed value must be 500 characters or fewer.
|
||||||
|
|
||||||
AdminOverrideBody:
|
AdminOverrideBody:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -698,6 +701,9 @@ components:
|
||||||
type: string
|
type: string
|
||||||
incoming:
|
incoming:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
maxLength: 500
|
||||||
|
|
||||||
PendingPickup:
|
PendingPickup:
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -8,13 +8,13 @@
|
||||||
"name": "afterhours-shift-manager",
|
"name": "afterhours-shift-manager",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@redocly/cli": "2.53.3"
|
"@redocly/cli": "2.54.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redocly/cli": {
|
"node_modules/@redocly/cli": {
|
||||||
"version": "2.53.3",
|
"version": "2.54.2",
|
||||||
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.54.2.tgz",
|
||||||
"integrity": "sha512-hzNAWzHCOZ05vwRx0ehTxNeJaxxizjGV505eKtfs9MR/8ieD/8lYhAK4GF5FaqRRwOEpLO0PvzQwBSKYXmxhRA==",
|
"integrity": "sha512-YQ53kSQV/zpYSdY3WiQvf7JxuVLD8jTEX37YOY6MS+G3tyHDCeONpUkAt6qr9n2/nmGm6m+A+PB/mGFvhkt1uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,6 @@
|
||||||
"openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml"
|
"openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@redocly/cli": "2.53.3"
|
"@redocly/cli": "2.54.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ def _deactivate(schedule: ShiftSchedule, date: str) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
|
if os.environ.get("STAGE", "prod") != "prod":
|
||||||
|
logger.info("Skipping holiday router because STAGE is not prod")
|
||||||
|
return {"skipped": "non_prod"}
|
||||||
|
|
||||||
action = event.get("action")
|
action = event.get("action")
|
||||||
date = event.get("date")
|
date = event.get("date")
|
||||||
logger.info("Holiday router invoked: action=%s date=%s", action, date)
|
logger.info("Holiday router invoked: action=%s date=%s", action, date)
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
boto3>=1.43.98
|
boto3>=1.43.99
|
||||||
requests>=2.34.2
|
requests>=2.34.2
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
PyJWT[crypto]==2.14.0
|
PyJWT[crypto]==2.14.0
|
||||||
boto3>=1.43.98
|
boto3>=1.43.99
|
||||||
requests>=2.34.2
|
requests>=2.34.2
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
|
if os.environ.get("STAGE", "prod") != "prod":
|
||||||
|
logger.info("Skipping 3CX queue scheduler because STAGE is not prod")
|
||||||
|
return {"skipped": "non_prod"}
|
||||||
|
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
current_hour = now.hour
|
current_hour = now.hour
|
||||||
day_name = now.strftime("%A")
|
day_name = now.strftime("%A")
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
boto3>=1.43.98
|
boto3>=1.43.99
|
||||||
requests>=2.34.2
|
requests>=2.34.2
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
boto3>=1.43.98
|
boto3>=1.43.99
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"}
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
|
if os.environ.get("STAGE", "prod") != "prod":
|
||||||
|
logger.info("Skipping roster sync because STAGE is not prod")
|
||||||
|
return {"skipped": "non_prod"}
|
||||||
|
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
|
|
||||||
# DST guard — two EventBridge rules fire, only one is at 6am ET
|
# DST guard — two EventBridge rules fire, only one is at 6am ET
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
boto3>=1.43.98
|
boto3>=1.43.99
|
||||||
requests>=2.34.2
|
requests>=2.34.2
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
@app.route("/slack/events", methods=["POST"])
|
@app.route("/slack/events", methods=["POST"])
|
||||||
def slack_events():
|
def slack_events():
|
||||||
|
if os.environ.get("STAGE", "prod") != "prod":
|
||||||
|
return jsonify({"error": "slack_disabled"}), 404
|
||||||
return _get_slack_handler().handle(request)
|
return _get_slack_handler().handle(request)
|
||||||
|
|
||||||
@app.route("/api/shifts", methods=["GET", "POST", "DELETE", "OPTIONS"])
|
@app.route("/api/shifts", methods=["GET", "POST", "DELETE", "OPTIONS"])
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
boto3>=1.43.98
|
boto3>=1.43.99
|
||||||
requests>=2.34.2
|
requests>=2.34.2
|
||||||
sentry-sdk==2.69.2
|
sentry-sdk==2.69.2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from shared.schedule import WEEKEND_DAYS
|
from shared.schedule import WEEKEND_DAYS, week_start
|
||||||
|
|
||||||
EASTERN = ZoneInfo("America/New_York")
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
|
@ -116,11 +116,11 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
schedule: ShiftSchedule instance
|
schedule: ShiftSchedule instance
|
||||||
start_date: Monday of the first week to show. Defaults to current week's Monday.
|
start_date: Sunday of the first week to show. Defaults to current week's Sunday.
|
||||||
"""
|
"""
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
if start_date is None:
|
if start_date is None:
|
||||||
start_date = now - timedelta(days=now.weekday()) # Monday of this week
|
start_date = week_start(now)
|
||||||
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
today_str = now.strftime("%Y-%m-%d")
|
today_str = now.strftime("%Y-%m-%d")
|
||||||
|
|
@ -254,8 +254,15 @@ def build_shift_change_message(
|
||||||
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
|
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
|
||||||
|
|
||||||
|
|
||||||
|
def _mrkdwn_text(value: str) -> str:
|
||||||
|
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
def build_swap_request_blocks(
|
def build_swap_request_blocks(
|
||||||
requester_slack: str, date_str: str, shift_type: str = "night"
|
requester_slack: str,
|
||||||
|
date_str: str,
|
||||||
|
shift_type: str = "night",
|
||||||
|
note: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Build the interactive Accept / Decline message DMed to a swap target."""
|
"""Build the interactive Accept / Decline message DMed to a swap target."""
|
||||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
|
@ -266,15 +273,18 @@ def build_swap_request_blocks(
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
action_suffix = "_day" if shift_type == "day" else ""
|
action_suffix = "_day" if shift_type == "day" else ""
|
||||||
|
text = (
|
||||||
|
f"<@{requester_slack}> wants you to cover the "
|
||||||
|
f"*{day_label}*{type_label} shift. Accept to take it on."
|
||||||
|
)
|
||||||
|
if note:
|
||||||
|
text += f"\n\nNote: {_mrkdwn_text(note)}"
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"type": "section",
|
"type": "section",
|
||||||
"text": {
|
"text": {
|
||||||
"type": "mrkdwn",
|
"type": "mrkdwn",
|
||||||
"text": (
|
"text": text,
|
||||||
f"<@{requester_slack}> wants you to cover the "
|
|
||||||
f"*{day_label}*{type_label} shift. Accept to take it on."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
14
src/shared/shared/effects.py
Normal file
14
src/shared/shared/effects.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""External side effects. Only production may call Slack or 3CX.
|
||||||
|
|
||||||
|
A missing STAGE is treated as prod so a task that lost its environment
|
||||||
|
variable does not silently drop production notifications. Dev and any
|
||||||
|
other named stage skip Slack and 3CX entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def prod_side_effects_enabled() -> bool:
|
||||||
|
return os.environ.get("STAGE", "prod") == "prod"
|
||||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from shared.effects import prod_side_effects_enabled
|
||||||
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
from shared.three_cx_client import ThreeCXClient, oauth_client
|
from shared.three_cx_client import ThreeCXClient, oauth_client
|
||||||
|
|
@ -28,6 +29,9 @@ def holiday_extensions(holiday: dict) -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
def activate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
|
def activate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
logger.info("Skipping holiday activate because STAGE is not prod")
|
||||||
|
return {"action": "activate", "date": date, "skipped": "non_prod"}
|
||||||
holiday = schedule.get_holiday(date)
|
holiday = schedule.get_holiday(date)
|
||||||
if holiday is None:
|
if holiday is None:
|
||||||
logger.info("No holiday record for %s — nothing to activate", date)
|
logger.info("No holiday record for %s — nothing to activate", date)
|
||||||
|
|
@ -77,6 +81,9 @@ def activate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def deactivate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
|
def deactivate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
logger.info("Skipping holiday deactivate because STAGE is not prod")
|
||||||
|
return {"action": "deactivate", "date": date, "skipped": "non_prod"}
|
||||||
holiday = schedule.get_holiday(date)
|
holiday = schedule.get_holiday(date)
|
||||||
if holiday is None:
|
if holiday is None:
|
||||||
logger.info("No holiday record for %s — nothing to deactivate", date)
|
logger.info("No holiday record for %s — nothing to deactivate", date)
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ def dispatch(
|
||||||
body.get("date", ""),
|
body.get("date", ""),
|
||||||
body.get("targetExtension", ""),
|
body.get("targetExtension", ""),
|
||||||
body.get("shiftType"),
|
body.get("shiftType"),
|
||||||
|
note=body.get("note"),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
method == "POST"
|
method == "POST"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
|
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule, week_start
|
||||||
from shared.shift_clock import (
|
from shared.shift_clock import (
|
||||||
EASTERN,
|
EASTERN,
|
||||||
holiday_window_active,
|
holiday_window_active,
|
||||||
|
|
@ -101,11 +101,11 @@ def _slot_payload(
|
||||||
|
|
||||||
|
|
||||||
def week_range(which: str) -> tuple[datetime, str]:
|
def week_range(which: str) -> tuple[datetime, str]:
|
||||||
|
"""Sunday–Saturday window. ``next`` is the following Sunday."""
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
this_monday = (now - timedelta(days=now.weekday())).replace(
|
start = week_start(now)
|
||||||
hour=0, minute=0, second=0, microsecond=0
|
if which == "next":
|
||||||
)
|
start = start + timedelta(days=7)
|
||||||
start = this_monday if which != "next" else this_monday + timedelta(days=7)
|
|
||||||
label = "this" if which != "next" else "next"
|
label = "this" if which != "next" else "next"
|
||||||
return start, label
|
return start, label
|
||||||
|
|
||||||
|
|
@ -160,9 +160,25 @@ def snapshot(schedule: ShiftSchedule, employee: dict, week: str = "this") -> dic
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
NOTE_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_swap_note(note: str | None) -> str | None:
|
||||||
|
if note is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(note, str):
|
||||||
|
raise ActionError(400, "INVALID_NOTE", "Note must be text.")
|
||||||
|
cleaned = note.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
if len(cleaned) > NOTE_MAX:
|
||||||
|
raise ActionError(400, "NOTE_TOO_LONG", "Note must be 500 characters or fewer.")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def _swap_payload(item: dict, my_ext: str) -> dict:
|
def _swap_payload(item: dict, my_ext: str) -> dict:
|
||||||
date_str, shift_type = _sk_to_date_shift(item.get("SK", ""))
|
date_str, shift_type = _sk_to_date_shift(item.get("SK", ""))
|
||||||
return {
|
payload = {
|
||||||
"date": date_str,
|
"date": date_str,
|
||||||
"shiftType": item.get("shift_type") or shift_type,
|
"shiftType": item.get("shift_type") or shift_type,
|
||||||
"requesterExt": item.get("requester_ext", ""),
|
"requesterExt": item.get("requester_ext", ""),
|
||||||
|
|
@ -171,6 +187,10 @@ def _swap_payload(item: dict, my_ext: str) -> dict:
|
||||||
"targetName": item.get("target_name", ""),
|
"targetName": item.get("target_name", ""),
|
||||||
"incoming": item.get("target_ext") == my_ext,
|
"incoming": item.get("target_ext") == my_ext,
|
||||||
}
|
}
|
||||||
|
note = item.get("note")
|
||||||
|
if isinstance(note, str) and note.strip():
|
||||||
|
payload["note"] = note.strip()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _pickup_payload(item: dict) -> dict:
|
def _pickup_payload(item: dict) -> dict:
|
||||||
|
|
@ -419,6 +439,7 @@ def swap(
|
||||||
date_str: str,
|
date_str: str,
|
||||||
target_extension: str,
|
target_extension: str,
|
||||||
shift_type: str | None,
|
shift_type: str | None,
|
||||||
|
note: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
date = _parse_date(date_str)
|
date = _parse_date(date_str)
|
||||||
date_str = date.strftime(DATE_FMT)
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
|
@ -454,8 +475,11 @@ def swap(
|
||||||
raise ActionError(400, "UNKNOWN_TARGET", "That extension is not on the roster.")
|
raise ActionError(400, "UNKNOWN_TARGET", "That extension is not on the roster.")
|
||||||
if target["extension"] == employee["extension"]:
|
if target["extension"] == employee["extension"]:
|
||||||
raise ActionError(400, "SELF_SWAP", "That shift is already yours.")
|
raise ActionError(400, "SELF_SWAP", "That shift is already yours.")
|
||||||
|
cleaned_note = _clean_swap_note(note)
|
||||||
expires_at = int(shift_start(date_str, resolved).timestamp())
|
expires_at = int(shift_start(date_str, resolved).timestamp())
|
||||||
schedule.create_pending_swap(date_str, resolved, employee, target, expires_at)
|
schedule.create_pending_swap(
|
||||||
|
date_str, resolved, employee, target, expires_at, note=cleaned_note
|
||||||
|
)
|
||||||
token = effects.slack_token()
|
token = effects.slack_token()
|
||||||
if target.get("slack_user_id"):
|
if target.get("slack_user_id"):
|
||||||
effects.dm_swap_request(
|
effects.dm_swap_request(
|
||||||
|
|
@ -465,6 +489,7 @@ def swap(
|
||||||
date_str,
|
date_str,
|
||||||
resolved,
|
resolved,
|
||||||
employee["name"],
|
employee["name"],
|
||||||
|
note=cleaned_note,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Single-table design:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
@ -36,6 +36,21 @@ DEFAULT_HOLIDAY_QUEUE = "802"
|
||||||
DEFAULT_IVR_NUMBER = "800"
|
DEFAULT_IVR_NUMBER = "800"
|
||||||
|
|
||||||
|
|
||||||
|
def week_start(when: datetime) -> datetime:
|
||||||
|
"""Sunday 00:00 Eastern of the Sun–Sat work week that contains ``when``.
|
||||||
|
|
||||||
|
Shifts belong to the week of their start date. A Saturday night shift
|
||||||
|
(5pm Saturday through 8am Sunday) stays in the week that ends Saturday.
|
||||||
|
Sunday day and Sunday night open the next week.
|
||||||
|
"""
|
||||||
|
if when.tzinfo is None:
|
||||||
|
when = when.replace(tzinfo=EASTERN)
|
||||||
|
else:
|
||||||
|
when = when.astimezone(EASTERN)
|
||||||
|
when = when.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return when - timedelta(days=(when.weekday() + 1) % 7)
|
||||||
|
|
||||||
|
|
||||||
def determine_shift_type(now: datetime | None = None) -> str:
|
def determine_shift_type(now: datetime | None = None) -> str:
|
||||||
"""Return the currently active shift type: 'day' or 'night'.
|
"""Return the currently active shift type: 'day' or 'night'.
|
||||||
|
|
||||||
|
|
@ -246,29 +261,32 @@ class ShiftSchedule:
|
||||||
requester: dict,
|
requester: dict,
|
||||||
target: dict,
|
target: dict,
|
||||||
expires_at: int,
|
expires_at: int,
|
||||||
|
note: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create (or supersede) a pending swap request for a shift.
|
"""Create (or supersede) a pending swap request for a shift.
|
||||||
|
|
||||||
One swap per shift (unique SK), so a new request overwrites any prior
|
One swap per shift (unique SK), so a new request overwrites any prior
|
||||||
pending one. ``expires_at`` is an epoch timestamp used for DynamoDB TTL.
|
pending one. ``expires_at`` is an epoch timestamp used for DynamoDB TTL.
|
||||||
|
``note`` is omitted when empty so Slack ``/oncall swap`` stays unchanged.
|
||||||
"""
|
"""
|
||||||
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
||||||
self.table.put_item(
|
item = {
|
||||||
Item={
|
"PK": "SWAP",
|
||||||
"PK": "SWAP",
|
"SK": sk,
|
||||||
"SK": sk,
|
"shift_type": shift_type,
|
||||||
"shift_type": shift_type,
|
"status": "pending",
|
||||||
"status": "pending",
|
"requester_ext": requester["extension"],
|
||||||
"requester_ext": requester["extension"],
|
"requester_name": requester["name"],
|
||||||
"requester_name": requester["name"],
|
"requester_slack": requester.get("slack_user_id", ""),
|
||||||
"requester_slack": requester.get("slack_user_id", ""),
|
"target_ext": target["extension"],
|
||||||
"target_ext": target["extension"],
|
"target_name": target["name"],
|
||||||
"target_name": target["name"],
|
"target_slack": target.get("slack_user_id", ""),
|
||||||
"target_slack": target.get("slack_user_id", ""),
|
"created_at": datetime.now(EASTERN).isoformat(),
|
||||||
"created_at": datetime.now(EASTERN).isoformat(),
|
"expires_at": expires_at,
|
||||||
"expires_at": expires_at,
|
}
|
||||||
}
|
if note:
|
||||||
)
|
item["note"] = note
|
||||||
|
self.table.put_item(Item=item)
|
||||||
|
|
||||||
def get_swap(self, date_str: str, shift_type: str = "night") -> dict | None:
|
def get_swap(self, date_str: str, shift_type: str = "night") -> dict | None:
|
||||||
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
||||||
|
|
@ -643,12 +661,27 @@ class ShiftSchedule:
|
||||||
# ── Pay records ─────────────────────────────────────────────────────
|
# ── Pay records ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_pay_record(self, week_key: str) -> dict | None:
|
def get_pay_record(self, week_key: str) -> dict | None:
|
||||||
"""Get a pay record by week key (e.g. '2026-04-06')."""
|
"""Get a pay record by week key (the Sunday that opens the week)."""
|
||||||
resp = self.table.get_item(Key={"PK": "PAY", "SK": week_key})
|
resp = self.table.get_item(Key={"PK": "PAY", "SK": week_key})
|
||||||
return resp.get("Item")
|
return resp.get("Item")
|
||||||
|
|
||||||
|
def list_pay_records(self) -> list[dict]:
|
||||||
|
"""Return every PAY row, including legacy Monday-keyed weeks."""
|
||||||
|
items: list[dict] = []
|
||||||
|
kwargs: dict = {"KeyConditionExpression": Key("PK").eq("PAY")}
|
||||||
|
while True:
|
||||||
|
resp = self.table.query(**kwargs)
|
||||||
|
items.extend(resp.get("Items", []))
|
||||||
|
last = resp.get("LastEvaluatedKey")
|
||||||
|
if not last:
|
||||||
|
return items
|
||||||
|
kwargs["ExclusiveStartKey"] = last
|
||||||
|
|
||||||
def save_pay_record(self, week_key: str, record: dict) -> None:
|
def save_pay_record(self, week_key: str, record: dict) -> None:
|
||||||
"""Save a weekly pay summary. week_key is the Monday date string."""
|
"""Save a weekly pay summary. week_key is the Sunday date string.
|
||||||
|
|
||||||
|
Older rows may still be keyed by Monday. New writes use Sunday.
|
||||||
|
"""
|
||||||
self.table.put_item(Item={"PK": "PAY", "SK": week_key, **record})
|
self.table.put_item(Item={"PK": "PAY", "SK": week_key, **record})
|
||||||
|
|
||||||
# ── Config ──────────────────────────────────────────────────────────
|
# ── Config ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,13 @@ from shared.blocks import (
|
||||||
build_holiday_added_blocks,
|
build_holiday_added_blocks,
|
||||||
build_pickup_request_blocks,
|
build_pickup_request_blocks,
|
||||||
build_shift_change_message,
|
build_shift_change_message,
|
||||||
|
_mrkdwn_text,
|
||||||
build_swap_request_blocks,
|
build_swap_request_blocks,
|
||||||
build_week_schedule,
|
build_week_schedule,
|
||||||
)
|
)
|
||||||
|
from shared.effects import prod_side_effects_enabled
|
||||||
from shared.ring_scheduler import update_queue_routing
|
from shared.ring_scheduler import update_queue_routing
|
||||||
from shared.schedule import FALLBACK_EXTENSION
|
from shared.schedule import FALLBACK_EXTENSION, week_start
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
from shared.shift_clock import is_active_shift_type, is_today
|
from shared.shift_clock import is_active_shift_type, is_today
|
||||||
from shared.three_cx_client import ThreeCXClient, oauth_client
|
from shared.three_cx_client import ThreeCXClient, oauth_client
|
||||||
|
|
@ -30,6 +32,8 @@ SLACK_API = "https://slack.com/api"
|
||||||
|
|
||||||
|
|
||||||
def slack_token() -> str | None:
|
def slack_token() -> str | None:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
return None
|
||||||
secret_id = os.environ.get("SLACK_BOT_TOKEN_SECRET")
|
secret_id = os.environ.get("SLACK_BOT_TOKEN_SECRET")
|
||||||
if not secret_id:
|
if not secret_id:
|
||||||
return None
|
return None
|
||||||
|
|
@ -41,6 +45,9 @@ def slack_token() -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def slack_call(method: str, token: str, **payload) -> bool:
|
def slack_call(method: str, token: str, **payload) -> bool:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
logger.info("Skipping Slack %s because STAGE is not prod", method)
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SLACK_API}/{method}",
|
f"{SLACK_API}/{method}",
|
||||||
|
|
@ -62,6 +69,9 @@ def slack_call(method: str, token: str, **payload) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def update_3cx_routing(extension: str) -> None:
|
def update_3cx_routing(extension: str) -> None:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
logger.info("Skipping 3CX routing because STAGE is not prod")
|
||||||
|
return
|
||||||
queue_number = os.environ.get("QUEUE_NUMBER")
|
queue_number = os.environ.get("QUEUE_NUMBER")
|
||||||
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||||||
if not queue_number or not secret_prefix:
|
if not queue_number or not secret_prefix:
|
||||||
|
|
@ -88,6 +98,8 @@ def maybe_repoint_today(date_str: str, shift_type: str, extension: str) -> bool:
|
||||||
|
|
||||||
def make_3cx_client() -> ThreeCXClient | None:
|
def make_3cx_client() -> ThreeCXClient | None:
|
||||||
"""Return the process OAuth client, refreshing it when the token or secret changed."""
|
"""Return the process OAuth client, refreshing it when the token or secret changed."""
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
return None
|
||||||
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||||||
if not secret_prefix:
|
if not secret_prefix:
|
||||||
logger.warning("3CX env vars not set — skipping 3CX call")
|
logger.warning("3CX env vars not set — skipping 3CX call")
|
||||||
|
|
@ -217,10 +229,10 @@ def delete_holiday_schedules(schedule_names: list[str]) -> None:
|
||||||
|
|
||||||
def schedule_fallback_text() -> str:
|
def schedule_fallback_text() -> str:
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
this_monday = now - timedelta(days=now.weekday())
|
start = week_start(now)
|
||||||
end_date = this_monday + timedelta(days=13)
|
end_date = start + timedelta(days=13)
|
||||||
return (
|
return (
|
||||||
f"After-Hours Schedule — {this_monday.strftime('%b %-d')} "
|
f"After-Hours Schedule — {start.strftime('%b %-d')} "
|
||||||
f"to {end_date.strftime('%b %-d')}"
|
f"to {end_date.strftime('%b %-d')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -272,15 +284,19 @@ def dm_swap_request(
|
||||||
date_str: str,
|
date_str: str,
|
||||||
shift_type: str,
|
shift_type: str,
|
||||||
requester_name: str,
|
requester_name: str,
|
||||||
|
note: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not token or not target_slack:
|
if not token or not target_slack:
|
||||||
return False
|
return False
|
||||||
|
text = f"{requester_name} wants to swap you the {date_str} shift"
|
||||||
|
if note:
|
||||||
|
text += f"\nNote: {_mrkdwn_text(note)}"
|
||||||
return slack_call(
|
return slack_call(
|
||||||
"chat.postMessage",
|
"chat.postMessage",
|
||||||
token,
|
token,
|
||||||
channel=target_slack,
|
channel=target_slack,
|
||||||
blocks=build_swap_request_blocks(requester_slack, date_str, shift_type),
|
blocks=build_swap_request_blocks(requester_slack, date_str, shift_type, note),
|
||||||
text=f"{requester_name} wants to swap you the {date_str} shift",
|
text=text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,8 @@ class ThreeCXClient:
|
||||||
def _authenticate_oauth(self, client_id: str, client_secret: str):
|
def _authenticate_oauth(self, client_id: str, client_secret: str):
|
||||||
"""Authenticate via OAuth2 client credentials (Enterprise license required).
|
"""Authenticate via OAuth2 client credentials (Enterprise license required).
|
||||||
API client must be created in 3CX Admin > Integrations > API."""
|
API client must be created in 3CX Admin > Integrations > API."""
|
||||||
|
# Drop the session bearer. A refresh otherwise sends the expired
|
||||||
|
# access token to /connect/token, and 3CX answers 400.
|
||||||
resp = self._raw_request(
|
resp = self._raw_request(
|
||||||
"POST",
|
"POST",
|
||||||
f"{self.base_url}/connect/token",
|
f"{self.base_url}/connect/token",
|
||||||
|
|
@ -145,7 +147,10 @@ class ThreeCXClient:
|
||||||
"client_secret": client_secret,
|
"client_secret": client_secret,
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
},
|
},
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Authorization": None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ fine and still supported.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v1.17.0 — September 25, 2026
|
||||||
|
|
||||||
|
**The work week now runs Sunday through Saturday, matching payroll.** The Monday
|
||||||
|
7am schedule post and pay summary use that week. A Saturday night shift (5pm
|
||||||
|
Saturday through 8am Sunday) stays in the Saturday week. Sunday day and Sunday
|
||||||
|
night open the next week. The first pay close after this change skips any date
|
||||||
|
already sent to Flex, so that Sunday is not paid twice.
|
||||||
|
|
||||||
## v1.16.0 — September 21, 2026
|
## v1.16.0 — September 21, 2026
|
||||||
|
|
||||||
**After Hours is available in the employee portal, and Slack still works.** Employees
|
**After Hours is available in the employee portal, and Slack still works.** Employees
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ from shared.schedule import (
|
||||||
ExtensionAlreadyRegistered,
|
ExtensionAlreadyRegistered,
|
||||||
ShiftSchedule,
|
ShiftSchedule,
|
||||||
determine_shift_type,
|
determine_shift_type,
|
||||||
|
week_start,
|
||||||
)
|
)
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
from shared.three_cx_client import ThreeCXClient
|
from shared.three_cx_client import ThreeCXClient
|
||||||
|
|
@ -345,10 +346,10 @@ def _droppable_shifts(schedule, date_str, day_name, employee_ext) -> list[str]:
|
||||||
def _schedule_fallback_text() -> str:
|
def _schedule_fallback_text() -> str:
|
||||||
"""Notification fallback text for the two-week schedule post."""
|
"""Notification fallback text for the two-week schedule post."""
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
this_monday = now - timedelta(days=now.weekday())
|
start = week_start(now)
|
||||||
end_date = this_monday + timedelta(days=13)
|
end_date = start + timedelta(days=13)
|
||||||
return (
|
return (
|
||||||
f"After-Hours Schedule — {this_monday.strftime('%b %-d')} "
|
f"After-Hours Schedule — {start.strftime('%b %-d')} "
|
||||||
f"to {end_date.strftime('%b %-d')}"
|
f"to {end_date.strftime('%b %-d')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -641,25 +642,50 @@ def _show_schedule(respond, schedule):
|
||||||
|
|
||||||
def _show_next_week(respond, schedule):
|
def _show_next_week(respond, schedule):
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
# Jump 2 weeks ahead from this week's Monday
|
# The default view is already two weeks, so "next" starts two Sundays ahead.
|
||||||
this_monday = now - timedelta(days=now.weekday())
|
next_start = week_start(now) + timedelta(days=14)
|
||||||
next_start = this_monday + timedelta(days=14)
|
|
||||||
blocks = build_week_schedule(schedule, start_date=next_start)
|
blocks = build_week_schedule(schedule, start_date=next_start)
|
||||||
respond(blocks=blocks)
|
respond(blocks=blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def _pay_week_bounds(pay_record: dict) -> tuple[datetime, datetime]:
|
||||||
|
"""Label bounds for a pay record.
|
||||||
|
|
||||||
|
New rows are Sunday–Saturday. A cutover row may store a shorter
|
||||||
|
``window_start``/``window_end``. Legacy rows are Monday–Sunday via
|
||||||
|
``week_start`` plus six days.
|
||||||
|
"""
|
||||||
|
start = datetime.strptime(
|
||||||
|
pay_record.get("window_start") or pay_record["week_start"], "%Y-%m-%d"
|
||||||
|
)
|
||||||
|
if pay_record.get("window_end"):
|
||||||
|
end = datetime.strptime(pay_record["window_end"], "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
end = start + timedelta(days=6)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
def _show_pay(respond, schedule):
|
def _show_pay(respond, schedule):
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
# Show last completed week's pay (previous Monday–Sunday)
|
# The Monday 7am close writes the Sun–Sat week that ended Saturday. On
|
||||||
this_monday = now - timedelta(days=now.weekday())
|
# Sunday, and on Monday before that close, that row is not written yet, so
|
||||||
prev_monday = this_monday - timedelta(days=7)
|
# also try the prior week's Sunday key and the legacy Monday keys.
|
||||||
week_key = prev_monday.strftime("%Y-%m-%d")
|
prev_start = week_start(now) - timedelta(days=7)
|
||||||
|
pay_record = None
|
||||||
pay_record = schedule.get_pay_record(week_key)
|
for start in (
|
||||||
|
prev_start,
|
||||||
|
prev_start + timedelta(days=1),
|
||||||
|
prev_start - timedelta(days=7),
|
||||||
|
prev_start - timedelta(days=6),
|
||||||
|
):
|
||||||
|
record = schedule.get_pay_record(start.strftime("%Y-%m-%d"))
|
||||||
|
if record and record.get("breakdown"):
|
||||||
|
pay_record = record
|
||||||
|
break
|
||||||
if pay_record and pay_record.get("breakdown"):
|
if pay_record and pay_record.get("breakdown"):
|
||||||
prev_sunday = prev_monday + timedelta(days=6)
|
label_start, label_end = _pay_week_bounds(pay_record)
|
||||||
week_label = (
|
week_label = (
|
||||||
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
|
f"{label_start.strftime('%b %-d')} to {label_end.strftime('%b %-d')}"
|
||||||
)
|
)
|
||||||
blocks = build_pay_summary_blocks(
|
blocks = build_pay_summary_blocks(
|
||||||
week_label, pay_record["breakdown"], pay_record["totals"]
|
week_label, pay_record["breakdown"], pay_record["totals"]
|
||||||
|
|
@ -667,7 +693,7 @@ def _show_pay(respond, schedule):
|
||||||
respond(blocks=blocks)
|
respond(blocks=blocks)
|
||||||
else:
|
else:
|
||||||
respond(
|
respond(
|
||||||
text=f"No pay record found for the week of {prev_monday.strftime('%b %-d')}."
|
text=f"No pay record found for the week of {prev_start.strftime('%b %-d')}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
slack_bolt>=1.30.0,<2.0
|
slack_bolt>=1.30.0,<2.0
|
||||||
boto3>=1.43.98
|
boto3>=1.43.99
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Lambda handler — posts the weekly on-call schedule and previous week's pay summary
|
"""Lambda handler — posts the two-week on-call schedule and the previous
|
||||||
to Slack every Monday at 7am ET, and enqueues after-hours dollars for Flex."""
|
Sunday–Saturday pay summary to Slack every Monday at 7am ET, and enqueues
|
||||||
|
after-hours dollars for Flex."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -13,7 +14,7 @@ from slack_sdk import WebClient
|
||||||
|
|
||||||
import shared.sentry_init # noqa: F401
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.blocks import build_pay_summary_blocks, build_week_schedule
|
from shared.blocks import build_pay_summary_blocks, build_week_schedule
|
||||||
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
|
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule, week_start
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
@ -22,16 +23,6 @@ logger.setLevel(logging.INFO)
|
||||||
EASTERN = ZoneInfo("America/New_York")
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
KIND_AFTER_HOURS = "after_hours"
|
KIND_AFTER_HOURS = "after_hours"
|
||||||
|
|
||||||
DAY_ORDER = [
|
|
||||||
"Monday",
|
|
||||||
"Tuesday",
|
|
||||||
"Wednesday",
|
|
||||||
"Thursday",
|
|
||||||
"Friday",
|
|
||||||
"Saturday",
|
|
||||||
"Sunday",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _record_pay_line(
|
def _record_pay_line(
|
||||||
breakdown, totals, *, date, day_label, name, ext, effective, base_rate, is_holiday
|
breakdown, totals, *, date, day_label, name, ext, effective, base_rate, is_holiday
|
||||||
|
|
@ -136,15 +127,20 @@ def _add_shift_to_pay(schedule, breakdown, totals, date, day_name, shift_type="n
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict:
|
def _calculate_weekly_pay(schedule: ShiftSchedule, week_start_dt: datetime) -> dict:
|
||||||
"""Calculate pay for a Mon–Sun week. Returns pay record dict."""
|
"""Calculate pay for a Sun–Sat week. Returns pay record dict.
|
||||||
|
|
||||||
|
``week_start_dt`` is the Sunday that opens the week. Each shift is attributed
|
||||||
|
by its start date, so Saturday night stays in this week and the following
|
||||||
|
Sunday does not.
|
||||||
|
"""
|
||||||
default_rate = schedule.get_shift_rate()
|
default_rate = schedule.get_shift_rate()
|
||||||
breakdown = []
|
breakdown = []
|
||||||
totals = {}
|
totals = {}
|
||||||
|
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
date = week_start + timedelta(days=i)
|
date = week_start_dt + timedelta(days=i)
|
||||||
day_name = DAY_ORDER[i]
|
day_name = date.strftime("%A")
|
||||||
date_str = date.strftime("%Y-%m-%d")
|
date_str = date.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# Day shifts (8am–5pm) exist on weekends and on holidays. Holidays are
|
# Day shifts (8am–5pm) exist on weekends and on holidays. Holidays are
|
||||||
|
|
@ -157,17 +153,104 @@ def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict
|
||||||
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "night")
|
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "night")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"week_start": week_start.strftime("%Y-%m-%d"),
|
"week_start": week_start_dt.strftime("%Y-%m-%d"),
|
||||||
"default_rate": str(default_rate),
|
"default_rate": str(default_rate),
|
||||||
"breakdown": breakdown,
|
"breakdown": breakdown,
|
||||||
"totals": totals,
|
"totals": totals,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _already_sent_dates(schedule: ShiftSchedule, week_key: str) -> set[str]:
|
||||||
|
"""Dates already included in some other pay row that Flex has received.
|
||||||
|
|
||||||
|
The current week's own row is ignored so a retry can rebuild it.
|
||||||
|
"""
|
||||||
|
sent: set[str] = set()
|
||||||
|
for record in schedule.list_pay_records():
|
||||||
|
if record.get("SK") == week_key:
|
||||||
|
continue
|
||||||
|
if not record.get("checkcomponents_sent"):
|
||||||
|
continue
|
||||||
|
for line in record.get("breakdown") or []:
|
||||||
|
date = line.get("date")
|
||||||
|
if date:
|
||||||
|
sent.add(str(date))
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_totals(breakdown: list[dict]) -> dict:
|
||||||
|
totals: dict = {}
|
||||||
|
for line in breakdown:
|
||||||
|
name = line["name"]
|
||||||
|
if name not in totals:
|
||||||
|
totals[name] = {
|
||||||
|
"shifts": 0,
|
||||||
|
"total": Decimal("0"),
|
||||||
|
"extension": line["extension"],
|
||||||
|
"rate": line.get("base_rate", line["rate"]),
|
||||||
|
"holiday_shifts": 0,
|
||||||
|
}
|
||||||
|
totals[name]["shifts"] += 1
|
||||||
|
totals[name]["total"] += Decimal(str(line["amount"]))
|
||||||
|
if line.get("is_holiday"):
|
||||||
|
totals[name]["holiday_shifts"] += 1
|
||||||
|
return totals
|
||||||
|
|
||||||
|
|
||||||
|
def _exclude_sent_dates(
|
||||||
|
schedule: ShiftSchedule, pay_record: dict, week_key: str
|
||||||
|
) -> dict:
|
||||||
|
"""Drop dates Flex already received, and shrink the reported window.
|
||||||
|
|
||||||
|
The PAY key stays the Sunday that opened the computed week. When the
|
||||||
|
leading Sunday (or any other day) was in a sent Monday–Sunday row, the
|
||||||
|
Flex window becomes the remaining span, often Monday–Saturday once.
|
||||||
|
"""
|
||||||
|
sent = _already_sent_dates(schedule, week_key)
|
||||||
|
if not sent:
|
||||||
|
return pay_record
|
||||||
|
start = datetime.strptime(pay_record["week_start"], "%Y-%m-%d").date()
|
||||||
|
kept_days = []
|
||||||
|
omitted = False
|
||||||
|
for offset in range(7):
|
||||||
|
day = start + timedelta(days=offset)
|
||||||
|
if day.isoformat() in sent:
|
||||||
|
omitted = True
|
||||||
|
else:
|
||||||
|
kept_days.append(day)
|
||||||
|
if not omitted:
|
||||||
|
return pay_record
|
||||||
|
pay_record["breakdown"] = [
|
||||||
|
line for line in pay_record["breakdown"] if str(line.get("date")) not in sent
|
||||||
|
]
|
||||||
|
pay_record["totals"] = _rebuild_totals(pay_record["breakdown"])
|
||||||
|
if kept_days and len(kept_days) < 7:
|
||||||
|
pay_record["window_start"] = kept_days[0].isoformat()
|
||||||
|
pay_record["window_end"] = kept_days[-1].isoformat()
|
||||||
|
return pay_record
|
||||||
|
|
||||||
|
|
||||||
|
def _pay_week_label(pay_record: dict) -> str:
|
||||||
|
start = datetime.strptime(
|
||||||
|
pay_record.get("window_start") or pay_record["week_start"], "%Y-%m-%d"
|
||||||
|
)
|
||||||
|
if pay_record.get("window_end"):
|
||||||
|
end = datetime.strptime(pay_record["window_end"], "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
end = start + timedelta(days=6)
|
||||||
|
return f"{start.strftime('%b %-d')} to {end.strftime('%b %-d')}"
|
||||||
|
|
||||||
|
|
||||||
def build_checkcomponents_payload(pay_record: dict) -> dict:
|
def build_checkcomponents_payload(pay_record: dict) -> dict:
|
||||||
"""Sibling dollar lines only. No Flex OAuth, no payPeriodId invention."""
|
"""Sibling dollar lines only. No Flex OAuth, no payPeriodId invention."""
|
||||||
week_start = datetime.strptime(pay_record["week_start"], "%Y-%m-%d").date()
|
week_start_date = datetime.strptime(pay_record["week_start"], "%Y-%m-%d").date()
|
||||||
window_end = week_start + timedelta(days=6)
|
window_end = week_start_date + timedelta(days=6)
|
||||||
|
if pay_record.get("window_start"):
|
||||||
|
week_start_date = datetime.strptime(
|
||||||
|
pay_record["window_start"], "%Y-%m-%d"
|
||||||
|
).date()
|
||||||
|
if pay_record.get("window_end"):
|
||||||
|
window_end = datetime.strptime(pay_record["window_end"], "%Y-%m-%d").date()
|
||||||
lines = []
|
lines = []
|
||||||
for info in pay_record.get("totals", {}).values():
|
for info in pay_record.get("totals", {}).values():
|
||||||
ext = str(info.get("extension") or "").strip()
|
ext = str(info.get("extension") or "").strip()
|
||||||
|
|
@ -185,7 +268,7 @@ def build_checkcomponents_payload(pay_record: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
"type": "checkcomponents",
|
"type": "checkcomponents",
|
||||||
"kind": KIND_AFTER_HOURS,
|
"kind": KIND_AFTER_HOURS,
|
||||||
"windowStart": week_start.isoformat(),
|
"windowStart": week_start_date.isoformat(),
|
||||||
"windowEnd": window_end.isoformat(),
|
"windowEnd": window_end.isoformat(),
|
||||||
"lines": lines,
|
"lines": lines,
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +289,10 @@ def _send_checkcomponents(pay_record: dict) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
|
if os.environ.get("STAGE", "prod") != "prod":
|
||||||
|
logger.info("Skipping weekly post because STAGE is not prod")
|
||||||
|
return {"skipped": "non_prod"}
|
||||||
|
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
|
|
||||||
# DST guard — same pattern as the 3CX scheduler
|
# DST guard — same pattern as the 3CX scheduler
|
||||||
|
|
@ -222,24 +309,22 @@ def handler(event, context):
|
||||||
schedule = ShiftSchedule()
|
schedule = ShiftSchedule()
|
||||||
slack = WebClient(token=bot_token)
|
slack = WebClient(token=bot_token)
|
||||||
|
|
||||||
# --- Previous week's pay summary ---
|
# --- Previous completed Sun–Sat pay summary ---
|
||||||
prev_monday = now - timedelta(days=7)
|
# Monday 7am is after Saturday night ends (Sunday 8am), so this week is closed.
|
||||||
prev_monday = prev_monday.replace(hour=0, minute=0, second=0, microsecond=0)
|
prev_sunday = week_start(now) - timedelta(days=7)
|
||||||
pay_record = _calculate_weekly_pay(schedule, prev_monday)
|
pay_record = _calculate_weekly_pay(schedule, prev_sunday)
|
||||||
|
|
||||||
pay_dm_user = os.environ.get("PAY_REPORT_USER")
|
pay_dm_user = os.environ.get("PAY_REPORT_USER")
|
||||||
|
|
||||||
week_key = prev_monday.strftime("%Y-%m-%d")
|
week_key = prev_sunday.strftime("%Y-%m-%d")
|
||||||
|
pay_record = _exclude_sent_dates(schedule, pay_record, week_key)
|
||||||
if pay_record["breakdown"]:
|
if pay_record["breakdown"]:
|
||||||
existing = schedule.get_pay_record(week_key)
|
existing = schedule.get_pay_record(week_key)
|
||||||
if existing and existing.get("checkcomponents_sent"):
|
if existing and existing.get("checkcomponents_sent"):
|
||||||
pay_record["checkcomponents_sent"] = True
|
pay_record["checkcomponents_sent"] = True
|
||||||
schedule.save_pay_record(week_key, pay_record)
|
schedule.save_pay_record(week_key, pay_record)
|
||||||
|
|
||||||
prev_sunday = prev_monday + timedelta(days=6)
|
week_label = _pay_week_label(pay_record)
|
||||||
week_label = (
|
|
||||||
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
|
|
||||||
)
|
|
||||||
pay_blocks = build_pay_summary_blocks(
|
pay_blocks = build_pay_summary_blocks(
|
||||||
week_label, pay_record["breakdown"], pay_record["totals"]
|
week_label, pay_record["breakdown"], pay_record["totals"]
|
||||||
)
|
)
|
||||||
|
|
@ -257,14 +342,14 @@ def handler(event, context):
|
||||||
else:
|
else:
|
||||||
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
|
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
|
||||||
|
|
||||||
# --- Two-week schedule (always starts on Monday of this week) ---
|
# --- Two-week schedule (starts on Sunday of this Sun–Sat week) ---
|
||||||
this_monday = now - timedelta(days=now.weekday())
|
this_sunday = week_start(now)
|
||||||
week_start = this_monday.strftime("%Y-%m-%d")
|
schedule_week = this_sunday.strftime("%Y-%m-%d")
|
||||||
blocks = build_week_schedule(schedule, start_date=this_monday)
|
blocks = build_week_schedule(schedule, start_date=this_sunday)
|
||||||
|
|
||||||
end_date = this_monday + timedelta(days=13)
|
end_date = this_sunday + timedelta(days=13)
|
||||||
fallback_text = (
|
fallback_text = (
|
||||||
f"After-Hours Schedule — {this_monday.strftime('%b %-d')} "
|
f"After-Hours Schedule — {this_sunday.strftime('%b %-d')} "
|
||||||
f"to {end_date.strftime('%b %-d')}"
|
f"to {end_date.strftime('%b %-d')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -300,7 +385,7 @@ def handler(event, context):
|
||||||
# deleted) ts, no-op its delete, and post a *second* schedule, orphaning
|
# deleted) ts, no-op its delete, and post a *second* schedule, orphaning
|
||||||
# this one at the bottom of the channel.
|
# this one at the bottom of the channel.
|
||||||
try:
|
try:
|
||||||
schedule.save_schedule_post(channel_id, message_ts, week_start)
|
schedule.save_schedule_post(channel_id, message_ts, schedule_week)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to persist schedule post %s; rolling it back to avoid an "
|
"Failed to persist schedule post %s; rolling it back to avoid an "
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
boto3>=1.43.98
|
boto3>=1.43.99
|
||||||
slack_sdk>=3.44.1,<4.0
|
slack_sdk>=3.44.1,<4.0
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,16 @@ check "correct_account" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check "dev_has_no_external_side_effects" {
|
||||||
|
assert {
|
||||||
|
condition = local.is_prod || alltrue([
|
||||||
|
for name in ["SHIFT_CHANNEL", "QUEUE_NUMBER", "PAY_REPORT_USER", "TCX_SECRET_PREFIX"] :
|
||||||
|
one([for env in local.api_environment : env.value if env.name == name]) == ""
|
||||||
|
])
|
||||||
|
error_message = "Non-prod must leave SHIFT_CHANNEL, QUEUE_NUMBER, PAY_REPORT_USER, and TCX_SECRET_PREFIX empty so the task cannot post to Slack or move the production phone queue."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
check "dev_has_no_paychex" {
|
check "dev_has_no_paychex" {
|
||||||
assert {
|
assert {
|
||||||
condition = local.is_prod || var.checkcomponents_queue_url == ""
|
condition = local.is_prod || var.checkcomponents_queue_url == ""
|
||||||
|
|
|
||||||
|
|
@ -182,9 +182,9 @@ locals {
|
||||||
{ name = "SHIFT_TABLE", value = aws_dynamodb_table.shifts.name },
|
{ name = "SHIFT_TABLE", value = aws_dynamodb_table.shifts.name },
|
||||||
{ name = "SLACK_BOT_TOKEN_SECRET", value = "afterhours-shift-manager/slack-bot-token" },
|
{ name = "SLACK_BOT_TOKEN_SECRET", value = "afterhours-shift-manager/slack-bot-token" },
|
||||||
{ name = "SLACK_SIGNING_SECRET", value = "afterhours-shift-manager/slack-signing-secret" },
|
{ name = "SLACK_SIGNING_SECRET", value = "afterhours-shift-manager/slack-signing-secret" },
|
||||||
{ name = "SHIFT_CHANNEL", value = var.shift_channel },
|
{ name = "SHIFT_CHANNEL", value = local.is_prod ? var.shift_channel : "" },
|
||||||
{ name = "TCX_SECRET_PREFIX", value = "afterhours-shift-manager/3cx-" },
|
{ name = "TCX_SECRET_PREFIX", value = local.is_prod ? "afterhours-shift-manager/3cx-" : "" },
|
||||||
{ name = "QUEUE_NUMBER", value = var.queue_number },
|
{ name = "QUEUE_NUMBER", value = local.is_prod ? var.queue_number : "" },
|
||||||
{ name = "TZ", value = var.timezone },
|
{ name = "TZ", value = var.timezone },
|
||||||
{ name = "HOLIDAY_SCHEDULER_ROLE_ARN", value = local.holiday_scheduler_role_arn },
|
{ name = "HOLIDAY_SCHEDULER_ROLE_ARN", value = local.holiday_scheduler_role_arn },
|
||||||
{ name = "HOLIDAY_SCHEDULE_GROUP", value = "default" },
|
{ name = "HOLIDAY_SCHEDULE_GROUP", value = "default" },
|
||||||
|
|
@ -195,7 +195,7 @@ locals {
|
||||||
var.portal_cognito_issuer != "" && var.portal_cognito_audience != "" ? [{ issuer = var.portal_cognito_issuer, audience = var.portal_cognito_audience }] : [],
|
var.portal_cognito_issuer != "" && var.portal_cognito_audience != "" ? [{ issuer = var.portal_cognito_issuer, audience = var.portal_cognito_audience }] : [],
|
||||||
var.portal_cognito_extra_trust,
|
var.portal_cognito_extra_trust,
|
||||||
)) },
|
)) },
|
||||||
{ name = "PAY_REPORT_USER", value = var.pay_report_user },
|
{ name = "PAY_REPORT_USER", value = local.is_prod ? var.pay_report_user : "" },
|
||||||
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
|
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
|
||||||
{ name = "ROSTER_API_TOKEN_SECRET", value = "afterhours-shift-manager/roster-api-token" },
|
{ name = "ROSTER_API_TOKEN_SECRET", value = "afterhours-shift-manager/roster-api-token" },
|
||||||
{ name = "SYNC_GROUP", value = "DEFAULT" },
|
{ name = "SYNC_GROUP", value = "DEFAULT" },
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ resource "aws_iam_role_policy" "holiday_scheduler" {
|
||||||
locals {
|
locals {
|
||||||
job_schedules = {
|
job_schedules = {
|
||||||
weekly-post = {
|
weekly-post = {
|
||||||
description = "Post weekly schedule Monday 7am Eastern"
|
description = "Post weekly schedule Monday 7am Eastern; closes the Sun-Sat pay week"
|
||||||
schedule = "cron(0 7 ? * MON *)"
|
schedule = "cron(0 7 ? * MON *)"
|
||||||
event = "weekly_post"
|
event = "weekly_post"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ def test_ecs_task_boundary_uses_static_arns():
|
||||||
|
|
||||||
def test_deploy_api_workflow_exists():
|
def test_deploy_api_workflow_exists():
|
||||||
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
|
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
|
||||||
pin = "cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16"
|
pin = "cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19"
|
||||||
assert deploy_api.count(pin) == 2
|
assert deploy_api.count(pin) == 2
|
||||||
assert "ssm-prefix: /afterhours-shift-manager/deploy" in deploy_api
|
assert "ssm-prefix: /afterhours-shift-manager/deploy" in deploy_api
|
||||||
assert "docker-platform: linux/arm64" in deploy_api
|
assert "docker-platform: linux/arm64" in deploy_api
|
||||||
|
|
@ -119,9 +119,9 @@ def test_in_repo_hcptf_roles():
|
||||||
|
|
||||||
def test_ci_runs_pytest_and_terraform_validate():
|
def test_ci_runs_pytest_and_terraform_validate():
|
||||||
assert "ci-python-sam" not in CI
|
assert "ci-python-sam" not in CI
|
||||||
assert "ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
|
assert "ci-python-app.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI
|
||||||
assert "ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
|
assert "ci-terraform.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI
|
||||||
assert "ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
|
assert "ci-autofix.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI
|
||||||
assert "name: ci-complete" in CI
|
assert "name: ci-complete" in CI
|
||||||
assert "pytest" in CI
|
assert "pytest" in CI
|
||||||
assert "terraform fmt -check" not in CI
|
assert "terraform fmt -check" not in CI
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ class TestBuildWeekSchedule:
|
||||||
blocks = build_week_schedule(schedule)
|
blocks = build_week_schedule(schedule)
|
||||||
assert blocks[0]["type"] == "header"
|
assert blocks[0]["type"] == "header"
|
||||||
assert "After-Hours Schedule" in blocks[0]["text"]["text"]
|
assert "After-Hours Schedule" in blocks[0]["text"]["text"]
|
||||||
|
# Monday 2026-06-01 belongs to the week that opened Sunday May 31.
|
||||||
|
assert blocks[0]["text"]["text"] == "After-Hours Schedule — May 31 to Jun 13"
|
||||||
assert blocks[1]["type"] == "section"
|
assert blocks[1]["type"] == "section"
|
||||||
|
|
||||||
@freeze_time("2026-06-01 12:00:00")
|
@freeze_time("2026-06-01 12:00:00")
|
||||||
|
|
@ -257,6 +259,37 @@ class TestBuildSwapRequestBlocks:
|
||||||
assert elements[0]["style"] == "primary" # Accept
|
assert elements[0]["style"] == "primary" # Accept
|
||||||
assert elements[1]["style"] == "danger" # Decline
|
assert elements[1]["style"] == "danger" # Decline
|
||||||
|
|
||||||
|
def test_note_is_appended_without_changing_actions(self):
|
||||||
|
blocks = build_swap_request_blocks(
|
||||||
|
"U_REQ", "2026-06-03", "night", note="Family <commitment> & more"
|
||||||
|
)
|
||||||
|
assert "Note: Family <commitment> & more" in blocks[0]["text"]["text"]
|
||||||
|
ids = self._action_ids(blocks)
|
||||||
|
assert ids == ["swap_accept_2026-06-03", "swap_decline_2026-06-03"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dm_swap_fallback_escapes_the_note(monkeypatch):
|
||||||
|
from shared import side_effects as effects
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_slack_call(_method, _token, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(effects, "slack_call", fake_slack_call)
|
||||||
|
assert effects.dm_swap_request(
|
||||||
|
"tok",
|
||||||
|
"U_REQ",
|
||||||
|
"U_TGT",
|
||||||
|
"2026-06-03",
|
||||||
|
"night",
|
||||||
|
"Alice",
|
||||||
|
note="ping <@U_ADMIN>",
|
||||||
|
)
|
||||||
|
assert "Note: ping <@U_ADMIN>" in captured["text"]
|
||||||
|
assert "<@U_ADMIN>" not in captured["text"]
|
||||||
|
|
||||||
|
|
||||||
class TestBuildSwapResolvedBlocks:
|
class TestBuildSwapResolvedBlocks:
|
||||||
def test_renders_text_no_buttons(self):
|
def test_renders_text_no_buttons(self):
|
||||||
|
|
|
||||||
54
tests/shared/test_effects.py
Normal file
54
tests/shared/test_effects.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""Non-prod must not call Slack or 3CX."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from shared.effects import prod_side_effects_enabled
|
||||||
|
from shared.holiday_flow import activate
|
||||||
|
from shared.side_effects import slack_call, slack_token, update_3cx_routing
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_stage_keeps_prod_effects(monkeypatch):
|
||||||
|
monkeypatch.delenv("STAGE", raising=False)
|
||||||
|
assert prod_side_effects_enabled() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_stage_disables_effects(monkeypatch):
|
||||||
|
monkeypatch.setenv("STAGE", "dev")
|
||||||
|
assert prod_side_effects_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_slack_token_does_not_read_secrets(monkeypatch):
|
||||||
|
monkeypatch.setenv("STAGE", "dev")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token"
|
||||||
|
)
|
||||||
|
read = MagicMock(side_effect=AssertionError("secret read"))
|
||||||
|
monkeypatch.setattr("shared.side_effects.get_secret", read)
|
||||||
|
assert slack_token() is None
|
||||||
|
read.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_slack_call_does_not_post(monkeypatch):
|
||||||
|
monkeypatch.setenv("STAGE", "dev")
|
||||||
|
post = MagicMock(side_effect=AssertionError("slack post"))
|
||||||
|
monkeypatch.setattr("shared.side_effects.requests.post", post)
|
||||||
|
assert slack_call("chat.postMessage", "xoxb-token", channel="C0APATP612N") is False
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_skips_3cx_even_when_queue_is_configured(monkeypatch):
|
||||||
|
monkeypatch.setenv("STAGE", "dev")
|
||||||
|
monkeypatch.setenv("QUEUE_NUMBER", "801")
|
||||||
|
monkeypatch.setenv("TCX_SECRET_PREFIX", "afterhours-shift-manager/3cx-")
|
||||||
|
route = MagicMock(side_effect=AssertionError("3cx"))
|
||||||
|
monkeypatch.setattr("shared.side_effects.update_queue_routing", route)
|
||||||
|
update_3cx_routing("101")
|
||||||
|
route.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_holiday_activate_does_not_build_a_client(monkeypatch):
|
||||||
|
monkeypatch.setenv("STAGE", "dev")
|
||||||
|
factory = MagicMock(side_effect=AssertionError("3cx client"))
|
||||||
|
result = activate(MagicMock(), "2026-07-04", client_factory=factory)
|
||||||
|
assert result["skipped"] == "non_prod"
|
||||||
|
factory.assert_not_called()
|
||||||
|
|
@ -75,6 +75,73 @@ def test_swap_creates_pending(schedule, seed, quiet_slack):
|
||||||
assert snap["pendingSwaps"][0]["incoming"] is True
|
assert snap["pendingSwaps"][0]["incoming"] is True
|
||||||
mine = snapshot(schedule, employee)
|
mine = snapshot(schedule, employee)
|
||||||
assert mine["pendingSwaps"][0]["incoming"] is False
|
assert mine["pendingSwaps"][0]["incoming"] is False
|
||||||
|
assert "note" not in pending
|
||||||
|
assert "note" not in snap["pendingSwaps"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_swap_stores_returns_and_dms_note(schedule, seed, quiet_slack, monkeypatch):
|
||||||
|
employee = _alice(schedule, seed)
|
||||||
|
seed.override("2026-06-12", "114", "Alice")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def capture(*args, **kwargs):
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(effects, "dm_swap_request", capture)
|
||||||
|
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
|
||||||
|
swap(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
"2026-06-12",
|
||||||
|
"115",
|
||||||
|
"night",
|
||||||
|
note=" Family commitment ",
|
||||||
|
)
|
||||||
|
pending = schedule.get_swap("2026-06-12", "night")
|
||||||
|
assert pending["note"] == "Family commitment"
|
||||||
|
snap = snapshot(schedule, employee)
|
||||||
|
assert snap["pendingSwaps"][0]["note"] == "Family commitment"
|
||||||
|
assert calls[0][1]["note"] == "Family commitment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_swap_accepts_note_that_trims_to_500(schedule, seed, quiet_slack):
|
||||||
|
employee = _alice(schedule, seed)
|
||||||
|
seed.override("2026-06-12", "114", "Alice")
|
||||||
|
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
|
||||||
|
swap(schedule, employee, "2026-06-12", "115", "night", note=f" {'a' * 500} ")
|
||||||
|
assert schedule.get_swap("2026-06-12", "night")["note"] == "a" * 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_swap_omits_blank_note(schedule, seed, quiet_slack):
|
||||||
|
employee = _alice(schedule, seed)
|
||||||
|
seed.override("2026-06-12", "114", "Alice")
|
||||||
|
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
|
||||||
|
swap(schedule, employee, "2026-06-12", "115", "night", note=" ")
|
||||||
|
pending = schedule.get_swap("2026-06-12", "night")
|
||||||
|
assert "note" not in pending
|
||||||
|
snap = snapshot(schedule, employee)
|
||||||
|
assert "note" not in snap["pendingSwaps"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_swap_rejects_long_note(schedule, seed, quiet_slack):
|
||||||
|
employee = _alice(schedule, seed)
|
||||||
|
seed.override("2026-06-12", "114", "Alice")
|
||||||
|
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
|
||||||
|
with pytest.raises(ActionError) as err:
|
||||||
|
swap(schedule, employee, "2026-06-12", "115", "night", note="a" * 501)
|
||||||
|
assert err.value.code == "NOTE_TOO_LONG"
|
||||||
|
assert schedule.get_swap("2026-06-12", "night") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_swap_rejects_non_string_note(schedule, seed, quiet_slack):
|
||||||
|
employee = _alice(schedule, seed)
|
||||||
|
seed.override("2026-06-12", "114", "Alice")
|
||||||
|
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
|
||||||
|
with pytest.raises(ActionError) as err:
|
||||||
|
swap(schedule, employee, "2026-06-12", "115", "night", note=12)
|
||||||
|
assert err.value.code == "INVALID_NOTE"
|
||||||
|
assert schedule.get_swap("2026-06-12", "night") is None
|
||||||
|
|
||||||
|
|
||||||
def test_late_pickup_creates_request(schedule, seed, quiet_slack):
|
def test_late_pickup_creates_request(schedule, seed, quiet_slack):
|
||||||
|
|
@ -95,6 +162,7 @@ def test_snapshot_includes_weekday_holiday_day_shift(schedule, seed):
|
||||||
)
|
)
|
||||||
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
|
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
|
||||||
snap = snapshot(schedule, employee)
|
snap = snapshot(schedule, employee)
|
||||||
|
assert snap["weekStart"] == "2026-06-07"
|
||||||
wednesday = next(day for day in snap["days"] if day["date"] == "2026-06-10")
|
wednesday = next(day for day in snap["days"] if day["date"] == "2026-06-10")
|
||||||
assert wednesday["slots"][0]["kind"] == "holiday"
|
assert wednesday["slots"][0]["kind"] == "holiday"
|
||||||
assert wednesday["slots"][0]["shiftType"] == "day"
|
assert wednesday["slots"][0]["shiftType"] == "day"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from shared.schedule import (
|
||||||
FALLBACK_EXTENSION,
|
FALLBACK_EXTENSION,
|
||||||
ShiftSchedule,
|
ShiftSchedule,
|
||||||
determine_shift_type,
|
determine_shift_type,
|
||||||
|
week_start,
|
||||||
)
|
)
|
||||||
|
|
||||||
ET = ZoneInfo("America/New_York")
|
ET = ZoneInfo("America/New_York")
|
||||||
|
|
@ -22,6 +23,20 @@ WED = "2026-06-03"
|
||||||
HOL = "2026-07-04" # holiday date (a Saturday)
|
HOL = "2026-07-04" # holiday date (a Saturday)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeekStart:
|
||||||
|
def test_sunday_opens_the_week(self):
|
||||||
|
sunday = datetime(2026, 6, 7, 15, tzinfo=ET)
|
||||||
|
assert week_start(sunday).strftime("%Y-%m-%d") == "2026-06-07"
|
||||||
|
|
||||||
|
def test_monday_belongs_to_the_sunday_before(self):
|
||||||
|
monday = datetime(2026, 6, 8, 8, tzinfo=ET)
|
||||||
|
assert week_start(monday).strftime("%Y-%m-%d") == "2026-06-07"
|
||||||
|
|
||||||
|
def test_saturday_stays_in_the_week_that_started_sunday(self):
|
||||||
|
saturday = datetime(2026, 6, 13, 23, tzinfo=ET)
|
||||||
|
assert week_start(saturday).strftime("%Y-%m-%d") == "2026-06-07"
|
||||||
|
|
||||||
|
|
||||||
class TestDetermineShiftType:
|
class TestDetermineShiftType:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"dt,expected",
|
"dt,expected",
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,9 @@ def test_oauth_client_refreshes_expired_token():
|
||||||
queue = client.get_queue("801")
|
queue = client.get_queue("801")
|
||||||
assert queue["Id"] == 83
|
assert queue["Id"] == 83
|
||||||
assert client.session.headers["Authorization"] == "Bearer tok-2"
|
assert client.session.headers["Authorization"] == "Bearer tok-2"
|
||||||
assert len(_token_posts()) == 2
|
posts = _token_posts()
|
||||||
|
assert len(posts) == 2
|
||||||
|
assert "Authorization" not in posts[1].request.headers
|
||||||
tcx._oauth_clients.clear()
|
tcx._oauth_clients.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Tests for slack-bot module-level helpers."""
|
"""Tests for slack-bot module-level helpers."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
|
|
||||||
# Monday 2026-06-01 08:00 ET → weekday, active shift is night.
|
# Monday 2026-06-01 08:00 ET → weekday, active shift is night.
|
||||||
|
|
@ -35,6 +37,53 @@ class TestShiftTiming:
|
||||||
assert slackbot_app._within_drop_lock("2026-06-02", "night") is True
|
assert slackbot_app._within_drop_lock("2026-06-02", "night") is True
|
||||||
|
|
||||||
|
|
||||||
|
def _save_pay(schedule, week_key, week_start):
|
||||||
|
schedule.save_pay_record(
|
||||||
|
week_key,
|
||||||
|
{
|
||||||
|
"week_start": week_start,
|
||||||
|
"breakdown": [
|
||||||
|
{
|
||||||
|
"date": week_start,
|
||||||
|
"date_label": "day",
|
||||||
|
"day": "Day",
|
||||||
|
"name": "Alice",
|
||||||
|
"extension": "114",
|
||||||
|
"rate": 50,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totals": {
|
||||||
|
"Alice": {
|
||||||
|
"shifts": 1,
|
||||||
|
"total": 50,
|
||||||
|
"extension": "114",
|
||||||
|
"rate": 50,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShowPay:
|
||||||
|
@freeze_time("2026-06-08 12:00:00") # Monday 08:00 ET
|
||||||
|
def test_falls_back_to_legacy_monday_key(self, slackbot_app, schedule):
|
||||||
|
_save_pay(schedule, "2026-06-01", "2026-06-01")
|
||||||
|
respond = MagicMock()
|
||||||
|
slackbot_app._show_pay(respond, schedule)
|
||||||
|
header = respond.call_args.kwargs["blocks"][0]["text"]["text"]
|
||||||
|
assert "Jun 1 to Jun 7" in header
|
||||||
|
|
||||||
|
@freeze_time("2026-06-14 12:00:00") # Sunday 08:00 ET
|
||||||
|
def test_sunday_shows_prior_close_before_monday_job(self, slackbot_app, schedule):
|
||||||
|
# Monday 2026-06-08 saved the week of Sun 2026-05-31. The next close
|
||||||
|
# (Sun 2026-06-07) is not written until Monday 2026-06-15.
|
||||||
|
_save_pay(schedule, "2026-05-31", "2026-05-31")
|
||||||
|
respond = MagicMock()
|
||||||
|
slackbot_app._show_pay(respond, schedule)
|
||||||
|
header = respond.call_args.kwargs["blocks"][0]["text"]["text"]
|
||||||
|
assert "May 31 to Jun 6" in header
|
||||||
|
|
||||||
|
|
||||||
class TestIsToday:
|
class TestIsToday:
|
||||||
@freeze_time(MON)
|
@freeze_time(MON)
|
||||||
def test_true_for_today(self, slackbot_app):
|
def test_true_for_today(self, slackbot_app):
|
||||||
|
|
|
||||||
|
|
@ -55,21 +55,23 @@ def test_posts_schedule_and_saves_post(weeklypost_app, schedule, seed, slack, en
|
||||||
assert result["message_ts"] == "999.000"
|
assert result["message_ts"] == "999.000"
|
||||||
# The new schedule post was persisted for next week's cleanup.
|
# The new schedule post was persisted for next week's cleanup.
|
||||||
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
|
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
|
||||||
|
# Monday 2026-06-08 is inside the week that opened Sunday 2026-06-07.
|
||||||
|
assert schedule.get_schedule_post("C_TEST")["week_start"] == "2026-06-07"
|
||||||
slack.chat_postMessage.assert_called()
|
slack.chat_postMessage.assert_called()
|
||||||
sqs.send_message.assert_not_called()
|
sqs.send_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(MON_0800)
|
@freeze_time(MON_0800)
|
||||||
def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env, sqs):
|
def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env, sqs):
|
||||||
# Previous week (Mon 2026-06-01) had Alice on the Monday night shift.
|
# Previous Sun–Sat week includes Monday 2026-06-01, Alice's night shift.
|
||||||
seed.config(shift_rate="50")
|
seed.config(shift_rate="50")
|
||||||
seed.weekly("Monday", "114", "Alice")
|
seed.weekly("Monday", "114", "Alice")
|
||||||
|
|
||||||
result = weeklypost_app.handler({"force": True}, None)
|
result = weeklypost_app.handler({"force": True}, None)
|
||||||
|
|
||||||
assert result["pay_calculated"] is True
|
assert result["pay_calculated"] is True
|
||||||
# Pay record saved under previous Monday's key.
|
# Pay record saved under the previous Sunday (Sun 2026-05-31 through Sat 2026-06-06).
|
||||||
assert schedule.get_pay_record("2026-06-01") is not None
|
assert schedule.get_pay_record("2026-05-31") is not None
|
||||||
# A DM went to the configured pay-report user.
|
# A DM went to the configured pay-report user.
|
||||||
dm_calls = [
|
dm_calls = [
|
||||||
c
|
c
|
||||||
|
|
@ -81,11 +83,50 @@ def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env, sqs)
|
||||||
body = json.loads(sqs.send_message.call_args.kwargs["MessageBody"])
|
body = json.loads(sqs.send_message.call_args.kwargs["MessageBody"])
|
||||||
assert body["type"] == "checkcomponents"
|
assert body["type"] == "checkcomponents"
|
||||||
assert body["kind"] == "after_hours"
|
assert body["kind"] == "after_hours"
|
||||||
assert body["windowStart"] == "2026-06-01"
|
assert body["windowStart"] == "2026-05-31"
|
||||||
assert body["windowEnd"] == "2026-06-07"
|
assert body["windowEnd"] == "2026-06-06"
|
||||||
assert "payPeriodId" not in body
|
assert "payPeriodId" not in body
|
||||||
assert body["lines"] == [{"extension": "114", "amount": "50.00"}]
|
assert body["lines"] == [{"extension": "114", "amount": "50.00"}]
|
||||||
assert schedule.get_pay_record("2026-06-01")["checkcomponents_sent"] is True
|
assert schedule.get_pay_record("2026-05-31")["checkcomponents_sent"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(MON_0800)
|
||||||
|
def test_cutover_omits_sunday_already_sent(
|
||||||
|
weeklypost_app, schedule, seed, slack, env, sqs
|
||||||
|
):
|
||||||
|
# Last ISO close (Mon 2026-05-25 through Sun 2026-05-31) already sent Sunday.
|
||||||
|
# The new Sun–Sat close must drop that Sunday and report Mon–Sat.
|
||||||
|
seed.config(shift_rate="50")
|
||||||
|
seed.weekly("Monday", "114", "Alice")
|
||||||
|
seed.weekly("Sunday", "114", "Alice", shift_type="day")
|
||||||
|
schedule.save_pay_record(
|
||||||
|
"2026-05-25",
|
||||||
|
{
|
||||||
|
"week_start": "2026-05-25",
|
||||||
|
"checkcomponents_sent": True,
|
||||||
|
"breakdown": [
|
||||||
|
{
|
||||||
|
"date": "2026-05-31",
|
||||||
|
"name": "Alice",
|
||||||
|
"extension": "114",
|
||||||
|
"amount": "50",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totals": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
weeklypost_app.handler({"force": True}, None)
|
||||||
|
|
||||||
|
record = schedule.get_pay_record("2026-05-31")
|
||||||
|
dates = {line["date"] for line in record["breakdown"]}
|
||||||
|
assert dates == {"2026-06-01"}
|
||||||
|
assert record["window_start"] == "2026-06-01"
|
||||||
|
assert record["window_end"] == "2026-06-06"
|
||||||
|
body = json.loads(sqs.send_message.call_args.kwargs["MessageBody"])
|
||||||
|
assert body["windowStart"] == "2026-06-01"
|
||||||
|
assert body["windowEnd"] == "2026-06-06"
|
||||||
|
assert body["lines"] == [{"extension": "114", "amount": "50.00"}]
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(MON_0800)
|
@freeze_time(MON_0800)
|
||||||
|
|
@ -180,7 +221,7 @@ def test_empty_queue_url_skips_checkcomponents(
|
||||||
|
|
||||||
assert result["posted"] is True
|
assert result["posted"] is True
|
||||||
sqs.send_message.assert_not_called()
|
sqs.send_message.assert_not_called()
|
||||||
assert "checkcomponents_sent" not in (schedule.get_pay_record("2026-06-01") or {})
|
assert "checkcomponents_sent" not in (schedule.get_pay_record("2026-05-31") or {})
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(MON_0800)
|
@freeze_time(MON_0800)
|
||||||
|
|
@ -199,7 +240,7 @@ def test_save_failure_does_not_enqueue_checkcomponents(
|
||||||
weeklypost_app.handler({"force": True}, None)
|
weeklypost_app.handler({"force": True}, None)
|
||||||
|
|
||||||
sqs.send_message.assert_not_called()
|
sqs.send_message.assert_not_called()
|
||||||
assert "checkcomponents_sent" not in (schedule.get_pay_record("2026-06-01") or {})
|
assert "checkcomponents_sent" not in (schedule.get_pay_record("2026-05-31") or {})
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(MON_0800)
|
@freeze_time(MON_0800)
|
||||||
|
|
@ -213,7 +254,7 @@ def test_retry_does_not_resend_checkcomponents(
|
||||||
weeklypost_app.handler({"force": True}, None)
|
weeklypost_app.handler({"force": True}, None)
|
||||||
|
|
||||||
sqs.send_message.assert_called_once()
|
sqs.send_message.assert_called_once()
|
||||||
assert schedule.get_pay_record("2026-06-01")["checkcomponents_sent"] is True
|
assert schedule.get_pay_record("2026-05-31")["checkcomponents_sent"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_payload_skips_fallback_and_zero(weeklypost_app):
|
def test_payload_skips_fallback_and_zero(weeklypost_app):
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
# 2026-06-01 is a Monday — required so DAY_ORDER lines up with real weekdays.
|
# 2026-06-01 is a Monday. Day names come from the date, so a week may start any day.
|
||||||
WEEK_START = datetime(2026, 6, 1)
|
WEEK_START = datetime(2026, 6, 1)
|
||||||
|
# Sunday that opens the week containing Monday 2026-06-01.
|
||||||
|
SUNDAY_WEEK = datetime(2026, 5, 31)
|
||||||
|
|
||||||
|
|
||||||
class TestAddShiftToPay:
|
class TestAddShiftToPay:
|
||||||
|
|
@ -64,6 +66,31 @@ class TestCalculateWeeklyPay:
|
||||||
assert record["totals"]["Alice"]["shifts"] == 2
|
assert record["totals"]["Alice"]["shifts"] == 2
|
||||||
assert record["totals"]["Alice"]["total"] == Decimal("100")
|
assert record["totals"]["Alice"]["total"] == Decimal("100")
|
||||||
|
|
||||||
|
def test_saturday_night_stays_in_week_ending_saturday(
|
||||||
|
self, weeklypost_app, schedule, seed
|
||||||
|
):
|
||||||
|
# Sat 2026-06-06 night runs into Sunday morning and stays on Saturday.
|
||||||
|
# Sun 2026-06-07 day opens the next week. Sun 2026-05-31 day is this week.
|
||||||
|
seed.config(shift_rate="50")
|
||||||
|
seed.weekly("Saturday", "200", "Alice", shift_type="night")
|
||||||
|
seed.weekly("Sunday", "201", "Bob", shift_type="day")
|
||||||
|
record = weeklypost_app._calculate_weekly_pay(schedule, SUNDAY_WEEK)
|
||||||
|
by_date = {line["date"]: line for line in record["breakdown"]}
|
||||||
|
assert by_date["2026-06-06"]["name"] == "Alice"
|
||||||
|
assert by_date["2026-06-06"]["day"].startswith("Sat")
|
||||||
|
assert by_date["2026-05-31"]["name"] == "Bob"
|
||||||
|
assert "2026-06-07" not in by_date
|
||||||
|
|
||||||
|
def test_sunday_day_opens_the_next_week(self, weeklypost_app, schedule, seed):
|
||||||
|
seed.config(shift_rate="50")
|
||||||
|
seed.weekly("Saturday", "200", "Alice", shift_type="night")
|
||||||
|
seed.weekly("Sunday", "201", "Bob", shift_type="day")
|
||||||
|
record = weeklypost_app._calculate_weekly_pay(schedule, datetime(2026, 6, 7))
|
||||||
|
by_date = {line["date"]: line for line in record["breakdown"]}
|
||||||
|
assert by_date["2026-06-07"]["name"] == "Bob"
|
||||||
|
assert by_date["2026-06-13"]["name"] == "Alice"
|
||||||
|
assert "2026-06-06" not in by_date
|
||||||
|
|
||||||
def test_empty_week_has_no_breakdown(self, weeklypost_app, schedule, seed):
|
def test_empty_week_has_no_breakdown(self, weeklypost_app, schedule, seed):
|
||||||
seed.config(shift_rate="50")
|
seed.config(shift_rate="50")
|
||||||
record = weeklypost_app._calculate_weekly_pay(schedule, WEEK_START)
|
record = weeklypost_app._calculate_weekly_pay(schedule, WEEK_START)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue