# shared/utils/tenant_settings_client.py """ Tenant Settings Client Shared utility for services to fetch tenant-specific settings from Tenant Service Includes Redis caching for performance """ import httpx import json from typing import Dict, Any, Optional from uuid import UUID import redis.asyncio as aioredis from datetime import timedelta import logging logger = logging.getLogger(__name__) class TenantSettingsClient: """ Client for fetching tenant settings from Tenant Service Features: - HTTP client to fetch settings from Tenant Service API - Redis caching with configurable TTL (default 5 minutes) - Automatic cache invalidation support - Fallback to defaults if Tenant Service is unavailable """ def __init__( self, tenant_service_url: str, redis_client: Optional[aioredis.Redis] = None, cache_ttl: int = 300, # 5 minutes default http_timeout: int = 10 ): """ Initialize TenantSettingsClient Args: tenant_service_url: Base URL of Tenant Service (e.g., "http://tenant-service:8000") redis_client: Optional Redis client for caching cache_ttl: Cache TTL in seconds (default 300 = 5 minutes) http_timeout: HTTP request timeout in seconds """ self.tenant_service_url = tenant_service_url.rstrip('/') self.redis = redis_client self.cache_ttl = cache_ttl self.http_timeout = http_timeout # HTTP client with connection pooling self.http_client = httpx.AsyncClient( timeout=http_timeout, limits=httpx.Limits(max_keepalive_connections=20, max_connections=100) ) async def get_procurement_settings(self, tenant_id: UUID) -> Dict[str, Any]: """ Get procurement settings for a tenant Args: tenant_id: UUID of the tenant Returns: Dictionary with procurement settings """ return await self._get_category_settings(tenant_id, "procurement") async def get_inventory_settings(self, tenant_id: UUID) -> Dict[str, Any]: """ Get inventory settings for a tenant Args: tenant_id: UUID of the tenant Returns: Dictionary with inventory settings """ return await self._get_category_settings(tenant_id, "inventory") async def get_production_settings(self, tenant_id: UUID) -> Dict[str, Any]: """ Get production settings for a tenant Args: tenant_id: UUID of the tenant Returns: Dictionary with production settings """ return await self._get_category_settings(tenant_id, "production") async def get_supplier_settings(self, tenant_id: UUID) -> Dict[str, Any]: """ Get supplier settings for a tenant Args: tenant_id: UUID of the tenant Returns: Dictionary with supplier settings """ return await self._get_category_settings(tenant_id, "supplier") async def get_pos_settings(self, tenant_id: UUID) -> Dict[str, Any]: """ Get POS settings for a tenant Args: tenant_id: UUID of the tenant Returns: Dictionary with POS settings """ return await self._get_category_settings(tenant_id, "pos") async def get_order_settings(self, tenant_id: UUID) -> Dict[str, Any]: """ Get order settings for a tenant Args: tenant_id: UUID of the tenant Returns: Dictionary with order settings """ return await self._get_category_settings(tenant_id, "order") async def get_all_settings(self, tenant_id: UUID) -> Dict[str, Any]: """ Get all settings for a tenant Args: tenant_id: UUID of the tenant Returns: Dictionary with all setting categories """ cache_key = f"tenant_settings:{tenant_id}:all" # Try cache first if self.redis: cached = await self._get_from_cache(cache_key) if cached: return cached # Fetch from Tenant Service try: url = f"{self.tenant_service_url}/api/v1/tenants/{tenant_id}/settings" response = await self.http_client.get(url) response.raise_for_status() settings = response.json() # Cache the result if self.redis: await self._set_in_cache(cache_key, settings) return settings except Exception as e: logger.error(f"Failed to fetch all settings for tenant {tenant_id}: {e}") return self._get_default_settings() async def invalidate_cache(self, tenant_id: UUID, category: Optional[str] = None): """ Invalidate cache for a tenant's settings Args: tenant_id: UUID of the tenant category: Optional category to invalidate. If None, invalidates all categories. """ if not self.redis: return if category: cache_key = f"tenant_settings:{tenant_id}:{category}" await self.redis.delete(cache_key) logger.info(f"Invalidated cache for tenant {tenant_id}, category {category}") else: # Invalidate all categories pattern = f"tenant_settings:{tenant_id}:*" keys = await self.redis.keys(pattern) if keys: await self.redis.delete(*keys) logger.info(f"Invalidated all cached settings for tenant {tenant_id}") async def _get_category_settings(self, tenant_id: UUID, category: str) -> Dict[str, Any]: """ Internal method to fetch settings for a specific category Args: tenant_id: UUID of the tenant category: Category name Returns: Dictionary with category settings """ cache_key = f"tenant_settings:{tenant_id}:{category}" # Try cache first if self.redis: cached = await self._get_from_cache(cache_key) if cached: return cached # Fetch from Tenant Service try: url = f"{self.tenant_service_url}/api/v1/tenants/{tenant_id}/settings/{category}" response = await self.http_client.get(url) response.raise_for_status() data = response.json() settings = data.get("settings", {}) # Cache the result if self.redis: await self._set_in_cache(cache_key, settings) return settings except httpx.HTTPStatusError as e: if e.response.status_code == 404: logger.warning(f"Settings not found for tenant {tenant_id}, using defaults") else: logger.error(f"HTTP error fetching {category} settings for tenant {tenant_id}: {e}") return self._get_default_category_settings(category) except Exception as e: logger.error(f"Failed to fetch {category} settings for tenant {tenant_id}: {e}") return self._get_default_category_settings(category) async def _get_from_cache(self, key: str) -> Optional[Dict[str, Any]]: """Get value from Redis cache""" try: cached = await self.redis.get(key) if cached: return json.loads(cached) except Exception as e: logger.warning(f"Redis get error for key {key}: {e}") return None async def _set_in_cache(self, key: str, value: Dict[str, Any]): """Set value in Redis cache with TTL""" try: await self.redis.setex( key, timedelta(seconds=self.cache_ttl), json.dumps(value) ) except Exception as e: logger.warning(f"Redis set error for key {key}: {e}") def _get_default_category_settings(self, category: str) -> Dict[str, Any]: """Get default settings for a category as fallback""" defaults = self._get_default_settings() return defaults.get(f"{category}_settings", {}) def _get_default_settings(self) -> Dict[str, Any]: """Get default settings for all categories as fallback""" return { "procurement_settings": { "auto_approve_enabled": True, "auto_approve_threshold_eur": 500.0, "auto_approve_min_supplier_score": 0.80, "require_approval_new_suppliers": True, "require_approval_critical_items": True, "procurement_lead_time_days": 3, "demand_forecast_days": 14, "safety_stock_percentage": 20.0, "po_approval_reminder_hours": 24, "po_critical_escalation_hours": 12 }, "inventory_settings": { "low_stock_threshold": 10, "reorder_point": 20, "reorder_quantity": 50, "expiring_soon_days": 7, "expiration_warning_days": 3, "quality_score_threshold": 8.0, "temperature_monitoring_enabled": True, "refrigeration_temp_min": 1.0, "refrigeration_temp_max": 4.0, "freezer_temp_min": -20.0, "freezer_temp_max": -15.0, "room_temp_min": 18.0, "room_temp_max": 25.0, "temp_deviation_alert_minutes": 15, "critical_temp_deviation_minutes": 5 }, "production_settings": { "planning_horizon_days": 7, "minimum_batch_size": 1.0, "maximum_batch_size": 100.0, "production_buffer_percentage": 10.0, "working_hours_per_day": 12, "max_overtime_hours": 4, "capacity_utilization_target": 0.85, "capacity_warning_threshold": 0.95, "quality_check_enabled": True, "minimum_yield_percentage": 85.0, "quality_score_threshold": 8.0, "schedule_optimization_enabled": True, "prep_time_buffer_minutes": 30, "cleanup_time_buffer_minutes": 15, "labor_cost_per_hour_eur": 15.0, "overhead_cost_percentage": 20.0 }, "supplier_settings": { "default_payment_terms_days": 30, "default_delivery_days": 3, "excellent_delivery_rate": 95.0, "good_delivery_rate": 90.0, "excellent_quality_rate": 98.0, "good_quality_rate": 95.0, "critical_delivery_delay_hours": 24, "critical_quality_rejection_rate": 10.0, "high_cost_variance_percentage": 15.0 }, "pos_settings": { "sync_interval_minutes": 5, "auto_sync_products": True, "auto_sync_transactions": True }, "order_settings": { "max_discount_percentage": 50.0, "default_delivery_window_hours": 48, "dynamic_pricing_enabled": False, "discount_enabled": True, "delivery_tracking_enabled": True } } async def close(self): """Close HTTP client connections""" await self.http_client.aclose() # Factory function for easy instantiation def create_tenant_settings_client( tenant_service_url: str, redis_client: Optional[aioredis.Redis] = None, cache_ttl: int = 300 ) -> TenantSettingsClient: """ Factory function to create a TenantSettingsClient Args: tenant_service_url: Base URL of Tenant Service redis_client: Optional Redis client for caching cache_ttl: Cache TTL in seconds Returns: TenantSettingsClient instance """ return TenantSettingsClient( tenant_service_url=tenant_service_url, redis_client=redis_client, cache_ttl=cache_ttl )