Coverage for app/demo/routes.py: 100%
74 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
1import logging
2import os
3from datetime import datetime, timedelta, timezone
5from flask import Blueprint, redirect, render_template, request, session, url_for
7from extensions import _rate_limiting_disabled, limiter as _limiter # pyright: ignore[reportMissingImports]
8from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports]
9from flask_babel import get_locale as _get_locale # pyright: ignore[reportMissingImports]
11from models import DemoSlot, User, db
13log = logging.getLogger(__name__)
15demo_bp = Blueprint("demo", __name__)
17_DEFAULT_BUSY_WINDOW = 30
20def _busy_window_minutes() -> int:
21 try:
22 return int(
23 os.environ.get("OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES", _DEFAULT_BUSY_WINDOW)
24 )
25 except ValueError:
26 return _DEFAULT_BUSY_WINDOW
29@demo_bp.before_app_request
30def _fix_stale_demo_session() -> None:
31 """After a demo wipe, user_id in session may point to a deleted user.
33 The seed() function deletes old users and creates new ones (sequences are
34 not reset), so stale user_ids from before the wipe no longer exist. Clear
35 the stale user_id so the next page load shows the landing page with a
36 fresh-entry prompt. The demo_slot_id is preserved so the visitor can
37 seamlessly re-enter the same sandbox number.
38 """
39 user_id = session.get("user_id")
40 if not user_id or not session.get("demo_slot_id"):
41 return
42 if db.session.get(User, user_id) is None:
43 session.pop("user_id", None)
46@demo_bp.route("/demo/enter", methods=["POST"])
47@_limiter.limit("3 per minute", exempt_when=_rate_limiting_disabled)
48def enter() -> ResponseReturnValue:
49 role = request.form.get("role", "owner") # "owner" or "renter"
51 # Restore existing slot if still valid
52 existing_slot_id = session.get("demo_slot_id")
53 if existing_slot_id:
54 slot = db.session.get(DemoSlot, existing_slot_id)
55 if slot:
56 session["user_id"] = _slot_user_id(slot, role)
57 session["demo_role"] = role
58 session.permanent = True
59 _touch_slot(slot)
60 return redirect(url_for("index"))
62 # Capture visitor locale (Accept-Language or manual switch) before session wipe
63 visitor_lang = str(_get_locale())
65 # Assign the least-recently-used slot
66 slot = DemoSlot.query.order_by(DemoSlot.last_activity_at.asc().nullsfirst()).first()
67 if slot is None:
68 return redirect(url_for("index"))
70 # If even the LRU slot is still warm, all slots are actively in use
71 window = _busy_window_minutes()
72 cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(minutes=window)
73 if slot.last_activity_at and slot.last_activity_at >= cutoff:
74 return render_template("demo_full.html"), 503
76 session.clear()
77 session["demo_slot_id"] = slot.id
78 session["user_id"] = _slot_user_id(slot, role)
79 session["demo_role"] = role
80 session["language"] = visitor_lang
81 session.permanent = True
82 _touch_slot(slot)
83 return redirect(url_for("index"))
86def _slot_user_id(slot: DemoSlot, role: str) -> int:
87 """Return the correct user_id for the requested role in this slot."""
88 if role in ("renter", "pilot") and slot.renter_user_id:
89 return int(slot.renter_user_id)
90 if role == "maintenance" and slot.maintenance_user_id:
91 return int(slot.maintenance_user_id)
92 if role == "viewer" and slot.viewer_user_id:
93 return int(slot.viewer_user_id)
94 if role == "sole_pilot" and slot.sole_pilot_user_id:
95 return int(slot.sole_pilot_user_id)
96 if role == "sole_operator" and slot.sole_operator_user_id:
97 return int(slot.sole_operator_user_id)
98 return int(slot.user_id)
101def _touch_slot(slot: DemoSlot) -> None:
102 slot.last_activity_at = datetime.now(timezone.utc)
103 db.session.commit()
106@demo_bp.route("/demo/next-wipe")
107def next_wipe() -> ResponseReturnValue:
108 """Return the scheduled next wipe time for the browser reload-detection logic."""
109 from flask import jsonify # noqa: PLC0415
111 return jsonify({"next_wipe": os.environ.get("OPENHANGAR_DEMO_NEXT_WIPE_UTC")}), 200
114def demo_has_recent_activity(window_minutes: int = 20) -> bool:
115 """Return True if any slot had activity within *window_minutes*."""
116 cutoff = datetime.now(timezone.utc) - timedelta(minutes=window_minutes)
117 return int(DemoSlot.query.filter(DemoSlot.last_activity_at >= cutoff).count()) > 0