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
« 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
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
16__all__ = (
17 'AdminModel',
18 'ChangeLoggedModel',
19 'NestedGroupModel',
20 'NestedGroupModelMixin',
21 'NestedLtreeGroupModel',
22 'NetBoxModel',
23 'OrganizationalModel',
24 'PrimaryModel',
25)
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
44 @property
45 def docs_url(self):
46 return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
48 def get_absolute_url(self):
49 from utilities.views import get_viewname
50 return reverse(get_viewname(self), args=[self.pk])
53#
54# Base model classes
55#
57class BaseModel(models.Model):
58 """
59 A global base model for all NetBox objects.
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 """
67 objects = RestrictedQuerySet.as_manager()
69 class Meta:
70 abstract = True
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)
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()
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)
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 })
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 })
116 # update the GFK field value
117 setattr(self, field.name, obj)
119 def save(self, *args, **kwargs):
120 self._coerce_nullable_unique_chars()
121 super().save(*args, **kwargs)
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 """
130 class Meta:
131 abstract = True
134class NetBoxModel(NetBoxFeatureSet, BaseModel):
135 """
136 Base model for most object types. Suitable for use by plugins.
137 """
139 class Meta:
140 abstract = True
143#
144# NetBox internal base models
145#
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 )
161 class Meta:
162 abstract = True
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 )
188 class Meta:
189 abstract = True
191 def __str__(self):
192 return self.name
195class NestedGroupModel(NestedGroupModelMixin, MPTTModel):
196 """
197 Deprecated MPTT-backed nested group base, retained for backwards compatibility with plugins.
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 )
211 objects = TreeManager()
213 class Meta:
214 abstract = True
216 class MPTTMeta:
217 order_insertion_by = ('name',)
219 def clean(self):
220 super().clean()
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 })
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.
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 )
256 # Re-declare so the LtreeManager wins over BaseModel's RestrictedQuerySet
257 # default manager via MRO resolution.
258 objects = LtreeManager()
260 class Meta:
261 abstract = True
262 ordering = ('sort_path',)
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 )
294 class Meta:
295 abstract = True
296 ordering = ('name',)
298 def __str__(self):
299 return self.name
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 )
321 class Meta:
322 abstract = True