Coverage for netbox/models/__init__.py: 88%

104 statements  

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

1from django.conf import settings 

2from django.contrib.contenttypes.fields import GenericForeignKey 

3from django.core.exceptions import ObjectDoesNotExist 

4from django.core.validators import ValidationError 

5from django.db import models 

6from django.urls import reverse 

7from django.utils.translation import gettext_lazy as _ 

8from mptt.models import MPTTModel, TreeForeignKey 

9 

10from netbox.models.features import * 

11from netbox.models.ltree import LtreeManager, LtreeModel, SortPathField 

12from netbox.models.mixins import OwnerMixin 

13from utilities.mptt import TreeManager 

14from utilities.querysets import RestrictedQuerySet 

15 

16__all__ = ( 

17 'AdminModel', 

18 'ChangeLoggedModel', 

19 'NestedGroupModel', 

20 'NestedGroupModelMixin', 

21 'NestedLtreeGroupModel', 

22 'NetBoxModel', 

23 'OrganizationalModel', 

24 'PrimaryModel', 

25) 

26 

27 

28class NetBoxFeatureSet( 

29 BookmarksMixin, 

30 ChangeLoggingMixin, 

31 CloningMixin, 

32 CustomFieldsMixin, 

33 CustomLinksMixin, 

34 CustomValidationMixin, 

35 ExportTemplatesMixin, 

36 JournalingMixin, 

37 NotificationsMixin, 

38 TagsMixin, 

39 EventRulesMixin 

40): 

41 class Meta: 

42 abstract = True 

43 

44 @property 

45 def docs_url(self): 

46 return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/' 

47 

48 def get_absolute_url(self): 

49 from utilities.views import get_viewname 

50 return reverse(get_viewname(self), args=[self.pk]) 

51 

52 

53# 

54# Base model classes 

55# 

56 

57class BaseModel(models.Model): 

58 """ 

59 A global base model for all NetBox objects. 

60 

61 This class provides some important overrides to Django's default functionality, such as 

62 - Overriding the default manager to use RestrictedQuerySet 

63 - Extending `clean()` to validate GenericForeignKey fields 

64 - Extending `clean()` and `save()` to coerce empty strings to None on unique nullable CharFields 

65 """ 

66 

67 objects = RestrictedQuerySet.as_manager() 

68 

69 class Meta: 

70 abstract = True 

71 

72 def _coerce_nullable_unique_chars(self): 

73 """ 

74 Coerce empty strings to None on unique nullable CharFields to avoid spurious 

75 uniqueness violations (PostgreSQL treats two empty strings as duplicates). 

76 """ 

77 for field in self._meta.concrete_fields: 

78 if ( 78 ↛ 84line 78 didn't jump to line 84 because the condition on line 78 was never true

79 isinstance(field, models.CharField) 

80 and field.null 

81 and field.unique 

82 and getattr(self, field.attname, None) == '' 

83 ): 

84 setattr(self, field.attname, None) 

85 

86 def clean(self): 

87 """ 

88 Validate the model for GenericForeignKey fields to ensure that the content type and object ID exist. 

89 """ 

90 super().clean() 

91 self._coerce_nullable_unique_chars() 

92 

93 for field in self._meta.get_fields(): 

94 if isinstance(field, GenericForeignKey): 

95 ct_value = getattr(self, field.ct_field, None) 

96 fk_value = getattr(self, field.fk_field, None) 

97 

98 if ct_value is None and fk_value is not None: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true

99 raise ValidationError({ 

100 field.ct_field: "This field cannot be null.", 

101 }) 

102 if fk_value is None and ct_value is not None: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true

103 raise ValidationError({ 

104 field.fk_field: "This field cannot be null.", 

105 }) 

106 

107 if ct_value and fk_value: 

108 klass = getattr(self, field.ct_field).model_class() 

109 try: 

110 obj = klass.objects.get(pk=fk_value) 

111 except ObjectDoesNotExist: 

112 raise ValidationError({ 

113 field.fk_field: f"Related object not found using the provided value: {fk_value}." 

114 }) 

115 

116 # update the GFK field value 

117 setattr(self, field.name, obj) 

118 

119 def save(self, *args, **kwargs): 

120 self._coerce_nullable_unique_chars() 

121 super().save(*args, **kwargs) 

122 

123 

124class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, EventRulesMixin, BaseModel): 

125 """ 

126 Base model for ancillary models; provides limited functionality for models which don't 

127 support NetBox's full feature set. 

128 """ 

129 

130 class Meta: 

131 abstract = True 

132 

133 

134class NetBoxModel(NetBoxFeatureSet, BaseModel): 

135 """ 

136 Base model for most object types. Suitable for use by plugins. 

137 """ 

138 

139 class Meta: 

140 abstract = True 

141 

142 

143# 

144# NetBox internal base models 

145# 

146 

147class PrimaryModel(OwnerMixin, NetBoxModel): 

148 """ 

149 Primary models represent real objects within the infrastructure being modeled. 

150 """ 

151 description = models.CharField( 

152 verbose_name=_('description'), 

153 max_length=200, 

154 blank=True 

155 ) 

