229 lines
8.9 KiB
Python
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 |