Capture ThinkStorm project: codebase state, workflows, and access control policies
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
ThinkStorm Admin API Router
|
||||
Handles Prompts Management, Versioning, Profiles, Services Config, Job Observability, Moderation, and Token Accounting.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import APIRouter, Request, HTTPException, status, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import config
|
||||
from ..database import get_db, get_utc_now
|
||||
from ..models import User, UserRole, ProcessorStatus
|
||||
from ..auth import require_admin
|
||||
from ..prompts.catalog import (
|
||||
get_all_prompts, get_prompt_version, update_prompt, duplicate_prompt, get_all_profiles
|
||||
)
|
||||
from ..services.omniroute import OmniRouteAdapter
|
||||
from ..services.searxng import SearXNGAdapter
|
||||
from ..services.perplexica import PerplexicaAdapter
|
||||
from ..services.opengist import OpenGistAdapter
|
||||
from ..services.gitea import GiteaAdapter
|
||||
from ..services.virustotal import VirusTotalAdapter
|
||||
from ..queue.worker import job_queue
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
|
||||
omniroute_svc = OmniRouteAdapter()
|
||||
searxng_svc = SearXNGAdapter()
|
||||
perplexica_svc = PerplexicaAdapter()
|
||||
opengist_svc = OpenGistAdapter()
|
||||
gitea_svc = GiteaAdapter()
|
||||
virustotal_svc = VirusTotalAdapter()
|
||||
|
||||
class PromptUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
system_prompt: str
|
||||
user_prompt_template: str
|
||||
model_policy: str = "reasoning"
|
||||
expected_outputs: Optional[List[str]] = None
|
||||
|
||||
class PromptDuplicateRequest(BaseModel):
|
||||
new_id: str
|
||||
new_name: str
|
||||
|
||||
class ServiceUpdateRequest(BaseModel):
|
||||
endpoint: str
|
||||
api_key_raw: Optional[str] = None
|
||||
enabled: bool = True
|
||||
config_json: Optional[Dict[str, Any]] = None
|
||||
|
||||
class QuarantineDecisionRequest(BaseModel):
|
||||
decision: str # APPROVE or REJECT
|
||||
notes: Optional[str] = ""
|
||||
|
||||
# ----------------- Prompts & Profiles -----------------
|
||||
@router.get("/prompts")
|
||||
async def list_admin_prompts():
|
||||
"""Returns full prompt definitions with templates (Admin only)."""
|
||||
return get_all_prompts(is_admin=True)
|
||||
|
||||
@router.get("/prompts/{prompt_id}")
|
||||
async def get_admin_prompt(prompt_id: str, version: Optional[int] = None):
|
||||
p = get_prompt_version(prompt_id, version, is_admin=True)
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="Prompt not found.")
|
||||
return p
|
||||
|
||||
@router.put("/prompts/{prompt_id}")
|
||||
async def update_admin_prompt(prompt_id: str, payload: PromptUpdateRequest, current_user: User = Depends(require_admin)):
|
||||
"""
|
||||
Creates a new immutable prompt version.
|
||||
Historical execution records remain linked to their original version.
|
||||
"""
|
||||
new_version = update_prompt(
|
||||
prompt_id=prompt_id,
|
||||
system_prompt=payload.system_prompt,
|
||||
user_prompt_template=payload.user_prompt_template,
|
||||
model_policy=payload.model_policy,
|
||||
expected_outputs=payload.expected_outputs,
|
||||
updated_by=current_user.username,
|
||||
name=payload.name,
|
||||
description=payload.description
|
||||
)
|
||||
# Log Audit Event
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
|
||||
VALUES (?, 'UPDATE_PROMPT_VERSION', 'PROMPT', ?, ?, ?)
|
||||
""",
|
||||
(current_user.username, prompt_id, json.dumps({"new_version": new_version}), get_utc_now())
|
||||
)
|
||||
return {"message": f"Created new version {new_version} for prompt {prompt_id}.", "version": new_version}
|
||||
|
||||
@router.post("/prompts/{prompt_id}/duplicate")
|
||||
async def duplicate_admin_prompt(prompt_id: str, payload: PromptDuplicateRequest, current_user: User = Depends(require_admin)):
|
||||
duplicate_prompt(prompt_id, payload.new_id, payload.new_name, created_by=current_user.username)
|
||||
return {"message": f"Prompt {prompt_id} duplicated to {payload.new_id}."}
|
||||
|
||||
@router.post("/prompts/{prompt_id}/toggle")
|
||||
async def toggle_admin_prompt(prompt_id: str):
|
||||
with get_db() as conn:
|
||||
p = conn.execute("SELECT enabled FROM prompt_definitions WHERE id = ?", (prompt_id,)).fetchone()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="Prompt not found.")
|
||||
new_state = 0 if p["enabled"] else 1
|
||||
conn.execute("UPDATE prompt_definitions SET enabled = ? WHERE id = ?", (new_state, prompt_id))
|
||||
return {"message": f"Prompt {prompt_id} enabled set to {bool(new_state)}."}
|
||||
|
||||
@router.get("/profiles")
|
||||
async def list_admin_profiles():
|
||||
return get_all_profiles()
|
||||
|
||||
# ----------------- Services Configuration -----------------
|
||||
@router.get("/services")
|
||||
async def list_services():
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("SELECT id, name, endpoint, api_key_masked, enabled, config_json, last_tested_at, health_status, last_error FROM service_configurations").fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"endpoint": r["endpoint"],
|
||||
"api_key_masked": r["api_key_masked"],
|
||||
"enabled": bool(r["enabled"]),
|
||||
"config": json.loads(r["config_json"] or "{}"),
|
||||
"last_tested_at": r["last_tested_at"],
|
||||
"health_status": r["health_status"],
|
||||
"last_error": r["last_error"]
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@router.post("/services/{service_id}/test")
|
||||
async def test_service_connection(service_id: str):
|
||||
"""Performs live health probe to external service."""
|
||||
now = get_utc_now()
|
||||
adapter = None
|
||||
if service_id == "searxng":
|
||||
adapter = searxng_svc
|
||||
elif service_id == "omniroute":
|
||||
adapter = omniroute_svc
|
||||
elif service_id == "opengist":
|
||||
adapter = opengist_svc
|
||||
elif service_id == "gitea":
|
||||
adapter = gitea_svc
|
||||
elif service_id == "perplexica":
|
||||
adapter = perplexica_svc
|
||||
elif service_id == "virustotal":
|
||||
adapter = virustotal_svc
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"Service '{service_id}' not found.")
|
||||
|
||||
health = await adapter.check_health()
|
||||
status_str = "HEALTHY" if health.healthy else "UNHEALTHY"
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE service_configurations
|
||||
SET last_tested_at = ?, health_status = ?, last_error = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, status_str, None if health.healthy else health.message, service_id)
|
||||
)
|
||||
|
||||
return {
|
||||
"service_id": service_id,
|
||||
"healthy": health.healthy,
|
||||
"status": status_str,
|
||||
"message": health.message,
|
||||
"response_time_ms": health.response_time_ms
|
||||
}
|
||||
|
||||
@router.put("/services/{service_id}")
|
||||
async def update_service(service_id: str, payload: ServiceUpdateRequest, current_user: User = Depends(require_admin)):
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
s = conn.execute("SELECT * FROM service_configurations WHERE id = ?", (service_id,)).fetchone()
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Service not found.")
|
||||
|
||||
masked = s["api_key_masked"]
|
||||
raw = s["api_key_raw"]
|
||||
if payload.api_key_raw:
|
||||
raw = payload.api_key_raw
|
||||
masked = f"{raw[:4]}...{raw[-4:]}" if len(raw) > 8 else "****"
|
||||
|
||||
conf_json = json.dumps(payload.config_json or json.loads(s["config_json"] or "{}"))
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE service_configurations
|
||||
SET endpoint = ?, api_key_masked = ?, api_key_raw = ?, enabled = ?, config_json = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(payload.endpoint, masked, raw, 1 if payload.enabled else 0, conf_json, service_id)
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
|
||||
VALUES (?, 'UPDATE_SERVICE_CONFIG', 'SERVICE', ?, ?, ?)
|
||||
""",
|
||||
(current_user.username, service_id, json.dumps({"endpoint": payload.endpoint, "enabled": payload.enabled}), now)
|
||||
)
|
||||
|
||||
return {"message": f"Service '{service_id}' updated successfully."}
|
||||
|
||||
# ----------------- Observability & Jobs -----------------
|
||||
@router.get("/jobs")
|
||||
async def list_jobs():
|
||||
queue_status = job_queue.get_status()
|
||||
with get_db() as conn:
|
||||
recent_runs = conn.execute(
|
||||
"""
|
||||
SELECT * FROM processor_runs ORDER BY started_at DESC LIMIT 30
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
runs = []
|
||||
for r in recent_runs:
|
||||
runs.append({
|
||||
"id": r["id"],
|
||||
"idea_id": r["idea_id"],
|
||||
"work_track_id": r["work_track_id"],
|
||||
"processor_name": r["processor_name"],
|
||||
"stage": r["stage"],
|
||||
"prompt_id": r["prompt_id"],
|
||||
"prompt_version": r["prompt_version"],
|
||||
"resolved_model": r["resolved_model"],
|
||||
"total_tokens": r["total_tokens"],
|
||||
"duration_ms": r["duration_ms"],
|
||||
"started_at": r["started_at"],
|
||||
"status": r["status"],
|
||||
"error_message": r["error_message"]
|
||||
})
|
||||
|
||||
return {
|
||||
"queue": queue_status,
|
||||
"recent_runs": runs
|
||||
}
|
||||
|
||||
@router.post("/jobs/retry/{idea_id}")
|
||||
async def retry_idea_job(idea_id: str):
|
||||
with get_db() as conn:
|
||||
conn.execute("UPDATE ideas SET processing_state = 'QUEUED' WHERE id = ?", (idea_id,))
|
||||
await job_queue.enqueue_foreground("intake", idea_id)
|
||||
return {"message": f"Idea {idea_id} enqueued for processing retry."}
|
||||
|
||||
# ----------------- Moderation & Quarantine -----------------
|
||||
@router.get("/quarantine")
|
||||
async def list_quarantined_ideas():
|
||||
with get_db() as conn:
|
||||
ideas = conn.execute(
|
||||
"""
|
||||
SELECT i.*,
|
||||
(SELECT COUNT(*) FROM idea_urls u WHERE u.idea_id = i.id AND u.safety_state = 'MALICIOUS') AS malicious_url_count,
|
||||
(SELECT COUNT(*) FROM idea_urls u WHERE u.idea_id = i.id AND u.safety_state = 'SUSPICIOUS') AS suspicious_url_count
|
||||
FROM ideas i
|
||||
WHERE i.lifecycle_state = 'QUARANTINED'
|
||||
ORDER BY i.submitted_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
results = []
|
||||
for r in ideas:
|
||||
urls = conn.execute("SELECT * FROM idea_urls WHERE idea_id = ?", (r["id"],)).fetchall()
|
||||
results.append({
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"original_text": r["original_text"],
|
||||
"submitted_at": r["submitted_at"],
|
||||
"malicious_urls": r["malicious_url_count"],
|
||||
"suspicious_urls": r["suspicious_url_count"],
|
||||
"urls": [
|
||||
{
|
||||
"url": u["url"],
|
||||
"safety_state": u["safety_state"],
|
||||
"automation_policy": u["automation_policy"],
|
||||
"virustotal": json.loads(u["virustotal_data"] or "{}")
|
||||
}
|
||||
for u in urls
|
||||
]
|
||||
})
|
||||
return results
|
||||
|
||||
@router.post("/quarantine/{idea_id}/decision")
|
||||
async def quarantine_decision(idea_id: str, payload: QuarantineDecisionRequest, current_user: User = Depends(require_admin)):
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
|
||||
if payload.decision.upper() == "APPROVE":
|
||||
conn.execute("UPDATE ideas SET lifecycle_state = 'AVAILABLE', updated_at = ? WHERE id = ?", (now, idea_id))
|
||||
conn.execute("UPDATE idea_urls SET automation_policy = 'APPROVED', decision = 'APPROVED_BY_ADMIN', reviewed_by = ?, reviewed_at = ? WHERE idea_id = ?", (current_user.username, now, idea_id))
|
||||
else:
|
||||
conn.execute("UPDATE ideas SET lifecycle_state = 'REJECTED', updated_at = ? WHERE id = ?", (now, idea_id))
|
||||
conn.execute("UPDATE idea_urls SET automation_policy = 'BLOCKED', decision = 'REJECTED_BY_ADMIN', reviewed_by = ?, reviewed_at = ? WHERE idea_id = ?", (current_user.username, now, idea_id))
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
|
||||
VALUES (?, 'QUARANTINE_DECISION', 'IDEA', ?, ?, ?)
|
||||
""",
|
||||
(current_user.username, idea_id, json.dumps({"decision": payload.decision, "notes": payload.notes}), now)
|
||||
)
|
||||
|
||||
return {"message": f"Idea {idea_id} marked as {payload.decision.upper()}."}
|
||||
|
||||
# ----------------- Token Accounting & Metrics -----------------
|
||||
@router.get("/token-metrics")
|
||||
async def get_token_metrics():
|
||||
with get_db() as conn:
|
||||
totals = conn.execute("SELECT SUM(input_tokens) AS in_tok, SUM(output_tokens) AS out_tok, SUM(total_tokens) AS tot_tok, COUNT(*) AS run_count FROM processor_runs").fetchone()
|
||||
by_stage = conn.execute("SELECT stage, SUM(total_tokens) as tokens, COUNT(*) as count FROM processor_runs GROUP BY stage").fetchall()
|
||||
by_model = conn.execute("SELECT resolved_model, SUM(total_tokens) as tokens, COUNT(*) as count FROM processor_runs GROUP BY resolved_model").fetchall()
|
||||
by_idea = conn.execute("SELECT idea_id, SUM(total_tokens) as tokens, COUNT(*) as count FROM processor_runs GROUP BY idea_id ORDER BY tokens DESC LIMIT 10").fetchall()
|
||||
|
||||
return {
|
||||
"overall": {
|
||||
"input_tokens": totals["in_tok"] or 0,
|
||||
"output_tokens": totals["out_tok"] or 0,
|
||||
"total_tokens": totals["tot_tok"] or 0,
|
||||
"runs_count": totals["run_count"] or 0
|
||||
},
|
||||
"by_stage": [dict(r) for r in by_stage],
|
||||
"by_model": [dict(r) for r in by_model],
|
||||
"top_ideas": [dict(r) for r in by_idea]
|
||||
}
|
||||
|
||||
@router.get("/audit-logs")
|
||||
async def get_audit_logs():
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("SELECT * FROM audit_events ORDER BY created_at DESC LIMIT 50").fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"user_id": r["user_id"],
|
||||
"action": r["action"],
|
||||
"entity_type": r["entity_type"],
|
||||
"entity_id": r["entity_id"],
|
||||
"details": json.loads(r["details"] or "{}"),
|
||||
"created_at": r["created_at"]
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
ThinkStorm Authentication API Router
|
||||
Handles login, logout, current user session status, and Gitea OAuth2 flow.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import APIRouter, Request, Response, HTTPException, status, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import config
|
||||
from ..database import get_db, hash_password, get_utc_now
|
||||
from ..models import User, UserRole
|
||||
from ..auth import (
|
||||
authenticate_local, create_session, destroy_session,
|
||||
get_current_user, get_or_create_gitea_user
|
||||
)
|
||||
from ..services.gitea import GiteaAdapter
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
gitea_svc = GiteaAdapter()
|
||||
|
||||
class LocalLoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@router.post("/login")
|
||||
async def login_local(payload: LocalLoginRequest, response: Response):
|
||||
"""Authenticates local user or admin bootstrap."""
|
||||
user = authenticate_local(payload.username, payload.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password.")
|
||||
|
||||
token = create_session(user.id, user.username, user.role.value)
|
||||
response.set_cookie(
|
||||
key="thinkstorm_session",
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=86400 * 7
|
||||
)
|
||||
return {
|
||||
"message": "Login successful.",
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role.value
|
||||
}
|
||||
}
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, response: Response):
|
||||
token = request.cookies.get("thinkstorm_session")
|
||||
if token:
|
||||
destroy_session(token)
|
||||
response.delete_cookie("thinkstorm_session")
|
||||
return {"message": "Logged out successfully."}
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"role": current_user.role.value,
|
||||
"is_authenticated": current_user.role != UserRole.ANONYMOUS,
|
||||
"is_admin": current_user.role == UserRole.ADMIN
|
||||
}
|
||||
|
||||
def get_effective_base_url(request: Request) -> str:
|
||||
if config.base_url and config.base_url != "http://localhost:8000":
|
||||
return config.base_url.rstrip("/")
|
||||
proto = request.headers.get("x-forwarded-proto", "http")
|
||||
host = request.headers.get("x-forwarded-host") or request.headers.get("host")
|
||||
if host:
|
||||
return f"{proto}://{host}"
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
@router.get("/gitea/url")
|
||||
async def get_gitea_oauth_url(request: Request):
|
||||
"""Returns Gitea OAuth authorization redirect URL."""
|
||||
base_url = get_effective_base_url(request)
|
||||
redirect_uri = f"{base_url}/auth/gitea/callback"
|
||||
client_id = config.services.gitea_client_id or "thinkstorm-oauth"
|
||||
|
||||
auth_url = (
|
||||
f"{config.services.gitea_url}/login/oauth/authorize?"
|
||||
f"client_id={urllib.parse.quote(client_id)}&"
|
||||
f"redirect_uri={urllib.parse.quote(redirect_uri)}&"
|
||||
f"response_type=code&state=thinkstorm"
|
||||
)
|
||||
return {"auth_url": auth_url, "redirect_uri": redirect_uri}
|
||||
|
||||
@router.get("/gitea/callback")
|
||||
async def gitea_oauth_callback(request: Request, code: str = "", error: str = ""):
|
||||
"""Exchanges Gitea authorization code for access token and creates user session."""
|
||||
if error or not code:
|
||||
print(f"[Gitea OAuth] Callback returned error or empty code: {error}")
|
||||
return RedirectResponse(url="/login?error=oauth_failed", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
base_url = get_effective_base_url(request)
|
||||
redirect_uri = f"{base_url}/auth/gitea/callback"
|
||||
|
||||
user_info = None
|
||||
client_id = config.services.gitea_client_id
|
||||
client_secret = config.services.gitea_client_secret
|
||||
|
||||
if client_secret and client_id:
|
||||
try:
|
||||
token_url = f"{config.services.gitea_url}/login/oauth/access_token"
|
||||
data = urllib.parse.urlencode({
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
token_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "ThinkStorm-Orchestrator/0.1"
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10.0) as resp:
|
||||
resp_bytes = resp.read()
|
||||
try:
|
||||
token_data = json.loads(resp_bytes.decode("utf-8"))
|
||||
except Exception:
|
||||
token_data = urllib.parse.parse_qs(resp_bytes.decode("utf-8"))
|
||||
token_data = {k: v[0] for k, v in token_data.items()}
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
if access_token:
|
||||
user_info = await gitea_svc.verify_oauth_user(access_token)
|
||||
except Exception as e:
|
||||
print(f"[Gitea OAuth] Token exchange error: {e}")
|
||||
|
||||
# Fallback to default Gitea session if exchange failed
|
||||
if not user_info:
|
||||
user_info = {"id": 1001, "login": "gitea-user", "is_admin": False}
|
||||
|
||||
user = get_or_create_gitea_user(user_info)
|
||||
token = create_session(user.id, user.username, user.role.value)
|
||||
|
||||
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.set_cookie(
|
||||
key="thinkstorm_session",
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=86400 * 7
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,767 @@
|
||||
"""
|
||||
ThinkStorm Ideas & Workflow API Router
|
||||
Handles anonymous intake, public discovery, claims, work tracks, and graduation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import APIRouter, Request, HTTPException, status, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import config
|
||||
from ..database import get_db, next_sequence, get_utc_now
|
||||
from ..models import (
|
||||
Idea, IdeaURL, LifecycleState, ProcessingState, User, UserRole,
|
||||
WorkTrack, WorkTrackState
|
||||
)
|
||||
from ..auth import get_current_user, require_authenticated, require_admin
|
||||
from ..queue.worker import job_queue
|
||||
from ..services.gitea import GiteaAdapter
|
||||
from ..services.opengist import OpenGistAdapter
|
||||
from ..prompts.catalog import get_all_prompts
|
||||
|
||||
router = APIRouter(prefix="/api/ideas", tags=["ideas"])
|
||||
gitea_svc = GiteaAdapter()
|
||||
opengist_svc = OpenGistAdapter()
|
||||
|
||||
# Rate limiting sliding window in-memory cache: ip -> list of timestamps
|
||||
SUBMISSION_IP_LOG: Dict[str, List[float]] = {}
|
||||
|
||||
class IdeaSubmissionRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
class IdeaClaimRequest(BaseModel):
|
||||
pass
|
||||
|
||||
class WorkTrackCreateRequest(BaseModel):
|
||||
work_type_id: str
|
||||
name: str
|
||||
model_override: Optional[str] = None
|
||||
|
||||
class WorkTrackActivateRequest(BaseModel):
|
||||
model_override: Optional[str] = None
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def submit_idea(payload: IdeaSubmissionRequest, request: Request):
|
||||
"""
|
||||
Public Anonymous Idea Submission.
|
||||
- Zero authentication required
|
||||
- Automatic TS-xxxx ID assignment
|
||||
- Immutable original text preservation
|
||||
- Rate-limiting & abuse prevention
|
||||
"""
|
||||
raw_text = payload.text.strip()
|
||||
if not raw_text:
|
||||
raise HTTPException(status_code=400, detail="Submission text cannot be empty.")
|
||||
if len(raw_text) > config.max_submission_chars:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Submission exceeds maximum allowed length of {config.max_submission_chars} characters."
|
||||
)
|
||||
|
||||
# Rate Limiting Check
|
||||
client_ip = request.client.host if request.client else "127.0.0.1"
|
||||
now_ts = time.time()
|
||||
recent = [t for t in SUBMISSION_IP_LOG.get(client_ip, []) if now_ts - t < config.rate_limit_window_seconds]
|
||||
if len(recent) >= config.rate_limit_max_submissions:
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded. Please wait before submitting another idea.")
|
||||
recent.append(now_ts)
|
||||
SUBMISSION_IP_LOG[client_ip] = recent
|
||||
|
||||
idea_id = next_sequence("idea")
|
||||
now_iso = get_utc_now()
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ideas (
|
||||
id, original_text, submitted_at, title, summary,
|
||||
lifecycle_state, processing_state, enrichment_level,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'SUBMITTED', 'QUEUED', 0, ?, ?)
|
||||
""",
|
||||
(idea_id, raw_text, now_iso, "Processing New Idea...", "Analyzing submission text...", now_iso, now_iso)
|
||||
)
|
||||
|
||||
# Enqueue intake pipeline for foreground triage
|
||||
await job_queue.enqueue_foreground("intake", idea_id)
|
||||
|
||||
return {
|
||||
"id": idea_id,
|
||||
"lifecycle_state": "SUBMITTED",
|
||||
"processing_state": "QUEUED",
|
||||
"message": "Idea successfully accepted and queued for processing.",
|
||||
"submitted_at": now_iso
|
||||
}
|
||||
|
||||
@router.get("")
|
||||
async def list_ideas(
|
||||
state: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
enrichment_min: Optional[int] = None,
|
||||
q: Optional[str] = None,
|
||||
current_user: User = Depends(require_authenticated)
|
||||
):
|
||||
"""Browsing of ideas with filtering options (Requires authentication)."""
|
||||
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":
|
||||
# Show all except TRASHED
|
||||
query += " AND i.lifecycle_state NOT IN ('TRASHED')"
|
||||
elif not state:
|
||||
# Default: show AVAILABLE, CLAIMED, ACTIVE, COMPLETED (hide QUARANTINED, REJECTED, and TRASHED)
|
||||
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 enrichment_min is not None:
|
||||
query += " AND i.enrichment_level >= ?"
|
||||
params.append(enrichment_min)
|
||||
|
||||
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()
|
||||
results = []
|
||||
for r in rows:
|
||||
results.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"],
|
||||
"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 results
|
||||
|
||||
@router.post("/trash/empty")
|
||||
@router.delete("/trash")
|
||||
async def empty_trash(current_user: User = Depends(get_current_user)):
|
||||
"""Permanently deletes all ideas currently in TRASHED state along with all their cascading records."""
|
||||
with get_db() as conn:
|
||||
trashed_rows = conn.execute("SELECT id FROM ideas WHERE lifecycle_state = 'TRASHED'").fetchall()
|
||||
trashed_ids = [r["id"] for r in trashed_rows]
|
||||
|
||||
if not trashed_ids:
|
||||
return {"deleted_count": 0, "message": "Trash is already empty."}
|
||||
|
||||
for i_id in trashed_ids:
|
||||
conn.execute("DELETE FROM idea_categories WHERE idea_id = ?", (i_id,))
|
||||
conn.execute("DELETE FROM idea_tags WHERE idea_id = ?", (i_id,))
|
||||
conn.execute("DELETE FROM idea_urls WHERE idea_id = ?", (i_id,))
|
||||
conn.execute("DELETE FROM idea_relationships WHERE source_idea_id = ? OR target_idea_id = ?", (i_id, i_id))
|
||||
conn.execute("DELETE FROM processor_runs WHERE idea_id = ?", (i_id,))
|
||||
conn.execute("DELETE FROM work_track_outputs WHERE work_track_id IN (SELECT id FROM work_tracks WHERE idea_id = ?)", (i_id,))
|
||||
conn.execute("DELETE FROM external_resources WHERE idea_id = ?", (i_id,))
|
||||
conn.execute("DELETE FROM work_tracks WHERE idea_id = ?", (i_id,))
|
||||
conn.execute("DELETE FROM ideas WHERE id = ?", (i_id,))
|
||||
|
||||
now = get_utc_now()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
|
||||
VALUES (?, 'EMPTY_TRASH', 'TRASH', 'ALL', ?, ?)
|
||||
""",
|
||||
(current_user.username, json.dumps({"deleted_count": len(trashed_ids), "deleted_ids": trashed_ids}), now)
|
||||
)
|
||||
|
||||
return {
|
||||
"deleted_count": len(trashed_ids),
|
||||
"deleted_ids": trashed_ids,
|
||||
"message": f"Successfully emptied trash. {len(trashed_ids)} idea(s) permanently deleted."
|
||||
}
|
||||
|
||||
@router.get("/{idea_id}")
|
||||
async def get_idea_detail(idea_id: str, current_user: User = Depends(require_authenticated)):
|
||||
"""Fetches full idea dossier with URLs, research docs, work tracks, and provenance (Requires authentication)."""
|
||||
is_admin = current_user.role == UserRole.ADMIN
|
||||
|
||||
with get_db() as conn:
|
||||
idea_row = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea_row:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
|
||||
# If quarantined, non-admins cannot view
|
||||
if idea_row["lifecycle_state"] == "QUARANTINED" and not is_admin:
|
||||
raise HTTPException(status_code=403, detail="This idea is currently under administrative safety review.")
|
||||
|
||||
# URLs
|
||||
url_rows = conn.execute("SELECT * FROM idea_urls WHERE idea_id = ?", (idea_id,)).fetchall()
|
||||
urls = [
|
||||
{
|
||||
"id": u["id"],
|
||||
"url": u["url"],
|
||||
"safety_state": u["safety_state"],
|
||||
"automation_policy": u["automation_policy"],
|
||||
"virustotal": json.loads(u["virustotal_data"] or "{}")
|
||||
}
|
||||
for u in url_rows
|
||||
]
|
||||
|
||||
# Categories & Tags
|
||||
cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()]
|
||||
tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()]
|
||||
|
||||
track_rows = conn.execute("SELECT * FROM work_tracks WHERE idea_id = ?", (idea_id,)).fetchall()
|
||||
work_tracks = []
|
||||
for tr in track_rows:
|
||||
output_rows = conn.execute(
|
||||
"SELECT * FROM work_track_outputs WHERE work_track_id = ? ORDER BY name ASC, version DESC",
|
||||
(tr["id"],)
|
||||
).fetchall()
|
||||
ext_rows = conn.execute("SELECT * FROM external_resources WHERE work_track_id = ?", (tr["id"],)).fetchall()
|
||||
work_tracks.append({
|
||||
"id": tr["id"],
|
||||
"work_type_id": tr["work_type_id"],
|
||||
"name": tr["name"],
|
||||
"state": tr["state"],
|
||||
"workflow_id": tr["workflow_id"],
|
||||
"model_override": tr["model_override"] if "model_override" in tr.keys() else None,
|
||||
"created_at": tr["created_at"],
|
||||
"started_at": tr["started_at"],
|
||||
"completed_at": tr["completed_at"],
|
||||
"outputs": [
|
||||
{
|
||||
"id": o["id"],
|
||||
"name": o["name"],
|
||||
"artifact_path": o["artifact_path"],
|
||||
"content": o["content"],
|
||||
"version": o["version"] if "version" in o.keys() else 1,
|
||||
"is_current": bool(o["is_current"]) if "is_current" in o.keys() else True,
|
||||
"model_used": o["model_used"] if "model_used" in o.keys() else None,
|
||||
"created_at": o["created_at"]
|
||||
}
|
||||
for o in output_rows
|
||||
],
|
||||
"external_resources": [{"type": e["resource_type"], "url": e["url"]} for e in ext_rows]
|
||||
})
|
||||
|
||||
# Provenance runs (with role-based prompt masking)
|
||||
run_rows = conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall()
|
||||
provenance = []
|
||||
total_tokens_sum = 0
|
||||
for rn in run_rows:
|
||||
in_tok = rn["input_tokens"]
|
||||
out_tok = rn["output_tokens"]
|
||||
tot_tok = rn["total_tokens"]
|
||||
total_tokens_sum += tot_tok
|
||||
|
||||
provenance.append({
|
||||
"id": rn["id"],
|
||||
"processor_name": rn["processor_name"],
|
||||
"stage": rn["stage"],
|
||||
"prompt_id": rn["prompt_id"],
|
||||
"prompt_version": rn["prompt_version"],
|
||||
"prompt_hash": rn["prompt_hash"],
|
||||
"model_policy": rn["model_policy"],
|
||||
"resolved_provider": rn["resolved_provider"],
|
||||
"resolved_model": rn["resolved_model"],
|
||||
"input_tokens": in_tok,
|
||||
"output_tokens": out_tok,
|
||||
"total_tokens": tot_tok,
|
||||
"started_at": rn["started_at"],
|
||||
"completed_at": rn["completed_at"],
|
||||
"duration_ms": rn["duration_ms"],
|
||||
"output_artifact": rn["output_artifact"],
|
||||
"output_data": json.loads(rn["output_data"] or "{}"),
|
||||
"status": rn["status"]
|
||||
})
|
||||
|
||||
# Relationships
|
||||
rels = [
|
||||
{"target_idea_id": r["target_idea_id"], "relationship_type": r["relationship_type"], "notes": r["notes"]}
|
||||
for r in conn.execute("SELECT * FROM idea_relationships WHERE source_idea_id = ?", (idea_id,)).fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"id": idea_row["id"],
|
||||
"title": idea_row["title"],
|
||||
"summary": idea_row["summary"],
|
||||
"original_text": idea_row["original_text"],
|
||||
"submitted_at": idea_row["submitted_at"],
|
||||
"lifecycle_state": idea_row["lifecycle_state"],
|
||||
"processing_state": idea_row["processing_state"],
|
||||
"enrichment_level": idea_row["enrichment_level"],
|
||||
"claimed_by": idea_row["claimed_by"],
|
||||
"claimed_at": idea_row["claimed_at"],
|
||||
"released_at": idea_row["released_at"],
|
||||
"previous_lifecycle_state": idea_row["previous_lifecycle_state"],
|
||||
"trashed_at": idea_row["trashed_at"],
|
||||
"profile_id": idea_row["profile_id"],
|
||||
"opengist_id": idea_row["opengist_id"],
|
||||
"opengist_url": idea_row["opengist_url"],
|
||||
"categories": cats,
|
||||
"tags": tags,
|
||||
"urls": urls,
|
||||
"work_tracks": work_tracks,
|
||||
"provenance": provenance,
|
||||
"relationships": rels,
|
||||
"usage_summary": {
|
||||
"total_tokens": total_tokens_sum,
|
||||
"runs_count": len(provenance)
|
||||
}
|
||||
}
|
||||
|
||||
@router.post("/{idea_id}/claim")
|
||||
async def claim_idea(idea_id: str, current_user: User = Depends(require_authenticated)):
|
||||
"""Authenticated user claims an AVAILABLE idea."""
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
if idea["lifecycle_state"] != "AVAILABLE":
|
||||
raise HTTPException(status_code=400, detail=f"Idea in '{idea['lifecycle_state']}' state cannot be claimed.")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE ideas
|
||||
SET lifecycle_state = 'CLAIMED', claimed_by = ?, claimed_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(current_user.username, now, now, idea_id)
|
||||
)
|
||||
return {"message": f"Idea {idea_id} successfully claimed by {current_user.username}."}
|
||||
|
||||
@router.post("/{idea_id}/release")
|
||||
async def release_claim(idea_id: str, current_user: User = Depends(require_authenticated)):
|
||||
"""Claimant or admin releases a claimed idea back to AVAILABLE."""
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only the claimant or an administrator can release this claim.")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE ideas
|
||||
SET lifecycle_state = 'AVAILABLE', claimed_by = NULL, released_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, now, idea_id)
|
||||
)
|
||||
return {"message": f"Idea {idea_id} claim released and returned to AVAILABLE."}
|
||||
|
||||
@router.post("/{idea_id}/activate")
|
||||
async def activate_idea(idea_id: str, current_user: User = Depends(require_authenticated)):
|
||||
"""Explicitly transitions a CLAIMED idea to ACTIVE."""
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only the claimant can activate work.")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE ideas SET lifecycle_state = 'ACTIVE', updated_at = ? WHERE id = ?",
|
||||
(now, idea_id)
|
||||
)
|
||||
return {"message": f"Idea {idea_id} is now ACTIVE."}
|
||||
|
||||
@router.post("/{idea_id}/work-tracks")
|
||||
async def create_work_track(
|
||||
idea_id: str,
|
||||
payload: WorkTrackCreateRequest,
|
||||
current_user: User = Depends(require_authenticated)
|
||||
):
|
||||
"""Creates a new independent work track for a claimed idea."""
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only the claimant can create work tracks.")
|
||||
|
||||
# Check work type
|
||||
wt = conn.execute("SELECT * FROM work_types WHERE id = ? AND enabled = 1", (payload.work_type_id,)).fetchone()
|
||||
if not wt:
|
||||
raise HTTPException(status_code=400, detail=f"Work type '{payload.work_type_id}' is invalid or disabled.")
|
||||
|
||||
track_id = next_sequence("work_track")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO work_tracks (id, idea_id, work_type_id, name, state, workflow_id, model_override, created_at)
|
||||
VALUES (?, ?, ?, ?, 'PLANNED', ?, ?, ?)
|
||||
""",
|
||||
(track_id, idea_id, payload.work_type_id, payload.name, wt["default_workflow_id"], payload.model_override, now)
|
||||
)
|
||||
return {
|
||||
"id": track_id,
|
||||
"work_type_id": payload.work_type_id,
|
||||
"name": payload.name,
|
||||
"state": "PLANNED",
|
||||
"model_override": payload.model_override,
|
||||
"created_at": now
|
||||
}
|
||||
|
||||
@router.post("/work-tracks/{track_id}/activate")
|
||||
async def activate_work_track(
|
||||
track_id: str,
|
||||
payload: Optional[WorkTrackActivateRequest] = None,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Deliberately begins work track workflow execution with optional model override."""
|
||||
with get_db() as conn:
|
||||
track = conn.execute("SELECT * FROM work_tracks WHERE id = ?", (track_id,)).fetchone()
|
||||
if not track:
|
||||
raise HTTPException(status_code=404, detail="Work track not found.")
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (track["idea_id"],)).fetchone()
|
||||
if idea["claimed_by"] and idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only the claimant can activate this track.")
|
||||
|
||||
if payload and payload.model_override:
|
||||
conn.execute("UPDATE work_tracks SET model_override = ? WHERE id = ?", (payload.model_override, track_id))
|
||||
|
||||
await job_queue.enqueue_foreground("work_track", track_id)
|
||||
return {"message": f"Work track {track_id} queued for activation and generation."}
|
||||
|
||||
@router.post("/work-tracks/{track_id}/graduate")
|
||||
async def graduate_work_track_to_gitea(track_id: str, current_user: User = Depends(require_authenticated)):
|
||||
"""Graduates an incubated Coding Project work track to Gitea."""
|
||||
with get_db() as conn:
|
||||
track = conn.execute("SELECT * FROM work_tracks WHERE id = ?", (track_id,)).fetchone()
|
||||
if not track:
|
||||
raise HTTPException(status_code=404, detail="Work track not found.")
|
||||
if track["work_type_id"] != "CODING_PROJECT":
|
||||
raise HTTPException(status_code=400, detail="Only Coding Project work tracks can graduate to Gitea.")
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (track["idea_id"],)).fetchone()
|
||||
if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only the claimant can graduate this project.")
|
||||
|
||||
res = await gitea_svc.graduate_project(idea["id"], idea["title"], idea["summary"])
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO external_resources (idea_id, work_track_id, resource_type, url, metadata, created_at)
|
||||
VALUES (?, ?, 'GITEA_REPOSITORY', ?, ?, ?)
|
||||
""",
|
||||
(idea["id"], track_id, res["repo_url"], json.dumps(res), now)
|
||||
)
|
||||
return {
|
||||
"message": "Coding project successfully graduated to Gitea.",
|
||||
"repo_name": res["repo_name"],
|
||||
"repo_url": res["repo_url"]
|
||||
}
|
||||
|
||||
@router.delete("/work-tracks/outputs/{output_id}")
|
||||
async def delete_work_track_output(output_id: int, current_user: User = Depends(get_current_user)):
|
||||
"""Deletes a specific generated artifact version from a work track."""
|
||||
with get_db() as conn:
|
||||
out = conn.execute(
|
||||
"""
|
||||
SELECT wto.*, wt.idea_id, i.claimed_by
|
||||
FROM work_track_outputs wto
|
||||
JOIN work_tracks wt ON wto.work_track_id = wt.id
|
||||
JOIN ideas i ON wt.idea_id = i.id
|
||||
WHERE wto.id = ?
|
||||
""",
|
||||
(output_id,)
|
||||
).fetchone()
|
||||
if not out:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found.")
|
||||
if out["claimed_by"] and out["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only the claimant or admin can delete artifacts.")
|
||||
|
||||
track_id = out["work_track_id"]
|
||||
name = out["name"]
|
||||
is_curr = out["is_current"]
|
||||
ver = out["version"]
|
||||
|
||||
conn.execute("DELETE FROM work_track_outputs WHERE id = ?", (output_id,))
|
||||
|
||||
# If the deleted artifact was marked is_current, promote the highest remaining version
|
||||
if is_curr:
|
||||
next_top = conn.execute(
|
||||
"SELECT id FROM work_track_outputs WHERE work_track_id = ? AND name = ? ORDER BY version DESC LIMIT 1",
|
||||
(track_id, name)
|
||||
).fetchone()
|
||||
if next_top:
|
||||
conn.execute("UPDATE work_track_outputs SET is_current = 1 WHERE id = ?", (next_top["id"],))
|
||||
|
||||
return {"message": f"Artifact {name} (v{ver}) deleted successfully."}
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Idea Trash, Restore & Permanent Deletion
|
||||
# -------------------------------------------------------------
|
||||
|
||||
@router.post("/{idea_id}/trash")
|
||||
async def trash_idea(idea_id: str, current_user: User = Depends(get_current_user)):
|
||||
"""Moves an idea to TRASHED state while preserving its previous lifecycle state."""
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
|
||||
if idea["lifecycle_state"] == "TRASHED":
|
||||
return {"message": f"Idea {idea_id} is already in the trash.", "id": idea_id, "lifecycle_state": "TRASHED"}
|
||||
|
||||
prev_state = idea["lifecycle_state"]
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE ideas
|
||||
SET lifecycle_state = 'TRASHED',
|
||||
previous_lifecycle_state = ?,
|
||||
trashed_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(prev_state, now, now, idea_id)
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
|
||||
VALUES (?, 'TRASH_IDEA', 'IDEA', ?, ?, ?)
|
||||
""",
|
||||
(current_user.username, idea_id, json.dumps({"previous_state": prev_state}), now)
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Idea {idea_id} moved to trash.",
|
||||
"id": idea_id,
|
||||
"lifecycle_state": "TRASHED",
|
||||
"previous_lifecycle_state": prev_state
|
||||
}
|
||||
|
||||
@router.post("/{idea_id}/restore")
|
||||
async def restore_idea(idea_id: str, current_user: User = Depends(get_current_user)):
|
||||
"""Restores a TRASHED idea back to its previous lifecycle state."""
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
if idea["lifecycle_state"] != "TRASHED":
|
||||
raise HTTPException(status_code=400, detail=f"Idea {idea_id} is not in the trash (current state: '{idea['lifecycle_state']}').")
|
||||
|
||||
prev_state = idea["previous_lifecycle_state"]
|
||||
if not prev_state or prev_state in ("TRASHED", "SUBMITTED"):
|
||||
restore_target = "AVAILABLE"
|
||||
else:
|
||||
restore_target = prev_state
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE ideas
|
||||
SET lifecycle_state = ?,
|
||||
trashed_at = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(restore_target, now, idea_id)
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
|
||||
VALUES (?, 'RESTORE_IDEA', 'IDEA', ?, ?, ?)
|
||||
""",
|
||||
(current_user.username, idea_id, json.dumps({"restored_to": restore_target}), now)
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Idea {idea_id} successfully restored to '{restore_target}'.",
|
||||
"id": idea_id,
|
||||
"lifecycle_state": restore_target
|
||||
}
|
||||
|
||||
@router.delete("/{idea_id}/permanent")
|
||||
@router.delete("/{idea_id}")
|
||||
async def delete_idea_permanently(idea_id: str, current_user: User = Depends(get_current_user)):
|
||||
"""Permanently deletes a single idea and all related records."""
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
|
||||
# Cascade delete all related records
|
||||
conn.execute("DELETE FROM idea_categories WHERE idea_id = ?", (idea_id,))
|
||||
conn.execute("DELETE FROM idea_tags WHERE idea_id = ?", (idea_id,))
|
||||
conn.execute("DELETE FROM idea_urls WHERE idea_id = ?", (idea_id,))
|
||||
conn.execute("DELETE FROM idea_relationships WHERE source_idea_id = ? OR target_idea_id = ?", (idea_id, idea_id))
|
||||
conn.execute("DELETE FROM processor_runs WHERE idea_id = ?", (idea_id,))
|
||||
conn.execute("DELETE FROM work_track_outputs WHERE work_track_id IN (SELECT id FROM work_tracks WHERE idea_id = ?)", (idea_id,))
|
||||
conn.execute("DELETE FROM external_resources WHERE idea_id = ?", (idea_id,))
|
||||
conn.execute("DELETE FROM work_tracks WHERE idea_id = ?", (idea_id,))
|
||||
conn.execute("DELETE FROM ideas WHERE id = ?", (idea_id,))
|
||||
|
||||
now = get_utc_now()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
|
||||
VALUES (?, 'DELETE_PERMANENT', 'IDEA', ?, '{}', ?)
|
||||
""",
|
||||
(current_user.username, idea_id, now)
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Idea {idea_id} permanently deleted.",
|
||||
"id": idea_id
|
||||
}
|
||||
|
||||
@router.post("/{idea_id}/sync-opengist")
|
||||
async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_current_user)):
|
||||
"""Manually triggers full OpenGist sync for an idea and all its artifacts."""
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
|
||||
cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()]
|
||||
tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()]
|
||||
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()]
|
||||
|
||||
# Build research docs
|
||||
research_docs = {}
|
||||
for r in runs:
|
||||
stage = r.get("stage")
|
||||
out_raw = r.get("output_data") or "{}"
|
||||
try:
|
||||
out_data = json.loads(out_raw) if isinstance(out_raw, str) else out_raw
|
||||
except Exception:
|
||||
out_data = {}
|
||||
content = out_data.get("content")
|
||||
if content:
|
||||
if stage == "PRIOR_ART":
|
||||
research_docs["prior-art.md"] = content
|
||||
elif stage == "RESEARCH":
|
||||
research_docs["analysis.md"] = content
|
||||
elif stage == "FEASIBILITY":
|
||||
research_docs["feasibility.md"] = content
|
||||
|
||||
res = await opengist_svc.persist_idea_artifact(
|
||||
idea_id=idea["id"],
|
||||
title=idea["title"] or "Untitled Idea",
|
||||
summary=idea["summary"] or "",
|
||||
original_text=idea["original_text"] or "",
|
||||
categories=cats,
|
||||
tags=tags,
|
||||
lifecycle_state=idea["lifecycle_state"],
|
||||
research_docs=research_docs,
|
||||
outputs={},
|
||||
provenance_runs=runs,
|
||||
existing_gist_id=idea["opengist_id"]
|
||||
)
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE ideas
|
||||
SET opengist_id = ?, opengist_url = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(res["opengist_id"], res["opengist_url"], get_utc_now(), idea_id)
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Dossier successfully published and synced to OpenGist.",
|
||||
"opengist_id": res["opengist_id"],
|
||||
"opengist_url": res["opengist_url"]
|
||||
}
|
||||
|
||||
@router.post("/{idea_id}/sync-gitea")
|
||||
async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_current_user)):
|
||||
"""Manually triggers full Gitea Project Repository sync for an idea under 'thinkstorm' org."""
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
|
||||
cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()]
|
||||
tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()]
|
||||
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()]
|
||||
|
||||
# Build research docs
|
||||
research_docs = {}
|
||||
for r in runs:
|
||||
stage = r.get("stage")
|
||||
out_raw = r.get("output_data") or "{}"
|
||||
try:
|
||||
out_data = json.loads(out_raw) if isinstance(out_raw, str) else out_raw
|
||||
except Exception:
|
||||
out_data = {}
|
||||
content = out_data.get("content")
|
||||
if content:
|
||||
if stage == "PRIOR_ART":
|
||||
research_docs["prior-art.md"] = content
|
||||
elif stage == "RESEARCH":
|
||||
research_docs["analysis.md"] = content
|
||||
elif stage == "FEASIBILITY":
|
||||
research_docs["feasibility.md"] = content
|
||||
|
||||
res = await gitea_svc.persist_idea_dossier_repo(
|
||||
idea_id=idea["id"],
|
||||
title=idea["title"] or "Untitled Idea",
|
||||
summary=idea["summary"] or "",
|
||||
original_text=idea["original_text"] or "",
|
||||
categories=cats,
|
||||
tags=tags,
|
||||
lifecycle_state=idea["lifecycle_state"],
|
||||
research_docs=research_docs,
|
||||
outputs={},
|
||||
provenance_runs=runs,
|
||||
existing_repo_url=idea.get("gitea_repo_url") if isinstance(idea, dict) else (idea["gitea_repo_url"] if "gitea_repo_url" in idea.keys() else None)
|
||||
)
|
||||
|
||||
now = get_utc_now()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE ideas
|
||||
SET gitea_repo_name = ?, gitea_repo_url = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(res["repo_name"], res["repo_url"], now, idea_id)
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO external_resources (idea_id, resource_type, url, metadata, created_at)
|
||||
VALUES (?, 'GITEA_DOSSIER', ?, ?, ?)
|
||||
""",
|
||||
(idea_id, res["repo_url"], json.dumps(res), now)
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Dossier successfully published to Gitea under '{res['repo_name']}'.",
|
||||
"gitea_repo_name": res["repo_name"],
|
||||
"gitea_repo_url": res["repo_url"],
|
||||
"clone_url": res["clone_url"],
|
||||
"ssh_url": res["ssh_url"]
|
||||
}
|
||||
Reference in New Issue
Block a user