mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 05:51:58 +00:00
Compare commits
24 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b39c8b27ea | ||
|
|
f2ef1df881 | ||
|
|
2fe7133411 | ||
|
|
cfa14c4240 | ||
|
|
3bd98aad8d | ||
|
|
7943207e97 | ||
|
|
c15ea8cee1 | ||
|
|
69359df69d | ||
|
|
bf1f3ea182 | ||
|
|
cc506f3c1f | ||
|
|
517e404e75 | ||
|
|
aa15277112 | ||
|
|
4d1c79b110 | ||
|
|
fdf595ea19 | ||
|
|
f7957436bd | ||
|
|
f632020b20 | ||
|
|
7574fc471a | ||
|
|
63b31fcdc3 | ||
|
|
449cc10b9f | ||
|
|
78f6d1dbe4 | ||
|
|
77780899c2 | ||
|
|
d7ad49d00f | ||
|
|
fb3a181e0c | ||
|
|
596e949eef |
59 changed files with 3127 additions and 952 deletions
32
.github/workflows/ci-terraform.yaml
vendored
32
.github/workflows/ci-terraform.yaml
vendored
|
|
@ -1,32 +0,0 @@
|
||||||
name: Terraform CI
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- "terraform/**"
|
|
||||||
- ".github/workflows/ci-terraform.yaml"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
terraform:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: terraform
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
|
|
||||||
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
|
||||||
with:
|
|
||||||
terraform_version: "1.9.8"
|
|
||||||
|
|
||||||
- name: Terraform fmt
|
|
||||||
run: terraform fmt -check -recursive
|
|
||||||
|
|
||||||
- name: Terraform init
|
|
||||||
run: terraform init -backend=false
|
|
||||||
|
|
||||||
- name: Terraform validate
|
|
||||||
run: terraform validate
|
|
||||||
59
.github/workflows/ci.yml
vendored
59
.github/workflows/ci.yml
vendored
|
|
@ -1,21 +1,38 @@
|
||||||
name: CI
|
name: CI
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, hotfix/**, release/**]
|
||||||
merge_group:
|
merge_group:
|
||||||
|
push:
|
||||||
|
branches: [hotfix/**, release/**]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
autofix:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
presets: ruff,terraform
|
||||||
|
terraform-version: "1.9.8"
|
||||||
|
node-version: "24.19.0"
|
||||||
|
format-command: npm run format:templates:write
|
||||||
|
lint-fix-command: npx eslint "src/server/templates/*.js" --fix
|
||||||
|
|
||||||
|
lint:
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
with:
|
with:
|
||||||
python-version: "3.12.14"
|
python-version: "3.12.14"
|
||||||
requirements: "requirements-api.txt"
|
|
||||||
collect-only: false
|
|
||||||
|
|
||||||
template-js:
|
template-js:
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -34,7 +51,12 @@ jobs:
|
||||||
- name: Check template JavaScript
|
- name: Check template JavaScript
|
||||||
run: npm run check:templates
|
run: npm run check:templates
|
||||||
|
|
||||||
|
- name: Lint OpenAPI
|
||||||
|
run: npm run openapi:lint
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -60,3 +82,30 @@ jobs:
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pytest
|
run: pytest
|
||||||
|
|
||||||
|
terraform:
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
|
with:
|
||||||
|
terraform-version: "1.9.8"
|
||||||
|
|
||||||
|
ci-complete:
|
||||||
|
name: ci-complete
|
||||||
|
needs: [autofix, lint, template-js, test, terraform]
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Require portions
|
||||||
|
env:
|
||||||
|
LINT: ${{ needs.lint.result }}
|
||||||
|
TEMPLATE_JS: ${{ needs.template-js.result }}
|
||||||
|
TEST: ${{ needs.test.result }}
|
||||||
|
TERRAFORM: ${{ needs.terraform.result }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test "${LINT}" = success
|
||||||
|
test "${TEMPLATE_JS}" = success
|
||||||
|
test "${TEST}" = success
|
||||||
|
test "${TERRAFORM}" = success
|
||||||
|
|
|
||||||
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@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
|
|
|
||||||
221
.github/workflows/deploy-api.yaml
vendored
221
.github/workflows/deploy-api.yaml
vendored
|
|
@ -1,8 +1,8 @@
|
||||||
name: Deploy API
|
name: Deploy API
|
||||||
|
|
||||||
# Fargate image CD (PLAT-215). GitHub Actions builds the Flask image, pushes
|
# Fargate image CD. GitHub Actions builds the Flask image, pushes to ECR,
|
||||||
# to ECR, and registers a new task definition. Terraform owns the cluster,
|
# and registers a new task definition. Terraform owns the cluster, service,
|
||||||
# service, ALB, and ignores container_definitions / task_definition.
|
# ALB, and ignores container_definitions / task_definition.
|
||||||
#
|
#
|
||||||
# push to main -> dev, at github.sha
|
# push to main -> dev, at github.sha
|
||||||
# release: published -> prod, at the release tag
|
# release: published -> prod, at the release tag
|
||||||
|
|
@ -18,9 +18,7 @@ on:
|
||||||
- "terraform/**"
|
- "terraform/**"
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
- "*.md"
|
- "*.md"
|
||||||
- ".github/workflows/weekly-menu.yml"
|
|
||||||
- ".github/workflows/ci.yml"
|
- ".github/workflows/ci.yml"
|
||||||
- ".github/workflows/ci-terraform.yaml"
|
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
@ -40,198 +38,33 @@ permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
target:
|
deploy-dev:
|
||||||
name: Resolve target
|
name: Deploy API to dev
|
||||||
runs-on: ubuntu-latest
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
|
||||||
timeout-minutes: 5
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
outputs:
|
|
||||||
environment: ${{ steps.resolve.outputs.environment }}
|
|
||||||
ref: ${{ steps.resolve.outputs.ref }}
|
|
||||||
steps:
|
|
||||||
- id: resolve
|
|
||||||
env:
|
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
|
||||||
GITHUB_REF_NAME_IN: ${{ github.ref }}
|
|
||||||
GITHUB_SHA_IN: ${{ github.sha }}
|
|
||||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
INPUT_ENVIRONMENT: ${{ inputs.environment }}
|
|
||||||
INPUT_REF: ${{ inputs.ref }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
case "${EVENT_NAME}" in
|
|
||||||
push)
|
|
||||||
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
|
|
||||||
echo "push deploys only run from main" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
environment=dev
|
|
||||||
ref="${GITHUB_SHA_IN}"
|
|
||||||
;;
|
|
||||||
release)
|
|
||||||
environment=prod
|
|
||||||
ref="${RELEASE_TAG}"
|
|
||||||
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
|
|
||||||
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
|
|
||||||
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
workflow_dispatch)
|
|
||||||
environment="${INPUT_ENVIRONMENT}"
|
|
||||||
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "unsupported event ${EVENT_NAME}" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
{
|
|
||||||
echo "environment=${environment}"
|
|
||||||
echo "ref=${ref}"
|
|
||||||
} >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "Deploying ${ref} to ${environment}"
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy API to ${{ needs.target.outputs.environment }}
|
|
||||||
needs: target
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
environment: ${{ needs.target.outputs.environment }}
|
|
||||||
concurrency:
|
|
||||||
group: deploy-api-${{ needs.target.outputs.environment }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
env:
|
secrets: inherit
|
||||||
AWS_REGION: us-east-1
|
|
||||||
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.target.outputs.ref }}
|
environment: dev
|
||||||
persist-credentials: false
|
ref: ${{ inputs.ref }}
|
||||||
|
ssm-prefix: /meal-order-manager/deploy
|
||||||
|
docker-platform: linux/amd64
|
||||||
|
extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}'
|
||||||
|
|
||||||
- name: Resolve commit
|
deploy-prod:
|
||||||
id: commit
|
name: Deploy API to prod
|
||||||
run: |
|
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
|
||||||
set -euo pipefail
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
sha="$(git rev-parse HEAD)"
|
permissions:
|
||||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
contents: read
|
||||||
echo "Building ${sha}"
|
id-token: write
|
||||||
|
secrets: inherit
|
||||||
- name: Configure AWS credentials using OIDC
|
|
||||||
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
|
|
||||||
with:
|
with:
|
||||||
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
environment: prod
|
||||||
aws-region: us-east-1
|
ref: ${{ github.event.release.tag_name || inputs.ref }}
|
||||||
audience: sts.amazonaws.com
|
ssm-prefix: /meal-order-manager/deploy
|
||||||
|
docker-platform: linux/amd64
|
||||||
- name: Get deploy parameters
|
ship-gate: true
|
||||||
id: deploy
|
extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}'
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
get_param() {
|
|
||||||
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
|
|
||||||
}
|
|
||||||
CLUSTER=$(get_param /meal-order-manager/deploy/cluster)
|
|
||||||
SERVICE=$(get_param /meal-order-manager/deploy/service)
|
|
||||||
FAMILY=$(get_param /meal-order-manager/deploy/task-family)
|
|
||||||
ECR=$(get_param /meal-order-manager/deploy/ecr-repository)
|
|
||||||
CONTAINER=$(get_param /meal-order-manager/deploy/container-name)
|
|
||||||
API_URL=$(get_param /meal-order-manager/deploy/api-url)
|
|
||||||
{
|
|
||||||
echo "cluster=${CLUSTER}"
|
|
||||||
echo "service=${SERVICE}"
|
|
||||||
echo "family=${FAMILY}"
|
|
||||||
echo "ecr=${ECR}"
|
|
||||||
echo "container=${CONTAINER}"
|
|
||||||
echo "api_url=${API_URL}"
|
|
||||||
} >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
- name: Login to Amazon ECR
|
|
||||||
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
|
|
||||||
|
|
||||||
- name: Build and push image
|
|
||||||
env:
|
|
||||||
ECR: ${{ steps.deploy.outputs.ecr }}
|
|
||||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
|
||||||
ENVIRONMENT: ${{ needs.target.outputs.environment }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker build \
|
|
||||||
--build-arg "GIT_SHA=${GIT_SHA}" \
|
|
||||||
-t "${ECR}:${GIT_SHA}" \
|
|
||||||
-t "${ECR}:${ENVIRONMENT}" \
|
|
||||||
.
|
|
||||||
docker push "${ECR}:${GIT_SHA}"
|
|
||||||
docker push "${ECR}:${ENVIRONMENT}"
|
|
||||||
|
|
||||||
- name: Register task definition and update service
|
|
||||||
env:
|
|
||||||
CLUSTER: ${{ steps.deploy.outputs.cluster }}
|
|
||||||
SERVICE: ${{ steps.deploy.outputs.service }}
|
|
||||||
FAMILY: ${{ steps.deploy.outputs.family }}
|
|
||||||
CONTAINER: ${{ steps.deploy.outputs.container }}
|
|
||||||
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
|
|
||||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
aws ecs describe-task-definition \
|
|
||||||
--task-definition "${FAMILY}" \
|
|
||||||
--query taskDefinition \
|
|
||||||
--output json \
|
|
||||||
| python3 -c '
|
|
||||||
import json, os, sys
|
|
||||||
td = json.load(sys.stdin)
|
|
||||||
for key in (
|
|
||||||
"taskDefinitionArn",
|
|
||||||
"revision",
|
|
||||||
"status",
|
|
||||||
"requiresAttributes",
|
|
||||||
"compatibilities",
|
|
||||||
"registeredAt",
|
|
||||||
"registeredBy",
|
|
||||||
"deregisteredAt",
|
|
||||||
):
|
|
||||||
td.pop(key, None)
|
|
||||||
image = os.environ["IMAGE"]
|
|
||||||
sha = os.environ["GIT_SHA"]
|
|
||||||
name = os.environ["CONTAINER"]
|
|
||||||
for container in td["containerDefinitions"]:
|
|
||||||
if container["name"] != name:
|
|
||||||
continue
|
|
||||||
container["image"] = image
|
|
||||||
env = {item["name"]: item["value"] for item in container.get("environment", [])}
|
|
||||||
env["GIT_SHA"] = sha
|
|
||||||
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
|
|
||||||
container.pop("command", None)
|
|
||||||
json.dump(td, sys.stdout)
|
|
||||||
' > /tmp/task-def.json
|
|
||||||
REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
|
|
||||||
aws ecs update-service \
|
|
||||||
--cluster "${CLUSTER}" \
|
|
||||||
--service "${SERVICE}" \
|
|
||||||
--task-definition "${FAMILY}:${REV}" \
|
|
||||||
--force-new-deployment \
|
|
||||||
>/dev/null
|
|
||||||
aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
|
|
||||||
|
|
||||||
- name: Verify health SHA
|
|
||||||
env:
|
|
||||||
API_URL: ${{ steps.deploy.outputs.api_url }}
|
|
||||||
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for _ in 1 2 3 4 5 6; do
|
|
||||||
BODY="$(curl -fsS "${API_URL}/api/health" || true)"
|
|
||||||
echo "${BODY}"
|
|
||||||
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
echo "health SHA did not match ${EXPECTED_SHA}" >&2
|
|
||||||
exit 1
|
|
||||||
|
|
|
||||||
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@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
|
|
|
||||||
179
.github/workflows/weekly-menu.yml
vendored
179
.github/workflows/weekly-menu.yml
vendored
|
|
@ -1,179 +0,0 @@
|
||||||
name: Weekly Menu Scrape & Publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Monday 7:30am EST = 12:30 UTC
|
|
||||||
- cron: '30 12 * * 1'
|
|
||||||
# Monday 7:30am EDT = 11:30 UTC
|
|
||||||
- cron: '30 11 * * 1'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: weekly-menu
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
scrape-and-publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# A hung Playwright scrape would otherwise hold the weekly-menu concurrency
|
|
||||||
# group for the 360-minute default.
|
|
||||||
timeout-minutes: 30
|
|
||||||
env:
|
|
||||||
AWS_REGION: us-east-1
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Timezone guard
|
|
||||||
if: github.event_name == 'schedule'
|
|
||||||
env:
|
|
||||||
CRON: ${{ github.event.schedule }}
|
|
||||||
run: |
|
|
||||||
OFFSET=$(TZ='America/New_York' date +%z)
|
|
||||||
echo "Cron: $CRON | Eastern offset: $OFFSET"
|
|
||||||
if { [ "$OFFSET" = "-0400" ] && [ "$CRON" = "30 12 * * 1" ]; } || \
|
|
||||||
{ [ "$OFFSET" = "-0500" ] && [ "$CRON" = "30 11 * * 1" ]; }; then
|
|
||||||
echo "Wrong-timezone cron fired — skipping"
|
|
||||||
echo "SKIP_RUN=true" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
|
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
with:
|
|
||||||
python-version: '3.12.14'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
run: |
|
|
||||||
pip install -r requirements.txt
|
|
||||||
playwright install chromium --with-deps
|
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
|
|
||||||
with:
|
|
||||||
role-to-assume: ${{ secrets.AWS_WEEKLY_MENU_ROLE_ARN }}
|
|
||||||
aws-region: us-east-1
|
|
||||||
|
|
||||||
- name: Scrape menu
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
run: python3 src/scraper/scrape_menu.py
|
|
||||||
|
|
||||||
# Deploy targets come from Parameter Store, written by Terraform
|
|
||||||
# (terraform/ssm.tf). They replace the CloudFormation stack outputs this
|
|
||||||
# job used to read; there is no CloudFormation stack any more.
|
|
||||||
- name: Get deploy parameters
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
id: stack
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
get_param() {
|
|
||||||
aws ssm get-parameter --name "$1" --query 'Parameter.Value' --output text
|
|
||||||
}
|
|
||||||
API_URL=$(get_param /meal-order-manager/deploy/api-url)
|
|
||||||
FORM_BUCKET=$(get_param /meal-order-manager/deploy/form-bucket)
|
|
||||||
DIST_ID=$(get_param /meal-order-manager/deploy/distribution-id)
|
|
||||||
FORM_URL=$(get_param /meal-order-manager/deploy/form-url)
|
|
||||||
for v in "$API_URL" "$FORM_BUCKET" "$DIST_ID" "$FORM_URL"; do
|
|
||||||
if [ -z "$v" ] || [ "$v" = "None" ]; then
|
|
||||||
echo "A /meal-order-manager/deploy/* parameter is missing; has Terraform been applied?" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "api_url=$API_URL" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "form_bucket=$FORM_BUCKET" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "dist_id=$DIST_ID" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "form_url=$FORM_URL" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Get discount settings
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
id: discount
|
|
||||||
env:
|
|
||||||
API_URL: ${{ steps.stack.outputs.api_url }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
MEALS_PUBLISH_KEY=$(aws ssm get-parameter \
|
|
||||||
--name /meal-order-manager/publish-key \
|
|
||||||
--with-decryption \
|
|
||||||
--query 'Parameter.Value' \
|
|
||||||
--output text)
|
|
||||||
SETTINGS=$(MEALS_PUBLISH_KEY="$MEALS_PUBLISH_KEY" python3 scripts/upload_menu.py settings --api-url "$API_URL")
|
|
||||||
BULK=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["bulk_discount_percent"])' "$SETTINGS")
|
|
||||||
SUBSIDY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["company_subsidy_percent"])' "$SETTINGS")
|
|
||||||
echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "company_subsidy=$SUBSIDY" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Get Google Client ID
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
id: google
|
|
||||||
run: |
|
|
||||||
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
|
|
||||||
--name /meal-order-manager/google-client-id \
|
|
||||||
--query 'Parameter.Value' \
|
|
||||||
--output text)
|
|
||||||
if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then
|
|
||||||
echo "Google client ID is required for cloud form generation" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "client_id=$GOOGLE_CLIENT_ID" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Generate order form
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
env:
|
|
||||||
BULK_DISCOUNT: ${{ steps.discount.outputs.bulk_discount }}
|
|
||||||
COMPANY_SUBSIDY: ${{ steps.discount.outputs.company_subsidy }}
|
|
||||||
GOOGLE_CLIENT_ID: ${{ steps.google.outputs.client_id }}
|
|
||||||
run: |
|
|
||||||
# Relative /api paths so the form stays same-origin on CloudFront
|
|
||||||
# after the ALB origin swap. Do not bake the ALB DNS into HTML.
|
|
||||||
python3 src/server/generate_form.py \
|
|
||||||
--bulk-discount "$BULK_DISCOUNT" \
|
|
||||||
--company-subsidy "$COMPANY_SUBSIDY" \
|
|
||||||
--google-client-id "$GOOGLE_CLIENT_ID"
|
|
||||||
|
|
||||||
- name: Publish menu through API
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
env:
|
|
||||||
API_URL: ${{ steps.stack.outputs.api_url }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
MEALS_PUBLISH_KEY=$(aws ssm get-parameter \
|
|
||||||
--name /meal-order-manager/publish-key \
|
|
||||||
--with-decryption \
|
|
||||||
--query 'Parameter.Value' \
|
|
||||||
--output text)
|
|
||||||
MEALS_PUBLISH_KEY="$MEALS_PUBLISH_KEY" python3 scripts/upload_menu.py publish --api-url "$API_URL"
|
|
||||||
|
|
||||||
- name: Upload form to S3
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
env:
|
|
||||||
FORM_BUCKET: ${{ steps.stack.outputs.form_bucket }}
|
|
||||||
run: |
|
|
||||||
WEEK=$(date +%Y-W%U)
|
|
||||||
aws s3 cp "output/order-form-$WEEK.html" \
|
|
||||||
"s3://${FORM_BUCKET}/index.html" \
|
|
||||||
--content-type "text/html" \
|
|
||||||
--cache-control "no-cache"
|
|
||||||
aws s3 cp "output/order-form-$WEEK.html" \
|
|
||||||
"s3://${FORM_BUCKET}/archive/$WEEK.html" \
|
|
||||||
--content-type "text/html"
|
|
||||||
|
|
||||||
- name: Invalidate CloudFront cache
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
env:
|
|
||||||
DIST_ID: ${{ steps.stack.outputs.dist_id }}
|
|
||||||
run: |
|
|
||||||
aws cloudfront create-invalidation \
|
|
||||||
--distribution-id "$DIST_ID" \
|
|
||||||
--paths "/index.html"
|
|
||||||
|
|
||||||
- name: Notify Slack
|
|
||||||
if: env.SKIP_RUN != 'true'
|
|
||||||
env:
|
|
||||||
FORM_URL: ${{ steps.stack.outputs.form_url }}
|
|
||||||
run: python3 scripts/notify_slack.py "$FORM_URL"
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
extends: .github
|
|
||||||
32
.redocly.yaml
Normal file
32
.redocly.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Same Redocly recommended ruleset as internal-portal (DEV-223 / DEV-289).
|
||||||
|
# Recommended operation-2xx-response does not count 302. Login-style redirects
|
||||||
|
# succeed with 302, so that rule is replaced by operation-2xx-or-3xx-response
|
||||||
|
# at error. operation-4xx-response is promoted to error so missing 4xx fails CI.
|
||||||
|
extends:
|
||||||
|
- recommended
|
||||||
|
|
||||||
|
rules:
|
||||||
|
operation-2xx-response: off
|
||||||
|
operation-4xx-response: error
|
||||||
|
rule/operation-2xx-or-3xx-response:
|
||||||
|
subject:
|
||||||
|
type: Responses
|
||||||
|
message: Operation must define a 2XX or 3XX response.
|
||||||
|
severity: error
|
||||||
|
assertions:
|
||||||
|
requireAny:
|
||||||
|
- "200"
|
||||||
|
- "201"
|
||||||
|
- "202"
|
||||||
|
- "204"
|
||||||
|
- "301"
|
||||||
|
- "302"
|
||||||
|
- "303"
|
||||||
|
- "307"
|
||||||
|
- "308"
|
||||||
|
- "2XX"
|
||||||
|
- "3XX"
|
||||||
|
|
||||||
|
apis:
|
||||||
|
meals@v1:
|
||||||
|
root: openapi.yaml
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
"justification": "The meals API ALB is the CloudFront HTTP origin for orders.seahaven.com. TLS and WAF terminate at CloudFront. Restricting the security group to the CloudFront managed prefix list would block GitHub-hosted weekly-menu HMAC publish, which must call the origin with X-Meals-Publish-Key. Application gates are HMAC on /api/publish, Cognito or Google Bearer on admin, and public submit only. Accepted as the HTTP-origin design for PLAT-215; TLS on the ALB is a follow-up."
|
"justification": "The meals API ALB is the CloudFront HTTP origin for orders.seahaven.com. TLS and WAF terminate at CloudFront. Restricting the security group to the CloudFront managed prefix list would block GitHub-hosted weekly-menu HMAC publish, which must call the origin with X-Meals-Publish-Key. Application gates are HMAC on /api/publish, Cognito or Google Bearer on admin, and public submit only. Accepted as the HTTP-origin design for PLAT-215; TLS on the ALB is a follow-up."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "checkov-CKV_AWS_111-40",
|
"id": "checkov-CKV_AWS_111-42",
|
||||||
"justification": "githubdeploy-meal-order-manager needs ecr:GetAuthorizationToken and ecs:RegisterTaskDefinition, both of which AWS documents as Resource *. Trust is pinned to environment:dev or environment:prod plus job_workflow_ref on deploy-api.yaml at refs/heads/main or refs/tags/v*. iam:PassRole is limited to the ECS task and execution roles. ECR push and SSM parameter reads are already resource-scoped."
|
"justification": "LINE SHIFT ONLY: dropping the workflow_ref trust condition shifts github_deploy from 49 to 42. The permission document is unchanged. Original justification: githubdeploy-meal-order-manager needs ecr:GetAuthorizationToken and ecs:RegisterTaskDefinition, both of which AWS documents as Resource *. Trust is pinned to environment:dev or environment:prod and job_workflow_ref on org cd-hcp-fargate.yaml@*. iam:PassRole is limited to the ECS task and execution roles. ECR push and SSM parameter reads are already resource-scoped."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY src/shared/requirements.txt /tmp/shared-requirements.txt
|
COPY src/shared/requirements.txt /tmp/shared-requirements.txt
|
||||||
|
|
|
||||||
58
README.md
58
README.md
|
|
@ -10,26 +10,21 @@ Automates weekly meal ordering from [Redefine Meals](https://www.redefinemeals.c
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Monday 7:30am ET Employees (Mon–Thu) Thursday 11:59pm ET
|
EventBridge Scheduler (America/New_York) Employees
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
┌──────────────────────────────────────┐ ┌────────────────────┐
|
||||||
│ GitHub Actions │ │ orders.seahaven │ │ EventBridge │
|
│ Mon 6:55 roster, Mon 7:30 menu │ │ orders.seahaven.com│
|
||||||
│ - Scrape menu │────S3 upload───▶│ .com │ │ Scheduler (ET) │
|
│ Thu 10:00 reminder, Thu 23:59 close │ │ CloudFront + S3 │
|
||||||
│ - HMAC publish │ │ (CloudFront+S3) │──POST───┐ │ → jobs SQS │
|
└──────────────────┬───────────────────┘ └─────────┬──────────┘
|
||||||
│ - Slack notify │ └──────────────────┘ │ └─────────┬────────┘
|
│ jobs SQS │ POST /api
|
||||||
└─────────────────┘ ▼ │
|
▼ ▼
|
||||||
┌──────────┐ │
|
┌──────────────────────────────────────────────────┐
|
||||||
Thu 10am: Slack DM │ ALB + │◀──────────┘
|
│ Fargate: Flask + SQS worker │
|
||||||
reminders to employees │ Fargate │
|
│ menu → DynamoDB │
|
||||||
who haven't ordered │ Flask │
|
│ form HTML → S3, then invalidate /index.html │
|
||||||
└────┬─────┘
|
└──────────────────────────────────────────────────┘
|
||||||
▼
|
|
||||||
┌──────────┐
|
|
||||||
│ DynamoDB │
|
|
||||||
│ orders │
|
|
||||||
└──────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The production HTTP app is `src/server/app.py` (gunicorn). Close, aggregate/PDF, Slack reminder, and roster sync run in the same task from a dedicated SQS consumer (`src/server/worker.py`). Playwright scrape stays in GitHub Actions.
|
The production HTTP app is `src/server/app.py` (gunicorn). Menu publish, close, aggregate/PDF, Slack reminder, and roster sync run in the same task from a dedicated SQS consumer (`src/server/worker.py`). Menu publish fetches the Redefine HTML catalog. It does not run a browser.
|
||||||
|
|
||||||
### Form frontend decisions
|
### Form frontend decisions
|
||||||
|
|
||||||
|
|
@ -45,21 +40,21 @@ the generated HTML, and the generated deployment artifact remains self-contained
|
||||||
| When | What | How |
|
| When | What | How |
|
||||||
|------|------|-----|
|
|------|------|-----|
|
||||||
| Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge Scheduler → jobs SQS → Fargate |
|
| Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge Scheduler → jobs SQS → Fargate |
|
||||||
| Monday 7:30am ET | Scrape menu, generate form, HMAC-publish menu, upload form to S3, post link to Slack | GitHub Actions cron |
|
| Monday 7:30am ET | Fetch menu, generate form, write menu, upload form to S3, invalidate CloudFront, post link to Slack | EventBridge Scheduler → jobs SQS |
|
||||||
| Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 form → CloudFront `/api/*` → ALB → Flask → DynamoDB |
|
| Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 form → CloudFront `/api/*` → ALB → Flask → DynamoDB |
|
||||||
| Thursday 10am ET | DM employees who haven't ordered yet | EventBridge Scheduler → jobs SQS |
|
| Thursday 10am ET | DM employees who haven't ordered yet | EventBridge Scheduler → jobs SQS |
|
||||||
| Thursday 11:59pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge Scheduler → jobs SQS |
|
| Thursday 11:59pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge Scheduler → jobs SQS |
|
||||||
|
|
||||||
### Weekly menu publication boundary
|
### Weekly menu publication
|
||||||
|
|
||||||
The scheduled GitHub workflow has no DynamoDB permissions. It sends two HMAC
|
Monday 7:30am Eastern, EventBridge Scheduler enqueues `publish_menu`. The Fargate worker fetches the Redefine menu HTML, parses the embedded catalog, writes that Eastern-time week's menu to DynamoDB, renders the form, uploads it to the form bucket, invalidates `/index.html`, and posts to Slack.
|
||||||
requests with `X-Meals-Publish-Key` from Parameter Store to the ALB:
|
|
||||||
|
`scripts/upload_menu.py` remains a manual HMAC fallback for one production Monday:
|
||||||
|
|
||||||
- `GET /api/publish/settings` returns only the bulk discount and company subsidy.
|
- `GET /api/publish/settings` returns only the bulk discount and company subsidy.
|
||||||
- `POST /api/publish/menu` validates and writes the current Eastern-time week's menu.
|
- `POST /api/publish/menu` validates and writes the current Eastern-time week's menu.
|
||||||
|
|
||||||
Publish routes are omitted from CloudFront. The generated form uses relative
|
Publish routes are omitted from CloudFront. The generated form uses relative `/api/...` paths so it stays same-origin on `orders.seahaven.com`.
|
||||||
`/api/...` paths so it stays same-origin on `orders.seahaven.com`.
|
|
||||||
|
|
||||||
### Reports (written to `meal-order-manager-reports-*` at Thursday close)
|
### Reports (written to `meal-order-manager-reports-*` at Thursday close)
|
||||||
|
|
||||||
|
|
@ -74,12 +69,14 @@ Publish routes are omitted from CloudFront. The generated form uses relative
|
||||||
Workspace: `meal-order-manager-prod` / `meal-order-manager-dev` (us-east-1)
|
Workspace: `meal-order-manager-prod` / `meal-order-manager-dev` (us-east-1)
|
||||||
|
|
||||||
- **ECS Fargate** — Flask + gunicorn + SQS job consumer. Desired count 2 in prod, 1 in dev.
|
- **ECS Fargate** — Flask + gunicorn + SQS job consumer. Desired count 2 in prod, 1 in dev.
|
||||||
|
- **VPC** — Prod attaches to the After Hours VPC (`existing_vpc_id` / `existing_public_subnet_ids` from afterhours-shift-manager outputs). The 10.60 CIDR is unused fallback.
|
||||||
|
- **HTTP contract** — `openapi.yaml`, linted in CI with `npm run openapi:lint` (Redocly `extends: recommended`, same as internal-portal and afterhours-shift-manager).
|
||||||
- **ALB** — origin for CloudFront `/api` behaviors and weekly-menu HMAC publish. Idle timeout 120s.
|
- **ALB** — origin for CloudFront `/api` behaviors and weekly-menu HMAC publish. Idle timeout 120s.
|
||||||
- **ECR** — API image. GitHub Actions `deploy-api.yaml` owns the image; Terraform ignores `container_definitions`.
|
- **ECR** — API image. GitHub Actions `deploy-api.yaml` owns the image; Terraform ignores `container_definitions`.
|
||||||
- **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports + weekly summary PDF)
|
- **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports + weekly summary PDF)
|
||||||
- **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com`. API origin is the ALB (HTTP-only).
|
- **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com`. API origin is the ALB (HTTP-only).
|
||||||
- **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config)
|
- **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config)
|
||||||
- **SQS** — `meal-order-manager-jobs` (+ DLQ). EventBridge Scheduler in `America/New_York` enqueues close, reminder, and roster sync.
|
- **SQS** — `meal-order-manager-jobs` (+ DLQ). EventBridge Scheduler in `America/New_York` enqueues menu publish, close, reminder, and roster sync.
|
||||||
- **Secrets Manager** — Slack bot token
|
- **Secrets Manager** — Slack bot token
|
||||||
- **CloudWatch Alarms** — ALB 5xx, ECS CPU, jobs DLQ, DynamoDB throttles, all notifying `site-alerts`
|
- **CloudWatch Alarms** — ALB 5xx, ECS CPU, jobs DLQ, DynamoDB throttles, all notifying `site-alerts`
|
||||||
- **HCP Terraform** — workspace `meal-order-manager-<env>` in project `seahaven-<env>`. Working directory `terraform/`. VCS file triggers should be `terraform/**` only after the image deploy workflow owns `src/`. Do not `terraform apply` locally to prod.
|
- **HCP Terraform** — workspace `meal-order-manager-<env>` in project `seahaven-<env>`. Working directory `terraform/`. VCS file triggers should be `terraform/**` only after the image deploy workflow owns `src/`. Do not `terraform apply` locally to prod.
|
||||||
|
|
@ -141,10 +138,11 @@ Local `:5050` is the same Flask app as production. DynamoDB is used when AWS cre
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt -r requirements-api.txt
|
pip install -r requirements.txt -r requirements-api.txt
|
||||||
playwright install chromium
|
|
||||||
PYTHONPATH=src:src/shared python3 -m server.app
|
PYTHONPATH=src:src/shared python3 -m server.app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Form tests and `src/scraper/recon.py` need `playwright install chromium`. Menu publish does not.
|
||||||
|
|
||||||
### Deploy to AWS
|
### Deploy to AWS
|
||||||
|
|
||||||
Image deploys are GitHub Actions `deploy-api.yaml` (push to `main` → dev, GitHub Release → prod). Infrastructure applies through HCP Terraform. First apply of the new `tf-managed` IAM policies needs the hcptf-bootstrap window.
|
Image deploys are GitHub Actions `deploy-api.yaml` (push to `main` → dev, GitHub Release → prod). Infrastructure applies through HCP Terraform. First apply of the new `tf-managed` IAM policies needs the hcptf-bootstrap window.
|
||||||
|
|
@ -184,14 +182,14 @@ python3 src/aggregator/aggregate.py # generate CSV reports
|
||||||
```
|
```
|
||||||
meal-order-manager/
|
meal-order-manager/
|
||||||
├── .github/workflows/
|
├── .github/workflows/
|
||||||
│ ├── weekly-menu.yml # Monday cron: scrape + HMAC publish + notify
|
|
||||||
│ ├── deploy-api.yaml # Image CD to Fargate
|
│ ├── deploy-api.yaml # Image CD to Fargate
|
||||||
│ ├── ci.yml # PR checks
|
│ └── ci.yml # PR checks (lint, pytest, template JS, terraform, ci-complete)
|
||||||
│ └── ci-terraform.yaml # terraform fmt / validate
|
|
||||||
├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue)
|
├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue)
|
||||||
|
├── openapi.yaml # Employee HTTP contract (Redocly recommended)
|
||||||
|
├── .redocly.yaml
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── scraper/ # Playwright menu scraper
|
│ ├── scraper/ # HTML menu parser (recon scripts still use Playwright)
|
||||||
│ ├── server/ # Flask API, form generator, job handlers
|
│ ├── server/ # Flask API, form generator, job handlers
|
||||||
│ ├── aggregator/ # Order aggregation + CSV reports
|
│ ├── aggregator/ # Order aggregation + CSV reports
|
||||||
│ └── shared/shared/ # db, secrets, slack, pdf helpers
|
│ └── shared/shared/ # db, secrets, slack, pdf helpers
|
||||||
|
|
|
||||||
665
openapi.yaml
Normal file
665
openapi.yaml
Normal file
|
|
@ -0,0 +1,665 @@
|
||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Meal Order Manager
|
||||||
|
version: 0.1.0
|
||||||
|
description: >
|
||||||
|
Flask HTTP API on ECS Fargate behind orders.seahaven.com. The internal
|
||||||
|
portal SPA calls menu, submit, and admin with a Cognito ID token from
|
||||||
|
GET /api/auth/meals-token. Weekly-menu GitHub Actions uses HMAC publish
|
||||||
|
routes that CloudFront does not expose. JSON errors are currently
|
||||||
|
`{ error: string }`. Health matches the portal BFF `{ stage, sha }`.
|
||||||
|
Lint with the same Redocly `extends: recommended` config as
|
||||||
|
internal-portal and afterhours-shift-manager.
|
||||||
|
contact:
|
||||||
|
name: Sea Haven Engineering
|
||||||
|
license:
|
||||||
|
name: Proprietary
|
||||||
|
identifier: LicenseRef-SeaHaven
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: /
|
||||||
|
description: orders.seahaven.com CloudFront / local Flask :5050
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Runtime
|
||||||
|
description: Unauthenticated health
|
||||||
|
- name: Menu
|
||||||
|
description: Public weekly menu and form status
|
||||||
|
- name: Orders
|
||||||
|
description: Employee submit and own-order lookup
|
||||||
|
- name: Admin
|
||||||
|
description: Admin order edit and summary PDF
|
||||||
|
- name: Publish
|
||||||
|
description: Weekly-menu HMAC publish (not on CloudFront)
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/api/health:
|
||||||
|
get:
|
||||||
|
operationId: getHealth
|
||||||
|
tags: [Runtime]
|
||||||
|
summary: Runtime health
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Process is up
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Health"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
|
||||||
|
/api/menu/{week}:
|
||||||
|
get:
|
||||||
|
operationId: getMenu
|
||||||
|
tags: [Menu]
|
||||||
|
summary: Published menu for a week
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/MenuWeekPath"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Menu payload
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MenuPayload"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
|
||||||
|
/api/form-status/{week}:
|
||||||
|
get:
|
||||||
|
operationId: getFormStatus
|
||||||
|
tags: [Menu]
|
||||||
|
summary: Open or closed for a week
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/MenuWeekPath"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Form status
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FormStatus"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
|
||||||
|
/api/submit-order:
|
||||||
|
post:
|
||||||
|
operationId: submitOrder
|
||||||
|
tags: [Orders]
|
||||||
|
summary: Place or replace this week's order
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SubmitBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Order saved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SubmitResult"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"410":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"429":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
|
||||||
|
/api/orders/{week}:
|
||||||
|
get:
|
||||||
|
operationId: getMyOrder
|
||||||
|
tags: [Orders]
|
||||||
|
summary: Caller's order only
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/OrderWeekPath"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Empty list when the caller has no order
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MyOrders"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
|
||||||
|
/api/admin/orders:
|
||||||
|
get:
|
||||||
|
operationId: adminListOrders
|
||||||
|
tags: [Admin]
|
||||||
|
summary: List weeks or one week's orders
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
parameters:
|
||||||
|
- name: week
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Weeks list or week detail
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AdminOrdersResponse"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
put:
|
||||||
|
operationId: adminUpdateOrder
|
||||||
|
tags: [Admin]
|
||||||
|
summary: Recalculate and replace an order
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AdminUpdateBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AdminMutation"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
delete:
|
||||||
|
operationId: adminDeleteOrder
|
||||||
|
tags: [Admin]
|
||||||
|
summary: Delete an order
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
parameters:
|
||||||
|
- name: week
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: email
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Deleted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AdminMutation"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
|
||||||
|
/api/admin/summary-pdf:
|
||||||
|
get:
|
||||||
|
operationId: adminSummaryPdf
|
||||||
|
tags: [Admin]
|
||||||
|
summary: Presigned summary PDF URL
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
parameters:
|
||||||
|
- name: week
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Short-lived URL
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SummaryPdf"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
|
||||||
|
/api/roster:
|
||||||
|
get:
|
||||||
|
operationId: getRoster
|
||||||
|
tags: [Orders]
|
||||||
|
summary: Name and email list used by the static form
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Roster
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FormRoster"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
|
||||||
|
/api/publish/settings:
|
||||||
|
get:
|
||||||
|
operationId: publishSettings
|
||||||
|
tags: [Publish]
|
||||||
|
summary: Bulk discount and subsidy for scrape
|
||||||
|
security:
|
||||||
|
- publishKey: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Pricing fields only
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PublishSettings"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
|
||||||
|
/api/publish/menu:
|
||||||
|
post:
|
||||||
|
operationId: publishMenu
|
||||||
|
tags: [Publish]
|
||||||
|
summary: Write this week's scraped menu
|
||||||
|
security:
|
||||||
|
- publishKey: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PublishMenuBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Published
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PublishMenuResult"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/StringError"
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
portalCognito:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: Portal Cognito ID token. GIS google_id_token is also accepted on submit until cutover.
|
||||||
|
publishKey:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: X-Meals-Publish-Key
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
MenuWeekPath:
|
||||||
|
name: week
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: "`current` or `YYYY-WNN`. Other values are 400."
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
OrderWeekPath:
|
||||||
|
name: week
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: "`YYYY-WNN` or `YYYY-MM-DD`. `current` is 400."
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
|
||||||
|
responses:
|
||||||
|
StringError:
|
||||||
|
description: Current meals JSON error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/StringErrorBody"
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
Health:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [stage, sha]
|
||||||
|
properties:
|
||||||
|
stage:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description: Workspace stage (`dev`, `prod`, or `local`)
|
||||||
|
sha:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description: Git SHA or `unknown` locally
|
||||||
|
|
||||||
|
StringErrorBody:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [error]
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
|
||||||
|
Meal:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required: [name, price]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
price:
|
||||||
|
type: number
|
||||||
|
calories:
|
||||||
|
type: [string, number, "null"]
|
||||||
|
protein:
|
||||||
|
type: [string, number, "null"]
|
||||||
|
description:
|
||||||
|
type: [string, "null"]
|
||||||
|
dietary_tags:
|
||||||
|
type: [array, "null"]
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
image_url:
|
||||||
|
type: [string, "null"]
|
||||||
|
is_new:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
MenuPayload:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- week
|
||||||
|
- form_status
|
||||||
|
- meal_count
|
||||||
|
- meals
|
||||||
|
- bulk_discount_percent
|
||||||
|
- company_subsidy_percent
|
||||||
|
properties:
|
||||||
|
week:
|
||||||
|
type: string
|
||||||
|
form_status:
|
||||||
|
type: string
|
||||||
|
scraped_at:
|
||||||
|
type: [string, "null"]
|
||||||
|
menu_url:
|
||||||
|
type: [string, "null"]
|
||||||
|
meal_count:
|
||||||
|
type: integer
|
||||||
|
meals:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Meal"
|
||||||
|
bulk_discount_percent:
|
||||||
|
type: number
|
||||||
|
company_subsidy_percent:
|
||||||
|
type: number
|
||||||
|
order_deadline:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
FormStatus:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [week, status]
|
||||||
|
properties:
|
||||||
|
week:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
reopen_at:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
SubmitItem:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required: [name, quantity]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
quantity:
|
||||||
|
type: number
|
||||||
|
retail_price:
|
||||||
|
type: number
|
||||||
|
|
||||||
|
SubmitBody:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/SubmitItem"
|
||||||
|
google_id_token:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
SubmitResult:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [status]
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
total:
|
||||||
|
type: number
|
||||||
|
|
||||||
|
MyOrders:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [week, orders]
|
||||||
|
properties:
|
||||||
|
week:
|
||||||
|
type: string
|
||||||
|
orders:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [employee_email]
|
||||||
|
properties:
|
||||||
|
employee_email:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
AdminWeek:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required: [week]
|
||||||
|
properties:
|
||||||
|
week:
|
||||||
|
type: string
|
||||||
|
form_status:
|
||||||
|
type: string
|
||||||
|
meal_count:
|
||||||
|
type: integer
|
||||||
|
order_count:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
AdminOrderItem:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [name, quantity, retail_price, price, subtotal]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
quantity:
|
||||||
|
type: integer
|
||||||
|
retail_price:
|
||||||
|
type: number
|
||||||
|
price:
|
||||||
|
type: number
|
||||||
|
subtotal:
|
||||||
|
type: number
|
||||||
|
|
||||||
|
AdminOrder:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [employee_name, employee_email, items, total, submitted_at]
|
||||||
|
properties:
|
||||||
|
employee_name:
|
||||||
|
type: string
|
||||||
|
employee_email:
|
||||||
|
type: string
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AdminOrderItem"
|
||||||
|
total:
|
||||||
|
type: number
|
||||||
|
submitted_at:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
AdminWeeks:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [weeks]
|
||||||
|
properties:
|
||||||
|
weeks:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AdminWeek"
|
||||||
|
|
||||||
|
AdminWeekOrders:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [week, orders, total_employees, grand_total]
|
||||||
|
properties:
|
||||||
|
week:
|
||||||
|
type: string
|
||||||
|
orders:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AdminOrder"
|
||||||
|
total_employees:
|
||||||
|
type: integer
|
||||||
|
grand_total:
|
||||||
|
type: number
|
||||||
|
|
||||||
|
AdminOrdersResponse:
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/AdminWeeks"
|
||||||
|
- $ref: "#/components/schemas/AdminWeekOrders"
|
||||||
|
|
||||||
|
AdminUpdateBody:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [week, email, items]
|
||||||
|
properties:
|
||||||
|
week:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/SubmitItem"
|
||||||
|
|
||||||
|
AdminMutation:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [status, week]
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
week:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
total:
|
||||||
|
type: number
|
||||||
|
|
||||||
|
SummaryPdf:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [week, url]
|
||||||
|
properties:
|
||||||
|
week:
|
||||||
|
type: string
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
|
||||||
|
FormRoster:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [employees]
|
||||||
|
properties:
|
||||||
|
employees:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [name, email]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
PublishSettings:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [bulk_discount_percent, company_subsidy_percent]
|
||||||
|
properties:
|
||||||
|
bulk_discount_percent:
|
||||||
|
type: number
|
||||||
|
company_subsidy_percent:
|
||||||
|
type: number
|
||||||
|
|
||||||
|
PublishMenuBody:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required: [meals]
|
||||||
|
properties:
|
||||||
|
meals:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Meal"
|
||||||
|
scraped_at:
|
||||||
|
type: string
|
||||||
|
menu_url:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
PublishMenuResult:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [status, week, meal_count]
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
week:
|
||||||
|
type: string
|
||||||
|
meal_count:
|
||||||
|
type: integer
|
||||||
46
package-lock.json
generated
46
package-lock.json
generated
|
|
@ -9,9 +9,10 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"eslint": "10.10.0",
|
"@redocly/cli": "2.57.0",
|
||||||
"globals": "17.12.0",
|
"eslint": "10.11.0",
|
||||||
"prettier": "3.9.8"
|
"globals": "17.13.0",
|
||||||
|
"prettier": "3.9.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cacheable/memory": {
|
"node_modules/@cacheable/memory": {
|
||||||
|
|
@ -256,6 +257,21 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@redocly/cli": {
|
||||||
|
"version": "2.57.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.57.0.tgz",
|
||||||
|
"integrity": "sha512-1d5fVyUaYlMNgCHUoyE2vUyxBhs/jmOHgsX/kFmfWzESw4f/G/OV/SU9E55rU7aUNmw9rHj1vXmL6yUdIn+KKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"openapi": "bin/cli.js",
|
||||||
|
"redocly": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0 || >=20.19.0 <21.0.0",
|
||||||
|
"npm": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
|
|
@ -328,9 +344,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -408,9 +424,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "10.10.0",
|
"version": "10.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz",
|
||||||
"integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==",
|
"integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|
@ -643,9 +659,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "17.12.0",
|
"version": "17.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
|
||||||
"integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
|
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -890,9 +906,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.9.8",
|
"version": "3.9.9",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
|
||||||
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
|
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -6,12 +6,14 @@
|
||||||
"check:templates": "npm run lint:templates && npm run format:templates",
|
"check:templates": "npm run lint:templates && npm run format:templates",
|
||||||
"format:templates": "prettier --check \"src/server/templates/*.js\"",
|
"format:templates": "prettier --check \"src/server/templates/*.js\"",
|
||||||
"format:templates:write": "prettier --write \"src/server/templates/*.js\"",
|
"format:templates:write": "prettier --write \"src/server/templates/*.js\"",
|
||||||
"lint:templates": "eslint \"src/server/templates/*.js\""
|
"lint:templates": "eslint \"src/server/templates/*.js\"",
|
||||||
|
"openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"eslint": "10.10.0",
|
"@redocly/cli": "2.57.0",
|
||||||
"globals": "17.12.0",
|
"eslint": "10.11.0",
|
||||||
"prettier": "3.9.8"
|
"globals": "17.13.0",
|
||||||
|
"prettier": "3.9.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
flask==3.1.3
|
flask==3.1.3
|
||||||
gunicorn==23.0.0
|
gunicorn==26.2.0
|
||||||
boto3==1.43.97
|
boto3==1.43.107
|
||||||
|
sentry-sdk==2.71.0
|
||||||
jinja2==3.1.6
|
jinja2==3.1.6
|
||||||
fpdf2==2.8.8
|
fpdf2==2.8.9
|
||||||
PyJWT[crypto]==2.14.0
|
PyJWT[crypto]==2.15.1
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
playwright==1.63.0
|
playwright==1.63.0
|
||||||
flask==3.1.3
|
flask==3.1.3
|
||||||
boto3==1.43.97
|
boto3==1.43.107
|
||||||
jinja2==3.1.6
|
jinja2==3.1.6
|
||||||
|
|
|
||||||
271
scripts/fill_redefine_cart.py
Normal file
271
scripts/fill_redefine_cart.py
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
"""Fill a Redefine Meals guest cart from an admin order-list CSV.
|
||||||
|
|
||||||
|
Opens a visible browser, adds each matched meal, then stops on the cart page.
|
||||||
|
Log in and check out in that window. Press Enter when finished, or close the
|
||||||
|
window. The script does not store a password and does not open checkout itself.
|
||||||
|
|
||||||
|
python3 scripts/fill_redefine_cart.py order-list-2026-W39.csv
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
|
from playwright.sync_api import Error as PlaywrightError # noqa: E402
|
||||||
|
from playwright.sync_api import sync_playwright # noqa: E402
|
||||||
|
|
||||||
|
from scraper.fill_cart import ( # noqa: E402
|
||||||
|
FillCartError,
|
||||||
|
FillPlan,
|
||||||
|
build_plan,
|
||||||
|
cart_lines,
|
||||||
|
error_text,
|
||||||
|
quantity_for,
|
||||||
|
)
|
||||||
|
from scraper.parse_menu import ( # noqa: E402
|
||||||
|
MenuParseError,
|
||||||
|
extract_catalog_products,
|
||||||
|
fetch_menu_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_MENU_URL = "https://www.redefinemeals.com/menu"
|
||||||
|
_CART_JS = """
|
||||||
|
async ({ method, path, body }) => {
|
||||||
|
const headers = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
};
|
||||||
|
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/);
|
||||||
|
if (match) {
|
||||||
|
headers["X-XSRF-TOKEN"] = decodeURIComponent(match[1]);
|
||||||
|
}
|
||||||
|
const init = { method, credentials: "same-origin", headers };
|
||||||
|
if (body !== null && body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
init.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const response = await fetch(path, init);
|
||||||
|
const text = await response.text();
|
||||||
|
let data = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
data = { message: text.slice(0, 200) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: response.ok, status: response.status, data };
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
sys.stdout.reconfigure(line_buffering=True)
|
||||||
|
sys.stderr.reconfigure(line_buffering=True)
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("csv_path", type=Path, help="Admin order-list CSV")
|
||||||
|
parser.add_argument("--menu-url", default=DEFAULT_MENU_URL)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
csv_text = args.csv_path.read_text(encoding="utf-8-sig")
|
||||||
|
menu_html = fetch_menu_html(args.menu_url)
|
||||||
|
products = extract_catalog_products(menu_html)
|
||||||
|
plan = build_plan(csv_text, products)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
print("CSV must be UTF-8.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except (OSError, FillCartError, MenuParseError) as exc:
|
||||||
|
print(exc, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
_print_plan(plan)
|
||||||
|
if not plan.adds:
|
||||||
|
print("Nothing matched. Not opening a browser.")
|
||||||
|
return 1 if plan.skipped else 0
|
||||||
|
|
||||||
|
return _fill(plan, args.menu_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill(plan: FillPlan, menu_url: str) -> int:
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
try:
|
||||||
|
browser = playwright.chromium.launch(headless=False)
|
||||||
|
except PlaywrightError as exc:
|
||||||
|
print(f"Browser failed: {exc}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"Install the browser with: playwright install chromium",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
return _fill_browser(browser, plan, menu_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_browser(browser, plan: FillPlan, menu_url: str) -> int:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
page = browser.new_page()
|
||||||
|
except PlaywrightError as exc:
|
||||||
|
print(f"Failed: {exc}")
|
||||||
|
print(f"Added 0. Failed 1. Skipped {len(plan.skipped)}.")
|
||||||
|
return 1
|
||||||
|
return _fill_page(page, plan, menu_url)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
browser.close()
|
||||||
|
except PlaywrightError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_page(page, plan: FillPlan, menu_url: str) -> int:
|
||||||
|
failed: list[str] = []
|
||||||
|
added = 0
|
||||||
|
ready = False
|
||||||
|
try:
|
||||||
|
page.goto(menu_url, wait_until="domcontentloaded", timeout=60000)
|
||||||
|
cart = _cart_request(page, "GET", "/api/cart")
|
||||||
|
existing = cart_lines(cart)
|
||||||
|
if existing:
|
||||||
|
print("The cart already has items:")
|
||||||
|
for line in existing:
|
||||||
|
print(f" {line.name} x{line.quantity}")
|
||||||
|
if not _confirm_clear():
|
||||||
|
print("Exiting without adding.")
|
||||||
|
return 1
|
||||||
|
cleared = _cart_request(page, "POST", "/api/cart/clear", {})
|
||||||
|
if cart_lines(cleared):
|
||||||
|
print("The cart still has items after clear. Exiting without adding.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
posted = []
|
||||||
|
page_closed = False
|
||||||
|
for index, item in enumerate(plan.adds):
|
||||||
|
try:
|
||||||
|
_cart_request(
|
||||||
|
page,
|
||||||
|
"POST",
|
||||||
|
"/api/cart/add",
|
||||||
|
{
|
||||||
|
"uuid": item.uuid,
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"properties": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
failed.append(f"{item.name}: {exc}")
|
||||||
|
print(f"Failed: {item.name}: {exc}")
|
||||||
|
continue
|
||||||
|
except PlaywrightError as exc:
|
||||||
|
failed.append(f"{item.name}: {exc}")
|
||||||
|
print(f"Failed: {item.name}: {exc}")
|
||||||
|
for rest in plan.adds[index + 1 :]:
|
||||||
|
message = f"{rest.name}: not attempted"
|
||||||
|
failed.append(message)
|
||||||
|
print(f"Failed: {message}")
|
||||||
|
page_closed = True
|
||||||
|
break
|
||||||
|
posted.append(item)
|
||||||
|
|
||||||
|
if not page_closed:
|
||||||
|
try:
|
||||||
|
current = cart_lines(_cart_request(page, "GET", "/api/cart"))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
failed.append(f"Could not read the cart: {exc}")
|
||||||
|
print(f"Failed: Could not read the cart: {exc}")
|
||||||
|
else:
|
||||||
|
for item in posted:
|
||||||
|
found = quantity_for(current, item.uuid)
|
||||||
|
if found != item.quantity:
|
||||||
|
message = (
|
||||||
|
f"{item.name}: requested {item.quantity}, cart has {found}"
|
||||||
|
)
|
||||||
|
failed.append(message)
|
||||||
|
print(f"Failed: {message}")
|
||||||
|
else:
|
||||||
|
added += 1
|
||||||
|
origin = _origin(page.url or menu_url)
|
||||||
|
page.goto(f"{origin}/cart", wait_until="domcontentloaded", timeout=60000)
|
||||||
|
ready = True
|
||||||
|
except (RuntimeError, PlaywrightError) as exc:
|
||||||
|
failed.append(str(exc))
|
||||||
|
print(f"Failed: {exc}")
|
||||||
|
print(f"Added {added}. Failed {len(failed)}. Skipped {len(plan.skipped)}.")
|
||||||
|
if ready:
|
||||||
|
_wait_for_review(page)
|
||||||
|
return 1 if failed or plan.skipped else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cart_request(page, method: str, path: str, body: object | None = None) -> object:
|
||||||
|
result = page.evaluate(_CART_JS, {"method": method, "path": path, "body": body})
|
||||||
|
if not isinstance(result, dict) or not result.get("ok"):
|
||||||
|
status = result.get("status") if isinstance(result, dict) else 0
|
||||||
|
data = result.get("data") if isinstance(result, dict) else None
|
||||||
|
raise RuntimeError(error_text(int(status or 0), data))
|
||||||
|
return result.get("data")
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_clear() -> bool:
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
print("Refusing to clear a cart without a typed yes.")
|
||||||
|
return False
|
||||||
|
answer = input("Type yes to clear the cart and add this order: ")
|
||||||
|
return answer.strip().casefold() == "yes"
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_review(page) -> None:
|
||||||
|
print(
|
||||||
|
"Cart is ready. Log in and check out in this browser window.\n"
|
||||||
|
"Press Enter here after you are done. Closing the window also stops the script."
|
||||||
|
)
|
||||||
|
finished = threading.Event()
|
||||||
|
|
||||||
|
def _wait_for_enter() -> None:
|
||||||
|
try:
|
||||||
|
input()
|
||||||
|
except EOFError:
|
||||||
|
pass
|
||||||
|
finished.set()
|
||||||
|
|
||||||
|
if sys.stdin.isatty():
|
||||||
|
threading.Thread(target=_wait_for_enter, daemon=True).start()
|
||||||
|
|
||||||
|
while not finished.is_set():
|
||||||
|
try:
|
||||||
|
closed = page.is_closed()
|
||||||
|
except PlaywrightError:
|
||||||
|
return
|
||||||
|
if closed:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
page.wait_for_timeout(250)
|
||||||
|
except PlaywrightError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _print_plan(plan: FillPlan) -> None:
|
||||||
|
if plan.ignored_zero:
|
||||||
|
print(f"Ignored {plan.ignored_zero} items with quantity 0.")
|
||||||
|
if plan.adds:
|
||||||
|
print("Adding:")
|
||||||
|
for item in plan.adds:
|
||||||
|
print(f" {item.name} x{item.quantity}")
|
||||||
|
if plan.skipped:
|
||||||
|
print("Skipped:")
|
||||||
|
for line in plan.skipped:
|
||||||
|
print(f" {line.name}: {line.reason}")
|
||||||
|
|
||||||
|
|
||||||
|
def _origin(url: str) -> str:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
return f"{parts.scheme}://{parts.netloc}"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
0
src/scraper/__init__.py
Normal file
0
src/scraper/__init__.py
Normal file
203
src/scraper/fill_cart.py
Normal file
203
src/scraper/fill_cart.py
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"""Match an admin order-list CSV to the Redefine menu catalog.
|
||||||
|
|
||||||
|
The browser script uses this plan. Nothing here contacts the site.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
_FORMULA_PREFIX = set("=+-@\t\r")
|
||||||
|
|
||||||
|
|
||||||
|
class FillCartError(ValueError):
|
||||||
|
"""The order-list CSV cannot be planned."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OrderLine:
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlannedAdd:
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkippedLine:
|
||||||
|
name: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FillPlan:
|
||||||
|
adds: tuple[PlannedAdd, ...]
|
||||||
|
skipped: tuple[SkippedLine, ...]
|
||||||
|
ignored_zero: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CartLine:
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
product_uuid: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(csv_text: str, products: list) -> FillPlan:
|
||||||
|
parsed = _parse_order_list(csv_text)
|
||||||
|
adds, skipped = _match(parsed.lines, products)
|
||||||
|
return FillPlan(
|
||||||
|
adds=tuple(adds),
|
||||||
|
skipped=parsed.skipped + tuple(skipped),
|
||||||
|
ignored_zero=parsed.ignored_zero,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cart_lines(cart: object) -> list[CartLine]:
|
||||||
|
if not isinstance(cart, dict):
|
||||||
|
return []
|
||||||
|
items = cart.get("items")
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
lines = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
product = item.get("product") if isinstance(item.get("product"), dict) else {}
|
||||||
|
name = str(product.get("name") or item.get("name") or "Unknown item").strip()
|
||||||
|
product_uuid = product.get("uuid") or item.get("uuid")
|
||||||
|
lines.append(
|
||||||
|
CartLine(
|
||||||
|
name=name or "Unknown item",
|
||||||
|
quantity=_as_int(item.get("quantity")),
|
||||||
|
product_uuid=str(product_uuid) if product_uuid else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def quantity_for(lines: list[CartLine], product_uuid: str) -> int:
|
||||||
|
return sum(line.quantity for line in lines if line.product_uuid == product_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def error_text(status: int, data: object) -> str:
|
||||||
|
"""Short cart-API failure text. Vendor bodies can include stack traces."""
|
||||||
|
message = ""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
raw = data.get("message")
|
||||||
|
if isinstance(raw, str):
|
||||||
|
message = " ".join(raw.split())
|
||||||
|
if message:
|
||||||
|
return f"HTTP {status}: {message[:200]}"
|
||||||
|
return f"HTTP {status}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ParsedCsv:
|
||||||
|
lines: tuple[OrderLine, ...]
|
||||||
|
skipped: tuple[SkippedLine, ...]
|
||||||
|
ignored_zero: int
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_order_list(csv_text: str) -> _ParsedCsv:
|
||||||
|
text = csv_text.lstrip("\ufeff")
|
||||||
|
if not text.strip():
|
||||||
|
raise FillCartError("CSV is empty")
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
if reader.fieldnames is None:
|
||||||
|
raise FillCartError("CSV must have Item and Quantity columns")
|
||||||
|
fields = {(name or "").strip().casefold(): name for name in reader.fieldnames}
|
||||||
|
if "item" not in fields or "quantity" not in fields:
|
||||||
|
raise FillCartError("CSV must have Item and Quantity columns")
|
||||||
|
|
||||||
|
totals: dict[str, int] = {}
|
||||||
|
display: dict[str, str] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
skipped: list[SkippedLine] = []
|
||||||
|
ignored_zero = 0
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
raw_name = row.get(fields["item"]) or ""
|
||||||
|
name = _unescape_item(str(raw_name).strip())
|
||||||
|
raw_qty = str(row.get(fields["quantity"]) or "").strip()
|
||||||
|
if not name and not raw_qty:
|
||||||
|
continue
|
||||||
|
if not name:
|
||||||
|
skipped.append(SkippedLine("(blank)", "missing a name"))
|
||||||
|
continue
|
||||||
|
if not raw_qty.isdigit():
|
||||||
|
skipped.append(SkippedLine(name, f"invalid quantity {raw_qty!r}"))
|
||||||
|
continue
|
||||||
|
quantity = int(raw_qty)
|
||||||
|
if quantity == 0:
|
||||||
|
ignored_zero += 1
|
||||||
|
continue
|
||||||
|
key = name.casefold()
|
||||||
|
if key not in totals:
|
||||||
|
order.append(key)
|
||||||
|
display[key] = name
|
||||||
|
totals[key] = 0
|
||||||
|
totals[key] += quantity
|
||||||
|
|
||||||
|
lines = tuple(OrderLine(display[key], totals[key]) for key in order)
|
||||||
|
return _ParsedCsv(lines=lines, skipped=tuple(skipped), ignored_zero=ignored_zero)
|
||||||
|
|
||||||
|
|
||||||
|
def _unescape_item(name: str) -> str:
|
||||||
|
if len(name) > 1 and name[0] == "'" and name[1] in _FORMULA_PREFIX:
|
||||||
|
return name[1:]
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _match(
|
||||||
|
lines: tuple[OrderLine, ...], products: list
|
||||||
|
) -> tuple[list[PlannedAdd], list[SkippedLine]]:
|
||||||
|
available: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
unavailable: set[str] = set()
|
||||||
|
for product in products:
|
||||||
|
if not isinstance(product, dict):
|
||||||
|
continue
|
||||||
|
name = str(product.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
key = name.casefold()
|
||||||
|
if product.get("available") is False:
|
||||||
|
unavailable.add(key)
|
||||||
|
continue
|
||||||
|
uuid = str(product.get("uuid") or "").strip()
|
||||||
|
if not uuid:
|
||||||
|
continue
|
||||||
|
available.setdefault(key, []).append((name, uuid))
|
||||||
|
|
||||||
|
adds: list[PlannedAdd] = []
|
||||||
|
skipped: list[SkippedLine] = []
|
||||||
|
for line in lines:
|
||||||
|
key = line.name.casefold()
|
||||||
|
matches = available.get(key, [])
|
||||||
|
if len(matches) == 1:
|
||||||
|
adds.append(PlannedAdd(matches[0][0], line.quantity, matches[0][1]))
|
||||||
|
elif len(matches) > 1:
|
||||||
|
skipped.append(SkippedLine(line.name, "matches more than one menu item"))
|
||||||
|
elif key in unavailable:
|
||||||
|
skipped.append(SkippedLine(line.name, "unavailable on the menu"))
|
||||||
|
else:
|
||||||
|
skipped.append(SkippedLine(line.name, "not on the menu"))
|
||||||
|
return adds, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: object) -> int:
|
||||||
|
if isinstance(value, bool) or value is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
|
||||||
|
return int(text)
|
||||||
|
return 0
|
||||||
176
src/scraper/parse_menu.py
Normal file
176
src/scraper/parse_menu.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""Parse the Redefine Meals menu from the HTML catalog embedded on the page.
|
||||||
|
|
||||||
|
The menu document includes an <orders-page> element whose :products and
|
||||||
|
:newest-ids attributes are JSON. That replaces the Playwright DOM scrape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
PRODUCTS_MARKER = ":products='"
|
||||||
|
NEWEST_MARKER = ":newest-ids='"
|
||||||
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
_SPACE_RE = re.compile(r"\s+")
|
||||||
|
_USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MenuParseError(RuntimeError):
|
||||||
|
"""The menu page did not contain a usable catalog."""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_menu_html(url: str, *, timeout: float = 30) -> str:
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
return response.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise MenuParseError(f"Failed to fetch {url}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_menu(url: str, *, timeout: float = 30) -> dict:
|
||||||
|
page = fetch_menu_html(url, timeout=timeout)
|
||||||
|
return parse_menu_html(page, menu_url=url)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_catalog_products(page: str) -> list:
|
||||||
|
"""Return the raw product objects embedded on the menu page."""
|
||||||
|
products = _extract_json_attr(page, PRODUCTS_MARKER)
|
||||||
|
if not isinstance(products, list):
|
||||||
|
raise MenuParseError(":products must be a JSON array")
|
||||||
|
if not products:
|
||||||
|
raise MenuParseError("Menu catalog is empty")
|
||||||
|
return products
|
||||||
|
|
||||||
|
|
||||||
|
def parse_menu_html(page: str, *, menu_url: str, scraped_at: str | None = None) -> dict:
|
||||||
|
products = _extract_json_attr(page, PRODUCTS_MARKER)
|
||||||
|
newest = _extract_json_attr(page, NEWEST_MARKER)
|
||||||
|
if not isinstance(products, list):
|
||||||
|
raise MenuParseError(":products must be a JSON array")
|
||||||
|
if not isinstance(newest, list):
|
||||||
|
raise MenuParseError(":newest-ids must be a JSON array")
|
||||||
|
if not products:
|
||||||
|
raise MenuParseError("Menu catalog is empty")
|
||||||
|
|
||||||
|
newest_ids = {str(item) for item in newest}
|
||||||
|
meals = []
|
||||||
|
for product in products:
|
||||||
|
if not isinstance(product, dict):
|
||||||
|
raise MenuParseError("Each catalog entry must be an object")
|
||||||
|
if product.get("available") is False:
|
||||||
|
continue
|
||||||
|
meals.append(_meal_from_product(product, newest_ids))
|
||||||
|
|
||||||
|
if not meals:
|
||||||
|
raise MenuParseError("Scraped 0 meals")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scraped_at": scraped_at or datetime.now(EASTERN).isoformat(),
|
||||||
|
"menu_url": menu_url,
|
||||||
|
"meal_count": len(meals),
|
||||||
|
"meals": meals,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_attr(page: str, marker: str):
|
||||||
|
index = page.find(marker)
|
||||||
|
if index < 0:
|
||||||
|
raise MenuParseError(f"Menu page is missing {marker}")
|
||||||
|
raw = html.unescape(page[index + len(marker) :])
|
||||||
|
try:
|
||||||
|
value, _end = json.JSONDecoder().raw_decode(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise MenuParseError(f"Menu page has invalid JSON after {marker}") from exc
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _meal_from_product(product: dict, newest_ids: set[str]) -> dict:
|
||||||
|
name = str(product.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise MenuParseError("A catalog entry is missing a name")
|
||||||
|
|
||||||
|
details = product.get("details") if isinstance(product.get("details"), dict) else {}
|
||||||
|
concerns = product.get("dietary_concerns") or []
|
||||||
|
if not isinstance(concerns, list):
|
||||||
|
raise MenuParseError(f"{name} dietary_concerns must be a list")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"price": _price(product),
|
||||||
|
"calories": _calories(details),
|
||||||
|
"protein": _protein(details),
|
||||||
|
"dietary_tags": [str(tag).strip() for tag in concerns if str(tag).strip()],
|
||||||
|
"image_url": _image_url(product),
|
||||||
|
"is_new": str(product.get("id")) in newest_ids,
|
||||||
|
"description": _plain_text(product.get("description")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _price(product: dict) -> float:
|
||||||
|
raw = (
|
||||||
|
product.get("sale_price")
|
||||||
|
if product.get("on_sale") is True
|
||||||
|
else product.get("price")
|
||||||
|
)
|
||||||
|
name = str(product.get("name") or "meal")
|
||||||
|
if isinstance(raw, bool) or raw is None or str(raw).strip() == "":
|
||||||
|
raise MenuParseError(f"{name} is missing a price")
|
||||||
|
try:
|
||||||
|
price = float(raw)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise MenuParseError(f"{name} has an invalid price") from exc
|
||||||
|
if price < 0:
|
||||||
|
raise MenuParseError(f"{name} has a negative price")
|
||||||
|
return price
|
||||||
|
|
||||||
|
|
||||||
|
def _calories(details: dict) -> int | None:
|
||||||
|
raw = details.get("calories")
|
||||||
|
if raw is None or str(raw).strip() == "":
|
||||||
|
return None
|
||||||
|
text = str(raw).strip().lower().removesuffix("cal").strip()
|
||||||
|
try:
|
||||||
|
return int(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _protein(details: dict) -> str | None:
|
||||||
|
raw = details.get("protein")
|
||||||
|
if raw is None or str(raw).strip() == "":
|
||||||
|
return None
|
||||||
|
text = str(raw).strip()
|
||||||
|
if text.lower().endswith("g"):
|
||||||
|
return text
|
||||||
|
return f"{text}g"
|
||||||
|
|
||||||
|
|
||||||
|
def _image_url(product: dict) -> str | None:
|
||||||
|
media = product.get("media_urls")
|
||||||
|
images = media.get("images") if isinstance(media, dict) else None
|
||||||
|
if not images or not isinstance(images, list):
|
||||||
|
return None
|
||||||
|
first = images[0]
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
return None
|
||||||
|
return first.get("original") or first.get("thumbnail")
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_text(value) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = html.unescape(str(value))
|
||||||
|
text = _SPACE_RE.sub(" ", _TAG_RE.sub(" ", text)).strip()
|
||||||
|
return text or None
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
"""
|
"""Fetch the Redefine Meals menu and write menu JSON for local form generation."""
|
||||||
Redefine Meals menu scraper.
|
|
||||||
|
|
||||||
Navigates to the menu page using a headless browser, waits for the
|
|
||||||
Vue.js SPA to render, and extracts structured meal data from the DOM.
|
|
||||||
Also intercepts network requests to detect any JSON API that could
|
|
||||||
replace the browser scrape in the future.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from playwright.sync_api import sync_playwright, TimeoutError as PwTimeout
|
|
||||||
|
try:
|
||||||
|
from scraper.parse_menu import MenuParseError, fetch_menu
|
||||||
|
except ImportError:
|
||||||
|
from parse_menu import MenuParseError, fetch_menu
|
||||||
|
|
||||||
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.json"
|
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.json"
|
||||||
|
|
||||||
|
|
@ -21,236 +18,8 @@ def load_config():
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
def scrape_menu(url: str, *, headless: bool = True, timeout_ms: int = 60_000) -> dict:
|
def scrape_menu(url: str, **_kwargs) -> dict:
|
||||||
"""
|
return fetch_menu(url)
|
||||||
Returns {
|
|
||||||
"scraped_at": ISO timestamp,
|
|
||||||
"menu_url": str,
|
|
||||||
"api_endpoints_found": [str],
|
|
||||||
"meals": [ { name, price, calories, protein, dietary_tags,
|
|
||||||
image_url, is_new, description } ]
|
|
||||||
}
|
|
||||||
Raises RuntimeError if the page fails to load or no meals are found.
|
|
||||||
"""
|
|
||||||
api_endpoints = []
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=headless)
|
|
||||||
context = browser.new_context(
|
|
||||||
user_agent=(
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/120.0.0.0 Safari/537.36"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
page = context.new_page()
|
|
||||||
|
|
||||||
def on_response(response):
|
|
||||||
ct = response.headers.get("content-type", "")
|
|
||||||
if "json" in ct and "/api/" in response.url and "cart" not in response.url:
|
|
||||||
api_endpoints.append(response.url)
|
|
||||||
|
|
||||||
page.on("response", on_response)
|
|
||||||
|
|
||||||
try:
|
|
||||||
page.goto(url, wait_until="networkidle", timeout=timeout_ms)
|
|
||||||
except PwTimeout:
|
|
||||||
browser.close()
|
|
||||||
raise RuntimeError(f"Timed out loading {url}")
|
|
||||||
|
|
||||||
page.wait_for_timeout(3000)
|
|
||||||
|
|
||||||
articles = page.query_selector_all("article.editorial_card")
|
|
||||||
if not articles:
|
|
||||||
browser.close()
|
|
||||||
raise RuntimeError(
|
|
||||||
"No meal cards found on page. The site layout may have changed. "
|
|
||||||
"Run recon.py to inspect the current structure."
|
|
||||||
)
|
|
||||||
|
|
||||||
meals = []
|
|
||||||
for article in articles:
|
|
||||||
meal = _extract_card(article)
|
|
||||||
if meal:
|
|
||||||
meals.append(meal)
|
|
||||||
|
|
||||||
# Try to get descriptions via Quick View modals
|
|
||||||
_enrich_with_descriptions(page, articles, meals)
|
|
||||||
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
if not meals:
|
|
||||||
raise RuntimeError("Scraped 0 meals — extraction selectors are likely broken.")
|
|
||||||
|
|
||||||
for meal in meals:
|
|
||||||
meal["dietary_tags"] = _clean_tags(meal["dietary_tags"])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"scraped_at": datetime.now().isoformat(),
|
|
||||||
"menu_url": url,
|
|
||||||
"api_endpoints_found": api_endpoints,
|
|
||||||
"meal_count": len(meals),
|
|
||||||
"meals": meals,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_card(article) -> dict | None:
|
|
||||||
try:
|
|
||||||
name_el = article.query_selector("h2.meal_title")
|
|
||||||
if not name_el:
|
|
||||||
return None
|
|
||||||
name = name_el.inner_text().strip()
|
|
||||||
|
|
||||||
price_el = article.query_selector(".meal_price")
|
|
||||||
price_text = price_el.inner_text().strip() if price_el else ""
|
|
||||||
price = _parse_price(price_text)
|
|
||||||
|
|
||||||
cal_el = article.query_selector(".card_macros_brief")
|
|
||||||
calories = None
|
|
||||||
protein = None
|
|
||||||
if cal_el:
|
|
||||||
macros_text = cal_el.inner_text()
|
|
||||||
calories, protein = _parse_macros(macros_text)
|
|
||||||
|
|
||||||
tag_els = article.query_selector_all(".diet_mini_tag")
|
|
||||||
dietary_tags = [
|
|
||||||
t.inner_text().strip().title() for t in tag_els if t.inner_text().strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
img_el = article.query_selector("img.main_meal_img")
|
|
||||||
image_url = img_el.get_attribute("src") if img_el else None
|
|
||||||
|
|
||||||
is_new = article.query_selector(".new_badge_pulse") is not None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"price": price,
|
|
||||||
"calories": calories,
|
|
||||||
"protein": protein,
|
|
||||||
"dietary_tags": dietary_tags,
|
|
||||||
"image_url": image_url,
|
|
||||||
"is_new": is_new,
|
|
||||||
"description": None,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Warning: failed to extract a card: {e}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _enrich_with_descriptions(page, articles, meals):
|
|
||||||
"""Click each meal's Quick View overlay to grab the description."""
|
|
||||||
for i, article in enumerate(articles):
|
|
||||||
if i >= len(meals):
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
overlay = article.query_selector(".card_overlay")
|
|
||||||
if not overlay:
|
|
||||||
continue
|
|
||||||
article.query_selector(".card_media_wrap").click()
|
|
||||||
page.wait_for_timeout(800)
|
|
||||||
|
|
||||||
modal = page.query_selector(
|
|
||||||
".modal.show, [class*='modal'][class*='show'], [class*='quickview']"
|
|
||||||
)
|
|
||||||
if not modal:
|
|
||||||
# Try broader selector
|
|
||||||
modal = page.query_selector(
|
|
||||||
"[class*='modal']:not([style*='display: none'])"
|
|
||||||
)
|
|
||||||
if modal and modal.is_visible():
|
|
||||||
desc_el = modal.query_selector(
|
|
||||||
"[class*='description'], [class*='desc'], .meal_description"
|
|
||||||
)
|
|
||||||
if desc_el:
|
|
||||||
desc_text = desc_el.inner_text().strip()
|
|
||||||
# Filter out price strings and very short text
|
|
||||||
if (
|
|
||||||
desc_text
|
|
||||||
and len(desc_text) > 10
|
|
||||||
and not desc_text.startswith("$")
|
|
||||||
):
|
|
||||||
meals[i]["description"] = desc_text
|
|
||||||
|
|
||||||
# Grab full macro details if available
|
|
||||||
detail_tags = modal.query_selector_all(
|
|
||||||
".diet_mini_tag, [class*='lifestyle'] span"
|
|
||||||
)
|
|
||||||
for tag_el in detail_tags:
|
|
||||||
tag_text = tag_el.inner_text().strip().title()
|
|
||||||
if tag_text and tag_text not in meals[i]["dietary_tags"]:
|
|
||||||
meals[i]["dietary_tags"].append(tag_text)
|
|
||||||
|
|
||||||
# Close modal
|
|
||||||
close_btn = modal.query_selector(
|
|
||||||
"button[class*='close'], [aria-label='Close'], .btn-close"
|
|
||||||
)
|
|
||||||
if close_btn:
|
|
||||||
close_btn.click()
|
|
||||||
else:
|
|
||||||
page.keyboard.press("Escape")
|
|
||||||
page.wait_for_timeout(300)
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f" Warning: Quick View failed for meal {i} ({meals[i]['name']}): {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
page.keyboard.press("Escape")
|
|
||||||
page.wait_for_timeout(300)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
KNOWN_TAGS = [
|
|
||||||
"Gluten Free",
|
|
||||||
"Dairy Free",
|
|
||||||
"Grass-Fed",
|
|
||||||
"Low Carb",
|
|
||||||
"Keto",
|
|
||||||
"Vegan",
|
|
||||||
"Vegetarian",
|
|
||||||
"Nut Free",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_tags(raw_tags: list[str]) -> list[str]:
|
|
||||||
"""Split concatenated tags and deduplicate."""
|
|
||||||
import re
|
|
||||||
|
|
||||||
cleaned = set()
|
|
||||||
for raw in raw_tags:
|
|
||||||
# Split on known tag boundaries (e.g., "Gluten Freedairy Free" → "Gluten Free", "Dairy Free")
|
|
||||||
remaining = raw
|
|
||||||
for known in KNOWN_TAGS:
|
|
||||||
if known.lower() in remaining.lower():
|
|
||||||
cleaned.add(known)
|
|
||||||
remaining = re.sub(
|
|
||||||
re.escape(known), "", remaining, flags=re.IGNORECASE
|
|
||||||
).strip()
|
|
||||||
if remaining and len(remaining) > 2:
|
|
||||||
cleaned.add(remaining.strip().title())
|
|
||||||
return sorted(cleaned)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_price(text: str) -> float | None:
|
|
||||||
text = text.replace("$", "").replace(",", "").strip()
|
|
||||||
try:
|
|
||||||
return float(text)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_macros(text: str) -> tuple[int | None, str | None]:
|
|
||||||
"""Parse '570cal • 39gP' into (570, '39g')."""
|
|
||||||
import re
|
|
||||||
|
|
||||||
cal_match = re.search(r"(\d+)\s*cal", text, re.IGNORECASE)
|
|
||||||
prot_match = re.search(r"(\d+g?)\s*P", text)
|
|
||||||
calories = int(cal_match.group(1)) if cal_match else None
|
|
||||||
protein = prot_match.group(1) if prot_match else None
|
|
||||||
if protein and not protein.endswith("g"):
|
|
||||||
protein += "g"
|
|
||||||
return calories, protein
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -261,31 +30,30 @@ def main():
|
||||||
)
|
)
|
||||||
output_dir.mkdir(exist_ok=True)
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
print(f"Scraping menu from {url} ...")
|
print(f"Fetching menu from {url} ...")
|
||||||
|
try:
|
||||||
result = scrape_menu(url)
|
result = scrape_menu(url)
|
||||||
|
except MenuParseError as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
week_str = datetime.now().strftime("%Y-W%U")
|
week_str = datetime.now().strftime("%Y-W%U")
|
||||||
output_file = output_dir / f"menu-{week_str}.json"
|
output_file = output_dir / f"menu-{week_str}.json"
|
||||||
with open(output_file, "w") as f:
|
with open(output_file, "w") as f:
|
||||||
json.dump(result, f, indent=2)
|
json.dump(result, f, indent=2)
|
||||||
|
|
||||||
print(f"\nScraped {result['meal_count']} meals")
|
print(f"\nFetched {result['meal_count']} meals")
|
||||||
if result["api_endpoints_found"]:
|
|
||||||
print("API endpoints detected (potential future shortcut):")
|
|
||||||
for ep in result["api_endpoints_found"]:
|
|
||||||
print(f" {ep}")
|
|
||||||
print(f"Output saved to {output_file}")
|
print(f"Output saved to {output_file}")
|
||||||
|
|
||||||
# Print summary table
|
|
||||||
print(f"\n{'Name':<40} {'Price':>7} {'Cal':>5} {'Prot':>5} {'Tags'}")
|
print(f"\n{'Name':<40} {'Price':>7} {'Cal':>5} {'Prot':>5} {'Tags'}")
|
||||||
print("-" * 90)
|
print("-" * 90)
|
||||||
for m in result["meals"]:
|
for meal in result["meals"]:
|
||||||
tags = ", ".join(m["dietary_tags"]) if m["dietary_tags"] else ""
|
tags = ", ".join(meal["dietary_tags"]) if meal["dietary_tags"] else ""
|
||||||
new = " *NEW*" if m["is_new"] else ""
|
new = " *NEW*" if meal["is_new"] else ""
|
||||||
price = f"${m['price']:.2f}" if m["price"] else "?"
|
price = f"${meal['price']:.2f}" if meal["price"] is not None else "?"
|
||||||
cal = str(m["calories"]) if m["calories"] else "?"
|
cal = str(meal["calories"]) if meal["calories"] else "?"
|
||||||
prot = m["protein"] or "?"
|
prot = meal["protein"] or "?"
|
||||||
print(f"{(m['name'] + new):<40} {price:>7} {cal:>5} {prot:>5} {tags}")
|
print(f"{(meal['name'] + new):<40} {price:>7} {cal:>5} {prot:>5} {tags}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import json
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, request, send_file
|
from flask import Flask, Response, jsonify, request, send_file
|
||||||
|
|
||||||
|
import server.sentry_init # noqa: F401
|
||||||
from server import http_api
|
from server import http_api
|
||||||
|
|
||||||
CORS_ORIGINS = [
|
CORS_ORIGINS = [
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,14 @@ def generate_form(
|
||||||
bulk_discount: float = 0,
|
bulk_discount: float = 0,
|
||||||
company_subsidy: float = 0,
|
company_subsidy: float = 0,
|
||||||
google_client_id: str = "",
|
google_client_id: str = "",
|
||||||
|
week: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
google_client_id = google_client_id.strip()
|
google_client_id = google_client_id.strip()
|
||||||
api_url = (api_url or "").rstrip("/")
|
api_url = (api_url or "").rstrip("/")
|
||||||
if api_url and not google_client_id:
|
if api_url and not google_client_id:
|
||||||
raise ValueError("Google client ID is required in cloud mode")
|
raise ValueError("Google client ID is required in cloud mode")
|
||||||
|
|
||||||
|
if not week:
|
||||||
week = datetime.now().strftime("%Y-W%U")
|
week = datetime.now().strftime("%Y-W%U")
|
||||||
scraped_at = menu.get("scraped_at", "unknown")
|
scraped_at = menu.get("scraped_at", "unknown")
|
||||||
deadline = config.get("order_deadline", "Thursday 11:59 PM")
|
deadline = config.get("order_deadline", "Thursday 11:59 PM")
|
||||||
|
|
|
||||||
|
|
@ -279,15 +279,20 @@ def lambda_handler(event, context):
|
||||||
return response(405, {"error": "Method not allowed"})
|
return response(405, {"error": "Method not allowed"})
|
||||||
|
|
||||||
|
|
||||||
def handle_menu(event):
|
def _week_from_path(event):
|
||||||
"""Return the published menu in the portal's public MenuPayload shape."""
|
|
||||||
requested_week = (event.get("pathParameters") or {}).get("week", "")
|
requested_week = (event.get("pathParameters") or {}).get("week", "")
|
||||||
if requested_week == "current":
|
if requested_week == "current":
|
||||||
week = current_week()
|
return current_week(), None
|
||||||
elif re.fullmatch(r"\d{4}-W\d{2}", requested_week):
|
if re.fullmatch(r"\d{4}-W\d{2}", requested_week):
|
||||||
week = requested_week
|
return requested_week, None
|
||||||
else:
|
return None, response(400, {"error": "week path param must be current or YYYY-WNN"})
|
||||||
return response(400, {"error": "week path param must be current or YYYY-WNN"})
|
|
||||||
|
|
||||||
|
def handle_menu(event):
|
||||||
|
"""Return the published menu in the portal's public MenuPayload shape."""
|
||||||
|
week, error = _week_from_path(event)
|
||||||
|
if error is not None:
|
||||||
|
return error
|
||||||
|
|
||||||
menu = get_menu(week)
|
menu = get_menu(week)
|
||||||
if menu is None:
|
if menu is None:
|
||||||
|
|
@ -358,7 +363,7 @@ def _require_publish_key(event) -> dict | None:
|
||||||
|
|
||||||
|
|
||||||
def handle_publish_settings(event=None):
|
def handle_publish_settings(event=None):
|
||||||
"""Return only the pricing fields needed by the weekly-menu workflow."""
|
"""Return only the pricing fields needed by the manual HMAC publish fallback."""
|
||||||
denied = _require_publish_key(event or {})
|
denied = _require_publish_key(event or {})
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
|
|
@ -623,7 +628,9 @@ def handle_my_orders(event):
|
||||||
|
|
||||||
|
|
||||||
def handle_form_status(event):
|
def handle_form_status(event):
|
||||||
week = event.get("pathParameters", {}).get("week", current_week())
|
week, error = _week_from_path(event)
|
||||||
|
if error is not None:
|
||||||
|
return error
|
||||||
status = get_form_status(week)
|
status = get_form_status(week)
|
||||||
result = {"week": week, "status": status}
|
result = {"week": week, "status": status}
|
||||||
if status == "closed":
|
if status == "closed":
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ def run_job(payload: dict) -> dict:
|
||||||
if event_type == "sync_roster":
|
if event_type == "sync_roster":
|
||||||
from server.jobs.sync_roster import lambda_handler
|
from server.jobs.sync_roster import lambda_handler
|
||||||
|
|
||||||
|
return lambda_handler(payload, None)
|
||||||
|
if event_type == "publish_menu":
|
||||||
|
from server.jobs.publish_menu import lambda_handler
|
||||||
|
|
||||||
return lambda_handler(payload, None)
|
return lambda_handler(payload, None)
|
||||||
from server.jobs.notify import lambda_handler
|
from server.jobs.notify import lambda_handler
|
||||||
|
|
||||||
|
|
|
||||||
131
src/server/jobs/publish_menu.py
Normal file
131
src/server/jobs/publish_menu.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""Monday menu publish: fetch the catalog, write it, upload the form, notify."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from scraper.parse_menu import fetch_menu
|
||||||
|
from server.generate_form import generate_form
|
||||||
|
from shared.db import current_week, get_settings, put_menu
|
||||||
|
from shared.secrets import get_parameter
|
||||||
|
|
||||||
|
DEFAULT_MENU_URL = "https://www.redefinemeals.com/menu"
|
||||||
|
DEFAULT_DEADLINE = "Thursday at 11:59 PM"
|
||||||
|
FORM_BUCKET_PARAM = "/meal-order-manager/deploy/form-bucket"
|
||||||
|
DISTRIBUTION_ID_PARAM = "/meal-order-manager/deploy/distribution-id"
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
del event, context
|
||||||
|
menu_url = os.environ.get("MENU_URL", DEFAULT_MENU_URL).strip() or DEFAULT_MENU_URL
|
||||||
|
menu = fetch_menu(menu_url)
|
||||||
|
week = current_week()
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
bulk_discount = float(settings.get("bulk_discount_percent") or 0)
|
||||||
|
company_subsidy = float(settings.get("company_subsidy_percent") or 0)
|
||||||
|
google_client_id = _google_client_id()
|
||||||
|
bucket = _configured("FORM_BUCKET", "FORM_BUCKET_PARAM", FORM_BUCKET_PARAM)
|
||||||
|
distribution_id = _configured(
|
||||||
|
"DISTRIBUTION_ID", "DISTRIBUTION_ID_PARAM", DISTRIBUTION_ID_PARAM
|
||||||
|
)
|
||||||
|
form_url = os.environ.get("FORM_URL", "").strip()
|
||||||
|
if not form_url:
|
||||||
|
raise RuntimeError("FORM_URL is required")
|
||||||
|
|
||||||
|
html = generate_form(
|
||||||
|
menu,
|
||||||
|
{"order_deadline": DEFAULT_DEADLINE, "roster": []},
|
||||||
|
bulk_discount=bulk_discount,
|
||||||
|
company_subsidy=company_subsidy,
|
||||||
|
google_client_id=google_client_id,
|
||||||
|
week=week,
|
||||||
|
)
|
||||||
|
put_menu(week, menu)
|
||||||
|
_upload_form(bucket, week, html)
|
||||||
|
_invalidate(distribution_id, week, menu.get("scraped_at") or week)
|
||||||
|
_notify(form_url)
|
||||||
|
return {
|
||||||
|
"status": "published",
|
||||||
|
"week": week,
|
||||||
|
"meal_count": menu["meal_count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _google_client_id() -> str:
|
||||||
|
direct = os.environ.get("GOOGLE_CLIENT_ID", "").strip()
|
||||||
|
if direct:
|
||||||
|
return direct
|
||||||
|
param = os.environ.get("GOOGLE_CLIENT_ID_PARAM", "").strip()
|
||||||
|
if not param:
|
||||||
|
raise RuntimeError("GOOGLE_CLIENT_ID_PARAM is required")
|
||||||
|
client_id = get_parameter(param).strip()
|
||||||
|
if not client_id:
|
||||||
|
raise RuntimeError("Google client ID is empty")
|
||||||
|
return client_id
|
||||||
|
|
||||||
|
|
||||||
|
def _configured(env_name: str, param_env: str, default_param: str) -> str:
|
||||||
|
direct = os.environ.get(env_name, "").strip()
|
||||||
|
if direct:
|
||||||
|
return direct
|
||||||
|
param = os.environ.get(param_env, default_param).strip() or default_param
|
||||||
|
value = get_parameter(param).strip()
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(f"{env_name} is empty")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_form(bucket: str, week: str, html: str) -> None:
|
||||||
|
body = html.encode("utf-8")
|
||||||
|
s3 = boto3.client("s3")
|
||||||
|
s3.put_object(
|
||||||
|
Bucket=bucket,
|
||||||
|
Key="index.html",
|
||||||
|
Body=body,
|
||||||
|
ContentType="text/html",
|
||||||
|
CacheControl="no-cache",
|
||||||
|
)
|
||||||
|
s3.put_object(
|
||||||
|
Bucket=bucket,
|
||||||
|
Key=f"archive/{week}.html",
|
||||||
|
Body=body,
|
||||||
|
ContentType="text/html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate(distribution_id: str, week: str, scraped_at: str) -> None:
|
||||||
|
reference = f"publish-menu-{week}-{scraped_at}".replace(":", "-")
|
||||||
|
boto3.client("cloudfront").create_invalidation(
|
||||||
|
DistributionId=distribution_id,
|
||||||
|
InvalidationBatch={
|
||||||
|
"Paths": {"Quantity": 1, "Items": ["/index.html"]},
|
||||||
|
"CallerReference": reference[:128],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify(form_url: str) -> None:
|
||||||
|
from shared.slack import post_channel_message
|
||||||
|
|
||||||
|
text = f"This week's meal order is open! Deadline: {DEFAULT_DEADLINE}."
|
||||||
|
blocks = [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"text": {"type": "plain_text", "text": "Meal Order Open"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f"*<{form_url}|Place your order>*\n\n*Deadline:* {DEFAULT_DEADLINE}\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
result = post_channel_message(text, blocks)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise RuntimeError(f"Slack API error: {result.get('error')}")
|
||||||
177
src/server/sentry_init.py
Normal file
177
src/server/sentry_init.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""Flask Sentry SDK init for meal-order-manager.
|
||||||
|
|
||||||
|
Imported for side effect from ``server.app``. ``init_sentry()`` is a no-op when
|
||||||
|
the DSN is unset, empty, or the literal ``unset``, so pytest and local
|
||||||
|
``python -m server.app`` never talk to Sentry. When ``SENTRY_DSN`` is absent,
|
||||||
|
the DSN is read from SSM via ``SENTRY_DSN_PARAM``. ``ParameterNotFound`` is
|
||||||
|
treated as unset so a mixed-PR race cannot crash gunicorn.
|
||||||
|
``before_send`` strips auth, cookies, the publish HMAC header, request bodies,
|
||||||
|
secrety extras, and exception stack-frame locals.
|
||||||
|
``include_local_variables=False`` keeps those locals out of the event in the
|
||||||
|
first place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||||
|
|
||||||
|
_HEADER_DROP_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"authorization",
|
||||||
|
"x-auth-token",
|
||||||
|
"cookie",
|
||||||
|
"x-amz-security-token",
|
||||||
|
"x-slack-signature",
|
||||||
|
"x-meals-publish-key",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_DROP_REQUEST_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"body",
|
||||||
|
"Body",
|
||||||
|
"data",
|
||||||
|
"cookies",
|
||||||
|
"raw_email",
|
||||||
|
"prompt",
|
||||||
|
"secret",
|
||||||
|
"SecretString",
|
||||||
|
"hmac",
|
||||||
|
"keys",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_DROP_EXTRA_NEEDLES = (
|
||||||
|
"body",
|
||||||
|
"email",
|
||||||
|
"prompt",
|
||||||
|
"secret",
|
||||||
|
"hmac",
|
||||||
|
"token",
|
||||||
|
"mime",
|
||||||
|
"raw_email",
|
||||||
|
"password",
|
||||||
|
"signing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_header(name):
|
||||||
|
lower = str(name).lower()
|
||||||
|
return lower in _HEADER_DROP_NAMES or lower.startswith("x-amz-")
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_headers(headers):
|
||||||
|
if isinstance(headers, dict):
|
||||||
|
return {k: v for k, v in headers.items() if not _drop_header(k)}
|
||||||
|
if isinstance(headers, list):
|
||||||
|
kept = []
|
||||||
|
for pair in headers:
|
||||||
|
if isinstance(pair, (list, tuple)) and pair and _drop_header(pair[0]):
|
||||||
|
continue
|
||||||
|
kept.append(pair)
|
||||||
|
return kept
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _stacktraces(event):
|
||||||
|
traces = []
|
||||||
|
stacktrace = event.get("stacktrace")
|
||||||
|
if isinstance(stacktrace, dict):
|
||||||
|
traces.append(stacktrace)
|
||||||
|
for section in ("exception", "threads"):
|
||||||
|
container = event.get(section)
|
||||||
|
if not isinstance(container, dict):
|
||||||
|
continue
|
||||||
|
values = container.get("values")
|
||||||
|
if not isinstance(values, list):
|
||||||
|
continue
|
||||||
|
for item in values:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
inner = item.get("stacktrace")
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
traces.append(inner)
|
||||||
|
return traces
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_stack_locals(event):
|
||||||
|
"""Drop frame locals. Names like ``raw``/``item`` still hold secrets."""
|
||||||
|
for stacktrace in _stacktraces(event):
|
||||||
|
frames = stacktrace.get("frames")
|
||||||
|
if not isinstance(frames, list):
|
||||||
|
continue
|
||||||
|
for frame in frames:
|
||||||
|
if isinstance(frame, dict):
|
||||||
|
frame.pop("vars", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _before_send(event, _hint):
|
||||||
|
request = event.get("request")
|
||||||
|
if isinstance(request, dict):
|
||||||
|
headers = request.get("headers")
|
||||||
|
if headers is not None:
|
||||||
|
request["headers"] = _scrub_headers(headers)
|
||||||
|
for key in list(request):
|
||||||
|
if key in _DROP_REQUEST_KEYS or str(key).lower() in {"body", "data"}:
|
||||||
|
request.pop(key, None)
|
||||||
|
extra = event.get("extra")
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
for key in list(extra):
|
||||||
|
lower = str(key).lower()
|
||||||
|
if any(needle in lower for needle in _DROP_EXTRA_NEEDLES):
|
||||||
|
extra.pop(key, None)
|
||||||
|
_strip_stack_locals(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def _is_parameter_not_found(exc: Exception) -> bool:
|
||||||
|
response_data = getattr(exc, "response", {})
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return False
|
||||||
|
return response_data.get("Error", {}).get("Code") == "ParameterNotFound"
|
||||||
|
|
||||||
|
|
||||||
|
def _dsn_from_param() -> str:
|
||||||
|
param = os.environ.get("SENTRY_DSN_PARAM", "").strip()
|
||||||
|
if not param:
|
||||||
|
return ""
|
||||||
|
from shared.secrets import get_parameter
|
||||||
|
|
||||||
|
try:
|
||||||
|
return get_parameter(param, decrypt=True).strip()
|
||||||
|
except ClientError as exc:
|
||||||
|
if _is_parameter_not_found(exc):
|
||||||
|
return ""
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_dsn() -> str:
|
||||||
|
dsn = os.environ.get("SENTRY_DSN", "").strip()
|
||||||
|
if dsn:
|
||||||
|
return dsn
|
||||||
|
return _dsn_from_param()
|
||||||
|
|
||||||
|
|
||||||
|
def init_sentry() -> None:
|
||||||
|
dsn = _resolve_dsn()
|
||||||
|
if not dsn or dsn.lower() == "unset":
|
||||||
|
return
|
||||||
|
kwargs = {
|
||||||
|
"dsn": dsn,
|
||||||
|
"integrations": [FlaskIntegration()],
|
||||||
|
"send_default_pii": False,
|
||||||
|
"include_local_variables": False,
|
||||||
|
"enable_logs": False,
|
||||||
|
"traces_sample_rate": 0.0,
|
||||||
|
"before_send": _before_send,
|
||||||
|
"environment": os.environ.get("STAGE", "").strip() or "local",
|
||||||
|
}
|
||||||
|
sha = os.environ.get("GIT_SHA", "").strip()
|
||||||
|
if sha:
|
||||||
|
kwargs["release"] = sha
|
||||||
|
sentry_sdk.init(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
init_sentry()
|
||||||
|
|
@ -29,6 +29,7 @@ let countdownTimer = null;
|
||||||
let mealsListDelegationBound = false;
|
let mealsListDelegationBound = false;
|
||||||
|
|
||||||
const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
|
const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
|
||||||
|
const GOOGLE_SESSION_KEY = "seahaven.meals.googleIdToken";
|
||||||
|
|
||||||
window.googleCredential = null;
|
window.googleCredential = null;
|
||||||
|
|
||||||
|
|
@ -95,6 +96,68 @@ function waitForGoogleAuth(attempt = 0) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeJwtPayload(token) {
|
||||||
|
if (!token || typeof token !== "string") return null;
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
try {
|
||||||
|
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
||||||
|
const json = new TextDecoder().decode(
|
||||||
|
Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)),
|
||||||
|
);
|
||||||
|
return JSON.parse(json);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleTokenIsUsable(token) {
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
if (!payload || !payload.email) return false;
|
||||||
|
if (typeof payload.exp === "number" && payload.exp * 1000 <= Date.now()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (GOOGLE_CLIENT_ID && payload.aud && payload.aud !== GOOGLE_CLIENT_ID) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredGoogleToken() {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(GOOGLE_SESSION_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistGoogleSession(token) {
|
||||||
|
try {
|
||||||
|
if (googleTokenIsUsable(token)) {
|
||||||
|
sessionStorage.setItem(GOOGLE_SESSION_KEY, token);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(GOOGLE_SESSION_KEY);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGoogleSession() {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(GOOGLE_SESSION_KEY);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreGoogleSession() {
|
||||||
|
const token = readStoredGoogleToken();
|
||||||
|
if (!googleTokenIsUsable(token)) {
|
||||||
|
if (token) clearGoogleSession();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
handleCredentialResponse({ credential: token });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function initGoogleAuth() {
|
function initGoogleAuth() {
|
||||||
google.accounts.id.initialize({
|
google.accounts.id.initialize({
|
||||||
client_id: GOOGLE_CLIENT_ID,
|
client_id: GOOGLE_CLIENT_ID,
|
||||||
|
|
@ -107,17 +170,19 @@ function initGoogleAuth() {
|
||||||
text: "signin_with",
|
text: "signin_with",
|
||||||
width: 300,
|
width: 300,
|
||||||
});
|
});
|
||||||
|
if (restoreGoogleSession()) return;
|
||||||
|
try {
|
||||||
|
google.accounts.id.prompt();
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCredentialResponse(response) {
|
function handleCredentialResponse(response) {
|
||||||
googleCredential = response.credential;
|
const token = response && response.credential;
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
if (!payload || !payload.email) return;
|
||||||
|
googleCredential = token;
|
||||||
syncGoogleCredential();
|
syncGoogleCredential();
|
||||||
const b64 = response.credential
|
persistGoogleSession(token);
|
||||||
.split(".")[1]
|
|
||||||
.replace(/-/g, "+")
|
|
||||||
.replace(/_/g, "/");
|
|
||||||
const payloadBytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
||||||
const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
|
|
||||||
googleUser = { name: payload.name, email: payload.email };
|
googleUser = { name: payload.name, email: payload.email };
|
||||||
|
|
||||||
document.getElementById("auth-overlay").classList.add("is-hidden");
|
document.getElementById("auth-overlay").classList.add("is-hidden");
|
||||||
|
|
@ -149,6 +214,7 @@ function signOut() {
|
||||||
googleCredential = null;
|
googleCredential = null;
|
||||||
googleUser = null;
|
googleUser = null;
|
||||||
syncGoogleCredential();
|
syncGoogleCredential();
|
||||||
|
clearGoogleSession();
|
||||||
if (
|
if (
|
||||||
CONFIG.authMode === "google" &&
|
CONFIG.authMode === "google" &&
|
||||||
typeof google !== "undefined" &&
|
typeof google !== "undefined" &&
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
|
import sentry_sdk
|
||||||
|
|
||||||
from server.jobs import run_job
|
from server.jobs import run_job
|
||||||
|
from server.sentry_init import init_sentry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
||||||
|
|
@ -27,6 +29,7 @@ def _stop(_signum, _frame) -> None:
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
signal.signal(signal.SIGTERM, _stop)
|
signal.signal(signal.SIGTERM, _stop)
|
||||||
signal.signal(signal.SIGINT, _stop)
|
signal.signal(signal.SIGINT, _stop)
|
||||||
|
init_sentry()
|
||||||
|
|
||||||
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
|
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
|
||||||
if not queue_url:
|
if not queue_url:
|
||||||
|
|
@ -57,6 +60,7 @@ def main() -> None:
|
||||||
continue
|
continue
|
||||||
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt)
|
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
sentry_sdk.capture_exception()
|
||||||
logger.exception("job failed; leaving message for retry")
|
logger.exception("job failed; leaving message for retry")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
boto3==1.43.97
|
boto3==1.43.107
|
||||||
fpdf2==2.8.8
|
fpdf2==2.8.9
|
||||||
PyJWT[crypto]==2.14.0
|
PyJWT[crypto]==2.15.1
|
||||||
|
|
|
||||||
106
terraform/.terraform.lock.hcl
generated
106
terraform/.terraform.lock.hcl
generated
|
|
@ -2,61 +2,71 @@
|
||||||
# Manual edits may be lost in future updates.
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
provider "registry.terraform.io/hashicorp/aws" {
|
provider "registry.terraform.io/hashicorp/aws" {
|
||||||
version = "6.65.0"
|
version = "6.67.0"
|
||||||
constraints = "6.65.0"
|
constraints = "6.67.0"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=",
|
"h1:01Y50Z+67vWZmsW9e8FQTj9NT/XW+x1n0YGdz2x4BpY=",
|
||||||
"h1:1QFvuV0+K3GieeXSlb7qi9Q334XrsnMP8dCRwpM7fkc=",
|
"h1:6dffiL4BGVg+Ac2/64N+svhucYstBnVTTu55hWEhmq8=",
|
||||||
"h1:36ZBGQTzM6dW8dLQ145loI9yw6/fSbRV+fvLGCeEubc=",
|
"h1:8kRViFkyn96SufnuV3Oi3QmfL+DiyxlhAPcSX2jH0T0=",
|
||||||
"h1:4uHlr+eDGOjf71giwLidIfGsMP6g5x2wkSGP5xq9EpM=",
|
"h1:EAfdlvAeLCxhO9G5nnZ6Ti1zsmQP1aClgS41rneOijY=",
|
||||||
"h1:9fSxZKfaAGrNSzXIz53IP2EmC1LYdO/fGYl7T87Y36Q=",
|
"h1:EB9ixYOZrSlYD7wtJxf88qwoyyWrlKDKxhzaCLIb3t4=",
|
||||||
"h1:GJCK46UtrJMGSyByH4SiDytbwX11bg79jvTGZ27BGWU=",
|
"h1:Ksjd+RJVccOFRlbg3gpoJjL7T3SMPHxso2dPzAVTyRE=",
|
||||||
"h1:H0qzEAMrqydb8eTZdebso8nS/b8w1BNHysP8nmM4pLk=",
|
"h1:OgdIUAQDtJBxlKjoPgChD1w57vl6hm6PyJHiBYgsgQA=",
|
||||||
"h1:RjeO6m/SvlhGUCDrwTdj99kJhKl/rC1zE9B5mi3YhVw=",
|
"h1:Syy68cIDOz7sXpxhjrkCwNHvmOj9VeaLVTJ/XnUKSuU=",
|
||||||
"h1:Yx8Kv/T/BHVE8S1WEyHsFdu4HcsAQIl5e/831DeoQ5k=",
|
"h1:Tz5wi4X4Gkj2I1Gif4MOtfrj4JerCz+5KqSi6Xj+n/A=",
|
||||||
"h1:ZFDxAUFzk1A2BPbLgu1LhAJ3erD4BbqZsF25yQobN4o=",
|
"h1:XaBXhLvtX5lUYkv5fHKzzMfoZXaIh5zU8hvM4jG2TXI=",
|
||||||
"h1:anUZ356aBWvbHzQalF036qNKO+RISPmq6ycIL4OdXxk=",
|
"h1:fOwuAcOFTGOU0GY1m4NHhDgTAdTSiU+9qZ+REdp5HIU=",
|
||||||
"h1:fhsSsZmfNFf4wErbcsmu1/ek1/TVUy7NCuMYeOpA1aE=",
|
"h1:gyduBRrLO8EiRf8zA1aiLRoWydrUMw+TG0pUbOXo1g4=",
|
||||||
"h1:l+w5eqL9UqpiVZ2ll0r+YvWAPjIL/WtqV8xqfH1+apM=",
|
"h1:iDfjW95J79sAMaOxeln73bKerm9SBemvLTrKepODumw=",
|
||||||
"h1:nt0kyMKN9kDNXKHOejaAo7LQIemP3tyntYjxHY32P0M=",
|
"h1:xH+es0QQOteWlNptLqZzI6k8y94Aq/11S7lozotvO2c=",
|
||||||
"h1:o2tj5YHQU1QNgANW2YAbjj0opl0BRJZl8W9trjYsqdA=",
|
"h1:zLmFaFw5LptpWftHL3O6+7uykOH/0F9AmyVQ7V6kslY=",
|
||||||
"zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be",
|
"zh:111d5686a1f4ccbc888bb5e2229308bcdd9149898c6af97969fcdfb0e7bd2aa0",
|
||||||
"zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3",
|
"zh:21b3b7693bd9754c039fd546f03ed09e7510c6189aa5ee37176f144cf8dd5f95",
|
||||||
"zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917",
|
"zh:25e03c7f025ff4851889537605aa560d2e39bd738b33a5204b8037eb164b475f",
|
||||||
"zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8",
|
"zh:2817a046f1060e25bf04b0eb78f4e251130bb92dc82ec1264ce156dc9bf78e67",
|
||||||
"zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09",
|
"zh:2d318d674c3ec9dbd97ddb10b12ad4827be4f508c43ba2ae1e0523baa9e367e3",
|
||||||
"zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8",
|
"zh:2f07ab356718267299e10b6072472ded29d82753883027bf43bcd687ac72feb2",
|
||||||
"zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7",
|
"zh:32307e67f83bc0dc94d38cfcd23d782166a09e0e975e2a5aa7d7a3e7ca5d3da8",
|
||||||
"zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5",
|
"zh:4ce94853264097dd7f4542b3cfd18d2b98b06d23321db45d5e384ea275a57f63",
|
||||||
"zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294",
|
"zh:869656c41cc7c7412f213e488f98167cff61deafcb8cf245d25d056e8dfdc263",
|
||||||
"zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e",
|
"zh:8fd8c814e9d8edf152552047db23bf5b5d63f22e6b0b5d47b2af851376e99349",
|
||||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||||
"zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f",
|
"zh:9cbc02ceef9bd469da497a1e7065ff984bdacfbe919ac3cce804a86c13b6e2c6",
|
||||||
"zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab",
|
"zh:9ea55dda2767acfc1f6337fc7d29b1d4d79d6f8f04871f8ad99a6078d2865994",
|
||||||
"zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af",
|
"zh:d7149df0819fb57a160489db45d33951765b8f0118daa3b4cd549a0135bc97a1",
|
||||||
"zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87",
|
"zh:e5819937bb7043d08c9829b32d52d9fb55a5b9a4d8d55d550d47a3d7392db546",
|
||||||
"zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59",
|
"zh:f93bc38dbc0ad53842303f30eec7a22c525c4d56209b54ec33a8d375cfc4e4fd",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "registry.terraform.io/hashicorp/random" {
|
provider "registry.terraform.io/hashicorp/random" {
|
||||||
version = "3.8.1"
|
version = "3.9.1"
|
||||||
constraints = "3.8.1"
|
constraints = "3.9.1"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:Eexl06+6J+s75uD46+WnZtpJZYRVUMB0AiuPBifK6Jc=",
|
"h1:0rmWNCsP90oLS9KtLiqcNmkRzsaOZD95THXyuGYUfOM=",
|
||||||
"h1:fdfOl1HabDT42XLH8qjmfTbVZpgQZ5lyOyOa+GQhm0w=",
|
"h1:4AN630toDK+4Is2MQcbGA37yR56nIEDPO0qv8ivZD2M=",
|
||||||
"h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=",
|
"h1:7uiStw0Rl9KOdX5UNMG/sp9nyadoD4LZekQTiYlYPhE=",
|
||||||
"zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4",
|
"h1:9RiO3l/4iDguiVMryazTvf8ZAOAwbS0LPWl/XRo2El4=",
|
||||||
"zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae",
|
"h1:AjDEYpTXfyc3CupsrJYjgxCYnqxdpCLPkaLKPiw9WTA=",
|
||||||
"zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57",
|
"h1:PYbnOqRuGn4c0/Ae1f7yOS/0zvmXNHFJRZjuF4KECnM=",
|
||||||
"zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0",
|
"h1:PlW+UZ4EElQF3NQwf41KQwavFujab3Czc51zu9dyVM8=",
|
||||||
"zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66",
|
"h1:VGeIpAn+nL6i8a6Y1W7XIq+Z6XFIWu5al4f2PNageoo=",
|
||||||
"zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511",
|
"h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=",
|
||||||
|
"h1:nozfr4CZq73d4HjubKJundX/8A+Mj282oS/hyNfjUPU=",
|
||||||
|
"h1:uFgaD8lhFrHzFm88V3/+wypq5AzCUdzXR60vLSlZ2cI=",
|
||||||
|
"h1:xxRd4yd9LvPiDqeiGQj7FhZHwD08tDcIToAAePfoahE=",
|
||||||
|
"zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7",
|
||||||
|
"zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26",
|
||||||
|
"zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2",
|
||||||
|
"zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1",
|
||||||
|
"zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb",
|
||||||
|
"zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f",
|
||||||
|
"zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa",
|
||||||
|
"zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097",
|
||||||
|
"zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5",
|
||||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||||
"zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9",
|
"zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b",
|
||||||
"zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05",
|
"zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc",
|
||||||
"zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8",
|
"zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c",
|
||||||
"zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b",
|
|
||||||
"zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,13 @@ check "dev_has_no_custom_domain" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check "prod_reuses_afterhours_vpc" {
|
||||||
|
assert {
|
||||||
|
condition = !local.is_prod || var.existing_vpc_id != ""
|
||||||
|
error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
check "existing_vpc_pair" {
|
check "existing_vpc_pair" {
|
||||||
assert {
|
assert {
|
||||||
condition = (var.existing_vpc_id == "") == (length(var.existing_public_subnet_ids) == 0)
|
condition = (var.existing_vpc_id == "") == (length(var.existing_public_subnet_ids) == 0)
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ locals {
|
||||||
{ name = "PORTAL_COGNITO_AUDIENCE_PARAM", value = aws_ssm_parameter.portal_cognito_audience.name },
|
{ name = "PORTAL_COGNITO_AUDIENCE_PARAM", value = aws_ssm_parameter.portal_cognito_audience.name },
|
||||||
{ name = "PORTAL_COGNITO_TRUST_PARAM", value = aws_ssm_parameter.portal_cognito_trust.name },
|
{ name = "PORTAL_COGNITO_TRUST_PARAM", value = aws_ssm_parameter.portal_cognito_trust.name },
|
||||||
{ name = "PUBLISH_KEY_PARAM", value = aws_ssm_parameter.publish_key.name },
|
{ name = "PUBLISH_KEY_PARAM", value = aws_ssm_parameter.publish_key.name },
|
||||||
|
{ name = "SENTRY_DSN_PARAM", value = aws_ssm_parameter.sentry_dsn.name },
|
||||||
{ name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id },
|
{ name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id },
|
||||||
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
|
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
|
||||||
{ name = "AWS_DEFAULT_REGION", value = var.aws_region },
|
{ name = "AWS_DEFAULT_REGION", value = var.aws_region },
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,8 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
|
||||||
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/githubdeploy-meal-order-manager"]
|
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/githubdeploy-meal-order-manager"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Kept so this apply can delete githubdeploy-meal-order-manager-weekly-menu.
|
||||||
|
# Drop the statement after that role is gone.
|
||||||
statement {
|
statement {
|
||||||
sid = "WriteDeployRoles"
|
sid = "WriteDeployRoles"
|
||||||
effect = "Allow"
|
effect = "Allow"
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,23 @@ data "aws_iam_policy_document" "ecs_task_boundary" {
|
||||||
resources = ["${aws_s3_bucket.reports.arn}/*"]
|
resources = ["${aws_s3_bucket.reports.arn}/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "FormObjects"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["s3:PutObject"]
|
||||||
|
resources = [
|
||||||
|
"${aws_s3_bucket.form.arn}/index.html",
|
||||||
|
"${aws_s3_bucket.form.arn}/archive/*.html",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "InvalidateForm"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["cloudfront:CreateInvalidation"]
|
||||||
|
resources = [aws_cloudfront_distribution.form.arn]
|
||||||
|
}
|
||||||
|
|
||||||
statement {
|
statement {
|
||||||
sid = "JobsQueue"
|
sid = "JobsQueue"
|
||||||
effect = "Allow"
|
effect = "Allow"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# GitHub Actions OIDC role for .github/workflows/deploy-api.yaml.
|
# GitHub Actions OIDC role for the thin deploy-api.yaml caller of
|
||||||
|
# org reusable cd-hcp-fargate.yaml.
|
||||||
|
|
||||||
data "aws_iam_policy_document" "github_deploy_assume" {
|
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
statement {
|
statement {
|
||||||
|
|
@ -26,12 +27,13 @@ data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# AWS STS GitHub condition keys include job_workflow_ref, not workflow_ref.
|
||||||
|
# A workflow_ref condition fail-closes AssumeRoleWithWebIdentity.
|
||||||
condition {
|
condition {
|
||||||
test = "StringLike"
|
test = "StringLike"
|
||||||
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
||||||
values = [
|
values = [
|
||||||
"Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/heads/main",
|
"Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*",
|
||||||
"Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/tags/v*",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
# GitHub Actions OIDC role for .github/workflows/weekly-menu.yml.
|
|
||||||
#
|
|
||||||
# Trust is pinned three ways (aud, sub to main, job_workflow_ref to the
|
|
||||||
# weekly-menu workflow at main) so no other workflow in the repo can assume it.
|
|
||||||
# Permissions mirror the mgmt github-oidc-deploy-roles weekly-menu role, retargeted
|
|
||||||
# to prod resources and without form-api-key (SigV4 publish path).
|
|
||||||
#
|
|
||||||
# OIDC provider ARN is literal (not a data source): hcptf-meal-order-manager-plan
|
|
||||||
# lacks iam:GetOpenIDConnectProvider, and the provider is account-stable.
|
|
||||||
|
|
||||||
data "aws_iam_policy_document" "weekly_menu_assume" {
|
|
||||||
statement {
|
|
||||||
effect = "Allow"
|
|
||||||
actions = ["sts:AssumeRoleWithWebIdentity"]
|
|
||||||
|
|
||||||
principals {
|
|
||||||
type = "Federated"
|
|
||||||
identifiers = [local.github_oidc_provider_arn]
|
|
||||||
}
|
|
||||||
|
|
||||||
condition {
|
|
||||||
test = "StringEquals"
|
|
||||||
variable = "token.actions.githubusercontent.com:aud"
|
|
||||||
values = ["sts.amazonaws.com"]
|
|
||||||
}
|
|
||||||
|
|
||||||
condition {
|
|
||||||
test = "StringEquals"
|
|
||||||
variable = "token.actions.githubusercontent.com:sub"
|
|
||||||
values = ["repo:Sea-Haven-Industries/meal-order-manager:ref:refs/heads/main"]
|
|
||||||
}
|
|
||||||
|
|
||||||
condition {
|
|
||||||
test = "StringEquals"
|
|
||||||
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
|
||||||
values = ["Sea-Haven-Industries/meal-order-manager/.github/workflows/weekly-menu.yml@refs/heads/main"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_role" "weekly_menu" {
|
|
||||||
name = "githubdeploy-meal-order-manager-weekly-menu"
|
|
||||||
path = "/tf-managed/"
|
|
||||||
description = "GitHub Actions weekly-menu scrape/publish for meal-order-manager"
|
|
||||||
assume_role_policy = data.aws_iam_policy_document.weekly_menu_assume.json
|
|
||||||
max_session_duration = 3600
|
|
||||||
|
|
||||||
# Not a Lambda execution role. Config omits permissions_boundary so a later
|
|
||||||
# apply will not PutRolePermissionsBoundary the Lambda ceiling back. Live still
|
|
||||||
# has seahaven-lambda-execution-boundary; omitting without ignore_changes would
|
|
||||||
# plan DeleteRolePermissionsBoundary, which hcptf-meal-order-manager is denied
|
|
||||||
# (DenyBoundaryTampering). Ignore the attribute so this apply does not touch
|
|
||||||
# the ceiling. An administrator deletes the live attachment, then a follow-up
|
|
||||||
# drops this lifecycle after refresh-only updates state to null.
|
|
||||||
lifecycle {
|
|
||||||
ignore_changes = [permissions_boundary]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data "aws_iam_policy_document" "weekly_menu" {
|
|
||||||
statement {
|
|
||||||
sid = "SlackBotSecret"
|
|
||||||
effect = "Allow"
|
|
||||||
actions = [
|
|
||||||
"secretsmanager:GetSecretValue",
|
|
||||||
]
|
|
||||||
resources = [var.slack_bot_secret_arn]
|
|
||||||
}
|
|
||||||
|
|
||||||
statement {
|
|
||||||
sid = "DeployAndAppParams"
|
|
||||||
effect = "Allow"
|
|
||||||
actions = [
|
|
||||||
"ssm:GetParameter",
|
|
||||||
]
|
|
||||||
resources = [
|
|
||||||
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/api-url",
|
|
||||||
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/form-bucket",
|
|
||||||
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/distribution-id",
|
|
||||||
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/form-url",
|
|
||||||
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.google_client_id_param}",
|
|
||||||
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.slack_channel_param}",
|
|
||||||
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/publish-key",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
statement {
|
|
||||||
sid = "FormObjects"
|
|
||||||
effect = "Allow"
|
|
||||||
actions = [
|
|
||||||
"s3:PutObject",
|
|
||||||
]
|
|
||||||
resources = [
|
|
||||||
"${aws_s3_bucket.form.arn}/index.html",
|
|
||||||
"${aws_s3_bucket.form.arn}/archive/*.html",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
statement {
|
|
||||||
sid = "InvalidateForm"
|
|
||||||
effect = "Allow"
|
|
||||||
actions = [
|
|
||||||
"cloudfront:CreateInvalidation",
|
|
||||||
]
|
|
||||||
resources = [aws_cloudfront_distribution.form.arn]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_role_policy" "weekly_menu" {
|
|
||||||
name = "weekly-menu-publish"
|
|
||||||
role = aws_iam_role.weekly_menu.id
|
|
||||||
policy = data.aws_iam_policy_document.weekly_menu.json
|
|
||||||
}
|
|
||||||
|
|
@ -7,8 +7,9 @@ locals {
|
||||||
|
|
||||||
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
|
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
|
||||||
|
|
||||||
# Created only when existing_vpc_id is empty. 10.60 is unused in 011934824531.
|
# Created only when existing_vpc_id is empty. Prod attaches to afterhours 10.70.
|
||||||
manage_vpc = var.existing_vpc_id == ""
|
# 10.60 is the unused fallback CIDR, not a second prod VPC.
|
||||||
|
manage_vpc = var.existing_vpc_id == "" && !local.is_prod
|
||||||
vpc_cidr = "10.60.0.0/16"
|
vpc_cidr = "10.60.0.0/16"
|
||||||
public_subnet_cidrs = ["10.60.0.0/24", "10.60.1.0/24"]
|
public_subnet_cidrs = ["10.60.0.0/24", "10.60.1.0/24"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,6 @@ output "orders_table_name" {
|
||||||
value = aws_dynamodb_table.orders.name
|
value = aws_dynamodb_table.orders.name
|
||||||
}
|
}
|
||||||
|
|
||||||
output "weekly_menu_role_arn" {
|
|
||||||
description = "OIDC role ARN for .github/workflows/weekly-menu.yml (repo secret AWS_WEEKLY_MENU_ROLE_ARN)."
|
|
||||||
value = aws_iam_role.weekly_menu.arn
|
|
||||||
}
|
|
||||||
|
|
||||||
output "github_deploy_role_arn" {
|
output "github_deploy_role_arn" {
|
||||||
description = "OIDC role ARN for .github/workflows/deploy-api.yaml (Environment DEPLOY_ROLE_ARN)."
|
description = "OIDC role ARN for .github/workflows/deploy-api.yaml (Environment DEPLOY_ROLE_ARN)."
|
||||||
value = aws_iam_role.github_deploy.arn
|
value = aws_iam_role.github_deploy.arn
|
||||||
|
|
@ -57,3 +52,13 @@ output "ecs_task_role_arn" {
|
||||||
description = "ECS task role; paychex-checkcomponents queue policy must allow this ARN."
|
description = "ECS task role; paychex-checkcomponents queue policy must allow this ARN."
|
||||||
value = aws_iam_role.ecs_task.arn
|
value = aws_iam_role.ecs_task.arn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output "vpc_id" {
|
||||||
|
description = "VPC the ALB and Fargate tasks run in. Prod attaches to afterhours."
|
||||||
|
value = local.vpc_id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "public_subnet_ids" {
|
||||||
|
description = "Public subnet IDs for the ALB and Fargate tasks."
|
||||||
|
value = local.public_subnet_ids
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ locals {
|
||||||
schedule = "cron(55 6 ? * MON *)"
|
schedule = "cron(55 6 ? * MON *)"
|
||||||
event = "sync_roster"
|
event = "sync_roster"
|
||||||
}
|
}
|
||||||
|
publish-menu = {
|
||||||
|
description = "Publish the weekly menu Monday 7:30am Eastern"
|
||||||
|
schedule = "cron(30 7 ? * MON *)"
|
||||||
|
event = "publish_menu"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,17 @@
|
||||||
# created and rotated out-of-band because it varies per environment; data.tf
|
# created and rotated out-of-band because it varies per environment; data.tf
|
||||||
# reads it. Do not turn that lookup into a resource.
|
# reads it. Do not turn that lookup into a resource.
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "sentry_dsn" {
|
||||||
|
name = "${local.ssm_prefix}/sentry-dsn"
|
||||||
|
type = "SecureString"
|
||||||
|
value = "unset"
|
||||||
|
description = "Sentry DSN for meal-order-manager. PutParameter writes the live value; Terraform ignores it. Empty or unset disables the SDK."
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resource "aws_ssm_parameter" "slack_channel_id" {
|
resource "aws_ssm_parameter" "slack_channel_id" {
|
||||||
name = local.slack_channel_param
|
name = local.slack_channel_param
|
||||||
type = "String"
|
type = "String"
|
||||||
|
|
@ -39,9 +50,8 @@ resource "aws_ssm_parameter" "portal_cognito_trust" {
|
||||||
# Deploy-time lookups
|
# Deploy-time lookups
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
#
|
#
|
||||||
# These replace the CloudFormation stack outputs that
|
# Deploy targets for the image workflow and the publish_menu job.
|
||||||
# .github/workflows/weekly-menu.yml used to read, so the job can resolve its
|
# There is no CloudFormation stack.
|
||||||
# deploy targets without a CloudFormation stack.
|
|
||||||
|
|
||||||
resource "aws_ssm_parameter" "deploy_api_url" {
|
resource "aws_ssm_parameter" "deploy_api_url" {
|
||||||
name = "${local.ssm_prefix}/deploy/api-url"
|
name = "${local.ssm_prefix}/deploy/api-url"
|
||||||
|
|
@ -89,19 +99,19 @@ resource "aws_ssm_parameter" "deploy_form_bucket" {
|
||||||
name = "${local.ssm_prefix}/deploy/form-bucket"
|
name = "${local.ssm_prefix}/deploy/form-bucket"
|
||||||
type = "String"
|
type = "String"
|
||||||
value = aws_s3_bucket.form.id
|
value = aws_s3_bucket.form.id
|
||||||
description = "S3 bucket holding the order form; sync target for the weekly-menu deploy job"
|
description = "S3 bucket holding the order form; upload target for the publish_menu job"
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_ssm_parameter" "deploy_distribution_id" {
|
resource "aws_ssm_parameter" "deploy_distribution_id" {
|
||||||
name = "${local.ssm_prefix}/deploy/distribution-id"
|
name = "${local.ssm_prefix}/deploy/distribution-id"
|
||||||
type = "String"
|
type = "String"
|
||||||
value = aws_cloudfront_distribution.form.id
|
value = aws_cloudfront_distribution.form.id
|
||||||
description = "CloudFront distribution ID; cache-invalidation target for the weekly-menu deploy job"
|
description = "CloudFront distribution ID; cache-invalidation target for the publish_menu job"
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_ssm_parameter" "deploy_form_url" {
|
resource "aws_ssm_parameter" "deploy_form_url" {
|
||||||
name = "${local.ssm_prefix}/deploy/form-url"
|
name = "${local.ssm_prefix}/deploy/form-url"
|
||||||
type = "String"
|
type = "String"
|
||||||
value = local.form_url
|
value = local.form_url
|
||||||
description = "Public order form URL; reported by the weekly-menu deploy job"
|
description = "Public order form URL; linked from the publish_menu Slack post"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ variable "checkcomponents_queue_arn" {
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "existing_vpc_id" {
|
variable "existing_vpc_id" {
|
||||||
description = "When set, place the ALB and Fargate tasks in this VPC instead of creating one."
|
description = "When set, place the ALB and Fargate tasks in this VPC instead of creating one. Prod attaches to the afterhours VPC."
|
||||||
type = string
|
type = string
|
||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ terraform {
|
||||||
required_providers {
|
required_providers {
|
||||||
aws = {
|
aws = {
|
||||||
source = "hashicorp/aws"
|
source = "hashicorp/aws"
|
||||||
version = "6.65.0"
|
version = "6.67.0"
|
||||||
}
|
}
|
||||||
random = {
|
random = {
|
||||||
source = "hashicorp/random"
|
source = "hashicorp/random"
|
||||||
version = "3.8.1"
|
version = "3.9.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ data "aws_availability_zones" "available" {
|
||||||
}
|
}
|
||||||
|
|
||||||
data "aws_vpc" "existing" {
|
data "aws_vpc" "existing" {
|
||||||
count = local.manage_vpc ? 0 : 1
|
count = var.existing_vpc_id == "" ? 0 : 1
|
||||||
id = var.existing_vpc_id
|
id = var.existing_vpc_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13,6 +13,17 @@ data "aws_subnet" "existing_public" {
|
||||||
id = each.value
|
id = each.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resource "terraform_data" "prod_requires_afterhours_vpc" {
|
||||||
|
input = var.existing_vpc_id
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
precondition {
|
||||||
|
condition = !local.is_prod || var.existing_vpc_id != ""
|
||||||
|
error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resource "aws_vpc" "this" {
|
resource "aws_vpc" "this" {
|
||||||
count = local.manage_vpc ? 1 : 0
|
count = local.manage_vpc ? 1 : 0
|
||||||
|
|
||||||
|
|
@ -24,6 +35,13 @@ resource "aws_vpc" "this" {
|
||||||
Name = "${local.project}-vpc"
|
Name = "${local.project}-vpc"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
precondition {
|
||||||
|
condition = !local.is_prod
|
||||||
|
error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# First apply updates the live hcptf apply role before CreateVpc.
|
# First apply updates the live hcptf apply role before CreateVpc.
|
||||||
depends_on = [
|
depends_on = [
|
||||||
aws_iam_role_policy_attachments_exclusive.hcptf_apply,
|
aws_iam_role_policy_attachments_exclusive.hcptf_apply,
|
||||||
|
|
@ -80,7 +98,7 @@ resource "aws_route_table_association" "public" {
|
||||||
}
|
}
|
||||||
|
|
||||||
locals {
|
locals {
|
||||||
vpc_id = local.manage_vpc ? aws_vpc.this[0].id : data.aws_vpc.existing[0].id
|
vpc_id = local.manage_vpc ? aws_vpc.this[0].id : try(data.aws_vpc.existing[0].id, var.existing_vpc_id)
|
||||||
public_subnet_ids = local.manage_vpc ? aws_subnet.public[*].id : var.existing_public_subnet_ids
|
public_subnet_ids = local.manage_vpc ? aws_subnet.public[*].id : var.existing_public_subnet_ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,16 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
# Several Lambda handlers construct boto3.client(...) at module load. On a CI
|
# Several Lambda handlers construct boto3.client(...) at module load. On a CI
|
||||||
# runner with no AWS config this raises NoRegionError during test collection
|
# runner with no AWS config this raises NoRegionError during test collection
|
||||||
# (a real region is read from ~/.aws/config locally, masking it). Set a default
|
# (a real region is read from ~/.aws/config locally, masking it). Set a default
|
||||||
# region before any import. Client construction is offline; real calls are mocked.
|
# region before any import. Client construction is offline; real calls are mocked.
|
||||||
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
os.environ.setdefault("AWS_REGION", "us-east-1")
|
os.environ.setdefault("AWS_REGION", "us-east-1")
|
||||||
|
os.environ.pop("SENTRY_DSN", None)
|
||||||
|
os.environ.pop("SENTRY_DSN_PARAM", None)
|
||||||
|
|
||||||
# Add the repo root so `import functions.<name>.handler` resolves under a bare
|
# Add the repo root so `import functions.<name>.handler` resolves under a bare
|
||||||
# `pytest` invocation. `python -m pytest` injects the CWD automatically, but CI
|
# `pytest` invocation. `python -m pytest` injects the CWD automatically, but CI
|
||||||
|
|
@ -25,3 +29,12 @@ sys.path.insert(0, os.path.abspath(_src_dir))
|
||||||
# without requiring a real Lambda layer or .aws-sam build.
|
# without requiring a real Lambda layer or .aws-sam build.
|
||||||
_shared_layer_dir = os.path.join(_src_dir, "shared")
|
_shared_layer_dir = os.path.join(_src_dir, "shared")
|
||||||
sys.path.insert(0, os.path.abspath(_shared_layer_dir))
|
sys.path.insert(0, os.path.abspath(_shared_layer_dir))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_sentry_dsn_env():
|
||||||
|
os.environ.pop("SENTRY_DSN", None)
|
||||||
|
os.environ.pop("SENTRY_DSN_PARAM", None)
|
||||||
|
yield
|
||||||
|
os.environ.pop("SENTRY_DSN", None)
|
||||||
|
os.environ.pop("SENTRY_DSN_PARAM", None)
|
||||||
|
|
|
||||||
9
tests/fixtures/menu_page.html
vendored
Normal file
9
tests/fixtures/menu_page.html
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<orders-page
|
||||||
|
:products='[{"id":2238,"name":"Korean Steak Bowl","description":"<div>Shaved ribeye & rice.</div>","type":"meal","price":"12.49","on_sale":false,"sale_price":"0.00","media_urls":{"images":[{"original":"https://example.com/korean.png"}]},"dietary_concerns":["Gluten Free","Dairy Free"],"available":true,"details":{"calories":"590","protein":"50"}},{"id":9,"name":"Sale Bowl","description":"<div>On sale.</div>","price":"14.00","on_sale":true,"sale_price":"9.50","media_urls":{"images":[{"original":"https://example.com/sale.png"}]},"dietary_concerns":[],"available":true,"details":{"calories":"400cal","protein":"30g"}},{"id":3,"name":"Hidden Bowl","description":"<div>Unavailable.</div>","price":"5.00","on_sale":false,"sale_price":"0.00","media_urls":{"images":[]},"dietary_concerns":[],"available":false,"details":{"calories":"100","protein":"10"}}]'
|
||||||
|
:newest-ids='[2238]'
|
||||||
|
></orders-page>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
265
tests/test_fill_cart.py
Normal file
265
tests/test_fill_cart.py
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
"""CSV parsing and menu matching for the Redefine cart filler."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import Error as PlaywrightError
|
||||||
|
|
||||||
|
from scraper.fill_cart import build_plan, cart_lines, error_text, quantity_for
|
||||||
|
from scraper.parse_menu import extract_catalog_products
|
||||||
|
|
||||||
|
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "fill_redefine_cart.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("fill_redefine_cart", _SCRIPT)
|
||||||
|
fill_redefine_cart = importlib.util.module_from_spec(_spec)
|
||||||
|
assert _spec.loader is not None
|
||||||
|
_spec.loader.exec_module(fill_redefine_cart)
|
||||||
|
|
||||||
|
PRODUCTS = [
|
||||||
|
{
|
||||||
|
"name": "Korean Steak Bowl",
|
||||||
|
"uuid": "korean-id",
|
||||||
|
"available": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sale Bowl",
|
||||||
|
"uuid": "sale-id",
|
||||||
|
"available": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hidden Bowl",
|
||||||
|
"uuid": "hidden-id",
|
||||||
|
"available": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Twin Bowl",
|
||||||
|
"uuid": "twin-a",
|
||||||
|
"available": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "twin bowl",
|
||||||
|
"uuid": "twin-b",
|
||||||
|
"available": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "No Id Bowl",
|
||||||
|
"uuid": "",
|
||||||
|
"available": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_plan_matches_names_and_skips_the_rest():
|
||||||
|
csv_text = "\n".join(
|
||||||
|
[
|
||||||
|
"Item,Quantity",
|
||||||
|
"korean steak bowl,4",
|
||||||
|
"Sale Bowl,2",
|
||||||
|
"Sale Bowl,3",
|
||||||
|
"Hidden Bowl,1",
|
||||||
|
"Twin Bowl,8",
|
||||||
|
"Missing Bowl,1",
|
||||||
|
"No Id Bowl,1",
|
||||||
|
"=cmd,1",
|
||||||
|
"Zero Bowl,0",
|
||||||
|
"Bad Qty,nope",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# The admin exporter prefixes formula-like names with an apostrophe.
|
||||||
|
csv_text = csv_text.replace("=cmd", "'=cmd")
|
||||||
|
|
||||||
|
plan = build_plan(csv_text, PRODUCTS)
|
||||||
|
|
||||||
|
assert [(item.name, item.quantity, item.uuid) for item in plan.adds] == [
|
||||||
|
("Korean Steak Bowl", 4, "korean-id"),
|
||||||
|
("Sale Bowl", 5, "sale-id"),
|
||||||
|
]
|
||||||
|
assert [(line.name, line.reason) for line in plan.skipped] == [
|
||||||
|
("Bad Qty", "invalid quantity 'nope'"),
|
||||||
|
("Hidden Bowl", "unavailable on the menu"),
|
||||||
|
("Twin Bowl", "matches more than one menu item"),
|
||||||
|
("Missing Bowl", "not on the menu"),
|
||||||
|
("No Id Bowl", "not on the menu"),
|
||||||
|
("=cmd", "not on the menu"),
|
||||||
|
]
|
||||||
|
assert plan.ignored_zero == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_plan_reads_quoted_fields_and_a_byte_order_mark():
|
||||||
|
csv_text = '\ufeffItem,Quantity\r\n"Bowl, Large",2\r\n'
|
||||||
|
products = [{"name": "Bowl, Large", "uuid": "bowl", "available": True}]
|
||||||
|
|
||||||
|
plan = build_plan(csv_text, products)
|
||||||
|
|
||||||
|
assert plan.adds[0].name == "Bowl, Large"
|
||||||
|
assert plan.adds[0].quantity == 2
|
||||||
|
assert plan.skipped == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_plan_requires_the_admin_columns():
|
||||||
|
try:
|
||||||
|
build_plan("Meal,Qty\nSoup,1\n", PRODUCTS)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "Item and Quantity" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected a column error")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_catalog_products_reads_the_menu_attribute():
|
||||||
|
page = """
|
||||||
|
<orders-page
|
||||||
|
:products='[{"name":"Korean Steak Bowl","uuid":"korean-id","available":true}]'
|
||||||
|
:newest-ids='[]'
|
||||||
|
></orders-page>
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert extract_catalog_products(page)[0]["uuid"] == "korean-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cart_lines_use_the_product_uuid_and_quantity():
|
||||||
|
cart = {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"uuid": "line-1",
|
||||||
|
"quantity": "4",
|
||||||
|
"product": {"name": "Korean Steak Bowl", "uuid": "korean-id"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = cart_lines(cart)
|
||||||
|
|
||||||
|
assert lines[0].name == "Korean Steak Bowl"
|
||||||
|
assert lines[0].product_uuid == "korean-id"
|
||||||
|
assert quantity_for(lines, "korean-id") == 4
|
||||||
|
assert quantity_for(lines, "line-1") == 0
|
||||||
|
|
||||||
|
|
||||||
|
class _CartPage:
|
||||||
|
def __init__(self, responses):
|
||||||
|
self._responses = list(responses)
|
||||||
|
|
||||||
|
def goto(self, url, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def evaluate(self, script, payload):
|
||||||
|
response = self._responses.pop(0)
|
||||||
|
if isinstance(response, Exception):
|
||||||
|
raise response
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_rejects_a_non_utf8_csv(tmp_path, capsys):
|
||||||
|
path = tmp_path / "order.csv"
|
||||||
|
path.write_bytes(b"Item,Quantity\nCaf\xe9 Bowl,1\n")
|
||||||
|
|
||||||
|
code = fill_redefine_cart.main([str(path)])
|
||||||
|
error = capsys.readouterr().err
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert error.strip() == "CSV must be UTF-8."
|
||||||
|
assert "Traceback" not in error
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_browser_prints_a_summary_when_the_page_cannot_open(capsys):
|
||||||
|
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
|
||||||
|
|
||||||
|
class ClosedBrowser:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def new_page(self):
|
||||||
|
raise PlaywrightError("browser has been closed")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
browser = ClosedBrowser()
|
||||||
|
code = fill_redefine_cart._fill_browser(
|
||||||
|
browser, plan, "https://www.redefinemeals.com/menu"
|
||||||
|
)
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert browser.closed
|
||||||
|
assert "Traceback" not in output
|
||||||
|
assert "Added 0. Failed 1. Skipped 0." in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_page_prints_a_summary_when_the_window_closes_mid_add(capsys):
|
||||||
|
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
|
||||||
|
page = _CartPage(
|
||||||
|
[
|
||||||
|
{"ok": True, "status": 200, "data": {"items": []}},
|
||||||
|
PlaywrightError("Target page, context or browser has been closed"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
code = fill_redefine_cart._fill_page(
|
||||||
|
page, plan, "https://www.redefinemeals.com/menu"
|
||||||
|
)
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert "Traceback" not in output
|
||||||
|
assert "Added 0. Failed 1. Skipped 0." in output
|
||||||
|
assert "Cart is ready" not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_page_counts_items_left_when_the_window_closes(capsys):
|
||||||
|
plan = build_plan(
|
||||||
|
"Item,Quantity\nKorean Steak Bowl,1\nSale Bowl,2\n",
|
||||||
|
PRODUCTS,
|
||||||
|
)
|
||||||
|
page = _CartPage(
|
||||||
|
[
|
||||||
|
{"ok": True, "status": 200, "data": {"items": []}},
|
||||||
|
PlaywrightError("Target page, context or browser has been closed"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
code = fill_redefine_cart._fill_page(
|
||||||
|
page, plan, "https://www.redefinemeals.com/menu"
|
||||||
|
)
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert "Korean Steak Bowl:" in output
|
||||||
|
assert "Sale Bowl: not attempted" in output
|
||||||
|
assert "Added 0. Failed 2. Skipped 0." in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_page_prints_a_summary_when_the_first_cart_read_fails(capsys):
|
||||||
|
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
|
||||||
|
page = _CartPage(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"status": 500,
|
||||||
|
"data": {"message": "cart unavailable", "trace": [{"file": "x"}]},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
code = fill_redefine_cart._fill_page(
|
||||||
|
page, plan, "https://www.redefinemeals.com/menu"
|
||||||
|
)
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert "HTTP 500: cart unavailable" in output
|
||||||
|
assert "x" not in output.split("Failed:", 1)[-1]
|
||||||
|
assert "Added 0. Failed 1. Skipped 0." in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_text_keeps_the_message_and_drops_the_trace():
|
||||||
|
body = {
|
||||||
|
"message": "Product is not available.",
|
||||||
|
"exception": "HttpException",
|
||||||
|
"file": "/home/app/secret.php",
|
||||||
|
"trace": [{"file": "/home/app/secret.php"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
text = error_text(422, body)
|
||||||
|
|
||||||
|
assert text == "HTTP 422: Product is not available."
|
||||||
|
assert "secret.php" not in text
|
||||||
|
|
@ -75,9 +75,8 @@ def _render_for_browser(*, google: bool = False) -> str:
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
def _google_credential() -> str:
|
def _google_credential(extra: dict | None = None) -> str:
|
||||||
payload = json.dumps(
|
payload_obj = {
|
||||||
{
|
|
||||||
"name": "Test Admin",
|
"name": "Test Admin",
|
||||||
"email": "test-admin@example.com",
|
"email": "test-admin@example.com",
|
||||||
"picture": (
|
"picture": (
|
||||||
|
|
@ -85,8 +84,11 @@ def _google_credential() -> str:
|
||||||
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
|
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
).encode()
|
if extra:
|
||||||
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
payload_obj.update(extra)
|
||||||
|
encoded = (
|
||||||
|
base64.urlsafe_b64encode(json.dumps(payload_obj).encode()).decode().rstrip("=")
|
||||||
|
)
|
||||||
return f"e30.{encoded}.signature"
|
return f"e30.{encoded}.signature"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -156,9 +158,11 @@ class TestGenerateFormStructural:
|
||||||
assert "x-api-key" not in html
|
assert "x-api-key" not in html
|
||||||
|
|
||||||
def test_cloud_configuration_has_no_form_api_key(self):
|
def test_cloud_configuration_has_no_form_api_key(self):
|
||||||
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
|
|
||||||
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
|
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
|
||||||
for text in (workflow, http_api):
|
publish_job = (
|
||||||
|
REPO_ROOT / "src" / "server" / "jobs" / "publish_menu.py"
|
||||||
|
).read_text()
|
||||||
|
for text in (http_api, publish_job):
|
||||||
assert "FORM_APIKEY" not in text
|
assert "FORM_APIKEY" not in text
|
||||||
assert "form-api-key" not in text
|
assert "form-api-key" not in text
|
||||||
assert "x-api-key" not in text
|
assert "x-api-key" not in text
|
||||||
|
|
@ -169,19 +173,30 @@ class TestGenerateFormStructural:
|
||||||
assert "SUBMIT_RATE_PER_SEC = 5.0" in http_api
|
assert "SUBMIT_RATE_PER_SEC = 5.0" in http_api
|
||||||
assert "def _allow_submit()" in http_api
|
assert "def _allow_submit()" in http_api
|
||||||
|
|
||||||
def test_weekly_menu_uses_hmac_publish_without_dynamodb(self):
|
def test_manual_publish_stays_hmac_and_scheduled_job_is_in_process(self):
|
||||||
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
|
|
||||||
script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text()
|
script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text()
|
||||||
locals_tf = (REPO_ROOT / "terraform" / "locals.tf").read_text()
|
locals_tf = (REPO_ROOT / "terraform" / "locals.tf").read_text()
|
||||||
|
scheduler = (REPO_ROOT / "terraform" / "scheduler.tf").read_text()
|
||||||
|
publish_job = (
|
||||||
|
REPO_ROOT / "src" / "server" / "jobs" / "publish_menu.py"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
for route in ("/api/publish/settings", "/api/publish/menu"):
|
for route in ("/api/publish/settings", "/api/publish/menu"):
|
||||||
assert route in script
|
assert route in script
|
||||||
assert 'authorizer = "HMAC"' in locals_tf
|
assert 'authorizer = "HMAC"' in locals_tf
|
||||||
assert "X-Meals-Publish-Key" in script
|
assert "X-Meals-Publish-Key" in script
|
||||||
assert "scripts/upload_menu.py settings" in workflow
|
|
||||||
assert "scripts/upload_menu.py publish" in workflow
|
|
||||||
assert "aws dynamodb" not in workflow
|
|
||||||
assert 'boto3.resource("dynamodb")' not in script
|
assert 'boto3.resource("dynamodb")' not in script
|
||||||
|
assert 'event = "publish_menu"' in scheduler
|
||||||
|
assert 'schedule = "cron(30 7 ? * MON *)"' in scheduler
|
||||||
|
assert "put_menu" in publish_job
|
||||||
|
assert not (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").exists()
|
||||||
|
|
||||||
|
def test_explicit_week_is_embedded_in_the_form(self):
|
||||||
|
menu, config = _load_fixtures()
|
||||||
|
html = generate_form(menu, config, week="2026-W38")
|
||||||
|
config_blob = _extract_config(html)
|
||||||
|
assert config_blob["week"] == "2026-W38"
|
||||||
|
assert "/api/form-status/2026-W38" in html
|
||||||
|
|
||||||
def test_local_and_google_render(self):
|
def test_local_and_google_render(self):
|
||||||
local = _render(google=False)
|
local = _render(google=False)
|
||||||
|
|
@ -410,6 +425,7 @@ class TestGenerateFormPlaywright:
|
||||||
id: {
|
id: {
|
||||||
initialize() {},
|
initialize() {},
|
||||||
renderButton() {},
|
renderButton() {},
|
||||||
|
prompt() {},
|
||||||
disableAutoSelect() {},
|
disableAutoSelect() {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -933,6 +949,7 @@ class TestGenerateFormGooglePlaywright:
|
||||||
id: {
|
id: {
|
||||||
initialize() {},
|
initialize() {},
|
||||||
renderButton() {},
|
renderButton() {},
|
||||||
|
prompt() {},
|
||||||
disableAutoSelect() {},
|
disableAutoSelect() {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -1007,6 +1024,105 @@ class TestGenerateFormGooglePlaywright:
|
||||||
finally:
|
finally:
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
||||||
|
def test_refresh_restores_google_session(self, tmp_path):
|
||||||
|
sync_api = pytest.importorskip("playwright.sync_api")
|
||||||
|
sync_playwright = sync_api.sync_playwright
|
||||||
|
|
||||||
|
out = tmp_path / "google-session-form.html"
|
||||||
|
out.write_text(_render_for_browser(google=True))
|
||||||
|
credential = _google_credential({"exp": 2_000_000_000})
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Chromium is required for form Playwright tests. "
|
||||||
|
"Run: playwright install --with-deps chromium"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
page = browser.new_page()
|
||||||
|
page.add_init_script(
|
||||||
|
"""window.google = {
|
||||||
|
accounts: {
|
||||||
|
id: {
|
||||||
|
initialize() {},
|
||||||
|
renderButton() {},
|
||||||
|
prompt() {},
|
||||||
|
disableAutoSelect() {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};"""
|
||||||
|
)
|
||||||
|
page.route(
|
||||||
|
"https://accounts.google.com/gsi/client",
|
||||||
|
lambda route: route.abort(),
|
||||||
|
)
|
||||||
|
_mock_form_routes(page)
|
||||||
|
page.goto(out.as_uri(), wait_until="domcontentloaded")
|
||||||
|
page.evaluate(
|
||||||
|
"(token) => handleCredentialResponse({ credential: token })",
|
||||||
|
credential,
|
||||||
|
)
|
||||||
|
page.wait_for_function(
|
||||||
|
"""() => {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
return app && !app.classList.contains('is-hidden');
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
assert page.evaluate(
|
||||||
|
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
|
||||||
|
)
|
||||||
|
|
||||||
|
page.reload(wait_until="domcontentloaded")
|
||||||
|
_mock_form_routes(page)
|
||||||
|
page.wait_for_function(
|
||||||
|
"""() => {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
const overlay = document.getElementById('auth-overlay');
|
||||||
|
const email = document.getElementById('user-email');
|
||||||
|
return app && !app.classList.contains('is-hidden')
|
||||||
|
&& overlay && overlay.classList.contains('is-hidden')
|
||||||
|
&& email && email.textContent === 'test-admin@example.com';
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
page.evaluate("signOut()")
|
||||||
|
assert (
|
||||||
|
page.evaluate(
|
||||||
|
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
page.reload(wait_until="domcontentloaded")
|
||||||
|
_mock_form_routes(page)
|
||||||
|
page.wait_for_function(
|
||||||
|
"""() => {
|
||||||
|
const overlay = document.getElementById('auth-overlay');
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
return overlay && !overlay.classList.contains('is-hidden')
|
||||||
|
&& app && app.classList.contains('is-hidden');
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
page.evaluate(
|
||||||
|
"(token) => sessionStorage.setItem('seahaven.meals.googleIdToken', token)",
|
||||||
|
_google_credential({"exp": 1}),
|
||||||
|
)
|
||||||
|
page.reload(wait_until="domcontentloaded")
|
||||||
|
_mock_form_routes(page)
|
||||||
|
page.wait_for_function(
|
||||||
|
"""() => {
|
||||||
|
const overlay = document.getElementById('auth-overlay');
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
return overlay && !overlay.classList.contains('is-hidden')
|
||||||
|
&& app && app.classList.contains('is-hidden')
|
||||||
|
&& !sessionStorage.getItem('seahaven.meals.googleIdToken');
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
browser.close()
|
||||||
|
|
||||||
@pytest.fixture(scope="class")
|
@pytest.fixture(scope="class")
|
||||||
@classmethod
|
@classmethod
|
||||||
def signed_in_admin_page(cls, tmp_path_factory):
|
def signed_in_admin_page(cls, tmp_path_factory):
|
||||||
|
|
@ -1036,6 +1152,7 @@ class TestGenerateFormGooglePlaywright:
|
||||||
id: {
|
id: {
|
||||||
initialize() {},
|
initialize() {},
|
||||||
renderButton() {},
|
renderButton() {},
|
||||||
|
prompt() {},
|
||||||
disableAutoSelect() {},
|
disableAutoSelect() {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
47
tests/test_openapi_contract.py
Normal file
47
tests/test_openapi_contract.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""OpenAPI 3.1 + Redocly recommended, matching internal-portal (DEV-289)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_uses_redocly_recommended():
|
||||||
|
redocly = (ROOT / ".redocly.yaml").read_text()
|
||||||
|
package = (ROOT / "package.json").read_text()
|
||||||
|
spec = (ROOT / "openapi.yaml").read_text()
|
||||||
|
ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text()
|
||||||
|
assert "extends:" in redocly
|
||||||
|
assert "- recommended" in redocly
|
||||||
|
assert "operation-2xx-response: off" in redocly
|
||||||
|
assert "operation-4xx-response: error" in redocly
|
||||||
|
assert "rule/operation-2xx-or-3xx-response" in redocly
|
||||||
|
assert "severity: error" in redocly
|
||||||
|
health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0]
|
||||||
|
assert '"403":' in health
|
||||||
|
assert '"400":' not in health
|
||||||
|
roster = spec.split("/api/roster:", 1)[1].split("\n /", 1)[0]
|
||||||
|
assert '"403":' in roster
|
||||||
|
assert '"400":' not in roster
|
||||||
|
form_status = spec.split("/api/form-status/{week}:", 1)[1].split("\n /", 1)[0]
|
||||||
|
assert '"400":' in form_status
|
||||||
|
menu = spec.split("/api/menu/{week}:", 1)[1].split("\n /", 1)[0]
|
||||||
|
orders = spec.split("/api/orders/{week}:", 1)[1].split("\n /", 1)[0]
|
||||||
|
assert "MenuWeekPath" in menu
|
||||||
|
assert "MenuWeekPath" in form_status
|
||||||
|
assert "OrderWeekPath" in orders
|
||||||
|
assert "WeekPath" not in spec.split("components:", 1)[1].split("MenuWeekPath", 1)[0]
|
||||||
|
assert "`current` or `YYYY-WNN`" in spec
|
||||||
|
assert "`YYYY-WNN` or `YYYY-MM-DD`" in spec
|
||||||
|
meal = spec.split(" Meal:", 1)[1].split("\n MenuPayload:", 1)[0]
|
||||||
|
assert 'type: [string, number, "null"]' in meal
|
||||||
|
assert 'type: [string, "null"]' in meal
|
||||||
|
assert 'menu_url:\n type: [string, "null"]' in spec
|
||||||
|
assert "root: openapi.yaml" in redocly
|
||||||
|
assert '"openapi:lint"' in package
|
||||||
|
dev = json.loads(package)["devDependencies"]
|
||||||
|
assert dev["@redocly/cli"]
|
||||||
|
assert "npm run openapi:lint" in ci
|
||||||
|
assert "openapi: 3.1.0" in spec
|
||||||
|
assert "required: [stage, sha]" in spec
|
||||||
|
assert "{ error: string }" in spec or "`{ error: string }`" in spec
|
||||||
66
tests/test_parse_menu.py
Normal file
66
tests/test_parse_menu.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""Parser for the catalog embedded on the Redefine menu page."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scraper.parse_menu import MenuParseError, extract_catalog_products, parse_menu_html
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).resolve().parent / "fixtures" / "menu_page.html"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_menu_html_maps_catalog_fields():
|
||||||
|
page = FIXTURE.read_text()
|
||||||
|
menu = parse_menu_html(
|
||||||
|
page,
|
||||||
|
menu_url="https://www.redefinemeals.com/menu",
|
||||||
|
scraped_at="2026-09-21T07:30:00-04:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert menu["meal_count"] == 2
|
||||||
|
assert menu["scraped_at"] == "2026-09-21T07:30:00-04:00"
|
||||||
|
korean, sale = menu["meals"]
|
||||||
|
|
||||||
|
assert korean["name"] == "Korean Steak Bowl"
|
||||||
|
assert korean["price"] == 12.49
|
||||||
|
assert korean["calories"] == 590
|
||||||
|
assert korean["protein"] == "50g"
|
||||||
|
assert korean["dietary_tags"] == ["Gluten Free", "Dairy Free"]
|
||||||
|
assert korean["image_url"] == "https://example.com/korean.png"
|
||||||
|
assert korean["is_new"] is True
|
||||||
|
assert korean["description"] == "Shaved ribeye & rice."
|
||||||
|
|
||||||
|
assert sale["name"] == "Sale Bowl"
|
||||||
|
assert sale["price"] == 9.50
|
||||||
|
assert sale["calories"] == 400
|
||||||
|
assert sale["protein"] == "30g"
|
||||||
|
assert sale["is_new"] is False
|
||||||
|
assert sale["description"] == "On sale."
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_menu_html_rejects_a_missing_catalog():
|
||||||
|
with pytest.raises(MenuParseError, match="missing :products"):
|
||||||
|
parse_menu_html("<html></html>", menu_url="https://example.com/menu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_catalog_products_rejects_an_empty_catalog():
|
||||||
|
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
|
||||||
|
with pytest.raises(MenuParseError, match="empty"):
|
||||||
|
extract_catalog_products(page)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_menu_html_rejects_an_empty_catalog():
|
||||||
|
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
|
||||||
|
with pytest.raises(MenuParseError, match="empty"):
|
||||||
|
parse_menu_html(page, menu_url="https://example.com/menu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_menu_html_rejects_a_meal_without_a_price():
|
||||||
|
page = """
|
||||||
|
<orders-page
|
||||||
|
:products='[{"id":1,"name":"Broken","price":null,"on_sale":false,"available":true}]'
|
||||||
|
:newest-ids='[]'
|
||||||
|
></orders-page>
|
||||||
|
"""
|
||||||
|
with pytest.raises(MenuParseError, match="missing a price"):
|
||||||
|
parse_menu_html(page, menu_url="https://example.com/menu")
|
||||||
145
tests/test_publish_menu.py
Normal file
145
tests/test_publish_menu.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"""publish_menu writes the menu, then the form, then Slack."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from server.jobs import publish_menu
|
||||||
|
from server.jobs import run_job
|
||||||
|
|
||||||
|
|
||||||
|
MENU = {
|
||||||
|
"scraped_at": "2026-09-21T07:30:00-04:00",
|
||||||
|
"menu_url": "https://www.redefinemeals.com/menu",
|
||||||
|
"meal_count": 1,
|
||||||
|
"meals": [{"name": "Korean Steak Bowl", "price": 12.49}],
|
||||||
|
}
|
||||||
|
|
||||||
|
ENV = {
|
||||||
|
"MENU_URL": "https://www.redefinemeals.com/menu",
|
||||||
|
"GOOGLE_CLIENT_ID": "client.apps.googleusercontent.com",
|
||||||
|
"FORM_BUCKET": "meal-order-manager-form-test",
|
||||||
|
"DISTRIBUTION_ID": "E123",
|
||||||
|
"FORM_URL": "https://orders.seahaven.com",
|
||||||
|
"TABLE_NAME": "meal-order-manager-orders",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clients(s3, cloudfront):
|
||||||
|
def client(name, **_kwargs):
|
||||||
|
if name == "s3":
|
||||||
|
return s3
|
||||||
|
if name == "cloudfront":
|
||||||
|
return cloudfront
|
||||||
|
raise AssertionError(name)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@patch("shared.slack.post_channel_message", return_value={"ok": True})
|
||||||
|
@patch("server.jobs.publish_menu.put_menu")
|
||||||
|
@patch(
|
||||||
|
"server.jobs.publish_menu.get_settings",
|
||||||
|
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 50},
|
||||||
|
)
|
||||||
|
@patch("server.jobs.publish_menu.generate_form", return_value="<html>form</html>")
|
||||||
|
@patch("server.jobs.publish_menu.current_week", return_value="2026-W38")
|
||||||
|
@patch("server.jobs.publish_menu.fetch_menu", return_value=MENU)
|
||||||
|
@patch("server.jobs.publish_menu.boto3.client")
|
||||||
|
def test_publish_menu_uploads_after_the_menu_write(
|
||||||
|
mock_client,
|
||||||
|
mock_fetch,
|
||||||
|
mock_week,
|
||||||
|
mock_generate,
|
||||||
|
mock_settings,
|
||||||
|
mock_put,
|
||||||
|
mock_slack,
|
||||||
|
):
|
||||||
|
s3 = MagicMock()
|
||||||
|
cloudfront = MagicMock()
|
||||||
|
mock_client.side_effect = _clients(s3, cloudfront)
|
||||||
|
order = []
|
||||||
|
mock_put.side_effect = lambda *args, **kwargs: order.append("menu")
|
||||||
|
s3.put_object.side_effect = lambda **kwargs: order.append(kwargs["Key"])
|
||||||
|
cloudfront.create_invalidation.side_effect = lambda **kwargs: order.append(
|
||||||
|
"invalidate"
|
||||||
|
)
|
||||||
|
mock_slack.side_effect = lambda *args, **kwargs: (
|
||||||
|
order.append("slack") or {"ok": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict("os.environ", ENV, clear=False):
|
||||||
|
result = publish_menu.lambda_handler({}, None)
|
||||||
|
|
||||||
|
assert result == {"status": "published", "week": "2026-W38", "meal_count": 1}
|
||||||
|
mock_fetch.assert_called_once_with("https://www.redefinemeals.com/menu")
|
||||||
|
mock_week.assert_called_once()
|
||||||
|
mock_settings.assert_called_once()
|
||||||
|
mock_generate.assert_called_once()
|
||||||
|
assert mock_generate.call_args.kwargs["week"] == "2026-W38"
|
||||||
|
assert mock_generate.call_args.kwargs["google_client_id"] == ENV["GOOGLE_CLIENT_ID"]
|
||||||
|
assert mock_generate.call_args.kwargs["bulk_discount"] == 10
|
||||||
|
assert mock_generate.call_args.kwargs["company_subsidy"] == 50
|
||||||
|
mock_put.assert_called_once_with("2026-W38", MENU)
|
||||||
|
assert s3.put_object.call_count == 2
|
||||||
|
index = s3.put_object.call_args_list[0].kwargs
|
||||||
|
archive = s3.put_object.call_args_list[1].kwargs
|
||||||
|
assert index["Bucket"] == ENV["FORM_BUCKET"]
|
||||||
|
assert index["Key"] == "index.html"
|
||||||
|
assert index["CacheControl"] == "no-cache"
|
||||||
|
assert archive["Key"] == "archive/2026-W38.html"
|
||||||
|
invalidation = cloudfront.create_invalidation.call_args.kwargs
|
||||||
|
assert invalidation["DistributionId"] == "E123"
|
||||||
|
assert invalidation["InvalidationBatch"]["Paths"]["Items"] == ["/index.html"]
|
||||||
|
mock_slack.assert_called_once()
|
||||||
|
assert mock_slack.call_args.args[0] == (
|
||||||
|
"This week's meal order is open! Deadline: Thursday at 11:59 PM."
|
||||||
|
)
|
||||||
|
assert mock_slack.call_args.args[1][1]["text"]["text"] == (
|
||||||
|
f"*<{ENV['FORM_URL']}|Place your order>*\n\n*Deadline:* Thursday at 11:59 PM\n"
|
||||||
|
)
|
||||||
|
assert order == [
|
||||||
|
"menu",
|
||||||
|
"index.html",
|
||||||
|
"archive/2026-W38.html",
|
||||||
|
"invalidate",
|
||||||
|
"slack",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("shared.slack.post_channel_message", return_value={"ok": True})
|
||||||
|
@patch("server.jobs.publish_menu.put_menu")
|
||||||
|
@patch("server.jobs.publish_menu.get_settings", return_value={})
|
||||||
|
@patch("server.jobs.publish_menu.generate_form", return_value="<html>form</html>")
|
||||||
|
@patch("server.jobs.publish_menu.current_week", return_value="2026-W38")
|
||||||
|
@patch("server.jobs.publish_menu.fetch_menu", return_value=MENU)
|
||||||
|
@patch("server.jobs.publish_menu.boto3.client")
|
||||||
|
def test_publish_menu_does_not_notify_when_upload_fails(
|
||||||
|
mock_client,
|
||||||
|
_fetch,
|
||||||
|
_week,
|
||||||
|
_generate,
|
||||||
|
_settings,
|
||||||
|
mock_put,
|
||||||
|
mock_slack,
|
||||||
|
):
|
||||||
|
s3 = MagicMock()
|
||||||
|
s3.put_object.side_effect = RuntimeError("s3 down")
|
||||||
|
mock_client.side_effect = _clients(s3, MagicMock())
|
||||||
|
|
||||||
|
with patch.dict("os.environ", ENV, clear=False):
|
||||||
|
with pytest.raises(RuntimeError, match="s3 down"):
|
||||||
|
publish_menu.lambda_handler({}, None)
|
||||||
|
|
||||||
|
mock_put.assert_called_once()
|
||||||
|
mock_slack.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_job_dispatches_publish_menu():
|
||||||
|
with patch(
|
||||||
|
"server.jobs.publish_menu.lambda_handler", return_value={"status": "published"}
|
||||||
|
) as handler:
|
||||||
|
result = run_job({"event": "publish_menu"})
|
||||||
|
|
||||||
|
assert result == {"status": "published"}
|
||||||
|
handler.assert_called_once()
|
||||||
220
tests/test_sentry_init.py
Normal file
220
tests/test_sentry_init.py
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
"""sentry_init: DSN no-op, FlaskIntegration, and before_send scrub."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||||
|
|
||||||
|
import server.sentry_init as sentry_mod
|
||||||
|
|
||||||
|
_FAKE_DSN = "https://key@o1.ingest.sentry.io/1"
|
||||||
|
|
||||||
|
|
||||||
|
def _parameter_not_found():
|
||||||
|
return ClientError(
|
||||||
|
{"Error": {"Code": "ParameterNotFound", "Message": "not found"}},
|
||||||
|
"GetParameter",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unset_dsn_does_not_init():
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {}, clear=False),
|
||||||
|
patch("sentry_sdk.init") as mocked,
|
||||||
|
):
|
||||||
|
# Ensure both sources are absent even if a prior test set them.
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.pop("SENTRY_DSN", None)
|
||||||
|
os.environ.pop("SENTRY_DSN_PARAM", None)
|
||||||
|
sentry_mod.init_sentry()
|
||||||
|
mocked.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_dsn_does_not_init(monkeypatch):
|
||||||
|
monkeypatch.setenv("SENTRY_DSN", "")
|
||||||
|
monkeypatch.delenv("SENTRY_DSN_PARAM", raising=False)
|
||||||
|
with patch("sentry_sdk.init") as mocked:
|
||||||
|
sentry_mod.init_sentry()
|
||||||
|
mocked.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_literal_unset_dsn_does_not_init(monkeypatch):
|
||||||
|
monkeypatch.setenv("SENTRY_DSN", "unset")
|
||||||
|
with patch("sentry_sdk.init") as mocked:
|
||||||
|
sentry_mod.init_sentry()
|
||||||
|
mocked.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_dsn_inits_flask_integration(monkeypatch):
|
||||||
|
monkeypatch.setenv("SENTRY_DSN", _FAKE_DSN)
|
||||||
|
monkeypatch.setenv("STAGE", "dev")
|
||||||
|
monkeypatch.setenv("GIT_SHA", "abc123def")
|
||||||
|
with patch("sentry_sdk.init") as mocked:
|
||||||
|
sentry_mod.init_sentry()
|
||||||
|
mocked.assert_called_once()
|
||||||
|
kwargs = mocked.call_args.kwargs
|
||||||
|
assert kwargs["dsn"] == _FAKE_DSN
|
||||||
|
assert kwargs["send_default_pii"] is False
|
||||||
|
assert kwargs["include_local_variables"] is False
|
||||||
|
assert kwargs["enable_logs"] is False
|
||||||
|
assert kwargs["traces_sample_rate"] == 0.0
|
||||||
|
assert kwargs["before_send"] is sentry_mod._before_send
|
||||||
|
assert kwargs["environment"] == "dev"
|
||||||
|
assert kwargs["release"] == "abc123def"
|
||||||
|
integrations = kwargs["integrations"]
|
||||||
|
assert len(integrations) == 1
|
||||||
|
assert isinstance(integrations[0], FlaskIntegration)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_stage_defaults_environment_to_local(monkeypatch):
|
||||||
|
monkeypatch.setenv("SENTRY_DSN", _FAKE_DSN)
|
||||||
|
monkeypatch.delenv("STAGE", raising=False)
|
||||||
|
monkeypatch.delenv("GIT_SHA", raising=False)
|
||||||
|
with patch("sentry_sdk.init") as mocked:
|
||||||
|
sentry_mod.init_sentry()
|
||||||
|
kwargs = mocked.call_args.kwargs
|
||||||
|
assert kwargs["environment"] == "local"
|
||||||
|
assert "release" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentry_dsn_param_fetches_from_ssm(monkeypatch):
|
||||||
|
monkeypatch.delenv("SENTRY_DSN", raising=False)
|
||||||
|
monkeypatch.setenv("SENTRY_DSN_PARAM", "/meal-order-manager/sentry-dsn")
|
||||||
|
monkeypatch.setenv("STAGE", "dev")
|
||||||
|
monkeypatch.setenv("GIT_SHA", "deadbeef")
|
||||||
|
with (
|
||||||
|
patch("shared.secrets.get_parameter", return_value=_FAKE_DSN) as mock_get,
|
||||||
|
patch("sentry_sdk.init") as mocked,
|
||||||
|
):
|
||||||
|
sentry_mod.init_sentry()
|
||||||
|
mock_get.assert_called_once_with("/meal-order-manager/sentry-dsn", decrypt=True)
|
||||||
|
mocked.assert_called_once()
|
||||||
|
assert mocked.call_args.kwargs["dsn"] == _FAKE_DSN
|
||||||
|
assert isinstance(mocked.call_args.kwargs["integrations"][0], FlaskIntegration)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentry_dsn_param_unset_value_does_not_init(monkeypatch):
|
||||||
|
monkeypatch.delenv("SENTRY_DSN", raising=False)
|
||||||
|
monkeypatch.setenv("SENTRY_DSN_PARAM", "/meal-order-manager/sentry-dsn")
|
||||||
|
with (
|
||||||
|
patch("shared.secrets.get_parameter", return_value="unset"),
|
||||||
|
patch("sentry_sdk.init") as mocked,
|
||||||
|
):
|
||||||
|
sentry_mod.init_sentry()
|
||||||
|
mocked.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentry_dsn_param_not_found_does_not_init(monkeypatch):
|
||||||
|
monkeypatch.delenv("SENTRY_DSN", raising=False)
|
||||||
|
monkeypatch.setenv("SENTRY_DSN_PARAM", "/meal-order-manager/sentry-dsn")
|
||||||
|
with (
|
||||||
|
patch("shared.secrets.get_parameter", side_effect=_parameter_not_found()),
|
||||||
|
patch("sentry_sdk.init") as mocked,
|
||||||
|
):
|
||||||
|
sentry_mod.init_sentry()
|
||||||
|
mocked.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_before_send_strips_auth_and_publish_key_headers():
|
||||||
|
event = {
|
||||||
|
"request": {
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer secret",
|
||||||
|
"X-Meals-Publish-Key": "hmac-secret",
|
||||||
|
"X-Auth-Token": "tok",
|
||||||
|
"Cookie": "session=abc",
|
||||||
|
"X-Amz-Date": "20260101T000000Z",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
"url": "https://example.invalid/api/submit-order",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = sentry_mod._before_send(event, {})
|
||||||
|
assert out["request"]["headers"] == {"Content-Type": "application/json"}
|
||||||
|
assert out["request"]["url"] == "https://example.invalid/api/submit-order"
|
||||||
|
|
||||||
|
|
||||||
|
def test_before_send_strips_list_headers():
|
||||||
|
event = {
|
||||||
|
"request": {
|
||||||
|
"headers": [
|
||||||
|
("Authorization", "Bearer secret"),
|
||||||
|
("X-Meals-Publish-Key", "hmac-secret"),
|
||||||
|
("Content-Type", "application/json"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = sentry_mod._before_send(event, {})
|
||||||
|
assert out["request"]["headers"] == [("Content-Type", "application/json")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_before_send_drops_body_and_secret_keys():
|
||||||
|
event = {
|
||||||
|
"request": {
|
||||||
|
"body": '{"google_id_token":"ya29.secret"}',
|
||||||
|
"data": {"google_id_token": "ya29.secret"},
|
||||||
|
"method": "POST",
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"google_id_token": "ya29.secret",
|
||||||
|
"publish_hmac": "aabbcc",
|
||||||
|
"bot_token": "xoxb-secret",
|
||||||
|
"week": "2026-W38",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out = sentry_mod._before_send(event, {})
|
||||||
|
assert "body" not in out["request"]
|
||||||
|
assert "data" not in out["request"]
|
||||||
|
assert out["request"]["method"] == "POST"
|
||||||
|
assert "google_id_token" not in out["extra"]
|
||||||
|
assert "publish_hmac" not in out["extra"]
|
||||||
|
assert "bot_token" not in out["extra"]
|
||||||
|
assert out["extra"]["week"] == "2026-W38"
|
||||||
|
|
||||||
|
|
||||||
|
def test_before_send_drops_exception_and_thread_frame_locals():
|
||||||
|
event = {
|
||||||
|
"exception": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"stacktrace": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"function": "handler",
|
||||||
|
"vars": {
|
||||||
|
"google_id_token": "ya29.secret",
|
||||||
|
"SecretString": "aabbcc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"threads": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"stacktrace": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"function": "_require_publish_key",
|
||||||
|
"vars": {"provided": "hmac-secret"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stacktrace": {
|
||||||
|
"frames": [{"function": "get_secret", "vars": {"item": {"token": "x"}}}]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out = sentry_mod._before_send(event, {})
|
||||||
|
assert "vars" not in out["exception"]["values"][0]["stacktrace"]["frames"][0]
|
||||||
|
assert "vars" not in out["threads"]["values"][0]["stacktrace"]["frames"][0]
|
||||||
|
assert "vars" not in out["stacktrace"]["frames"][0]
|
||||||
|
assert (
|
||||||
|
out["exception"]["values"][0]["stacktrace"]["frames"][0]["function"]
|
||||||
|
== "handler"
|
||||||
|
)
|
||||||
|
|
@ -1446,6 +1446,33 @@ def test_form_status_open(mock_week, mock_status):
|
||||||
assert "reopen_at" not in body, "reopen_at should NOT be present when form is open"
|
assert "reopen_at" not in body, "reopen_at should NOT be present when form is open"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
|
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
||||||
|
def test_form_status_current_maps_to_current_week(mock_week, mock_status):
|
||||||
|
from submit_order_handler import lambda_handler
|
||||||
|
|
||||||
|
event = _make_event(
|
||||||
|
method="GET", path="/form-status/current", path_parameters={"week": "current"}
|
||||||
|
)
|
||||||
|
status, body = _parse_response(lambda_handler(event, None))
|
||||||
|
assert status == 200, f"Expected 200, got {status}: {body}"
|
||||||
|
assert body["week"] == "2026-W20"
|
||||||
|
mock_status.assert_called_once_with("2026-W20")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("submit_order_handler.get_form_status")
|
||||||
|
def test_form_status_rejects_invalid_week(mock_status):
|
||||||
|
from submit_order_handler import lambda_handler
|
||||||
|
|
||||||
|
event = _make_event(
|
||||||
|
method="GET", path="/form-status/nope", path_parameters={"week": "nope"}
|
||||||
|
)
|
||||||
|
status, body = _parse_response(lambda_handler(event, None))
|
||||||
|
assert status == 400, f"Expected 400, got {status}: {body}"
|
||||||
|
assert "week path param" in body["error"]
|
||||||
|
mock_status.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@patch("submit_order_handler.get_form_status", return_value="closed")
|
@patch("submit_order_handler.get_form_status", return_value="closed")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
||||||
def test_form_status_closed_reopen_at(mock_week, mock_status):
|
def test_form_status_closed_reopen_at(mock_week, mock_status):
|
||||||
|
|
@ -2137,6 +2164,24 @@ def test_my_orders_empty_when_none(mock_looks_like, mock_portal, mock_get):
|
||||||
assert body == {"week": "2026-W36", "orders": []}
|
assert body == {"week": "2026-W36", "orders": []}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("submit_order_handler.get_order")
|
||||||
|
@patch(
|
||||||
|
"submit_order_handler._verify_portal_token",
|
||||||
|
return_value={
|
||||||
|
"name": "Portal Employee",
|
||||||
|
"email": "portal.employee@seahavenind.com",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@patch("submit_order_handler.looks_like_cognito_token", return_value=True)
|
||||||
|
def test_my_orders_rejects_current_week(mock_looks_like, mock_portal, mock_get):
|
||||||
|
status, body = _parse_response(
|
||||||
|
submit_order_handler.lambda_handler(_orders_event(week="current"), None)
|
||||||
|
)
|
||||||
|
assert status == 400
|
||||||
|
assert "YYYY-WNN" in body["error"]
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_my_orders_requires_bearer():
|
def test_my_orders_requires_bearer():
|
||||||
status, body = _parse_response(
|
status, body = _parse_response(
|
||||||
submit_order_handler.lambda_handler(_orders_event(token=""), None)
|
submit_order_handler.lambda_handler(_orders_event(token=""), None)
|
||||||
|
|
|
||||||
18
tests/test_terraform_github_deploy.py
Normal file
18
tests/test_terraform_github_deploy.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""githubdeploy OIDC trust pins the org reusable with AWS-supported claims."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
IAM = Path(__file__).resolve().parents[1] / "terraform" / "iam_github_deploy.tf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_github_deploy_trust_uses_org_reusable_and_caller():
|
||||||
|
text = IAM.read_text()
|
||||||
|
assert "token.actions.githubusercontent.com:sub" in text
|
||||||
|
assert "repo:Sea-Haven-Industries/meal-order-manager:environment:dev" in text
|
||||||
|
assert "repo:Sea-Haven-Industries/meal-order-manager:environment:prod" in text
|
||||||
|
assert (
|
||||||
|
"Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*" in text
|
||||||
|
)
|
||||||
|
assert "token.actions.githubusercontent.com:job_workflow_ref" in text
|
||||||
|
assert "token.actions.githubusercontent.com:workflow_ref" not in text
|
||||||
|
assert "cd-hcp-spa.yaml" not in text
|
||||||
|
|
@ -5,6 +5,14 @@ from pathlib import Path
|
||||||
IAM = Path(__file__).resolve().parents[1] / "terraform" / "iam.tf"
|
IAM = Path(__file__).resolve().parents[1] / "terraform" / "iam.tf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_boundary_can_publish_the_order_form():
|
||||||
|
text = IAM.read_text()
|
||||||
|
assert 'sid = "FormObjects"' in text
|
||||||
|
assert "cloudfront:CreateInvalidation" in text
|
||||||
|
assert "${aws_s3_bucket.form.arn}/index.html" in text
|
||||||
|
assert not (IAM.parent / "iam_github_weekly_menu.tf").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_checkcomponents_send_omitted_when_queue_arn_empty():
|
def test_checkcomponents_send_omitted_when_queue_arn_empty():
|
||||||
text = IAM.read_text()
|
text = IAM.read_text()
|
||||||
assert "compact([var.checkcomponents_queue_arn])" not in text
|
assert "compact([var.checkcomponents_queue_arn])" not in text
|
||||||
|
|
|
||||||
40
tests/test_terraform_sentry.py
Normal file
40
tests/test_terraform_sentry.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""Sentry DSN is an SSM SecureString; the task receives the parameter name."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
TERRAFORM = ROOT / "terraform"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(name: str) -> str:
|
||||||
|
return (TERRAFORM / name).read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentry_dsn_is_secure_string_stub():
|
||||||
|
ssm_tf = _read("ssm.tf")
|
||||||
|
assert 'resource "aws_ssm_parameter" "sentry_dsn"' in ssm_tf
|
||||||
|
assert 'name = "${local.ssm_prefix}/sentry-dsn"' in ssm_tf
|
||||||
|
assert 'type = "SecureString"' in ssm_tf
|
||||||
|
assert 'value = "unset"' in ssm_tf
|
||||||
|
assert "ignore_changes = [value]" in ssm_tf
|
||||||
|
assert 'data "aws_ssm_parameter" "sentry_dsn_value"' not in ssm_tf
|
||||||
|
|
||||||
|
|
||||||
|
def test_ecs_task_receives_sentry_dsn_parameter_name():
|
||||||
|
ecs_tf = _read("ecs.tf")
|
||||||
|
assert ecs_tf.count("SENTRY_DSN_PARAM") == 1
|
||||||
|
assert "aws_ssm_parameter.sentry_dsn.name" in ecs_tf
|
||||||
|
assert "SENTRY_DSN " not in ecs_tf
|
||||||
|
|
||||||
|
|
||||||
|
def test_terraform_does_not_embed_a_sentry_dsn():
|
||||||
|
for path in TERRAFORM.glob("*.tf"):
|
||||||
|
text = path.read_text()
|
||||||
|
assert "ingest.sentry.io" not in text
|
||||||
|
assert "SENTRY_DSN =" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_deploy_api_injects_sentry_dsn_param():
|
||||||
|
workflow = (ROOT / ".github/workflows/deploy-api.yaml").read_text()
|
||||||
|
assert "extra-task-env:" in workflow
|
||||||
|
assert '"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"' in workflow
|
||||||
|
|
@ -21,7 +21,9 @@ def test_meals_owns_a_vpc_instead_of_looking_up_default():
|
||||||
assert "count = local.manage_vpc ? 1 : 0" in vpc
|
assert "count = local.manage_vpc ? 1 : 0" in vpc
|
||||||
assert 'variable "existing_vpc_id"' in variables
|
assert 'variable "existing_vpc_id"' in variables
|
||||||
assert 'variable "existing_public_subnet_ids"' in variables
|
assert 'variable "existing_public_subnet_ids"' in variables
|
||||||
assert 'manage_vpc = var.existing_vpc_id == ""' in locals_tf
|
assert (
|
||||||
|
'manage_vpc = var.existing_vpc_id == "" && !local.is_prod' in locals_tf
|
||||||
|
)
|
||||||
assert 'data "aws_vpc" "default"' not in ecs
|
assert 'data "aws_vpc" "default"' not in ecs
|
||||||
assert "data.aws_vpc.default" not in ecs
|
assert "data.aws_vpc.default" not in ecs
|
||||||
assert "data.aws_subnets.default" not in ecs
|
assert "data.aws_subnets.default" not in ecs
|
||||||
|
|
@ -33,3 +35,11 @@ def test_meals_owns_a_vpc_instead_of_looking_up_default():
|
||||||
assert 'check "existing_subnets_in_vpc"' in data
|
assert 'check "existing_subnets_in_vpc"' in data
|
||||||
assert "from = aws_vpc.this" in vpc
|
assert "from = aws_vpc.this" in vpc
|
||||||
assert "to = aws_vpc.this[0]" in vpc
|
assert "to = aws_vpc.this[0]" in vpc
|
||||||
|
outputs = _read("outputs.tf")
|
||||||
|
assert 'output "vpc_id"' in outputs
|
||||||
|
assert "value = local.vpc_id" in outputs
|
||||||
|
assert 'output "public_subnet_ids"' in outputs
|
||||||
|
assert 'check "prod_reuses_afterhours_vpc"' in data
|
||||||
|
assert "prod_requires_afterhours_vpc" in vpc
|
||||||
|
assert "Do not mint 10.60." in vpc
|
||||||
|
assert 'count = var.existing_vpc_id == "" ? 0 : 1' in vpc
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,20 @@ def test_worker_deletes_completed_jobs(mock_run, mock_client):
|
||||||
sqs.delete_message.assert_called_once_with(
|
sqs.delete_message.assert_called_once_with(
|
||||||
QueueUrl="https://sqs.example/jobs", ReceiptHandle="rh-1"
|
QueueUrl="https://sqs.example/jobs", ReceiptHandle="rh-1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("server.worker.sentry_sdk.capture_exception")
|
||||||
|
@patch("server.worker.boto3.client")
|
||||||
|
@patch("server.worker.run_job")
|
||||||
|
def test_worker_captures_job_failures(mock_run, mock_client, mock_capture):
|
||||||
|
sqs = MagicMock()
|
||||||
|
mock_client.return_value = sqs
|
||||||
|
sqs.receive_message.side_effect = _one_message_then_stop({"event": "close"})
|
||||||
|
mock_run.side_effect = RuntimeError("boom")
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"JOBS_QUEUE_URL": "https://sqs.example/jobs"}):
|
||||||
|
worker._running = True
|
||||||
|
worker.main()
|
||||||
|
|
||||||
|
mock_capture.assert_called_once()
|
||||||
|
sqs.delete_message.assert_not_called()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue