import hmac import os from datetime import datetime, timedelta, timezone from functools import wraps from flask import Flask, jsonify, redirect, render_template, request, session, url_for import automation import scheduler import storage APP_VERSION = "0.8.0" APP_VERSION_DATE = "2026-09-01" app = Flask(__name__) app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-change-me") LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "changeme") DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] DAY_LABELS = {"mon": "Mon", "tue": "Tue", "wed": "Wed", "thu": "Thu", "fri": "Fri", "sat": "Sat", "sun": "Sun"} def login_required(view): @wraps(view) def wrapped(*args, **kwargs): if not session.get("authenticated"): return redirect(url_for("login", next=request.path)) return view(*args, **kwargs) return wrapped @app.context_processor def inject_version(): return {"app_version": APP_VERSION, "app_version_date": APP_VERSION_DATE} @app.route("/login", methods=["GET", "POST"]) def login(): error = None if request.method == "POST": password = request.form.get("password", "") if hmac.compare_digest(password, LOGIN_PASSWORD): session["authenticated"] = True return redirect(request.args.get("next") or url_for("dashboard")) error = "Incorrect password" return render_template("login.html", error=error) @app.route("/logout") def logout(): session.clear() return redirect(url_for("login")) def _merged_rules(): """Combine the cached rule list with saved schedules and last-run status.""" cache = storage.load("rules_cache.json", {"updated_at": None, "rules": []}) schedules = storage.load("schedules.json", {}) history = storage.load("run_history.json", []) last_run_by_rule = {} for entry in history: rid = entry.get("rule_id") if rid and rid not in last_run_by_rule: last_run_by_rule[rid] = entry rules = [] for r in cache.get("rules", []): rid = r["id"] sched = schedules.get(rid, {}) rules.append( { "id": rid, "name": r["name"], "enabled": sched.get("enabled", True), "schedules": sched.get("schedules", []), "last_run": last_run_by_rule.get(rid), } ) return rules, cache.get("updated_at") def _session_warning(): """True if the most recent run/refresh failed specifically because the saved MassPrice session is missing/expired — surfaced as a dashboard banner so a dead session doesn't just look like "no rules".""" history = storage.load("run_history.json", []) if not history: return False latest = history[0] return latest.get("status") == "failure" and "session expired or missing" in ( latest.get("message") or "" ) @app.route("/") @login_required def dashboard(): rules, rules_updated_at = _merged_rules() return render_template( "dashboard.html", rules=rules, rules_updated_at=rules_updated_at, days=DAYS, day_labels=DAY_LABELS, session_warning=_session_warning(), ) @app.route("/session") @login_required def session_page(): return render_template("session.html", status=automation.session_status()) @app.route("/api/session/import", methods=["POST"]) @login_required def api_session_import(): body = request.get_json(force=True, silent=True) or {} try: automation.import_cookies(body.get("cookies", "")) except automation.AutomationError as exc: return jsonify({"error": str(exc)}), 409 except Exception as exc: return jsonify({"error": str(exc)}), 502 return jsonify({"ok": True}) def _parse_ts(value): try: return datetime.fromisoformat(value) except (TypeError, ValueError): return None @app.route("/activity") @login_required def activity(): history = storage.load("run_history.json", []) cutoff = datetime.now(timezone.utc) - timedelta(hours=24) recent = [e for e in history if (_parse_ts(e.get("timestamp")) or cutoff) >= cutoff] return render_template("activity.html", history=recent) @app.route("/api/rules/refresh", methods=["POST"]) @login_required def api_refresh_rules(): try: rules = automation.list_rules() except Exception as exc: return jsonify({"error": str(exc)}), 502 return jsonify({"rules": rules}) @app.route("/api/rules//schedule", methods=["POST"]) @login_required def api_save_schedule(rule_id): body = request.get_json(force=True, silent=True) or {} schedules = storage.load("schedules.json", {}) cache = storage.load("rules_cache.json", {"rules": []}) name = next((r["name"] for r in cache.get("rules", []) if r["id"] == rule_id), None) schedules[rule_id] = { "name": name, "enabled": bool(body.get("enabled", True)), "schedules": body.get("schedules", []), } storage.save("schedules.json", schedules) scheduler.rebuild_jobs() return jsonify({"ok": True}) @app.route("/api/rules//run", methods=["POST"]) @login_required def api_run_rule(rule_id): cache = storage.load("rules_cache.json", {"rules": []}) name = next((r["name"] for r in cache.get("rules", []) if r["id"] == rule_id), None) scheduler.enqueue_run(rule_id, name) return jsonify({"ok": True}) def _bootstrap(): scheduler.start(tz=os.environ.get("TZ")) # Best-effort initial rule sync so the dashboard isn't blank before # the user ever clicks "Refresh Rules". Silently skipped if # Playwright/credentials aren't available yet (e.g. local dev). if storage.load("rules_cache.json", None) is None: try: automation.list_rules() except Exception: pass _bootstrap() if __name__ == "__main__": app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=False)