156 comments = models.TextField( 

157 verbose_name=_('comments'), 

158 blank=True 

159 ) 

160 

161 class Meta: 

162 abstract = True 

163 

164 

165class NestedGroupModelMixin(OwnerMixin, NetBoxModel): 

166 """ 

167 Shared field set and behavior for hierarchical group models. Concrete bases supply the 

168 tree backend (MPTT or ltree) and the corresponding `parent` ForeignKey / manager. 

169 """ 

170 name = models.CharField( 

171 verbose_name=_('name'), 

172 max_length=100 

173 ) 

174 slug = models.SlugField( 

175 verbose_name=_('slug'), 

176 max_length=100 

177 ) 

178 description = models.CharField( 

179 verbose_name=_('description'), 

180 max_length=200, 

181 blank=True 

182 ) 

183 comments = models.TextField( 

184 verbose_name=_('comments'), 

185 blank=True 

186 ) 

187 

188 class Meta: 

189 abstract = True 

190 

191 def __str__(self): 

192 return self.name 

193 

194 

195class NestedGroupModel(NestedGroupModelMixin, MPTTModel): 

196 """ 

197 Deprecated MPTT-backed nested group base, retained for backwards compatibility with plugins. 

198 

199 New code (in NetBox core and in plugins) should use `NestedLtreeGroupModel` instead. This 

200 class will be removed in a future release once the deprecation period has elapsed. 

201 """ 

202 parent = TreeForeignKey( 

203 to='self', 

204 on_delete=models.CASCADE, 

205 related_name='children', 

206 blank=True, 

207 null=True, 

208 db_index=True 

209 ) 

210 

211 objects = TreeManager() 

212 

213 class Meta: 

214 abstract = True 

215 

216 class MPTTMeta: 

217 order_insertion_by = ('name',) 

218 

219 def clean(self): 

220 super().clean() 

221 

222 # A nested group cannot be its own parent or a descendant of itself. The ltree 

223 # base (NestedLtreeGroupModel) enforces this via LtreeModel.clean(); this MPTT 

224 # variant keeps the original get_descendants()-based check. 

225 if not self._state.adding and self.parent and self.parent in self.get_descendants(include_self=True): 

226 raise ValidationError({ 

227 "parent": _("Cannot assign self or a descendant as parent.") 

228 }) 

229 

230 

231class NestedLtreeGroupModel(NestedGroupModelMixin, LtreeModel): 

232 """ 

233 Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest 

234 recursively using PostgreSQL ltree. Within each parent, each child instance must have a unique name. 

235 

236 `sort_path` is a trigger-maintained text column holding a chr(9)-separated chain of ancestor 

237 names; ordering by it yields tree-flatten output with siblings in name (collation) order. 

238 Inserts, reparents, AND renames all update `sort_path` (a rename cascades to descendants), so 

239 list ordering reflects renames immediately — unlike django-mptt's `order_insertion_by`, which 

240 left descendants stale until a manual rebuild. 

241 """ 

242 parent = models.ForeignKey( 

243 to='self', 

244 on_delete=models.CASCADE, 

245 related_name='children', 

246 blank=True, 

247 null=True, 

248 db_index=True 

249 ) 

250 sort_path = SortPathField( 

251 editable=False, 

252 blank=True, 

253 default='', 

254 ) 

255 

256 # Re-declare so the LtreeManager wins over BaseModel's RestrictedQuerySet 

257 # default manager via MRO resolution. 

258 objects = LtreeManager() 

259 

260 class Meta: 

261 abstract = True 

262 ordering = ('sort_path',) 

263 

264 

265class OrganizationalModel(OwnerMixin, NetBoxModel): 

266 """ 

267 Organizational models are those which are used solely to categorize and qualify other objects, and do not convey 

268 any real information about the infrastructure being modeled (for example, functional device roles). Organizational 

269 models provide the following standard attributes: 

270 - Unique name 

271 - Unique slug (automatically derived from name) 

272 - Optional description 

273 """ 

274 name = models.CharField( 

275 verbose_name=_('name'), 

276 max_length=100, 

277 unique=True 

278 ) 

279 slug = models.SlugField( 

280 verbose_name=_('slug'), 

281 max_length=100, 

282 unique=True 

283 ) 

284 description = models.CharField( 

285 verbose_name=_('description'), 

286 max_length=200, 

287 blank=True 

288 ) 

289 comments = models.TextField( 

290 verbose_name=_('comments'), 

291 blank=True 

292 ) 

293 

294 class Meta: 

295 abstract = True 

296 ordering = ('name',) 

297 

298 def __str__(self): 

299 return self.name 

300 

301 

302class AdminModel( 

303 BookmarksMixin, 

304 CloningMixin, 

305 CustomLinksMixin, 

306 CustomValidationMixin, 

307 EventRulesMixin, 

308 ExportTemplatesMixin, 

309 NotificationsMixin, 

310 BaseModel, 

311): 

312 """ 

313 A model which represents an administrative resource. 

314 """ 

315 description = models.CharField( 

316 verbose_name=_('description'), 

317 max_length=200, 

318 blank=True 

319 ) 

320 

321 class Meta: 

322 abstract = True