Files
ThinkStorm/thinkstorm/main.py
T

248 lines
8.8 KiB
Python

"""
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 .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)
# 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("<h1>Idea Not Found</h1>", status_code=404)
elif e.status_code == 401:
return RedirectResponse("/login")
elif e.status_code == 403:
return HTMLResponse(f"<h1>Access Denied: {e.detail}</h1>", 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)