This commit is contained in:
HotSwapp
2025-08-18 20:20:04 -05:00
parent 89b2bc0aa2
commit bac8cc4bd5
114 changed files with 30258 additions and 1341 deletions

View File

@@ -0,0 +1,571 @@
"""
Advanced Variable Resolution Service
This service handles complex variable processing including:
- Conditional logic evaluation
- Mathematical calculations and formulas
- Dynamic data source queries
- Variable dependency resolution
- Caching and performance optimization
"""
from __future__ import annotations
import re
import json
import math
import operator
from datetime import datetime, date, timedelta
from typing import Dict, Any, List, Optional, Tuple, Union
from decimal import Decimal, InvalidOperation
import logging
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.models.template_variables import (
TemplateVariable, VariableContext, VariableAuditLog,
VariableType, VariableTemplate
)
from app.models.files import File
from app.models.rolodex import Rolodex
from app.core.logging import get_logger
logger = get_logger("advanced_variables")
class VariableProcessor:
"""
Handles advanced variable processing with conditional logic, calculations, and data sources
"""
def __init__(self, db: Session):
self.db = db
self._cache: Dict[str, Any] = {}
# Safe functions available in formula expressions
self.safe_functions = {
'abs': abs,
'round': round,
'min': min,
'max': max,
'sum': sum,
'len': len,
'str': str,
'int': int,
'float': float,
'math_ceil': math.ceil,
'math_floor': math.floor,
'math_sqrt': math.sqrt,
'today': lambda: date.today(),
'now': lambda: datetime.now(),
'days_between': lambda d1, d2: (d1 - d2).days if isinstance(d1, date) and isinstance(d2, date) else 0,
'format_currency': lambda x: f"${float(x):,.2f}" if x is not None else "$0.00",
'format_date': lambda d, fmt='%B %d, %Y': d.strftime(fmt) if isinstance(d, date) else str(d),
}
# Safe operators for formula evaluation
self.operators = {
'+': operator.add,
'-': operator.sub,
'*': operator.mul,
'/': operator.truediv,
'//': operator.floordiv,
'%': operator.mod,
'**': operator.pow,
'==': operator.eq,
'!=': operator.ne,
'<': operator.lt,
'<=': operator.le,
'>': operator.gt,
'>=': operator.ge,
'and': operator.and_,
'or': operator.or_,
'not': operator.not_,
}
def resolve_variables(
self,
variables: List[str],
context_type: str = "global",
context_id: str = "default",
base_context: Optional[Dict[str, Any]] = None
) -> Tuple[Dict[str, Any], List[str]]:
"""
Resolve a list of variables with their current values
Args:
variables: List of variable names to resolve
context_type: Context type (file, client, global, etc.)
context_id: Specific context identifier
base_context: Additional context values to use
Returns:
Tuple of (resolved_variables, unresolved_variables)
"""
resolved = {}
unresolved = []
processing_order = self._determine_processing_order(variables)
# Start with base context
if base_context:
resolved.update(base_context)
for var_name in processing_order:
try:
value = self._resolve_single_variable(
var_name, context_type, context_id, resolved
)
if value is not None:
resolved[var_name] = value
else:
unresolved.append(var_name)
except Exception as e:
logger.error(f"Error resolving variable {var_name}: {str(e)}")
unresolved.append(var_name)
return resolved, unresolved
def _resolve_single_variable(
self,
var_name: str,
context_type: str,
context_id: str,
current_context: Dict[str, Any]
) -> Any:
"""
Resolve a single variable based on its type and configuration
"""
# Get variable definition
var_def = self.db.query(TemplateVariable).filter(
TemplateVariable.name == var_name,
TemplateVariable.active == True
).first()
if not var_def:
return None
# Check for static value first
if var_def.static_value is not None:
return self._convert_value(var_def.static_value, var_def.variable_type)
# Check cache if enabled
cache_key = f"{var_name}:{context_type}:{context_id}"
if var_def.cache_duration_minutes > 0:
cached_value = self._get_cached_value(var_def, cache_key)
if cached_value is not None:
return cached_value
# Get context-specific value
context_value = self._get_context_value(var_def.id, context_type, context_id)
# Process based on variable type
if var_def.variable_type == VariableType.CALCULATED:
value = self._process_calculated_variable(var_def, current_context)
elif var_def.variable_type == VariableType.CONDITIONAL:
value = self._process_conditional_variable(var_def, current_context)
elif var_def.variable_type == VariableType.QUERY:
value = self._process_query_variable(var_def, current_context, context_type, context_id)
elif var_def.variable_type == VariableType.LOOKUP:
value = self._process_lookup_variable(var_def, current_context, context_type, context_id)
else:
# Simple variable types (string, number, date, boolean)
value = context_value if context_value is not None else var_def.default_value
value = self._convert_value(value, var_def.variable_type)
# Apply validation
if not self._validate_value(value, var_def):
logger.warning(f"Validation failed for variable {var_name}")
return var_def.default_value
# Cache the result
if var_def.cache_duration_minutes > 0:
self._cache_value(var_def, cache_key, value)
return value
def _process_calculated_variable(
self,
var_def: TemplateVariable,
context: Dict[str, Any]
) -> Any:
"""
Process a calculated variable using its formula
"""
if not var_def.formula:
return var_def.default_value
try:
# Create safe execution environment
safe_context = {
**self.safe_functions,
**context,
'__builtins__': {} # Disable built-ins for security
}
# Evaluate the formula
result = eval(var_def.formula, safe_context)
return result
except Exception as e:
logger.error(f"Error evaluating formula for {var_def.name}: {str(e)}")
return var_def.default_value
def _process_conditional_variable(
self,
var_def: TemplateVariable,
context: Dict[str, Any]
) -> Any:
"""
Process a conditional variable using if/then/else logic
"""
if not var_def.conditional_logic:
return var_def.default_value
try:
logic = var_def.conditional_logic
if isinstance(logic, str):
logic = json.loads(logic)
# Process conditional rules
for rule in logic.get('rules', []):
condition = rule.get('condition')
if self._evaluate_condition(condition, context):
return self._convert_value(rule.get('value'), var_def.variable_type)
# No conditions matched, return default
return logic.get('default', var_def.default_value)
except Exception as e:
logger.error(f"Error processing conditional logic for {var_def.name}: {str(e)}")
return var_def.default_value
def _process_query_variable(
self,
var_def: TemplateVariable,
context: Dict[str, Any],
context_type: str,
context_id: str
) -> Any:
"""
Process a variable that gets its value from a database query
"""
if not var_def.data_source_query:
return var_def.default_value
try:
# Substitute context variables in the query
query = var_def.data_source_query
for key, value in context.items():
query = query.replace(f":{key}", str(value) if value is not None else "NULL")
# Add context parameters
query = query.replace(":context_id", context_id)
query = query.replace(":context_type", context_type)
# Execute query
result = self.db.execute(text(query)).first()
if result:
return result[0] if len(result) == 1 else dict(result)
return var_def.default_value
except Exception as e:
logger.error(f"Error executing query for {var_def.name}: {str(e)}")
return var_def.default_value
def _process_lookup_variable(
self,
var_def: TemplateVariable,
context: Dict[str, Any],
context_type: str,
context_id: str
) -> Any:
"""
Process a variable that looks up values from a reference table
"""
if not all([var_def.lookup_table, var_def.lookup_key_field, var_def.lookup_value_field]):
return var_def.default_value
try:
# Get the lookup key from context
lookup_key = context.get(var_def.lookup_key_field)
if lookup_key is None and context_type == "file":
# Try to get from file context
file_obj = self.db.query(File).filter(File.file_no == context_id).first()
if file_obj:
lookup_key = getattr(file_obj, var_def.lookup_key_field, None)
if lookup_key is None:
return var_def.default_value
# Build and execute lookup query
query = text(f"""
SELECT {var_def.lookup_value_field}
FROM {var_def.lookup_table}
WHERE {var_def.lookup_key_field} = :lookup_key
LIMIT 1
""")
result = self.db.execute(query, {"lookup_key": lookup_key}).first()
return result[0] if result else var_def.default_value
except Exception as e:
logger.error(f"Error processing lookup for {var_def.name}: {str(e)}")
return var_def.default_value
def _evaluate_condition(self, condition: Dict[str, Any], context: Dict[str, Any]) -> bool:
"""
Evaluate a conditional expression
"""
try:
field = condition.get('field')
operator_name = condition.get('operator', 'equals')
expected_value = condition.get('value')
actual_value = context.get(field)
# Convert values for comparison
if operator_name in ['equals', 'not_equals']:
return (actual_value == expected_value) if operator_name == 'equals' else (actual_value != expected_value)
elif operator_name in ['greater_than', 'less_than', 'greater_equal', 'less_equal']:
try:
actual_num = float(actual_value) if actual_value is not None else 0
expected_num = float(expected_value) if expected_value is not None else 0
if operator_name == 'greater_than':
return actual_num > expected_num
elif operator_name == 'less_than':
return actual_num < expected_num
elif operator_name == 'greater_equal':
return actual_num >= expected_num
elif operator_name == 'less_equal':
return actual_num <= expected_num
except (ValueError, TypeError):
return False
elif operator_name == 'contains':
return str(expected_value) in str(actual_value) if actual_value else False
elif operator_name == 'is_empty':
return actual_value is None or str(actual_value).strip() == ''
elif operator_name == 'is_not_empty':
return actual_value is not None and str(actual_value).strip() != ''
return False
except Exception:
return False
def _determine_processing_order(self, variables: List[str]) -> List[str]:
"""
Determine the order to process variables based on dependencies
"""
# Get all variable definitions
var_defs = self.db.query(TemplateVariable).filter(
TemplateVariable.name.in_(variables),
TemplateVariable.active == True
).all()
var_deps = {}
for var_def in var_defs:
deps = var_def.depends_on or []
if isinstance(deps, str):
deps = json.loads(deps)
var_deps[var_def.name] = [dep for dep in deps if dep in variables]
# Topological sort for dependency resolution
ordered = []
remaining = set(variables)
while remaining:
# Find variables with no unresolved dependencies
ready = [var for var in remaining if not any(dep in remaining for dep in var_deps.get(var, []))]
if not ready:
# Circular dependency or missing dependency, add remaining arbitrarily
ready = list(remaining)
ordered.extend(ready)
remaining -= set(ready)
return ordered
def _get_context_value(self, variable_id: int, context_type: str, context_id: str) -> Any:
"""
Get the context-specific value for a variable
"""
context = self.db.query(VariableContext).filter(
VariableContext.variable_id == variable_id,
VariableContext.context_type == context_type,
VariableContext.context_id == context_id
).first()
return context.computed_value if context and context.computed_value else (context.value if context else None)
def _convert_value(self, value: Any, var_type: VariableType) -> Any:
"""
Convert a value to the appropriate type
"""
if value is None:
return None
try:
if var_type == VariableType.NUMBER:
return float(value) if '.' in str(value) else int(value)
elif var_type == VariableType.BOOLEAN:
if isinstance(value, bool):
return value
return str(value).lower() in ('true', '1', 'yes', 'on')
elif var_type == VariableType.DATE:
if isinstance(value, date):
return value
# Try to parse date string
from dateutil.parser import parse
return parse(str(value)).date()
else:
return str(value)
except (ValueError, TypeError):
return value
def _validate_value(self, value: Any, var_def: TemplateVariable) -> bool:
"""
Validate a value against the variable's validation rules
"""
if var_def.required and (value is None or str(value).strip() == ''):
return False
if not var_def.validation_rules:
return True
try:
rules = var_def.validation_rules
if isinstance(rules, str):
rules = json.loads(rules)
# Apply validation rules
for rule_type, rule_value in rules.items():
if rule_type == 'min_length' and len(str(value)) < rule_value:
return False
elif rule_type == 'max_length' and len(str(value)) > rule_value:
return False
elif rule_type == 'pattern' and not re.match(rule_value, str(value)):
return False
elif rule_type == 'min_value' and float(value) < rule_value:
return False
elif rule_type == 'max_value' and float(value) > rule_value:
return False
return True
except Exception:
return True # Don't fail validation on rule processing errors
def _get_cached_value(self, var_def: TemplateVariable, cache_key: str) -> Any:
"""
Get cached value if still valid
"""
if not var_def.last_cached_at:
return None
cache_age = datetime.now() - var_def.last_cached_at
if cache_age.total_seconds() > (var_def.cache_duration_minutes * 60):
return None
return var_def.cached_value
def _cache_value(self, var_def: TemplateVariable, cache_key: str, value: Any):
"""
Cache a computed value
"""
var_def.cached_value = str(value) if value is not None else None
var_def.last_cached_at = datetime.now()
self.db.commit()
def set_variable_value(
self,
variable_name: str,
value: Any,
context_type: str = "global",
context_id: str = "default",
user_name: Optional[str] = None
) -> bool:
"""
Set a variable value in a specific context
"""
try:
var_def = self.db.query(TemplateVariable).filter(
TemplateVariable.name == variable_name,
TemplateVariable.active == True
).first()
if not var_def:
return False
# Get or create context
context = self.db.query(VariableContext).filter(
VariableContext.variable_id == var_def.id,
VariableContext.context_type == context_type,
VariableContext.context_id == context_id
).first()
old_value = context.value if context else None
if not context:
context = VariableContext(
variable_id=var_def.id,
context_type=context_type,
context_id=context_id,
value=str(value) if value is not None else None,
source="manual"
)
self.db.add(context)
else:
context.value = str(value) if value is not None else None
# Validate the value
converted_value = self._convert_value(value, var_def.variable_type)
context.is_valid = self._validate_value(converted_value, var_def)
# Log the change
audit_log = VariableAuditLog(
variable_id=var_def.id,
context_type=context_type,
context_id=context_id,
old_value=old_value,
new_value=context.value,
change_type="updated",
changed_by=user_name
)
self.db.add(audit_log)
self.db.commit()
return True
except Exception as e:
logger.error(f"Error setting variable {variable_name}: {str(e)}")
self.db.rollback()
return False
def get_variables_for_template(self, template_id: int) -> List[Dict[str, Any]]:
"""
Get all variables associated with a template
"""
variables = self.db.query(TemplateVariable, VariableTemplate).join(
VariableTemplate, VariableTemplate.variable_id == TemplateVariable.id
).filter(
VariableTemplate.template_id == template_id,
TemplateVariable.active == True
).order_by(VariableTemplate.display_order, TemplateVariable.name).all()
result = []
for var_def, var_template in variables:
result.append({
'id': var_def.id,
'name': var_def.name,
'display_name': var_def.display_name or var_def.name,
'description': var_def.description,
'type': var_def.variable_type.value,
'required': var_template.override_required if var_template.override_required is not None else var_def.required,
'default_value': var_template.override_default or var_def.default_value,
'group_name': var_template.group_name,
'validation_rules': var_def.validation_rules
})
return result