import hashlib from django.conf import settings from django.db.models import Q from .models import FeatureFlag, FeatureFlagOverride from .registry import FeatureRegistry class FeatureService: """Evaluates operational feature flags with tenant/user overrides and caching.""" def is_enabled( self, slug: str, *, user=None, tenant_id=None, default: bool | None = None, ttl_seconds: int = 60, ) -> bool: config = getattr(settings, "INFRASYNTH_FEATURES", {}) ttl_seconds = ttl_seconds or config.get("CACHE_TTL_SECONDS", 60) if tenant_id is None: from infrasynth.tenancy.context import get_current_tenant tenant = get_current_tenant() tenant_id = tenant.pk if tenant is not None else None if user and user.is_authenticated: override = self._get_user_override(slug, user) if override is not None: return override if user and user.is_authenticated: override = self._get_group_override(slug, user) if override is not None: return override # Resolution: tenant override → global default → registry default. flag = None if tenant_id: flag = self._get_tenant_flag(slug, tenant_id, ttl_seconds) if flag is None: flag = self._get_global_flag(slug, ttl_seconds) if flag is not None: return self._evaluate_flag(flag, slug, user=user, tenant_id=tenant_id) registry_default = FeatureRegistry.get_all().get(slug) if registry_default: return registry_default.default return default if default is not None else False def _evaluate_flag(self, flag, slug, *, user=None, tenant_id=None) -> bool: if not flag.is_active: return False if flag.environments: environment = getattr(settings, "ENVIRONMENT", None) or getattr( settings, "INFRASYNTH_ENVIRONMENT", "development" ) if environment not in flag.environments: return False rollout = int(flag.rollout_percentage or 0) if rollout >= 100: return True if rollout <= 0: return False return self._in_rollout(slug, user=user, tenant_id=tenant_id) < rollout @staticmethod def _in_rollout(slug: str, *, user=None, tenant_id=None) -> int: """Stable bucket in ``[0, 100)`` for a (flag, user/tenant) pair.""" algorithm = str(getattr(settings, "INFRASYNTH_FEATURES", {}).get("ROLLOUT_HASH_ALGORITHM", "md5")) identity = ( str(getattr(user, "pk", "")) if user is not None and getattr(user, "is_authenticated", False) else f"tenant:{tenant_id}" ) digest = hashlib.new(algorithm, f"{slug}:{identity}".encode()).hexdigest() return int(digest[:8], 16) % 100 def get_active_flags(self, *, user=None, tenant_id=None) -> dict[str, bool]: all_slugs = set(FeatureRegistry.get_all().keys()) db_slugs = set( FeatureFlag.all_objects.filter(Q(tenant_id=tenant_id) | Q(tenant_id__isnull=True)).values_list( "slug", flat=True ) ) all_slugs.update(db_slugs) return {slug: self.is_enabled(slug, user=user, tenant_id=tenant_id) for slug in sorted(all_slugs)} def _get_user_override(self, slug: str, user) -> bool | None: override = FeatureFlagOverride.all_objects.filter(flag__slug=slug, user=user).first() if override: return override.is_enabled return None def _get_group_override(self, slug: str, user) -> bool | None: groups = list(user.groups.all()) if not groups: return None override = FeatureFlagOverride.all_objects.filter(flag__slug=slug, group__in=groups).first() if override: return override.is_enabled return None def _get_tenant_flag(self, slug: str, tenant_id, ttl_seconds: int) -> FeatureFlag | None: cache, prefix = self._cache() cache_key = f"{prefix}:tenant:{tenant_id}:features:{slug}" cached = cache.get(cache_key) if cached is not None: return cached if isinstance(cached, FeatureFlag) else None flag = FeatureFlag.all_objects.filter(slug=slug, tenant_id=tenant_id).first() cache.set(cache_key, flag, ttl_seconds) return flag def _get_global_flag(self, slug: str, ttl_seconds: int) -> FeatureFlag | None: cache, prefix = self._cache() cache_key = f"{prefix}:tenant:global:features:{slug}" cached = cache.get(cache_key) if cached is not None: return cached if isinstance(cached, FeatureFlag) else None flag = FeatureFlag.all_objects.filter(slug=slug, tenant_id__isnull=True).first() cache.set(cache_key, flag, ttl_seconds) return flag @staticmethod def _cache(): """Returns ``(cache_client, key_prefix)`` from ``INFRASYNTH_FEATURES``.""" from django.core.cache import caches config = getattr(settings, "INFRASYNTH_FEATURES", {}) alias = config.get("CACHE_BACKEND", "default") or "default" return caches[alias], config.get("CACHE_KEY_PREFIX", "features")