lotr-sut/sut/backend/app.py
Fellowship Scholar f6a5823439 init commit
2026-03-29 20:07:56 +00:00

229 lines
8.9 KiB
Python

"""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