""" ThinkStorm Main Application Entrypoint Initializes FastAPI, mounts routes, static files, Jinja2 templates, and starts the background queue worker. """ import os from pathlib import Path from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Depends, HTTPException, status from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from .config import config, BASE_DIR from .database import init_db, get_db from .models import User, UserRole from .auth import get_current_user from .queue.worker import job_queue from .api.ideas import router as ideas_router from .api.admin import router as admin_router from .api.auth_routes import router as auth_router from .api.signal_integration import router as signal_router from .prompts.catalog import get_all_prompts, get_all_profiles TEMPLATES_DIR = BASE_DIR / "thinkstorm" / "templates" STATIC_DIR = BASE_DIR / "thinkstorm" / "static" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) @asynccontextmanager async def lifespan(app: FastAPI): # Startup print("[ThinkStorm] Initializing operational SQLite schema and seed defaults...") init_db() print("[ThinkStorm] Starting background job queue worker...") await job_queue.start() yield # Shutdown print("[ThinkStorm] Shutting down...") from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware app = FastAPI( title="ThinkStorm Orchestrator", description="AI-Assisted Self-Hosted Idea Collection and Incubation Platform", version=config.version, lifespan=lifespan ) app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*") @app.middleware("http") async def add_no_cache_headers(request: Request, call_next): response = await call_next(request) if request.url.path.startswith("/static/") or request.url.path.endswith(".js"): response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response # Mount static files app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # Mount API routers app.include_router(ideas_router) app.include_router(admin_router) app.include_router(auth_router) app.include_router(signal_router) # Trash root aliases from .api.ideas import empty_trash app.add_api_route("/api/trash/empty", empty_trash, methods=["POST"]) app.add_api_route("/api/trash", empty_trash, methods=["DELETE"]) # Gitea OAuth callback root alias from .api.auth_routes import gitea_oauth_callback app.add_api_route("/auth/gitea/callback", gitea_oauth_callback, methods=["GET"]) # ----------------- Frontend HTML Routes ----------------- @app.get("/", response_class=HTMLResponse) async def home_view(request: Request, user: User = Depends(get_current_user)): return templates.TemplateResponse( request=request, name="index.html", context={"user": user, "active_page": "home"} ) @app.get("/ideas", response_class=HTMLResponse) async def ideas_view( request: Request, state: str = None, category: str = None, tag: str = None, q: str = None, user: User = Depends(get_current_user) ): if user.role == UserRole.ANONYMOUS: return RedirectResponse("/login") query = """ SELECT i.*, GROUP_CONCAT(DISTINCT c.name) AS category_names, GROUP_CONCAT(DISTINCT t.name) AS tag_names FROM ideas i LEFT JOIN idea_categories ic ON i.id = ic.idea_id LEFT JOIN categories c ON ic.category_id = c.id LEFT JOIN idea_tags it ON i.id = it.idea_id LEFT JOIN tags t ON it.tag_id = t.id WHERE 1=1 """ params = [] if state and state.upper() != "ALL": query += " AND i.lifecycle_state = ?" params.append(state.upper()) elif state and state.upper() == "ALL": query += " AND i.lifecycle_state NOT IN ('TRASHED')" elif not state: query += " AND i.lifecycle_state NOT IN ('QUARANTINED', 'REJECTED', 'TRASHED')" if category: query += " AND c.name = ?" params.append(category) if tag: query += " AND t.name = ?" params.append(tag.lstrip("#").lower()) if q: query += " AND (i.title LIKE ? OR i.summary LIKE ? OR i.original_text LIKE ?)" term = f"%{q}%" params.extend([term, term, term]) query += " GROUP BY i.id ORDER BY i.submitted_at DESC LIMIT 50" with get_db() as conn: rows = conn.execute(query, params).fetchall() trashed_count_row = conn.execute("SELECT COUNT(*) FROM ideas WHERE lifecycle_state = 'TRASHED'").fetchone() trashed_count = trashed_count_row[0] if trashed_count_row else 0 ideas = [] for r in rows: ideas.append({ "id": r["id"], "title": r["title"] or "Untitled Idea", "summary": r["summary"], "lifecycle_state": r["lifecycle_state"], "processing_state": r["processing_state"], "enrichment_level": r["enrichment_level"], "claimed_by": r["claimed_by"], "submitted_at": r["submitted_at"], "trashed_at": r["trashed_at"] if "trashed_at" in r.keys() else None, "categories": [c.strip() for c in r["category_names"].split(",")] if r["category_names"] else [], "tags": [t.strip() for t in r["tag_names"].split(",")] if r["tag_names"] else [] }) return templates.TemplateResponse( request=request, name="ideas.html", context={ "user": user, "ideas": ideas, "current_state": state, "search_query": q, "trashed_count": trashed_count, "active_page": "ideas" } ) @app.get("/ideas/{idea_id}", response_class=HTMLResponse) async def idea_detail_view(idea_id: str, request: Request, user: User = Depends(get_current_user)): if user.role == UserRole.ANONYMOUS: return RedirectResponse("/login") from .api.ideas import get_idea_detail try: idea_data = await get_idea_detail(idea_id, current_user=user) except HTTPException as e: if e.status_code == 404: return HTMLResponse("

Idea Not Found

", status_code=404) elif e.status_code == 401: return RedirectResponse("/login") elif e.status_code == 403: return HTMLResponse(f"

Access Denied: {e.detail}

", status_code=403) raise return templates.TemplateResponse( request=request, name="idea_detail.html", context={"user": user, "idea": idea_data, "active_page": "ideas"} ) @app.get("/admin", response_class=HTMLResponse) async def admin_view(request: Request, user: User = Depends(get_current_user)): if user.role != UserRole.ADMIN: return RedirectResponse("/login") from .api.admin import list_services, list_jobs, list_quarantined_ideas, get_token_metrics, get_audit_logs prompts = get_all_prompts(is_admin=True) profiles = get_all_profiles() services = await list_services() jobs_data = await list_jobs() quarantined = await list_quarantined_ideas() metrics = await get_token_metrics() audit_logs = await get_audit_logs() with get_db() as conn: trashed_rows = conn.execute("SELECT * FROM ideas WHERE lifecycle_state = 'TRASHED' ORDER BY trashed_at DESC, submitted_at DESC").fetchall() trashed_ideas = [dict(r) for r in trashed_rows] return templates.TemplateResponse( request=request, name="admin.html", context={ "user": user, "prompts": prompts, "profiles": profiles, "services": services, "jobs_data": jobs_data, "quarantined": quarantined, "trashed_ideas": trashed_ideas, "trashed_count": len(trashed_ideas), "metrics": metrics, "audit_logs": audit_logs, "active_page": "admin" } ) @app.get("/login", response_class=HTMLResponse) async def login_view(request: Request, user: User = Depends(get_current_user)): if user.role != UserRole.ANONYMOUS: return RedirectResponse("/") return templates.TemplateResponse(request=request, name="login.html", context={"user": user, "active_page": "login"}) @app.get("/auth/gitea/callback") async def gitea_callback_redirect(code: str, request: Request): # Handled via auth API router from .api.auth_routes import gitea_oauth_callback from fastapi.responses import Response res = Response() await gitea_oauth_callback(code=code, request=request, response=res) redirect = RedirectResponse("/", status_code=302) # Forward set-cookie for k, v in res.headers.items(): if k.lower() == "set-cookie": redirect.headers[k] = v return redirect if __name__ == "__main__": import uvicorn uvicorn.run("thinkstorm.main:app", host=config.host, port=config.port, reload=True)