mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 05:51:58 +00:00
Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b39c8b27ea | ||
|
|
f2ef1df881 | ||
|
|
2fe7133411 | ||
|
|
cfa14c4240 | ||
|
|
3bd98aad8d | ||
|
|
7943207e97 | ||
|
|
c15ea8cee1 | ||
|
|
69359df69d | ||
|
|
bf1f3ea182 |
17 changed files with 828 additions and 73 deletions
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -12,7 +12,7 @@ permissions:
|
||||||
jobs:
|
jobs:
|
||||||
autofix:
|
autofix:
|
||||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
lint:
|
lint:
|
||||||
needs: autofix
|
needs: autofix
|
||||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
with:
|
with:
|
||||||
python-version: "3.12.14"
|
python-version: "3.12.14"
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ jobs:
|
||||||
terraform:
|
terraform:
|
||||||
needs: autofix
|
needs: autofix
|
||||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
with:
|
with:
|
||||||
terraform-version: "1.9.8"
|
terraform-version: "1.9.8"
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -7,4 +7,4 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
review:
|
review:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
|
|
|
||||||
4
.github/workflows/deploy-api.yaml
vendored
4
.github/workflows/deploy-api.yaml
vendored
|
|
@ -41,7 +41,7 @@ jobs:
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
name: Deploy API to dev
|
name: Deploy API to dev
|
||||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
@ -56,7 +56,7 @@ jobs:
|
||||||
deploy-prod:
|
deploy-prod:
|
||||||
name: Deploy API to prod
|
name: Deploy API to prod
|
||||||
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
|
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
|
||||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
|
|
@ -10,4 +10,4 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
label:
|
label:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
30
package-lock.json
generated
30
package-lock.json
generated
|
|
@ -9,10 +9,10 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@redocly/cli": "2.54.2",
|
"@redocly/cli": "2.57.0",
|
||||||
"eslint": "10.11.0",
|
"eslint": "10.11.0",
|
||||||
"globals": "17.12.0",
|
"globals": "17.13.0",
|
||||||
"prettier": "3.9.8"
|
"prettier": "3.9.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cacheable/memory": {
|
"node_modules/@cacheable/memory": {
|
||||||
|
|
@ -258,9 +258,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@redocly/cli": {
|
"node_modules/@redocly/cli": {
|
||||||
"version": "2.54.2",
|
"version": "2.57.0",
|
||||||
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.54.2.tgz",
|
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.57.0.tgz",
|
||||||
"integrity": "sha512-YQ53kSQV/zpYSdY3WiQvf7JxuVLD8jTEX37YOY6MS+G3tyHDCeONpUkAt6qr9n2/nmGm6m+A+PB/mGFvhkt1uQ==",
|
"integrity": "sha512-1d5fVyUaYlMNgCHUoyE2vUyxBhs/jmOHgsX/kFmfWzESw4f/G/OV/SU9E55rU7aUNmw9rHj1vXmL6yUdIn+KKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -344,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": {
|
||||||
|
|
@ -659,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": {
|
||||||
|
|
@ -906,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": {
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@redocly/cli": "2.54.2",
|
"@redocly/cli": "2.57.0",
|
||||||
"eslint": "10.11.0",
|
"eslint": "10.11.0",
|
||||||
"globals": "17.12.0",
|
"globals": "17.13.0",
|
||||||
"prettier": "3.9.8"
|
"prettier": "3.9.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
flask==3.1.3
|
flask==3.1.3
|
||||||
gunicorn==26.2.0
|
gunicorn==26.2.0
|
||||||
boto3==1.43.98
|
boto3==1.43.107
|
||||||
sentry-sdk==2.69.2
|
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.98
|
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())
|
||||||
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
|
||||||
|
|
@ -30,16 +30,30 @@ class MenuParseError(RuntimeError):
|
||||||
"""The menu page did not contain a usable catalog."""
|
"""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})
|
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
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:
|
except urllib.error.URLError as exc:
|
||||||
raise MenuParseError(f"Failed to fetch {url}") from 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)
|
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:
|
def parse_menu_html(page: str, *, menu_url: str, scraped_at: str | None = None) -> dict:
|
||||||
products = _extract_json_attr(page, PRODUCTS_MARKER)
|
products = _extract_json_attr(page, PRODUCTS_MARKER)
|
||||||
newest = _extract_json_attr(page, NEWEST_MARKER)
|
newest = _extract_json_attr(page, NEWEST_MARKER)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
boto3==1.43.98
|
boto3==1.43.107
|
||||||
fpdf2==2.8.8
|
fpdf2==2.8.9
|
||||||
PyJWT[crypto]==2.14.0
|
PyJWT[crypto]==2.15.1
|
||||||
|
|
|
||||||
64
terraform/.terraform.lock.hcl
generated
64
terraform/.terraform.lock.hcl
generated
|
|
@ -2,40 +2,40 @@
|
||||||
# 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",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ 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"
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -4,7 +4,7 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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"
|
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")
|
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():
|
def test_parse_menu_html_rejects_an_empty_catalog():
|
||||||
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
|
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
|
||||||
with pytest.raises(MenuParseError, match="empty"):
|
with pytest.raises(MenuParseError, match="empty"):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue