From c7825e391cabbdcc7616e78d70313957fee38b25 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 14:46:50 -0500 Subject: [PATCH 1/9] Designate feature mixin classes & employ class_prepared signal to register features --- netbox/extras/utils.py | 23 ++- netbox/netbox/models/__init__.py | 129 +++++++++++++ .../netbox/{models.py => models/features.py} | 171 ++++++------------ 3 files changed, 198 insertions(+), 125 deletions(-) create mode 100644 netbox/netbox/models/__init__.py rename netbox/netbox/{models.py => models/features.py} (57%) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index ace49cce5..16749ad5d 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -57,21 +57,24 @@ class FeatureQuery: return query +def register_features(model, features): + if 'model_features' not in registry: + registry['model_features'] = { + f: collections.defaultdict(list) for f in EXTRAS_FEATURES + } + for feature in features: + if feature not in EXTRAS_FEATURES: + raise ValueError(f"{feature} is not a valid extras feature!") + app_label, model_name = model._meta.label_lower.split('.') + registry['model_features'][feature][app_label].append(model_name) + + def extras_features(*features): """ Decorator used to register extras provided features to a model """ def wrapper(model_class): # Initialize the model_features store if not already defined - if 'model_features' not in registry: - registry['model_features'] = { - f: collections.defaultdict(list) for f in EXTRAS_FEATURES - } - for feature in features: - if feature in EXTRAS_FEATURES: - app_label, model_name = model_class._meta.label_lower.split('.') - registry['model_features'][feature][app_label].append(model_name) - else: - raise ValueError('{} is not a valid extras feature!'.format(feature)) + register_features(model_class, features) return model_class return wrapper diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py new file mode 100644 index 000000000..e715cf329 --- /dev/null +++ b/netbox/netbox/models/__init__.py @@ -0,0 +1,129 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.core.validators import ValidationError +from django.db import models +from mptt.models import MPTTModel, TreeForeignKey + +from utilities.mptt import TreeManager +from utilities.querysets import RestrictedQuerySet +from netbox.models.features import * + +__all__ = ( + 'BigIDModel', + 'ChangeLoggedModel', + 'NestedGroupModel', + 'OrganizationalModel', + 'PrimaryModel', +) + + +# +# Base model classes +# + +class BigIDModel(models.Model): + """ + Abstract base model for all data objects. Ensures the use of a 64-bit PK. + """ + id = models.BigAutoField( + primary_key=True + ) + + class Meta: + abstract = True + + +class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): + """ + Base model for all objects which support change logging. + """ + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + + +class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + journal_entries = GenericRelation( + to='extras.JournalEntry', + object_id_field='assigned_object_id', + content_type_field='assigned_object_type' + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + + +class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): + """ + Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest + recursively using MPTT. Within each parent, each child instance must have a unique name. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = TreeManager() + + class Meta: + abstract = True + + class MPTTMeta: + order_insertion_by = ('name',) + + def __str__(self): + return self.name + + def clean(self): + super().clean() + + # An MPTT model cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({ + "parent": "Cannot assign self as parent." + }) + + +class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): + """ + Organizational models are those which are used solely to categorize and qualify other objects, and do not convey + any real information about the infrastructure being modeled (for example, functional device roles). Organizational + models provide the following standard attributes: + - Unique name + - Unique slug (automatically derived from name) + - Optional description + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + ordering = ('name',) diff --git a/netbox/netbox/models.py b/netbox/netbox/models/features.py similarity index 57% rename from netbox/netbox/models.py rename to netbox/netbox/models/features.py index 3e6ebd8b2..99f8c00b7 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models/features.py @@ -1,34 +1,37 @@ import logging -from django.contrib.contenttypes.fields import GenericRelation +from django.db.models.signals import class_prepared +from django.dispatch import receiver + from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import ValidationError from django.db import models -from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from extras.choices import ObjectChangeActionChoices +from extras.utils import register_features from netbox.signals import post_clean -from utilities.mptt import TreeManager -from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object __all__ = ( - 'BigIDModel', - 'ChangeLoggedModel', - 'NestedGroupModel', - 'OrganizationalModel', - 'PrimaryModel', + 'ChangeLoggingMixin', + 'CustomFieldsMixin', + 'CustomLinksMixin', + 'CustomValidationMixin', + 'ExportTemplatesMixin', + 'JobResultsMixin', + 'TagsMixin', + 'WebhooksMixin', ) # -# Mixins +# Feature mixins # class ChangeLoggingMixin(models.Model): """ - Provides change logging support. + Provides change logging support for a model. Adds the `created` and `last_updated` fields. """ created = models.DateField( auto_now_add=True, @@ -74,7 +77,7 @@ class ChangeLoggingMixin(models.Model): class CustomFieldsMixin(models.Model): """ - Provides support for custom fields. + Enables support for custom fields. """ custom_field_data = models.JSONField( encoder=DjangoJSONEncoder, @@ -128,6 +131,14 @@ class CustomFieldsMixin(models.Model): raise ValidationError(f"Missing required custom field '{cf.name}'.") +class CustomLinksMixin(models.Model): + """ + Enables support for custom links. + """ + class Meta: + abstract = True + + class CustomValidationMixin(models.Model): """ Enables user-configured validation rules for built-in models by extending the clean() method. @@ -142,9 +153,25 @@ class CustomValidationMixin(models.Model): post_clean.send(sender=self.__class__, instance=self) +class ExportTemplatesMixin(models.Model): + """ + Enables support for export templates. + """ + class Meta: + abstract = True + + +class JobResultsMixin(models.Model): + """ + Enable the assignment of JobResults to a model. + """ + class Meta: + abstract = True + + class TagsMixin(models.Model): """ - Enable the assignment of Tags. + Enable the assignment of Tags to a model. """ tags = TaggableManager( through='extras.TaggedItem' @@ -154,113 +181,27 @@ class TagsMixin(models.Model): abstract = True -# -# Base model classes - -class BigIDModel(models.Model): +class WebhooksMixin(models.Model): """ - Abstract base model for all data objects. Ensures the use of a 64-bit PK. + Enables support for webhooks. """ - id = models.BigAutoField( - primary_key=True - ) - class Meta: abstract = True -class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): - """ - Base model for all objects which support change logging. - """ - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True +FEATURES_MAP = ( + ('custom_fields', CustomFieldsMixin), + ('custom_links', CustomLinksMixin), + ('export_templates', ExportTemplatesMixin), + ('job_results', JobResultsMixin), + ('tags', TagsMixin), + ('webhooks', WebhooksMixin), +) -class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): - """ - Primary models represent real objects within the infrastructure being modeled. - """ - journal_entries = GenericRelation( - to='extras.JournalEntry', - object_id_field='assigned_object_id', - content_type_field='assigned_object_type' - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True - - -class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): - """ - Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest - recursively using MPTT. Within each parent, each child instance must have a unique name. - """ - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - name = models.CharField( - max_length=100 - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = TreeManager() - - class Meta: - abstract = True - - class MPTTMeta: - order_insertion_by = ('name',) - - def __str__(self): - return self.name - - def clean(self): - super().clean() - - # An MPTT model cannot be its own parent - if self.pk and self.parent_id == self.pk: - raise ValidationError({ - "parent": "Cannot assign self as parent." - }) - - -class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): - """ - Organizational models are those which are used solely to categorize and qualify other objects, and do not convey - any real information about the infrastructure being modeled (for example, functional device roles). Organizational - models provide the following standard attributes: - - Unique name - - Unique slug (automatically derived from name) - - Optional description - """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True - ordering = ('name',) +@receiver(class_prepared) +def _register_features(sender, **kwargs): + features = { + feature for feature, cls in FEATURES_MAP if issubclass(sender, cls) + } + register_features(sender, features) From cdae0c2bef3f5cfe81acf0bf3927ed5d98a3650d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 15:16:10 -0500 Subject: [PATCH 2/9] Remove extras_features() decorator --- netbox/circuits/models/circuits.py | 7 ++---- netbox/circuits/models/providers.py | 3 --- netbox/dcim/models/cables.py | 2 -- .../dcim/models/device_component_templates.py | 16 +++--------- netbox/dcim/models/device_components.py | 12 --------- netbox/dcim/models/devices.py | 9 ------- netbox/dcim/models/power.py | 3 --- netbox/dcim/models/racks.py | 4 --- netbox/dcim/models/sites.py | 6 ----- netbox/extras/constants.py | 1 + netbox/extras/models/configcontexts.py | 5 ++-- netbox/extras/models/customfields.py | 6 ++--- netbox/extras/models/models.py | 24 +++++++----------- netbox/extras/models/tags.py | 5 ++-- netbox/extras/utils.py | 11 -------- netbox/ipam/models/fhrp.py | 6 ++--- netbox/ipam/models/ip.py | 8 ------ netbox/ipam/models/services.py | 3 --- netbox/ipam/models/vlans.py | 3 --- netbox/ipam/models/vrfs.py | 3 --- netbox/netbox/models/__init__.py | 25 +++++++++++-------- netbox/netbox/models/features.py | 17 +++++++++++++ netbox/tenancy/models/contacts.py | 8 ++---- netbox/tenancy/models/tenants.py | 3 --- netbox/virtualization/models.py | 7 ------ netbox/wireless/models.py | 4 --- 26 files changed, 58 insertions(+), 143 deletions(-) diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 013aef557..e697caa0a 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -5,8 +5,8 @@ from django.urls import reverse from circuits.choices import * from dcim.models import LinkTermination -from extras.utils import extras_features from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel +from netbox.models.features import WebhooksMixin __all__ = ( 'Circuit', @@ -15,7 +15,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named @@ -44,7 +43,6 @@ class CircuitType(OrganizationalModel): return reverse('circuits:circuittype', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Circuit(PrimaryModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple @@ -138,8 +136,7 @@ class Circuit(PrimaryModel): return CircuitStatusChoices.colors.get(self.status, 'secondary') -@extras_features('webhooks') -class CircuitTermination(ChangeLoggedModel, LinkTermination): +class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 153e241a7..8fd52c587 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -3,7 +3,6 @@ from django.db import models from django.urls import reverse from dcim.fields import ASNField -from extras.utils import extras_features from netbox.models import PrimaryModel __all__ = ( @@ -12,7 +11,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Provider(PrimaryModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -72,7 +70,6 @@ class Provider(PrimaryModel): return reverse('circuits:provider', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ProviderNetwork(PrimaryModel): """ This represents a provider network which exists outside of NetBox, the details of which are unknown or diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 12fe91036..18bf65895 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -11,7 +11,6 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel from utilities.fields import ColorField from utilities.utils import to_meters @@ -29,7 +28,6 @@ __all__ = ( # Cables # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Cable(PrimaryModel): """ A physical connection between two endpoints. diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b3ede8282..72ac9df40 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,4 +1,4 @@ -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -7,8 +7,8 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * -from extras.utils import extras_features from netbox.models import ChangeLoggedModel +from netbox.models.features import WebhooksMixin from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -32,7 +32,7 @@ __all__ = ( ) -class ComponentTemplateModel(ChangeLoggedModel): +class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -135,7 +135,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel): return self.name -@extras_features('webhooks') class ConsolePortTemplate(ModularComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. @@ -164,7 +163,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class ConsoleServerPortTemplate(ModularComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. @@ -193,7 +191,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class PowerPortTemplate(ModularComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. @@ -245,7 +242,6 @@ class PowerPortTemplate(ModularComponentTemplateModel): }) -@extras_features('webhooks') class PowerOutletTemplate(ModularComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. @@ -307,7 +303,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class InterfaceTemplate(ModularComponentTemplateModel): """ A template for a physical data interface on a new Device. @@ -347,7 +342,6 @@ class InterfaceTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class FrontPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -420,7 +414,6 @@ class FrontPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class RearPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. @@ -460,7 +453,6 @@ class RearPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class ModuleBayTemplate(ComponentTemplateModel): """ A template for a ModuleBay to be created for a new parent Device. @@ -486,7 +478,6 @@ class ModuleBayTemplate(ComponentTemplateModel): ) -@extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. @@ -511,7 +502,6 @@ class DeviceBayTemplate(ComponentTemplateModel): ) -@extras_features('webhooks') class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): """ A template for an InventoryItem to be created for a new parent Device. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 916161ced..c26b32575 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,7 +11,6 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField from dcim.svg import CableTraceSVG -from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -254,7 +253,6 @@ class PathEndpoint(models.Model): # Console components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. @@ -282,7 +280,6 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. @@ -314,7 +311,6 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): # Power components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. @@ -407,7 +403,6 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): } -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. @@ -522,7 +517,6 @@ class BaseInterface(models.Model): return self.fhrp_group_assignments.count() -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. @@ -793,7 +787,6 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo # Pass-through ports # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class FrontPort(ModularComponentModel, LinkTermination): """ A pass-through port on the front of a Device. @@ -847,7 +840,6 @@ class FrontPort(ModularComponentModel, LinkTermination): }) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RearPort(ModularComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. @@ -891,7 +883,6 @@ class RearPort(ModularComponentModel, LinkTermination): # Bays # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ModuleBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -912,7 +903,6 @@ class ModuleBay(ComponentModel): return reverse('dcim:modulebay', kwargs={'pk': self.pk}) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -963,7 +953,6 @@ class DeviceBay(ComponentModel): # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class InventoryItemRole(OrganizationalModel): """ Inventory items may optionally be assigned a functional role. @@ -994,7 +983,6 @@ class InventoryItemRole(OrganizationalModel): return reverse('dcim:inventoryitemrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class InventoryItem(MPTTModel, ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 631f0c8c1..f94c9757d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -13,7 +13,6 @@ from dcim.choices import * from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet -from extras.utils import extras_features from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices @@ -37,7 +36,6 @@ __all__ = ( # Device Types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -70,7 +68,6 @@ class Manufacturer(OrganizationalModel): return reverse('dcim:manufacturer', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceType(PrimaryModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as @@ -353,7 +350,6 @@ class DeviceType(PrimaryModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ModuleType(PrimaryModel): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional @@ -487,7 +483,6 @@ class ModuleType(PrimaryModel): # Devices # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a @@ -525,7 +520,6 @@ class DeviceRole(OrganizationalModel): return reverse('dcim:devicerole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". @@ -575,7 +569,6 @@ class Platform(OrganizationalModel): return reverse('dcim:platform', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, @@ -1012,7 +1005,6 @@ class Device(PrimaryModel, ConfigContextModel): return DeviceStatusChoices.colors.get(self.status, 'secondary') -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Module(PrimaryModel, ConfigContextModel): """ A Module represents a field-installable component within a Device which may itself hold multiple device components @@ -1095,7 +1087,6 @@ class Module(PrimaryModel, ConfigContextModel): # Virtual chassis # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VirtualChassis(PrimaryModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index e3146c167..fe7f69df9 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,7 +6,6 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * -from extras.utils import extras_features from netbox.models import PrimaryModel from utilities.validators import ExclusionValidator from .device_components import LinkTermination, PathEndpoint @@ -21,7 +20,6 @@ __all__ = ( # Power # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerPanel(PrimaryModel): """ A distribution point for electrical power; e.g. a data center RPP. @@ -68,7 +66,6 @@ class PowerPanel(PrimaryModel): ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): """ An electrical circuit delivered from a PowerPanel. diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index c324d4cba..1ebbbcba4 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -13,7 +13,6 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from extras.utils import extras_features from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices @@ -34,7 +33,6 @@ __all__ = ( # Racks # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. @@ -65,7 +63,6 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Rack(PrimaryModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. @@ -438,7 +435,6 @@ class Rack(PrimaryModel): return int(allocated_draw_total / available_power_total * 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackReservation(PrimaryModel): """ One or more reserved units within a Rack. diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a71206224..3756933ac 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -7,8 +7,6 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from dcim.fields import ASNField -from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField @@ -24,7 +22,6 @@ __all__ = ( # Regions # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Region(NestedGroupModel): """ A region represents a geographic collection of sites. For example, you might create regions representing countries, @@ -111,7 +108,6 @@ class Region(NestedGroupModel): # Site groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class SiteGroup(NestedGroupModel): """ A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and @@ -198,7 +194,6 @@ class SiteGroup(NestedGroupModel): # Sites # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Site(PrimaryModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility @@ -322,7 +317,6 @@ class Site(PrimaryModel): # Locations # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Location(NestedGroupModel): """ A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 64cc82f63..123eb0a45 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -7,6 +7,7 @@ EXTRAS_FEATURES = [ 'custom_links', 'export_templates', 'job_results', + 'journaling', 'tags', 'webhooks' ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 2a14f143f..0dc5d57db 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -5,8 +5,8 @@ from django.db import models from django.urls import reverse from extras.querysets import ConfigContextQuerySet -from extras.utils import extras_features from netbox.models import ChangeLoggedModel +from netbox.models.features import WebhooksMixin from utilities.utils import deepmerge @@ -20,8 +20,7 @@ __all__ = ( # Config contexts # -@extras_features('webhooks') -class ConfigContext(ChangeLoggedModel): +class ConfigContext(WebhooksMixin, ChangeLoggedModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index c0f040300..923d84413 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -12,8 +12,9 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from extras.choices import * -from extras.utils import FeatureQuery, extras_features +from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, WebhooksMixin from utilities import filters from utilities.forms import ( CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -40,8 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -@extras_features('webhooks', 'export_templates') -class CustomField(ChangeLoggedModel): +class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index ab877b99e..7189aed03 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -17,8 +17,9 @@ from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * from extras.conditions import ConditionSet -from extras.utils import extras_features, FeatureQuery, image_upload +from extras.utils import FeatureQuery, image_upload from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -35,8 +36,7 @@ __all__ = ( ) -@extras_features('webhooks', 'export_templates') -class Webhook(ChangeLoggedModel): +class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or delete in NetBox. The request will contain a representation of the object, which the remote application can act on. @@ -184,8 +184,7 @@ class Webhook(ChangeLoggedModel): return render_jinja2(self.payload_url, context) -@extras_features('webhooks', 'export_templates') -class CustomLink(ChangeLoggedModel): +class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. @@ -258,8 +257,7 @@ class CustomLink(ChangeLoggedModel): } -@extras_features('webhooks', 'export_templates') -class ExportTemplate(ChangeLoggedModel): +class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, @@ -345,8 +343,7 @@ class ExportTemplate(ChangeLoggedModel): return response -@extras_features('webhooks') -class ImageAttachment(ChangeLoggedModel): +class ImageAttachment(WebhooksMixin, ChangeLoggedModel): """ An uploaded image which is associated with an object. """ @@ -424,8 +421,7 @@ class ImageAttachment(ChangeLoggedModel): return super().to_objectchange(action, related_object=self.parent) -@extras_features('webhooks') -class JournalEntry(ChangeLoggedModel): +class JournalEntry(WebhooksMixin, ChangeLoggedModel): """ A historical remark concerning an object; collectively, these form an object's journal. The journal is used to preserve historical context around an object, and complements NetBox's built-in change logging. For example, you @@ -603,8 +599,7 @@ class ConfigRevision(models.Model): # Custom scripts & reports # -@extras_features('job_results') -class Script(models.Model): +class Script(JobResultsMixin, models.Model): """ Dummy model used to generate permissions for custom scripts. Does not exist in the database. """ @@ -616,8 +611,7 @@ class Script(models.Model): # Reports # -@extras_features('job_results') -class Report(models.Model): +class Report(JobResultsMixin, models.Model): """ Dummy model used to generate permissions for reports. Does not exist in the database. """ diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 2925da652..df8446b9c 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -3,8 +3,8 @@ from django.urls import reverse from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from extras.utils import extras_features from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, WebhooksMixin from utilities.choices import ColorChoices from utilities.fields import ColorField @@ -13,8 +13,7 @@ from utilities.fields import ColorField # Tags # -@extras_features('webhooks', 'export_templates') -class Tag(ChangeLoggedModel, TagBase): +class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): color = ColorField( default=ColorChoices.COLOR_GREY ) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 16749ad5d..487ca3c0b 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -67,14 +67,3 @@ def register_features(model, features): raise ValueError(f"{feature} is not a valid extras feature!") app_label, model_name = model._meta.label_lower.split('.') registry['model_features'][feature][app_label].append(model_name) - - -def extras_features(*features): - """ - Decorator used to register extras provided features to a model - """ - def wrapper(model_class): - # Initialize the model_features store if not already defined - register_features(model_class, features) - return model_class - return wrapper diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 9e721c65f..a0e575e45 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -4,8 +4,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from extras.utils import extras_features from netbox.models import ChangeLoggedModel, PrimaryModel +from netbox.models.features import WebhooksMixin from ipam.choices import * from ipam.constants import * @@ -15,7 +15,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class FHRPGroup(PrimaryModel): """ A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) @@ -70,8 +69,7 @@ class FHRPGroup(PrimaryModel): return reverse('ipam:fhrpgroup', args=[self.pk]) -@extras_features('webhooks') -class FHRPGroupAssignment(ChangeLoggedModel): +class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel): interface_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 9d6fb5edc..44dd84525 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,7 +9,6 @@ from django.utils.functional import cached_property from dcim.fields import ASNField from dcim.models import Device -from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel from ipam.choices import * from ipam.constants import * @@ -54,7 +53,6 @@ class GetAvailablePrefixesMixin: return available_prefixes.iter_cidrs()[0] -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RIR(OrganizationalModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address @@ -90,7 +88,6 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ASN(PrimaryModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have @@ -150,7 +147,6 @@ class ASN(PrimaryModel): return self.asn -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize @@ -253,7 +249,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or @@ -285,7 +280,6 @@ class Role(OrganizationalModel): return reverse('ipam:role', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Prefix(GetAvailablePrefixesMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and @@ -563,7 +557,6 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class IPRange(PrimaryModel): """ A range of IP addresses, defined by start and end addresses. @@ -759,7 +752,6 @@ class IPRange(PrimaryModel): return int(float(child_count) / self.size * 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class IPAddress(PrimaryModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 43f8353bc..bd8030a0a 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -4,7 +4,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from extras.utils import extras_features from ipam.choices import * from ipam.constants import * from netbox.models import PrimaryModel @@ -47,7 +46,6 @@ class ServiceBase(models.Model): return array_to_string(self.ports) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ServiceTemplate(ServiceBase, PrimaryModel): """ A template for a Service to be applied to a device or virtual machine. @@ -64,7 +62,6 @@ class ServiceTemplate(ServiceBase, PrimaryModel): return reverse('ipam:servicetemplate', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Service(ServiceBase, PrimaryModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 31c8da2b6..f73571ea9 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -6,7 +6,6 @@ from django.db import models from django.urls import reverse from dcim.models import Interface -from extras.utils import extras_features from ipam.choices import * from ipam.constants import * from ipam.querysets import VLANQuerySet @@ -20,7 +19,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VLANGroup(OrganizationalModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. @@ -118,7 +116,6 @@ class VLANGroup(OrganizationalModel): return None -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VLAN(PrimaryModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index 11fab9c44..f1b2d682f 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -1,7 +1,6 @@ from django.db import models from django.urls import reverse -from extras.utils import extras_features from ipam.constants import * from netbox.models import PrimaryModel @@ -12,7 +11,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VRF(PrimaryModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing @@ -75,7 +73,6 @@ class VRF(PrimaryModel): return reverse('ipam:vrf', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RouteTarget(PrimaryModel): """ A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index e715cf329..e38412221 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,4 +1,3 @@ -from django.contrib.contenttypes.fields import GenericRelation from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey @@ -20,6 +19,18 @@ __all__ = ( # Base model classes # +class BaseModel( + CustomFieldsMixin, + CustomLinksMixin, + ExportTemplatesMixin, + JournalingMixin, + TagsMixin, + WebhooksMixin, +): + class Meta: + abstract = True + + class BigIDModel(models.Model): """ Abstract base model for all data objects. Ensures the use of a 64-bit PK. @@ -42,23 +53,17 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): abstract = True -class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): +class PrimaryModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): """ Primary models represent real objects within the infrastructure being modeled. """ - journal_entries = GenericRelation( - to='extras.JournalEntry', - object_id_field='assigned_object_id', - content_type_field='assigned_object_type' - ) - objects = RestrictedQuerySet.as_manager() class Meta: abstract = True -class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): +class NestedGroupModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -100,7 +105,7 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMi }) -class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): +class OrganizationalModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 99f8c00b7..ed0e481ad 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,5 +1,6 @@ import logging +from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver @@ -20,6 +21,7 @@ __all__ = ( 'CustomValidationMixin', 'ExportTemplatesMixin', 'JobResultsMixin', + 'JournalingMixin', 'TagsMixin', 'WebhooksMixin', ) @@ -169,6 +171,20 @@ class JobResultsMixin(models.Model): abstract = True +class JournalingMixin(models.Model): + """ + Enables support for JournalEntry assignment. + """ + journal_entries = GenericRelation( + to='extras.JournalEntry', + object_id_field='assigned_object_id', + content_type_field='assigned_object_type' + ) + + class Meta: + abstract = True + + class TagsMixin(models.Model): """ Enable the assignment of Tags to a model. @@ -194,6 +210,7 @@ FEATURES_MAP = ( ('custom_links', CustomLinksMixin), ('export_templates', ExportTemplatesMixin), ('job_results', JobResultsMixin), + ('journaling', JournalingMixin), ('tags', TagsMixin), ('webhooks', WebhooksMixin), ) diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 42a7ffe7d..ecc599021 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -4,8 +4,8 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from extras.utils import extras_features from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel +from netbox.models.features import WebhooksMixin from tenancy.choices import * __all__ = ( @@ -16,7 +16,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactGroup(NestedGroupModel): """ An arbitrary collection of Contacts. @@ -50,7 +49,6 @@ class ContactGroup(NestedGroupModel): return reverse('tenancy:contactgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactRole(OrganizationalModel): """ Functional role for a Contact assigned to an object. @@ -78,7 +76,6 @@ class ContactRole(OrganizationalModel): return reverse('tenancy:contactrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Contact(PrimaryModel): """ Contact information for a particular object(s) in NetBox. @@ -129,8 +126,7 @@ class Contact(PrimaryModel): return reverse('tenancy:contact', args=[self.pk]) -@extras_features('webhooks') -class ContactAssignment(ChangeLoggedModel): +class ContactAssignment(WebhooksMixin, ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index d480f9112..9952a700d 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -3,7 +3,6 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel __all__ = ( @@ -12,7 +11,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class TenantGroup(NestedGroupModel): """ An arbitrary collection of Tenants. @@ -45,7 +43,6 @@ class TenantGroup(NestedGroupModel): return reverse('tenancy:tenantgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Tenant(PrimaryModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index b19715127..d2f513f0b 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,7 +7,6 @@ from django.urls import reverse from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet -from extras.utils import extras_features from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField @@ -15,7 +14,6 @@ from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar from .choices import * - __all__ = ( 'Cluster', 'ClusterGroup', @@ -29,7 +27,6 @@ __all__ = ( # Cluster types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterType(OrganizationalModel): """ A type of Cluster. @@ -61,7 +58,6 @@ class ClusterType(OrganizationalModel): # Cluster groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. @@ -104,7 +100,6 @@ class ClusterGroup(OrganizationalModel): # Clusters # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Cluster(PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. @@ -188,7 +183,6 @@ class Cluster(PrimaryModel): # Virtual machines # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VirtualMachine(PrimaryModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. @@ -351,7 +345,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): # Interfaces # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VMInterface(PrimaryModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 2fcfc97aa..843462ec6 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -5,7 +5,6 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from extras.utils import extras_features from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel from .choices import * from .constants import * @@ -41,7 +40,6 @@ class WirelessAuthenticationBase(models.Model): abstract = True -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLANGroup(NestedGroupModel): """ A nested grouping of WirelessLANs @@ -81,7 +79,6 @@ class WirelessLANGroup(NestedGroupModel): return reverse('wireless:wirelesslangroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): """ A wireless network formed among an arbitrary number of access point and clients. @@ -120,7 +117,6 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): return reverse('wireless:wirelesslan', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLink(WirelessAuthenticationBase, PrimaryModel): """ A point-to-point connection between two wireless Interfaces. From 047bed2a865bcc69f0702ad9b06f664ab9ccc3c5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 15:22:28 -0500 Subject: [PATCH 3/9] Tweak registry initialization --- netbox/extras/registry.py | 13 +++++++++++-- netbox/extras/utils.py | 8 +------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py index cb58f5135..07fd4cc24 100644 --- a/netbox/extras/registry.py +++ b/netbox/extras/registry.py @@ -1,3 +1,8 @@ +import collections + +from extras.constants import EXTRAS_FEATURES + + class Registry(dict): """ Central registry for registration of functionality. Once a store (key) is defined, it cannot be overwritten or @@ -7,15 +12,19 @@ class Registry(dict): try: return super().__getitem__(key) except KeyError: - raise KeyError("Invalid store: {}".format(key)) + raise KeyError(f"Invalid store: {key}") def __setitem__(self, key, value): if key in self: - raise KeyError("Store already set: {}".format(key)) + raise KeyError(f"Store already set: {key}") super().__setitem__(key, value) def __delitem__(self, key): raise TypeError("Cannot delete stores from registry") +# Initialize the global registry registry = Registry() +registry['model_features'] = { + feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES +} diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 487ca3c0b..e16807821 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,5 +1,3 @@ -import collections - from django.db.models import Q from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager @@ -58,12 +56,8 @@ class FeatureQuery: def register_features(model, features): - if 'model_features' not in registry: - registry['model_features'] = { - f: collections.defaultdict(list) for f in EXTRAS_FEATURES - } for feature in features: if feature not in EXTRAS_FEATURES: raise ValueError(f"{feature} is not a valid extras feature!") app_label, model_name = model._meta.label_lower.split('.') - registry['model_features'][feature][app_label].append(model_name) + registry['model_features'][feature][app_label].add(model_name) From dd552264559c968c94a725c9558a4fd01cd039c4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 16:44:18 -0500 Subject: [PATCH 4/9] Draft documentation for model features --- docs/plugins/development/index.md | 3 ++ docs/plugins/development/model-features.md | 37 ++++++++++++++++++++++ mkdocs.yml | 18 ++++++++++- netbox/netbox/models/features.py | 24 +++++++++++--- 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 docs/plugins/development/index.md create mode 100644 docs/plugins/development/model-features.md diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md new file mode 100644 index 000000000..31ce5fc2e --- /dev/null +++ b/docs/plugins/development/index.md @@ -0,0 +1,3 @@ +# Plugins Development + +TODO diff --git a/docs/plugins/development/model-features.md b/docs/plugins/development/model-features.md new file mode 100644 index 000000000..83f9b4205 --- /dev/null +++ b/docs/plugins/development/model-features.md @@ -0,0 +1,37 @@ +# Model Features + +Plugin models can leverage certain NetBox features by inheriting from designated Python classes (documented below), defined in `netbox.models.features`. These classes perform two crucial functions: + +1. Apply any fields, methods, or attributes necessary to the operation of the feature +2. Register the model with NetBox as utilizing the feature + +For example, to enable support for tags in a plugin model, it should inherit from `TagsMixin`: + +```python +# models.py +from django.db.models import models +from netbox.models.features import TagsMixin + +class MyModel(TagsMixin, models.Model): + foo = models.CharField() + ... +``` + +This will ensure that TaggableManager is applied to the model, and that the model is registered with NetBox as taggable. + +!!! note + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. + +::: netbox.models.features.CustomLinksMixin + +::: netbox.models.features.CustomFieldsMixin + +::: netbox.models.features.ExportTemplatesMixin + +::: netbox.models.features.JobResultsMixin + +::: netbox.models.features.JournalingMixin + +::: netbox.models.features.TagsMixin + +::: netbox.models.features.WebhooksMixin diff --git a/mkdocs.yml b/mkdocs.yml index f89bdaea7..764a04c86 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,19 @@ theme: toggle: icon: material/lightbulb name: Switch to Light Mode +plugins: + - mkdocstrings: + handlers: + python: + setup_commands: + - import os + - import django + - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") + - django.setup() + rendering: + show_root_heading: true + show_root_full_path: false + show_root_toc_entry: false extra: social: - icon: fontawesome/brands/github @@ -84,7 +97,10 @@ nav: - Webhooks: 'additional-features/webhooks.md' - Plugins: - Using Plugins: 'plugins/index.md' - - Developing Plugins: 'plugins/development.md' + - Developing Plugins: + - Introduction: 'plugins/development/index.md' + - Model Features: 'plugins/development/model-features.md' + - Developing Plugins (Old): 'plugins/development.md' - Administration: - Authentication: 'administration/authentication.md' - Permissions: 'administration/permissions.md' diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index ed0e481ad..7865e3c8a 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -93,13 +93,25 @@ class CustomFieldsMixin(models.Model): @property def cf(self): """ - Convenience wrapper for custom field data. + A pass-through convenience alias for accessing `custom_field_data` (read-only). + + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.cf + {'cust_id': 'CYB01'} + ``` """ return self.custom_field_data def get_custom_fields(self): """ - Return a dictionary of custom fields for a single object in the form {: value}. + Return a dictionary of custom fields for a single object in the form `{field: value}`. + + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.get_custom_fields() + {: 'CYB01'} + ``` """ from extras.models import CustomField @@ -165,7 +177,7 @@ class ExportTemplatesMixin(models.Model): class JobResultsMixin(models.Model): """ - Enable the assignment of JobResults to a model. + Enables support for job results. """ class Meta: abstract = True @@ -173,7 +185,8 @@ class JobResultsMixin(models.Model): class JournalingMixin(models.Model): """ - Enables support for JournalEntry assignment. + Enables support for object journaling. Adds a generic relation (`journal_entries`) + to NetBox's JournalEntry model. """ journal_entries = GenericRelation( to='extras.JournalEntry', @@ -187,7 +200,8 @@ class JournalingMixin(models.Model): class TagsMixin(models.Model): """ - Enable the assignment of Tags to a model. + Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute, + which is a `TaggableManager` instance. """ tags = TaggableManager( through='extras.TaggedItem' From d104544d6f26f54d8982b385b7bf6aa31f86255d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 16:52:00 -0500 Subject: [PATCH 5/9] Add mkdocstrings --- docs/requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index c8726f8e6..b2e4e9a1b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,3 +5,7 @@ markdown-include # MkDocs Material theme (for documentation build) # https://github.com/squidfunk/mkdocs-material mkdocs-material + +# Introspection for embedded code +# https://github.com/mkdocstrings/mkdocstrings +mkdocstrings From 196784474def8604dda5a74a13a9be29d2708dd9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 16:58:06 -0500 Subject: [PATCH 6/9] Update documentation requirements --- base_requirements.txt | 4 ++++ docs/requirements.txt | 11 ----------- requirements.txt | 1 + 3 files changed, 5 insertions(+), 11 deletions(-) delete mode 100644 docs/requirements.txt diff --git a/base_requirements.txt b/base_requirements.txt index aaa9c7f44..7ceb344b0 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -82,6 +82,10 @@ markdown-include # https://github.com/squidfunk/mkdocs-material mkdocs-material +# Introspection for embedded code +# https://github.com/mkdocstrings/mkdocstrings +mkdocstrings + # Library for manipulating IP prefixes and addresses # https://github.com/drkjam/netaddr netaddr diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index b2e4e9a1b..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# File inclusion plugin for Python-Markdown -# https://github.com/cmacmackin/markdown-include -markdown-include - -# MkDocs Material theme (for documentation build) -# https://github.com/squidfunk/mkdocs-material -mkdocs-material - -# Introspection for embedded code -# https://github.com/mkdocstrings/mkdocstrings -mkdocstrings diff --git a/requirements.txt b/requirements.txt index 83c1b9f2e..a23be8637 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ Jinja2==3.0.3 Markdown==3.3.6 markdown-include==0.6.0 mkdocs-material==8.1.7 +mkdocstrings==0.17.0 netaddr==0.8.0 Pillow==8.4.0 psycopg2-binary==2.9.3 From b7682ca9e829102b636864b23eaff15eb4562544 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 09:27:37 -0500 Subject: [PATCH 7/9] Fix documentation build --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 764a04c86..4fdf22f97 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,7 @@ plugins: setup_commands: - import os - import django + - os.chdir('netbox/') - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - django.setup() rendering: From 1a8f144f5c2ca9d307f8ab7829f595a539206e81 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 10:53:00 -0500 Subject: [PATCH 8/9] Include custom validation in BaseModel --- docs/plugins/development/model-features.md | 46 ++++++++++++++++++---- mkdocs.yml | 1 + netbox/netbox/models/__init__.py | 7 ++-- netbox/netbox/models/features.py | 2 +- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/docs/plugins/development/model-features.md b/docs/plugins/development/model-features.md index 83f9b4205..7b70709d1 100644 --- a/docs/plugins/development/model-features.md +++ b/docs/plugins/development/model-features.md @@ -1,23 +1,51 @@ # Model Features -Plugin models can leverage certain NetBox features by inheriting from designated Python classes (documented below), defined in `netbox.models.features`. These classes perform two crucial functions: +## Enabling NetBox Features -1. Apply any fields, methods, or attributes necessary to the operation of the feature -2. Register the model with NetBox as utilizing the feature +Plugin models can leverage certain NetBox features by inheriting from NetBox's `BaseModel` class. This class extends the plugin model to enable numerous feature, including: -For example, to enable support for tags in a plugin model, it should inherit from `TagsMixin`: +* Custom fields +* Custom links +* Custom validation +* Export templates +* Job results +* Journaling +* Tags +* Webhooks + +This class performs two crucial functions: + +1. Apply any fields, methods, or attributes necessary to the operation of these features +2. Register the model with NetBox as utilizing these feature + +Simply subclass BaseModel when defining a model in your plugin: ```python # models.py -from django.db.models import models -from netbox.models.features import TagsMixin +from netbox.models import BaseModel -class MyModel(TagsMixin, models.Model): +class MyModel(BaseModel): foo = models.CharField() ... ``` -This will ensure that TaggableManager is applied to the model, and that the model is registered with NetBox as taggable. +## Enabling Features Individually + +If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (You will also need to inherit from Django's built-in `Model` class.) + +```python +# models.py +from django.db.models import models +from netbox.models.features import ExportTemplatesMixin, TagsMixin + +class MyModel(ExportTemplatesMixin, TagsMixin, models.Model): + foo = models.CharField() + ... +``` + +The example above will enable export templates and tags, but no other NetBox features. A complete list of available feature mixins is included below. (Inheriting all the available mixins is essentially the same as subclassing `BaseModel`.) + +## Feature Mixins Reference !!! note Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. @@ -26,6 +54,8 @@ This will ensure that TaggableManager is applied to the model, and that the mode ::: netbox.models.features.CustomFieldsMixin +::: netbox.models.features.CustomValidationMixin + ::: netbox.models.features.ExportTemplatesMixin ::: netbox.models.features.JobResultsMixin diff --git a/mkdocs.yml b/mkdocs.yml index 4fdf22f97..585e6d76f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,7 @@ plugins: - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - django.setup() rendering: + heading_level: 3 show_root_heading: true show_root_full_path: false show_root_toc_entry: false diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index e38412221..2db2e2602 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -22,6 +22,7 @@ __all__ = ( class BaseModel( CustomFieldsMixin, CustomLinksMixin, + CustomValidationMixin, ExportTemplatesMixin, JournalingMixin, TagsMixin, @@ -53,7 +54,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): abstract = True -class PrimaryModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): +class PrimaryModel(BaseModel, ChangeLoggingMixin, BigIDModel): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -63,7 +64,7 @@ class PrimaryModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDMo abstract = True -class NestedGroupModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel, MPTTModel): +class NestedGroupModel(BaseModel, ChangeLoggingMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -105,7 +106,7 @@ class NestedGroupModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, Big }) -class OrganizationalModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): +class OrganizationalModel(BaseModel, ChangeLoggingMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 7865e3c8a..ce3980459 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -155,7 +155,7 @@ class CustomLinksMixin(models.Model): class CustomValidationMixin(models.Model): """ - Enables user-configured validation rules for built-in models by extending the clean() method. + Enables user-configured validation rules for models. """ class Meta: abstract = True From e6acae5f94485a2eb459e17a0ed008f76fcc7226 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 11:41:00 -0500 Subject: [PATCH 9/9] Omit job results as a supported feature --- docs/plugins/development/model-features.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/plugins/development/model-features.md b/docs/plugins/development/model-features.md index 7b70709d1..35eb9389f 100644 --- a/docs/plugins/development/model-features.md +++ b/docs/plugins/development/model-features.md @@ -8,7 +8,6 @@ Plugin models can leverage certain NetBox features by inheriting from NetBox's ` * Custom links * Custom validation * Export templates -* Job results * Journaling * Tags * Webhooks @@ -58,8 +57,6 @@ The example above will enable export templates and tags, but no other NetBox fea ::: netbox.models.features.ExportTemplatesMixin -::: netbox.models.features.JobResultsMixin - ::: netbox.models.features.JournalingMixin ::: netbox.models.features.TagsMixin