- Migrated from HIVE branding to WHOOSH across all components - Enhanced backend API with new services: AI models, BZZZ integration, templates, members - Added comprehensive testing suite with security, performance, and integration tests - Improved frontend with new components for project setup, AI models, and team management - Updated MCP server implementation with WHOOSH-specific tools and resources - Enhanced deployment configurations with production-ready Docker setups - Added comprehensive documentation and setup guides - Implemented age encryption service and UCXL integration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
504 lines
19 KiB
Python
504 lines
19 KiB
Python
"""
|
|
Project Template API for WHOOSH - Advanced project template management.
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
|
|
from pydantic import BaseModel, Field
|
|
from typing import List, Dict, Optional, Any
|
|
from datetime import datetime
|
|
import tempfile
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from app.services.template_service import ProjectTemplateService
|
|
from app.services.gitea_service import GiteaService
|
|
from app.core.auth_deps import get_current_user_context
|
|
|
|
router = APIRouter(prefix="/api/templates", tags=["project-templates"])
|
|
|
|
# Pydantic models for request/response validation
|
|
|
|
class TemplateInfo(BaseModel):
|
|
template_id: str
|
|
name: str
|
|
description: str
|
|
icon: str
|
|
category: str
|
|
tags: List[str]
|
|
difficulty: str
|
|
estimated_setup_time: str
|
|
features: List[str]
|
|
tech_stack: Dict[str, List[str]]
|
|
requirements: Optional[Dict[str, str]] = None
|
|
|
|
class TemplateListResponse(BaseModel):
|
|
templates: List[TemplateInfo]
|
|
categories: List[str]
|
|
total_count: int
|
|
|
|
class TemplateDetailResponse(BaseModel):
|
|
metadata: TemplateInfo
|
|
starter_files: Dict[str, str]
|
|
file_structure: List[str]
|
|
|
|
class ProjectFromTemplateRequest(BaseModel):
|
|
template_id: str
|
|
project_name: str = Field(..., min_length=1, max_length=100)
|
|
project_description: Optional[str] = Field(None, max_length=500)
|
|
author_name: Optional[str] = Field(None, max_length=100)
|
|
custom_variables: Optional[Dict[str, str]] = None
|
|
create_repository: bool = True
|
|
repository_private: bool = False
|
|
|
|
class ProjectFromTemplateResponse(BaseModel):
|
|
success: bool
|
|
project_id: str
|
|
template_id: str
|
|
files_created: List[str]
|
|
repository_url: Optional[str] = None
|
|
next_steps: List[str]
|
|
setup_time: str
|
|
error: Optional[str] = None
|
|
|
|
class TemplateValidationRequest(BaseModel):
|
|
template_id: str
|
|
project_variables: Dict[str, str]
|
|
|
|
class TemplateValidationResponse(BaseModel):
|
|
valid: bool
|
|
missing_requirements: List[str]
|
|
warnings: List[str]
|
|
estimated_size: str
|
|
|
|
def get_template_service():
|
|
"""Dependency injection for template service."""
|
|
return ProjectTemplateService()
|
|
|
|
def get_gitea_service():
|
|
"""Dependency injection for GITEA service."""
|
|
return GiteaService()
|
|
|
|
@router.get("/", response_model=TemplateListResponse)
|
|
async def list_templates(
|
|
category: Optional[str] = None,
|
|
tag: Optional[str] = None,
|
|
difficulty: Optional[str] = None,
|
|
template_service: ProjectTemplateService = Depends(get_template_service)
|
|
):
|
|
"""List all available project templates with optional filtering."""
|
|
try:
|
|
templates = template_service.list_templates()
|
|
|
|
# Apply filters
|
|
if category:
|
|
templates = [t for t in templates if t.get("category") == category]
|
|
|
|
if tag:
|
|
templates = [t for t in templates if tag in t.get("tags", [])]
|
|
|
|
if difficulty:
|
|
templates = [t for t in templates if t.get("difficulty") == difficulty]
|
|
|
|
# Extract unique categories for filter options
|
|
all_templates = template_service.list_templates()
|
|
categories = list(set(t.get("category", "other") for t in all_templates))
|
|
|
|
# Convert to response format
|
|
template_infos = []
|
|
for template in templates:
|
|
template_info = TemplateInfo(
|
|
template_id=template["template_id"],
|
|
name=template["name"],
|
|
description=template["description"],
|
|
icon=template["icon"],
|
|
category=template.get("category", "other"),
|
|
tags=template.get("tags", []),
|
|
difficulty=template.get("difficulty", "beginner"),
|
|
estimated_setup_time=template.get("estimated_setup_time", "5-10 minutes"),
|
|
features=template.get("features", []),
|
|
tech_stack=template.get("tech_stack", {}),
|
|
requirements=template.get("requirements")
|
|
)
|
|
template_infos.append(template_info)
|
|
|
|
return TemplateListResponse(
|
|
templates=template_infos,
|
|
categories=sorted(categories),
|
|
total_count=len(template_infos)
|
|
)
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to list templates: {str(e)}")
|
|
|
|
@router.get("/{template_id}", response_model=TemplateDetailResponse)
|
|
async def get_template_details(
|
|
template_id: str,
|
|
template_service: ProjectTemplateService = Depends(get_template_service)
|
|
):
|
|
"""Get detailed information about a specific template including files."""
|
|
try:
|
|
template = template_service.get_template(template_id)
|
|
if not template:
|
|
raise HTTPException(status_code=404, detail=f"Template '{template_id}' not found")
|
|
|
|
metadata = template["metadata"]
|
|
starter_files = template["starter_files"]
|
|
|
|
# Create file structure list
|
|
file_structure = sorted(starter_files.keys())
|
|
|
|
template_info = TemplateInfo(
|
|
template_id=metadata["template_id"],
|
|
name=metadata["name"],
|
|
description=metadata["description"],
|
|
icon=metadata["icon"],
|
|
category=metadata.get("category", "other"),
|
|
tags=metadata.get("tags", []),
|
|
difficulty=metadata.get("difficulty", "beginner"),
|
|
estimated_setup_time=metadata.get("estimated_setup_time", "5-10 minutes"),
|
|
features=metadata.get("features", []),
|
|
tech_stack=metadata.get("tech_stack", {}),
|
|
requirements=metadata.get("requirements")
|
|
)
|
|
|
|
return TemplateDetailResponse(
|
|
metadata=template_info,
|
|
starter_files=starter_files,
|
|
file_structure=file_structure
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to get template details: {str(e)}")
|
|
|
|
@router.post("/validate", response_model=TemplateValidationResponse)
|
|
async def validate_template_setup(
|
|
request: TemplateValidationRequest,
|
|
template_service: ProjectTemplateService = Depends(get_template_service)
|
|
):
|
|
"""Validate template requirements and project variables before creation."""
|
|
try:
|
|
template = template_service.get_template(request.template_id)
|
|
if not template:
|
|
raise HTTPException(status_code=404, detail=f"Template '{request.template_id}' not found")
|
|
|
|
metadata = template["metadata"]
|
|
requirements = metadata.get("requirements", {})
|
|
|
|
# Check for missing requirements
|
|
missing_requirements = []
|
|
for req_name, req_version in requirements.items():
|
|
# This would check system requirements in a real implementation
|
|
# For now, we'll simulate the check
|
|
if req_name in ["docker", "nodejs", "python"]:
|
|
# Assume these are available
|
|
pass
|
|
else:
|
|
missing_requirements.append(f"{req_name} {req_version}")
|
|
|
|
# Generate warnings
|
|
warnings = []
|
|
if metadata.get("difficulty") == "advanced":
|
|
warnings.append("This is an advanced template requiring significant setup time")
|
|
|
|
if len(template["starter_files"]) > 50:
|
|
warnings.append("This template creates many files and may take longer to set up")
|
|
|
|
# Estimate project size
|
|
total_files = len(template["starter_files"])
|
|
if total_files < 10:
|
|
estimated_size = "Small (< 10 files)"
|
|
elif total_files < 30:
|
|
estimated_size = "Medium (10-30 files)"
|
|
else:
|
|
estimated_size = "Large (30+ files)"
|
|
|
|
return TemplateValidationResponse(
|
|
valid=len(missing_requirements) == 0,
|
|
missing_requirements=missing_requirements,
|
|
warnings=warnings,
|
|
estimated_size=estimated_size
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Template validation failed: {str(e)}")
|
|
|
|
@router.post("/create-project", response_model=ProjectFromTemplateResponse)
|
|
async def create_project_from_template(
|
|
request: ProjectFromTemplateRequest,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: Dict[str, Any] = Depends(get_current_user_context),
|
|
template_service: ProjectTemplateService = Depends(get_template_service),
|
|
gitea_service: GiteaService = Depends(get_gitea_service)
|
|
):
|
|
"""Create a new project from a template with optional GITEA repository creation."""
|
|
start_time = datetime.now()
|
|
|
|
try:
|
|
# Validate template exists
|
|
template = template_service.get_template(request.template_id)
|
|
if not template:
|
|
raise HTTPException(status_code=404, detail=f"Template '{request.template_id}' not found")
|
|
|
|
# Prepare project variables
|
|
project_variables = {
|
|
"project_name": request.project_name,
|
|
"project_description": request.project_description or "",
|
|
"author_name": request.author_name or current_user.get("name", "WHOOSH User"),
|
|
**(request.custom_variables or {})
|
|
}
|
|
|
|
# Create temporary directory for project files
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
# Generate project from template
|
|
result = template_service.create_project_from_template(
|
|
request.template_id,
|
|
project_variables,
|
|
temp_dir
|
|
)
|
|
|
|
repository_url = None
|
|
|
|
# Create GITEA repository if requested
|
|
if request.create_repository:
|
|
try:
|
|
repo_name = request.project_name.lower().replace(" ", "-").replace("_", "-")
|
|
repo_info = gitea_service.create_repository(
|
|
owner="whoosh", # Default organization
|
|
repo_name=repo_name,
|
|
description=request.project_description or f"Project created from {template['metadata']['name']} template",
|
|
private=request.repository_private,
|
|
auto_init=True
|
|
)
|
|
|
|
if repo_info:
|
|
repository_url = repo_info.get("html_url")
|
|
|
|
# TODO: Upload generated files to repository
|
|
# This would require git operations to push the template files
|
|
# to the newly created repository
|
|
|
|
else:
|
|
# Repository creation failed, but continue with project creation
|
|
pass
|
|
|
|
except Exception as e:
|
|
print(f"Warning: Repository creation failed: {e}")
|
|
# Continue without repository
|
|
|
|
# Calculate setup time
|
|
setup_time = str(datetime.now() - start_time)
|
|
|
|
# Generate project ID
|
|
project_id = f"proj_{request.project_name.lower().replace(' ', '_')}_{int(start_time.timestamp())}"
|
|
|
|
# Get next steps from template
|
|
next_steps = template["metadata"].get("next_steps", [
|
|
"Review the generated project structure",
|
|
"Install dependencies as specified in requirements files",
|
|
"Configure environment variables",
|
|
"Run initial setup scripts",
|
|
"Start development server"
|
|
])
|
|
|
|
# Add repository-specific next steps
|
|
if repository_url:
|
|
next_steps.insert(0, f"Clone your repository: git clone {repository_url}")
|
|
next_steps.append("Commit and push your initial changes")
|
|
|
|
return ProjectFromTemplateResponse(
|
|
success=True,
|
|
project_id=project_id,
|
|
template_id=request.template_id,
|
|
files_created=result["files_created"],
|
|
repository_url=repository_url,
|
|
next_steps=next_steps,
|
|
setup_time=setup_time
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
setup_time = str(datetime.now() - start_time)
|
|
return ProjectFromTemplateResponse(
|
|
success=False,
|
|
project_id="",
|
|
template_id=request.template_id,
|
|
files_created=[],
|
|
repository_url=None,
|
|
next_steps=[],
|
|
setup_time=setup_time,
|
|
error=str(e)
|
|
)
|
|
|
|
@router.get("/categories", response_model=List[str])
|
|
async def get_template_categories(
|
|
template_service: ProjectTemplateService = Depends(get_template_service)
|
|
):
|
|
"""Get all available template categories."""
|
|
try:
|
|
templates = template_service.list_templates()
|
|
categories = list(set(t.get("category", "other") for t in templates))
|
|
return sorted(categories)
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to get categories: {str(e)}")
|
|
|
|
@router.get("/tags", response_model=List[str])
|
|
async def get_template_tags(
|
|
template_service: ProjectTemplateService = Depends(get_template_service)
|
|
):
|
|
"""Get all available template tags."""
|
|
try:
|
|
templates = template_service.list_templates()
|
|
all_tags = []
|
|
for template in templates:
|
|
all_tags.extend(template.get("tags", []))
|
|
|
|
# Remove duplicates and sort
|
|
unique_tags = sorted(list(set(all_tags)))
|
|
return unique_tags
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to get tags: {str(e)}")
|
|
|
|
@router.get("/{template_id}/preview", response_model=Dict[str, Any])
|
|
async def preview_template_files(
|
|
template_id: str,
|
|
file_path: Optional[str] = None,
|
|
template_service: ProjectTemplateService = Depends(get_template_service)
|
|
):
|
|
"""Preview template files or get file structure."""
|
|
try:
|
|
template = template_service.get_template(template_id)
|
|
if not template:
|
|
raise HTTPException(status_code=404, detail=f"Template '{template_id}' not found")
|
|
|
|
if file_path:
|
|
# Return specific file content
|
|
starter_files = template["starter_files"]
|
|
if file_path not in starter_files:
|
|
raise HTTPException(status_code=404, detail=f"File '{file_path}' not found in template")
|
|
|
|
return {
|
|
"file_path": file_path,
|
|
"content": starter_files[file_path],
|
|
"size": len(starter_files[file_path]),
|
|
"type": "text" if file_path.endswith(('.txt', '.md', '.py', '.js', '.ts', '.json', '.yml', '.yaml')) else "binary"
|
|
}
|
|
else:
|
|
# Return file structure overview
|
|
starter_files = template["starter_files"]
|
|
file_structure = {}
|
|
|
|
for file_path in sorted(starter_files.keys()):
|
|
parts = Path(file_path).parts
|
|
current = file_structure
|
|
|
|
for part in parts[:-1]:
|
|
if part not in current:
|
|
current[part] = {}
|
|
current = current[part]
|
|
|
|
# Add file with metadata
|
|
filename = parts[-1]
|
|
current[filename] = {
|
|
"type": "file",
|
|
"size": len(starter_files[file_path]),
|
|
"extension": Path(file_path).suffix
|
|
}
|
|
|
|
return {
|
|
"template_id": template_id,
|
|
"file_structure": file_structure,
|
|
"total_files": len(starter_files),
|
|
"total_size": sum(len(content) for content in starter_files.values())
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to preview template: {str(e)}")
|
|
|
|
@router.post("/{template_id}/download")
|
|
async def download_template_archive(
|
|
template_id: str,
|
|
template_service: ProjectTemplateService = Depends(get_template_service)
|
|
):
|
|
"""Download template as a ZIP archive."""
|
|
try:
|
|
template = template_service.get_template(template_id)
|
|
if not template:
|
|
raise HTTPException(status_code=404, detail=f"Template '{template_id}' not found")
|
|
|
|
# Create temporary ZIP file
|
|
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as temp_zip:
|
|
import zipfile
|
|
|
|
with zipfile.ZipFile(temp_zip.name, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
# Add template metadata
|
|
zf.writestr("template.json", json.dumps(template["metadata"], indent=2))
|
|
|
|
# Add all starter files
|
|
for file_path, content in template["starter_files"].items():
|
|
zf.writestr(file_path, content)
|
|
|
|
# Return file for download
|
|
from fastapi.responses import FileResponse
|
|
return FileResponse(
|
|
temp_zip.name,
|
|
media_type="application/zip",
|
|
filename=f"{template_id}-template.zip"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to download template: {str(e)}")
|
|
|
|
# Template Statistics and Analytics
|
|
|
|
@router.get("/stats/overview")
|
|
async def get_template_statistics(
|
|
template_service: ProjectTemplateService = Depends(get_template_service)
|
|
):
|
|
"""Get overview statistics about available templates."""
|
|
try:
|
|
templates = template_service.list_templates()
|
|
|
|
# Calculate statistics
|
|
total_templates = len(templates)
|
|
categories = {}
|
|
difficulties = {}
|
|
tech_stacks = {}
|
|
|
|
for template in templates:
|
|
# Count categories
|
|
category = template.get("category", "other")
|
|
categories[category] = categories.get(category, 0) + 1
|
|
|
|
# Count difficulties
|
|
difficulty = template.get("difficulty", "beginner")
|
|
difficulties[difficulty] = difficulties.get(difficulty, 0) + 1
|
|
|
|
# Count tech stack components
|
|
tech_stack = template.get("tech_stack", {})
|
|
for category, technologies in tech_stack.items():
|
|
for tech in technologies:
|
|
tech_stacks[tech] = tech_stacks.get(tech, 0) + 1
|
|
|
|
# Get most popular technologies
|
|
popular_tech = sorted(tech_stacks.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
|
|
return {
|
|
"total_templates": total_templates,
|
|
"categories": categories,
|
|
"difficulties": difficulties,
|
|
"popular_technologies": dict(popular_tech),
|
|
"average_features_per_template": sum(len(t.get("features", [])) for t in templates) / total_templates if templates else 0
|
|
}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to get template statistics: {str(e)}") |