Compare commits

...

9 commits
v1.1.0 ... main

Author SHA1 Message Date
renovate[bot]
b39c8b27ea
chore(deps): update terraform aws to v6.67.0 (#228)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 16:22:40 +00:00
renovate[bot]
f2ef1df881
chore(deps): update npm minor and patch (#227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 16:22:21 +00:00
renovate[bot]
2fe7133411
chore(deps): update pip minor and patch (#226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 16:21:26 +00:00
dependabot[bot]
cfa14c4240
chore(deps-dev): bump brace-expansion from 5.0.9 to 5.0.12 in the npm_and_yarn group across 1 directory (#225)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
* chore(deps-dev): bump brace-expansion

Bumps the npm_and_yarn group with 1 update in the / directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `brace-expansion` from 5.0.9 to 5.0.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.9...v5.0.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: update org workflow SHA pins to latest version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-10-02 22:06:10 +00:00
renovate[bot]
3bd98aad8d
chore(deps): update dependency pyjwt to v2.15.0 [security] (#224)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-01 16:30:27 +00:00
renovate[bot]
7943207e97
chore(deps): update dependency prettier to v3.9.9 (#222)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-28 16:05:21 +00:00
renovate[bot]
c15ea8cee1
chore(deps): update sea-haven-industries/.github action to v1.0.19 (#223)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-28 16:05:18 +00:00
Adam Moussa
69359df69d
docs(agents): drop security review gates (#221)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
Agents no longer treat a security review or a cross-family review as a merge gate.
2026-09-26 17:17:35 -04:00
Adam Moussa
bf1f3ea182
feat(cart): add a local Redefine cart filler (#220)
* feat(cart): add a local Redefine cart filler

Match the admin order-list CSV to the live menu and add each meal to a guest cart so the weekly bulk order does not have to be typed in by hand.

* fix(cart): report cart failures without a traceback

A closed window or a failed cart request during the fill now prints a short failure and the Added/Failed/Skipped summary instead of crashing.

* fix(cart): fail cleanly on a bad CSV and a closed browser

A non-UTF-8 order list and a Playwright error while opening the page now print a short message and exit 1 instead of a traceback.

* fix(cart): account for a closed window and an empty menu

A closed page during add marks the remaining meals as not attempted, and an empty menu catalog raises the same error as the menu parser.
2026-09-26 20:36:38 +00:00
17 changed files with 828 additions and 73 deletions

View file

@ -12,7 +12,7 @@ permissions:
jobs:
autofix:
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
permissions:
contents: write
secrets: inherit
@ -26,7 +26,7 @@ jobs:
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@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
with:
python-version: "3.12.14"
@ -86,7 +86,7 @@ jobs:
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@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
with:
terraform-version: "1.9.8"

View file

@ -7,4 +7,4 @@ permissions:
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21

View file

@ -41,7 +41,7 @@ jobs:
deploy-dev:
name: Deploy API to dev
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
permissions:
contents: read
id-token: write
@ -56,7 +56,7 @@ jobs:
deploy-prod:
name: Deploy API to prod
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
permissions:
contents: read
id-token: write

View file

@ -10,4 +10,4 @@ permissions:
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21

View file

@ -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.
- 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 must pass before merge.
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.

30
package-lock.json generated
View file

@ -9,10 +9,10 @@
"version": "1.0.0",
"devDependencies": {
"@eslint/js": "10.0.1",
"@redocly/cli": "2.54.2",
"@redocly/cli": "2.57.0",
"eslint": "10.11.0",
"globals": "17.12.0",
"prettier": "3.9.8"
"globals": "17.13.0",
"prettier": "3.9.9"
}
},
"node_modules/@cacheable/memory": {
@ -258,9 +258,9 @@
"license": "MIT"
},
"node_modules/@redocly/cli": {
"version": "2.54.2",
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.54.2.tgz",
"integrity": "sha512-YQ53kSQV/zpYSdY3WiQvf7JxuVLD8jTEX37YOY6MS+G3tyHDCeONpUkAt6qr9n2/nmGm6m+A+PB/mGFvhkt1uQ==",
"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": {
@ -344,9 +344,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -659,9 +659,9 @@
}
},
"node_modules/globals": {
"version": "17.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
"integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
"version": "17.13.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
"dev": true,
"license": "MIT",
"engines": {
@ -906,9 +906,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"version": "3.9.9",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
"dev": true,
"license": "MIT",
"bin": {

View file

@ -11,9 +11,9 @@
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@redocly/cli": "2.54.2",
"@redocly/cli": "2.57.0",
"eslint": "10.11.0",
"globals": "17.12.0",
"prettier": "3.9.8"
"globals": "17.13.0",
"prettier": "3.9.9"
}
}

View file

@ -1,7 +1,7 @@
flask==3.1.3
gunicorn==26.2.0
boto3==1.43.98
sentry-sdk==2.69.2
boto3==1.43.107
sentry-sdk==2.71.0
jinja2==3.1.6
fpdf2==2.8.8
PyJWT[crypto]==2.14.0
fpdf2==2.8.9
PyJWT[crypto]==2.15.1

View file

@ -1,4 +1,4 @@
playwright==1.63.0
flask==3.1.3
boto3==1.43.98
boto3==1.43.107
jinja2==3.1.6

View 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())

203
src/scraper/fill_cart.py Normal file
View 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

View file

@ -30,16 +30,30 @@ class MenuParseError(RuntimeError):
"""The menu page did not contain a usable catalog."""
def fetch_menu(url: str, *, timeout: float = 30) -> dict:
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:
page = response.read().decode("utf-8", "replace")
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)

View file

@ -1,3 +1,3 @@
boto3==1.43.98
fpdf2==2.8.8
PyJWT[crypto]==2.14.0
boto3==1.43.107
fpdf2==2.8.9
PyJWT[crypto]==2.15.1

View file

@ -2,40 +2,40 @@
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.65.0"
constraints = "6.65.0"
version = "6.67.0"
constraints = "6.67.0"
hashes = [
"h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=",
"h1:1QFvuV0+K3GieeXSlb7qi9Q334XrsnMP8dCRwpM7fkc=",
"h1:36ZBGQTzM6dW8dLQ145loI9yw6/fSbRV+fvLGCeEubc=",
"h1:4uHlr+eDGOjf71giwLidIfGsMP6g5x2wkSGP5xq9EpM=",
"h1:9fSxZKfaAGrNSzXIz53IP2EmC1LYdO/fGYl7T87Y36Q=",
"h1:GJCK46UtrJMGSyByH4SiDytbwX11bg79jvTGZ27BGWU=",
"h1:H0qzEAMrqydb8eTZdebso8nS/b8w1BNHysP8nmM4pLk=",
"h1:RjeO6m/SvlhGUCDrwTdj99kJhKl/rC1zE9B5mi3YhVw=",
"h1:Yx8Kv/T/BHVE8S1WEyHsFdu4HcsAQIl5e/831DeoQ5k=",
"h1:ZFDxAUFzk1A2BPbLgu1LhAJ3erD4BbqZsF25yQobN4o=",
"h1:anUZ356aBWvbHzQalF036qNKO+RISPmq6ycIL4OdXxk=",
"h1:fhsSsZmfNFf4wErbcsmu1/ek1/TVUy7NCuMYeOpA1aE=",
"h1:l+w5eqL9UqpiVZ2ll0r+YvWAPjIL/WtqV8xqfH1+apM=",
"h1:nt0kyMKN9kDNXKHOejaAo7LQIemP3tyntYjxHY32P0M=",
"h1:o2tj5YHQU1QNgANW2YAbjj0opl0BRJZl8W9trjYsqdA=",
"zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be",
"zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3",
"zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917",
"zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8",
"zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09",
"zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8",
"zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7",
"zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5",
"zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294",
"zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e",
"h1:01Y50Z+67vWZmsW9e8FQTj9NT/XW+x1n0YGdz2x4BpY=",
"h1:6dffiL4BGVg+Ac2/64N+svhucYstBnVTTu55hWEhmq8=",
"h1:8kRViFkyn96SufnuV3Oi3QmfL+DiyxlhAPcSX2jH0T0=",
"h1:EAfdlvAeLCxhO9G5nnZ6Ti1zsmQP1aClgS41rneOijY=",
"h1:EB9ixYOZrSlYD7wtJxf88qwoyyWrlKDKxhzaCLIb3t4=",
"h1:Ksjd+RJVccOFRlbg3gpoJjL7T3SMPHxso2dPzAVTyRE=",
"h1:OgdIUAQDtJBxlKjoPgChD1w57vl6hm6PyJHiBYgsgQA=",
"h1:Syy68cIDOz7sXpxhjrkCwNHvmOj9VeaLVTJ/XnUKSuU=",
"h1:Tz5wi4X4Gkj2I1Gif4MOtfrj4JerCz+5KqSi6Xj+n/A=",
"h1:XaBXhLvtX5lUYkv5fHKzzMfoZXaIh5zU8hvM4jG2TXI=",
"h1:fOwuAcOFTGOU0GY1m4NHhDgTAdTSiU+9qZ+REdp5HIU=",
"h1:gyduBRrLO8EiRf8zA1aiLRoWydrUMw+TG0pUbOXo1g4=",
"h1:iDfjW95J79sAMaOxeln73bKerm9SBemvLTrKepODumw=",
"h1:xH+es0QQOteWlNptLqZzI6k8y94Aq/11S7lozotvO2c=",
"h1:zLmFaFw5LptpWftHL3O6+7uykOH/0F9AmyVQ7V6kslY=",
"zh:111d5686a1f4ccbc888bb5e2229308bcdd9149898c6af97969fcdfb0e7bd2aa0",
"zh:21b3b7693bd9754c039fd546f03ed09e7510c6189aa5ee37176f144cf8dd5f95",
"zh:25e03c7f025ff4851889537605aa560d2e39bd738b33a5204b8037eb164b475f",
"zh:2817a046f1060e25bf04b0eb78f4e251130bb92dc82ec1264ce156dc9bf78e67",
"zh:2d318d674c3ec9dbd97ddb10b12ad4827be4f508c43ba2ae1e0523baa9e367e3",
"zh:2f07ab356718267299e10b6072472ded29d82753883027bf43bcd687ac72feb2",
"zh:32307e67f83bc0dc94d38cfcd23d782166a09e0e975e2a5aa7d7a3e7ca5d3da8",
"zh:4ce94853264097dd7f4542b3cfd18d2b98b06d23321db45d5e384ea275a57f63",
"zh:869656c41cc7c7412f213e488f98167cff61deafcb8cf245d25d056e8dfdc263",
"zh:8fd8c814e9d8edf152552047db23bf5b5d63f22e6b0b5d47b2af851376e99349",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f",
"zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab",
"zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af",
"zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87",
"zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59",
"zh:9cbc02ceef9bd469da497a1e7065ff984bdacfbe919ac3cce804a86c13b6e2c6",
"zh:9ea55dda2767acfc1f6337fc7d29b1d4d79d6f8f04871f8ad99a6078d2865994",
"zh:d7149df0819fb57a160489db45d33951765b8f0118daa3b4cd549a0135bc97a1",
"zh:e5819937bb7043d08c9829b32d52d9fb55a5b9a4d8d55d550d47a3d7392db546",
"zh:f93bc38dbc0ad53842303f30eec7a22c525c4d56209b54ec33a8d375cfc4e4fd",
]
}

View file

@ -4,7 +4,7 @@ terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.65.0"
version = "6.67.0"
}
random = {
source = "hashicorp/random"

265
tests/test_fill_cart.py Normal file
View 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

View file

@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from scraper.parse_menu import MenuParseError, parse_menu_html
from scraper.parse_menu import MenuParseError, extract_catalog_products, parse_menu_html
FIXTURE = Path(__file__).resolve().parent / "fixtures" / "menu_page.html"
@ -43,6 +43,12 @@ def test_parse_menu_html_rejects_a_missing_catalog():
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"):