Split webui into Flask blueprints and data domain modules

- Split app.py (66 routes) into 3 blueprints: pages (public), api (JSON), admin (@admin_required)
- Split data.py (4,360 LOC) into 7 domain modules: drafts, authors, ratings, gaps, analysis, search, proposals
- Add data/__init__.py re-exporting all public functions for backward compatibility
- Add custom 404/500 error pages matching dark theme
- Add request timing logging via before_request/after_request hooks
- Refactor app.py into create_app() factory pattern
- All 106 tests pass, all 66 routes preserved

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 03:37:15 +01:00
parent c066b04d74
commit 3fb17100d7
17 changed files with 4144 additions and 5170 deletions

View File

@@ -0,0 +1,206 @@
"""Public page routes (no admin required)."""
from __future__ import annotations
from flask import Blueprint, render_template, request, abort, g
from webui.data import (
get_db,
get_overview_stats,
get_category_counts,
get_drafts_page,
get_draft_detail,
get_rating_distributions,
get_timeline_data,
get_timeline_animation_data,
get_ideas_by_type,
get_top_authors,
get_org_data,
get_category_radar_data,
get_score_histogram,
get_author_network_full,
get_cross_org_data,
get_citation_graph,
get_idea_clusters,
get_category_summary,
global_search,
get_architecture,
get_ask_search,
get_citation_influence,
get_bcp_analysis,
)
pages_bp = Blueprint("pages", __name__)
def db():
if "db" not in g:
g.db = get_db()
return g.db
@pages_bp.route("/")
def overview():
stats = get_overview_stats(db())
categories = get_category_counts(db())
timeline = get_timeline_data(db())
scores = get_score_histogram(db())
radar = get_category_radar_data(db())
return render_template(
"overview.html",
stats=stats,
categories=categories,
timeline=timeline,
scores=scores,
radar=radar,
)
@pages_bp.route("/drafts")
def drafts():
page = request.args.get("page", 1, type=int)
search = request.args.get("q", "")
category = request.args.get("cat", "")
source = request.args.get("source", "")
min_score = request.args.get("min_score", 0.0, type=float)
sort = request.args.get("sort", "score")
sort_dir = request.args.get("dir", "desc")
result = get_drafts_page(
db(),
page=page,
search=search,
category=category,
min_score=min_score,
sort=sort,
sort_dir=sort_dir,
source=source,
)
categories = get_category_counts(db())
cat_summary = get_category_summary(db(), category) if category else None
return render_template(
"drafts.html",
result=result,
categories=categories,
cat_summary=cat_summary,
search=search,
current_cat=category,
current_source=source,
min_score=min_score,
sort=sort,
sort_dir=sort_dir,
)
@pages_bp.route("/drafts/<string:name>")
def draft_detail(name: str):
database = db()
detail = get_draft_detail(database, name)
if not detail:
abort(404)
# Build set of draft ref IDs that exist in our DB for internal linking
ref_draft_ids = [r["id"] for r in detail.get("refs", []) if r["type"] == "draft"]
known_drafts = set()
if ref_draft_ids:
placeholders = ",".join("?" * len(ref_draft_ids))
rows = database.conn.execute(
f"SELECT name FROM drafts WHERE name IN ({placeholders})", ref_draft_ids
).fetchall()
known_drafts = {r["name"] for r in rows}
return render_template("draft_detail.html", draft=detail, known_drafts=known_drafts)
@pages_bp.route("/ideas")
def ideas():
data = get_ideas_by_type(db())
return render_template("ideas.html", data=data)
@pages_bp.route("/ratings")
def ratings():
distributions = get_rating_distributions(db())
radar = get_category_radar_data(db())
return render_template(
"ratings.html",
dist=distributions,
radar=radar,
)
@pages_bp.route("/timeline")
def timeline_animation():
data = get_timeline_animation_data(db())
return render_template("timeline.html", animation=data)
@pages_bp.route("/idea-clusters")
def idea_clusters():
data = get_idea_clusters(db())
return render_template("idea_clusters.html", clusters=data)
@pages_bp.route("/architecture")
def architecture():
data = get_architecture(db())
return render_template("architecture.html", arch=data)
@pages_bp.route("/authors")
def authors():
top = get_top_authors(db(), limit=50)
orgs = get_org_data(db(), limit=20)
network = get_author_network_full(db())
cross_org = get_cross_org_data(db(), limit=20)
return render_template(
"authors.html",
authors=top,
orgs=orgs,
orgs_data=orgs,
network=network,
cross_org=cross_org,
)
@pages_bp.route("/citations")
def citations():
from webui.auth import is_admin as check_admin
graph = get_citation_graph(db())
influence = get_citation_influence(db()) if check_admin() else None
bcp = get_bcp_analysis(db()) if check_admin() else None
return render_template("citations.html", graph=graph, influence=influence, bcp=bcp)
@pages_bp.route("/about")
def about():
from ietf_analyzer.config import Config
cfg = Config.load()
stats = get_overview_stats(db())
return render_template("about.html", stats=stats, search_keywords=cfg.search_keywords,
fetch_since=cfg.fetch_since)
@pages_bp.route("/impressum")
def impressum():
return render_template("impressum.html")
@pages_bp.route("/datenschutz")
def datenschutz():
return render_template("datenschutz.html")
@pages_bp.route("/search")
def search():
q = request.args.get("q", "").strip()
results = global_search(db(), q) if q else {"drafts": [], "ideas": [], "authors": [], "gaps": []}
total = sum(len(v) for v in results.values())
return render_template("search_results.html", query=q, results=results, total=total)
@pages_bp.route("/ask")
def ask_page():
question = request.args.get("q", "")
result = None
if question:
top_k = request.args.get("top", 5, type=int)
result = get_ask_search(db(), question, top_k=top_k)
return render_template("ask.html", question=question, result=result)