Initial commit: Lunch Notifier source from server

This commit is contained in:
mattie726 2026-08-25 22:53:07 -07:00 committed by Barely Removable
commit b568287528
9 changed files with 878 additions and 0 deletions

576
app.py Normal file
View file

@ -0,0 +1,576 @@
"""
Lunch Notifier web UI + scheduler combined.
Visit http://localhost:2323 to manage settings.
"""
import json
import logging
import os
import smtplib
import threading
import time
from datetime import date, datetime
from email.message import EmailMessage
from pathlib import Path
import schedule
from flask import Flask, jsonify, request
from scraper import get_lunch_menu
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
app = Flask(__name__)
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "/data/settings.json"))
PORT = int(os.environ.get("PORT", "2323"))
# Env-var defaults (used only when no settings file exists yet)
_DEFAULTS = {
"phone_numbers": [p for p in [os.environ.get("PHONE_NUMBER", "")] if p],
"carrier_gateway": os.environ.get("CARRIER_GATEWAY", "vtext.com"),
"run_time": os.environ.get("RUN_TIME", "07:00"),
"run_days": [d.strip() for d in os.environ.get("RUN_DAYS", "mon,tue,wed,thu,fri").split(",") if d.strip()],
"vacations": [],
}
_status = {"last_run": None, "last_result": None, "last_message": None}
_lock = threading.Lock()
# ── Settings ──────────────────────────────────────────────────────────────────
def load_settings() -> dict:
if SETTINGS_FILE.exists():
try:
with open(SETTINGS_FILE) as f:
return json.load(f)
except Exception:
pass
return dict(_DEFAULTS)
def save_settings(s: dict) -> None:
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(s, f, indent=2)
def is_on_vacation(settings: dict) -> str | None:
today = date.today().isoformat()
for v in settings.get("vacations", []):
if v["start"] <= today <= v["end"]:
return v.get("label", "Vacation")
return None
# ── Core job ──────────────────────────────────────────────────────────────────
def _send_email_sms(message: str, phone: str, gateway: str) -> None:
gmail_user = os.environ["GMAIL_USER"]
gmail_pass = os.environ["GMAIL_APP_PASSWORD"]
to_addr = f"{phone}@{gateway}"
msg = EmailMessage()
msg["From"] = gmail_user
msg["To"] = to_addr
msg.set_content(message)
log.info(f"Sending → {to_addr}")
with smtplib.SMTP("smtp.gmail.com", 587, timeout=15) as smtp:
smtp.starttls()
smtp.login(gmail_user, gmail_pass)
smtp.send_message(msg)
log.info(f"Sent ✓ → {to_addr}")
def run_job() -> None:
global _status
settings = load_settings()
vac = is_on_vacation(settings)
if vac:
log.info(f"Skipping — vacation: {vac}")
_status = {**_status, "last_run": datetime.now().isoformat(), "last_result": f"skipped ({vac})"}
return
phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()]
gateway = settings.get("carrier_gateway", "vtext.com")
try:
items, date_str = get_lunch_menu()
if items:
body = "\n".join(f"{i}" for i in items)
message = f"Mariposa Lunch {date_str}:\n{body}"
else:
message = f"No lunch menu posted for {date_str} (may be a break or holiday)."
except Exception as e:
log.exception("Menu fetch failed")
message = f"Error fetching Mariposa lunch menu: {e}"
if len(message) > 1400:
message = message[:1397] + "..."
log.info(f"Message:\n{message}")
errors = []
for phone in phones:
try:
_send_email_sms(message, phone, gateway)
except Exception as e:
log.error(f"Failed to send to {phone}: {e}")
errors.append(str(e))
_status = {
"last_run": datetime.now().isoformat(),
"last_result": "error: " + "; ".join(errors) if errors else "ok",
"last_message": message,
}
# ── Scheduler ─────────────────────────────────────────────────────────────────
DAY_MAP_FNS = {
"mon": lambda: schedule.every().monday,
"tue": lambda: schedule.every().tuesday,
"wed": lambda: schedule.every().wednesday,
"thu": lambda: schedule.every().thursday,
"fri": lambda: schedule.every().friday,
"sat": lambda: schedule.every().saturday,
"sun": lambda: schedule.every().sunday,
}
def apply_schedule(settings: dict) -> None:
with _lock:
schedule.clear()
run_time = settings.get("run_time", "07:00")
for day in settings.get("run_days", []):
day = day.strip().lower()
if day in DAY_MAP_FNS:
DAY_MAP_FNS[day]().at(run_time).do(run_job)
log.info(f"Scheduled: {day} at {run_time}")
def _schedule_loop() -> None:
while True:
with _lock:
schedule.run_pending()
time.sleep(30)
# ── Flask API ─────────────────────────────────────────────────────────────────
MASCOT_URL = "https://resources.finalsite.net/images/f_png,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png"
@app.route("/")
def index():
return HTML, 200, {"Content-Type": "text/html"}
@app.route("/icon.png")
def icon():
import requests as req
r = req.get(MASCOT_URL, timeout=10)
return r.content, 200, {"Content-Type": "image/png", "Cache-Control": "public, max-age=86400"}
@app.route("/api/settings", methods=["GET"])
def api_get_settings():
return jsonify(load_settings())
@app.route("/api/settings", methods=["POST"])
def api_post_settings():
data = request.get_json(force=True)
s = load_settings()
for key in ("phone_numbers", "carrier_gateway", "run_time", "run_days"):
if key in data:
s[key] = data[key]
save_settings(s)
apply_schedule(s)
return jsonify({"ok": True})
@app.route("/api/vacation", methods=["POST"])
def api_add_vacation():
data = request.get_json(force=True)
s = load_settings()
s.setdefault("vacations", []).append({
"id": str(int(time.time() * 1000)),
"start": data["start"],
"end": data["end"],
"label": data.get("label", "Vacation"),
})
save_settings(s)
return jsonify({"ok": True})
@app.route("/api/vacation/<vid>", methods=["DELETE"])
def api_delete_vacation(vid: str):
s = load_settings()
s["vacations"] = [v for v in s.get("vacations", []) if str(v.get("id")) != vid]
save_settings(s)
return jsonify({"ok": True})
@app.route("/api/send", methods=["POST"])
def api_send():
threading.Thread(target=run_job, daemon=True).start()
return jsonify({"ok": True})
@app.route("/api/status")
def api_status():
s = load_settings()
with _lock:
next_runs = sorted([j.next_run for j in schedule.get_jobs() if j.next_run])
return jsonify({
**_status,
"next_run": next_runs[0].isoformat() if next_runs else None,
"on_vacation": is_on_vacation(s),
})
# ── Embedded UI ───────────────────────────────────────────────────────────────
HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🍱 Lunch Notifier</title>
<link rel="icon" href="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png">
<style>
:root {
--bg: #0d1117;
--card: #161b22;
--primary: #14b8a6;
--primary-hover: #0d9488;
--danger: #f87171;
--success: #4ade80;
--text: #e6edf3;
--muted: #8b949e;
--border: #30363d;
--radius: 12px;
--shadow: 0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; }
.container { max-width: 620px; margin: 0 auto; padding: 24px 16px 48px; }
header { margin-bottom: 28px; }
header h1 { font-size: 1.5rem; font-weight: 700; }
header p { color: var(--muted); font-size: .875rem; margin-top: 2px; }
.card { background: var(--card); border-radius: var(--radius); padding: 20px;
margin-bottom: 16px; box-shadow: var(--shadow); border: 1px solid var(--border); }
.card-label { font-size: .7rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
/* Status grid */
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.stat dt { font-size: .75rem; color: var(--muted); margin-bottom: 2px; }
.stat dd { font-size: .875rem; font-weight: 500; }
/* Badges */
.badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: .75rem; font-weight: 600; }
.badge-green { background: #14532d; color: #4ade80; }
.badge-yellow { background: #713f12; color: #fbbf24; }
.badge-red { background: #7f1d1d; color: #fca5a5; }
.badge-blue { background: #1e3a5f; color: #7dd3fc; }
/* Buttons */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 18px; border-radius: 8px; font-size: .875rem; font-weight: 600;
cursor: pointer; border: none; transition: background .15s, opacity .15s; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); }
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--bg); }
.btn-danger { background: var(--card); color: var(--danger); border: 1px solid #7f1d1d; }
.btn-danger:hover { background: #1a0f0f; }
.btn-send { width: 100%; padding: 14px; font-size: 1rem; border-radius: 10px; }
.btn-sm { padding: 6px 12px; font-size: .75rem; }
/* Form elements */
.field { margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text);
margin-bottom: 6px; }
input[type=text], input[type=time], input[type=date] {
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
font-size: .875rem; color: var(--text); background: var(--card);
}
input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: transparent; }
/* Days toggle */
.days-row { display: flex; gap: 6px; flex-wrap: wrap; }
.day-chip { padding: 6px 13px; border: 1.5px solid var(--border); border-radius: 99px;
font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--card);
transition: all .15s; user-select: none; }
.day-chip.on { background: var(--primary); color: #fff; border-color: var(--primary); }
/* Lists */
.item-list { list-style: none; }
.item-row { display: flex; align-items: center; gap: 10px; padding: 10px 0;
border-bottom: 1px solid var(--border); }
.item-row:last-child { border-bottom: none; }
.item-main { flex: 1; }
.item-title { font-size: .875rem; font-weight: 500; }
.item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; }
/* Add row */
.add-row { display: flex; gap: 8px; align-items: flex-end; margin-top: 14px; }
.add-row .field { flex: 1; margin-bottom: 0; }
/* Divider */
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
/* Toast */
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(8px);
background: #1f2937; color: #fff; padding: 10px 20px; border-radius: 99px;
font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s;
pointer-events: none; white-space: nowrap; z-index: 100; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.empty-state { color: var(--muted); font-size: .875rem; padding: 6px 0; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
</style>
</head>
<body>
<div class="container">
<header>
<div style="display:flex;align-items:center;gap:16px;">
<img src="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png" alt="Chip the Challenger" style="height:80px;width:auto;">
<div>
<h1>Lunch Notifier</h1>
<p>Kyrene de la Mariposa &middot; Automated lunch menu texts</p>
</div>
</div>
</header>
<!-- Status -->
<div class="card">
<div class="card-label">Status</div>
<dl class="stat-grid">
<div class="stat"><dt>Last Run</dt><dd id="s-last-run"></dd></div>
<div class="stat"><dt>Result</dt><dd id="s-result"></dd></div>
<div class="stat"><dt>Next Scheduled</dt><dd id="s-next"></dd></div>
<div class="stat"><dt>Mode</dt><dd id="s-mode"></dd></div>
</dl>
<div id="s-last-msg" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div>
</div>
<!-- Send Now -->
<div class="card">
<div class="card-label">Manual Send</div>
<button id="send-btn" class="btn btn-primary btn-send" onclick="sendNow()">
Send Lunch Menu Now
</button>
</div>
<!-- Schedule -->
<div class="card">
<div class="card-label">Schedule</div>
<div class="field">
<label class="field-label">Send Time</label>
<input type="time" id="f-time" style="max-width:160px">
</div>
<div class="field">
<label class="field-label">Days</label>
<div class="days-row" id="days-row"></div>
</div>
<hr class="divider">
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
</div>
<!-- Phone Numbers -->
<div class="card">
<div class="card-label">Phone Numbers</div>
<ul class="item-list" id="phone-list"></ul>
<div class="add-row">
<div class="field">
<label class="field-label">Add Number (10 digits)</label>
<input type="text" id="f-phone" placeholder="4805551234" maxlength="15"
onkeydown="if(event.key==='Enter') addPhone()">
</div>
<button class="btn btn-primary btn-sm" onclick="addPhone()">Add</button>
</div>
</div>
<!-- Vacation -->
<div class="card">
<div class="card-label">Vacation / No-Send Periods</div>
<ul class="item-list" id="vac-list"></ul>
<hr class="divider">
<div class="field">
<label class="field-label">Label</label>
<input type="text" id="f-vac-label" placeholder="Spring Break">
</div>
<div class="row-2">
<div class="field">
<label class="field-label">Start Date</label>
<input type="date" id="f-vac-start">
</div>
<div class="field">
<label class="field-label">End Date</label>
<input type="date" id="f-vac-end">
</div>
</div>
<button class="btn btn-primary" onclick="addVacation()">Add Period</button>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const DAYS = ['mon','tue','wed','thu','fri','sat','sun'];
const DAY_LABELS = {mon:'Mon',tue:'Tue',wed:'Wed',thu:'Thu',fri:'Fri',sat:'Sat',sun:'Sun'};
let cfg = {};
async function load() {
const [s, st] = await Promise.all([
fetch('/api/settings').then(r=>r.json()),
fetch('/api/status').then(r=>r.json()),
]);
cfg = s;
applySettings(s);
applyStatus(st);
}
function fmt(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString([], {month:'short',day:'numeric'}) + ' ' +
d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});
}
function applySettings(s) {
document.getElementById('f-time').value = s.run_time || '07:00';
// Days
const row = document.getElementById('days-row');
row.innerHTML = DAYS.map(d =>
`<span class="day-chip ${(s.run_days||[]).includes(d)?'on':''}" data-day="${d}"
onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>`
).join('');
// Phones
const pl = document.getElementById('phone-list');
if (!(s.phone_numbers||[]).length) {
pl.innerHTML = '<li class="empty-state">No numbers added yet.</li>';
} else {
pl.innerHTML = (s.phone_numbers||[]).map((p,i) => `
<li class="item-row">
<div class="item-main"><div class="item-title">${p}</div></div>
<button class="btn btn-danger btn-sm" onclick="removePhone(${i})">Remove</button>
</li>`).join('');
}
// Vacations
const vl = document.getElementById('vac-list');
if (!(s.vacations||[]).length) {
vl.innerHTML = '<li class="empty-state">No vacation periods set.</li>';
} else {
vl.innerHTML = (s.vacations||[]).map(v => `
<li class="item-row">
<div class="item-main">
<div class="item-title">${v.label}</div>
<div class="item-sub">${v.start} &rarr; ${v.end}</div>
</div>
<button class="btn btn-danger btn-sm" onclick="removeVacation('${v.id}')">Remove</button>
</li>`).join('');
}
}
function applyStatus(st) {
document.getElementById('s-last-run').textContent = fmt(st.last_run);
const res = st.last_result;
let cls = res==='ok' ? 'badge-green' : res?.startsWith('skipped') ? 'badge-yellow' : res ? 'badge-red' : '';
document.getElementById('s-result').innerHTML = res ? `<span class="badge ${cls}">${res}</span>` : '';
document.getElementById('s-next').textContent = fmt(st.next_run);
const vac = st.on_vacation;
document.getElementById('s-mode').innerHTML = vac
? `<span class="badge badge-yellow">Vacation: ${vac}</span>`
: '<span class="badge badge-green">Active</span>';
const msgEl = document.getElementById('s-last-msg');
if (st.last_message) {
msgEl.textContent = st.last_message;
msgEl.style.display = '';
} else {
msgEl.style.display = 'none';
}
}
async function saveSchedule() {
const run_time = document.getElementById('f-time').value;
const run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
await post('/api/settings', {run_time, run_days});
toast('Schedule saved');
load();
}
async function addPhone() {
const input = document.getElementById('f-phone');
const phone = input.value.trim().replace(/\D/g,'');
if (!phone) { toast('Enter a 10-digit number'); return; }
const phone_numbers = [...(cfg.phone_numbers||[]), phone];
await post('/api/settings', {phone_numbers});
input.value = '';
toast('Number added');
load();
}
async function removePhone(idx) {
const phone_numbers = (cfg.phone_numbers||[]).filter((_,i)=>i!==idx);
await post('/api/settings', {phone_numbers});
toast('Number removed');
load();
}
async function addVacation() {
const label = document.getElementById('f-vac-label').value.trim() || 'Vacation';
const start = document.getElementById('f-vac-start').value;
const end = document.getElementById('f-vac-end').value;
if (!start || !end) { toast('Set both start and end dates'); return; }
if (end < start) { toast('End must be after start'); return; }
await post('/api/vacation', {label, start, end});
document.getElementById('f-vac-label').value = '';
document.getElementById('f-vac-start').value = '';
document.getElementById('f-vac-end').value = '';
toast('Vacation period added');
load();
}
async function removeVacation(id) {
await fetch(`/api/vacation/${id}`, {method:'DELETE'});
toast('Removed');
load();
}
async function sendNow() {
const btn = document.getElementById('send-btn');
btn.disabled = true;
btn.textContent = 'Sending…';
await post('/api/send', {});
toast('Triggered! Check status in a moment.');
setTimeout(() => { btn.disabled=false; btn.textContent='Send Lunch Menu Now'; load(); }, 6000);
}
async function post(url, body) {
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
}
let toastTimer;
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 3000);
}
load();
setInterval(load, 30000);
</script>
</body>
</html>"""
# ── Entrypoint ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
if not SETTINGS_FILE.exists():
log.info(f"Creating default settings at {SETTINGS_FILE}")
save_settings(_DEFAULTS)
apply_schedule(load_settings())
threading.Thread(target=_schedule_loop, daemon=True).start()
log.info(f"Web UI → http://localhost:{PORT}")
app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False)