meal-order-manager/src/server/app.py
Adam Moussa 09052fa91a
Some checks failed
Deploy / deploy (push) Has been cancelled
chore: resolve open code scanning alerts (#58)
* 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.
2026-07-23 20:00:47 +00:00

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)