Implements the parallel.md workstreams (Agents A-E) toward REFACTOR_GOAL.md. Phase 1 — app factory + blueprints + /api/v1: - app.py -> create_app() factory (no module-level app); entrypoints updated - routes/ (auth, pages, settings, admin, assets) + blueprints/api.py at /api/v1 - config.py / extensions.py / security.py extracted; services/ layer added - endpoint names preserved so template url_for() calls keep resolving (static check: all 27 template url_for endpoints are defined routes) Phase 2 — one pluggable filter system: - filter_pipeline/registry.py: @register_stage / @register_plugin + discover_modules - engine._init_stages() instantiates registered stages (no hardcoded dict); process_batch is AI-aware: only short-circuits to the AI-disabled path when a filterset's stages declare requires_ai, so offline filtersets run with AI off - BaseFilterPlugin gets a consumer (stages/plugins.py); Keyword/Quality re-enabled via filter_config.json plugins config - comment tree modes ported to stages/comment_filter.py + shared rules.py; wired into /api/v1/posts/<uuid> and /api/v1/comments/<uuid> via FilterEngine.filter_comments() (fails open) - offline quality_filter filterset exercises plugins+ranker without AI - legacy filter_lib / comment_lib / html_generation_lib / generate_html / active_html path deleted Phase 3 prep — pluggable fetchers + Postgres models: - Post / Comment SQLAlchemy models added to models.py - migrate_content_to_db.py backfill (idempotent by uuid, batched, --dry-run) - platforms/ fetcher registry (extension point) - live reads/writes still go through PostService (disk JSON); cutover deferred Phase 6 — test harness: - pytest.ini + tests/ (conftest with in-memory SQLite fixture, no Postgres; stubbed polling/filter singletons) - test_app_factory.py (route registration, no module-level app), test_api_contracts.py (posts/post_detail/comments/filters shape with monkeypatched post_service + get_filter_engine), test_filter_pipeline.py + test_plugin_contract.py (Flask-free; validated locally 12/12 incl. drop-in stage/plugin discovered with zero core edits) Other: .gitignore added (__pycache__, data/, secrets); pytest in requirements. Verification: py_compile clean across the project; the Flask-free filter-pipeline and plugin-contract tests pass locally. App-factory / API-contract tests need deps+docker to run; runtime flask routes / Auth0-repeated-create_app also gated on docker. Co-Authored-By: Claude <noreply@anthropic.com>
171 lines
5.2 KiB
Python
171 lines
5.2 KiB
Python
"""BalanceBoard Flask application factory."""
|
|
|
|
import logging
|
|
import time
|
|
|
|
from flask import Flask, redirect, render_template, request, url_for
|
|
from flask_login import current_user
|
|
from markupsafe import Markup, escape
|
|
|
|
from blueprints.api import create_api_blueprint
|
|
from config import Config, DEFAULT_PORT
|
|
from database import db, init_db
|
|
from extensions import get_filter_engine, get_polling_service, login_manager, oauth
|
|
from models import User, bcrypt
|
|
from routes.admin import register_admin_routes
|
|
from routes.assets import register_asset_routes
|
|
from routes.auth import register_auth_routes
|
|
from routes.pages import register_page_routes
|
|
from routes.settings import register_settings_routes
|
|
from user_service import UserService
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
handlers=[logging.FileHandler("app.log"), logging.StreamHandler()],
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def create_app(config_class=Config):
|
|
"""Create and configure the Flask application."""
|
|
app = Flask(__name__, static_folder="themes", template_folder="templates")
|
|
app.config.from_object(config_class)
|
|
|
|
init_db(app)
|
|
bcrypt.init_app(app)
|
|
login_manager.init_app(app)
|
|
oauth.init_app(app)
|
|
|
|
user_service = UserService()
|
|
services_started = {"value": False}
|
|
|
|
@app.before_request
|
|
def _ensure_services_started():
|
|
"""Initialize background services lazily on the first request."""
|
|
if services_started["value"]:
|
|
return
|
|
|
|
filter_engine = get_filter_engine()
|
|
logger.info(
|
|
"FilterEngine initialized with %s filtersets",
|
|
len(filter_engine.get_available_filtersets()),
|
|
)
|
|
polling_service = get_polling_service()
|
|
polling_service.init_app(app)
|
|
polling_service.start()
|
|
services_started["value"] = True
|
|
|
|
auth0 = oauth.register(
|
|
"auth0",
|
|
client_id=app.config["AUTH0_CLIENT_ID"],
|
|
client_secret=app.config["AUTH0_CLIENT_SECRET"],
|
|
server_metadata_url=f"https://{app.config['AUTH0_DOMAIN']}/.well-known/openid_configuration",
|
|
client_kwargs={"scope": "openid profile email"},
|
|
)
|
|
|
|
app.register_blueprint(create_api_blueprint(), url_prefix="/api/v1")
|
|
register_asset_routes(app)
|
|
register_page_routes(app)
|
|
register_settings_routes(app, user_service)
|
|
register_admin_routes(app, user_service)
|
|
register_auth_routes(app, user_service, auth0)
|
|
|
|
register_template_filters(app)
|
|
register_login_loader(user_service)
|
|
register_request_guards(app)
|
|
register_context_processors(app)
|
|
register_error_handlers(app)
|
|
|
|
return app
|
|
|
|
|
|
def register_template_filters(app):
|
|
"""Register Jinja filters used by legacy templates."""
|
|
|
|
@app.template_filter("nl2br")
|
|
def nl2br_filter(text):
|
|
"""Convert newlines to escaped <br> tags."""
|
|
if not text:
|
|
return text
|
|
return Markup(str(escape(text)).replace("\n", "<br>\n"))
|
|
|
|
@app.template_filter("timeago")
|
|
def timeago_filter(timestamp):
|
|
"""Format a unix timestamp as a relative age string."""
|
|
if not timestamp:
|
|
return "Recently"
|
|
try:
|
|
ts = float(timestamp)
|
|
except (TypeError, ValueError):
|
|
return "Recently"
|
|
|
|
diff = time.time() - ts
|
|
if diff < 0:
|
|
return "Recently"
|
|
if diff < 3600:
|
|
return f"{int(diff // 60)}m ago"
|
|
if diff < 86400:
|
|
return f"{int(diff // 3600)}h ago"
|
|
if diff < 604800:
|
|
return f"{int(diff // 86400)}d ago"
|
|
return time.strftime("%Y-%m-%d", time.localtime(ts))
|
|
|
|
|
|
def register_login_loader(user_service):
|
|
"""Register Flask-Login user loading."""
|
|
|
|
@login_manager.user_loader
|
|
def load_user(user_id):
|
|
return user_service.get_user_by_id(user_id)
|
|
|
|
|
|
def register_request_guards(app):
|
|
"""Register request guards shared by page routes."""
|
|
|
|
@app.before_request
|
|
def check_first_user():
|
|
"""Redirect to admin setup if no users exist yet."""
|
|
if request.endpoint and (
|
|
request.endpoint.startswith("static")
|
|
or request.endpoint in ["login", "signup", "admin_setup", "serve_theme", "serve_logo"]
|
|
):
|
|
return
|
|
|
|
if current_user.is_authenticated:
|
|
return
|
|
|
|
try:
|
|
if User.query.count() == 0:
|
|
return redirect(url_for("admin_setup"))
|
|
except Exception as e:
|
|
logger.warning(f"Database not ready for user count check: {e}")
|
|
|
|
|
|
def register_context_processors(app):
|
|
"""Register template context processors."""
|
|
|
|
@app.context_processor
|
|
def inject_app_config():
|
|
return {"APP_NAME": app.config["APP_NAME"]}
|
|
|
|
|
|
def register_error_handlers(app):
|
|
"""Register error pages for the legacy Jinja UI."""
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(e):
|
|
return render_template("404.html"), 404
|
|
|
|
@app.errorhandler(500)
|
|
def server_error(e):
|
|
return render_template("500.html"), 500
|
|
|
|
|
|
if __name__ == "__main__":
|
|
flask_app = create_app()
|
|
print("BalanceBoard starting...")
|
|
print("Database: PostgreSQL with SQLAlchemy")
|
|
print("Password hashing: bcrypt")
|
|
print("Authentication: Flask-Login")
|
|
flask_app.run(host="0.0.0.0", port=DEFAULT_PORT, debug=True) |