Files
balanceboard/app.py
Chelsea 6cf35ca034 Phase 1-3 + 6: pluggable filter system, app factory, test harness
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>
2026-07-03 02:29:46 -05:00

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)