Major additions: - marketplace/: Agent template registry with FTS5 search, ratings, versioning - observability/: Prometheus metrics, distributed tracing, structured logging - ledger/migrations/: Database migration scripts for multi-tenant support - tests/governance/: 15 new test files for phases 6-12 (295 total tests) - bin/validate-phases: Full 12-phase validation script New features: - Multi-tenant support with tenant isolation and quota enforcement - Agent marketplace with semantic versioning and search - Observability with metrics, tracing, and log correlation - Tier-1 agent bootstrap scripts Updated components: - ledger/api.py: Extended API for tenants, marketplace, observability - ledger/schema.sql: Added tenant, project, marketplace tables - testing/framework.ts: Enhanced test framework - checkpoint/checkpoint.py: Improved checkpoint management Archived: - External integrations (Slack/GitHub/PagerDuty) moved to .archive/ - Old checkpoint files cleaned up Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
721 lines
25 KiB
Python
721 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Agent Marketplace API
|
|
=====================
|
|
REST API for the agent template marketplace.
|
|
|
|
Endpoints:
|
|
- /marketplace/templates - Browse and search templates
|
|
- /marketplace/templates/{id} - Get template details
|
|
- /marketplace/publish - Publish new template
|
|
- /marketplace/install - Install template to project
|
|
- /marketplace/ratings - Rate and review templates
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional, List
|
|
from contextlib import contextmanager
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Depends
|
|
from pydantic import BaseModel, Field
|
|
|
|
# =============================================================================
|
|
# Configuration
|
|
# =============================================================================
|
|
|
|
DB_PATH = Path("/opt/agent-governance/ledger/governance.db")
|
|
TEMPLATE_STORAGE = Path("/opt/agent-governance/marketplace/templates")
|
|
TEMPLATE_STORAGE.mkdir(parents=True, exist_ok=True)
|
|
|
|
# =============================================================================
|
|
# Database
|
|
# =============================================================================
|
|
|
|
@contextmanager
|
|
def get_db():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def row_to_dict(row: sqlite3.Row) -> dict:
|
|
return dict(row)
|
|
|
|
|
|
def rows_to_list(rows: list) -> list:
|
|
return [row_to_dict(row) for row in rows]
|
|
|
|
|
|
# =============================================================================
|
|
# Models
|
|
# =============================================================================
|
|
|
|
class TenantContext(BaseModel):
|
|
"""Simplified tenant context for marketplace"""
|
|
tenant_id: str = "default"
|
|
project_id: str = "default"
|
|
user_id: Optional[str] = None
|
|
role: str = "viewer"
|
|
|
|
|
|
class TemplateCreate(BaseModel):
|
|
name: str
|
|
slug: str
|
|
description: Optional[str] = None
|
|
category: str = "general"
|
|
tier_required: int = 0
|
|
is_public: bool = False
|
|
license: str = "MIT"
|
|
repository_url: Optional[str] = None
|
|
documentation_url: Optional[str] = None
|
|
tags: List[str] = []
|
|
capabilities: List[str] = []
|
|
requirements: Optional[dict] = None
|
|
|
|
|
|
class TemplateVersionCreate(BaseModel):
|
|
version: str # Semantic version: 1.0.0
|
|
release_notes: Optional[str] = None
|
|
config_schema: Optional[dict] = None
|
|
template_content: dict # Full template definition
|
|
is_stable: bool = True
|
|
|
|
|
|
class TemplateInstall(BaseModel):
|
|
template_id: str
|
|
version: Optional[str] = None # None = latest
|
|
config_overrides: Optional[dict] = None
|
|
|
|
|
|
class TemplateRating(BaseModel):
|
|
rating: int = Field(ge=1, le=5)
|
|
review_title: Optional[str] = None
|
|
review_text: Optional[str] = None
|
|
|
|
|
|
class TemplateUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
category: Optional[str] = None
|
|
is_public: Optional[bool] = None
|
|
tags: Optional[List[str]] = None
|
|
capabilities: Optional[List[str]] = None
|
|
|
|
|
|
# =============================================================================
|
|
# Helper Functions
|
|
# =============================================================================
|
|
|
|
def parse_version(version: str) -> int:
|
|
"""Convert semver to sortable integer: 1.2.3 -> 10203"""
|
|
parts = version.split(".")
|
|
if len(parts) != 3:
|
|
return 0
|
|
try:
|
|
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
|
|
return major * 10000 + minor * 100 + patch
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
def validate_slug(slug: str) -> bool:
|
|
"""Validate slug format: lowercase, alphanumeric, hyphens only"""
|
|
return bool(re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', slug)) or len(slug) == 1
|
|
|
|
|
|
def update_template_stats(conn, template_id: str, stat: str, increment: int = 1):
|
|
"""Update template statistics"""
|
|
conn.execute(f"""
|
|
INSERT INTO template_stats (template_id, {stat}, updated_at)
|
|
VALUES (?, ?, datetime('now'))
|
|
ON CONFLICT(template_id) DO UPDATE SET
|
|
{stat} = {stat} + ?,
|
|
updated_at = datetime('now')
|
|
""", (template_id, increment, increment))
|
|
|
|
|
|
def calculate_avg_rating(conn, template_id: str):
|
|
"""Recalculate average rating for template"""
|
|
cursor = conn.execute("""
|
|
SELECT AVG(rating) as avg, COUNT(*) as count
|
|
FROM template_ratings WHERE template_id = ?
|
|
""", (template_id,))
|
|
row = cursor.fetchone()
|
|
|
|
conn.execute("""
|
|
UPDATE template_stats
|
|
SET avg_rating = ?, rating_count = ?, updated_at = datetime('now')
|
|
WHERE template_id = ?
|
|
""", (row["avg"] or 0, row["count"], template_id))
|
|
|
|
|
|
# =============================================================================
|
|
# Router
|
|
# =============================================================================
|
|
|
|
router = APIRouter(prefix="/marketplace", tags=["Marketplace"])
|
|
|
|
|
|
# =============================================================================
|
|
# Browse & Search Endpoints
|
|
# =============================================================================
|
|
|
|
@router.get("/templates")
|
|
async def list_templates(
|
|
category: Optional[str] = None,
|
|
tag: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
author: Optional[str] = None,
|
|
verified_only: bool = False,
|
|
sort: str = Query("popular", pattern="^(popular|recent|rating|downloads)$"),
|
|
limit: int = Query(20, le=100),
|
|
offset: int = Query(0, ge=0)
|
|
):
|
|
"""List and search marketplace templates"""
|
|
with get_db() as conn:
|
|
# Use FTS for search, regular query otherwise
|
|
if search:
|
|
cursor = conn.execute("""
|
|
SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count,
|
|
ts.trending_score
|
|
FROM agent_templates t
|
|
LEFT JOIN template_stats ts ON t.template_id = ts.template_id
|
|
WHERE t.template_id IN (
|
|
SELECT template_id FROM template_search WHERE template_search MATCH ?
|
|
)
|
|
AND t.is_public = 1
|
|
ORDER BY ts.trending_score DESC
|
|
LIMIT ? OFFSET ?
|
|
""", (search, limit, offset))
|
|
else:
|
|
query = """
|
|
SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count,
|
|
ts.trending_score
|
|
FROM agent_templates t
|
|
LEFT JOIN template_stats ts ON t.template_id = ts.template_id
|
|
WHERE t.is_public = 1
|
|
"""
|
|
params = []
|
|
|
|
if category:
|
|
query += " AND t.category = ?"
|
|
params.append(category)
|
|
if tag:
|
|
query += " AND t.tags LIKE ?"
|
|
params.append(f'%"{tag}"%')
|
|
if author:
|
|
query += " AND t.author_id = ?"
|
|
params.append(author)
|
|
if verified_only:
|
|
query += " AND t.is_verified = 1"
|
|
|
|
# Sorting
|
|
sort_map = {
|
|
"popular": "ts.trending_score DESC",
|
|
"recent": "t.created_at DESC",
|
|
"rating": "ts.avg_rating DESC",
|
|
"downloads": "ts.total_downloads DESC"
|
|
}
|
|
query += f" ORDER BY {sort_map.get(sort, 'ts.trending_score DESC')}"
|
|
query += " LIMIT ? OFFSET ?"
|
|
params.extend([limit, offset])
|
|
|
|
cursor = conn.execute(query, params)
|
|
|
|
templates = rows_to_list(cursor.fetchall())
|
|
|
|
# Parse JSON fields
|
|
for t in templates:
|
|
t["tags"] = json.loads(t["tags"]) if t.get("tags") else []
|
|
t["capabilities"] = json.loads(t["capabilities"]) if t.get("capabilities") else []
|
|
|
|
return {"templates": templates, "count": len(templates)}
|
|
|
|
|
|
@router.get("/templates/{template_id}")
|
|
async def get_template(template_id: str):
|
|
"""Get template details with versions"""
|
|
with get_db() as conn:
|
|
# Get template
|
|
cursor = conn.execute("""
|
|
SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count,
|
|
ts.active_installs, ts.trending_score
|
|
FROM agent_templates t
|
|
LEFT JOIN template_stats ts ON t.template_id = ts.template_id
|
|
WHERE t.template_id = ?
|
|
""", (template_id,))
|
|
template = cursor.fetchone()
|
|
|
|
if not template:
|
|
raise HTTPException(status_code=404, detail="Template not found")
|
|
|
|
template = row_to_dict(template)
|
|
template["tags"] = json.loads(template["tags"]) if template.get("tags") else []
|
|
template["capabilities"] = json.loads(template["capabilities"]) if template.get("capabilities") else []
|
|
template["requirements"] = json.loads(template["requirements"]) if template.get("requirements") else {}
|
|
|
|
# Get versions
|
|
cursor = conn.execute("""
|
|
SELECT version_id, version, version_number, release_notes,
|
|
is_stable, is_deprecated, published_at
|
|
FROM template_versions
|
|
WHERE template_id = ?
|
|
ORDER BY version_number DESC
|
|
""", (template_id,))
|
|
template["versions"] = rows_to_list(cursor.fetchall())
|
|
|
|
# Get recent ratings
|
|
cursor = conn.execute("""
|
|
SELECT rating, review_title, review_text, created_at
|
|
FROM template_ratings
|
|
WHERE template_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT 5
|
|
""", (template_id,))
|
|
template["recent_reviews"] = rows_to_list(cursor.fetchall())
|
|
|
|
return template
|
|
|
|
|
|
@router.get("/templates/{template_id}/versions/{version}")
|
|
async def get_template_version(template_id: str, version: str):
|
|
"""Get specific version of a template with full content"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT * FROM template_versions
|
|
WHERE template_id = ? AND version = ?
|
|
""", (template_id, version))
|
|
ver = cursor.fetchone()
|
|
|
|
if not ver:
|
|
raise HTTPException(status_code=404, detail="Version not found")
|
|
|
|
ver = row_to_dict(ver)
|
|
ver["config_schema"] = json.loads(ver["config_schema"]) if ver.get("config_schema") else {}
|
|
ver["template_content"] = json.loads(ver["template_content"]) if ver.get("template_content") else {}
|
|
|
|
return ver
|
|
|
|
|
|
@router.get("/categories")
|
|
async def list_categories():
|
|
"""List marketplace categories"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT c.*, COUNT(t.template_id) as template_count
|
|
FROM marketplace_categories c
|
|
LEFT JOIN agent_templates t ON c.category_id = t.category AND t.is_public = 1
|
|
WHERE c.is_active = 1
|
|
GROUP BY c.category_id
|
|
ORDER BY c.sort_order
|
|
""")
|
|
categories = rows_to_list(cursor.fetchall())
|
|
|
|
return {"categories": categories}
|
|
|
|
|
|
# =============================================================================
|
|
# Publishing Endpoints
|
|
# =============================================================================
|
|
|
|
@router.post("/templates")
|
|
async def create_template(
|
|
template: TemplateCreate,
|
|
ctx: TenantContext = None
|
|
):
|
|
"""Create a new template (publish to marketplace)"""
|
|
if ctx is None:
|
|
ctx = TenantContext()
|
|
|
|
if not validate_slug(template.slug):
|
|
raise HTTPException(status_code=400, detail="Invalid slug format")
|
|
|
|
template_id = f"tpl-{secrets.token_hex(8)}"
|
|
|
|
with get_db() as conn:
|
|
try:
|
|
conn.execute("""
|
|
INSERT INTO agent_templates
|
|
(template_id, name, slug, description, author_id, author_name,
|
|
category, tier_required, is_public, license, repository_url,
|
|
documentation_url, tags, capabilities, requirements)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
template_id, template.name, template.slug, template.description,
|
|
ctx.tenant_id, ctx.user_id or ctx.tenant_id, template.category,
|
|
template.tier_required, 1 if template.is_public else 0, template.license,
|
|
template.repository_url, template.documentation_url,
|
|
json.dumps(template.tags), json.dumps(template.capabilities),
|
|
json.dumps(template.requirements) if template.requirements else None
|
|
))
|
|
|
|
# Initialize stats
|
|
conn.execute("""
|
|
INSERT INTO template_stats (template_id) VALUES (?)
|
|
""", (template_id,))
|
|
|
|
conn.commit()
|
|
except sqlite3.IntegrityError as e:
|
|
raise HTTPException(status_code=409, detail=f"Template slug already exists: {e}")
|
|
|
|
return {"status": "created", "template_id": template_id}
|
|
|
|
|
|
@router.post("/templates/{template_id}/versions")
|
|
async def create_version(
|
|
template_id: str,
|
|
version: TemplateVersionCreate,
|
|
ctx: TenantContext = None
|
|
):
|
|
"""Publish a new version of a template"""
|
|
if ctx is None:
|
|
ctx = TenantContext()
|
|
|
|
# Verify ownership
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT author_id FROM agent_templates WHERE template_id = ?
|
|
""", (template_id,))
|
|
template = cursor.fetchone()
|
|
|
|
if not template:
|
|
raise HTTPException(status_code=404, detail="Template not found")
|
|
if template["author_id"] != ctx.tenant_id:
|
|
raise HTTPException(status_code=403, detail="Not template owner")
|
|
|
|
version_id = f"ver-{secrets.token_hex(8)}"
|
|
version_number = parse_version(version.version)
|
|
content_json = json.dumps(version.template_content)
|
|
file_hash = hashlib.sha256(content_json.encode()).hexdigest()
|
|
|
|
try:
|
|
conn.execute("""
|
|
INSERT INTO template_versions
|
|
(version_id, template_id, version, version_number, release_notes,
|
|
config_schema, template_content, file_hash, is_stable)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
version_id, template_id, version.version, version_number,
|
|
version.release_notes,
|
|
json.dumps(version.config_schema) if version.config_schema else None,
|
|
content_json, file_hash, 1 if version.is_stable else 0
|
|
))
|
|
|
|
# Update template timestamp
|
|
conn.execute("""
|
|
UPDATE agent_templates SET updated_at = datetime('now')
|
|
WHERE template_id = ?
|
|
""", (template_id,))
|
|
|
|
conn.commit()
|
|
except sqlite3.IntegrityError:
|
|
raise HTTPException(status_code=409, detail="Version already exists")
|
|
|
|
return {"status": "created", "version_id": version_id, "version": version.version}
|
|
|
|
|
|
@router.patch("/templates/{template_id}")
|
|
async def update_template(
|
|
template_id: str,
|
|
update: TemplateUpdate,
|
|
ctx: TenantContext = None
|
|
):
|
|
"""Update template metadata"""
|
|
if ctx is None:
|
|
ctx = TenantContext()
|
|
|
|
with get_db() as conn:
|
|
# Verify ownership
|
|
cursor = conn.execute("""
|
|
SELECT author_id FROM agent_templates WHERE template_id = ?
|
|
""", (template_id,))
|
|
template = cursor.fetchone()
|
|
|
|
if not template:
|
|
raise HTTPException(status_code=404, detail="Template not found")
|
|
if template["author_id"] != ctx.tenant_id:
|
|
raise HTTPException(status_code=403, detail="Not template owner")
|
|
|
|
updates = []
|
|
params = []
|
|
|
|
if update.name is not None:
|
|
updates.append("name = ?")
|
|
params.append(update.name)
|
|
if update.description is not None:
|
|
updates.append("description = ?")
|
|
params.append(update.description)
|
|
if update.category is not None:
|
|
updates.append("category = ?")
|
|
params.append(update.category)
|
|
if update.is_public is not None:
|
|
updates.append("is_public = ?")
|
|
params.append(1 if update.is_public else 0)
|
|
if update.tags is not None:
|
|
updates.append("tags = ?")
|
|
params.append(json.dumps(update.tags))
|
|
if update.capabilities is not None:
|
|
updates.append("capabilities = ?")
|
|
params.append(json.dumps(update.capabilities))
|
|
|
|
if not updates:
|
|
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
updates.append("updated_at = datetime('now')")
|
|
params.append(template_id)
|
|
|
|
conn.execute(
|
|
f"UPDATE agent_templates SET {', '.join(updates)} WHERE template_id = ?",
|
|
params
|
|
)
|
|
conn.commit()
|
|
|
|
return {"status": "updated", "template_id": template_id}
|
|
|
|
|
|
# =============================================================================
|
|
# Installation Endpoints
|
|
# =============================================================================
|
|
|
|
@router.post("/install")
|
|
async def install_template(
|
|
install: TemplateInstall,
|
|
ctx: TenantContext = None
|
|
):
|
|
"""Install a template to current project"""
|
|
if ctx is None:
|
|
ctx = TenantContext()
|
|
|
|
with get_db() as conn:
|
|
# Get template
|
|
cursor = conn.execute("""
|
|
SELECT template_id, is_public, author_id FROM agent_templates
|
|
WHERE template_id = ?
|
|
""", (install.template_id,))
|
|
template = cursor.fetchone()
|
|
|
|
if not template:
|
|
raise HTTPException(status_code=404, detail="Template not found")
|
|
|
|
# Check access (public or owner)
|
|
if not template["is_public"] and template["author_id"] != ctx.tenant_id:
|
|
raise HTTPException(status_code=403, detail="Template not accessible")
|
|
|
|
# Get version (latest stable if not specified)
|
|
if install.version:
|
|
cursor = conn.execute("""
|
|
SELECT version_id FROM template_versions
|
|
WHERE template_id = ? AND version = ?
|
|
""", (install.template_id, install.version))
|
|
else:
|
|
cursor = conn.execute("""
|
|
SELECT version_id FROM template_versions
|
|
WHERE template_id = ? AND is_stable = 1
|
|
ORDER BY version_number DESC LIMIT 1
|
|
""", (install.template_id,))
|
|
|
|
version = cursor.fetchone()
|
|
if not version:
|
|
raise HTTPException(status_code=404, detail="Version not found")
|
|
|
|
# Install
|
|
conn.execute("""
|
|
INSERT INTO template_installations
|
|
(template_id, version_id, tenant_id, project_id, config_overrides)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(template_id, tenant_id, project_id) DO UPDATE SET
|
|
version_id = ?, config_overrides = ?, updated_at = datetime('now')
|
|
""", (
|
|
install.template_id, version["version_id"], ctx.tenant_id, ctx.project_id,
|
|
json.dumps(install.config_overrides) if install.config_overrides else None,
|
|
version["version_id"],
|
|
json.dumps(install.config_overrides) if install.config_overrides else None
|
|
))
|
|
|
|
# Update stats
|
|
update_template_stats(conn, install.template_id, "total_installs")
|
|
update_template_stats(conn, install.template_id, "active_installs")
|
|
|
|
# Record download
|
|
conn.execute("""
|
|
INSERT INTO template_downloads (template_id, version_id, tenant_id)
|
|
VALUES (?, ?, ?)
|
|
""", (install.template_id, version["version_id"], ctx.tenant_id))
|
|
|
|
update_template_stats(conn, install.template_id, "total_downloads")
|
|
|
|
conn.commit()
|
|
|
|
return {"status": "installed", "template_id": install.template_id}
|
|
|
|
|
|
@router.get("/installed")
|
|
async def list_installed(
|
|
ctx: TenantContext = None
|
|
):
|
|
"""List templates installed in current project"""
|
|
if ctx is None:
|
|
ctx = TenantContext()
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT i.*, t.name, t.description, t.category, v.version
|
|
FROM template_installations i
|
|
JOIN agent_templates t ON i.template_id = t.template_id
|
|
LEFT JOIN template_versions v ON i.version_id = v.version_id
|
|
WHERE i.tenant_id = ? AND i.project_id = ?
|
|
ORDER BY i.installed_at DESC
|
|
""", (ctx.tenant_id, ctx.project_id))
|
|
installed = rows_to_list(cursor.fetchall())
|
|
|
|
return {"installed": installed, "count": len(installed)}
|
|
|
|
|
|
@router.delete("/installed/{template_id}")
|
|
async def uninstall_template(
|
|
template_id: str,
|
|
ctx: TenantContext = None
|
|
):
|
|
"""Uninstall a template from current project"""
|
|
if ctx is None:
|
|
ctx = TenantContext()
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
DELETE FROM template_installations
|
|
WHERE template_id = ? AND tenant_id = ? AND project_id = ?
|
|
""", (template_id, ctx.tenant_id, ctx.project_id))
|
|
conn.commit()
|
|
|
|
if cursor.rowcount == 0:
|
|
raise HTTPException(status_code=404, detail="Installation not found")
|
|
|
|
# Update active installs
|
|
conn.execute("""
|
|
UPDATE template_stats
|
|
SET active_installs = active_installs - 1, updated_at = datetime('now')
|
|
WHERE template_id = ? AND active_installs > 0
|
|
""", (template_id,))
|
|
conn.commit()
|
|
|
|
return {"status": "uninstalled", "template_id": template_id}
|
|
|
|
|
|
# =============================================================================
|
|
# Rating Endpoints
|
|
# =============================================================================
|
|
|
|
@router.post("/templates/{template_id}/rate")
|
|
async def rate_template(
|
|
template_id: str,
|
|
rating: TemplateRating,
|
|
ctx: TenantContext = None
|
|
):
|
|
"""Rate and review a template"""
|
|
if ctx is None:
|
|
ctx = TenantContext()
|
|
|
|
with get_db() as conn:
|
|
# Check if template exists
|
|
cursor = conn.execute("""
|
|
SELECT 1 FROM agent_templates WHERE template_id = ?
|
|
""", (template_id,))
|
|
if not cursor.fetchone():
|
|
raise HTTPException(status_code=404, detail="Template not found")
|
|
|
|
# Check if user has installed (verified reviewer)
|
|
cursor = conn.execute("""
|
|
SELECT 1 FROM template_installations
|
|
WHERE template_id = ? AND tenant_id = ?
|
|
""", (template_id, ctx.tenant_id))
|
|
is_verified = 1 if cursor.fetchone() else 0
|
|
|
|
conn.execute("""
|
|
INSERT INTO template_ratings
|
|
(template_id, tenant_id, user_id, rating, review_title, review_text, is_verified_user)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(template_id, tenant_id) DO UPDATE SET
|
|
rating = ?, review_title = ?, review_text = ?, updated_at = datetime('now')
|
|
""", (
|
|
template_id, ctx.tenant_id, ctx.user_id, rating.rating,
|
|
rating.review_title, rating.review_text, is_verified,
|
|
rating.rating, rating.review_title, rating.review_text
|
|
))
|
|
|
|
# Recalculate average
|
|
calculate_avg_rating(conn, template_id)
|
|
conn.commit()
|
|
|
|
return {"status": "rated", "template_id": template_id, "rating": rating.rating}
|
|
|
|
|
|
@router.get("/templates/{template_id}/ratings")
|
|
async def get_ratings(
|
|
template_id: str,
|
|
limit: int = Query(20, le=100),
|
|
offset: int = Query(0, ge=0)
|
|
):
|
|
"""Get ratings for a template"""
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT rating, review_title, review_text, is_verified_user, created_at
|
|
FROM template_ratings
|
|
WHERE template_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
""", (template_id, limit, offset))
|
|
ratings = rows_to_list(cursor.fetchall())
|
|
|
|
# Get summary
|
|
cursor = conn.execute("""
|
|
SELECT avg_rating, rating_count FROM template_stats
|
|
WHERE template_id = ?
|
|
""", (template_id,))
|
|
stats = cursor.fetchone()
|
|
|
|
return {
|
|
"ratings": ratings,
|
|
"count": len(ratings),
|
|
"avg_rating": stats["avg_rating"] if stats else 0,
|
|
"total_ratings": stats["rating_count"] if stats else 0
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# My Templates (Author View)
|
|
# =============================================================================
|
|
|
|
@router.get("/my-templates")
|
|
async def list_my_templates(
|
|
ctx: TenantContext = None
|
|
):
|
|
"""List templates created by current tenant"""
|
|
if ctx is None:
|
|
ctx = TenantContext()
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.execute("""
|
|
SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count,
|
|
ts.active_installs
|
|
FROM agent_templates t
|
|
LEFT JOIN template_stats ts ON t.template_id = ts.template_id
|
|
WHERE t.author_id = ?
|
|
ORDER BY t.updated_at DESC
|
|
""", (ctx.tenant_id,))
|
|
templates = rows_to_list(cursor.fetchall())
|
|
|
|
for t in templates:
|
|
t["tags"] = json.loads(t["tags"]) if t.get("tags") else []
|
|
|
|
return {"templates": templates, "count": len(templates)}
|