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>
This commit is contained in:
2026-07-03 02:29:46 -05:00
parent cdba720a1c
commit 6cf35ca034
64 changed files with 3902 additions and 3776 deletions

96
routes/pages.py Normal file
View File

@@ -0,0 +1,96 @@
"""Page route registration for the legacy Jinja UI."""
import json
import logging
from flask import current_app, redirect, render_template, url_for
from flask_login import current_user, login_required
from services import get_display_name_for_source, load_platform_config, post_service
logger = logging.getLogger(__name__)
def _load_user_settings():
if not current_user.is_authenticated:
return {}
try:
return json.loads(current_user.settings) if current_user.settings else {}
except (json.JSONDecodeError, TypeError):
return {}
def register_page_routes(app):
"""Register legacy page routes while preserving endpoint names."""
@app.route("/")
def index():
"""Serve the main feed page."""
quick_stats = post_service.quick_stats()
if current_user.is_authenticated:
return render_template(
"dashboard.html",
user_settings=_load_user_settings(),
quick_stats=quick_stats,
)
if current_app.config.get("ALLOW_ANONYMOUS_ACCESS", False):
user_settings = {
"filter_set": "no_filter",
"communities": [],
"experience": {
"infinite_scroll": False,
"auto_refresh": False,
"push_notifications": False,
"dark_patterns_opt_in": False,
"time_filter_enabled": False,
"time_filter_days": 7,
},
}
return render_template(
"dashboard.html",
user_settings=user_settings,
anonymous=True,
quick_stats=quick_stats,
)
return redirect(url_for("login"))
@app.route("/bookmarks")
@login_required
def bookmarks():
"""Bookmarks page."""
return render_template("bookmarks.html", user=current_user)
@app.route("/post/<post_id>")
def post_detail(post_id):
"""Serve individual post detail page with modern theme."""
try:
platform_config = load_platform_config()
cached_posts, cached_comments = post_service.load()
post_data = cached_posts.get(post_id)
if not post_data:
return render_template("404.html"), 404
post = dict(post_data)
post["source_display"] = get_display_name_for_source(
post.get("platform", ""),
post.get("source", ""),
platform_config,
)
comments_flat = cached_comments.get(post_id, [])
logger.info(f"Loading post {post_id}: found {len(comments_flat)} comments")
comments = post_service.build_comment_tree(comments_flat)
return render_template(
"post_detail.html",
post=post,
comments=comments,
user_settings=_load_user_settings(),
)
except Exception as e:
logger.error(f"Error loading post {post_id}: {e}")
return render_template("404.html"), 404