GT AI OS Community v2.0.33 - Add NVIDIA NIM and Nemotron agents
- Updated python_coding_microproject.csv to use NVIDIA NIM Kimi K2 - Updated kali_linux_shell_simulator.csv to use NVIDIA NIM Kimi K2 - Made more general-purpose (flexible targets, expanded tools) - Added nemotron-mini-agent.csv for fast local inference via Ollama - Added nemotron-agent.csv for advanced reasoning via Ollama - Added wiki page: Projects for NVIDIA NIMs and Nemotron
This commit is contained in:
42
apps/control-panel-backend/app/models/__init__.py
Normal file
42
apps/control-panel-backend/app/models/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Database models for GT 2.0 Control Panel
|
||||
"""
|
||||
from app.models.tenant import Tenant, TenantResource
|
||||
from app.models.user import User
|
||||
from app.models.user_tenant_assignment import UserTenantAssignment
|
||||
from app.models.user_data import UserResourceData, UserPreferences, UserProgress
|
||||
from app.models.ai_resource import AIResource
|
||||
from app.models.usage import UsageRecord
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.model_config import ModelConfig, ModelUsageLog
|
||||
from app.models.tenant_model_config import TenantModelConfig
|
||||
from app.models.resource_usage import ResourceQuota, ResourceUsage, ResourceAlert, ResourceTemplate, SystemMetrics
|
||||
from app.models.system import SystemVersion, UpdateJob, BackupRecord, UpdateStatus, BackupType
|
||||
from app.models.session import Session
|
||||
|
||||
__all__ = [
|
||||
"Tenant",
|
||||
"TenantResource",
|
||||
"User",
|
||||
"UserTenantAssignment",
|
||||
"UserResourceData",
|
||||
"UserPreferences",
|
||||
"UserProgress",
|
||||
"AIResource",
|
||||
"UsageRecord",
|
||||
"AuditLog",
|
||||
"ModelConfig",
|
||||
"ModelUsageLog",
|
||||
"TenantModelConfig",
|
||||
"ResourceQuota",
|
||||
"ResourceUsage",
|
||||
"ResourceAlert",
|
||||
"ResourceTemplate",
|
||||
"SystemMetrics",
|
||||
"SystemVersion",
|
||||
"UpdateJob",
|
||||
"BackupRecord",
|
||||
"UpdateStatus",
|
||||
"BackupType",
|
||||
"Session"
|
||||
]
|
||||
357
apps/control-panel-backend/app/models/ai_resource.py
Normal file
357
apps/control-panel-backend/app/models/ai_resource.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Comprehensive Resource database model for all GT 2.0 resource families with HA support
|
||||
|
||||
Supports 6 resource families:
|
||||
- AI/ML Resources (LLMs, embeddings, image generation, function calling)
|
||||
- RAG Engine Resources (vector databases, document processing, retrieval systems)
|
||||
- Agentic Workflow Resources (multi-step AI workflows, agent frameworks)
|
||||
- App Integration Resources (external tools, APIs, webhooks)
|
||||
- External Web Services (Canvas LMS, CTFd, Guacamole, iframe-embedded services)
|
||||
- AI Literacy & Cognitive Skills (educational games, puzzles, learning content)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Float, JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AIResource(Base):
|
||||
"""Comprehensive Resource model for managing all GT 2.0 resource families with HA support"""
|
||||
|
||||
__tablename__ = "ai_resources"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
resource_type = Column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
index=True
|
||||
) # ai_ml, rag_engine, agentic_workflow, app_integration, external_service, ai_literacy
|
||||
provider = Column(String(50), nullable=False, index=True)
|
||||
model_name = Column(String(100), nullable=True) # Optional for non-AI resources
|
||||
|
||||
# Resource Family Specific Fields
|
||||
resource_subtype = Column(String(50), nullable=True, index=True) # llm, vector_db, game, etc.
|
||||
personalization_mode = Column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="shared",
|
||||
index=True
|
||||
) # shared, user_scoped, session_based
|
||||
|
||||
# High Availability Configuration
|
||||
api_endpoints = Column(JSON, nullable=False, default=list) # Multiple endpoints for HA
|
||||
primary_endpoint = Column(Text, nullable=True)
|
||||
api_key_encrypted = Column(Text, nullable=True)
|
||||
failover_endpoints = Column(JSON, nullable=False, default=list) # Failover endpoints
|
||||
health_check_url = Column(Text, nullable=True)
|
||||
|
||||
# External Service Configuration (for iframe embedding, etc.)
|
||||
iframe_url = Column(Text, nullable=True) # For external web services
|
||||
sandbox_config = Column(JSON, nullable=False, default=dict) # Security sandboxing options
|
||||
auth_config = Column(JSON, nullable=False, default=dict) # Authentication configuration
|
||||
|
||||
# Performance and Limits
|
||||
max_requests_per_minute = Column(Integer, nullable=False, default=60)
|
||||
max_tokens_per_request = Column(Integer, nullable=False, default=4000)
|
||||
cost_per_1k_tokens = Column(Float, nullable=False, default=0.0)
|
||||
latency_sla_ms = Column(Integer, nullable=False, default=5000)
|
||||
|
||||
# Configuration and Status
|
||||
configuration = Column(JSON, nullable=False, default=dict)
|
||||
health_status = Column(String(20), nullable=False, default="unknown", index=True) # healthy, unhealthy, unknown
|
||||
last_health_check = Column(DateTime(timezone=True), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
priority = Column(Integer, nullable=False, default=100) # For load balancing weights
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
tenant_resources = relationship("TenantResource", back_populates="ai_resource", cascade="all, delete-orphan")
|
||||
usage_records = relationship("UsageRecord", back_populates="ai_resource", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AIResource(id={self.id}, name='{self.name}', provider='{self.provider}')>"
|
||||
|
||||
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
|
||||
"""Convert comprehensive resource to dictionary with HA information"""
|
||||
data = {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"resource_type": self.resource_type,
|
||||
"resource_subtype": self.resource_subtype,
|
||||
"provider": self.provider,
|
||||
"model_name": self.model_name,
|
||||
"personalization_mode": self.personalization_mode,
|
||||
"primary_endpoint": self.primary_endpoint,
|
||||
"health_check_url": self.health_check_url,
|
||||
"iframe_url": self.iframe_url,
|
||||
"sandbox_config": self.sandbox_config,
|
||||
"auth_config": self.auth_config,
|
||||
"max_requests_per_minute": self.max_requests_per_minute,
|
||||
"max_tokens_per_request": self.max_tokens_per_request,
|
||||
"cost_per_1k_tokens": self.cost_per_1k_tokens,
|
||||
"latency_sla_ms": self.latency_sla_ms,
|
||||
"configuration": self.configuration,
|
||||
"health_status": self.health_status,
|
||||
"last_health_check": self.last_health_check.isoformat() if self.last_health_check else None,
|
||||
"is_active": self.is_active,
|
||||
"priority": self.priority,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
if include_sensitive:
|
||||
data["api_key_encrypted"] = self.api_key_encrypted
|
||||
data["api_endpoints"] = self.api_endpoints
|
||||
data["failover_endpoints"] = self.failover_endpoints
|
||||
|
||||
return data
|
||||
|
||||
# Resource Family Properties
|
||||
@property
|
||||
def is_ai_ml(self) -> bool:
|
||||
"""Check if resource is an AI/ML resource"""
|
||||
return self.resource_type == "ai_ml"
|
||||
|
||||
@property
|
||||
def is_rag_engine(self) -> bool:
|
||||
"""Check if resource is a RAG engine"""
|
||||
return self.resource_type == "rag_engine"
|
||||
|
||||
@property
|
||||
def is_agentic_workflow(self) -> bool:
|
||||
"""Check if resource is an agentic workflow"""
|
||||
return self.resource_type == "agentic_workflow"
|
||||
|
||||
@property
|
||||
def is_app_integration(self) -> bool:
|
||||
"""Check if resource is an app integration"""
|
||||
return self.resource_type == "app_integration"
|
||||
|
||||
@property
|
||||
def is_external_service(self) -> bool:
|
||||
"""Check if resource is an external web service"""
|
||||
return self.resource_type == "external_service"
|
||||
|
||||
@property
|
||||
def is_ai_literacy(self) -> bool:
|
||||
"""Check if resource is an AI literacy resource"""
|
||||
return self.resource_type == "ai_literacy"
|
||||
|
||||
# AI/ML Subtype Properties (legacy compatibility)
|
||||
@property
|
||||
def is_llm(self) -> bool:
|
||||
"""Check if resource is an LLM"""
|
||||
return self.is_ai_ml and self.resource_subtype == "llm"
|
||||
|
||||
@property
|
||||
def is_embedding(self) -> bool:
|
||||
"""Check if resource is an embedding model"""
|
||||
return self.is_ai_ml and self.resource_subtype == "embedding"
|
||||
|
||||
@property
|
||||
def is_image_generation(self) -> bool:
|
||||
"""Check if resource is an image generation model"""
|
||||
return self.is_ai_ml and self.resource_subtype == "image_generation"
|
||||
|
||||
@property
|
||||
def is_function_calling(self) -> bool:
|
||||
"""Check if resource supports function calling"""
|
||||
return self.is_ai_ml and self.resource_subtype == "function_calling"
|
||||
|
||||
# Personalization Properties
|
||||
@property
|
||||
def is_shared(self) -> bool:
|
||||
"""Check if resource uses shared data model"""
|
||||
return self.personalization_mode == "shared"
|
||||
|
||||
@property
|
||||
def is_user_scoped(self) -> bool:
|
||||
"""Check if resource uses user-scoped data model"""
|
||||
return self.personalization_mode == "user_scoped"
|
||||
|
||||
@property
|
||||
def is_session_based(self) -> bool:
|
||||
"""Check if resource uses session-based data model"""
|
||||
return self.personalization_mode == "session_based"
|
||||
|
||||
@property
|
||||
def is_healthy(self) -> bool:
|
||||
"""Check if resource is currently healthy"""
|
||||
return self.health_status == "healthy" and self.is_active
|
||||
|
||||
@property
|
||||
def has_failover(self) -> bool:
|
||||
"""Check if resource has failover endpoints configured"""
|
||||
return bool(self.failover_endpoints and len(self.failover_endpoints) > 0)
|
||||
|
||||
def get_default_config(self) -> Dict[str, Any]:
|
||||
"""Get default configuration based on resource type and subtype"""
|
||||
if self.is_ai_ml:
|
||||
return self._get_ai_ml_config()
|
||||
elif self.is_rag_engine:
|
||||
return self._get_rag_engine_config()
|
||||
elif self.is_agentic_workflow:
|
||||
return self._get_agentic_workflow_config()
|
||||
elif self.is_app_integration:
|
||||
return self._get_app_integration_config()
|
||||
elif self.is_external_service:
|
||||
return self._get_external_service_config()
|
||||
elif self.is_ai_literacy:
|
||||
return self._get_ai_literacy_config()
|
||||
else:
|
||||
return {}
|
||||
|
||||
def _get_ai_ml_config(self) -> Dict[str, Any]:
|
||||
"""Get AI/ML specific configuration"""
|
||||
if self.resource_subtype == "llm":
|
||||
return {
|
||||
"max_tokens": 4000,
|
||||
"temperature": 0.7,
|
||||
"top_p": 1.0,
|
||||
"frequency_penalty": 0.0,
|
||||
"presence_penalty": 0.0,
|
||||
"stream": False,
|
||||
"stop": None
|
||||
}
|
||||
elif self.resource_subtype == "embedding":
|
||||
return {
|
||||
"dimensions": 1536,
|
||||
"batch_size": 100,
|
||||
"encoding_format": "float"
|
||||
}
|
||||
elif self.resource_subtype == "image_generation":
|
||||
return {
|
||||
"size": "1024x1024",
|
||||
"quality": "standard",
|
||||
"style": "natural",
|
||||
"response_format": "url"
|
||||
}
|
||||
elif self.resource_subtype == "function_calling":
|
||||
return {
|
||||
"max_tokens": 4000,
|
||||
"temperature": 0.1,
|
||||
"function_call": "auto",
|
||||
"tools": []
|
||||
}
|
||||
return {}
|
||||
|
||||
def _get_rag_engine_config(self) -> Dict[str, Any]:
|
||||
"""Get RAG engine specific configuration"""
|
||||
return {
|
||||
"chunk_size": 512,
|
||||
"chunk_overlap": 50,
|
||||
"similarity_threshold": 0.7,
|
||||
"max_results": 10,
|
||||
"rerank": True,
|
||||
"include_metadata": True
|
||||
}
|
||||
|
||||
def _get_agentic_workflow_config(self) -> Dict[str, Any]:
|
||||
"""Get agentic workflow specific configuration"""
|
||||
return {
|
||||
"max_iterations": 10,
|
||||
"timeout_seconds": 300,
|
||||
"auto_approve": False,
|
||||
"human_in_loop": True,
|
||||
"retry_on_failure": True,
|
||||
"max_retries": 3
|
||||
}
|
||||
|
||||
def _get_app_integration_config(self) -> Dict[str, Any]:
|
||||
"""Get app integration specific configuration"""
|
||||
return {
|
||||
"timeout_seconds": 30,
|
||||
"retry_attempts": 3,
|
||||
"rate_limit_per_minute": 60,
|
||||
"webhook_secret": None,
|
||||
"auth_method": "api_key"
|
||||
}
|
||||
|
||||
def _get_external_service_config(self) -> Dict[str, Any]:
|
||||
"""Get external service specific configuration"""
|
||||
return {
|
||||
"iframe_sandbox": [
|
||||
"allow-same-origin",
|
||||
"allow-scripts",
|
||||
"allow-forms",
|
||||
"allow-popups"
|
||||
],
|
||||
"csp_policy": "default-src 'self'",
|
||||
"session_timeout": 3600,
|
||||
"auto_logout": True,
|
||||
"single_sign_on": True
|
||||
}
|
||||
|
||||
def _get_ai_literacy_config(self) -> Dict[str, Any]:
|
||||
"""Get AI literacy resource specific configuration"""
|
||||
return {
|
||||
"difficulty_adaptive": True,
|
||||
"progress_tracking": True,
|
||||
"multiplayer_enabled": False,
|
||||
"explanation_mode": True,
|
||||
"hint_system": True,
|
||||
"time_limits": False
|
||||
}
|
||||
|
||||
def merge_config(self, custom_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Merge custom configuration with defaults"""
|
||||
default_config = self.get_default_config()
|
||||
merged_config = default_config.copy()
|
||||
merged_config.update(custom_config or {})
|
||||
merged_config.update(self.configuration or {})
|
||||
return merged_config
|
||||
|
||||
def get_available_endpoints(self) -> List[str]:
|
||||
"""Get all available endpoints for this resource"""
|
||||
endpoints = []
|
||||
if self.primary_endpoint:
|
||||
endpoints.append(self.primary_endpoint)
|
||||
if self.api_endpoints:
|
||||
endpoints.extend([ep for ep in self.api_endpoints if ep != self.primary_endpoint])
|
||||
if self.failover_endpoints:
|
||||
endpoints.extend([ep for ep in self.failover_endpoints if ep not in endpoints])
|
||||
return endpoints
|
||||
|
||||
def get_healthy_endpoints(self) -> List[str]:
|
||||
"""Get list of healthy endpoints (for HA routing)"""
|
||||
if self.is_healthy:
|
||||
return self.get_available_endpoints()
|
||||
return []
|
||||
|
||||
def update_health_status(self, status: str, last_check: Optional[datetime] = None) -> None:
|
||||
"""Update health status of the resource"""
|
||||
self.health_status = status
|
||||
self.last_health_check = last_check or datetime.utcnow()
|
||||
|
||||
def calculate_cost(self, tokens_used: int) -> int:
|
||||
"""Calculate cost in cents for token usage"""
|
||||
if self.cost_per_1k_tokens <= 0:
|
||||
return 0
|
||||
return int((tokens_used / 1000) * self.cost_per_1k_tokens * 100)
|
||||
|
||||
@classmethod
|
||||
def get_groq_defaults(cls) -> Dict[str, Any]:
|
||||
"""Get default configuration for Groq resources"""
|
||||
return {
|
||||
"provider": "groq",
|
||||
"api_endpoints": ["https://api.groq.com/openai/v1"],
|
||||
"primary_endpoint": "https://api.groq.com/openai/v1",
|
||||
"health_check_url": "https://api.groq.com/openai/v1/models",
|
||||
"max_requests_per_minute": 30,
|
||||
"max_tokens_per_request": 8000,
|
||||
"latency_sla_ms": 3000,
|
||||
"priority": 100
|
||||
}
|
||||
118
apps/control-panel-backend/app/models/audit.py
Normal file
118
apps/control-panel-backend/app/models/audit.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Audit log database model
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB, INET
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""System audit log for tracking all administrative actions"""
|
||||
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
action = Column(String(100), nullable=False, index=True)
|
||||
resource_type = Column(String(50), nullable=True, index=True)
|
||||
resource_id = Column(String(100), nullable=True)
|
||||
details = Column(JSON, nullable=False, default=dict)
|
||||
ip_address = Column(String(45), nullable=True) # IPv4: 15 chars, IPv6: 45 chars
|
||||
user_agent = Column(Text, nullable=True)
|
||||
|
||||
# Timestamp
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="audit_logs")
|
||||
tenant = relationship("Tenant", back_populates="audit_logs")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AuditLog(id={self.id}, action='{self.action}', user_id={self.user_id})>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert audit log to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"action": self.action,
|
||||
"resource_type": self.resource_type,
|
||||
"resource_id": self.resource_id,
|
||||
"details": self.details,
|
||||
"ip_address": str(self.ip_address) if self.ip_address else None,
|
||||
"user_agent": self.user_agent,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create_log(
|
||||
cls,
|
||||
action: str,
|
||||
user_id: Optional[int] = None,
|
||||
tenant_id: Optional[int] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> "AuditLog":
|
||||
"""Create a new audit log entry"""
|
||||
return cls(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details=details or {},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
|
||||
# Common audit actions
|
||||
class AuditActions:
|
||||
"""Standard audit action constants"""
|
||||
|
||||
# Authentication
|
||||
USER_LOGIN = "user.login"
|
||||
USER_LOGOUT = "user.logout"
|
||||
USER_LOGIN_FAILED = "user.login_failed"
|
||||
|
||||
# User management
|
||||
USER_CREATE = "user.create"
|
||||
USER_UPDATE = "user.update"
|
||||
USER_DELETE = "user.delete"
|
||||
USER_ACTIVATE = "user.activate"
|
||||
USER_DEACTIVATE = "user.deactivate"
|
||||
|
||||
# Tenant management
|
||||
TENANT_CREATE = "tenant.create"
|
||||
TENANT_UPDATE = "tenant.update"
|
||||
TENANT_DELETE = "tenant.delete"
|
||||
TENANT_DEPLOY = "tenant.deploy"
|
||||
TENANT_SUSPEND = "tenant.suspend"
|
||||
TENANT_ACTIVATE = "tenant.activate"
|
||||
|
||||
# Resource management
|
||||
RESOURCE_CREATE = "resource.create"
|
||||
RESOURCE_UPDATE = "resource.update"
|
||||
RESOURCE_DELETE = "resource.delete"
|
||||
RESOURCE_ASSIGN = "resource.assign"
|
||||
RESOURCE_UNASSIGN = "resource.unassign"
|
||||
|
||||
# System actions
|
||||
SYSTEM_BACKUP = "system.backup"
|
||||
SYSTEM_RESTORE = "system.restore"
|
||||
SYSTEM_CONFIG_UPDATE = "system.config_update"
|
||||
|
||||
# Security events
|
||||
SECURITY_POLICY_UPDATE = "security.policy_update"
|
||||
SECURITY_BREACH_DETECTED = "security.breach_detected"
|
||||
SECURITY_ACCESS_DENIED = "security.access_denied"
|
||||
209
apps/control-panel-backend/app/models/model_config.py
Normal file
209
apps/control-panel-backend/app/models/model_config.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Model Configuration Database Schema for GT 2.0 Admin Control Panel
|
||||
|
||||
This model stores configurations for all AI models across the GT 2.0 platform.
|
||||
Configurations are synced to resource clusters via RabbitMQ messages.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, JSON, Boolean, DateTime, Float, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ModelConfig(Base):
|
||||
"""Model configuration stored in PostgreSQL admin database"""
|
||||
__tablename__ = "model_configs"
|
||||
|
||||
# Primary key - UUID
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# Business identifier - unique per provider (same model_id can exist for different providers)
|
||||
model_id = Column(String(255), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
version = Column(String(50), default="1.0")
|
||||
|
||||
# Provider information
|
||||
provider = Column(String(50), nullable=False) # groq, external, openai, anthropic, nvidia
|
||||
model_type = Column(String(50), nullable=False) # llm, embedding, audio, tts, vision
|
||||
|
||||
# Endpoint configuration
|
||||
endpoint = Column(String(500), nullable=False)
|
||||
api_key_name = Column(String(100)) # Environment variable name for API key
|
||||
|
||||
# Model specifications
|
||||
context_window = Column(Integer)
|
||||
max_tokens = Column(Integer)
|
||||
dimensions = Column(Integer) # For embedding models
|
||||
|
||||
# Capabilities (JSON object)
|
||||
capabilities = Column(JSON, default={})
|
||||
|
||||
# Cost information (per million tokens, as per Groq pricing)
|
||||
cost_per_million_input = Column(Float, default=0.0)
|
||||
cost_per_million_output = Column(Float, default=0.0)
|
||||
|
||||
# Configuration and metadata
|
||||
description = Column(Text)
|
||||
config = Column(JSON, default={}) # Additional provider-specific config
|
||||
|
||||
# Status and health
|
||||
is_active = Column(Boolean, default=True)
|
||||
health_status = Column(String(20), default="unknown") # healthy, unhealthy, unknown
|
||||
last_health_check = Column(DateTime)
|
||||
|
||||
# Compound model flag (for pass-through pricing based on actual usage)
|
||||
is_compound = Column(Boolean, default=False)
|
||||
|
||||
# Usage tracking (will be updated from resource clusters)
|
||||
request_count = Column(Integer, default=0)
|
||||
error_count = Column(Integer, default=0)
|
||||
success_rate = Column(Float, default=100.0)
|
||||
avg_latency_ms = Column(Float, default=0.0)
|
||||
|
||||
# Tenant access control (JSON array)
|
||||
# Example: {"allowed_tenants": ["tenant1", "tenant2"], "blocked_tenants": [], "global_access": true}
|
||||
tenant_restrictions = Column(JSON, default=lambda: {"global_access": True})
|
||||
|
||||
# Required capabilities to use this model (JSON array)
|
||||
# Example: ["llm:execute", "advanced:reasoning", "vision:analyze"]
|
||||
required_capabilities = Column(JSON, default=list)
|
||||
|
||||
# Lifecycle timestamps
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
tenant_configs = relationship("TenantModelConfig", back_populates="model_config", cascade="all, delete-orphan")
|
||||
|
||||
# Unique constraint: same model_id can exist for different providers
|
||||
__table_args__ = (
|
||||
UniqueConstraint('model_id', 'provider', name='model_configs_model_id_provider_unique'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
"id": str(self.id) if self.id else None,
|
||||
"model_id": self.model_id,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"provider": self.provider,
|
||||
"model_type": self.model_type,
|
||||
"endpoint": self.endpoint,
|
||||
"api_key_name": self.api_key_name,
|
||||
"specifications": {
|
||||
"context_window": self.context_window,
|
||||
"max_tokens": self.max_tokens,
|
||||
"dimensions": self.dimensions,
|
||||
},
|
||||
"capabilities": self.capabilities or {},
|
||||
"cost": {
|
||||
"per_million_input": self.cost_per_million_input,
|
||||
"per_million_output": self.cost_per_million_output,
|
||||
},
|
||||
"description": self.description,
|
||||
"config": self.config or {},
|
||||
"status": {
|
||||
"is_active": self.is_active,
|
||||
"is_compound": self.is_compound,
|
||||
"health_status": self.health_status,
|
||||
"last_health_check": self.last_health_check.isoformat() if self.last_health_check else None,
|
||||
},
|
||||
"usage": {
|
||||
"request_count": self.request_count,
|
||||
"error_count": self.error_count,
|
||||
"success_rate": self.success_rate,
|
||||
"avg_latency_ms": self.avg_latency_ms,
|
||||
},
|
||||
"access_control": {
|
||||
"tenant_restrictions": self.tenant_restrictions or {},
|
||||
"required_capabilities": self.required_capabilities or [],
|
||||
},
|
||||
"timestamps": {
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> 'ModelConfig':
|
||||
"""Create ModelConfig from dictionary"""
|
||||
# Handle both nested and flat data formats
|
||||
specifications = data.get("specifications", {})
|
||||
cost = data.get("cost", {})
|
||||
status = data.get("status", {})
|
||||
access_control = data.get("access_control", {})
|
||||
|
||||
return cls(
|
||||
model_id=data.get("model_id"),
|
||||
name=data.get("name"),
|
||||
version=data.get("version", "1.0"),
|
||||
provider=data.get("provider"),
|
||||
model_type=data.get("model_type"),
|
||||
endpoint=data.get("endpoint"),
|
||||
api_key_name=data.get("api_key_name"),
|
||||
# Handle both nested and flat context_window/max_tokens with type conversion
|
||||
context_window=int(specifications.get("context_window") or data.get("context_window", 0)) if (specifications.get("context_window") or data.get("context_window")) else None,
|
||||
max_tokens=int(specifications.get("max_tokens") or data.get("max_tokens", 0)) if (specifications.get("max_tokens") or data.get("max_tokens")) else None,
|
||||
dimensions=int(specifications.get("dimensions") or data.get("dimensions", 0)) if (specifications.get("dimensions") or data.get("dimensions")) else None,
|
||||
capabilities=data.get("capabilities", {}),
|
||||
# Handle both nested and flat cost fields with type conversion
|
||||
cost_per_million_input=float(cost.get("per_million_input") or data.get("cost_per_million_input", 0.0)),
|
||||
cost_per_million_output=float(cost.get("per_million_output") or data.get("cost_per_million_output", 0.0)),
|
||||
description=data.get("description"),
|
||||
config=data.get("config", {}),
|
||||
# Handle both nested and flat is_active
|
||||
is_active=status.get("is_active") if status.get("is_active") is not None else data.get("is_active", True),
|
||||
# Handle both nested and flat is_compound
|
||||
is_compound=status.get("is_compound") if status.get("is_compound") is not None else data.get("is_compound", False),
|
||||
tenant_restrictions=access_control.get("tenant_restrictions", data.get("tenant_restrictions", {"global_access": True})),
|
||||
required_capabilities=access_control.get("required_capabilities", data.get("required_capabilities", [])),
|
||||
)
|
||||
|
||||
|
||||
class ModelUsageLog(Base):
|
||||
"""Log of model usage events from resource clusters"""
|
||||
__tablename__ = "model_usage_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
model_id = Column(String(255), nullable=False, index=True)
|
||||
tenant_id = Column(String(100), nullable=False, index=True)
|
||||
user_id = Column(String(100), nullable=False)
|
||||
|
||||
# Usage metrics
|
||||
tokens_input = Column(Integer, default=0)
|
||||
tokens_output = Column(Integer, default=0)
|
||||
tokens_total = Column(Integer, default=0)
|
||||
cost = Column(Float, default=0.0)
|
||||
latency_ms = Column(Float)
|
||||
|
||||
# Request metadata
|
||||
success = Column(Boolean, default=True)
|
||||
error_message = Column(Text)
|
||||
request_id = Column(String(100))
|
||||
|
||||
# Timestamp
|
||||
timestamp = Column(DateTime, default=func.now())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"model_id": self.model_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"user_id": self.user_id,
|
||||
"tokens": {
|
||||
"input": self.tokens_input,
|
||||
"output": self.tokens_output,
|
||||
"total": self.tokens_total,
|
||||
},
|
||||
"cost": self.cost,
|
||||
"latency_ms": self.latency_ms,
|
||||
"success": self.success,
|
||||
"error_message": self.error_message,
|
||||
"request_id": self.request_id,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
}
|
||||
362
apps/control-panel-backend/app/models/resource_schemas.py
Normal file
362
apps/control-panel-backend/app/models/resource_schemas.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Resource-specific configuration schemas for comprehensive resource management
|
||||
|
||||
Defines Pydantic models for validating configuration data for each resource family:
|
||||
- AI/ML Resources (LLMs, embeddings, image generation, function calling)
|
||||
- RAG Engine Resources (vector databases, document processing, retrieval systems)
|
||||
- Agentic Workflow Resources (multi-step AI workflows, agent frameworks)
|
||||
- App Integration Resources (external tools, APIs, webhooks)
|
||||
- External Web Services (Canvas LMS, CTFd, Guacamole, iframe-embedded services)
|
||||
- AI Literacy & Cognitive Skills (educational games, puzzles, learning content)
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional, Union, Literal
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# Base Configuration Schema
|
||||
class BaseResourceConfig(BaseModel):
|
||||
"""Base configuration for all resource types"""
|
||||
timeout_seconds: Optional[int] = Field(30, ge=1, le=3600, description="Request timeout in seconds")
|
||||
retry_attempts: Optional[int] = Field(3, ge=0, le=10, description="Number of retry attempts")
|
||||
rate_limit_per_minute: Optional[int] = Field(60, ge=1, le=10000, description="Rate limit per minute")
|
||||
|
||||
|
||||
# AI/ML Resource Configurations
|
||||
class LLMConfig(BaseResourceConfig):
|
||||
"""Configuration for LLM resources"""
|
||||
max_tokens: Optional[int] = Field(4000, ge=1, le=100000, description="Maximum tokens per request")
|
||||
temperature: Optional[float] = Field(0.7, ge=0.0, le=2.0, description="Sampling temperature")
|
||||
top_p: Optional[float] = Field(1.0, ge=0.0, le=1.0, description="Top-p sampling parameter")
|
||||
frequency_penalty: Optional[float] = Field(0.0, ge=-2.0, le=2.0, description="Frequency penalty")
|
||||
presence_penalty: Optional[float] = Field(0.0, ge=-2.0, le=2.0, description="Presence penalty")
|
||||
stream: Optional[bool] = Field(False, description="Enable streaming responses")
|
||||
stop: Optional[List[str]] = Field(None, description="Stop sequences")
|
||||
system_prompt: Optional[str] = Field(None, description="Default system prompt")
|
||||
|
||||
|
||||
class EmbeddingConfig(BaseResourceConfig):
|
||||
"""Configuration for embedding model resources"""
|
||||
dimensions: Optional[int] = Field(1536, ge=128, le=8192, description="Embedding dimensions")
|
||||
batch_size: Optional[int] = Field(100, ge=1, le=1000, description="Batch processing size")
|
||||
encoding_format: Optional[Literal["float", "base64"]] = Field("float", description="Output encoding format")
|
||||
normalize_embeddings: Optional[bool] = Field(True, description="Normalize embedding vectors")
|
||||
|
||||
|
||||
class ImageGenerationConfig(BaseResourceConfig):
|
||||
"""Configuration for image generation resources"""
|
||||
size: Optional[str] = Field("1024x1024", description="Image dimensions")
|
||||
quality: Optional[Literal["standard", "hd"]] = Field("standard", description="Image quality")
|
||||
style: Optional[Literal["natural", "vivid"]] = Field("natural", description="Image style")
|
||||
response_format: Optional[Literal["url", "b64_json"]] = Field("url", description="Response format")
|
||||
n: Optional[int] = Field(1, ge=1, le=10, description="Number of images to generate")
|
||||
|
||||
|
||||
class FunctionCallingConfig(BaseResourceConfig):
|
||||
"""Configuration for function calling resources"""
|
||||
max_tokens: Optional[int] = Field(4000, ge=1, le=100000, description="Maximum tokens per request")
|
||||
temperature: Optional[float] = Field(0.1, ge=0.0, le=2.0, description="Sampling temperature")
|
||||
function_call: Optional[Union[str, Dict[str, str]]] = Field("auto", description="Function call behavior")
|
||||
tools: Optional[List[Dict[str, Any]]] = Field(default_factory=list, description="Available tools/functions")
|
||||
parallel_tool_calls: Optional[bool] = Field(True, description="Allow parallel tool calls")
|
||||
|
||||
|
||||
# RAG Engine Configurations
|
||||
class VectorDatabaseConfig(BaseResourceConfig):
|
||||
"""Configuration for vector database resources"""
|
||||
chunk_size: Optional[int] = Field(512, ge=64, le=8192, description="Document chunk size")
|
||||
chunk_overlap: Optional[int] = Field(50, ge=0, le=500, description="Chunk overlap size")
|
||||
similarity_threshold: Optional[float] = Field(0.7, ge=0.0, le=1.0, description="Similarity threshold")
|
||||
max_results: Optional[int] = Field(10, ge=1, le=100, description="Maximum search results")
|
||||
rerank: Optional[bool] = Field(True, description="Enable result reranking")
|
||||
include_metadata: Optional[bool] = Field(True, description="Include document metadata")
|
||||
similarity_metric: Optional[Literal["cosine", "euclidean", "dot_product"]] = Field("cosine", description="Similarity metric")
|
||||
|
||||
|
||||
class DocumentProcessorConfig(BaseResourceConfig):
|
||||
"""Configuration for document processing resources"""
|
||||
supported_formats: Optional[List[str]] = Field(
|
||||
default_factory=lambda: ["pdf", "docx", "txt", "md", "html"],
|
||||
description="Supported document formats"
|
||||
)
|
||||
extract_images: Optional[bool] = Field(False, description="Extract images from documents")
|
||||
ocr_enabled: Optional[bool] = Field(False, description="Enable OCR for scanned documents")
|
||||
preserve_formatting: Optional[bool] = Field(True, description="Preserve document formatting")
|
||||
max_file_size_mb: Optional[int] = Field(50, ge=1, le=1000, description="Maximum file size in MB")
|
||||
|
||||
|
||||
# Agentic Workflow Configurations
|
||||
class WorkflowConfig(BaseResourceConfig):
|
||||
"""Configuration for agentic workflow resources"""
|
||||
max_iterations: Optional[int] = Field(10, ge=1, le=100, description="Maximum workflow iterations")
|
||||
timeout_seconds: Optional[int] = Field(300, ge=30, le=3600, description="Workflow timeout")
|
||||
auto_approve: Optional[bool] = Field(False, description="Auto-approve workflow steps")
|
||||
human_in_loop: Optional[bool] = Field(True, description="Require human approval")
|
||||
retry_on_failure: Optional[bool] = Field(True, description="Retry failed steps")
|
||||
max_retries: Optional[int] = Field(3, ge=0, le=10, description="Maximum retry attempts per step")
|
||||
parallel_execution: Optional[bool] = Field(False, description="Enable parallel step execution")
|
||||
checkpoint_enabled: Optional[bool] = Field(True, description="Save workflow checkpoints")
|
||||
|
||||
|
||||
class AgentFrameworkConfig(BaseResourceConfig):
|
||||
"""Configuration for agent framework resources"""
|
||||
agent_type: Optional[str] = Field("conversational", description="Type of agent")
|
||||
memory_enabled: Optional[bool] = Field(True, description="Enable agent memory")
|
||||
memory_type: Optional[Literal["buffer", "summary", "vector"]] = Field("buffer", description="Memory storage type")
|
||||
max_memory_size: Optional[int] = Field(1000, ge=100, le=10000, description="Maximum memory entries")
|
||||
tools_enabled: Optional[bool] = Field(True, description="Enable agent tools")
|
||||
max_tool_calls: Optional[int] = Field(5, ge=1, le=20, description="Maximum tool calls per turn")
|
||||
|
||||
|
||||
# App Integration Configurations
|
||||
class APIIntegrationConfig(BaseResourceConfig):
|
||||
"""Configuration for API integration resources"""
|
||||
auth_method: Optional[Literal["api_key", "bearer_token", "oauth2", "basic_auth"]] = Field("api_key", description="Authentication method")
|
||||
base_url: Optional[str] = Field(None, description="Base URL for API")
|
||||
headers: Optional[Dict[str, str]] = Field(default_factory=dict, description="Default headers")
|
||||
webhook_enabled: Optional[bool] = Field(False, description="Enable webhook support")
|
||||
webhook_secret: Optional[str] = Field(None, description="Webhook validation secret")
|
||||
rate_limit_strategy: Optional[Literal["fixed", "sliding", "token_bucket"]] = Field("fixed", description="Rate limiting strategy")
|
||||
|
||||
|
||||
class WebhookConfig(BaseResourceConfig):
|
||||
"""Configuration for webhook resources"""
|
||||
endpoint_url: Optional[str] = Field(None, description="Webhook endpoint URL")
|
||||
secret_token: Optional[str] = Field(None, description="Secret for webhook validation")
|
||||
supported_events: Optional[List[str]] = Field(default_factory=list, description="Supported event types")
|
||||
retry_policy: Optional[Dict[str, Any]] = Field(
|
||||
default_factory=lambda: {"max_retries": 3, "backoff_multiplier": 2},
|
||||
description="Retry policy for failed webhooks"
|
||||
)
|
||||
signature_header: Optional[str] = Field("X-Hub-Signature-256", description="Signature header name")
|
||||
|
||||
|
||||
# External Service Configurations
|
||||
class IframeServiceConfig(BaseResourceConfig):
|
||||
"""Configuration for iframe-embedded external services"""
|
||||
iframe_url: str = Field(..., description="URL to embed in iframe")
|
||||
sandbox_permissions: Optional[List[str]] = Field(
|
||||
default_factory=lambda: ["allow-same-origin", "allow-scripts", "allow-forms", "allow-popups"],
|
||||
description="Iframe sandbox permissions"
|
||||
)
|
||||
csp_policy: Optional[str] = Field("default-src 'self'", description="Content Security Policy")
|
||||
session_timeout: Optional[int] = Field(3600, ge=300, le=86400, description="Session timeout in seconds")
|
||||
auto_logout: Optional[bool] = Field(True, description="Auto logout on session timeout")
|
||||
single_sign_on: Optional[bool] = Field(True, description="Enable single sign-on")
|
||||
resize_enabled: Optional[bool] = Field(True, description="Allow iframe resizing")
|
||||
width: Optional[str] = Field("100%", description="Iframe width")
|
||||
height: Optional[str] = Field("600px", description="Iframe height")
|
||||
|
||||
|
||||
class LMSIntegrationConfig(IframeServiceConfig):
|
||||
"""Configuration for Learning Management System integration"""
|
||||
lms_type: Optional[Literal["canvas", "moodle", "blackboard", "schoology"]] = Field("canvas", description="LMS platform type")
|
||||
course_id: Optional[str] = Field(None, description="Course identifier")
|
||||
assignment_sync: Optional[bool] = Field(True, description="Sync assignments")
|
||||
grade_passback: Optional[bool] = Field(True, description="Enable grade passback")
|
||||
enrollment_sync: Optional[bool] = Field(False, description="Sync enrollments")
|
||||
|
||||
|
||||
class CyberRangeConfig(IframeServiceConfig):
|
||||
"""Configuration for cyber range environments (CTFd, Guacamole, etc.)"""
|
||||
platform_type: Optional[Literal["ctfd", "guacamole", "custom"]] = Field("ctfd", description="Cyber range platform")
|
||||
vm_template: Optional[str] = Field(None, description="Virtual machine template")
|
||||
network_isolation: Optional[bool] = Field(True, description="Enable network isolation")
|
||||
auto_destroy: Optional[bool] = Field(True, description="Auto-destroy sessions")
|
||||
max_session_duration: Optional[int] = Field(14400, ge=1800, le=86400, description="Maximum session duration")
|
||||
resource_limits: Optional[Dict[str, str]] = Field(
|
||||
default_factory=lambda: {"cpu": "2", "memory": "4Gi", "storage": "20Gi"},
|
||||
description="Resource limits for VMs"
|
||||
)
|
||||
|
||||
|
||||
# AI Literacy Configurations
|
||||
class StrategicGameConfig(BaseResourceConfig):
|
||||
"""Configuration for strategic games (Chess, Go, etc.)"""
|
||||
game_type: Literal["chess", "go", "poker", "bridge", "custom"] = Field(..., description="Type of strategic game")
|
||||
ai_opponent_model: Optional[str] = Field(None, description="AI model for opponent")
|
||||
difficulty_levels: Optional[List[str]] = Field(
|
||||
default_factory=lambda: ["beginner", "intermediate", "expert", "adaptive"],
|
||||
description="Available difficulty levels"
|
||||
)
|
||||
explanation_mode: Optional[bool] = Field(True, description="Provide move explanations")
|
||||
hint_system: Optional[bool] = Field(True, description="Enable hints")
|
||||
multiplayer_enabled: Optional[bool] = Field(False, description="Support multiple players")
|
||||
time_controls: Optional[Dict[str, int]] = Field(
|
||||
default_factory=lambda: {"blitz": 300, "rapid": 900, "classical": 1800},
|
||||
description="Time control options in seconds"
|
||||
)
|
||||
|
||||
|
||||
class LogicPuzzleConfig(BaseResourceConfig):
|
||||
"""Configuration for logic puzzles"""
|
||||
puzzle_types: Optional[List[str]] = Field(
|
||||
default_factory=lambda: ["sudoku", "logic_grid", "lateral_thinking", "mathematical"],
|
||||
description="Types of puzzles available"
|
||||
)
|
||||
difficulty_adaptive: Optional[bool] = Field(True, description="Adapt difficulty based on performance")
|
||||
progress_tracking: Optional[bool] = Field(True, description="Track user progress")
|
||||
hint_system: Optional[bool] = Field(True, description="Provide hints")
|
||||
time_limits: Optional[bool] = Field(False, description="Enable time limits")
|
||||
collaborative_solving: Optional[bool] = Field(False, description="Allow collaborative solving")
|
||||
|
||||
|
||||
class PhilosophicalDilemmaConfig(BaseResourceConfig):
|
||||
"""Configuration for philosophical dilemma resources"""
|
||||
dilemma_categories: Optional[List[str]] = Field(
|
||||
default_factory=lambda: ["ethics", "epistemology", "metaphysics", "logic"],
|
||||
description="Categories of philosophical dilemmas"
|
||||
)
|
||||
ai_socratic_method: Optional[bool] = Field(True, description="Use AI for Socratic questioning")
|
||||
debate_mode: Optional[bool] = Field(True, description="Enable debate functionality")
|
||||
argument_analysis: Optional[bool] = Field(True, description="Analyze argument structure")
|
||||
bias_detection: Optional[bool] = Field(True, description="Detect cognitive biases")
|
||||
multi_perspective: Optional[bool] = Field(True, description="Present multiple perspectives")
|
||||
|
||||
|
||||
class EducationalContentConfig(BaseResourceConfig):
|
||||
"""Configuration for educational content resources"""
|
||||
content_type: Optional[Literal["interactive", "video", "text", "mixed"]] = Field("mixed", description="Type of content")
|
||||
adaptive_learning: Optional[bool] = Field(True, description="Adapt to learner progress")
|
||||
assessment_enabled: Optional[bool] = Field(True, description="Include assessments")
|
||||
prerequisite_checking: Optional[bool] = Field(True, description="Check prerequisites")
|
||||
learning_analytics: Optional[bool] = Field(True, description="Collect learning analytics")
|
||||
personalization_level: Optional[Literal["none", "basic", "advanced"]] = Field("basic", description="Personalization level")
|
||||
|
||||
|
||||
# Configuration Union Type
|
||||
ResourceConfigType = Union[
|
||||
# AI/ML
|
||||
LLMConfig,
|
||||
EmbeddingConfig,
|
||||
ImageGenerationConfig,
|
||||
FunctionCallingConfig,
|
||||
# RAG Engine
|
||||
VectorDatabaseConfig,
|
||||
DocumentProcessorConfig,
|
||||
# Agentic Workflow
|
||||
WorkflowConfig,
|
||||
AgentFrameworkConfig,
|
||||
# App Integration
|
||||
APIIntegrationConfig,
|
||||
WebhookConfig,
|
||||
# External Service
|
||||
IframeServiceConfig,
|
||||
LMSIntegrationConfig,
|
||||
CyberRangeConfig,
|
||||
# AI Literacy
|
||||
StrategicGameConfig,
|
||||
LogicPuzzleConfig,
|
||||
PhilosophicalDilemmaConfig,
|
||||
EducationalContentConfig
|
||||
]
|
||||
|
||||
|
||||
def get_config_schema(resource_type: str, resource_subtype: str) -> BaseResourceConfig:
|
||||
"""Get the appropriate configuration schema for a resource type and subtype"""
|
||||
if resource_type == "ai_ml":
|
||||
if resource_subtype == "llm":
|
||||
return LLMConfig()
|
||||
elif resource_subtype == "embedding":
|
||||
return EmbeddingConfig()
|
||||
elif resource_subtype == "image_generation":
|
||||
return ImageGenerationConfig()
|
||||
elif resource_subtype == "function_calling":
|
||||
return FunctionCallingConfig()
|
||||
elif resource_type == "rag_engine":
|
||||
if resource_subtype == "vector_database":
|
||||
return VectorDatabaseConfig()
|
||||
elif resource_subtype == "document_processor":
|
||||
return DocumentProcessorConfig()
|
||||
elif resource_type == "agentic_workflow":
|
||||
if resource_subtype == "workflow":
|
||||
return WorkflowConfig()
|
||||
elif resource_subtype == "agent_framework":
|
||||
return AgentFrameworkConfig()
|
||||
elif resource_type == "app_integration":
|
||||
if resource_subtype == "api":
|
||||
return APIIntegrationConfig()
|
||||
elif resource_subtype == "webhook":
|
||||
return WebhookConfig()
|
||||
elif resource_type == "external_service":
|
||||
if resource_subtype == "lms":
|
||||
return LMSIntegrationConfig()
|
||||
elif resource_subtype == "cyber_range":
|
||||
return CyberRangeConfig()
|
||||
elif resource_subtype == "iframe":
|
||||
return IframeServiceConfig()
|
||||
elif resource_type == "ai_literacy":
|
||||
if resource_subtype == "strategic_game":
|
||||
return StrategicGameConfig()
|
||||
elif resource_subtype == "logic_puzzle":
|
||||
return LogicPuzzleConfig()
|
||||
elif resource_subtype == "philosophical_dilemma":
|
||||
return PhilosophicalDilemmaConfig()
|
||||
elif resource_subtype == "educational_content":
|
||||
return EducationalContentConfig()
|
||||
|
||||
# Default fallback
|
||||
return BaseResourceConfig()
|
||||
|
||||
|
||||
def validate_resource_config(resource_type: str, resource_subtype: str, config_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Validate resource configuration data against the appropriate schema"""
|
||||
schema = get_config_schema(resource_type, resource_subtype)
|
||||
|
||||
# Create instance with provided data
|
||||
if resource_type == "ai_ml":
|
||||
if resource_subtype == "llm":
|
||||
validated = LLMConfig(**config_data)
|
||||
elif resource_subtype == "embedding":
|
||||
validated = EmbeddingConfig(**config_data)
|
||||
elif resource_subtype == "image_generation":
|
||||
validated = ImageGenerationConfig(**config_data)
|
||||
elif resource_subtype == "function_calling":
|
||||
validated = FunctionCallingConfig(**config_data)
|
||||
else:
|
||||
validated = BaseResourceConfig(**config_data)
|
||||
elif resource_type == "rag_engine":
|
||||
if resource_subtype == "vector_database":
|
||||
validated = VectorDatabaseConfig(**config_data)
|
||||
elif resource_subtype == "document_processor":
|
||||
validated = DocumentProcessorConfig(**config_data)
|
||||
else:
|
||||
validated = BaseResourceConfig(**config_data)
|
||||
elif resource_type == "agentic_workflow":
|
||||
if resource_subtype == "workflow":
|
||||
validated = WorkflowConfig(**config_data)
|
||||
elif resource_subtype == "agent_framework":
|
||||
validated = AgentFrameworkConfig(**config_data)
|
||||
else:
|
||||
validated = BaseResourceConfig(**config_data)
|
||||
elif resource_type == "app_integration":
|
||||
if resource_subtype == "api":
|
||||
validated = APIIntegrationConfig(**config_data)
|
||||
elif resource_subtype == "webhook":
|
||||
validated = WebhookConfig(**config_data)
|
||||
else:
|
||||
validated = BaseResourceConfig(**config_data)
|
||||
elif resource_type == "external_service":
|
||||
if resource_subtype == "lms":
|
||||
validated = LMSIntegrationConfig(**config_data)
|
||||
elif resource_subtype == "cyber_range":
|
||||
validated = CyberRangeConfig(**config_data)
|
||||
elif resource_subtype == "iframe":
|
||||
validated = IframeServiceConfig(**config_data)
|
||||
else:
|
||||
validated = BaseResourceConfig(**config_data)
|
||||
elif resource_type == "ai_literacy":
|
||||
if resource_subtype == "strategic_game":
|
||||
validated = StrategicGameConfig(**config_data)
|
||||
elif resource_subtype == "logic_puzzle":
|
||||
validated = LogicPuzzleConfig(**config_data)
|
||||
elif resource_subtype == "philosophical_dilemma":
|
||||
validated = PhilosophicalDilemmaConfig(**config_data)
|
||||
elif resource_subtype == "educational_content":
|
||||
validated = EducationalContentConfig(**config_data)
|
||||
else:
|
||||
validated = BaseResourceConfig(**config_data)
|
||||
else:
|
||||
validated = BaseResourceConfig(**config_data)
|
||||
|
||||
return validated.dict(exclude_unset=True)
|
||||
209
apps/control-panel-backend/app/models/resource_usage.py
Normal file
209
apps/control-panel-backend/app/models/resource_usage.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Resource Usage and Quota Models for GT 2.0 Control Panel
|
||||
|
||||
Tracks resource allocation and usage across all tenants with granular monitoring.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ResourceQuota(Base):
|
||||
"""
|
||||
Resource quotas allocated to tenants.
|
||||
|
||||
Tracks maximum allowed usage per resource type with cost tracking.
|
||||
"""
|
||||
__tablename__ = "resource_quotas"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
resource_type = Column(String(50), nullable=False, index=True) # cpu, memory, storage, api_calls, etc.
|
||||
max_value = Column(Float, nullable=False) # Maximum allowed value
|
||||
current_usage = Column(Float, default=0.0, nullable=False) # Current usage
|
||||
warning_threshold = Column(Float, default=0.8, nullable=False) # Warning at 80%
|
||||
critical_threshold = Column(Float, default=0.95, nullable=False) # Critical at 95%
|
||||
unit = Column(String(20), nullable=False) # units, MB, cores, calls/hour, etc.
|
||||
cost_per_unit = Column(Float, default=0.0, nullable=False) # Cost per unit of usage
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="resource_quotas")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ResourceQuota(tenant_id={self.tenant_id}, type={self.resource_type}, usage={self.current_usage}/{self.max_value})>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"resource_type": self.resource_type,
|
||||
"max_value": self.max_value,
|
||||
"current_usage": self.current_usage,
|
||||
"usage_percentage": (self.current_usage / self.max_value * 100) if self.max_value > 0 else 0,
|
||||
"warning_threshold": self.warning_threshold,
|
||||
"critical_threshold": self.critical_threshold,
|
||||
"unit": self.unit,
|
||||
"cost_per_unit": self.cost_per_unit,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
class ResourceUsage(Base):
|
||||
"""
|
||||
Historical resource usage records.
|
||||
|
||||
Tracks all resource consumption events for billing and analytics.
|
||||
"""
|
||||
__tablename__ = "resource_usage"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
resource_type = Column(String(50), nullable=False, index=True)
|
||||
usage_amount = Column(Float, nullable=False) # Amount of resource used (can be negative for refunds)
|
||||
cost = Column(Float, default=0.0, nullable=False) # Cost of this usage
|
||||
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
usage_metadata = Column(Text) # JSON metadata about the usage event
|
||||
user_id = Column(String(100)) # User who initiated the usage (optional)
|
||||
service = Column(String(50)) # Service that generated the usage (optional)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="resource_usage_records")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ResourceUsage(tenant_id={self.tenant_id}, type={self.resource_type}, amount={self.usage_amount}, cost=${self.cost})>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"resource_type": self.resource_type,
|
||||
"usage_amount": self.usage_amount,
|
||||
"cost": self.cost,
|
||||
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
||||
"metadata": self.usage_metadata,
|
||||
"user_id": self.user_id,
|
||||
"service": self.service
|
||||
}
|
||||
|
||||
|
||||
class ResourceAlert(Base):
|
||||
"""
|
||||
Resource usage alerts and notifications.
|
||||
|
||||
Generated when resource usage exceeds thresholds.
|
||||
"""
|
||||
__tablename__ = "resource_alerts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
resource_type = Column(String(50), nullable=False, index=True)
|
||||
alert_level = Column(String(20), nullable=False, index=True) # info, warning, critical
|
||||
message = Column(Text, nullable=False)
|
||||
current_usage = Column(Float, nullable=False)
|
||||
max_value = Column(Float, nullable=False)
|
||||
percentage_used = Column(Float, nullable=False)
|
||||
acknowledged = Column(Boolean, default=False, nullable=False)
|
||||
acknowledged_by = Column(String(100)) # User who acknowledged the alert
|
||||
acknowledged_at = Column(DateTime)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="resource_alerts")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ResourceAlert(tenant_id={self.tenant_id}, level={self.alert_level}, type={self.resource_type})>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"resource_type": self.resource_type,
|
||||
"alert_level": self.alert_level,
|
||||
"message": self.message,
|
||||
"current_usage": self.current_usage,
|
||||
"max_value": self.max_value,
|
||||
"percentage_used": self.percentage_used,
|
||||
"acknowledged": self.acknowledged,
|
||||
"acknowledged_by": self.acknowledged_by,
|
||||
"acknowledged_at": self.acknowledged_at.isoformat() if self.acknowledged_at else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def acknowledge(self, user_id: str):
|
||||
"""Acknowledge this alert"""
|
||||
self.acknowledged = True
|
||||
self.acknowledged_by = user_id
|
||||
self.acknowledged_at = datetime.utcnow()
|
||||
|
||||
|
||||
class ResourceTemplate(Base):
|
||||
"""
|
||||
Predefined resource allocation templates.
|
||||
|
||||
Templates for different tenant tiers (startup, standard, enterprise).
|
||||
"""
|
||||
__tablename__ = "resource_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(50), unique=True, nullable=False, index=True)
|
||||
display_name = Column(String(100), nullable=False)
|
||||
description = Column(Text)
|
||||
template_data = Column(Text, nullable=False) # JSON resource configuration
|
||||
monthly_cost = Column(Float, default=0.0, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ResourceTemplate(name={self.name}, cost=${self.monthly_cost})>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"display_name": self.display_name,
|
||||
"description": self.description,
|
||||
"template_data": self.template_data,
|
||||
"monthly_cost": self.monthly_cost,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
class SystemMetrics(Base):
|
||||
"""
|
||||
System-wide resource metrics and capacity planning data.
|
||||
|
||||
Tracks aggregate usage across all tenants for capacity planning.
|
||||
"""
|
||||
__tablename__ = "system_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
metric_name = Column(String(100), nullable=False, index=True)
|
||||
metric_value = Column(Float, nullable=False)
|
||||
metric_unit = Column(String(20), nullable=False)
|
||||
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
metric_metadata = Column(Text) # JSON metadata about the metric
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemMetrics(name={self.metric_name}, value={self.metric_value}, timestamp={self.timestamp})>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"metric_name": self.metric_name,
|
||||
"metric_value": self.metric_value,
|
||||
"metric_unit": self.metric_unit,
|
||||
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
||||
"metadata": self.metric_metadata
|
||||
}
|
||||
90
apps/control-panel-backend/app/models/session.py
Normal file
90
apps/control-panel-backend/app/models/session.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Session database model for server-side session tracking.
|
||||
|
||||
OWASP/NIST Compliant Session Management (Issue #264):
|
||||
- Server-side session state is authoritative
|
||||
- Tracks idle timeout (30 min) and absolute timeout (8 hours)
|
||||
- Session token hash stored (never plaintext)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Session(Base):
|
||||
"""Server-side session model for OWASP/NIST compliant session management"""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
session_token_hash = Column(String(64), unique=True, nullable=False, index=True) # SHA-256 hash
|
||||
|
||||
# Session timing (NIST SP 800-63B compliant)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
last_activity_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
absolute_expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Session metadata for security auditing
|
||||
ip_address = Column(String(45), nullable=True) # IPv6 compatible
|
||||
user_agent = Column(Text, nullable=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
||||
|
||||
# Session state
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
revoke_reason = Column(String(50), nullable=True) # 'logout', 'idle_timeout', 'absolute_timeout', 'admin_revoke', 'password_change', 'cleanup_stale'
|
||||
ended_at = Column(DateTime(timezone=True), nullable=True) # When session ended (any reason: logout, timeout, etc.)
|
||||
app_type = Column(String(20), default='control_panel', nullable=False) # 'control_panel' or 'tenant_app'
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="sessions")
|
||||
tenant = relationship("Tenant", backref="sessions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Session(id={self.id}, user_id={self.user_id}, is_active={self.is_active})>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert session to dictionary (excluding sensitive data)"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"user_id": self.user_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"last_activity_at": self.last_activity_at.isoformat() if self.last_activity_at else None,
|
||||
"absolute_expires_at": self.absolute_expires_at.isoformat() if self.absolute_expires_at else None,
|
||||
"ip_address": self.ip_address,
|
||||
"is_active": self.is_active,
|
||||
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None,
|
||||
"revoke_reason": self.revoke_reason,
|
||||
"ended_at": self.ended_at.isoformat() if self.ended_at else None,
|
||||
"app_type": self.app_type,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if session is expired (either idle or absolute)"""
|
||||
if not self.is_active:
|
||||
return True
|
||||
|
||||
now = datetime.now(self.absolute_expires_at.tzinfo) if self.absolute_expires_at.tzinfo else datetime.utcnow()
|
||||
|
||||
# Check absolute timeout
|
||||
if now >= self.absolute_expires_at:
|
||||
return True
|
||||
|
||||
# Check idle timeout (30 minutes)
|
||||
from datetime import timedelta
|
||||
idle_timeout = timedelta(minutes=30)
|
||||
idle_expires_at = self.last_activity_at + idle_timeout
|
||||
|
||||
if now >= idle_expires_at:
|
||||
return True
|
||||
|
||||
return False
|
||||
151
apps/control-panel-backend/app/models/system.py
Normal file
151
apps/control-panel-backend/app/models/system.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
System management models for version tracking, updates, and backups
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, JSON, Enum as SQLEnum, BigInteger
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
import enum
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class UpdateStatus(str, enum.Enum):
|
||||
"""Update job status states"""
|
||||
pending = "pending"
|
||||
in_progress = "in_progress"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
rolled_back = "rolled_back"
|
||||
|
||||
|
||||
class BackupType(str, enum.Enum):
|
||||
"""Backup types"""
|
||||
manual = "manual"
|
||||
pre_update = "pre_update"
|
||||
scheduled = "scheduled"
|
||||
|
||||
|
||||
class SystemVersion(Base):
|
||||
"""Track installed system versions"""
|
||||
|
||||
__tablename__ = "system_versions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
version = Column(String(50), nullable=False, index=True)
|
||||
installed_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
installed_by = Column(String(255), nullable=True) # User email or "system"
|
||||
is_current = Column(Boolean, default=True, nullable=False)
|
||||
release_notes = Column(Text, nullable=True)
|
||||
git_commit = Column(String(40), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemVersion(id={self.id}, version='{self.version}', current={self.is_current})>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": self.uuid,
|
||||
"version": self.version,
|
||||
"installed_at": self.installed_at.isoformat() if self.installed_at else None,
|
||||
"installed_by": self.installed_by,
|
||||
"is_current": self.is_current,
|
||||
"release_notes": self.release_notes,
|
||||
"git_commit": self.git_commit
|
||||
}
|
||||
|
||||
|
||||
class UpdateJob(Base):
|
||||
"""Track update job execution"""
|
||||
|
||||
__tablename__ = "update_jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False, index=True)
|
||||
target_version = Column(String(50), nullable=False)
|
||||
status = Column(SQLEnum(UpdateStatus), default=UpdateStatus.pending, nullable=False, index=True)
|
||||
started_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
current_stage = Column(String(100), nullable=True) # e.g., "pulling_images", "backing_up", "migrating_db"
|
||||
logs = Column(JSON, default=list, nullable=False) # Array of log entries with timestamps
|
||||
error_message = Column(Text, nullable=True)
|
||||
backup_id = Column(Integer, nullable=True) # Reference to pre-update backup
|
||||
started_by = Column(String(255), nullable=True) # User email
|
||||
rollback_reason = Column(Text, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UpdateJob(id={self.id}, version='{self.target_version}', status='{self.status}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": self.uuid,
|
||||
"target_version": self.target_version,
|
||||
"status": self.status.value if isinstance(self.status, UpdateStatus) else self.status,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"current_stage": self.current_stage,
|
||||
"logs": self.logs or [],
|
||||
"error_message": self.error_message,
|
||||
"backup_id": self.backup_id,
|
||||
"started_by": self.started_by,
|
||||
"rollback_reason": self.rollback_reason
|
||||
}
|
||||
|
||||
def add_log(self, message: str, level: str = "info"):
|
||||
"""Add a log entry"""
|
||||
if self.logs is None:
|
||||
self.logs = []
|
||||
self.logs.append({
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"level": level,
|
||||
"message": message
|
||||
})
|
||||
|
||||
|
||||
class BackupRecord(Base):
|
||||
"""Track system backups"""
|
||||
|
||||
__tablename__ = "backup_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False, index=True)
|
||||
backup_type = Column(SQLEnum(BackupType), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
size_bytes = Column(BigInteger, nullable=True) # Size of backup archive
|
||||
location = Column(String(500), nullable=False) # Full path to backup file
|
||||
version = Column(String(50), nullable=True) # System version at backup time
|
||||
components = Column(JSON, default=dict, nullable=False) # Which components backed up
|
||||
checksum = Column(String(64), nullable=True) # SHA256 checksum
|
||||
created_by = Column(String(255), nullable=True) # User email or "system"
|
||||
description = Column(Text, nullable=True)
|
||||
is_valid = Column(Boolean, default=True, nullable=False) # False if corrupted
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True) # Retention policy
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BackupRecord(id={self.id}, type='{self.backup_type}', version='{self.version}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": self.uuid,
|
||||
"backup_type": self.backup_type.value if isinstance(self.backup_type, BackupType) else self.backup_type,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"size_bytes": self.size_bytes,
|
||||
"size": self.size_bytes, # Alias for frontend compatibility
|
||||
"size_mb": round(self.size_bytes / (1024 * 1024), 2) if self.size_bytes else None,
|
||||
"location": self.location,
|
||||
"version": self.version,
|
||||
"components": self.components or {},
|
||||
"checksum": self.checksum,
|
||||
"created_by": self.created_by,
|
||||
"description": self.description,
|
||||
"is_valid": self.is_valid,
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
"download_url": f"/api/v1/system/backups/{self.uuid}/download" if self.is_valid else None
|
||||
}
|
||||
163
apps/control-panel-backend/app/models/tenant.py
Normal file
163
apps/control-panel-backend/app/models/tenant.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Tenant database model
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, UniqueConstraint, JSON, Numeric
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
"""Tenant model for multi-tenancy"""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
domain = Column(String(50), unique=True, nullable=False, index=True)
|
||||
template = Column(String(20), nullable=False, default="basic")
|
||||
status = Column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="pending",
|
||||
index=True
|
||||
) # pending, deploying, active, suspended, terminated
|
||||
max_users = Column(Integer, nullable=False, default=100)
|
||||
resource_limits = Column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=lambda: {"cpu": "1000m", "memory": "2Gi", "storage": "10Gi"}
|
||||
)
|
||||
namespace = Column(String(100), unique=True, nullable=False)
|
||||
subdomain = Column(String(50), unique=True, nullable=False)
|
||||
database_path = Column(String(255), nullable=True)
|
||||
encryption_key = Column(Text, nullable=True)
|
||||
|
||||
# Frontend URL (for password reset emails, etc.)
|
||||
# If not set, defaults to http://localhost:3002
|
||||
frontend_url = Column(String(255), nullable=True)
|
||||
|
||||
# API Keys (encrypted)
|
||||
api_keys = Column(JSON, default=dict) # {"groq": {"key": "encrypted", "enabled": true}, ...}
|
||||
api_key_encryption_version = Column(String(20), default="v1")
|
||||
|
||||
# Feature toggles
|
||||
optics_enabled = Column(Boolean, default=False) # Enable Optics cost tracking tab
|
||||
|
||||
# Budget fields (Issue #234)
|
||||
monthly_budget_cents = Column(Integer, nullable=True) # NULL = unlimited
|
||||
budget_warning_threshold = Column(Integer, default=80) # Percentage
|
||||
budget_critical_threshold = Column(Integer, default=90) # Percentage
|
||||
budget_enforcement_enabled = Column(Boolean, default=True)
|
||||
|
||||
# Per-tenant storage pricing overrides (Issue #218)
|
||||
# Hot tier: NULL = use system default ($0.15/GiB/month)
|
||||
storage_price_dataset_hot = Column(Numeric(10, 4), nullable=True)
|
||||
storage_price_conversation_hot = Column(Numeric(10, 4), nullable=True)
|
||||
|
||||
# Cold tier: Allocation-based model
|
||||
# Monthly cost = allocated_tibs × price_per_tib
|
||||
cold_storage_allocated_tibs = Column(Numeric(10, 4), nullable=True) # NULL = no cold storage
|
||||
cold_storage_price_per_tib = Column(Numeric(10, 2), nullable=True, default=10.00) # Default $10/TiB/month
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
# users relationship replaced with user_assignments for multi-tenant support
|
||||
user_assignments = relationship("UserTenantAssignment", back_populates="tenant", cascade="all, delete-orphan")
|
||||
tenant_resources = relationship("TenantResource", back_populates="tenant", cascade="all, delete-orphan")
|
||||
usage_records = relationship("UsageRecord", back_populates="tenant", cascade="all, delete-orphan")
|
||||
audit_logs = relationship("AuditLog", back_populates="tenant", cascade="all, delete-orphan")
|
||||
|
||||
# Resource management relationships
|
||||
resource_quotas = relationship("ResourceQuota", back_populates="tenant", cascade="all, delete-orphan")
|
||||
resource_usage_records = relationship("ResourceUsage", back_populates="tenant", cascade="all, delete-orphan")
|
||||
resource_alerts = relationship("ResourceAlert", back_populates="tenant", cascade="all, delete-orphan")
|
||||
|
||||
# Model access relationships
|
||||
model_configs = relationship("TenantModelConfig", back_populates="tenant", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant(id={self.id}, domain='{self.domain}', status='{self.status}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert tenant to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"name": self.name,
|
||||
"domain": self.domain,
|
||||
"template": self.template,
|
||||
"status": self.status,
|
||||
"max_users": self.max_users,
|
||||
"resource_limits": self.resource_limits,
|
||||
"namespace": self.namespace,
|
||||
"subdomain": self.subdomain,
|
||||
"frontend_url": self.frontend_url,
|
||||
"api_keys_configured": {k: v.get('enabled', False) for k, v in (self.api_keys or {}).items()},
|
||||
"optics_enabled": self.optics_enabled or False,
|
||||
"monthly_budget_cents": self.monthly_budget_cents,
|
||||
"budget_warning_threshold": self.budget_warning_threshold or 80,
|
||||
"budget_critical_threshold": self.budget_critical_threshold or 90,
|
||||
"budget_enforcement_enabled": self.budget_enforcement_enabled or False,
|
||||
"storage_price_dataset_hot": float(self.storage_price_dataset_hot) if self.storage_price_dataset_hot else None,
|
||||
"storage_price_conversation_hot": float(self.storage_price_conversation_hot) if self.storage_price_conversation_hot else None,
|
||||
"cold_storage_allocated_tibs": float(self.cold_storage_allocated_tibs) if self.cold_storage_allocated_tibs else None,
|
||||
"cold_storage_price_per_tib": float(self.cold_storage_price_per_tib) if self.cold_storage_price_per_tib else 10.00,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if tenant is active"""
|
||||
return self.status == "active" and self.deleted_at is None
|
||||
|
||||
|
||||
class TenantResource(Base):
|
||||
"""Tenant resource assignments"""
|
||||
|
||||
__tablename__ = "tenant_resources"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
|
||||
resource_id = Column(Integer, ForeignKey("ai_resources.id", ondelete="CASCADE"), nullable=False)
|
||||
usage_limits = Column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=lambda: {"max_requests_per_hour": 1000, "max_tokens_per_request": 4000}
|
||||
)
|
||||
is_enabled = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="tenant_resources")
|
||||
ai_resource = relationship("AIResource", back_populates="tenant_resources")
|
||||
|
||||
# Unique constraint
|
||||
__table_args__ = (
|
||||
UniqueConstraint('tenant_id', 'resource_id', name='unique_tenant_resource'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantResource(tenant_id={self.tenant_id}, resource_id={self.resource_id})>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert tenant resource to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"resource_id": self.resource_id,
|
||||
"usage_limits": self.usage_limits,
|
||||
"is_enabled": self.is_enabled,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
213
apps/control-panel-backend/app/models/tenant_model_config.py
Normal file
213
apps/control-panel-backend/app/models/tenant_model_config.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Tenant Model Configuration Database Schema for GT 2.0 Admin Control Panel
|
||||
|
||||
This model manages which AI models are available to which tenants,
|
||||
along with tenant-specific permissions and rate limits.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, JSON, Boolean, DateTime, Integer, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TenantModelConfig(Base):
|
||||
"""Configuration linking tenants to available models with permissions"""
|
||||
__tablename__ = "tenant_model_configs"
|
||||
|
||||
# Primary key
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign keys
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
# New UUID foreign key to model_configs.id
|
||||
model_config_id = Column(UUID(as_uuid=True), ForeignKey("model_configs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
# Keep model_id for backwards compatibility and easier queries (denormalized)
|
||||
model_id = Column(String(255), nullable=False, index=True)
|
||||
|
||||
# Configuration
|
||||
is_enabled = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Tenant-specific capabilities (JSON object)
|
||||
# Example: {"reasoning": true, "function_calling": false, "vision": true}
|
||||
tenant_capabilities = Column(JSON, default={})
|
||||
|
||||
# Tenant-specific rate limits (JSON object)
|
||||
# Storage: max_requests_per_hour (database format)
|
||||
# API returns: requests_per_minute (1000/min = 60000/hour)
|
||||
# Example: {"max_requests_per_hour": 60000, "max_tokens_per_request": 4000, "concurrent_requests": 5}
|
||||
rate_limits = Column(JSON, default=lambda: {
|
||||
"max_requests_per_hour": 60000, # 1000 requests per minute
|
||||
"max_tokens_per_request": 4000,
|
||||
"concurrent_requests": 5,
|
||||
"max_cost_per_hour": 10.0
|
||||
})
|
||||
|
||||
# Usage constraints (JSON object)
|
||||
# Example: {"allowed_users": ["admin", "developer"], "blocked_users": [], "time_restrictions": {}}
|
||||
usage_constraints = Column(JSON, default={})
|
||||
|
||||
# Priority for this tenant (higher = more priority when resources are limited)
|
||||
priority = Column(Integer, default=1, nullable=False)
|
||||
|
||||
# Lifecycle timestamps
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="model_configs")
|
||||
model_config = relationship("ModelConfig", back_populates="tenant_configs")
|
||||
|
||||
# Unique constraint - one config per tenant-model pair (using UUID now)
|
||||
__table_args__ = (
|
||||
UniqueConstraint('tenant_id', 'model_config_id', name='unique_tenant_model_config'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantModelConfig(tenant_id={self.tenant_id}, model_id='{self.model_id}', enabled={self.is_enabled})>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert to dictionary for API responses.
|
||||
|
||||
Translation layer: Converts database per-hour values to per-minute for API.
|
||||
Database stores max_requests_per_hour, API returns requests_per_minute.
|
||||
"""
|
||||
# Get raw rate limits from database
|
||||
db_rate_limits = self.rate_limits or {}
|
||||
|
||||
# Translate max_requests_per_hour to requests_per_minute
|
||||
api_rate_limits = {}
|
||||
for key, value in db_rate_limits.items():
|
||||
if key == "max_requests_per_hour":
|
||||
# Convert to per-minute for API response
|
||||
api_rate_limits["requests_per_minute"] = value // 60
|
||||
else:
|
||||
# Keep other fields as-is
|
||||
api_rate_limits[key] = value
|
||||
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"model_config_id": str(self.model_config_id) if self.model_config_id else None,
|
||||
"model_id": self.model_id,
|
||||
"is_enabled": self.is_enabled,
|
||||
"tenant_capabilities": self.tenant_capabilities or {},
|
||||
"rate_limits": api_rate_limits, # Translated to per-minute
|
||||
"usage_constraints": self.usage_constraints or {},
|
||||
"priority": self.priority,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat()
|
||||
}
|
||||
|
||||
def can_user_access(self, user_capabilities: List[str], user_id: str) -> bool:
|
||||
"""
|
||||
Check if a user can access this model based on tenant configuration
|
||||
|
||||
Args:
|
||||
user_capabilities: List of user capability strings
|
||||
user_id: User identifier
|
||||
|
||||
Returns:
|
||||
True if user can access the model
|
||||
"""
|
||||
if not self.is_enabled:
|
||||
return False
|
||||
|
||||
constraints = self.usage_constraints or {}
|
||||
|
||||
# Check if user is explicitly blocked
|
||||
if user_id in constraints.get("blocked_users", []):
|
||||
return False
|
||||
|
||||
# Check if there's an allowed users list and user is not in it
|
||||
allowed_users = constraints.get("allowed_users", [])
|
||||
if allowed_users and user_id not in allowed_users:
|
||||
return False
|
||||
|
||||
# Check if user has required capabilities for tenant-specific model access
|
||||
required_caps = constraints.get("required_capabilities", [])
|
||||
if required_caps:
|
||||
for required_cap in required_caps:
|
||||
if required_cap not in user_capabilities:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_effective_rate_limits(self) -> Dict[str, Any]:
|
||||
"""Get effective rate limits with defaults (database format: per-hour)"""
|
||||
defaults = {
|
||||
"max_requests_per_hour": 60000, # 1000 requests per minute
|
||||
"max_tokens_per_request": 4000,
|
||||
"concurrent_requests": 5,
|
||||
"max_cost_per_hour": 10.0
|
||||
}
|
||||
|
||||
rate_limits = self.rate_limits or {}
|
||||
return {**defaults, **rate_limits}
|
||||
|
||||
def check_rate_limit(self, metric: str, current_value: float) -> bool:
|
||||
"""
|
||||
Check if current usage is within rate limits
|
||||
|
||||
Args:
|
||||
metric: Rate limit metric name
|
||||
current_value: Current usage value
|
||||
|
||||
Returns:
|
||||
True if within limits
|
||||
"""
|
||||
limits = self.get_effective_rate_limits()
|
||||
limit = limits.get(metric)
|
||||
|
||||
if limit is None:
|
||||
return True # No limit set
|
||||
|
||||
return current_value <= limit
|
||||
|
||||
@classmethod
|
||||
def create_default_config(
|
||||
cls,
|
||||
tenant_id: int,
|
||||
model_id: str,
|
||||
model_config_id: Optional['UUID'] = None,
|
||||
custom_rate_limits: Optional[Dict[str, Any]] = None,
|
||||
custom_capabilities: Optional[Dict[str, Any]] = None
|
||||
) -> 'TenantModelConfig':
|
||||
"""
|
||||
Create a default tenant model configuration
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
model_id: Model identifier (string, for backwards compatibility)
|
||||
model_config_id: UUID of the model_configs record (required for FK)
|
||||
custom_rate_limits: Optional custom rate limits
|
||||
custom_capabilities: Optional custom capabilities
|
||||
|
||||
Returns:
|
||||
New TenantModelConfig instance
|
||||
"""
|
||||
default_rate_limits = {
|
||||
"max_requests_per_hour": 60000, # 1000 requests per minute
|
||||
"max_tokens_per_request": 4000,
|
||||
"concurrent_requests": 5,
|
||||
"max_cost_per_hour": 10.0
|
||||
}
|
||||
|
||||
if custom_rate_limits:
|
||||
default_rate_limits.update(custom_rate_limits)
|
||||
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
model_config_id=model_config_id,
|
||||
model_id=model_id,
|
||||
is_enabled=True,
|
||||
tenant_capabilities=custom_capabilities or {},
|
||||
rate_limits=default_rate_limits,
|
||||
usage_constraints={},
|
||||
priority=1
|
||||
)
|
||||
59
apps/control-panel-backend/app/models/tenant_template.py
Normal file
59
apps/control-panel-backend/app/models/tenant_template.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Tenant Template Model
|
||||
Stores reusable tenant configuration templates
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TenantTemplate(Base):
|
||||
"""Tenant template model for storing reusable configurations"""
|
||||
|
||||
__tablename__ = "tenant_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
template_data = Column(JSONB, nullable=False)
|
||||
is_default = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantTemplate(id={self.id}, name='{self.name}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert template to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"template_data": self.template_data,
|
||||
"is_default": self.is_default,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Get template summary with resource counts"""
|
||||
model_count = len(self.template_data.get("model_configs", []))
|
||||
agent_count = len(self.template_data.get("agents", []))
|
||||
dataset_count = len(self.template_data.get("datasets", []))
|
||||
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"is_default": self.is_default,
|
||||
"resource_counts": {
|
||||
"models": model_count,
|
||||
"agents": agent_count,
|
||||
"datasets": dataset_count
|
||||
},
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
112
apps/control-panel-backend/app/models/tfa_rate_limit.py
Normal file
112
apps/control-panel-backend/app/models/tfa_rate_limit.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
TFA Verification Rate Limiting Model
|
||||
|
||||
Tracks failed TFA verification attempts per user with 1-minute rolling windows.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import Column, Integer, DateTime, ForeignKey, select
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TFAVerificationRateLimit(Base):
|
||||
"""Track TFA verification attempts per user (user-based rate limiting only)"""
|
||||
|
||||
__tablename__ = "tfa_verification_rate_limits"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
request_count = Column(Integer, nullable=False, default=1)
|
||||
window_start = Column(DateTime(timezone=True), nullable=False)
|
||||
window_end = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationship
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
@staticmethod
|
||||
async def is_rate_limited(user_id: int, db_session) -> bool:
|
||||
"""
|
||||
Check if user is rate limited (5 attempts per 1 minute) - async
|
||||
|
||||
Args:
|
||||
user_id: User ID to check
|
||||
db_session: AsyncSession
|
||||
|
||||
Returns:
|
||||
True if rate limited, False otherwise
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Find active rate limit record for this user
|
||||
result = await db_session.execute(
|
||||
select(TFAVerificationRateLimit).where(
|
||||
TFAVerificationRateLimit.user_id == user_id,
|
||||
TFAVerificationRateLimit.window_end > now
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
return False
|
||||
|
||||
# Check if limit exceeded (5 attempts per minute)
|
||||
return record.request_count >= 5
|
||||
|
||||
@staticmethod
|
||||
async def record_attempt(user_id: int, db_session) -> None:
|
||||
"""
|
||||
Record a TFA verification attempt for user - async
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
db_session: AsyncSession
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Find or create rate limit record
|
||||
result = await db_session.execute(
|
||||
select(TFAVerificationRateLimit).where(
|
||||
TFAVerificationRateLimit.user_id == user_id,
|
||||
TFAVerificationRateLimit.window_end > now
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if record:
|
||||
# Increment existing record
|
||||
record.request_count += 1
|
||||
else:
|
||||
# Create new record with 1-minute window
|
||||
record = TFAVerificationRateLimit(
|
||||
user_id=user_id,
|
||||
request_count=1,
|
||||
window_start=now,
|
||||
window_end=now + timedelta(minutes=1)
|
||||
)
|
||||
db_session.add(record)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
@staticmethod
|
||||
def cleanup_expired(db_session) -> int:
|
||||
"""
|
||||
Clean up expired rate limit records
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
|
||||
Returns:
|
||||
Number of records deleted
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
deleted = db_session.query(TFAVerificationRateLimit).filter(
|
||||
TFAVerificationRateLimit.window_end < now
|
||||
).delete()
|
||||
db_session.commit()
|
||||
return deleted
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TFAVerificationRateLimit(user_id={self.user_id}, count={self.request_count}, window_end={self.window_end})>"
|
||||
70
apps/control-panel-backend/app/models/usage.py
Normal file
70
apps/control-panel-backend/app/models/usage.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Usage tracking database model
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class UsageRecord(Base):
|
||||
"""Usage tracking for billing and monitoring"""
|
||||
|
||||
__tablename__ = "usage_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
resource_id = Column(Integer, ForeignKey("ai_resources.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_email = Column(String(255), nullable=False, index=True)
|
||||
request_type = Column(String(50), nullable=False, index=True) # chat, embedding, image_generation, etc.
|
||||
tokens_used = Column(Integer, nullable=False, default=0)
|
||||
cost_cents = Column(Integer, nullable=False, default=0)
|
||||
request_metadata = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
# Timestamp
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="usage_records")
|
||||
ai_resource = relationship("AIResource", back_populates="usage_records")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UsageRecord(id={self.id}, tenant_id={self.tenant_id}, tokens={self.tokens_used})>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert usage record to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"resource_id": self.resource_id,
|
||||
"user_email": self.user_email,
|
||||
"request_type": self.request_type,
|
||||
"tokens_used": self.tokens_used,
|
||||
"cost_cents": self.cost_cents,
|
||||
"request_metadata": self.request_metadata,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
@property
|
||||
def cost_dollars(self) -> float:
|
||||
"""Get cost in dollars"""
|
||||
return self.cost_cents / 100.0
|
||||
|
||||
@classmethod
|
||||
def calculate_cost(cls, tokens_used: int, resource_type: str, provider: str) -> int:
|
||||
"""Calculate cost in cents based on usage"""
|
||||
# Cost calculation logic (example rates)
|
||||
if provider == "groq":
|
||||
if resource_type == "llm":
|
||||
# Groq LLM pricing: ~$0.0001 per 1K tokens
|
||||
return max(1, int((tokens_used / 1000) * 0.01 * 100)) # Convert to cents
|
||||
elif resource_type == "embedding":
|
||||
# Embedding pricing: ~$0.00002 per 1K tokens
|
||||
return max(1, int((tokens_used / 1000) * 0.002 * 100)) # Convert to cents
|
||||
|
||||
# Default fallback cost
|
||||
return max(1, int((tokens_used / 1000) * 0.001 * 100)) # 0.1 cents per 1K tokens
|
||||
154
apps/control-panel-backend/app/models/used_temp_token.py
Normal file
154
apps/control-panel-backend/app/models/used_temp_token.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Used Temp Token Model for Replay Prevention and TFA Session Management
|
||||
|
||||
Tracks temporary tokens that have been used for TFA verification to prevent replay attacks.
|
||||
Also serves as TFA session storage for server-side session management.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class UsedTempToken(Base):
|
||||
"""
|
||||
Track used temporary tokens to prevent replay attacks.
|
||||
Also stores TFA session data for server-side session management.
|
||||
"""
|
||||
|
||||
__tablename__ = "used_temp_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
token_id = Column(String(255), nullable=False, unique=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
used_at = Column(DateTime(timezone=True), nullable=True) # NULL until token is used
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
# TFA Session Data (for server-side session management)
|
||||
user_email = Column(String(255), nullable=True) # User email for TFA session
|
||||
tfa_configured = Column(Boolean, nullable=True) # Whether TFA is already configured
|
||||
qr_code_uri = Column(Text, nullable=True) # QR code data URI (only if setup needed)
|
||||
manual_entry_key = Column(String(255), nullable=True) # Manual entry key (only if setup needed)
|
||||
temp_token = Column(Text, nullable=True) # Actual JWT temp token for verification
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationship
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
@staticmethod
|
||||
async def is_token_used(token_id: str, db_session) -> bool:
|
||||
"""
|
||||
Check if token has already been used (async)
|
||||
|
||||
Note: A token is "used" if used_at is NOT NULL.
|
||||
Records with used_at=NULL are active TFA sessions, not used tokens.
|
||||
|
||||
Args:
|
||||
token_id: Unique token identifier
|
||||
db_session: AsyncSession
|
||||
|
||||
Returns:
|
||||
True if token has been used (used_at is set), False otherwise
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db_session.execute(
|
||||
select(UsedTempToken).where(
|
||||
UsedTempToken.token_id == token_id,
|
||||
UsedTempToken.used_at.isnot(None), # Check if used_at is set
|
||||
UsedTempToken.expires_at > datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
return record is not None
|
||||
|
||||
@staticmethod
|
||||
def create_tfa_session(
|
||||
token_id: str,
|
||||
user_id: int,
|
||||
user_email: str,
|
||||
tfa_configured: bool,
|
||||
temp_token: str,
|
||||
qr_code_uri: str = None,
|
||||
manual_entry_key: str = None,
|
||||
db_session = None,
|
||||
expires_minutes: int = 5
|
||||
) -> 'UsedTempToken':
|
||||
"""
|
||||
Create a new TFA session (server-side)
|
||||
|
||||
Args:
|
||||
token_id: Unique token identifier (session ID)
|
||||
user_id: User ID
|
||||
user_email: User email
|
||||
tfa_configured: Whether TFA is already configured
|
||||
temp_token: JWT temp token for verification
|
||||
qr_code_uri: QR code data URI (if setup needed)
|
||||
manual_entry_key: Manual entry key (if setup needed)
|
||||
db_session: Database session
|
||||
expires_minutes: Minutes until expiry (default 5)
|
||||
|
||||
Returns:
|
||||
Created session record
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
record = UsedTempToken(
|
||||
token_id=token_id,
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
tfa_configured=tfa_configured,
|
||||
temp_token=temp_token,
|
||||
qr_code_uri=qr_code_uri,
|
||||
manual_entry_key=manual_entry_key,
|
||||
created_at=now,
|
||||
used_at=None, # Not used yet
|
||||
expires_at=now + timedelta(minutes=expires_minutes)
|
||||
)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def mark_token_used(token_id: str, user_id: int, db_session, expires_minutes: int = 5) -> None:
|
||||
"""
|
||||
Mark token as used (backward compatibility for existing code)
|
||||
|
||||
Args:
|
||||
token_id: Unique token identifier
|
||||
user_id: User ID
|
||||
db_session: Database session
|
||||
expires_minutes: Minutes until expiry (default 5)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
record = UsedTempToken(
|
||||
token_id=token_id,
|
||||
user_id=user_id,
|
||||
used_at=now,
|
||||
expires_at=now + timedelta(minutes=expires_minutes)
|
||||
)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
|
||||
@staticmethod
|
||||
def cleanup_expired(db_session) -> int:
|
||||
"""
|
||||
Clean up expired token records
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
|
||||
Returns:
|
||||
Number of records deleted
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
deleted = db_session.query(UsedTempToken).filter(
|
||||
UsedTempToken.expires_at < now
|
||||
).delete()
|
||||
db_session.commit()
|
||||
return deleted
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UsedTempToken(token_id={self.token_id}, user_id={self.user_id}, used_at={self.used_at})>"
|
||||
229
apps/control-panel-backend/app/models/user.py
Normal file
229
apps/control-panel-backend/app/models/user.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
User database model
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model with capability-based authorization"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
full_name = Column(String(100), nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
user_type = Column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="tenant_user"
|
||||
) # super_admin, tenant_admin, tenant_user
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
|
||||
current_tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) # Current active tenant for multi-tenant users
|
||||
capabilities = Column(JSON, nullable=False, default=list)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
last_login = Column(DateTime(timezone=True), nullable=True) # For billing calculation
|
||||
last_login_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Two-Factor Authentication fields
|
||||
tfa_enabled = Column(Boolean, nullable=False, default=False)
|
||||
tfa_secret = Column(Text, nullable=True) # Encrypted TOTP secret
|
||||
tfa_required = Column(Boolean, nullable=False, default=False) # Admin can enforce TFA
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
tenant_assignments = relationship("UserTenantAssignment", foreign_keys="UserTenantAssignment.user_id", back_populates="user", cascade="all, delete-orphan")
|
||||
audit_logs = relationship("AuditLog", back_populates="user", cascade="all, delete-orphan")
|
||||
resource_data = relationship("UserResourceData", back_populates="user", cascade="all, delete-orphan")
|
||||
preferences = relationship("UserPreferences", back_populates="user", cascade="all, delete-orphan", uselist=False)
|
||||
progress = relationship("UserProgress", back_populates="user", cascade="all, delete-orphan")
|
||||
sessions = relationship("Session", back_populates="user", passive_deletes=True) # Let DB CASCADE handle deletion
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, email='{self.email}', user_type='{self.user_type}')>"
|
||||
|
||||
def to_dict(self, include_sensitive: bool = False, include_tenants: bool = False) -> Dict[str, Any]:
|
||||
"""Convert user to dictionary"""
|
||||
data = {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"email": self.email,
|
||||
"full_name": self.full_name,
|
||||
"user_type": self.user_type,
|
||||
"current_tenant_id": self.current_tenant_id,
|
||||
"capabilities": self.capabilities,
|
||||
"is_active": self.is_active,
|
||||
"last_login_at": self.last_login_at.isoformat() if self.last_login_at else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
# TFA fields (never include tfa_secret for security)
|
||||
"tfa_enabled": self.tfa_enabled,
|
||||
"tfa_required": self.tfa_required,
|
||||
"tfa_status": self.tfa_status
|
||||
}
|
||||
|
||||
if include_tenants:
|
||||
data["tenant_assignments"] = [
|
||||
assignment.to_dict() for assignment in self.tenant_assignments
|
||||
if assignment.is_active and not assignment.deleted_at
|
||||
]
|
||||
|
||||
if include_sensitive:
|
||||
data["hashed_password"] = self.hashed_password
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def is_super_admin(self) -> bool:
|
||||
"""Check if user is super admin"""
|
||||
return self.user_type == "super_admin"
|
||||
|
||||
@property
|
||||
def is_tenant_admin(self) -> bool:
|
||||
"""Check if user is tenant admin"""
|
||||
return self.user_type == "tenant_admin"
|
||||
|
||||
@property
|
||||
def is_tenant_user(self) -> bool:
|
||||
"""Check if user is regular tenant user"""
|
||||
return self.user_type == "tenant_user"
|
||||
|
||||
@property
|
||||
def tfa_status(self) -> str:
|
||||
"""Get TFA status: disabled, enabled, or enforced"""
|
||||
if self.tfa_required:
|
||||
return "enforced"
|
||||
elif self.tfa_enabled:
|
||||
return "enabled"
|
||||
else:
|
||||
return "disabled"
|
||||
|
||||
def has_capability(self, resource: str, action: str) -> bool:
|
||||
"""Check if user has specific capability"""
|
||||
if not self.capabilities:
|
||||
return False
|
||||
|
||||
for capability in self.capabilities:
|
||||
# Check resource match (support wildcards)
|
||||
resource_match = (
|
||||
capability.get("resource") == "*" or
|
||||
capability.get("resource") == resource or
|
||||
(capability.get("resource", "").endswith("*") and
|
||||
resource.startswith(capability.get("resource", "").rstrip("*")))
|
||||
)
|
||||
|
||||
# Check action match
|
||||
actions = capability.get("actions", [])
|
||||
action_match = "*" in actions or action in actions
|
||||
|
||||
if resource_match and action_match:
|
||||
# Check constraints if present
|
||||
constraints = capability.get("constraints", {})
|
||||
if constraints:
|
||||
# Check validity period
|
||||
valid_until = constraints.get("valid_until")
|
||||
if valid_until:
|
||||
from datetime import datetime
|
||||
if datetime.fromisoformat(valid_until.replace('Z', '+00:00')) < datetime.now():
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_tenant_assignment(self, tenant_id: int) -> Optional['UserTenantAssignment']:
|
||||
"""Get user's assignment for specific tenant"""
|
||||
from app.models.user_tenant_assignment import UserTenantAssignment
|
||||
for assignment in self.tenant_assignments:
|
||||
if assignment.tenant_id == tenant_id and assignment.is_active and not assignment.deleted_at:
|
||||
return assignment
|
||||
return None
|
||||
|
||||
def get_current_tenant_assignment(self) -> Optional['UserTenantAssignment']:
|
||||
"""Get user's current active tenant assignment"""
|
||||
if not self.current_tenant_id:
|
||||
return self.get_primary_tenant_assignment()
|
||||
return self.get_tenant_assignment(self.current_tenant_id)
|
||||
|
||||
def get_primary_tenant_assignment(self) -> Optional['UserTenantAssignment']:
|
||||
"""Get user's primary tenant assignment"""
|
||||
for assignment in self.tenant_assignments:
|
||||
if assignment.is_primary_tenant and assignment.is_active and not assignment.deleted_at:
|
||||
return assignment
|
||||
# Fallback to first active assignment
|
||||
active_assignments = [a for a in self.tenant_assignments if a.is_active and not a.deleted_at]
|
||||
return active_assignments[0] if active_assignments else None
|
||||
|
||||
def get_available_tenants(self) -> List['UserTenantAssignment']:
|
||||
"""Get all tenant assignments user has access to"""
|
||||
return [
|
||||
assignment for assignment in self.tenant_assignments
|
||||
if assignment.is_active and not assignment.deleted_at
|
||||
]
|
||||
|
||||
def has_tenant_access(self, tenant_id: int) -> bool:
|
||||
"""Check if user has access to specific tenant"""
|
||||
return self.get_tenant_assignment(tenant_id) is not None
|
||||
|
||||
def switch_to_tenant(self, tenant_id: int) -> bool:
|
||||
"""Switch user's current tenant context"""
|
||||
if self.has_tenant_access(tenant_id):
|
||||
self.current_tenant_id = tenant_id
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_tenant_capabilities(self, tenant_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""Get capabilities for specific tenant or current tenant"""
|
||||
target_tenant_id = tenant_id or self.current_tenant_id
|
||||
if not target_tenant_id:
|
||||
return []
|
||||
|
||||
assignment = self.get_tenant_assignment(target_tenant_id)
|
||||
if not assignment:
|
||||
return []
|
||||
|
||||
return assignment.tenant_capabilities or []
|
||||
|
||||
def has_tenant_capability(self, resource: str, action: str, tenant_id: Optional[int] = None) -> bool:
|
||||
"""Check if user has specific capability in tenant"""
|
||||
target_tenant_id = tenant_id or self.current_tenant_id
|
||||
if not target_tenant_id:
|
||||
return False
|
||||
|
||||
assignment = self.get_tenant_assignment(target_tenant_id)
|
||||
if not assignment:
|
||||
return False
|
||||
|
||||
return assignment.has_capability(resource, action)
|
||||
|
||||
def is_tenant_admin(self, tenant_id: Optional[int] = None) -> bool:
|
||||
"""Check if user is admin in specific tenant"""
|
||||
target_tenant_id = tenant_id or self.current_tenant_id
|
||||
if not target_tenant_id:
|
||||
return False
|
||||
|
||||
assignment = self.get_tenant_assignment(target_tenant_id)
|
||||
if not assignment:
|
||||
return False
|
||||
|
||||
return assignment.is_tenant_admin
|
||||
|
||||
def get_current_tenant_context(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get current tenant context for JWT token"""
|
||||
assignment = self.get_current_tenant_assignment()
|
||||
if not assignment:
|
||||
return None
|
||||
return assignment.get_tenant_context()
|
||||
347
apps/control-panel-backend/app/models/user_data.py
Normal file
347
apps/control-panel-backend/app/models/user_data.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
User data separation models for comprehensive personalization support
|
||||
|
||||
Supports 3 personalization modes:
|
||||
- Shared: Data shared across all users (default for most resources)
|
||||
- User-scoped: Each user has isolated data (conversations, preferences, progress)
|
||||
- Session-based: Data isolated per session (temporary, disposable)
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Float, JSON, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class UserResourceData(Base):
|
||||
"""User-specific data for resources that support personalization"""
|
||||
|
||||
__tablename__ = "user_resource_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
|
||||
# Foreign Keys
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
resource_id = Column(Integer, ForeignKey("ai_resources.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# Data Storage
|
||||
data_type = Column(String(50), nullable=False, index=True) # preferences, progress, state, conversation
|
||||
data_key = Column(String(100), nullable=False, index=True) # Identifier for the specific data
|
||||
data_value = Column(JSON, nullable=False, default=dict) # The actual data
|
||||
|
||||
# Metadata
|
||||
is_encrypted = Column(Boolean, nullable=False, default=False)
|
||||
expiry_date = Column(DateTime(timezone=True), nullable=True) # For session-based data
|
||||
version = Column(Integer, nullable=False, default=1) # For data versioning
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
accessed_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="resource_data")
|
||||
tenant = relationship("Tenant")
|
||||
resource = relationship("AIResource")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserResourceData(user_id={self.user_id}, resource_id={self.resource_id}, data_type='{self.data_type}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"user_id": self.user_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"resource_id": self.resource_id,
|
||||
"data_type": self.data_type,
|
||||
"data_key": self.data_key,
|
||||
"data_value": self.data_value,
|
||||
"is_encrypted": self.is_encrypted,
|
||||
"expiry_date": self.expiry_date.isoformat() if self.expiry_date else None,
|
||||
"version": self.version,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"accessed_at": self.accessed_at.isoformat() if self.accessed_at else None
|
||||
}
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if data has expired (for session-based resources)"""
|
||||
if not self.expiry_date:
|
||||
return False
|
||||
return datetime.utcnow() > self.expiry_date
|
||||
|
||||
def update_access_time(self) -> None:
|
||||
"""Update the last accessed timestamp"""
|
||||
self.accessed_at = datetime.utcnow()
|
||||
|
||||
|
||||
class UserPreferences(Base):
|
||||
"""User preferences for various resources and system settings"""
|
||||
|
||||
__tablename__ = "user_preferences"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
|
||||
# Foreign Keys
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# Preference Categories
|
||||
ui_preferences = Column(JSON, nullable=False, default=dict) # Theme, layout, accessibility
|
||||
ai_preferences = Column(JSON, nullable=False, default=dict) # Model preferences, system prompts
|
||||
learning_preferences = Column(JSON, nullable=False, default=dict) # AI literacy settings, difficulty
|
||||
privacy_preferences = Column(JSON, nullable=False, default=dict) # Data sharing, analytics opt-out
|
||||
notification_preferences = Column(JSON, nullable=False, default=dict) # Email, in-app notifications
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="preferences")
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserPreferences(user_id={self.user_id}, tenant_id={self.tenant_id})>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"user_id": self.user_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"ui_preferences": self.ui_preferences,
|
||||
"ai_preferences": self.ai_preferences,
|
||||
"learning_preferences": self.learning_preferences,
|
||||
"privacy_preferences": self.privacy_preferences,
|
||||
"notification_preferences": self.notification_preferences,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def get_preference(self, category: str, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific preference value"""
|
||||
category_data = getattr(self, f"{category}_preferences", {})
|
||||
return category_data.get(key, default)
|
||||
|
||||
def set_preference(self, category: str, key: str, value: Any) -> None:
|
||||
"""Set a specific preference value"""
|
||||
if hasattr(self, f"{category}_preferences"):
|
||||
current_prefs = getattr(self, f"{category}_preferences") or {}
|
||||
current_prefs[key] = value
|
||||
setattr(self, f"{category}_preferences", current_prefs)
|
||||
|
||||
|
||||
class UserProgress(Base):
|
||||
"""User progress tracking for AI literacy and learning resources"""
|
||||
|
||||
__tablename__ = "user_progress"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
|
||||
# Foreign Keys
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
resource_id = Column(Integer, ForeignKey("ai_resources.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# Progress Data
|
||||
skill_area = Column(String(50), nullable=False, index=True) # chess, logic, critical_thinking, etc.
|
||||
current_level = Column(String(20), nullable=False, default="beginner") # beginner, intermediate, expert
|
||||
experience_points = Column(Integer, nullable=False, default=0)
|
||||
completion_percentage = Column(Float, nullable=False, default=0.0) # 0.0 to 100.0
|
||||
|
||||
# Performance Metrics
|
||||
total_sessions = Column(Integer, nullable=False, default=0)
|
||||
total_time_minutes = Column(Integer, nullable=False, default=0)
|
||||
success_rate = Column(Float, nullable=False, default=0.0) # 0.0 to 100.0
|
||||
average_score = Column(Float, nullable=False, default=0.0)
|
||||
|
||||
# Detailed Progress Data
|
||||
achievements = Column(JSON, nullable=False, default=list) # List of earned achievements
|
||||
milestones = Column(JSON, nullable=False, default=dict) # Progress milestones
|
||||
learning_analytics = Column(JSON, nullable=False, default=dict) # Detailed analytics data
|
||||
|
||||
# Adaptive Learning
|
||||
difficulty_adjustments = Column(JSON, nullable=False, default=dict) # Difficulty level adjustments
|
||||
strength_areas = Column(JSON, nullable=False, default=list) # Areas of strength
|
||||
improvement_areas = Column(JSON, nullable=False, default=list) # Areas needing improvement
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
last_activity = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="progress")
|
||||
tenant = relationship("Tenant")
|
||||
resource = relationship("AIResource")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserProgress(user_id={self.user_id}, skill_area='{self.skill_area}', level='{self.current_level}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"user_id": self.user_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"resource_id": self.resource_id,
|
||||
"skill_area": self.skill_area,
|
||||
"current_level": self.current_level,
|
||||
"experience_points": self.experience_points,
|
||||
"completion_percentage": self.completion_percentage,
|
||||
"total_sessions": self.total_sessions,
|
||||
"total_time_minutes": self.total_time_minutes,
|
||||
"success_rate": self.success_rate,
|
||||
"average_score": self.average_score,
|
||||
"achievements": self.achievements,
|
||||
"milestones": self.milestones,
|
||||
"learning_analytics": self.learning_analytics,
|
||||
"difficulty_adjustments": self.difficulty_adjustments,
|
||||
"strength_areas": self.strength_areas,
|
||||
"improvement_areas": self.improvement_areas,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"last_activity": self.last_activity.isoformat() if self.last_activity else None
|
||||
}
|
||||
|
||||
def add_achievement(self, achievement: str) -> None:
|
||||
"""Add an achievement to the user's list"""
|
||||
if achievement not in self.achievements:
|
||||
achievements = self.achievements or []
|
||||
achievements.append(achievement)
|
||||
self.achievements = achievements
|
||||
|
||||
def update_score(self, new_score: float) -> None:
|
||||
"""Update average score with new score"""
|
||||
if self.total_sessions == 0:
|
||||
self.average_score = new_score
|
||||
else:
|
||||
total_score = self.average_score * self.total_sessions
|
||||
total_score += new_score
|
||||
self.total_sessions += 1
|
||||
self.average_score = total_score / self.total_sessions
|
||||
|
||||
def calculate_success_rate(self, successful_attempts: int, total_attempts: int) -> None:
|
||||
"""Calculate and update success rate"""
|
||||
if total_attempts > 0:
|
||||
self.success_rate = (successful_attempts / total_attempts) * 100.0
|
||||
|
||||
|
||||
class SessionData(Base):
|
||||
"""Session-based data for temporary, disposable user interactions"""
|
||||
|
||||
__tablename__ = "session_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
|
||||
# Foreign Keys
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
resource_id = Column(Integer, ForeignKey("ai_resources.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# Session Info
|
||||
session_id = Column(String(100), nullable=False, index=True) # Browser/app session ID
|
||||
data_type = Column(String(50), nullable=False, index=True) # conversation, game_state, temp_files
|
||||
data_content = Column(JSON, nullable=False, default=dict) # Session-specific data
|
||||
|
||||
# Auto-cleanup
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
auto_cleanup = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
last_accessed = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
tenant = relationship("Tenant")
|
||||
resource = relationship("AIResource")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SessionData(session_id='{self.session_id}', user_id={self.user_id}, data_type='{self.data_type}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"user_id": self.user_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"resource_id": self.resource_id,
|
||||
"session_id": self.session_id,
|
||||
"data_type": self.data_type,
|
||||
"data_content": self.data_content,
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
"auto_cleanup": self.auto_cleanup,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"last_accessed": self.last_accessed.isoformat() if self.last_accessed else None
|
||||
}
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if session data has expired"""
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
def extend_expiry(self, minutes: int = 60) -> None:
|
||||
"""Extend the expiry time by specified minutes"""
|
||||
self.expires_at = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
self.last_accessed = datetime.utcnow()
|
||||
|
||||
|
||||
# Data separation utility functions
|
||||
def get_user_data_scope(resource, user_id: int, tenant_id: int, session_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get appropriate data scope based on resource personalization mode"""
|
||||
if resource.personalization_mode == "shared":
|
||||
return {"scope": "tenant", "tenant_id": tenant_id}
|
||||
elif resource.personalization_mode == "user_scoped":
|
||||
return {"scope": "user", "user_id": user_id, "tenant_id": tenant_id}
|
||||
elif resource.personalization_mode == "session_based":
|
||||
return {"scope": "session", "user_id": user_id, "tenant_id": tenant_id, "session_id": session_id}
|
||||
else:
|
||||
# Default to shared
|
||||
return {"scope": "tenant", "tenant_id": tenant_id}
|
||||
|
||||
|
||||
def cleanup_expired_session_data() -> None:
|
||||
"""Utility function to clean up expired session data (should be run periodically)"""
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.database import engine
|
||||
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
|
||||
try:
|
||||
# Delete expired session data
|
||||
expired_count = db.query(SessionData).filter(
|
||||
SessionData.expires_at < datetime.utcnow(),
|
||||
SessionData.auto_cleanup == True
|
||||
).delete()
|
||||
|
||||
# Clean up expired user resource data
|
||||
expired_user_data = db.query(UserResourceData).filter(
|
||||
UserResourceData.expiry_date < datetime.utcnow(),
|
||||
UserResourceData.expiry_date.isnot(None)
|
||||
).delete()
|
||||
|
||||
db.commit()
|
||||
return {"session_data_cleaned": expired_count, "user_data_cleaned": expired_user_data}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise e
|
||||
finally:
|
||||
db.close()
|
||||
250
apps/control-panel-backend/app/models/user_tenant_assignment.py
Normal file
250
apps/control-panel-backend/app/models/user_tenant_assignment.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
User-Tenant Assignment Model for Multi-Tenant User Management
|
||||
|
||||
Manages the many-to-many relationship between users and tenants with
|
||||
tenant-specific user details, roles, and capabilities.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class UserTenantAssignment(Base):
|
||||
"""
|
||||
User-Tenant Assignment with tenant-specific user details and roles
|
||||
|
||||
This model allows users to:
|
||||
- Belong to multiple tenants with different roles
|
||||
- Have tenant-specific display names and contact info
|
||||
- Have different capabilities per tenant
|
||||
- Track activity per tenant
|
||||
"""
|
||||
|
||||
__tablename__ = "user_tenant_assignments"
|
||||
|
||||
# Composite primary key
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# Tenant-specific user profile
|
||||
tenant_user_role = Column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="tenant_user"
|
||||
) # super_admin, tenant_admin, tenant_user
|
||||
tenant_display_name = Column(String(100), nullable=True) # Optional tenant-specific name
|
||||
tenant_email = Column(String(255), nullable=True, index=True) # Optional tenant-specific email
|
||||
tenant_department = Column(String(100), nullable=True) # Department within tenant
|
||||
tenant_title = Column(String(100), nullable=True) # Job title within tenant
|
||||
|
||||
# Tenant-specific authentication (optional)
|
||||
tenant_password_hash = Column(String(255), nullable=True) # Tenant-specific password if required
|
||||
requires_2fa = Column(Boolean, nullable=False, default=False)
|
||||
last_password_change = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Tenant-specific permissions and limits
|
||||
tenant_capabilities = Column(JSON, nullable=False, default=list) # Tenant-specific capabilities
|
||||
resource_limits = Column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=lambda: {
|
||||
"max_conversations": 100,
|
||||
"max_datasets": 10,
|
||||
"max_agents": 20,
|
||||
"daily_api_calls": 1000
|
||||
}
|
||||
)
|
||||
|
||||
# Status and activity tracking
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
is_primary_tenant = Column(Boolean, nullable=False, default=False) # User's main tenant
|
||||
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
last_accessed = Column(DateTime(timezone=True), nullable=True)
|
||||
last_login_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Invitation tracking
|
||||
invited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
invitation_accepted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True) # Soft delete
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", foreign_keys=[user_id], back_populates="tenant_assignments")
|
||||
tenant = relationship("Tenant", back_populates="user_assignments")
|
||||
inviter = relationship("User", foreign_keys=[invited_by])
|
||||
|
||||
# Unique constraint to prevent duplicate assignments
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'tenant_id', name='unique_user_tenant_assignment'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserTenantAssignment(user_id={self.user_id}, tenant_id={self.tenant_id}, role='{self.tenant_user_role}')>"
|
||||
|
||||
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
|
||||
"""Convert assignment to dictionary"""
|
||||
data = {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"tenant_user_role": self.tenant_user_role,
|
||||
"tenant_display_name": self.tenant_display_name,
|
||||
"tenant_email": self.tenant_email,
|
||||
"tenant_department": self.tenant_department,
|
||||
"tenant_title": self.tenant_title,
|
||||
"requires_2fa": self.requires_2fa,
|
||||
"tenant_capabilities": self.tenant_capabilities,
|
||||
"resource_limits": self.resource_limits,
|
||||
"is_active": self.is_active,
|
||||
"is_primary_tenant": self.is_primary_tenant,
|
||||
"joined_at": self.joined_at.isoformat() if self.joined_at else None,
|
||||
"last_accessed": self.last_accessed.isoformat() if self.last_accessed else None,
|
||||
"last_login_at": self.last_login_at.isoformat() if self.last_login_at else None,
|
||||
"invitation_accepted_at": self.invitation_accepted_at.isoformat() if self.invitation_accepted_at else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
if include_sensitive:
|
||||
data["tenant_password_hash"] = self.tenant_password_hash
|
||||
data["last_password_change"] = self.last_password_change.isoformat() if self.last_password_change else None
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def is_tenant_admin(self) -> bool:
|
||||
"""Check if user is tenant admin in this tenant"""
|
||||
return self.tenant_user_role in ["super_admin", "tenant_admin"]
|
||||
|
||||
@property
|
||||
def is_super_admin(self) -> bool:
|
||||
"""Check if user is super admin in this tenant"""
|
||||
return self.tenant_user_role == "super_admin"
|
||||
|
||||
@property
|
||||
def effective_display_name(self) -> str:
|
||||
"""Get effective display name (tenant-specific or fallback to user's name)"""
|
||||
if self.tenant_display_name:
|
||||
return self.tenant_display_name
|
||||
return self.user.full_name if self.user else "Unknown User"
|
||||
|
||||
@property
|
||||
def effective_email(self) -> str:
|
||||
"""Get effective email (tenant-specific or fallback to user's email)"""
|
||||
if self.tenant_email:
|
||||
return self.tenant_email
|
||||
return self.user.email if self.user else "unknown@example.com"
|
||||
|
||||
def has_capability(self, resource: str, action: str) -> bool:
|
||||
"""Check if user has specific capability in this tenant"""
|
||||
if not self.tenant_capabilities:
|
||||
return False
|
||||
|
||||
for capability in self.tenant_capabilities:
|
||||
# Check resource match (support wildcards)
|
||||
resource_match = (
|
||||
capability.get("resource") == "*" or
|
||||
capability.get("resource") == resource or
|
||||
(capability.get("resource", "").endswith("*") and
|
||||
resource.startswith(capability.get("resource", "").rstrip("*")))
|
||||
)
|
||||
|
||||
# Check action match
|
||||
actions = capability.get("actions", [])
|
||||
action_match = "*" in actions or action in actions
|
||||
|
||||
if resource_match and action_match:
|
||||
# Check constraints if present
|
||||
constraints = capability.get("constraints", {})
|
||||
if constraints:
|
||||
# Check validity period
|
||||
valid_until = constraints.get("valid_until")
|
||||
if valid_until:
|
||||
from datetime import datetime
|
||||
if datetime.fromisoformat(valid_until.replace('Z', '+00:00')) < datetime.now():
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def update_last_access(self) -> None:
|
||||
"""Update last accessed timestamp"""
|
||||
self.last_accessed = datetime.utcnow()
|
||||
|
||||
def update_last_login(self) -> None:
|
||||
"""Update last login timestamp"""
|
||||
self.last_login_at = datetime.utcnow()
|
||||
self.last_accessed = datetime.utcnow()
|
||||
|
||||
def get_resource_limit(self, resource_type: str, default: int = 0) -> int:
|
||||
"""Get resource limit for specific resource type"""
|
||||
if not self.resource_limits:
|
||||
return default
|
||||
return self.resource_limits.get(resource_type, default)
|
||||
|
||||
def can_create_resource(self, resource_type: str, current_count: int) -> bool:
|
||||
"""Check if user can create another resource of given type"""
|
||||
limit = self.get_resource_limit(resource_type)
|
||||
return limit == 0 or current_count < limit # 0 means unlimited
|
||||
|
||||
def set_as_primary_tenant(self) -> None:
|
||||
"""Mark this tenant as user's primary tenant"""
|
||||
# This should be called within a transaction to ensure only one primary per user
|
||||
self.is_primary_tenant = True
|
||||
|
||||
def add_capability(self, resource: str, actions: List[str], constraints: Optional[Dict] = None) -> None:
|
||||
"""Add a capability to this user-tenant assignment"""
|
||||
capability = {
|
||||
"resource": resource,
|
||||
"actions": actions
|
||||
}
|
||||
if constraints:
|
||||
capability["constraints"] = constraints
|
||||
|
||||
if not self.tenant_capabilities:
|
||||
self.tenant_capabilities = []
|
||||
|
||||
# Remove existing capability for same resource if exists
|
||||
self.tenant_capabilities = [
|
||||
cap for cap in self.tenant_capabilities
|
||||
if cap.get("resource") != resource
|
||||
]
|
||||
|
||||
self.tenant_capabilities.append(capability)
|
||||
|
||||
def remove_capability(self, resource: str) -> None:
|
||||
"""Remove capability for specific resource"""
|
||||
if not self.tenant_capabilities:
|
||||
return
|
||||
|
||||
self.tenant_capabilities = [
|
||||
cap for cap in self.tenant_capabilities
|
||||
if cap.get("resource") != resource
|
||||
]
|
||||
|
||||
def get_tenant_context(self) -> Dict[str, Any]:
|
||||
"""Get tenant context for JWT token"""
|
||||
return {
|
||||
"id": str(self.tenant_id), # Ensure tenant ID is string for JWT consistency
|
||||
"domain": self.tenant.domain if self.tenant else "unknown",
|
||||
"name": self.tenant.name if self.tenant else "Unknown Tenant",
|
||||
"role": self.tenant_user_role,
|
||||
"display_name": self.effective_display_name,
|
||||
"email": self.effective_email,
|
||||
"department": self.tenant_department,
|
||||
"title": self.tenant_title,
|
||||
"capabilities": self.tenant_capabilities or [],
|
||||
"resource_limits": self.resource_limits or {},
|
||||
"is_primary": self.is_primary_tenant
|
||||
}
|
||||
520
apps/control-panel-backend/app/models/wiki_content.py
Normal file
520
apps/control-panel-backend/app/models/wiki_content.py
Normal file
@@ -0,0 +1,520 @@
|
||||
"""
|
||||
Dynamic Wiki & Documentation System Models
|
||||
|
||||
Supports context-aware documentation that adapts based on:
|
||||
- User's current resource/tool being used
|
||||
- User's role and permissions
|
||||
- Tenant configuration
|
||||
- Learning progress and skill level
|
||||
|
||||
Features:
|
||||
- Versioned content management
|
||||
- Role-based content visibility
|
||||
- Interactive tutorials and guides
|
||||
- Searchable knowledge base
|
||||
- AI-powered content suggestions
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Float, JSON, ForeignKey, Index
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class WikiPage(Base):
|
||||
"""Core wiki page model with versioning and context awareness"""
|
||||
|
||||
__tablename__ = "wiki_pages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
|
||||
# Page Identity
|
||||
title = Column(String(200), nullable=False, index=True)
|
||||
slug = Column(String(250), nullable=False, unique=True, index=True)
|
||||
category = Column(String(50), nullable=False, index=True) # getting_started, tutorials, reference, troubleshooting
|
||||
|
||||
# Content
|
||||
content = Column(Text, nullable=False) # Markdown content
|
||||
excerpt = Column(String(500), nullable=True) # Brief description
|
||||
content_type = Column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="markdown",
|
||||
index=True
|
||||
) # markdown, html, interactive
|
||||
|
||||
# Context Targeting
|
||||
target_resources = Column(JSON, nullable=False, default=list) # Resource IDs this content applies to
|
||||
target_roles = Column(JSON, nullable=False, default=list) # User roles this content is for
|
||||
target_skill_levels = Column(JSON, nullable=False, default=list) # beginner, intermediate, expert
|
||||
tenant_specific = Column(Boolean, nullable=False, default=False) # Tenant-specific content
|
||||
|
||||
# Metadata
|
||||
tags = Column(JSON, nullable=False, default=list) # Searchable tags
|
||||
search_keywords = Column(Text, nullable=True) # Additional search terms
|
||||
featured = Column(Boolean, nullable=False, default=False) # Featured content
|
||||
priority = Column(Integer, nullable=False, default=100) # Display priority (lower = higher priority)
|
||||
|
||||
# Versioning
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
is_current_version = Column(Boolean, nullable=False, default=True, index=True)
|
||||
parent_page_id = Column(Integer, ForeignKey("wiki_pages.id"), nullable=True) # For versioning
|
||||
|
||||
# Publishing
|
||||
is_published = Column(Boolean, nullable=False, default=False, index=True)
|
||||
published_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Analytics
|
||||
view_count = Column(Integer, nullable=False, default=0)
|
||||
helpful_votes = Column(Integer, nullable=False, default=0)
|
||||
not_helpful_votes = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
versions = relationship("WikiPage", remote_side=[id], cascade="all, delete-orphan")
|
||||
parent_page = relationship("WikiPage", remote_side=[id])
|
||||
attachments = relationship("WikiAttachment", back_populates="wiki_page", cascade="all, delete-orphan")
|
||||
|
||||
# Indexes for performance
|
||||
__table_args__ = (
|
||||
Index('idx_wiki_context', 'category', 'is_published', 'is_current_version'),
|
||||
Index('idx_wiki_search', 'title', 'tags', 'search_keywords'),
|
||||
Index('idx_wiki_targeting', 'target_roles', 'target_skill_levels'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<WikiPage(id={self.id}, title='{self.title}', category='{self.category}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"title": self.title,
|
||||
"slug": self.slug,
|
||||
"category": self.category,
|
||||
"content": self.content,
|
||||
"excerpt": self.excerpt,
|
||||
"content_type": self.content_type,
|
||||
"target_resources": self.target_resources,
|
||||
"target_roles": self.target_roles,
|
||||
"target_skill_levels": self.target_skill_levels,
|
||||
"tenant_specific": self.tenant_specific,
|
||||
"tags": self.tags,
|
||||
"search_keywords": self.search_keywords,
|
||||
"featured": self.featured,
|
||||
"priority": self.priority,
|
||||
"version": self.version,
|
||||
"is_current_version": self.is_current_version,
|
||||
"parent_page_id": self.parent_page_id,
|
||||
"is_published": self.is_published,
|
||||
"published_at": self.published_at.isoformat() if self.published_at else None,
|
||||
"view_count": self.view_count,
|
||||
"helpful_votes": self.helpful_votes,
|
||||
"not_helpful_votes": self.not_helpful_votes,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
@property
|
||||
def helpfulness_score(self) -> float:
|
||||
"""Calculate helpfulness score (0-100)"""
|
||||
total_votes = self.helpful_votes + self.not_helpful_votes
|
||||
if total_votes == 0:
|
||||
return 0.0
|
||||
return (self.helpful_votes / total_votes) * 100.0
|
||||
|
||||
def increment_view(self) -> None:
|
||||
"""Increment view count"""
|
||||
self.view_count += 1
|
||||
|
||||
def add_helpful_vote(self) -> None:
|
||||
"""Add helpful vote"""
|
||||
self.helpful_votes += 1
|
||||
|
||||
def add_not_helpful_vote(self) -> None:
|
||||
"""Add not helpful vote"""
|
||||
self.not_helpful_votes += 1
|
||||
|
||||
def matches_context(self, resource_ids: List[int], user_role: str, skill_level: str) -> bool:
|
||||
"""Check if page matches current user context"""
|
||||
# Check resource targeting
|
||||
if self.target_resources and not any(rid in self.target_resources for rid in resource_ids):
|
||||
return False
|
||||
|
||||
# Check role targeting
|
||||
if self.target_roles and user_role not in self.target_roles:
|
||||
return False
|
||||
|
||||
# Check skill level targeting
|
||||
if self.target_skill_levels and skill_level not in self.target_skill_levels:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class WikiAttachment(Base):
|
||||
"""Attachments for wiki pages (images, files, etc.)"""
|
||||
|
||||
__tablename__ = "wiki_attachments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
|
||||
# Foreign Keys
|
||||
wiki_page_id = Column(Integer, ForeignKey("wiki_pages.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# File Information
|
||||
filename = Column(String(255), nullable=False)
|
||||
original_filename = Column(String(255), nullable=False)
|
||||
file_type = Column(String(50), nullable=False, index=True) # image, document, video, etc.
|
||||
mime_type = Column(String(100), nullable=False)
|
||||
file_size_bytes = Column(Integer, nullable=False)
|
||||
|
||||
# Storage
|
||||
storage_path = Column(String(500), nullable=False) # Path to file in storage
|
||||
public_url = Column(String(500), nullable=True) # Public URL if applicable
|
||||
|
||||
# Metadata
|
||||
alt_text = Column(String(200), nullable=True) # For accessibility
|
||||
caption = Column(String(500), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
wiki_page = relationship("WikiPage", back_populates="attachments")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<WikiAttachment(id={self.id}, filename='{self.filename}', page_id={self.wiki_page_id})>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"wiki_page_id": self.wiki_page_id,
|
||||
"filename": self.filename,
|
||||
"original_filename": self.original_filename,
|
||||
"file_type": self.file_type,
|
||||
"mime_type": self.mime_type,
|
||||
"file_size_bytes": self.file_size_bytes,
|
||||
"storage_path": self.storage_path,
|
||||
"public_url": self.public_url,
|
||||
"alt_text": self.alt_text,
|
||||
"caption": self.caption,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
|
||||
class InteractiveTutorial(Base):
|
||||
"""Interactive step-by-step tutorials"""
|
||||
|
||||
__tablename__ = "interactive_tutorials"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
|
||||
# Tutorial Identity
|
||||
title = Column(String(200), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
difficulty_level = Column(String(20), nullable=False, default="beginner", index=True)
|
||||
estimated_duration = Column(Integer, nullable=True) # Minutes
|
||||
|
||||
# Tutorial Structure
|
||||
steps = Column(JSON, nullable=False, default=list) # Ordered list of tutorial steps
|
||||
prerequisites = Column(JSON, nullable=False, default=list) # Required knowledge/skills
|
||||
learning_objectives = Column(JSON, nullable=False, default=list) # What user will learn
|
||||
|
||||
# Context
|
||||
resource_id = Column(Integer, ForeignKey("ai_resources.id"), nullable=True, index=True)
|
||||
category = Column(String(50), nullable=False, index=True)
|
||||
tags = Column(JSON, nullable=False, default=list)
|
||||
|
||||
# Configuration
|
||||
allows_skipping = Column(Boolean, nullable=False, default=True)
|
||||
tracks_progress = Column(Boolean, nullable=False, default=True)
|
||||
provides_feedback = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Publishing
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
# Analytics
|
||||
completion_count = Column(Integer, nullable=False, default=0)
|
||||
average_completion_time = Column(Integer, nullable=True) # Minutes
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
resource = relationship("AIResource")
|
||||
progress_records = relationship("TutorialProgress", back_populates="tutorial", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<InteractiveTutorial(id={self.id}, title='{self.title}', difficulty='{self.difficulty_level}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"difficulty_level": self.difficulty_level,
|
||||
"estimated_duration": self.estimated_duration,
|
||||
"steps": self.steps,
|
||||
"prerequisites": self.prerequisites,
|
||||
"learning_objectives": self.learning_objectives,
|
||||
"resource_id": self.resource_id,
|
||||
"category": self.category,
|
||||
"tags": self.tags,
|
||||
"allows_skipping": self.allows_skipping,
|
||||
"tracks_progress": self.tracks_progress,
|
||||
"provides_feedback": self.provides_feedback,
|
||||
"is_active": self.is_active,
|
||||
"completion_count": self.completion_count,
|
||||
"average_completion_time": self.average_completion_time,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
class TutorialProgress(Base):
|
||||
"""User progress through interactive tutorials"""
|
||||
|
||||
__tablename__ = "tutorial_progress"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
|
||||
# Foreign Keys
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tutorial_id = Column(Integer, ForeignKey("interactive_tutorials.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# Progress Data
|
||||
current_step = Column(Integer, nullable=False, default=0)
|
||||
completed_steps = Column(JSON, nullable=False, default=list) # List of completed step indices
|
||||
is_completed = Column(Boolean, nullable=False, default=False)
|
||||
completion_percentage = Column(Float, nullable=False, default=0.0)
|
||||
|
||||
# Performance
|
||||
start_time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
completion_time = Column(DateTime(timezone=True), nullable=True)
|
||||
total_time_spent = Column(Integer, nullable=False, default=0) # Seconds
|
||||
|
||||
# Feedback and Notes
|
||||
user_feedback = Column(Text, nullable=True)
|
||||
difficulty_rating = Column(Integer, nullable=True) # 1-5 scale
|
||||
notes = Column(Text, nullable=True) # User's personal notes
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
tutorial = relationship("InteractiveTutorial", back_populates="progress_records")
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TutorialProgress(user_id={self.user_id}, tutorial_id={self.tutorial_id}, step={self.current_step})>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"user_id": self.user_id,
|
||||
"tutorial_id": self.tutorial_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"current_step": self.current_step,
|
||||
"completed_steps": self.completed_steps,
|
||||
"is_completed": self.is_completed,
|
||||
"completion_percentage": self.completion_percentage,
|
||||
"start_time": self.start_time.isoformat() if self.start_time else None,
|
||||
"completion_time": self.completion_time.isoformat() if self.completion_time else None,
|
||||
"total_time_spent": self.total_time_spent,
|
||||
"user_feedback": self.user_feedback,
|
||||
"difficulty_rating": self.difficulty_rating,
|
||||
"notes": self.notes,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def advance_step(self) -> None:
|
||||
"""Advance to next step"""
|
||||
if self.current_step not in self.completed_steps:
|
||||
completed = self.completed_steps or []
|
||||
completed.append(self.current_step)
|
||||
self.completed_steps = completed
|
||||
|
||||
self.current_step += 1
|
||||
self.completion_percentage = (len(self.completed_steps) / len(self.tutorial.steps)) * 100.0
|
||||
|
||||
if self.completion_percentage >= 100.0:
|
||||
self.is_completed = True
|
||||
self.completion_time = datetime.utcnow()
|
||||
|
||||
|
||||
class ContextualHelp(Base):
|
||||
"""Context-aware help system that provides relevant assistance based on current state"""
|
||||
|
||||
__tablename__ = "contextual_help"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(uuid.uuid4()), unique=True, nullable=False)
|
||||
|
||||
# Help Context
|
||||
trigger_context = Column(String(100), nullable=False, index=True) # page_url, resource_id, error_code, etc.
|
||||
help_type = Column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="tooltip",
|
||||
index=True
|
||||
) # tooltip, modal, sidebar, inline, notification
|
||||
|
||||
# Content
|
||||
title = Column(String(200), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
content_type = Column(String(20), nullable=False, default="markdown")
|
||||
|
||||
# Targeting
|
||||
target_user_types = Column(JSON, nullable=False, default=list) # User types this help applies to
|
||||
trigger_conditions = Column(JSON, nullable=False, default=dict) # Conditions for showing help
|
||||
display_priority = Column(Integer, nullable=False, default=100)
|
||||
|
||||
# Behavior
|
||||
is_dismissible = Column(Boolean, nullable=False, default=True)
|
||||
auto_show = Column(Boolean, nullable=False, default=False) # Show automatically
|
||||
show_once_per_user = Column(Boolean, nullable=False, default=False) # Only show once
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
# Analytics
|
||||
view_count = Column(Integer, nullable=False, default=0)
|
||||
dismiss_count = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ContextualHelp(id={self.id}, context='{self.trigger_context}', type='{self.help_type}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"uuid": str(self.uuid),
|
||||
"trigger_context": self.trigger_context,
|
||||
"help_type": self.help_type,
|
||||
"title": self.title,
|
||||
"content": self.content,
|
||||
"content_type": self.content_type,
|
||||
"target_user_types": self.target_user_types,
|
||||
"trigger_conditions": self.trigger_conditions,
|
||||
"display_priority": self.display_priority,
|
||||
"is_dismissible": self.is_dismissible,
|
||||
"auto_show": self.auto_show,
|
||||
"show_once_per_user": self.show_once_per_user,
|
||||
"is_active": self.is_active,
|
||||
"view_count": self.view_count,
|
||||
"dismiss_count": self.dismiss_count,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def should_show_for_user(self, user_type: str, context_data: Dict[str, Any]) -> bool:
|
||||
"""Check if help should be shown for given user and context"""
|
||||
# Check if help is active
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
# Check user type targeting
|
||||
if self.target_user_types and user_type not in self.target_user_types:
|
||||
return False
|
||||
|
||||
# Check trigger conditions
|
||||
if self.trigger_conditions:
|
||||
for condition_key, condition_value in self.trigger_conditions.items():
|
||||
if context_data.get(condition_key) != condition_value:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Search and Discovery utilities
|
||||
def search_wiki_content(
|
||||
query: str,
|
||||
resource_ids: List[int] = None,
|
||||
user_role: str = None,
|
||||
skill_level: str = None,
|
||||
categories: List[str] = None,
|
||||
limit: int = 10
|
||||
) -> List[WikiPage]:
|
||||
"""Search wiki content with context filtering"""
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.database import engine
|
||||
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
|
||||
try:
|
||||
query_obj = db.query(WikiPage).filter(
|
||||
WikiPage.is_published == True,
|
||||
WikiPage.is_current_version == True
|
||||
)
|
||||
|
||||
# Text search
|
||||
if query:
|
||||
query_obj = query_obj.filter(
|
||||
WikiPage.title.ilike(f"%{query}%") |
|
||||
WikiPage.content.ilike(f"%{query}%") |
|
||||
WikiPage.search_keywords.ilike(f"%{query}%")
|
||||
)
|
||||
|
||||
# Category filtering
|
||||
if categories:
|
||||
query_obj = query_obj.filter(WikiPage.category.in_(categories))
|
||||
|
||||
# Context filtering
|
||||
if resource_ids:
|
||||
query_obj = query_obj.filter(
|
||||
WikiPage.target_resources.overlap(resource_ids) |
|
||||
(WikiPage.target_resources == [])
|
||||
)
|
||||
|
||||
if user_role:
|
||||
query_obj = query_obj.filter(
|
||||
WikiPage.target_roles.contains([user_role]) |
|
||||
(WikiPage.target_roles == [])
|
||||
)
|
||||
|
||||
if skill_level:
|
||||
query_obj = query_obj.filter(
|
||||
WikiPage.target_skill_levels.contains([skill_level]) |
|
||||
(WikiPage.target_skill_levels == [])
|
||||
)
|
||||
|
||||
# Order by priority and helpfulness
|
||||
query_obj = query_obj.order_by(
|
||||
WikiPage.featured.desc(),
|
||||
WikiPage.priority.asc(),
|
||||
WikiPage.helpful_votes.desc()
|
||||
)
|
||||
|
||||
return query_obj.limit(limit).all()
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user