Coverage for netbox/config/__init__.py: 81%

68 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import logging 

2import threading 

3 

4from django.conf import settings 

5from django.core.cache import cache 

6from django.db.utils import DatabaseError 

7from django.utils.translation import gettext_lazy as _ 

8 

9from .parameters import PARAMS 

10 

11__all__ = ( 

12 'PARAMS', 

13 'Config', 

14 'ConfigItem', 

15 'clear_config', 

16 'get_config', 

17) 

18 

19_thread_locals = threading.local() 

20 

21logger = logging.getLogger('netbox.config') 

22 

23# Sentinel used to distinguish a cache miss from a cached "empty" config (an empty dict is a 

24# legitimate cached value when no ConfigRevision exists). 

25_MISSING = object() 

26 

27 

28def get_config(): 

29 """ 

30 Return the current NetBox configuration, pulling it from cache if not already loaded in memory. 

31 """ 

32 if not hasattr(_thread_locals, 'config'): 

33 _thread_locals.config = Config() 

34 logger.debug("Initialized configuration") 

35 return _thread_locals.config 

36 

37 

38def clear_config(): 

39 """ 

40 Delete the currently loaded configuration, if any. 

41 """ 

42 if hasattr(_thread_locals, 'config'): 42 ↛ exitline 42 didn't return from function 'clear_config' because the condition on line 42 was always true

43 del _thread_locals.config 

44 logger.debug("Cleared configuration") 

45 

46 

47class Config: 

48 """ 

49 Fetch and store in memory the current NetBox configuration. This class must be instantiated prior to access, and 

50 must be re-instantiated each time it's necessary to check for updates to the cached config. 

51 """ 

52 def __init__(self): 

53 self._populate_from_cache() 

54 # Only consult the database when the cache has genuinely never been populated. A cached 

55 # empty config (no ConfigRevision) is authoritative and must not trigger a re-query. 

56 if self._cache_miss: 

57 self._populate_from_db() 

58 self.defaults = {param.name: param.default for param in PARAMS} 

59 

60 def __getattr__(self, item): 

61 

62 # Check for hard-coded configuration in settings.py 

63 if hasattr(settings, item): 

64 return getattr(settings, item) 

65 

66 # Return config value from cache 

67 if item in self.config: 67 ↛ 68line 67 didn't jump to line 68 because the condition on line 67 was never true

68 return self.config[item] 

69 

70 # Fall back to the parameter's default value 

71 if item in self.defaults: 71 ↛ 74line 71 didn't jump to line 74 because the condition on line 71 was always true

72 return self.defaults[item] 

73 

74 raise AttributeError(_("Invalid configuration parameter: {item}").format(item=item)) 

75 

76 def _populate_from_cache(self): 

77 """Populate config data from Redis cache""" 

78 cached_config = cache.get('config', _MISSING) 

79 cached_version = cache.get('config_version', _MISSING) 

80 

81 # Treat the cache as warm only when both keys are present. A missing 'config_version' 

82 # (e.g. evicted or never written) must re-query the database, even if 'config' is cached. 

83 # The no-revision branch writes both keys (config={}, config_version=None), so the 

84 # intentional empty state is still a cache hit. 

85 self._cache_miss = cached_config is _MISSING or cached_version is _MISSING 

86 

87 # A cached value of None (ConfigRevision.data is nullable) or {} is a legitimate empty 

88 # config and must not crash attribute access; normalize it to an empty dict. 

89 self.config = {} if cached_config is _MISSING else (cached_config or {}) 

90 self.version = None if cached_version is _MISSING else cached_version 

91 if self.config: 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true

92 logger.debug("Loaded configuration data from cache") 

93 

94 def _populate_from_db(self): 

95 """Cache data from latest ConfigRevision, then populate from cache""" 

96 from core.models import ConfigRevision 

97 

98 try: 

99 # Enforce the creation date as the ordering parameter 

100 revision = ConfigRevision.objects.get(active=True) 

101 logger.debug(f"Loaded active configuration revision (#{revision.pk})") 

102 except (ConfigRevision.DoesNotExist, ConfigRevision.MultipleObjectsReturned): 

103 revision = ConfigRevision.objects.order_by('-created').first() 

104 if revision is None: 104 ↛ 113line 104 didn't jump to line 113 because the condition on line 104 was always true

105 logger.debug("No configuration found in database; proceeding with default values") 

106 # Cache the empty state so subsequent requests are served from the cache rather than 

107 # re-querying the database on every request (#22158). Creating the first 

108 # ConfigRevision overwrites this via the post_save handler. 

109 cache.set('config', {}, None) 

110 cache.set('config_version', None, None) 

111 self._populate_from_cache() 

112 return 

113 logger.debug(f"No active configuration revision found; falling back to most recent (#{revision.pk})") 

114 except DatabaseError: 

115 # The database may not be available yet (e.g. when running a management command). Do NOT 

116 # cache anything here, so the next instantiation re-queries once the database is reachable. 

117 logger.warning("Skipping config initialization (database unavailable)") 

118 return 

119 

120 revision.activate(update_db=False) 

121 self._populate_from_cache() 

122 logger.debug("Filled cache with data from latest ConfigRevision") 

123 

124 

125class ConfigItem: 

126 """ 

127 A callable to retrieve a configuration parameter from the cache. This can serve as a placeholder to defer 

128 referencing a configuration parameter. 

129 """ 

130 def __init__(self, item): 

131 self.item = item 

132 

133 def __call__(self): 

134 config = get_config() 

135 return getattr(config, self.item)