GT AI OS Community Edition v2.0.33

Security hardening release addressing CodeQL and Dependabot alerts:

- Fix stack trace exposure in error responses
- Add SSRF protection with DNS resolution checking
- Implement proper URL hostname validation (replaces substring matching)
- Add centralized path sanitization to prevent path traversal
- Fix ReDoS vulnerability in email validation regex
- Improve HTML sanitization in validation utilities
- Fix capability wildcard matching in auth utilities
- Update glob dependency to address CVE
- Add CodeQL suppression comments for verified false positives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HackWeasel
2025-12-12 17:04:45 -05:00
commit b9dfb86260
746 changed files with 232071 additions and 0 deletions

View 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"
]

View 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
}

View 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"

View 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(),
}

View 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)

View 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
}

View 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

View 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
}

View 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
}

View 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
)

View 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
}

View 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})>"

View 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

View 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})>"

View 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()

View 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()

View 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
}

View 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()