diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..3ac7ce4c45f5eca354bf95faf027559c78319c17 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Flask +FLASK_SECRET_KEY=your-secret-key-here +FLASK_ENV=development +FLASK_DEBUG=1 + +# Firebase +FIREBASE_CREDENTIALS_PATH=path/to/serviceAccountKey.json +FIREBASE_API_KEY=your-firebase-api-key +FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +FIREBASE_PROJECT_ID=your-project-id +FIREBASE_STORAGE_BUCKET=your-project.appspot.com +FIREBASE_MESSAGING_SENDER_ID=your-sender-id +FIREBASE_APP_ID=your-app-id + +# Google Gemini +GEMINI_API_KEY=your-gemini-api-key + +# Google Maps +GOOGLE_MAPS_API_KEY=your-google-maps-api-key + +# Google Cloud Translation +GOOGLE_TRANSLATE_API_KEY=your-translate-api-key + +# App Config +USE_MOCK_SERVICES=true +VENUE_CAPACITY=50000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..7d28a31e5d20d89f89841c671b89d8b866de545f --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Environment +.env +*.env +serviceAccountKey.json + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +env/ +*.egg-info/ +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +htmlcov/ +.coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..2cd5114189c4cffb6b8ff2d7275c304d4885c8e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.9-slim + +WORKDIR /app + +# Create a user to run the app (Hugging Face Spaces requirement) +RUN useradd -m -u 1000 user + +# Install required packages +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir gunicorn + +# Copy the rest of the application +COPY . /app/ + +# Set ownership to the non-root user so the app can create and write to game.db +RUN chown -R user:user /app + +# Switch to the non-root user +USER user + +# Expose port 7860 which is the default for Hugging Face Spaces +EXPOSE 7860 + +# Initialize the database and then start gunicorn +CMD ["sh", "-c", "python -c 'from app import init_db; init_db()' && gunicorn -b 0.0.0.0:7860 app:app"] \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..1ecc458e8e2fed6cc0ba26f8bab712de0e384b1f --- /dev/null +++ b/app.py @@ -0,0 +1,131 @@ +"""VenueFlow — Flask application factory.""" + +import logging +from flask import Flask, redirect, url_for, render_template +from config import get_config + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +def create_app(config_class=None): + """Application factory.""" + app = Flask(__name__) + + # Load configuration + if config_class is None: + config_class = get_config() + app.config.from_object(config_class) + + # ─── Security Headers ──────────────────────────────────────────────── + @app.after_request + def set_security_headers(response): + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + if not app.debug: + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) + return response + + # ─── Initialize Services ───────────────────────────────────────────── + from services.firebase_service import init_firebase + from services.gemini_service import init_gemini + from services.translation_service import init_translation + + init_firebase(app) + init_gemini(app) + init_translation(app) + + # ─── Initialize Demo Data ──────────────────────────────────────────── + from services.simulator import ( + create_demo_venue, + create_demo_queue_stations, + create_demo_event, + Simulator, + ) + from services.crowd_service import CrowdService + from services.queue_service import QueueService + from services.maps_service import MapsService + from services.notification_service import NotificationService + + venue = create_demo_venue() + event = create_demo_event() + crowd_service = CrowdService() + queue_service = QueueService() + maps_service = MapsService(venue) + notification_service = NotificationService() + + # Register queue stations + queue_stations = create_demo_queue_stations() + queue_service.register_stations(queue_stations) + + # Create simulator + simulator = Simulator(venue, queue_service, crowd_service, notification_service, event) + + # Wire SSE broadcasting + from blueprints.sse import broadcast_update + + def on_sim_update(): + broadcast_update({ + "venue": venue.to_dict(), + "event": event.to_dict(), + "sim_status": simulator.get_status(), + }) + + simulator.set_update_callback(on_sim_update) + + # Store services in app config for blueprint access + app.config["VENUE_OBJ"] = venue + app.config["EVENT_OBJ"] = event + app.config["CROWD_SERVICE"] = crowd_service + app.config["QUEUE_SERVICE"] = queue_service + app.config["MAPS_SERVICE"] = maps_service + app.config["NOTIFICATION_SERVICE"] = notification_service + app.config["SIMULATOR_OBJ"] = simulator + + # Run initial tick to populate data + simulator.tick() + + # ─── Register Blueprints ───────────────────────────────────────────── + from blueprints.auth import auth_bp + from blueprints.attendee import attendee_bp + from blueprints.operator import operator_bp + from blueprints.api import api_bp + from blueprints.sse import sse_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(attendee_bp) + app.register_blueprint(operator_bp) + app.register_blueprint(api_bp) + app.register_blueprint(sse_bp) + + # ─── Root Route ────────────────────────────────────────────────────── + @app.route("/") + def index(): + return redirect(url_for("auth.login")) + + # ─── Error Handlers ────────────────────────────────────────────────── + @app.errorhandler(404) + def not_found(e): + return render_template("errors/404.html"), 404 + + @app.errorhandler(500) + def server_error(e): + return render_template("errors/500.html"), 500 + + logger.info("🏟️ VenueFlow app created successfully") + return app + + +# ─── Entry Point ───────────────────────────────────────────────────────────── + +if __name__ == "__main__": + app = create_app() + app.run(host="0.0.0.0", port=5000, debug=True, threaded=True) diff --git a/blueprints/__init__.py b/blueprints/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d6dec51face04fd7537b7edb9071e169e6ab2d45 --- /dev/null +++ b/blueprints/__init__.py @@ -0,0 +1 @@ +"""Blueprints package.""" diff --git a/blueprints/api.py b/blueprints/api.py new file mode 100644 index 0000000000000000000000000000000000000000..855012a01143137de6264ced3e6bd74b5677aca4 --- /dev/null +++ b/blueprints/api.py @@ -0,0 +1,359 @@ +"""REST API endpoints for real-time data access.""" + +from flask import Blueprint, jsonify, request, session, current_app +import bleach + +api_bp = Blueprint("api", __name__, url_prefix="/api") + + +def _get_services(): + return { + "venue": current_app.config.get("VENUE_OBJ"), + "event": current_app.config.get("EVENT_OBJ"), + "crowd": current_app.config.get("CROWD_SERVICE"), + "queue": current_app.config.get("QUEUE_SERVICE"), + "maps": current_app.config.get("MAPS_SERVICE"), + "notifications": current_app.config.get("NOTIFICATION_SERVICE"), + "simulator": current_app.config.get("SIMULATOR_OBJ"), + } + + +# ─── Crowd Data ────────────────────────────────────────────────────────────── + + +@api_bp.route("/crowd/summary") +def crowd_summary(): + """Get venue crowd summary.""" + svc = _get_services() + if not svc["crowd"] or not svc["venue"]: + return jsonify({"error": "Service unavailable"}), 503 + return jsonify(svc["crowd"].get_venue_summary(svc["venue"])) + + +@api_bp.route("/crowd/heatmap") +def crowd_heatmap(): + """Get heatmap data for visualization.""" + svc = _get_services() + if not svc["crowd"] or not svc["venue"]: + return jsonify({"error": "Service unavailable"}), 503 + return jsonify(svc["crowd"].get_crowd_flow_data(svc["venue"])) + + +@api_bp.route("/crowd/zone/") +def crowd_zone(zone_id): + """Get specific zone data.""" + svc = _get_services() + venue = svc["venue"] + if not venue: + return jsonify({"error": "Service unavailable"}), 503 + zone = venue.get_zone(zone_id) + if not zone: + return jsonify({"error": "Zone not found"}), 404 + trend = svc["crowd"].get_zone_trend(zone_id) if svc["crowd"] else [] + return jsonify({"zone": zone.to_dict(), "trend": trend}) + + +@api_bp.route("/crowd/recommend") +def crowd_recommend(): + """Get recommended least crowded zones.""" + svc = _get_services() + zone_type = request.args.get("type") + return jsonify(svc["crowd"].get_recommended_zones(svc["venue"], zone_type)) + + +# ─── Queue Data ────────────────────────────────────────────────────────────── + + +@api_bp.route("/queue/summary") +def queue_summary(): + """Get queue overview.""" + svc = _get_services() + if not svc["queue"]: + return jsonify({"error": "Service unavailable"}), 503 + return jsonify(svc["queue"].get_queue_summary()) + + +@api_bp.route("/queue/stations") +def queue_stations(): + """Get all queue stations.""" + svc = _get_services() + category = request.args.get("category") + if category: + return jsonify(svc["queue"].get_stations_by_category(category)) + return jsonify(svc["queue"].get_all_stations()) + + +@api_bp.route("/queue/shortest") +def queue_shortest(): + """Find shortest queue.""" + svc = _get_services() + category = request.args.get("category") + result = svc["queue"].get_shortest_queue(category) + if result: + return jsonify(result) + return jsonify({"error": "No open stations found"}), 404 + + +@api_bp.route("/queue/join", methods=["POST"]) +def queue_join(): + """Join a virtual queue.""" + svc = _get_services() + user_id = session.get("user_id") + if not user_id: + return jsonify({"error": "Not authenticated"}), 401 + + data = request.get_json() + station_id = data.get("station_id", "") if data else "" + if not station_id: + return jsonify({"error": "Station ID required"}), 400 + + ticket = svc["queue"].join_virtual_queue(user_id, station_id) + if ticket: + return jsonify(ticket) + return jsonify({"error": "Could not join queue"}), 400 + + +@api_bp.route("/queue/tickets") +def queue_tickets(): + """Get user's virtual queue tickets.""" + svc = _get_services() + user_id = session.get("user_id") + if not user_id: + return jsonify({"error": "Not authenticated"}), 401 + return jsonify(svc["queue"].get_user_tickets(user_id)) + + +@api_bp.route("/queue/cancel", methods=["POST"]) +def queue_cancel(): + """Cancel a virtual queue ticket.""" + svc = _get_services() + user_id = session.get("user_id") + if not user_id: + return jsonify({"error": "Not authenticated"}), 401 + + data = request.get_json() + ticket_id = data.get("ticket_id", "") if data else "" + if svc["queue"].cancel_ticket(ticket_id, user_id): + return jsonify({"success": True}) + return jsonify({"error": "Could not cancel ticket"}), 400 + + +# ─── Navigation ────────────────────────────────────────────────────────────── + + +@api_bp.route("/navigate/route") +def navigate_route(): + """Get route between two zones.""" + svc = _get_services() + from_zone = request.args.get("from") + to_zone = request.args.get("to") + accessible = request.args.get("accessible", "false").lower() == "true" + + if not from_zone or not to_zone: + return jsonify({"error": "from and to zone IDs required"}), 400 + + route = svc["maps"].find_route(from_zone, to_zone, accessible) + if route: + return jsonify(route) + return jsonify({"error": "Route not found"}), 404 + + +@api_bp.route("/navigate/nearest") +def navigate_nearest(): + """Find nearest zone of a type.""" + svc = _get_services() + from_zone = request.args.get("from") + target_type = request.args.get("type") + accessible = request.args.get("accessible", "false").lower() == "true" + + if not from_zone or not target_type: + return jsonify({"error": "from zone and type required"}), 400 + + result = svc["maps"].find_nearest(from_zone, target_type, accessible) + if result: + return jsonify(result) + return jsonify({"error": "No matching zone found"}), 404 + + +@api_bp.route("/navigate/pois") +def navigate_pois(): + """Get all points of interest.""" + svc = _get_services() + return jsonify(svc["maps"].get_points_of_interest()) + + +# ─── Chatbot ───────────────────────────────────────────────────────────────── + + +@api_bp.route("/chat", methods=["POST"]) +def chat(): + """Send a message to the AI chatbot.""" + from services import gemini_service + + user_id = session.get("user_id") + if not user_id: + return jsonify({"error": "Not authenticated"}), 401 + + data = request.get_json() + message = bleach.clean(data.get("message", "").strip()) if data else "" + if not message: + return jsonify({"error": "Message required"}), 400 + if len(message) > 500: + return jsonify({"error": "Message too long (max 500 chars)"}), 400 + + # Build venue context for the AI + svc = _get_services() + venue_context = None + if svc["crowd"] and svc["venue"]: + summary = svc["crowd"].get_venue_summary(svc["venue"]) + queue_summary = svc["queue"].get_queue_summary() if svc["queue"] else {} + venue_context = { + "overall_occupancy": summary.get("overall_occupancy"), + "hotspots": [h["name"] for h in summary.get("hotspots", [])[:3]], + "queue_categories": queue_summary.get("categories", {}), + } + + history = data.get("history", []) + response = gemini_service.get_chat_response(message, venue_context, history) + + return jsonify({"response": response}) + + +@api_bp.route("/chat/suggestions") +def chat_suggestions(): + """Get chatbot quick-reply suggestions.""" + from services import gemini_service + return jsonify(gemini_service.get_quick_suggestions()) + + +# ─── Notifications ─────────────────────────────────────────────────────────── + + +@api_bp.route("/notifications") +def notifications(): + """Get notifications for the current user.""" + svc = _get_services() + role = session.get("user_role", "attendee") + limit = request.args.get("limit", 20, type=int) + return jsonify(svc["notifications"].get_alerts(role=role, limit=limit)) + + +@api_bp.route("/notifications/unread") +def notifications_unread(): + """Get unread notification count.""" + svc = _get_services() + role = session.get("user_role", "attendee") + return jsonify({"count": svc["notifications"].get_unread_count(role)}) + + +@api_bp.route("/notifications/read", methods=["POST"]) +def notifications_read(): + """Mark a notification as read.""" + svc = _get_services() + data = request.get_json() + alert_id = data.get("id", "") if data else "" + if svc["notifications"].mark_read(alert_id): + return jsonify({"success": True}) + return jsonify({"error": "Alert not found"}), 404 + + +# ─── Simulator Control ─────────────────────────────────────────────────────── + + +@api_bp.route("/simulator/status") +def simulator_status(): + """Get simulator status.""" + svc = _get_services() + simulator = svc["simulator"] + if not simulator: + return jsonify({"error": "Simulator unavailable"}), 503 + return jsonify(simulator.get_status()) + + +@api_bp.route("/simulator/start", methods=["POST"]) +def simulator_start(): + """Start the simulation.""" + if session.get("user_role") not in ("operator", "admin"): + return jsonify({"error": "Operator access required"}), 403 + svc = _get_services() + svc["simulator"].start() + return jsonify({"success": True, "status": svc["simulator"].get_status()}) + + +@api_bp.route("/simulator/stop", methods=["POST"]) +def simulator_stop(): + """Stop the simulation.""" + if session.get("user_role") not in ("operator", "admin"): + return jsonify({"error": "Operator access required"}), 403 + svc = _get_services() + svc["simulator"].stop() + return jsonify({"success": True, "status": svc["simulator"].get_status()}) + + +@api_bp.route("/simulator/phase", methods=["POST"]) +def simulator_phase(): + """Change simulation phase.""" + if session.get("user_role") not in ("operator", "admin"): + return jsonify({"error": "Operator access required"}), 403 + svc = _get_services() + data = request.get_json() + phase = data.get("phase", "") if data else "" + svc["simulator"].set_phase(phase) + return jsonify({"success": True, "status": svc["simulator"].get_status()}) + + +@api_bp.route("/simulator/speed", methods=["POST"]) +def simulator_speed(): + """Change simulation speed.""" + if session.get("user_role") not in ("operator", "admin"): + return jsonify({"error": "Operator access required"}), 403 + svc = _get_services() + data = request.get_json() + speed = float(data.get("speed", 1.0)) if data else 1.0 + svc["simulator"].set_speed(speed) + return jsonify({"success": True, "status": svc["simulator"].get_status()}) + + +# ─── Translation ───────────────────────────────────────────────────────────── + + +@api_bp.route("/translate", methods=["POST"]) +def translate(): + """Translate text.""" + from services import translation_service + + data = request.get_json() + text = data.get("text", "") if data else "" + target = data.get("target_lang", "en") if data else "en" + + if not text: + return jsonify({"error": "Text required"}), 400 + + translated = translation_service.translate_text(text, target) + return jsonify({"translated": translated, "target_lang": target}) + + +@api_bp.route("/languages") +def languages(): + """Get supported languages.""" + from services import translation_service + return jsonify(translation_service.get_supported_languages()) + + +# ─── Event Info ────────────────────────────────────────────────────────────── + + +@api_bp.route("/event") +def event_info(): + """Get current event information.""" + svc = _get_services() + event = svc["event"] + return jsonify(event.to_dict() if event else {}) + + +@api_bp.route("/venue") +def venue_info(): + """Get venue information.""" + svc = _get_services() + venue = svc["venue"] + return jsonify(venue.to_dict() if venue else {}) diff --git a/blueprints/attendee.py b/blueprints/attendee.py new file mode 100644 index 0000000000000000000000000000000000000000..4cacda966611222ca1b756ae71cc368cf712403f --- /dev/null +++ b/blueprints/attendee.py @@ -0,0 +1,113 @@ +"""Attendee-facing routes.""" + +from flask import Blueprint, render_template, session, current_app + +attendee_bp = Blueprint("attendee", __name__, url_prefix="/app") + + +def _get_services(): + """Get shared services from app context.""" + return { + "venue": current_app.config.get("VENUE_OBJ"), + "event": current_app.config.get("EVENT_OBJ"), + "crowd": current_app.config.get("CROWD_SERVICE"), + "queue": current_app.config.get("QUEUE_SERVICE"), + "maps": current_app.config.get("MAPS_SERVICE"), + "notifications": current_app.config.get("NOTIFICATION_SERVICE"), + } + + +@attendee_bp.before_request +def require_login(): + """Require login for all attendee pages.""" + from blueprints.auth import login_required + # Check inline to avoid decorator issues with blueprints + if "user_id" not in session: + from flask import redirect, url_for, flash + flash("Please log in to continue.", "warning") + return redirect(url_for("auth.login")) + + +@attendee_bp.route("/") +def home(): + """Attendee home page with venue overview.""" + svc = _get_services() + venue = svc["venue"] + event = svc["event"] + crowd = svc["crowd"] + queue = svc["queue"] + + venue_summary = crowd.get_venue_summary(venue) if crowd else {} + queue_summary = queue.get_queue_summary() if queue else {} + + return render_template( + "attendee/home.html", + venue=venue.to_dict() if venue else {}, + event=event.to_dict() if event else {}, + venue_summary=venue_summary, + queue_summary=queue_summary, + user_name=session.get("user_name", "Guest"), + ) + + +@attendee_bp.route("/heatmap") +def heatmap(): + """Live crowd heatmap page.""" + svc = _get_services() + venue = svc["venue"] + crowd = svc["crowd"] + + heatmap_data = crowd.get_crowd_flow_data(venue) if crowd else {} + + return render_template( + "attendee/heatmap.html", + venue=venue.to_dict() if venue else {}, + heatmap_data=heatmap_data, + maps_api_key=current_app.config.get("GOOGLE_MAPS_API_KEY", ""), + ) + + +@attendee_bp.route("/queues") +def queues(): + """Queue status and virtual queue page.""" + svc = _get_services() + queue = svc["queue"] + + queue_summary = queue.get_queue_summary() if queue else {} + user_tickets = queue.get_user_tickets(session.get("user_id", "")) if queue else [] + + return render_template( + "attendee/queues.html", + queue_summary=queue_summary, + user_tickets=user_tickets, + ) + + +@attendee_bp.route("/navigate") +def navigate(): + """Wayfinding and navigation page.""" + svc = _get_services() + venue = svc["venue"] + maps_svc = svc["maps"] + + pois = maps_svc.get_points_of_interest() if maps_svc else [] + + return render_template( + "attendee/navigate.html", + venue=venue.to_dict() if venue else {}, + pois=pois, + maps_api_key=current_app.config.get("GOOGLE_MAPS_API_KEY", ""), + ) + + +@attendee_bp.route("/profile") +def profile(): + """User profile and accessibility settings.""" + from services.translation_service import get_supported_languages + + return render_template( + "attendee/profile.html", + user_name=session.get("user_name", "Guest"), + user_email=session.get("user_email", ""), + languages=get_supported_languages(), + ) diff --git a/blueprints/auth.py b/blueprints/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..b7c7065fd95b692e7bcbf5f9ea232ab30e9b120d --- /dev/null +++ b/blueprints/auth.py @@ -0,0 +1,139 @@ +"""Authentication blueprint with Firebase Auth (mock fallback).""" + +from flask import Blueprint, render_template, request, redirect, url_for, session, flash, current_app +from services import firebase_service +import bleach +import re + +auth_bp = Blueprint("auth", __name__, url_prefix="/auth") + + +def _sanitize(text: str) -> str: + """Sanitize user input.""" + return bleach.clean(text.strip()) + + +def _validate_email(email: str) -> bool: + """Basic email validation.""" + pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + return bool(re.match(pattern, email)) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + """Login page and handler.""" + if request.method == "POST": + email = _sanitize(request.form.get("email", "")) + password = request.form.get("password", "") + + if not email or not password: + flash("Please fill in all fields.", "error") + return render_template("auth/login.html") + + if not _validate_email(email): + flash("Please enter a valid email address.", "error") + return render_template("auth/login.html") + + # Authenticate + if current_app.config.get("USE_MOCK_SERVICES"): + user = firebase_service.verify_mock_login(email, password) + else: + # TODO: Implement Firebase Auth token verification + user = firebase_service.verify_mock_login(email, password) + + if user: + session["user_id"] = user["uid"] + session["user_email"] = user["email"] + session["user_name"] = user["display_name"] + session["user_role"] = user["role"] + + flash(f"Welcome back, {user['display_name']}!", "success") + + if user["role"] in ("operator", "admin"): + return redirect(url_for("operator.dashboard")) + return redirect(url_for("attendee.home")) + else: + flash("Invalid email or password.", "error") + + return render_template("auth/login.html") + + +@auth_bp.route("/register", methods=["GET", "POST"]) +def register(): + """Registration page and handler.""" + if request.method == "POST": + email = _sanitize(request.form.get("email", "")) + password = request.form.get("password", "") + confirm_password = request.form.get("confirm_password", "") + display_name = _sanitize(request.form.get("display_name", "")) + + errors = [] + if not email or not password or not display_name: + errors.append("Please fill in all fields.") + if not _validate_email(email): + errors.append("Please enter a valid email address.") + if len(password) < 6: + errors.append("Password must be at least 6 characters.") + if password != confirm_password: + errors.append("Passwords do not match.") + + if errors: + for err in errors: + flash(err, "error") + return render_template("auth/register.html") + + # Create user + user = firebase_service.create_mock_user(email, password, display_name) + if user: + session["user_id"] = user["uid"] + session["user_email"] = user["email"] + session["user_name"] = user["display_name"] + session["user_role"] = user["role"] + flash("Account created successfully! Welcome to VenueFlow.", "success") + return redirect(url_for("attendee.home")) + else: + flash("Could not create account. Please try again.", "error") + + return render_template("auth/register.html") + + +@auth_bp.route("/logout") +def logout(): + """Clear session and redirect to login.""" + session.clear() + flash("You have been logged out.", "info") + return redirect(url_for("auth.login")) + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + + +def login_required(f): + """Decorator to require authentication.""" + from functools import wraps + + @wraps(f) + def decorated(*args, **kwargs): + if "user_id" not in session: + flash("Please log in to continue.", "warning") + return redirect(url_for("auth.login")) + return f(*args, **kwargs) + + return decorated + + +def operator_required(f): + """Decorator to require operator role.""" + from functools import wraps + + @wraps(f) + def decorated(*args, **kwargs): + if "user_id" not in session: + flash("Please log in to continue.", "warning") + return redirect(url_for("auth.login")) + if session.get("user_role") not in ("operator", "admin"): + flash("Access denied. Operator privileges required.", "error") + return redirect(url_for("attendee.home")) + return f(*args, **kwargs) + + return decorated diff --git a/blueprints/operator.py b/blueprints/operator.py new file mode 100644 index 0000000000000000000000000000000000000000..498758a4406ef739d6858ae657da4c578c57fc7d --- /dev/null +++ b/blueprints/operator.py @@ -0,0 +1,105 @@ +"""Operator dashboard routes.""" + +from flask import Blueprint, render_template, session, current_app + +operator_bp = Blueprint("operator", __name__, url_prefix="/operator") + + +def _get_services(): + """Get shared services from app context.""" + return { + "venue": current_app.config.get("VENUE_OBJ"), + "event": current_app.config.get("EVENT_OBJ"), + "crowd": current_app.config.get("CROWD_SERVICE"), + "queue": current_app.config.get("QUEUE_SERVICE"), + "notifications": current_app.config.get("NOTIFICATION_SERVICE"), + "simulator": current_app.config.get("SIMULATOR_OBJ"), + } + + +@operator_bp.before_request +def require_operator(): + """Require operator role for all operator pages.""" + if "user_id" not in session: + from flask import redirect, url_for, flash + flash("Please log in to continue.", "warning") + return redirect(url_for("auth.login")) + if session.get("user_role") not in ("operator", "admin"): + from flask import redirect, url_for, flash + flash("Access denied. Operator privileges required.", "error") + return redirect(url_for("attendee.home")) + + +@operator_bp.route("/") +@operator_bp.route("/dashboard") +def dashboard(): + """Main operator dashboard.""" + svc = _get_services() + venue = svc["venue"] + event = svc["event"] + crowd = svc["crowd"] + queue = svc["queue"] + simulator = svc["simulator"] + + venue_summary = crowd.get_venue_summary(venue) if crowd else {} + queue_summary = queue.get_queue_summary() if queue else {} + sim_status = simulator.get_status() if simulator else {} + + return render_template( + "operator/dashboard.html", + venue=venue.to_dict() if venue else {}, + event=event.to_dict() if event else {}, + venue_summary=venue_summary, + queue_summary=queue_summary, + sim_status=sim_status, + user_name=session.get("user_name", "Operator"), + ) + + +@operator_bp.route("/analytics") +def analytics(): + """Historical analytics view.""" + svc = _get_services() + venue = svc["venue"] + crowd = svc["crowd"] + + venue_summary = crowd.get_venue_summary(venue) if crowd else {} + + return render_template( + "operator/analytics.html", + venue_summary=venue_summary, + ) + + +@operator_bp.route("/alerts") +def alerts(): + """Alert management view.""" + svc = _get_services() + notifications = svc["notifications"] + + all_alerts = notifications.get_alerts(role="operator", limit=100) if notifications else [] + + return render_template( + "operator/alerts.html", + alerts=all_alerts, + ) + + +@operator_bp.route("/simulator") +def simulator_view(): + """Simulation control panel.""" + svc = _get_services() + simulator = svc["simulator"] + event = svc["event"] + + from models.event import EventPhase + phases = [{"value": p.value, "label": p.label, "desc": p.description} for p in EventPhase] + + sim_status = simulator.get_status() if simulator else {} + + return render_template( + "operator/simulator.html", + sim_status=sim_status, + event=event.to_dict() if event else {}, + phases=phases, + ) diff --git a/blueprints/sse.py b/blueprints/sse.py new file mode 100644 index 0000000000000000000000000000000000000000..ccc8ca4f4c0963d525e7a534c69db73634dceb3d --- /dev/null +++ b/blueprints/sse.py @@ -0,0 +1,67 @@ +"""Server-Sent Events (SSE) endpoint for real-time push updates.""" + +import json +import time +import queue +import logging +from flask import Blueprint, Response, session, current_app + +logger = logging.getLogger(__name__) + +sse_bp = Blueprint("sse", __name__, url_prefix="/sse") + +# Thread-safe message queues for SSE clients +_client_queues = {} + + +def broadcast_update(data: dict, event_type: str = "update"): + """Send an update to all connected SSE clients.""" + message = f"event: {event_type}\ndata: {json.dumps(data)}\n\n" + dead_clients = [] + + for client_id, q in _client_queues.items(): + try: + q.put_nowait(message) + except queue.Full: + dead_clients.append(client_id) + + # Clean up dead clients + for cid in dead_clients: + _client_queues.pop(cid, None) + + +def _generate_events(client_id: str): + """Generator for SSE events.""" + q = queue.Queue(maxsize=50) + _client_queues[client_id] = q + + try: + while True: + try: + # Wait for a message (with timeout for heartbeat) + message = q.get(timeout=15) + yield message + except queue.Empty: + # Send heartbeat to keep connection alive + yield f"event: heartbeat\ndata: {json.dumps({'time': time.time()})}\n\n" + except GeneratorExit: + pass + finally: + _client_queues.pop(client_id, None) + + +@sse_bp.route("/stream") +def stream(): + """SSE stream endpoint for real-time updates.""" + user_id = session.get("user_id", "anon") + client_id = f"{user_id}_{time.time()}" + + return Response( + _generate_events(client_id), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/config.py b/config.py new file mode 100644 index 0000000000000000000000000000000000000000..e720bdbf14d53410c9b04c4d9f97461ed01795c9 --- /dev/null +++ b/config.py @@ -0,0 +1,71 @@ +"""Application configuration loaded from environment variables.""" + +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + """Base configuration.""" + + SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "dev-secret-key-change-in-production") + WTF_CSRF_ENABLED = True + + # Firebase + FIREBASE_CREDENTIALS_PATH = os.getenv("FIREBASE_CREDENTIALS_PATH", "") + FIREBASE_API_KEY = os.getenv("FIREBASE_API_KEY", "") + FIREBASE_AUTH_DOMAIN = os.getenv("FIREBASE_AUTH_DOMAIN", "") + FIREBASE_PROJECT_ID = os.getenv("FIREBASE_PROJECT_ID", "") + FIREBASE_STORAGE_BUCKET = os.getenv("FIREBASE_STORAGE_BUCKET", "") + FIREBASE_MESSAGING_SENDER_ID = os.getenv("FIREBASE_MESSAGING_SENDER_ID", "") + FIREBASE_APP_ID = os.getenv("FIREBASE_APP_ID", "") + + # Google Gemini + GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") + + # Google Maps + GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "") + + # Google Cloud Translation + GOOGLE_TRANSLATE_API_KEY = os.getenv("GOOGLE_TRANSLATE_API_KEY", "") + + # App + USE_MOCK_SERVICES = os.getenv("USE_MOCK_SERVICES", "true").lower() == "true" + VENUE_CAPACITY = int(os.getenv("VENUE_CAPACITY", "50000")) + + +class DevelopmentConfig(Config): + """Development configuration.""" + + DEBUG = True + TESTING = False + + +class ProductionConfig(Config): + """Production configuration.""" + + DEBUG = False + TESTING = False + USE_MOCK_SERVICES = False + + +class TestingConfig(Config): + """Testing configuration.""" + + TESTING = True + WTF_CSRF_ENABLED = False + USE_MOCK_SERVICES = True + + +config_map = { + "development": DevelopmentConfig, + "production": ProductionConfig, + "testing": TestingConfig, +} + + +def get_config(): + """Return config class based on FLASK_ENV.""" + env = os.getenv("FLASK_ENV", "development") + return config_map.get(env, DevelopmentConfig) diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 0000000000000000000000000000000000000000..2f0401682e8c647543934e6c9da93bccb5095a0c --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,535 @@ +# VenueFlow — Smart Sporting Venue Experience Platform + +A Flask-based real-time system that transforms the physical event experience at large-scale sporting venues by addressing crowd movement, waiting times, and real-time coordination. + +--- + +## Problem Statement + +Attendees at large sporting events face: +- **Crowd congestion** at gates, concourses, and exits +- **Long queues** at food stalls, merchandise, and restrooms +- **Poor wayfinding** inside massive, unfamiliar venues +- **No real-time information** about crowd conditions or wait times +- **Accessibility barriers** for attendees with disabilities + +VenueFlow solves these with an intelligent, real-time web platform for both **fans** (mobile-first attendee app) and **operators** (venue management dashboard). + +--- + +## Architecture Overview + +``` +┌──────────────────────────────────────────────────────────┐ +│ FRONTEND (Jinja2 + JS) │ +│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Attendee│ │ Operator │ │ Heatmap │ │ Chatbot │ │ +│ │ App │ │Dashboard │ │ View │ │ Widget │ │ +│ └────┬────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ └─────────────┴────────────┴──────────────┘ │ +│ │ SSE / Fetch │ +├──────────────────────────┼───────────────────────────────┤ +│ FLASK BACKEND │ +│ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ Auth & │ │ Real-time │ │ AI Engine │ │ +│ │ Security │ │ Engine │ │ (Gemini) │ │ +│ └──────────┘ └────────────┘ └──────────────┘ │ +│ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ Queue │ │ Crowd │ │ Notification │ │ +│ │ Manager │ │ Analytics │ │ Service │ │ +│ └──────────┘ └────────────┘ └──────────────┘ │ +├──────────────────────────────────────────────────────────┤ +│ GOOGLE SERVICES │ +│ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ Firebase │ │Google Maps │ │ Gemini │ │ +│ │ Firestore│ │ JS API │ │ 2.5 API │ │ +│ └──────────┘ └────────────┘ └──────────────┘ │ +│ ┌──────────┐ ┌────────────┐ │ +│ │ Cloud │ │ Firebase │ │ +│ │Translate │ │ Auth │ │ +│ └──────────┘ └────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +### Tech Stack + +| Layer | Technology | +|-------|-----------| +| **Backend** | Python 3.11+, Flask, Flask-SocketIO | +| **Frontend** | Jinja2 templates, Vanilla JS, CSS3 | +| **Database** | Firebase Firestore (real-time sync) | +| **Auth** | Firebase Authentication | +| **Maps** | Google Maps JavaScript API | +| **AI** | Google Gemini 2.5 Flash API | +| **Translation** | Google Cloud Translation API | +| **Real-time** | Server-Sent Events (SSE) + Firebase listeners | +| **Deployment** | Docker-ready for Cloud Run | + +--- + +## User Review Required + +> [!IMPORTANT] +> **Google API Keys Required**: You will need to provide API keys / service accounts for: +> - Firebase project (Firestore + Auth) +> - Google Maps JavaScript API key +> - Google Gemini API key +> - Google Cloud Translation API key +> +> These will be stored in a `.env` file (never committed to git). + +> [!WARNING] +> **Simulated Sensor Data**: Since we don't have physical IoT sensors, the system will include a **simulation engine** that generates realistic crowd density, queue lengths, and movement patterns. This makes the demo fully functional without hardware. + +> [!IMPORTANT] +> **Two User Interfaces**: +> 1. **Attendee App** (mobile-first) — For fans at the venue +> 2. **Operator Dashboard** — For venue staff/management +> +> Both are served from the same Flask app. Please confirm this dual-interface approach. + +--- + +## Proposed Changes + +### Project Structure + +``` +EventManager/ +├── app.py # Flask application factory +├── config.py # Configuration & env management +├── requirements.txt # Python dependencies +├── Dockerfile # Container deployment +├── .env.example # Environment variable template +├── .gitignore +│ +├── blueprints/ +│ ├── __init__.py +│ ├── auth.py # Authentication routes +│ ├── attendee.py # Attendee-facing routes +│ ├── operator.py # Operator dashboard routes +│ ├── api.py # REST API endpoints +│ └── sse.py # Server-Sent Events stream +│ +├── services/ +│ ├── __init__.py +│ ├── firebase_service.py # Firebase Firestore & Auth +│ ├── crowd_service.py # Crowd density analytics +│ ├── queue_service.py # Queue management logic +│ ├── gemini_service.py # AI chatbot & recommendations +│ ├── maps_service.py # Maps & wayfinding +│ ├── translation_service.py # Multi-language support +│ ├── notification_service.py # Alert & notification engine +│ └── simulator.py # Realistic data simulation +│ +├── models/ +│ ├── __init__.py +│ ├── venue.py # Venue, Zone, Gate models +│ ├── queue.py # Queue & wait time models +│ └── event.py # Event & attendee models +│ +├── static/ +│ ├── css/ +│ │ ├── base.css # Design system & tokens +│ │ ├── attendee.css # Attendee app styles +│ │ ├── operator.css # Operator dashboard styles +│ │ └── accessibility.css # A11y overrides +│ ├── js/ +│ │ ├── app.js # Core JS utilities +│ │ ├── heatmap.js # Crowd heatmap rendering +│ │ ├── queue.js # Queue display & updates +│ │ ├── wayfinding.js # Navigation & routing +│ │ ├── chatbot.js # Gemini chatbot widget +│ │ ├── notifications.js # Real-time notification handler +│ │ ├── sse-client.js # SSE connection manager +│ │ ├── dashboard.js # Operator dashboard logic +│ │ ├── simulator-ui.js # Simulation controls +│ │ └── accessibility.js # A11y toggle & preferences +│ └── images/ +│ └── (generated assets) +│ +├── templates/ +│ ├── base.html # Base template with a11y +│ ├── attendee/ +│ │ ├── home.html # Fan landing page +│ │ ├── heatmap.html # Live crowd heatmap +│ │ ├── queues.html # Queue status & booking +│ │ ├── navigate.html # Wayfinding page +│ │ └── profile.html # Preferences & a11y settings +│ ├── operator/ +│ │ ├── dashboard.html # Main control dashboard +│ │ ├── analytics.html # Historical analytics +│ │ ├── alerts.html # Alert management +│ │ └── simulator.html # Simulation controls +│ ├── auth/ +│ │ ├── login.html +│ │ └── register.html +│ └── components/ +│ ├── chatbot.html # Chatbot widget partial +│ ├── notification.html # Notification toast partial +│ └── nav.html # Navigation component +│ +└── tests/ + ├── __init__.py + ├── conftest.py # Pytest fixtures + ├── test_auth.py # Authentication tests + ├── test_crowd_service.py # Crowd analytics tests + ├── test_queue_service.py # Queue management tests + ├── test_api.py # API endpoint tests + ├── test_simulator.py # Simulator logic tests + └── test_accessibility.py # Accessibility audit tests +``` + +--- + +### Component 1: Core Application & Configuration + +#### [NEW] `app.py` +- Flask application factory pattern with blueprint registration +- CORS, CSP, and security headers middleware +- Error handlers (404, 500) with accessible error pages +- SSE stream endpoint registration + +#### [NEW] `config.py` +- Environment-based configuration (dev/staging/prod) +- Secure loading of all API keys from `.env` +- Firebase, Gemini, Maps, Translation config constants + +#### [NEW] `requirements.txt` +Key dependencies: +``` +flask>=3.1 +flask-socketio>=5.3 +firebase-admin>=6.5 +google-generativeai>=0.8 +google-cloud-translate>=3.16 +gunicorn>=22.0 +python-dotenv>=1.0 +pytest>=8.0 +``` + +--- + +### Component 2: Authentication (Firebase Auth) + +#### [NEW] `blueprints/auth.py` +- Login / Register routes using Firebase Authentication +- Role-based access: `attendee` vs `operator` +- Session management with secure HTTP-only cookies +- CSRF protection on all forms + +#### [NEW] `services/firebase_service.py` +- Firebase Admin SDK initialization (singleton) +- Firestore CRUD helpers with connection pooling +- Real-time listener setup for zone/queue collections +- Token verification for authenticated API calls + +--- + +### Component 3: Real-Time Crowd Heatmap + +#### [NEW] `services/crowd_service.py` +- Processes zone density data from Firestore +- Calculates crowd density levels: 🟢 Low / 🟡 Moderate / 🔴 High / ⚫ Critical +- Threshold-based alerting triggers +- Historical trend computation + +#### [NEW] `static/js/heatmap.js` +- Renders interactive venue map overlay using **Google Maps JavaScript API** +- Real-time heatmap layer with color-coded zones +- Click-to-inspect zone details (capacity, current count, trend) +- Auto-refresh via SSE stream + +#### [NEW] `templates/attendee/heatmap.html` +- Mobile-first responsive layout +- Legend with WCAG-compliant color indicators + text labels +- "Best route" suggestion panel +- Accessible alternative: tabular zone status view + +--- + +### Component 4: Queue Management System + +#### [NEW] `services/queue_service.py` +- Manages virtual queues for food, merchandise, restrooms +- Estimated wait time calculation (moving average algorithm) +- "Book your spot" — virtual queue reservation +- Queue position tracking and SMS/notification alerts + +#### [NEW] `static/js/queue.js` +- Live queue dashboard with animated progress bars +- Sort/filter by wait time, category, proximity +- "Join Queue" button with confirmation modal +- Real-time position counter + +#### [NEW] `templates/attendee/queues.html` +- Card-based queue listings with status indicators +- Category filters (🍕 Food, 🛍 Merch, 🚻 Restrooms) +- Estimated wait time with confidence indicator +- Virtual queue ticket with QR code + +--- + +### Component 5: Wayfinding & Navigation + +#### [NEW] `services/maps_service.py` +- Google Maps integration for venue layout +- Shortest-path routing between venue zones +- Accessibility-aware routing (elevators, ramps) +- Points-of-interest data management + +#### [NEW] `static/js/wayfinding.js` +- Interactive venue map with Google Maps JS API +- Turn-by-turn directions within the venue +- "Navigate to nearest [restroom/food/exit]" functionality +- Crowd-aware routing (avoids congested paths) + +#### [NEW] `templates/attendee/navigate.html` +- Full-screen map with controls +- Quick-action buttons: "Nearest Exit", "My Seat", "Food Court" +- Step-by-step accessible text directions +- Estimated walking time display + +--- + +### Component 6: AI Chatbot (Google Gemini) + +#### [NEW] `services/gemini_service.py` +- Gemini 2.5 Flash integration for conversational AI +- Context-aware: knows venue layout, current crowd data, queue times +- Multi-language support via Cloud Translation API +- Safety filters and responsible AI guardrails +- Prompts: + - "Where's the shortest food queue?" + - "How do I get to Gate 7?" + - "Is Section B crowded right now?" + +#### [NEW] `static/js/chatbot.js` +- Floating chat widget (bottom-right) +- Message bubbles with typing indicator +- Quick-reply suggestion chips +- Language selector for real-time translation +- ARIA live region for screen reader announcements + +#### [NEW] `templates/components/chatbot.html` +- Accessible chat interface with proper ARIA roles +- Resizable/minimizable widget +- Keyboard-navigable message list + +--- + +### Component 7: Operator Dashboard + +#### [NEW] `blueprints/operator.py` +- Protected routes (operator role required) +- Dashboard, analytics, alerts, and simulator views +- API endpoints for crowd threshold management + +#### [NEW] `static/js/dashboard.js` +- Real-time KPI cards (total attendance, avg wait, alerts) +- Zone-by-zone capacity meters +- Alert timeline with severity levels +- Staff deployment recommendations + +#### [NEW] `templates/operator/dashboard.html` +- Grid layout with responsive breakpoints +- Live charts (crowd trends, queue throughput) +- Draggable alert cards +- Emergency broadcast panel + +--- + +### Component 8: Notification & Alert System + +#### [NEW] `services/notification_service.py` +- Multi-channel: in-app toasts, SSE push, email +- Priority levels: Info / Warning / Critical / Emergency +- Geo-targeted: alerts to specific venue zones +- Automatic triggers from crowd density thresholds + +#### [NEW] `blueprints/sse.py` +- Server-Sent Events endpoint for real-time push +- Per-user event filtering (zone, role) +- Heartbeat keep-alive for connection stability +- Graceful reconnection handling + +#### [NEW] `static/js/notifications.js` +- Toast notification stack (top-right) +- Sound alerts for critical notifications +- Notification center with history +- "Do Not Disturb" mode for operators + +--- + +### Component 9: Simulation Engine + +#### [NEW] `services/simulator.py` +- Generates realistic crowd movement patterns +- Simulates event lifecycle: pre-event → kickoff → halftime → post-event +- Queue fluctuation with realistic distributions +- Configurable parameters: venue capacity, event type, weather +- Writes simulated data to Firestore in real-time + +#### [NEW] `templates/operator/simulator.html` +- Simulation control panel (start/stop/speed) +- Event phase selector +- Parameter sliders (crowd size, arrival rate) +- Live preview of simulated data + +--- + +### Component 10: Accessibility & Internationalization + +#### [NEW] `static/css/accessibility.css` +- High-contrast mode styles +- Large text mode (150% scaling) +- Reduced motion preference support +- Focus indicator styles (3px solid, 3:1 contrast) + +#### [NEW] `static/js/accessibility.js` +- Accessibility preferences panel +- Toggle: high contrast, large text, reduced motion +- Persisted via localStorage +- Screen reader announcement utility + +#### [NEW] `services/translation_service.py` +- Google Cloud Translation API integration +- Auto-detect user language from browser +- On-demand translation of UI strings and notifications +- Cached translations for performance + +--- + +## Google Services Integration Summary + +| Google Service | Usage | Why | +|---|---|---| +| **Firebase Firestore** | Real-time database for crowd, queue, and event data | Sub-second sync, offline support, scales automatically | +| **Firebase Auth** | User authentication (email/social login) | Secure, supports role-based access, easy integration | +| **Google Maps JS API** | Venue map, heatmap overlay, wayfinding routes | Industry standard mapping, custom overlays, directions | +| **Gemini 2.5 Flash** | AI chatbot for attendee assistance | Context-aware responses, fast inference, multi-turn | +| **Cloud Translation** | Multi-language support | Real-time translation for international events | + +--- + +## Security Implementation + +1. **Authentication**: Firebase Auth with JWT token verification +2. **Authorization**: Role-based access control (attendee/operator/admin) +3. **CSRF**: Flask-WTF CSRF tokens on all forms +4. **CSP**: Content Security Policy headers +5. **Input Validation**: Server-side validation on all endpoints +6. **Rate Limiting**: API rate limiting to prevent abuse +7. **Secrets Management**: All keys in `.env`, never in source +8. **HTTPS**: Enforced in production via Dockerfile/Cloud Run +9. **XSS Protection**: Jinja2 auto-escaping enabled +10. **SQL Injection**: N/A (NoSQL Firestore) — Firestore Security Rules used + +--- + +## Accessibility Compliance (WCAG 2.2 AA) + +- **Contrast**: All text ≥ 4.5:1 ratio, UI components ≥ 3:1 +- **Target Size**: All interactive elements ≥ 44×44 CSS pixels +- **Keyboard Navigation**: Full keyboard support with visible focus indicators +- **Screen Readers**: ARIA landmarks, live regions, proper heading hierarchy +- **Reduced Motion**: `prefers-reduced-motion` media query support +- **High Contrast Mode**: Toggle-able high contrast theme +- **Skip Links**: "Skip to content" link on every page +- **Semantic HTML**: Proper use of `