Add admin configuration system and M3 content automation
Admin Configuration System: - Add registry_settings table to database schema - Create settings.py module with 17 configurable settings across 5 categories (vetting, similarity, sync, moderation, rate_limits) - Add admin API endpoints for settings management (list/get/set/reset) - Create admin settings web UI page with tabbed categories - Add CLI commands: cmdforge registry config list/get/set - Wire settings page link into admin index M3 Content Automation Scripts: - scrutiny.py: Quality scoring with 5 weighted checks - similarity.py: TF-IDF based duplicate detection - vet_pipeline.py: Combined vetting with auto-decision logic - fabric_sync.py: Scheduled Fabric pattern synchronization - fabric-sync.service/timer: systemd units for scheduled sync Settings available: - Vetting thresholds (approve/reject) and check weights - Similarity detection thresholds (duplicate/similar/related) - Fabric sync configuration (enabled, interval, auto-approve) - Moderation settings (require review, auto-approve private) - Rate limiting configuration Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -190,6 +190,16 @@ def main():
|
||||
p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)")
|
||||
p_reg_browse.set_defaults(func=cmd_registry)
|
||||
|
||||
# registry config (admin settings management)
|
||||
p_reg_config = registry_sub.add_parser("config", help="Manage registry settings (admin)")
|
||||
p_reg_config.add_argument("action", nargs="?", choices=["list", "get", "set"], default="list",
|
||||
help="Action to perform (default: list)")
|
||||
p_reg_config.add_argument("key", nargs="?", help="Setting key (for get/set)")
|
||||
p_reg_config.add_argument("value", nargs="?", help="Setting value (for set)")
|
||||
p_reg_config.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
p_reg_config.add_argument("--category", "-c", help="Filter by category (for list)")
|
||||
p_reg_config.set_defaults(func=cmd_registry)
|
||||
|
||||
# Default for registry with no subcommand
|
||||
p_registry.set_defaults(func=lambda args: cmd_registry(args) if args.registry_cmd else (setattr(args, 'registry_cmd', None) or cmd_registry(args)))
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ def cmd_registry(args):
|
||||
return _cmd_registry_my_tools(args)
|
||||
elif args.registry_cmd == "browse":
|
||||
return _cmd_registry_browse(args)
|
||||
elif args.registry_cmd == "config":
|
||||
return _cmd_registry_config(args)
|
||||
else:
|
||||
# Default: show registry help
|
||||
print("Registry commands:")
|
||||
@@ -44,6 +46,7 @@ def cmd_registry(args):
|
||||
print(" publish [path] Publish a tool")
|
||||
print(" my-tools List your published tools")
|
||||
print(" browse Browse tools (TUI)")
|
||||
print(" config [action] Manage registry settings (admin)")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -505,3 +508,158 @@ def _cmd_registry_browse(args):
|
||||
from ..gui import run_gui
|
||||
# Launch GUI - it will open to Registry page
|
||||
return run_gui()
|
||||
|
||||
|
||||
def _cmd_registry_config(args):
|
||||
"""Manage registry settings (admin only)."""
|
||||
from ..registry_client import RegistryError, get_client
|
||||
|
||||
action = getattr(args, 'action', 'list')
|
||||
key = getattr(args, 'key', None)
|
||||
value = getattr(args, 'value', None)
|
||||
as_json = getattr(args, 'json', False)
|
||||
category = getattr(args, 'category', None)
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
|
||||
if action == "list":
|
||||
return _config_list(client, as_json, category)
|
||||
elif action == "get":
|
||||
if not key:
|
||||
print("Error: key is required for 'get' action", file=sys.stderr)
|
||||
print("Usage: cmdforge registry config get <key>", file=sys.stderr)
|
||||
return 1
|
||||
return _config_get(client, key, as_json)
|
||||
elif action == "set":
|
||||
if not key or value is None:
|
||||
print("Error: key and value are required for 'set' action", file=sys.stderr)
|
||||
print("Usage: cmdforge registry config set <key> <value>", file=sys.stderr)
|
||||
return 1
|
||||
return _config_set(client, key, value)
|
||||
|
||||
except RegistryError as e:
|
||||
if e.code == "UNAUTHORIZED":
|
||||
print("Authentication failed.", file=sys.stderr)
|
||||
print("This command requires admin privileges.", file=sys.stderr)
|
||||
print("Set your admin token with: cmdforge config set-token <token>", file=sys.stderr)
|
||||
elif e.code == "FORBIDDEN":
|
||||
print("Access denied. Admin privileges required.", file=sys.stderr)
|
||||
elif e.code == "CONNECTION_ERROR":
|
||||
print("Could not connect to the registry.", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error: {e.message}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _config_list(client, as_json, category=None):
|
||||
"""List all settings."""
|
||||
# Use the admin settings endpoint
|
||||
response = client._request("GET", "/admin/settings")
|
||||
settings = response.get("settings", [])
|
||||
|
||||
# Filter by category if specified
|
||||
if category:
|
||||
settings = [s for s in settings if s.get("category") == category]
|
||||
|
||||
if as_json:
|
||||
print(json.dumps({"settings": settings}, indent=2))
|
||||
return 0
|
||||
|
||||
if not settings:
|
||||
print("No settings found.")
|
||||
return 0
|
||||
|
||||
# Group by category
|
||||
by_category = {}
|
||||
for s in settings:
|
||||
cat = s.get("category", "general")
|
||||
if cat not in by_category:
|
||||
by_category[cat] = []
|
||||
by_category[cat].append(s)
|
||||
|
||||
print("Registry Settings")
|
||||
print("=" * 60)
|
||||
|
||||
for cat, cat_settings in sorted(by_category.items()):
|
||||
print(f"\n[{cat.upper()}]")
|
||||
for s in cat_settings:
|
||||
key = s.get("key", "")
|
||||
value = s.get("value")
|
||||
value_type = s.get("value_type", "string")
|
||||
desc = s.get("description", "")
|
||||
is_default = s.get("is_default", True)
|
||||
|
||||
# Format value display
|
||||
if value_type == "bool":
|
||||
value_str = "true" if value else "false"
|
||||
else:
|
||||
value_str = str(value)
|
||||
|
||||
status = "" if is_default else " (modified)"
|
||||
print(f" {key}")
|
||||
print(f" Value: {value_str}{status}")
|
||||
if desc:
|
||||
print(f" {desc}")
|
||||
|
||||
print()
|
||||
print("Use 'cmdforge registry config get <key>' to see a setting's value")
|
||||
print("Use 'cmdforge registry config set <key> <value>' to change a setting")
|
||||
return 0
|
||||
|
||||
|
||||
def _config_get(client, key, as_json):
|
||||
"""Get a specific setting."""
|
||||
response = client._request("GET", f"/admin/settings/{key}")
|
||||
|
||||
if as_json:
|
||||
print(json.dumps(response, indent=2))
|
||||
return 0
|
||||
|
||||
setting = response.get("setting", {})
|
||||
print(f"Key: {setting.get('key', key)}")
|
||||
print(f"Value: {setting.get('value')}")
|
||||
print(f"Type: {setting.get('value_type', 'string')}")
|
||||
print(f"Category: {setting.get('category', 'general')}")
|
||||
if setting.get('description'):
|
||||
print(f"Description: {setting['description']}")
|
||||
if setting.get('updated_at'):
|
||||
print(f"Last updated: {setting['updated_at'][:19]} by {setting.get('updated_by', 'system')}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _config_set(client, key, value):
|
||||
"""Set a setting value."""
|
||||
# Try to parse value as appropriate type
|
||||
parsed_value = value
|
||||
|
||||
# Try to parse as bool
|
||||
if value.lower() in ("true", "false"):
|
||||
parsed_value = value.lower() == "true"
|
||||
# Try to parse as number
|
||||
else:
|
||||
try:
|
||||
if "." in value:
|
||||
parsed_value = float(value)
|
||||
else:
|
||||
parsed_value = int(value)
|
||||
except ValueError:
|
||||
# Keep as string
|
||||
pass
|
||||
|
||||
response = client._request("PUT", f"/admin/settings/{key}", json={"value": parsed_value})
|
||||
|
||||
if response.get("success"):
|
||||
print(f"Setting '{key}' updated successfully.")
|
||||
print(f"New value: {response.get('setting', {}).get('value', parsed_value)}")
|
||||
else:
|
||||
print(f"Failed to update setting: {response.get('error', 'Unknown error')}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
@@ -2932,6 +2932,163 @@ def create_app() -> Flask:
|
||||
"meta": paginate(page, per_page, total),
|
||||
})
|
||||
|
||||
# ─── Admin Settings API ───────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/admin/settings", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_list_settings() -> Response:
|
||||
"""List all configurable settings with current values."""
|
||||
from .settings import get_all_settings
|
||||
|
||||
category = request.args.get("category")
|
||||
settings = get_all_settings(g.db)
|
||||
|
||||
if category:
|
||||
settings = [s for s in settings if s["category"] == category]
|
||||
|
||||
# Group by category
|
||||
categories = {}
|
||||
for s in settings:
|
||||
cat = s["category"]
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append(s)
|
||||
|
||||
return jsonify({
|
||||
"data": settings,
|
||||
"categories": categories,
|
||||
"available_categories": list(categories.keys()),
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/settings/<path:key>", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_setting(key: str) -> Response:
|
||||
"""Get a single setting value."""
|
||||
from .settings import get_setting, DEFAULT_SETTINGS
|
||||
|
||||
value = get_setting(g.db, key)
|
||||
if value is None:
|
||||
return error_response("NOT_FOUND", f"Setting '{key}' not found", 404)
|
||||
|
||||
# Find metadata
|
||||
setting_meta = None
|
||||
for s in DEFAULT_SETTINGS:
|
||||
if s.key == key:
|
||||
setting_meta = s
|
||||
break
|
||||
|
||||
return jsonify({
|
||||
"key": key,
|
||||
"value": value,
|
||||
"value_type": setting_meta.value_type if setting_meta else "string",
|
||||
"description": setting_meta.description if setting_meta else "",
|
||||
"category": setting_meta.category if setting_meta else "general",
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/settings/<path:key>", methods=["PUT"])
|
||||
@require_admin
|
||||
def admin_update_setting(key: str) -> Response:
|
||||
"""Update a setting value."""
|
||||
from .settings import set_setting, get_setting
|
||||
|
||||
data = request.get_json()
|
||||
if not data or "value" not in data:
|
||||
return error_response("VALIDATION_ERROR", "Missing 'value' in request body")
|
||||
|
||||
value = data["value"]
|
||||
success = set_setting(g.db, key, value, updated_by=g.user_slug)
|
||||
|
||||
if not success:
|
||||
return error_response(
|
||||
"VALIDATION_ERROR",
|
||||
f"Invalid setting key '{key}' or invalid value",
|
||||
400,
|
||||
)
|
||||
|
||||
# Log the change
|
||||
log_audit(
|
||||
"update_setting",
|
||||
"setting",
|
||||
key,
|
||||
{"old_value": get_setting(g.db, key), "new_value": value},
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"key": key,
|
||||
"value": get_setting(g.db, key),
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/settings/<path:key>", methods=["DELETE"])
|
||||
@require_admin
|
||||
def admin_reset_setting(key: str) -> Response:
|
||||
"""Reset a setting to its default value."""
|
||||
from .settings import reset_setting, get_setting, DEFAULT_SETTINGS
|
||||
|
||||
# Check if it's a valid setting
|
||||
valid = any(s.key == key for s in DEFAULT_SETTINGS)
|
||||
if not valid:
|
||||
return error_response("NOT_FOUND", f"Setting '{key}' not found", 404)
|
||||
|
||||
reset_setting(g.db, key)
|
||||
|
||||
log_audit("reset_setting", "setting", key, {})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"key": key,
|
||||
"value": get_setting(g.db, key),
|
||||
"message": "Setting reset to default",
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/settings/reset-all", methods=["POST"])
|
||||
@require_admin
|
||||
def admin_reset_all_settings() -> Response:
|
||||
"""Reset all settings to defaults."""
|
||||
from .settings import reset_all_settings
|
||||
|
||||
count = reset_all_settings(g.db)
|
||||
|
||||
log_audit("reset_all_settings", "settings", "all", {"count": count})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"reset_count": count,
|
||||
"message": f"Reset {count} settings to defaults",
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/settings/vetting", methods=["GET"])
|
||||
@require_moderator
|
||||
def admin_get_vetting_config() -> Response:
|
||||
"""Get current vetting configuration (for moderators)."""
|
||||
from .settings import get_vetting_config
|
||||
|
||||
return jsonify(get_vetting_config(g.db))
|
||||
|
||||
@app.route("/api/v1/admin/settings/similarity", methods=["GET"])
|
||||
@require_moderator
|
||||
def admin_get_similarity_config() -> Response:
|
||||
"""Get current similarity detection configuration."""
|
||||
from .settings import get_similarity_config
|
||||
|
||||
return jsonify(get_similarity_config(g.db))
|
||||
|
||||
@app.route("/api/v1/admin/settings/sync", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_sync_config() -> Response:
|
||||
"""Get Fabric sync configuration."""
|
||||
from .settings import get_sync_config
|
||||
|
||||
return jsonify(get_sync_config(g.db))
|
||||
|
||||
@app.route("/api/v1/admin/settings/moderation", methods=["GET"])
|
||||
@require_moderator
|
||||
def admin_get_moderation_config() -> Response:
|
||||
"""Get moderation configuration."""
|
||||
from .settings import get_moderation_config
|
||||
|
||||
return jsonify(get_moderation_config(g.db))
|
||||
|
||||
# ─── Reviews & Ratings API ────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/reviews", methods=["POST"])
|
||||
|
||||
@@ -383,6 +383,19 @@ CREATE TABLE IF NOT EXISTS tool_usage (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_tool ON tool_usage(tool_id);
|
||||
|
||||
-- Admin Configuration Settings
|
||||
CREATE TABLE IF NOT EXISTS registry_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
value_type TEXT DEFAULT 'string',
|
||||
description TEXT,
|
||||
category TEXT DEFAULT 'general',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settings_category ON registry_settings(category);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Registry settings management.
|
||||
|
||||
Provides configurable settings for vetting, moderation, and sync.
|
||||
Settings are stored in the database and can be modified via admin API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .db import query_one, query_all
|
||||
|
||||
|
||||
@dataclass
|
||||
class Setting:
|
||||
"""A single configuration setting."""
|
||||
key: str
|
||||
value: Any
|
||||
value_type: str # string, int, float, bool, json
|
||||
description: str
|
||||
category: str
|
||||
updated_at: Optional[str] = None
|
||||
updated_by: Optional[str] = None
|
||||
|
||||
|
||||
# Default settings with descriptions
|
||||
DEFAULT_SETTINGS: List[Setting] = [
|
||||
# Vetting thresholds
|
||||
Setting(
|
||||
key="vetting.approve_threshold",
|
||||
value=0.8,
|
||||
value_type="float",
|
||||
description="Quality score threshold for auto-approval (0.0-1.0)",
|
||||
category="vetting",
|
||||
),
|
||||
Setting(
|
||||
key="vetting.reject_threshold",
|
||||
value=0.3,
|
||||
value_type="float",
|
||||
description="Quality score threshold for auto-rejection (0.0-1.0)",
|
||||
category="vetting",
|
||||
),
|
||||
|
||||
# Vetting weights
|
||||
Setting(
|
||||
key="vetting.weight.honesty",
|
||||
value=0.25,
|
||||
value_type="float",
|
||||
description="Weight for honesty check (description matches behavior)",
|
||||
category="vetting",
|
||||
),
|
||||
Setting(
|
||||
key="vetting.weight.transparency",
|
||||
value=0.20,
|
||||
value_type="float",
|
||||
description="Weight for transparency check (no obfuscation)",
|
||||
category="vetting",
|
||||
),
|
||||
Setting(
|
||||
key="vetting.weight.scope",
|
||||
value=0.15,
|
||||
value_type="float",
|
||||
description="Weight for scope check (appropriate boundaries)",
|
||||
category="vetting",
|
||||
),
|
||||
Setting(
|
||||
key="vetting.weight.efficiency",
|
||||
value=0.15,
|
||||
value_type="float",
|
||||
description="Weight for efficiency check (prompt quality)",
|
||||
category="vetting",
|
||||
),
|
||||
Setting(
|
||||
key="vetting.weight.safety",
|
||||
value=0.25,
|
||||
value_type="float",
|
||||
description="Weight for safety check (no dangerous patterns)",
|
||||
category="vetting",
|
||||
),
|
||||
|
||||
# Similarity detection
|
||||
Setting(
|
||||
key="similarity.duplicate_threshold",
|
||||
value=0.9,
|
||||
value_type="float",
|
||||
description="Similarity score to consider tools as duplicates (0.0-1.0)",
|
||||
category="similarity",
|
||||
),
|
||||
Setting(
|
||||
key="similarity.similar_threshold",
|
||||
value=0.7,
|
||||
value_type="float",
|
||||
description="Similarity score to flag tools as similar (0.0-1.0)",
|
||||
category="similarity",
|
||||
),
|
||||
Setting(
|
||||
key="similarity.related_threshold",
|
||||
value=0.5,
|
||||
value_type="float",
|
||||
description="Similarity score to flag tools as related (0.0-1.0)",
|
||||
category="similarity",
|
||||
),
|
||||
|
||||
# Fabric sync
|
||||
Setting(
|
||||
key="sync.enabled",
|
||||
value=False,
|
||||
value_type="bool",
|
||||
description="Enable scheduled Fabric pattern sync",
|
||||
category="sync",
|
||||
),
|
||||
Setting(
|
||||
key="sync.interval_hours",
|
||||
value=24,
|
||||
value_type="int",
|
||||
description="Hours between Fabric sync runs",
|
||||
category="sync",
|
||||
),
|
||||
Setting(
|
||||
key="sync.auto_approve",
|
||||
value=False,
|
||||
value_type="bool",
|
||||
description="Auto-approve synced Fabric patterns (skip moderation)",
|
||||
category="sync",
|
||||
),
|
||||
Setting(
|
||||
key="sync.default_provider",
|
||||
value="claude",
|
||||
value_type="string",
|
||||
description="Default AI provider for synced patterns",
|
||||
category="sync",
|
||||
),
|
||||
|
||||
# Moderation
|
||||
Setting(
|
||||
key="moderation.require_review",
|
||||
value=True,
|
||||
value_type="bool",
|
||||
description="Require manual review for public tools",
|
||||
category="moderation",
|
||||
),
|
||||
Setting(
|
||||
key="moderation.auto_approve_private",
|
||||
value=True,
|
||||
value_type="bool",
|
||||
description="Auto-approve private/unlisted tools",
|
||||
category="moderation",
|
||||
),
|
||||
Setting(
|
||||
key="moderation.max_pending_per_user",
|
||||
value=10,
|
||||
value_type="int",
|
||||
description="Maximum pending tools per user before rate limiting",
|
||||
category="moderation",
|
||||
),
|
||||
|
||||
# Rate limits (can override defaults)
|
||||
Setting(
|
||||
key="rate_limit.publish.limit",
|
||||
value=20,
|
||||
value_type="int",
|
||||
description="Max publish requests per window",
|
||||
category="rate_limits",
|
||||
),
|
||||
Setting(
|
||||
key="rate_limit.publish.window",
|
||||
value=3600,
|
||||
value_type="int",
|
||||
description="Publish rate limit window in seconds",
|
||||
category="rate_limits",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _convert_value(value: str, value_type: str) -> Any:
|
||||
"""Convert stored string value to appropriate type."""
|
||||
if value_type == "int":
|
||||
return int(value)
|
||||
elif value_type == "float":
|
||||
return float(value)
|
||||
elif value_type == "bool":
|
||||
return value.lower() in ("true", "1", "yes")
|
||||
elif value_type == "json":
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_value(value: Any, value_type: str) -> str:
|
||||
"""Serialize value for storage."""
|
||||
if value_type == "bool":
|
||||
return "true" if value else "false"
|
||||
elif value_type == "json":
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_setting(conn: sqlite3.Connection, key: str) -> Optional[Any]:
|
||||
"""Get a single setting value.
|
||||
|
||||
Returns the default if not set in database.
|
||||
"""
|
||||
row = query_one(
|
||||
conn,
|
||||
"SELECT value, value_type FROM registry_settings WHERE key = ?",
|
||||
[key],
|
||||
)
|
||||
|
||||
if row:
|
||||
return _convert_value(row["value"], row["value_type"])
|
||||
|
||||
# Return default if exists
|
||||
for setting in DEFAULT_SETTINGS:
|
||||
if setting.key == key:
|
||||
return setting.value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_settings_by_category(conn: sqlite3.Connection, category: str) -> Dict[str, Any]:
|
||||
"""Get all settings for a category as a dict."""
|
||||
result = {}
|
||||
|
||||
# Start with defaults
|
||||
for setting in DEFAULT_SETTINGS:
|
||||
if setting.category == category:
|
||||
result[setting.key] = setting.value
|
||||
|
||||
# Override with DB values
|
||||
rows = query_all(
|
||||
conn,
|
||||
"SELECT key, value, value_type FROM registry_settings WHERE category = ?",
|
||||
[category],
|
||||
)
|
||||
for row in rows:
|
||||
result[row["key"]] = _convert_value(row["value"], row["value_type"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_all_settings(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
|
||||
"""Get all settings with their current values and metadata."""
|
||||
result = []
|
||||
|
||||
# Get all DB settings
|
||||
db_settings = {}
|
||||
rows = query_all(conn, "SELECT * FROM registry_settings")
|
||||
for row in rows:
|
||||
db_settings[row["key"]] = dict(row)
|
||||
|
||||
# Build combined list
|
||||
for default in DEFAULT_SETTINGS:
|
||||
if default.key in db_settings:
|
||||
row = db_settings[default.key]
|
||||
result.append({
|
||||
"key": default.key,
|
||||
"value": _convert_value(row["value"], row["value_type"]),
|
||||
"value_type": row["value_type"],
|
||||
"description": default.description,
|
||||
"category": default.category,
|
||||
"updated_at": row["updated_at"],
|
||||
"updated_by": row["updated_by"],
|
||||
"is_default": False,
|
||||
})
|
||||
else:
|
||||
result.append({
|
||||
"key": default.key,
|
||||
"value": default.value,
|
||||
"value_type": default.value_type,
|
||||
"description": default.description,
|
||||
"category": default.category,
|
||||
"updated_at": None,
|
||||
"updated_by": None,
|
||||
"is_default": True,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def set_setting(
|
||||
conn: sqlite3.Connection,
|
||||
key: str,
|
||||
value: Any,
|
||||
updated_by: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Set a setting value.
|
||||
|
||||
Returns True if successful, False if key is not a valid setting.
|
||||
"""
|
||||
# Find the setting definition
|
||||
setting_def = None
|
||||
for s in DEFAULT_SETTINGS:
|
||||
if s.key == key:
|
||||
setting_def = s
|
||||
break
|
||||
|
||||
if not setting_def:
|
||||
return False
|
||||
|
||||
# Validate and convert value
|
||||
try:
|
||||
if setting_def.value_type == "int":
|
||||
value = int(value)
|
||||
elif setting_def.value_type == "float":
|
||||
value = float(value)
|
||||
# Validate range for thresholds
|
||||
if "threshold" in key or "weight" in key:
|
||||
if not 0.0 <= value <= 1.0:
|
||||
return False
|
||||
elif setting_def.value_type == "bool":
|
||||
if isinstance(value, str):
|
||||
value = value.lower() in ("true", "1", "yes")
|
||||
else:
|
||||
value = bool(value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
serialized = _serialize_value(value, setting_def.value_type)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO registry_settings (key, value, value_type, description, category, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
""",
|
||||
[key, serialized, setting_def.value_type, setting_def.description,
|
||||
setting_def.category, now, updated_by],
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
def reset_setting(conn: sqlite3.Connection, key: str) -> bool:
|
||||
"""Reset a setting to its default value (remove from DB)."""
|
||||
conn.execute("DELETE FROM registry_settings WHERE key = ?", [key])
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
def reset_all_settings(conn: sqlite3.Connection) -> int:
|
||||
"""Reset all settings to defaults. Returns count of reset settings."""
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM registry_settings")
|
||||
count = cursor.fetchone()[0]
|
||||
conn.execute("DELETE FROM registry_settings")
|
||||
conn.commit()
|
||||
return count
|
||||
|
||||
|
||||
def get_vetting_config(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Get vetting configuration as a structured dict.
|
||||
|
||||
This is the main entry point for the scrutiny system to get config.
|
||||
"""
|
||||
return {
|
||||
"approve_threshold": get_setting(conn, "vetting.approve_threshold"),
|
||||
"reject_threshold": get_setting(conn, "vetting.reject_threshold"),
|
||||
"weights": {
|
||||
"honesty": get_setting(conn, "vetting.weight.honesty"),
|
||||
"transparency": get_setting(conn, "vetting.weight.transparency"),
|
||||
"scope": get_setting(conn, "vetting.weight.scope"),
|
||||
"efficiency": get_setting(conn, "vetting.weight.efficiency"),
|
||||
"safety": get_setting(conn, "vetting.weight.safety"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_similarity_config(conn: sqlite3.Connection) -> Dict[str, float]:
|
||||
"""Get similarity detection configuration."""
|
||||
return {
|
||||
"duplicate_threshold": get_setting(conn, "similarity.duplicate_threshold"),
|
||||
"similar_threshold": get_setting(conn, "similarity.similar_threshold"),
|
||||
"related_threshold": get_setting(conn, "similarity.related_threshold"),
|
||||
}
|
||||
|
||||
|
||||
def get_sync_config(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Get Fabric sync configuration."""
|
||||
return {
|
||||
"enabled": get_setting(conn, "sync.enabled"),
|
||||
"interval_hours": get_setting(conn, "sync.interval_hours"),
|
||||
"auto_approve": get_setting(conn, "sync.auto_approve"),
|
||||
"default_provider": get_setting(conn, "sync.default_provider"),
|
||||
}
|
||||
|
||||
|
||||
def get_moderation_config(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Get moderation configuration."""
|
||||
return {
|
||||
"require_review": get_setting(conn, "moderation.require_review"),
|
||||
"auto_approve_private": get_setting(conn, "moderation.auto_approve_private"),
|
||||
"max_pending_per_user": get_setting(conn, "moderation.max_pending_per_user"),
|
||||
}
|
||||
@@ -973,6 +973,17 @@ def _require_moderator_role():
|
||||
return None
|
||||
|
||||
|
||||
def _require_admin_role():
|
||||
"""Check if current user has admin role."""
|
||||
redirect_response = _require_login()
|
||||
if redirect_response:
|
||||
return redirect_response
|
||||
user = _load_current_publisher()
|
||||
if not user or user.get("role") != "admin":
|
||||
return render_template("errors/403.html"), 403
|
||||
return None
|
||||
|
||||
|
||||
@web_bp.route("/dashboard/admin", endpoint="admin_dashboard")
|
||||
def admin_dashboard():
|
||||
"""Admin dashboard overview."""
|
||||
@@ -1077,3 +1088,47 @@ def admin_reports():
|
||||
meta=meta,
|
||||
status_filter=status_filter,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/dashboard/admin/settings", endpoint="admin_settings")
|
||||
def admin_settings():
|
||||
"""Admin settings configuration page."""
|
||||
forbidden = _require_admin_role()
|
||||
if forbidden:
|
||||
return forbidden
|
||||
|
||||
user = _load_current_publisher()
|
||||
token = session.get("auth_token")
|
||||
|
||||
status, payload = _api_get("/api/v1/admin/settings", token=token)
|
||||
|
||||
if status != 200:
|
||||
return render_template(
|
||||
"admin/settings.html",
|
||||
user=user,
|
||||
active_page="admin_settings",
|
||||
settings_by_category={},
|
||||
categories=[],
|
||||
token=token,
|
||||
error=payload.get("error", "Failed to load settings"),
|
||||
)
|
||||
|
||||
settings = payload.get("data", [])
|
||||
categories = payload.get("available_categories", [])
|
||||
|
||||
# Group settings by category
|
||||
settings_by_category = {}
|
||||
for s in settings:
|
||||
cat = s.get("category", "general")
|
||||
if cat not in settings_by_category:
|
||||
settings_by_category[cat] = []
|
||||
settings_by_category[cat].append(s)
|
||||
|
||||
return render_template(
|
||||
"admin/settings.html",
|
||||
user=user,
|
||||
active_page="admin_settings",
|
||||
settings_by_category=settings_by_category,
|
||||
categories=categories,
|
||||
token=token,
|
||||
)
|
||||
|
||||
@@ -83,6 +83,15 @@
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Manage publishers</span>
|
||||
</a>
|
||||
{% if user.role == 'admin' %}
|
||||
<a href="{{ url_for('web.admin_settings') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Registry settings</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('web.dashboard') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"/>
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block dashboard_header %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Registry Settings</h1>
|
||||
<p class="mt-1 text-gray-600">Configure vetting, moderation, and sync settings</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="resetAllSettings()" class="px-4 py-2 text-sm font-medium text-red-700 bg-red-100 rounded-lg hover:bg-red-200">
|
||||
Reset All to Defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Category Tabs -->
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Settings categories">
|
||||
{% for cat in categories %}
|
||||
<button onclick="showCategory('{{ cat }}')"
|
||||
id="tab-{{ cat }}"
|
||||
class="category-tab {% if loop.first %}border-indigo-500 text-indigo-600{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm capitalize">
|
||||
{{ cat.replace('_', ' ') }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Settings Panels -->
|
||||
{% for cat, cat_settings in settings_by_category.items() %}
|
||||
<div id="panel-{{ cat }}" class="category-panel {% if not loop.first %}hidden{% endif %}">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-lg font-medium text-gray-900 capitalize">{{ cat.replace('_', ' ') }} Settings</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
{% for setting in cat_settings %}
|
||||
<div class="px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0 pr-4">
|
||||
<p class="text-sm font-medium text-gray-900">{{ setting.key.split('.')[-1].replace('_', ' ').title() }}</p>
|
||||
<p class="text-sm text-gray-500">{{ setting.description }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Key: <code class="bg-gray-100 px-1 rounded">{{ setting.key }}</code>
|
||||
{% if setting.updated_at %}
|
||||
<span class="ml-2">Last updated: {{ setting.updated_at[:10] }} by {{ setting.updated_by or 'system' }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
{% if setting.value_type == 'bool' %}
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox"
|
||||
id="setting-{{ setting.key|replace('.', '-') }}"
|
||||
{% if setting.value %}checked{% endif %}
|
||||
onchange="updateSetting('{{ setting.key }}', this.checked)"
|
||||
class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||
</label>
|
||||
{% elif setting.value_type == 'float' %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="range"
|
||||
id="range-{{ setting.key|replace('.', '-') }}"
|
||||
min="0" max="1" step="0.05"
|
||||
value="{{ setting.value }}"
|
||||
oninput="document.getElementById('input-{{ setting.key|replace('.', '-') }}').value = this.value"
|
||||
onchange="updateSetting('{{ setting.key }}', parseFloat(this.value))"
|
||||
class="w-24 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<input type="number"
|
||||
id="input-{{ setting.key|replace('.', '-') }}"
|
||||
min="0" max="1" step="0.01"
|
||||
value="{{ setting.value }}"
|
||||
onchange="updateSetting('{{ setting.key }}', parseFloat(this.value)); document.getElementById('range-{{ setting.key|replace('.', '-') }}').value = this.value"
|
||||
class="w-20 px-2 py-1 text-sm border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
{% elif setting.value_type == 'int' %}
|
||||
<input type="number"
|
||||
id="input-{{ setting.key|replace('.', '-') }}"
|
||||
value="{{ setting.value }}"
|
||||
onchange="updateSetting('{{ setting.key }}', parseInt(this.value))"
|
||||
class="w-24 px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500">
|
||||
{% else %}
|
||||
<input type="text"
|
||||
id="input-{{ setting.key|replace('.', '-') }}"
|
||||
value="{{ setting.value }}"
|
||||
onchange="updateSetting('{{ setting.key }}', this.value)"
|
||||
class="w-48 px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500">
|
||||
{% endif %}
|
||||
<button onclick="resetSetting('{{ setting.key }}')"
|
||||
title="Reset to default"
|
||||
class="p-1 text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Info Panel -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">About these settings</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li><strong>Vetting:</strong> Controls how tools are automatically scored and approved/rejected</li>
|
||||
<li><strong>Similarity:</strong> Thresholds for detecting duplicate or similar tools</li>
|
||||
<li><strong>Sync:</strong> Fabric pattern synchronization settings</li>
|
||||
<li><strong>Moderation:</strong> Rules for tool review workflow</li>
|
||||
<li><strong>Rate Limits:</strong> API rate limiting configuration</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = '{{ token }}';
|
||||
|
||||
function showCategory(cat) {
|
||||
// Hide all panels
|
||||
document.querySelectorAll('.category-panel').forEach(p => p.classList.add('hidden'));
|
||||
// Show selected panel
|
||||
document.getElementById('panel-' + cat).classList.remove('hidden');
|
||||
|
||||
// Update tab styles
|
||||
document.querySelectorAll('.category-tab').forEach(t => {
|
||||
t.classList.remove('border-indigo-500', 'text-indigo-600');
|
||||
t.classList.add('border-transparent', 'text-gray-500');
|
||||
});
|
||||
document.getElementById('tab-' + cat).classList.remove('border-transparent', 'text-gray-500');
|
||||
document.getElementById('tab-' + cat).classList.add('border-indigo-500', 'text-indigo-600');
|
||||
}
|
||||
|
||||
async function updateSetting(key, value) {
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/settings/' + key, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
},
|
||||
body: JSON.stringify({ value: value })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Setting updated successfully', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to update setting', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Network error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSetting(key) {
|
||||
if (!confirm('Reset this setting to its default value?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/settings/' + key, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Setting reset to default', 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to reset setting', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Network error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAllSettings() {
|
||||
if (!confirm('Reset ALL settings to their default values? This cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/settings/reset-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showToast('All settings reset to defaults', 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to reset settings', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Network error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white ${type === 'success' ? 'bg-green-600' : 'bg-red-600'} transition-opacity duration-300`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user