mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 00:01:58 +00:00
Merge branch 'feature/admin-summary-pdf-download'
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
This commit is contained in:
commit
5ec63687a1
5 changed files with 205 additions and 1 deletions
|
|
@ -42,7 +42,7 @@ who haven't ordered
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| `reports/{week}/order-summary.csv` | Meal-level aggregate (meal, qty, unit price, line total) for the Redefine order |
|
| `reports/{week}/order-summary.csv` | Meal-level aggregate (meal, qty, unit price, line total) for the Redefine order |
|
||||||
| `reports/{week}/payroll-deductions.csv` | Per-employee payroll deduction totals |
|
| `reports/{week}/payroll-deductions.csv` | Per-employee payroll deduction totals |
|
||||||
| `reports/{week}/weekly-summary-{week}.pdf` | Per-person summary (employee → item → quantity, **no pricing**); stored only, not emailed |
|
| `reports/{week}/weekly-summary-{week}.pdf` | Per-person summary (employee → item → quantity, **no pricing**); downloadable from the admin panel via presigned URL |
|
||||||
|
|
||||||
## AWS Resources
|
## AWS Resources
|
||||||
|
|
||||||
|
|
@ -69,6 +69,7 @@ Admins (configured in DynamoDB `CONFIG/SETTINGS` → `admin_emails` list) get an
|
||||||
- Edit order quantities, add new menu items, remove items
|
- Edit order quantities, add new menu items, remove items
|
||||||
- Delete orders entirely
|
- Delete orders entirely
|
||||||
- **Download order list** — a CSV rollup of item → total quantity across all employees (no per-employee breakdown, no prices) to drive the bulk Redefine order. Generated client-side from the loaded week, so it works for open weeks too.
|
- **Download order list** — a CSV rollup of item → total quantity across all employees (no per-employee breakdown, no prices) to drive the bulk Redefine order. Generated client-side from the loaded week, so it works for open weeks too.
|
||||||
|
- **Download summary PDF** — fetches a short-lived presigned URL for the week's per-person summary PDF (generated at Thursday close) and opens it. Returns 404 for weeks that haven't closed yet.
|
||||||
|
|
||||||
All admin operations enforce server-side price recalculation from the menu.
|
All admin operations enforce server-side price recalculation from the menu.
|
||||||
|
|
||||||
|
|
@ -80,6 +81,7 @@ All admin operations enforce server-side price recalculation from the menu.
|
||||||
| GET | `/api/admin/orders?week=YYYY-WNN` | Get all orders for a week |
|
| GET | `/api/admin/orders?week=YYYY-WNN` | Get all orders for a week |
|
||||||
| PUT | `/api/admin/orders` | Update an order (recalculates prices) |
|
| PUT | `/api/admin/orders` | Update an order (recalculates prices) |
|
||||||
| DELETE | `/api/admin/orders?week=...&email=...` | Delete an order |
|
| DELETE | `/api/admin/orders?week=...&email=...` | Delete an order |
|
||||||
|
| GET | `/api/admin/summary-pdf?week=YYYY-WNN` | Presigned URL for the week's summary PDF (404 if week not closed) |
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
@ -21,6 +22,7 @@ from shared.db import (
|
||||||
get_orders,
|
get_orders,
|
||||||
get_roster,
|
get_roster,
|
||||||
get_settings,
|
get_settings,
|
||||||
|
get_summary,
|
||||||
list_weeks,
|
list_weeks,
|
||||||
put_order,
|
put_order,
|
||||||
)
|
)
|
||||||
|
|
@ -33,6 +35,7 @@ if not logger.handlers:
|
||||||
|
|
||||||
EASTERN = ZoneInfo("America/New_York")
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
|
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
|
||||||
|
PRESIGNED_URL_TTL_SECONDS = 300 # summary-PDF presigned URL lifetime
|
||||||
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
|
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -47,6 +50,7 @@ _settings_ts = 0.0
|
||||||
_google_client_id = None
|
_google_client_id = None
|
||||||
_google_client_id_ts = 0.0
|
_google_client_id_ts = 0.0
|
||||||
_lambda = boto3.client("lambda")
|
_lambda = boto3.client("lambda")
|
||||||
|
_s3 = boto3.client("s3")
|
||||||
|
|
||||||
|
|
||||||
def _get_api_key() -> str:
|
def _get_api_key() -> str:
|
||||||
|
|
@ -191,6 +195,9 @@ def lambda_handler(event, context):
|
||||||
return handle_admin_update(event)
|
return handle_admin_update(event)
|
||||||
return handle_admin_orders(event)
|
return handle_admin_orders(event)
|
||||||
|
|
||||||
|
if "/admin/summary-pdf" in path:
|
||||||
|
return handle_admin_summary_pdf(event)
|
||||||
|
|
||||||
if "/form-status/" in path:
|
if "/form-status/" in path:
|
||||||
return handle_form_status(event)
|
return handle_form_status(event)
|
||||||
|
|
||||||
|
|
@ -250,6 +257,41 @@ def handle_admin_orders(event):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_summary_pdf(event):
|
||||||
|
"""Return a short-lived presigned URL for the week's summary PDF."""
|
||||||
|
user, err = _verify_admin(event)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
qs = event.get("queryStringParameters") or {}
|
||||||
|
week = qs.get("week", "")
|
||||||
|
if not re.match(r"^\d{4}-W\d{2}$", week):
|
||||||
|
return response(400, {"error": "week query param must be YYYY-WNN"})
|
||||||
|
|
||||||
|
summary = get_summary(week)
|
||||||
|
if summary is None:
|
||||||
|
return response(404, {"error": "No summary PDF: week not found or not closed"})
|
||||||
|
pdf_key = summary.get("weekly_summary_pdf_s3_key", "")
|
||||||
|
# The key comes from a DynamoDB record; only presign keys matching the
|
||||||
|
# shape aggregate_orders writes, so a tampered record can't expose other
|
||||||
|
# report files (e.g. payroll CSVs).
|
||||||
|
if not re.match(
|
||||||
|
r"^reports/\d{4}-W\d{2}/weekly-summary-\d{4}-W\d{2}\.pdf$", pdf_key
|
||||||
|
):
|
||||||
|
if pdf_key:
|
||||||
|
logger.error("Unexpected summary PDF key shape for %s: %s", week, pdf_key)
|
||||||
|
return response(500, {"error": "Internal error"})
|
||||||
|
return response(404, {"error": "No summary PDF available for this week"})
|
||||||
|
|
||||||
|
url = _s3.generate_presigned_url(
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": os.environ["REPORTS_BUCKET"], "Key": pdf_key},
|
||||||
|
ExpiresIn=PRESIGNED_URL_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
logger.info("Admin %s requested summary PDF for %s", user["email"], week)
|
||||||
|
return response(200, {"week": week, "url": url})
|
||||||
|
|
||||||
|
|
||||||
def handle_admin_delete(event):
|
def handle_admin_delete(event):
|
||||||
user, err = _verify_admin(event)
|
user, err = _verify_admin(event)
|
||||||
if err:
|
if err:
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ def generate_form(
|
||||||
admin_url = (
|
admin_url = (
|
||||||
f"{api_url}/api/admin/orders" if api_url else "/api/admin/orders"
|
f"{api_url}/api/admin/orders" if api_url else "/api/admin/orders"
|
||||||
).replace("</", "<\\/")
|
).replace("</", "<\\/")
|
||||||
|
admin_pdf_url = (
|
||||||
|
f"{api_url}/api/admin/summary-pdf" if api_url else "/api/admin/summary-pdf"
|
||||||
|
).replace("</", "<\\/")
|
||||||
api_key_json = json.dumps(api_key).replace("</", "<\\/")
|
api_key_json = json.dumps(api_key).replace("</", "<\\/")
|
||||||
has_discount = bulk_discount > 0 or company_subsidy > 0
|
has_discount = bulk_discount > 0 or company_subsidy > 0
|
||||||
use_google_auth = bool(google_client_id)
|
use_google_auth = bool(google_client_id)
|
||||||
|
|
@ -416,6 +419,7 @@ body {{ padding-bottom: 80px; }}
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
<select id="admin-week" onchange="loadWeekOrders(this.value)" style="padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:0.9rem;"></select>
|
<select id="admin-week" onchange="loadWeekOrders(this.value)" style="padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:0.9rem;"></select>
|
||||||
<button id="admin-download-btn" onclick="downloadOrderList()" style="margin:0;padding:8px 16px;font-size:0.85rem;background:#1a1a2e;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600;" disabled>Download order list</button>
|
<button id="admin-download-btn" onclick="downloadOrderList()" style="margin:0;padding:8px 16px;font-size:0.85rem;background:#1a1a2e;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600;" disabled>Download order list</button>
|
||||||
|
<button id="admin-pdf-btn" onclick="downloadSummaryPdf()" style="margin:0;padding:8px 16px;font-size:0.85rem;background:#1a1a2e;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600;">Download summary PDF</button>
|
||||||
<button onclick="hideAdmin()" class="back-btn" style="margin:0;padding:8px 16px;font-size:0.85rem;">Back to Menu</button>
|
<button onclick="hideAdmin()" class="back-btn" style="margin:0;padding:8px 16px;font-size:0.85rem;">Back to Menu</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -452,6 +456,7 @@ const SUBMIT_URL = '{submit_url}';
|
||||||
const STATUS_URL = '{status_url}';
|
const STATUS_URL = '{status_url}';
|
||||||
const ROSTER_URL = '{roster_url}';
|
const ROSTER_URL = '{roster_url}';
|
||||||
const ADMIN_URL = '{admin_url}';
|
const ADMIN_URL = '{admin_url}';
|
||||||
|
const ADMIN_PDF_URL = '{admin_pdf_url}';
|
||||||
const API_KEY = {api_key_json};
|
const API_KEY = {api_key_json};
|
||||||
const WEEK = '{week}';
|
const WEEK = '{week}';
|
||||||
const BULK_DISCOUNT = {bulk_discount};
|
const BULK_DISCOUNT = {bulk_discount};
|
||||||
|
|
@ -838,6 +843,28 @@ function downloadOrderList() {{
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
// Fetch a short-lived presigned URL for the loaded week's summary PDF
|
||||||
|
// (generated at Thursday close) and open it. 404 means the week has not
|
||||||
|
// closed yet, so no PDF exists.
|
||||||
|
function downloadSummaryPdf() {{
|
||||||
|
const week = currentAdminWeek || WEEK;
|
||||||
|
const btn = document.getElementById('admin-pdf-btn');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
fetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), {{ headers: adminHeaders() }})
|
||||||
|
.then(r => r.json().then(data => ({{ ok: r.ok, status: r.status, data }})))
|
||||||
|
.then(({{ ok, status, data }}) => {{
|
||||||
|
if (ok && data.url) {{
|
||||||
|
window.location.href = data.url;
|
||||||
|
}} else if (status === 404) {{
|
||||||
|
alert('No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.');
|
||||||
|
}} else {{
|
||||||
|
alert('Could not fetch the summary PDF: ' + (data.error || 'unknown error'));
|
||||||
|
}}
|
||||||
|
}})
|
||||||
|
.catch(() => alert('Could not fetch the summary PDF.'))
|
||||||
|
.finally(() => {{ if (btn) btn.disabled = false; }});
|
||||||
|
}}
|
||||||
|
|
||||||
let lastAdminData = null;
|
let lastAdminData = null;
|
||||||
const origLoadWeekOrders = loadWeekOrders;
|
const origLoadWeekOrders = loadWeekOrders;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,7 @@ Resources:
|
||||||
FORM_APIKEY_SM_NAME: meal-order-manager/form-api-key
|
FORM_APIKEY_SM_NAME: meal-order-manager/form-api-key
|
||||||
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
|
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
|
||||||
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
|
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
|
||||||
|
REPORTS_BUCKET: !Ref ReportsBucket
|
||||||
Policies:
|
Policies:
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
TableName: !Ref OrdersTable
|
TableName: !Ref OrdersTable
|
||||||
|
|
@ -270,6 +271,12 @@ Resources:
|
||||||
- Effect: Allow
|
- Effect: Allow
|
||||||
Action: ssm:GetParameter
|
Action: ssm:GetParameter
|
||||||
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*'
|
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*'
|
||||||
|
# Read-only access to weekly summary PDFs (only — not the
|
||||||
|
# payroll/order CSVs) for the admin summary-pdf presigned-URL
|
||||||
|
# endpoint.
|
||||||
|
- Effect: Allow
|
||||||
|
Action: s3:GetObject
|
||||||
|
Resource: !Sub '${ReportsBucket.Arn}/reports/*/weekly-summary-*.pdf'
|
||||||
Events:
|
Events:
|
||||||
SubmitOrder:
|
SubmitOrder:
|
||||||
Type: HttpApi
|
Type: HttpApi
|
||||||
|
|
@ -307,6 +314,12 @@ Resources:
|
||||||
ApiId: !Ref OrderApi
|
ApiId: !Ref OrderApi
|
||||||
Path: /api/admin/orders
|
Path: /api/admin/orders
|
||||||
Method: DELETE
|
Method: DELETE
|
||||||
|
AdminSummaryPdf:
|
||||||
|
Type: HttpApi
|
||||||
|
Properties:
|
||||||
|
ApiId: !Ref OrderApi
|
||||||
|
Path: /api/admin/summary-pdf
|
||||||
|
Method: GET
|
||||||
|
|
||||||
CloseFormFunction:
|
CloseFormFunction:
|
||||||
Type: AWS::Serverless::Function
|
Type: AWS::Serverless::Function
|
||||||
|
|
|
||||||
|
|
@ -1584,3 +1584,123 @@ def test_slack_failure_does_not_fail_order(
|
||||||
assert status == 200, f"Expected 200 despite Slack failure, got {status}: {body}"
|
assert status == 200, f"Expected 200 despite Slack failure, got {status}: {body}"
|
||||||
assert body["status"] == "ok", f"Expected status='ok', got '{body['status']}'"
|
assert body["status"] == "ok", f"Expected status='ok', got '{body['status']}'"
|
||||||
mock_put.assert_called_once()
|
mock_put.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin summary-PDF endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _pdf_event(week=None):
|
||||||
|
qs = {"week": week} if week else {}
|
||||||
|
return {
|
||||||
|
"rawPath": "/api/admin/summary-pdf",
|
||||||
|
"requestContext": {"http": {"method": "GET"}},
|
||||||
|
"queryStringParameters": qs,
|
||||||
|
"headers": {"authorization": "Bearer admin-token"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"REPORTS_BUCKET": "test-reports-bucket"})
|
||||||
|
@patch("submit_order_handler._s3")
|
||||||
|
@patch("submit_order_handler.get_summary")
|
||||||
|
@patch(
|
||||||
|
"submit_order_handler._verify_admin",
|
||||||
|
return_value=({"email": "adam@seahavenind.com"}, None),
|
||||||
|
)
|
||||||
|
def test_admin_summary_pdf_returns_presigned_url(
|
||||||
|
mock_verify, mock_get_summary, mock_s3
|
||||||
|
):
|
||||||
|
"""Closed week with a stamped PDF key returns a presigned URL."""
|
||||||
|
from submit_order_handler import lambda_handler
|
||||||
|
|
||||||
|
mock_get_summary.return_value = {
|
||||||
|
"weekly_summary_pdf_s3_key": "reports/2026-W22/weekly-summary-2026-W22.pdf"
|
||||||
|
}
|
||||||
|
mock_s3.generate_presigned_url.return_value = "https://signed.example/pdf"
|
||||||
|
|
||||||
|
status, body = _parse_response(lambda_handler(_pdf_event("2026-W22"), None))
|
||||||
|
|
||||||
|
assert status == 200, f"Expected 200, got {status}: {body}"
|
||||||
|
assert body["url"] == "https://signed.example/pdf"
|
||||||
|
assert body["week"] == "2026-W22"
|
||||||
|
mock_s3.generate_presigned_url.assert_called_once_with(
|
||||||
|
"get_object",
|
||||||
|
Params={
|
||||||
|
"Bucket": "test-reports-bucket",
|
||||||
|
"Key": "reports/2026-W22/weekly-summary-2026-W22.pdf",
|
||||||
|
},
|
||||||
|
ExpiresIn=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("submit_order_handler.get_summary", return_value=None)
|
||||||
|
@patch(
|
||||||
|
"submit_order_handler._verify_admin",
|
||||||
|
return_value=({"email": "adam@seahavenind.com"}, None),
|
||||||
|
)
|
||||||
|
def test_admin_summary_pdf_404_when_week_not_closed(mock_verify, mock_get_summary):
|
||||||
|
"""No SUMMARY item (week still open) returns 404."""
|
||||||
|
from submit_order_handler import lambda_handler
|
||||||
|
|
||||||
|
status, body = _parse_response(lambda_handler(_pdf_event("2026-W23"), None))
|
||||||
|
|
||||||
|
assert status == 404, f"Expected 404, got {status}: {body}"
|
||||||
|
assert "no summary pdf" in body["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"submit_order_handler._verify_admin",
|
||||||
|
return_value=({"email": "adam@seahavenind.com"}, None),
|
||||||
|
)
|
||||||
|
def test_admin_summary_pdf_400_without_week(mock_verify):
|
||||||
|
"""Missing week query param returns 400."""
|
||||||
|
from submit_order_handler import lambda_handler
|
||||||
|
|
||||||
|
status, body = _parse_response(lambda_handler(_pdf_event(), None))
|
||||||
|
|
||||||
|
assert status == 400, f"Expected 400, got {status}: {body}"
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"submit_order_handler._verify_admin",
|
||||||
|
return_value=({"email": "adam@seahavenind.com"}, None),
|
||||||
|
)
|
||||||
|
def test_admin_summary_pdf_400_malformed_week(mock_verify):
|
||||||
|
"""Week not matching YYYY-WNN returns 400 before any lookup."""
|
||||||
|
from submit_order_handler import lambda_handler
|
||||||
|
|
||||||
|
status, body = _parse_response(lambda_handler(_pdf_event("../../etc"), None))
|
||||||
|
|
||||||
|
assert status == 400, f"Expected 400, got {status}: {body}"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("submit_order_handler._s3")
|
||||||
|
@patch("submit_order_handler.get_summary")
|
||||||
|
@patch(
|
||||||
|
"submit_order_handler._verify_admin",
|
||||||
|
return_value=({"email": "adam@seahavenind.com"}, None),
|
||||||
|
)
|
||||||
|
def test_admin_summary_pdf_rejects_tampered_key(mock_verify, mock_get_summary, mock_s3):
|
||||||
|
"""A PDF key outside the expected shape is never presigned (500, no URL)."""
|
||||||
|
from submit_order_handler import lambda_handler
|
||||||
|
|
||||||
|
mock_get_summary.return_value = {
|
||||||
|
"weekly_summary_pdf_s3_key": "reports/2026-W22/payroll-deductions.csv"
|
||||||
|
}
|
||||||
|
|
||||||
|
status, body = _parse_response(lambda_handler(_pdf_event("2026-W22"), None))
|
||||||
|
|
||||||
|
assert status == 500, f"Expected 500 for tampered key, got {status}: {body}"
|
||||||
|
mock_s3.generate_presigned_url.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"submit_order_handler._verify_admin",
|
||||||
|
return_value=(None, submit_order_handler.response(401, {"error": "Missing token"})),
|
||||||
|
)
|
||||||
|
def test_admin_summary_pdf_requires_admin(mock_verify):
|
||||||
|
"""Auth failure from _verify_admin is returned as-is."""
|
||||||
|
from submit_order_handler import lambda_handler
|
||||||
|
|
||||||
|
status, body = _parse_response(lambda_handler(_pdf_event("2026-W22"), None))
|
||||||
|
|
||||||
|
assert status == 401, f"Expected 401, got {status}: {body}"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue