2026-05-12 18:25:15 -04:00
|
|
|
"""
|
|
|
|
|
Lightweight Flask server for the meal order form.
|
|
|
|
|
|
|
|
|
|
Serves the generated HTML form and handles order submissions.
|
|
|
|
|
Orders are saved as JSON files in the orders directory, one per employee per week.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
2026-05-13 15:06:00 -04:00
|
|
|
import time
|
2026-05-13 11:35:56 -04:00
|
|
|
import urllib.request
|
2026-05-12 18:25:15 -04:00
|
|
|
from datetime import datetime
|
2026-05-13 13:39:18 -04:00
|
|
|
from decimal import Decimal, ROUND_HALF_UP
|
2026-05-12 18:25:15 -04:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-05-13 11:35:56 -04:00
|
|
|
import boto3
|
2026-05-12 18:25:15 -04:00
|
|
|
from flask import Flask, jsonify, request, send_file
|
|
|
|
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
CONFIG_PATH = PROJECT_ROOT / "config.json"
|
|
|
|
|
OUTPUT_DIR = PROJECT_ROOT / "output"
|
|
|
|
|
ORDERS_DIR = PROJECT_ROOT / "orders"
|
|
|
|
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_config():
|
|
|
|
|
with open(CONFIG_PATH) as f:
|
|
|
|
|
return json.load(f)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def current_week() -> str:
|
|
|
|
|
return datetime.now().strftime("%Y-W%U")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def latest_menu_file() -> Path | None:
|
|
|
|
|
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
|
|
|
|
|
return files[0] if files else None
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 20:28:08 +00:00
|
|
|
def _official_menu_retail_by_name() -> dict[str, Decimal]:
|
|
|
|
|
menu_file = latest_menu_file()
|
|
|
|
|
if not menu_file:
|
|
|
|
|
return {}
|
|
|
|
|
with open(menu_file) as f:
|
|
|
|
|
meals = (json.load(f) or {}).get("meals") or []
|
|
|
|
|
out: dict[str, Decimal] = {}
|
|
|
|
|
for meal in meals:
|
|
|
|
|
name = (meal.get("name") or "").strip()
|
|
|
|
|
if not name or meal.get("price") is None:
|
|
|
|
|
continue
|
|
|
|
|
out[name] = Decimal(str(meal["price"]))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 18:25:15 -04:00
|
|
|
@app.route("/")
|
|
|
|
|
def index():
|
|
|
|
|
form_file = OUTPUT_DIR / f"order-form-{current_week()}.html"
|
|
|
|
|
if not form_file.exists():
|
|
|
|
|
return "No order form generated for this week. Run generate_form.py first.", 404
|
|
|
|
|
return send_file(form_file)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/menu")
|
|
|
|
|
def get_menu():
|
|
|
|
|
menu_file = latest_menu_file()
|
|
|
|
|
if not menu_file:
|
2026-05-12 19:31:27 -04:00
|
|
|
return jsonify(
|
|
|
|
|
{"error": "No menu data available. Run scrape_menu.py first."}
|
|
|
|
|
), 404
|
2026-05-12 18:25:15 -04:00
|
|
|
with open(menu_file) as f:
|
|
|
|
|
return jsonify(json.load(f))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/roster")
|
|
|
|
|
def get_roster():
|
|
|
|
|
config = load_config()
|
|
|
|
|
return jsonify(config.get("roster", []))
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 15:06:00 -04:00
|
|
|
# Match functions/submit_order/handler.py: TTL so a transient SSM failure cannot
|
|
|
|
|
# pin client_id to "" for the process lifetime (which would skip Google auth).
|
|
|
|
|
_GOOGLE_CLIENT_ID_CACHE_TTL_SECONDS = 300
|
|
|
|
|
|
|
|
|
|
_google_client_id_cache: str | None = None
|
|
|
|
|
_google_client_id_cache_ts = 0.0
|
2026-05-13 11:35:56 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_google_client_id() -> str:
|
2026-05-13 15:06:00 -04:00
|
|
|
global _google_client_id_cache, _google_client_id_cache_ts
|
|
|
|
|
now = time.monotonic()
|
2026-05-13 15:07:12 -04:00
|
|
|
if (
|
|
|
|
|
_google_client_id_cache is not None
|
|
|
|
|
and (now - _google_client_id_cache_ts) <= _GOOGLE_CLIENT_ID_CACHE_TTL_SECONDS
|
|
|
|
|
):
|
2026-05-13 15:06:00 -04:00
|
|
|
return _google_client_id_cache
|
|
|
|
|
|
|
|
|
|
config = load_config()
|
|
|
|
|
from_config = (config.get("google_client_id") or "").strip()
|
|
|
|
|
if from_config:
|
|
|
|
|
_google_client_id_cache = from_config
|
|
|
|
|
_google_client_id_cache_ts = now
|
|
|
|
|
return _google_client_id_cache
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
ssm = boto3.client("ssm")
|
|
|
|
|
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
|
|
|
|
|
_google_client_id_cache = (resp["Parameter"].get("Value") or "").strip()
|
|
|
|
|
except Exception:
|
|
|
|
|
_google_client_id_cache = ""
|
|
|
|
|
_google_client_id_cache_ts = now
|
2026-05-13 11:35:56 -04:00
|
|
|
return _google_client_id_cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_google_token(token: str, client_id: str) -> dict | None:
|
|
|
|
|
if not client_id:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
req = urllib.request.Request(
|
|
|
|
|
f"https://oauth2.googleapis.com/tokeninfo?id_token={token}"
|
|
|
|
|
)
|
|
|
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
|
|
|
data = json.loads(resp.read())
|
|
|
|
|
if data.get("aud") != client_id:
|
|
|
|
|
return None
|
2026-05-13 14:05:10 -04:00
|
|
|
if data.get("hd") != "seahavenind.com":
|
|
|
|
|
return None
|
2026-05-13 11:35:56 -04:00
|
|
|
return {"name": data.get("name", ""), "email": data.get("email", "")}
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 18:25:15 -04:00
|
|
|
@app.route("/api/submit-order", methods=["POST"])
|
|
|
|
|
def submit_order():
|
|
|
|
|
data = request.get_json()
|
|
|
|
|
if not data:
|
|
|
|
|
return jsonify({"error": "No data received"}), 400
|
|
|
|
|
|
2026-05-13 14:05:10 -04:00
|
|
|
client_id = _get_google_client_id()
|
2026-05-13 11:35:56 -04:00
|
|
|
google_token = data.get("google_id_token")
|
2026-05-13 14:05:10 -04:00
|
|
|
|
|
|
|
|
if client_id:
|
|
|
|
|
if not google_token:
|
|
|
|
|
return jsonify({"error": "Google authentication is required"}), 403
|
2026-05-13 11:35:56 -04:00
|
|
|
user_info = _verify_google_token(google_token, client_id)
|
|
|
|
|
if not user_info:
|
|
|
|
|
return jsonify({"error": "Invalid or unauthorized Google account"}), 403
|
|
|
|
|
name = user_info["name"]
|
|
|
|
|
email = user_info["email"]
|
|
|
|
|
else:
|
|
|
|
|
name = data.get("employee_name", "").strip()
|
|
|
|
|
email = data.get("employee_email", "").strip()
|
|
|
|
|
|
2026-05-12 18:25:15 -04:00
|
|
|
items = data.get("items", [])
|
|
|
|
|
|
|
|
|
|
if not name:
|
|
|
|
|
return jsonify({"error": "Employee name is required"}), 400
|
|
|
|
|
if not email:
|
|
|
|
|
return jsonify({"error": "Employee email is required"}), 400
|
|
|
|
|
if not items or not any(i.get("quantity", 0) > 0 for i in items):
|
|
|
|
|
return jsonify({"error": "Please select at least one meal"}), 400
|
|
|
|
|
|
2026-05-13 11:35:56 -04:00
|
|
|
config = load_config()
|
2026-05-13 13:39:18 -04:00
|
|
|
TWO_PLACES = Decimal("0.01")
|
|
|
|
|
bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0)))
|
|
|
|
|
subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0)))
|
2026-05-13 14:05:10 -04:00
|
|
|
bulk_pct = max(Decimal("0"), min(Decimal("100"), bulk_pct))
|
|
|
|
|
subsidy_pct = max(Decimal("0"), min(Decimal("100"), subsidy_pct))
|
2026-05-13 13:39:18 -04:00
|
|
|
bulk_mult = Decimal("1") - (bulk_pct / Decimal("100"))
|
|
|
|
|
subsidy_mult = Decimal("1") - (subsidy_pct / Decimal("100"))
|
2026-05-13 11:35:56 -04:00
|
|
|
|
|
|
|
|
filtered = [i for i in items if i.get("quantity", 0) > 0]
|
2026-05-13 20:28:08 +00:00
|
|
|
official_retail = _official_menu_retail_by_name()
|
|
|
|
|
if not official_retail:
|
|
|
|
|
return jsonify({"error": "Menu temporarily unavailable"}), 503
|
|
|
|
|
for item in filtered:
|
|
|
|
|
meal_name = (item.get("name") or "").strip()
|
|
|
|
|
if meal_name not in official_retail:
|
|
|
|
|
return jsonify({"error": "One or more meals are not on this week's menu"}), 400
|
|
|
|
|
|
2026-05-13 11:35:56 -04:00
|
|
|
for item in filtered:
|
2026-05-13 20:28:08 +00:00
|
|
|
meal_name = (item.get("name") or "").strip()
|
|
|
|
|
retail = official_retail[meal_name]
|
2026-05-13 13:39:18 -04:00
|
|
|
qty = Decimal(str(item.get("quantity", 0)))
|
|
|
|
|
bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
|
|
|
|
emp_price = (bulk_price * subsidy_mult).quantize(
|
|
|
|
|
TWO_PLACES, rounding=ROUND_HALF_UP
|
|
|
|
|
)
|
|
|
|
|
subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
|
|
|
|
item["retail_price"] = float(retail)
|
|
|
|
|
item["bulk_price"] = float(bulk_price)
|
|
|
|
|
item["price"] = float(emp_price)
|
|
|
|
|
item["subtotal"] = float(subtotal)
|
2026-05-13 11:35:56 -04:00
|
|
|
|
2026-05-12 18:25:15 -04:00
|
|
|
week = current_week()
|
|
|
|
|
week_dir = ORDERS_DIR / week
|
|
|
|
|
week_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2026-05-13 18:32:08 +00:00
|
|
|
# Match Lambda: one order file per employee email (not display name).
|
|
|
|
|
slug = email.strip().lower()
|
|
|
|
|
slug_safe = slug.replace("/", "_").replace("\\", "_")
|
|
|
|
|
order_file = week_dir / f"{slug_safe}.json"
|
2026-05-12 18:25:15 -04:00
|
|
|
|
2026-05-13 13:39:18 -04:00
|
|
|
total = float(
|
|
|
|
|
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
|
|
|
|
|
TWO_PLACES, rounding=ROUND_HALF_UP
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-05-13 11:35:56 -04:00
|
|
|
|
2026-05-12 18:25:15 -04:00
|
|
|
order = {
|
|
|
|
|
"employee_name": name,
|
|
|
|
|
"employee_email": email,
|
|
|
|
|
"week": week,
|
|
|
|
|
"submitted_at": datetime.now().isoformat(),
|
2026-05-13 11:35:56 -04:00
|
|
|
"items": filtered,
|
|
|
|
|
"total": total,
|
2026-05-12 18:25:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with open(order_file, "w") as f:
|
|
|
|
|
json.dump(order, f, indent=2)
|
|
|
|
|
|
2026-05-12 19:31:27 -04:00
|
|
|
return jsonify(
|
|
|
|
|
{"status": "ok", "message": f"Order saved for {name}", "total": order["total"]}
|
|
|
|
|
)
|
2026-05-12 18:25:15 -04:00
|
|
|
|
|
|
|
|
|
2026-05-13 11:35:56 -04:00
|
|
|
@app.route("/api/form-status/<week>")
|
|
|
|
|
def form_status(week: str):
|
|
|
|
|
return jsonify({"week": week, "status": "open"})
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 18:25:15 -04:00
|
|
|
@app.route("/api/orders/<week>")
|
|
|
|
|
def get_orders(week: str):
|
|
|
|
|
week_dir = ORDERS_DIR / week
|
|
|
|
|
if not week_dir.exists():
|
|
|
|
|
return jsonify({"orders": [], "week": week})
|
|
|
|
|
|
|
|
|
|
orders = []
|
|
|
|
|
for f in sorted(week_dir.glob("*.json")):
|
|
|
|
|
with open(f) as fh:
|
|
|
|
|
orders.append(json.load(fh))
|
|
|
|
|
|
|
|
|
|
return jsonify({"orders": orders, "week": week})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
ORDERS_DIR.mkdir(exist_ok=True)
|
|
|
|
|
print(f"Menu file: {latest_menu_file()}")
|
|
|
|
|
print(f"Orders dir: {ORDERS_DIR}")
|
|
|
|
|
app.run(host="0.0.0.0", port=5050, debug=True)
|