2022-04-15 14:45:28 -04:00
|
|
|
from collections import defaultdict
|
2022-11-04 14:53:18 -04:00
|
|
|
from functools import cached_property
|
2022-04-15 14:45:28 -04:00
|
|
|
|
2022-01-19 15:16:10 -05:00
|
|
|
from django.contrib.contenttypes.fields import GenericRelation
|
2022-01-19 14:46:50 -05:00
|
|
|
from django.db.models.signals import class_prepared
|
|
|
|
from django.dispatch import receiver
|
|
|
|
|
2021-02-24 21:01:16 -05:00
|
|
|
from django.core.validators import ValidationError
|
|
|
|
from django.db import models
|
2021-03-10 14:32:50 -05:00
|
|
|
from taggit.managers import TaggableManager
|
2021-02-24 21:01:16 -05:00
|
|
|
|
2022-05-24 10:12:32 +02:00
|
|
|
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
2022-09-09 16:44:58 -04:00
|
|
|
from extras.utils import is_taggable, register_features
|
2021-06-09 10:15:34 -04:00
|
|
|
from netbox.signals import post_clean
|
2022-09-30 13:03:24 -07:00
|
|
|
from utilities.json import CustomFieldJSONEncoder
|
2021-02-24 21:01:16 -05:00
|
|
|
from utilities.utils import serialize_object
|
2022-10-07 11:36:14 -04:00
|
|
|
from utilities.views import register_model_view
|
2021-02-24 21:01:16 -05:00
|
|
|
|
|
|
|
__all__ = (
|
2022-01-19 14:46:50 -05:00
|
|
|
'ChangeLoggingMixin',
|
2022-09-09 16:44:58 -04:00
|
|
|
'CloningMixin',
|
2022-01-19 14:46:50 -05:00
|
|
|
'CustomFieldsMixin',
|
|
|
|
'CustomLinksMixin',
|
|
|
|
'CustomValidationMixin',
|
|
|
|
'ExportTemplatesMixin',
|
|
|
|
'JobResultsMixin',
|
2022-01-19 15:16:10 -05:00
|
|
|
'JournalingMixin',
|
2022-01-19 14:46:50 -05:00
|
|
|
'TagsMixin',
|
|
|
|
'WebhooksMixin',
|
2021-02-24 21:01:16 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
2021-02-26 16:12:52 -05:00
|
|
|
#
|
2022-01-19 14:46:50 -05:00
|
|
|
# Feature mixins
|
2021-02-26 16:12:52 -05:00
|
|
|
#
|
2021-02-24 21:01:16 -05:00
|
|
|
|
2021-02-26 16:12:52 -05:00
|
|
|
class ChangeLoggingMixin(models.Model):
|
2021-02-24 21:01:16 -05:00
|
|
|
"""
|
2022-01-19 14:46:50 -05:00
|
|
|
Provides change logging support for a model. Adds the `created` and `last_updated` fields.
|
2021-02-24 21:01:16 -05:00
|
|
|
"""
|
2022-02-08 14:41:44 -05:00
|
|
|
created = models.DateTimeField(
|
2021-02-24 21:01:16 -05:00
|
|
|
auto_now_add=True,
|
|
|
|
blank=True,
|
|
|
|
null=True
|
|
|
|
)
|
|
|
|
last_updated = models.DateTimeField(
|
|
|
|
auto_now=True,
|
|
|
|
blank=True,
|
|
|
|
null=True
|
|
|
|
)
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
2022-07-01 15:52:16 -04:00
|
|
|
def serialize_object(self):
|
|
|
|
"""
|
|
|
|
Return a JSON representation of the instance. Models can override this method to replace or extend the default
|
|
|
|
serialization logic provided by the `serialize_object()` utility function.
|
|
|
|
"""
|
|
|
|
return serialize_object(self)
|
|
|
|
|
2021-03-04 13:06:04 -05:00
|
|
|
def snapshot(self):
|
|
|
|
"""
|
2022-07-01 15:52:16 -04:00
|
|
|
Save a snapshot of the object's current state in preparation for modification. The snapshot is saved as
|
|
|
|
`_prechange_snapshot` on the instance.
|
2021-03-04 13:06:04 -05:00
|
|
|
"""
|
2022-07-01 15:52:16 -04:00
|
|
|
self._prechange_snapshot = self.serialize_object()
|
2021-03-04 13:06:04 -05:00
|
|
|
|
2022-01-26 20:25:23 -05:00
|
|
|
def to_objectchange(self, action):
|
2021-02-24 21:01:16 -05:00
|
|
|
"""
|
|
|
|
Return a new ObjectChange representing a change made to this object. This will typically be called automatically
|
|
|
|
by ChangeLoggingMiddleware.
|
|
|
|
"""
|
|
|
|
from extras.models import ObjectChange
|
2021-03-04 13:06:04 -05:00
|
|
|
objectchange = ObjectChange(
|
2021-02-24 21:01:16 -05:00
|
|
|
changed_object=self,
|
2021-12-18 14:16:37 -05:00
|
|
|
object_repr=str(self)[:200],
|
2021-03-04 13:06:04 -05:00
|
|
|
action=action
|
2021-02-24 21:01:16 -05:00
|
|
|
)
|
2021-03-04 13:06:04 -05:00
|
|
|
if hasattr(self, '_prechange_snapshot'):
|
|
|
|
objectchange.prechange_data = self._prechange_snapshot
|
|
|
|
if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
|
2022-07-01 15:52:16 -04:00
|
|
|
objectchange.postchange_data = self.serialize_object()
|
2021-03-04 13:06:04 -05:00
|
|
|
|
|
|
|
return objectchange
|
2021-02-24 21:01:16 -05:00
|
|
|
|
|
|
|
|
2022-09-09 16:44:58 -04:00
|
|
|
class CloningMixin(models.Model):
|
|
|
|
"""
|
|
|
|
Provides the clone() method used to prepare a copy of existing objects.
|
|
|
|
"""
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
|
|
|
def clone(self):
|
|
|
|
"""
|
2022-09-13 14:36:37 -04:00
|
|
|
Returns a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre-
|
|
|
|
populating an object creation form in the UI. By default, this method will replicate any fields listed in the
|
|
|
|
model's `clone_fields` list (if defined), but it can be overridden to apply custom logic.
|
|
|
|
|
|
|
|
```python
|
|
|
|
class MyModel(NetBoxModel):
|
|
|
|
def clone(self):
|
|
|
|
attrs = super().clone()
|
|
|
|
attrs['extra-value'] = 123
|
|
|
|
return attrs
|
|
|
|
```
|
2022-09-09 16:44:58 -04:00
|
|
|
"""
|
|
|
|
attrs = {}
|
|
|
|
|
|
|
|
for field_name in getattr(self, 'clone_fields', []):
|
|
|
|
field = self._meta.get_field(field_name)
|
|
|
|
field_value = field.value_from_object(self)
|
|
|
|
if field_value not in (None, ''):
|
|
|
|
attrs[field_name] = field_value
|
|
|
|
|
|
|
|
# Include tags (if applicable)
|
|
|
|
if is_taggable(self):
|
|
|
|
attrs['tags'] = [tag.pk for tag in self.tags.all()]
|
|
|
|
|
|
|
|
return attrs
|
|
|
|
|
|
|
|
|
2021-02-26 16:12:52 -05:00
|
|
|
class CustomFieldsMixin(models.Model):
|
2021-02-24 21:01:16 -05:00
|
|
|
"""
|
2022-01-19 14:46:50 -05:00
|
|
|
Enables support for custom fields.
|
2021-02-24 21:01:16 -05:00
|
|
|
"""
|
|
|
|
custom_field_data = models.JSONField(
|
2022-09-30 13:03:24 -07:00
|
|
|
encoder=CustomFieldJSONEncoder,
|
2021-02-24 21:01:16 -05:00
|
|
|
blank=True,
|
|
|
|
default=dict
|
|
|
|
)
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
2022-11-04 14:53:18 -04:00
|
|
|
@cached_property
|
2021-02-24 21:01:16 -05:00
|
|
|
def cf(self):
|
|
|
|
"""
|
2022-11-04 14:53:18 -04:00
|
|
|
Return a dictionary mapping each custom field for this instance to its deserialized value.
|
2022-01-19 16:44:18 -05:00
|
|
|
|
|
|
|
```python
|
|
|
|
>>> tenant = Tenant.objects.first()
|
|
|
|
>>> tenant.cf
|
2022-11-04 14:53:18 -04:00
|
|
|
{'primary_site': <Site: DM-NYC>, 'cust_id': 'DMI01', 'is_active': True}
|
2022-01-19 16:44:18 -05:00
|
|
|
```
|
2021-02-24 21:01:16 -05:00
|
|
|
"""
|
2022-11-04 14:53:18 -04:00
|
|
|
return {
|
|
|
|
cf.name: cf.deserialize(self.custom_field_data.get(cf.name))
|
|
|
|
for cf in self.custom_fields
|
|
|
|
}
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def custom_fields(self):
|
|
|
|
"""
|
|
|
|
Return the QuerySet of CustomFields assigned to this model.
|
|
|
|
|
|
|
|
```python
|
|
|
|
>>> tenant = Tenant.objects.first()
|
|
|
|
>>> tenant.custom_fields
|
|
|
|
<RestrictedQuerySet [<CustomField: Primary site>, <CustomField: Customer ID>, <CustomField: Is active>]>
|
|
|
|
```
|
|
|
|
"""
|
|
|
|
from extras.models import CustomField
|
|
|
|
return CustomField.objects.get_for_model(self)
|
2021-02-24 21:01:16 -05:00
|
|
|
|
2022-05-24 10:12:32 +02:00
|
|
|
def get_custom_fields(self, omit_hidden=False):
|
2021-02-24 21:01:16 -05:00
|
|
|
"""
|
2022-01-19 16:44:18 -05:00
|
|
|
Return a dictionary of custom fields for a single object in the form `{field: value}`.
|
|
|
|
|
|
|
|
```python
|
|
|
|
>>> tenant = Tenant.objects.first()
|
|
|
|
>>> tenant.get_custom_fields()
|
|
|
|
{<CustomField: Customer ID>: 'CYB01'}
|
|
|
|
```
|
2022-11-04 14:53:18 -04:00
|
|
|
|
|
|
|
Args:
|
|
|
|
omit_hidden: If True, custom fields with no UI visibility will be omitted.
|
2021-02-24 21:01:16 -05:00
|
|
|
"""
|
|
|
|
from extras.models import CustomField
|
2021-12-30 17:03:41 -05:00
|
|
|
data = {}
|
2022-11-04 14:53:18 -04:00
|
|
|
|
2021-12-30 17:03:41 -05:00
|
|
|
for field in CustomField.objects.get_for_model(self):
|
2022-05-24 10:12:32 +02:00
|
|
|
# Skip fields that are hidden if 'omit_hidden' is set
|
|
|
|
if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
|
|
|
|
continue
|
|
|
|
|
2021-12-30 17:03:41 -05:00
|
|
|
value = self.custom_field_data.get(field.name)
|
|
|
|
data[field] = field.deserialize(value)
|
|
|
|
|
|
|
|
return data
|
2021-02-24 21:01:16 -05:00
|
|
|
|
2022-04-15 14:45:28 -04:00
|
|
|
def get_custom_fields_by_group(self):
|
|
|
|
"""
|
2022-05-24 16:39:05 -04:00
|
|
|
Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted.
|
2022-11-04 14:53:18 -04:00
|
|
|
|
|
|
|
```python
|
|
|
|
>>> tenant = Tenant.objects.first()
|
|
|
|
>>> tenant.get_custom_fields_by_group()
|
|
|
|
{
|
|
|
|
'': {<CustomField: Primary site>: <Site: DM-NYC>},
|
|
|
|
'Billing': {<CustomField: Customer ID>: 'DMI01', <CustomField: Is active>: True}
|
|
|
|
}
|
|
|
|
```
|
2022-04-15 14:45:28 -04:00
|
|
|
"""
|
2022-11-04 14:53:18 -04:00
|
|
|
from extras.models import CustomField
|
|
|
|
groups = defaultdict(dict)
|
|
|
|
visible_custom_fields = CustomField.objects.get_for_model(self).exclude(
|
|
|
|
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
|
|
|
|
)
|
|
|
|
|
|
|
|
for cf in visible_custom_fields:
|
|
|
|
value = self.custom_field_data.get(cf.name)
|
|
|
|
value = cf.deserialize(value)
|
|
|
|
groups[cf.group_name][cf] = value
|
2022-04-15 14:45:28 -04:00
|
|
|
|
2022-11-04 14:53:18 -04:00
|
|
|
return dict(groups)
|
2022-04-15 14:45:28 -04:00
|
|
|
|
2023-02-10 12:19:44 +01:00
|
|
|
def populate_custom_field_defaults(self):
|
|
|
|
"""
|
|
|
|
Apply the default value for each custom field
|
|
|
|
"""
|
|
|
|
for cf in self.custom_fields:
|
|
|
|
self.custom_field_data[cf.name] = cf.default
|
|
|
|
|
2021-02-24 21:01:16 -05:00
|
|
|
def clean(self):
|
|
|
|
super().clean()
|
|
|
|
from extras.models import CustomField
|
|
|
|
|
2021-12-30 17:03:41 -05:00
|
|
|
custom_fields = {
|
|
|
|
cf.name: cf for cf in CustomField.objects.get_for_model(self)
|
|
|
|
}
|
2021-02-24 21:01:16 -05:00
|
|
|
|
|
|
|
# Validate all field values
|
|
|
|
for field_name, value in self.custom_field_data.items():
|
|
|
|
if field_name not in custom_fields:
|
|
|
|
raise ValidationError(f"Unknown field name '{field_name}' in custom field data.")
|
|
|
|
try:
|
|
|
|
custom_fields[field_name].validate(value)
|
|
|
|
except ValidationError as e:
|
|
|
|
raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}")
|
|
|
|
|
|
|
|
# Check for missing required values
|
|
|
|
for cf in custom_fields.values():
|
|
|
|
if cf.required and cf.name not in self.custom_field_data:
|
|
|
|
raise ValidationError(f"Missing required custom field '{cf.name}'.")
|
|
|
|
|
|
|
|
|
2022-01-19 14:46:50 -05:00
|
|
|
class CustomLinksMixin(models.Model):
|
|
|
|
"""
|
|
|
|
Enables support for custom links.
|
|
|
|
"""
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
|
|
|
|
2021-06-09 10:15:34 -04:00
|
|
|
class CustomValidationMixin(models.Model):
|
|
|
|
"""
|
2022-01-20 10:53:00 -05:00
|
|
|
Enables user-configured validation rules for models.
|
2021-06-09 10:15:34 -04:00
|
|
|
"""
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
|
|
|
def clean(self):
|
|
|
|
super().clean()
|
|
|
|
|
2023-02-08 20:36:20 +01:00
|
|
|
# If the instance is a base for replications, skip custom validation
|
|
|
|
if getattr(self, '_replicated_base', False):
|
|
|
|
return
|
|
|
|
|
2021-06-09 10:15:34 -04:00
|
|
|
# Send the post_clean signal
|
|
|
|
post_clean.send(sender=self.__class__, instance=self)
|
|
|
|
|
|
|
|
|
2022-01-19 14:46:50 -05:00
|
|
|
class ExportTemplatesMixin(models.Model):
|
2021-02-26 16:12:52 -05:00
|
|
|
"""
|
2022-01-19 14:46:50 -05:00
|
|
|
Enables support for export templates.
|
2021-02-26 16:12:52 -05:00
|
|
|
"""
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
|
|
|
|
2022-01-19 14:46:50 -05:00
|
|
|
class JobResultsMixin(models.Model):
|
2021-03-10 13:35:13 -05:00
|
|
|
"""
|
2022-01-19 16:44:18 -05:00
|
|
|
Enables support for job results.
|
2021-03-10 13:35:13 -05:00
|
|
|
"""
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
|
|
|
|
2022-01-19 15:16:10 -05:00
|
|
|
class JournalingMixin(models.Model):
|
|
|
|
"""
|
2022-01-19 16:44:18 -05:00
|
|
|
Enables support for object journaling. Adds a generic relation (`journal_entries`)
|
|
|
|
to NetBox's JournalEntry model.
|
2022-01-19 15:16:10 -05:00
|
|
|
"""
|
|
|
|
journal_entries = GenericRelation(
|
|
|
|
to='extras.JournalEntry',
|
|
|
|
object_id_field='assigned_object_id',
|
|
|
|
content_type_field='assigned_object_type'
|
|
|
|
)
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
|
|
|
|
2022-01-19 14:46:50 -05:00
|
|
|
class TagsMixin(models.Model):
|
2021-02-26 16:12:52 -05:00
|
|
|
"""
|
2022-01-19 16:44:18 -05:00
|
|
|
Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute,
|
|
|
|
which is a `TaggableManager` instance.
|
2021-02-26 16:12:52 -05:00
|
|
|
"""
|
2022-01-19 14:46:50 -05:00
|
|
|
tags = TaggableManager(
|
|
|
|
through='extras.TaggedItem'
|
2021-03-16 15:00:08 -04:00
|
|
|
)
|
2021-02-26 16:12:52 -05:00
|
|
|
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
|
|
|
|
2022-01-19 14:46:50 -05:00
|
|
|
class WebhooksMixin(models.Model):
|
2021-02-24 21:01:16 -05:00
|
|
|
"""
|
2022-01-19 14:46:50 -05:00
|
|
|
Enables support for webhooks.
|
2021-02-24 21:01:16 -05:00
|
|
|
"""
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
|
|
|
|
2022-01-19 14:46:50 -05:00
|
|
|
FEATURES_MAP = (
|
|
|
|
('custom_fields', CustomFieldsMixin),
|
|
|
|
('custom_links', CustomLinksMixin),
|
|
|
|
('export_templates', ExportTemplatesMixin),
|
|
|
|
('job_results', JobResultsMixin),
|
2022-01-19 15:16:10 -05:00
|
|
|
('journaling', JournalingMixin),
|
2022-01-19 14:46:50 -05:00
|
|
|
('tags', TagsMixin),
|
|
|
|
('webhooks', WebhooksMixin),
|
|
|
|
)
|
2021-02-24 21:01:16 -05:00
|
|
|
|
2021-11-11 15:04:22 -05:00
|
|
|
|
2022-01-19 14:46:50 -05:00
|
|
|
@receiver(class_prepared)
|
|
|
|
def _register_features(sender, **kwargs):
|
|
|
|
features = {
|
|
|
|
feature for feature, cls in FEATURES_MAP if issubclass(sender, cls)
|
|
|
|
}
|
|
|
|
register_features(sender, features)
|
2022-10-06 16:20:35 -04:00
|
|
|
|
|
|
|
# Feature view registration
|
|
|
|
if issubclass(sender, JournalingMixin):
|
|
|
|
register_model_view(
|
|
|
|
sender,
|
|
|
|
'journal',
|
|
|
|
kwargs={'model': sender}
|
2022-10-07 11:36:14 -04:00
|
|
|
)('netbox.views.generic.ObjectJournalView')
|
2022-10-06 16:20:35 -04:00
|
|
|
if issubclass(sender, ChangeLoggingMixin):
|
|
|
|
register_model_view(
|
|
|
|
sender,
|
|
|
|
'changelog',
|
|
|
|
kwargs={'model': sender}
|
2022-10-07 11:36:14 -04:00
|
|
|
)('netbox.views.generic.ObjectChangeLogView')
|