mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Some checks failed
Deploy / deploy (push) Has been cancelled
* ci: add least-privilege permissions blocks to workflow callers Resolves code scanning alerts #9 and #11 (actions/missing-workflow-permissions). Both callable workflows only need contents: read; the dependency-review callable already declares it internally, this caps the caller token to match. * fix: turn off debug mode in Flask app configuration. Resolves code scanning alert #2 (Flask app is run in debug mode) * ci: bump reusable workflow pin to f71002a (ruff 0.15.22 pin) Picks up Sea-Haven-Industries/.github#88, which pins ruff in ci-python-sam so unpinned installs no longer float to new releases with changed default rule sets (0.16.0 broke CI with 89 pre-existing findings). Refs Sea-Haven-Industries/.github#87.
377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""
|
|
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
|
|
import time
|
|
import urllib.request
|
|
from datetime import datetime
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
from pathlib import Path
|
|
|
|
import boto3
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
@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:
|
|
return jsonify(
|
|
{"error": "No menu data available. Run scrape_menu.py first."}
|
|
), 404
|
|
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", []))
|
|
|
|
|
|
# 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
|
|
|
|
|
|
def _get_google_client_id() -> str:
|
|
global _google_client_id_cache, _google_client_id_cache_ts
|
|
now = time.monotonic()
|
|
if (
|
|
_google_client_id_cache is not None
|
|
and (now - _google_client_id_cache_ts) <= _GOOGLE_CLIENT_ID_CACHE_TTL_SECONDS
|
|
):
|
|
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
|
|
return _google_client_id_cache
|
|
|
|
|
|
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
|
|
|
|
|
|
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
|
|
if data.get("hd") not in ALLOWED_DOMAINS:
|
|
return None
|
|
return {"name": data.get("name", ""), "email": data.get("email", "")}
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
@app.route("/api/submit-order", methods=["POST"])
|
|
def submit_order():
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({"error": "No data received"}), 400
|
|
|
|
client_id = _get_google_client_id()
|
|
google_token = data.get("google_id_token")
|
|
|
|
if client_id:
|
|
if not google_token:
|
|
return jsonify({"error": "Google authentication is required"}), 403
|
|
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()
|
|
|
|
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
|
|
|
|
config = load_config()
|
|
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)))
|
|
bulk_pct = max(Decimal("0"), min(Decimal("100"), bulk_pct))
|
|
subsidy_pct = max(Decimal("0"), min(Decimal("100"), subsidy_pct))
|
|
bulk_mult = Decimal("1") - (bulk_pct / Decimal("100"))
|
|
subsidy_mult = Decimal("1") - (subsidy_pct / Decimal("100"))
|
|
|
|
filtered = [i for i in items if i.get("quantity", 0) > 0]
|
|
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
|
|
|
|
for item in filtered:
|
|
meal_name = (item.get("name") or "").strip()
|
|
retail = official_retail[meal_name]
|
|
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)
|
|
|
|
week = current_week()
|
|
week_dir = ORDERS_DIR / week
|
|
week_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 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"
|
|
|
|
total = float(
|
|
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
|
|
TWO_PLACES, rounding=ROUND_HALF_UP
|
|
)
|
|
)
|
|
|
|
order = {
|
|
"employee_name": name,
|
|
"employee_email": email,
|
|
"week": week,
|
|
"submitted_at": datetime.now().isoformat(),
|
|
"items": filtered,
|
|
"total": total,
|
|
}
|
|
|
|
with open(order_file, "w") as f:
|
|
json.dump(order, f, indent=2)
|
|
|
|
return jsonify(
|
|
{"status": "ok", "message": f"Order saved for {name}", "total": order["total"]}
|
|
)
|
|
|
|
|
|
@app.route("/api/form-status/<week>")
|
|
def form_status(week: str):
|
|
return jsonify({"week": week, "status": "open"})
|
|
|
|
|
|
@app.route("/api/admin/orders")
|
|
def admin_orders():
|
|
week = request.args.get("week")
|
|
if not week:
|
|
weeks = []
|
|
for f in sorted(ORDERS_DIR.iterdir(), reverse=True):
|
|
if f.is_dir():
|
|
order_count = len(list(f.glob("*.json")))
|
|
weeks.append(
|
|
{
|
|
"week": f.name,
|
|
"form_status": "open",
|
|
"meal_count": 0,
|
|
"order_count": order_count,
|
|
}
|
|
)
|
|
return jsonify({"weeks": weeks})
|
|
|
|
week_dir = ORDERS_DIR / week
|
|
if not week_dir.exists():
|
|
return jsonify(
|
|
{"week": week, "orders": [], "total_employees": 0, "grand_total": 0}
|
|
)
|
|
|
|
orders = []
|
|
for f in sorted(week_dir.glob("*.json")):
|
|
with open(f) as fh:
|
|
orders.append(json.load(fh))
|
|
orders.sort(key=lambda o: o.get("employee_name", ""))
|
|
|
|
return jsonify(
|
|
{
|
|
"week": week,
|
|
"orders": orders,
|
|
"total_employees": len(orders),
|
|
"grand_total": round(sum(o.get("total", 0) for o in orders), 2),
|
|
}
|
|
)
|
|
|
|
|
|
@app.route("/api/admin/orders", methods=["DELETE"])
|
|
def admin_delete_order():
|
|
week = request.args.get("week", "")
|
|
email = request.args.get("email", "")
|
|
if not week or not email:
|
|
return jsonify({"error": "week and email are required"}), 400
|
|
|
|
slug = email.strip().lower().replace("/", "_").replace("\\", "_")
|
|
order_file = ORDERS_DIR / week / f"{slug}.json"
|
|
if not order_file.exists():
|
|
return jsonify({"error": "Order not found"}), 404
|
|
|
|
order_file.unlink()
|
|
return jsonify({"status": "deleted", "week": week, "email": email})
|
|
|
|
|
|
@app.route("/api/admin/orders", methods=["PUT"])
|
|
def admin_update_order():
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({"error": "No data received"}), 400
|
|
|
|
week = data.get("week", "")
|
|
email = data.get("email", "")
|
|
new_items = data.get("items", [])
|
|
if not week or not email:
|
|
return jsonify({"error": "week and email are required"}), 400
|
|
|
|
slug = email.strip().lower().replace("/", "_").replace("\\", "_")
|
|
order_file = ORDERS_DIR / week / f"{slug}.json"
|
|
if not order_file.exists():
|
|
return jsonify({"error": "Order not found"}), 404
|
|
|
|
with open(order_file) as f:
|
|
existing = json.load(f)
|
|
|
|
filtered = [i for i in new_items if i.get("quantity", 0) > 0]
|
|
if not filtered:
|
|
return jsonify({"error": "At least one item required"}), 400
|
|
|
|
config = load_config()
|
|
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)))
|
|
bulk_mult = Decimal("1") - (
|
|
max(Decimal("0"), min(Decimal("100"), bulk_pct)) / Decimal("100")
|
|
)
|
|
subsidy_mult = Decimal("1") - (
|
|
max(Decimal("0"), min(Decimal("100"), subsidy_pct)) / Decimal("100")
|
|
)
|
|
|
|
official_retail = _official_menu_retail_by_name()
|
|
for item in filtered:
|
|
meal_name = (item.get("name") or "").strip()
|
|
retail = official_retail.get(meal_name)
|
|
if retail is None:
|
|
return jsonify({"error": f"'{meal_name}' not on this week's menu"}), 400
|
|
qty = Decimal(str(item["quantity"]))
|
|
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)
|
|
|
|
total = float(
|
|
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
|
|
TWO_PLACES, rounding=ROUND_HALF_UP
|
|
)
|
|
)
|
|
|
|
existing["items"] = filtered
|
|
existing["total"] = total
|
|
|
|
with open(order_file, "w") as f:
|
|
json.dump(existing, f, indent=2)
|
|
|
|
return jsonify({"status": "updated", "week": week, "email": email, "total": total})
|
|
|
|
|
|
@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=False)
|