Files
balanceboard/routes/admin.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

304 lines
11 KiB
Python

"""Admin route registration."""
import logging
import os
import shutil
from datetime import datetime
from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from database import db
from extensions import get_polling_service
from models import PollLog, PollSource
from services import load_platform_config, post_service
logger = logging.getLogger(__name__)
def _require_admin(redirect_endpoint="index", message="Access denied"):
if current_user.is_admin:
return None
flash(message, "error")
return redirect(url_for(redirect_endpoint))
def register_admin_routes(app, user_service):
"""Register admin routes while preserving endpoint names."""
@app.route("/admin")
@login_required
def admin_panel():
"""Admin panel - user management."""
denied = _require_admin("index", "Access denied. Admin privileges required.")
if denied:
return denied
if not user_service:
flash("User service not available", "error")
return redirect(url_for("index"))
users = user_service.get_all_users()
return render_template("admin.html", users=users)
@app.route("/admin/user/<user_id>/delete", methods=["POST"])
@login_required
def admin_delete_user(user_id):
"""Delete user (admin only)."""
denied = _require_admin("index")
if denied:
return denied
if current_user.id == user_id:
flash("You cannot delete your own account!", "error")
return redirect(url_for("admin_panel"))
user = user_service.get_user_by_id(user_id)
if user:
username = user.username
if user_service.delete_user(user_id):
flash(f"User {username} has been deleted.", "success")
logger.info(f"Admin {current_user.id} deleted user {username} ({user_id})")
else:
flash("Error deleting user", "error")
logger.error(f"Failed to delete user {user_id}")
else:
flash("User not found", "error")
return redirect(url_for("admin_panel"))
@app.route("/admin/user/<user_id>/toggle-admin", methods=["POST"])
@login_required
def admin_toggle_admin(user_id):
"""Toggle user admin status."""
denied = _require_admin("index")
if denied:
return denied
target_user = user_service.get_user_by_id(user_id)
if target_user:
user_service.update_user_admin_status(user_id, not target_user.is_admin)
flash("Admin status updated", "success")
else:
flash("User not found", "error")
return redirect(url_for("admin_panel"))
@app.route("/admin/clear_cache", methods=["POST"])
@login_required
def admin_clear_cache():
"""Clear application cache."""
denied = _require_admin("admin_panel")
if denied:
return denied
try:
for cache_dir in ["cache", "temp"]:
if os.path.exists(cache_dir):
shutil.rmtree(cache_dir)
post_service.invalidate()
flash("Cache cleared successfully", "success")
logger.info(f"Cache cleared by admin user {current_user.id}")
except Exception as e:
flash(f"Error clearing cache: {str(e)}", "error")
logger.error(f"Cache clearing error: {e}")
return redirect(url_for("admin_panel"))
@app.route("/admin/backup_data", methods=["POST"])
@login_required
def admin_backup_data():
"""Create backup of application data."""
denied = _require_admin("admin_panel")
if denied:
return denied
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"balanceboard_backup_{timestamp}"
backup_dir = f"backups/{backup_name}"
os.makedirs(backup_dir, exist_ok=True)
for dir_name in ["data", "templates", "themes", "static"]:
if os.path.exists(dir_name):
shutil.copytree(dir_name, f"{backup_dir}/{dir_name}")
for file_name in ["app.py", "models.py", "database.py", "filtersets.json"]:
if os.path.exists(file_name):
shutil.copy2(file_name, backup_dir)
flash(f"Backup created: {backup_name}", "success")
except Exception as e:
flash(f"Error creating backup: {str(e)}", "error")
return redirect(url_for("admin_panel"))
@app.route("/admin/polling")
@login_required
def admin_polling():
"""Admin polling management page."""
denied = _require_admin("index", "Access denied. Admin privileges required.")
if denied:
return denied
sources = PollSource.query.order_by(PollSource.platform, PollSource.display_name).all()
scheduler_status = get_polling_service().get_status()
platform_config = load_platform_config()
return render_template(
"admin_polling.html",
sources=sources,
scheduler_status=scheduler_status,
platform_config=platform_config,
)
@app.route("/admin/polling/add", methods=["POST"])
@login_required
def admin_polling_add():
"""Add a new poll source."""
denied = _require_admin("index")
if denied:
return denied
platform = request.form.get("platform")
source_id = request.form.get("source_id")
custom_source_id = request.form.get("custom_source_id")
display_name = request.form.get("display_name")
poll_interval = int(request.form.get("poll_interval", 60))
max_posts = int(request.form.get("max_posts", 100))
fetch_comments = request.form.get("fetch_comments", "true") == "true"
priority = request.form.get("priority", "medium")
if custom_source_id and custom_source_id.strip():
source_id = custom_source_id.strip()
if not platform or not source_id or not display_name:
flash("Missing required fields", "error")
return redirect(url_for("admin_polling"))
existing = PollSource.query.filter_by(platform=platform, source_id=source_id).first()
if existing:
flash(f"Source {platform}:{source_id} already exists", "warning")
return redirect(url_for("admin_polling"))
source = PollSource(
platform=platform,
source_id=source_id,
display_name=display_name,
poll_interval_minutes=poll_interval,
max_posts=max_posts,
fetch_comments=fetch_comments,
priority=priority,
enabled=False,
created_by=current_user.id,
)
db.session.add(source)
db.session.commit()
flash(f"Added polling source: {display_name}", "success")
logger.info(f"Admin {current_user.id} added poll source {platform}:{source_id}")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/toggle", methods=["POST"])
@login_required
def admin_polling_toggle(source_id):
"""Toggle a poll source on/off."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
source.enabled = not source.enabled
db.session.commit()
status = "enabled" if source.enabled else "disabled"
flash(f"Polling {status} for {source.display_name}", "success")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/update", methods=["POST"])
@login_required
def admin_polling_update(source_id):
"""Update poll source configuration."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
if request.form.get("poll_interval"):
source.poll_interval_minutes = int(request.form.get("poll_interval"))
if request.form.get("max_posts"):
source.max_posts = int(request.form.get("max_posts"))
if request.form.get("fetch_comments") is not None:
source.fetch_comments = request.form.get("fetch_comments") == "true"
if request.form.get("priority"):
source.priority = request.form.get("priority")
if request.form.get("display_name"):
source.display_name = request.form.get("display_name")
db.session.commit()
flash(f"Updated settings for {source.display_name}", "success")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/poll-now", methods=["POST"])
@login_required
def admin_polling_poll_now(source_id):
"""Manually trigger polling for a source."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
try:
get_polling_service().poll_now(source_id)
flash(f"Polling started for {source.display_name}", "success")
except Exception as e:
flash(f"Error starting poll: {str(e)}", "error")
logger.error(f"Error triggering poll for {source_id}: {e}")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/delete", methods=["POST"])
@login_required
def admin_polling_delete(source_id):
"""Delete a poll source."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
display_name = source.display_name
db.session.delete(source)
db.session.commit()
flash(f"Deleted polling source: {display_name}", "success")
logger.info(f"Admin {current_user.id} deleted poll source {source_id}")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/logs")
@login_required
def admin_polling_logs(source_id):
"""View logs for a specific poll source."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
logs = source.logs.limit(50).all()
return render_template("admin_polling_logs.html", source=source, logs=logs)