"""Main Flask application for the Fellowship Quest Tracker.""" from dotenv import load_dotenv import os # Load environment variables from .env file (if present) # This must happen before any config is loaded load_dotenv() from flask import Flask, jsonify, request, session from flask_cors import CORS from flask_restx import Api from config import config from models.user import db from utils.database import init_db from utils.seed_data import seed_database from routes.auth import auth_bp, auth_api from routes.quests import quests_bp, quests_api from routes.members import members_bp, members_api from routes.locations import locations_bp, locations_api from routes.npc_chat import npc_chat_bp, npc_chat_api from routes.shop import shop_bp, shop_api from services.shop_service import ShopService from datetime import datetime, timezone, timedelta from typing import Optional APP_STARTED_AT_UTC = datetime.now(timezone.utc) def _read_uptime_seconds() -> Optional[float]: try: with open('/proc/uptime', 'r', encoding='utf-8') as uptime_file: first_field = uptime_file.read().split()[0] return float(first_field) except (OSError, ValueError, IndexError): return None def _instance_boot_time_utc() -> Optional[datetime]: uptime_seconds = _read_uptime_seconds() if uptime_seconds is None: return None return datetime.now(timezone.utc) - timedelta(seconds=uptime_seconds) def create_app(config_name: str = None) -> Flask: """Create and configure Flask application.""" app = Flask(__name__) # Load configuration config_name = config_name or os.environ.get('FLASK_ENV', 'development') app.config.from_object(config[config_name]) # Configure session app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Allows cross-site cookies for development app.config['SESSION_COOKIE_SECURE'] = False # Set to True in production with HTTPS # Initialize CORS with specific origins (required when using credentials) # Allow both localhost:3000 (dev) and localhost (production via nginx) # Flask-CORS handles preflight OPTIONS requests automatically CORS( app, supports_credentials=True, resources={ r"/api/*": { "origins": [ "http://localhost:3000", "http://localhost", "http://127.0.0.1:3000", "http://127.0.0.1", ] } }, allow_headers=["Content-Type", "Authorization"], methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], ) # Initialize database (this also initializes db) init_db(app) # Seed database with initial data # Skip seeding in test mode # Skip seeding in test mode if not app.config.get("TESTING") and not os.environ.get("TESTING"): seed_database(app) # Create main API with Swagger documentation api = Api( app, version='1.0', title='The Fellowship\'s Quest List API', description='REST API for tracking the Fellowship\'s epic journey through Middle-earth', doc='/api/swagger/', prefix='/api' ) # Register blueprints (this registers both the blueprints and their Flask-RESTX routes) # Flask-RESTX Api objects bound to blueprints automatically register routes when blueprint is registered app.register_blueprint(auth_bp) app.register_blueprint(quests_bp) app.register_blueprint(members_bp) app.register_blueprint(locations_bp) app.register_blueprint(npc_chat_bp) app.register_blueprint(shop_bp) # Note: We don't add the Api objects as namespaces because they're already bound to blueprints # Adding them as namespaces would cause route conflicts. The routes work from blueprints alone. # For Swagger, each Api has its own documentation, but we can add them to the main API if needed. # However, this requires creating Namespace objects, not using the Api objects directly. # Health check endpoint @app.route('/api/health') def health(): """Health check endpoint.""" return jsonify({'status': 'healthy', 'service': 'fellowship-quest-tracker'}), 200 # Test cleanup endpoint (development only - deletes accumulated test data) @app.route('/api/test/cleanup', methods=['POST']) def test_cleanup(): """Delete quests created during e2e test runs to prevent database bloat.""" from models.quest import Quest as QuestModel # Only allowed in non-production environments if app.config.get('ENV') == 'production': return jsonify({'error': 'Not available in production'}), 403 test_patterns = [ 'BDD', 'Test Quest', 'Find the One Ring', 'Explore Rivendell', 'Defeat Sauron', 'Completed Quest', 'In Progress Quest', 'Journey Quest', 'Battle Quest', 'Ring Quest', 'Mordor Quest', 'Rivendell Quest', 'Dark Magic Quest', 'Mini-game Quest', ] deleted = 0 try: for pattern in test_patterns: quests = QuestModel.query.filter(QuestModel.title.contains(pattern)).all() for q in quests: db.session.delete(q) deleted += 1 db.session.commit() return jsonify({'deleted': deleted, 'status': 'ok'}), 200 except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/test/set_gold', methods=['POST']) def test_set_gold(): """Set the current user's gold balance (for e2e testing only).""" if app.config.get('ENV') == 'production': return jsonify({'error': 'Not available in production'}), 403 from models.user import User as UserModel user_id = session.get('user_id') if not user_id: return jsonify({'error': 'Not authenticated'}), 401 data = request.get_json() or {} gold = data.get('gold') if gold is None or not isinstance(gold, int) or gold < 0: return jsonify({'error': 'gold must be a non-negative integer'}), 400 try: user = UserModel.query.get(user_id) if not user: return jsonify({'error': 'User not found'}), 404 user.gold = gold db.session.commit() return jsonify({'gold': user.gold, 'status': 'ok'}), 200 except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/test/reset_shop', methods=['POST']) def test_reset_shop(): """Reset all items to not-sold and clear inventory for e2e testing.""" if app.config.get('ENV') == 'production': return jsonify({'error': 'Not available in production'}), 403 try: result = ShopService.reset_for_tests() return jsonify(result), 200 except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/status') def status(): """Runtime status endpoint exposed for public uptime/restart information.""" instance_boot_time = _instance_boot_time_utc() payload = { 'status': 'ok', 'service': 'fellowship-quest-tracker', 'app_started_at_utc': APP_STARTED_AT_UTC.isoformat(), 'instance_boot_time_utc': instance_boot_time.isoformat() if instance_boot_time else None, 'now_utc': datetime.now(timezone.utc).isoformat(), } return jsonify(payload), 200 # API info endpoint (instead of root, since nginx handles root routing) @app.route('/api') def api_info(): """API information endpoint.""" return jsonify({ 'message': 'Welcome to The Fellowship\'s Quest List API', 'version': '1.0', 'docs': '/api/swagger/', 'health': '/api/health', 'status': '/api/status' }), 200 # Debug endpoint to list all registered routes (development only) if app.config.get('DEBUG'): @app.route('/api/routes') def list_routes(): """List all registered routes (debug endpoint).""" routes = [] for rule in app.url_map.iter_rules(): if rule.rule.startswith('/api'): routes.append({ 'endpoint': rule.endpoint, 'methods': list(rule.methods - {'HEAD', 'OPTIONS'}), 'path': str(rule) }) return jsonify({'routes': sorted(routes, key=lambda x: x['path'])}), 200 return app if __name__ == '__main__': try: app = create_app() app.run(host='0.0.0.0', port=5000, debug=True) except Exception as e: import traceback print(f"Error starting application: {e}") traceback.print_exc() raise