Преглед изворни кода

Merge pull request #8414 from netbox-community/8392-plugins-features

Closes #8392: Enable NetBox features for plugin models
Jeremy Stretch пре 4 година
родитељ
комит
7002319cc8

+ 4 - 0
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

+ 3 - 0
docs/plugins/development/index.md

@@ -0,0 +1,3 @@
+# Plugins Development
+
+TODO

+ 64 - 0
docs/plugins/development/model-features.md

@@ -0,0 +1,64 @@
+# Model Features
+
+## Enabling NetBox Features
+
+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:
+
+* Custom fields
+* Custom links
+* Custom validation
+* Export templates
+* 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 netbox.models import BaseModel
+
+class MyModel(BaseModel):
+    foo = models.CharField()
+    ...
+```
+
+## 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.
+
+::: netbox.models.features.CustomLinksMixin
+
+::: netbox.models.features.CustomFieldsMixin
+
+::: netbox.models.features.CustomValidationMixin
+
+::: netbox.models.features.ExportTemplatesMixin
+
+::: netbox.models.features.JournalingMixin
+
+::: netbox.models.features.TagsMixin
+
+::: netbox.models.features.WebhooksMixin

+ 0 - 7
docs/requirements.txt

@@ -1,7 +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

+ 19 - 1
mkdocs.yml

@@ -16,6 +16,21 @@ theme:
       toggle:
         icon: material/lightbulb
         name: Switch to Light Mode
+plugins:
+  - mkdocstrings:
+      handlers:
+        python:
+          setup_commands:
+            - import os
+            - import django
+            - os.chdir('netbox/')
+            - 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
 extra:
   social:
     - icon: fontawesome/brands/github
@@ -84,7 +99,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'

+ 2 - 5
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,

+ 0 - 3
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

+ 0 - 2
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.

+ 3 - 13
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.

+ 0 - 12
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.

+ 0 - 9
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).

+ 0 - 3
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.

+ 0 - 4
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.

+ 0 - 6
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

+ 1 - 0
netbox/extras/constants.py

@@ -7,6 +7,7 @@ EXTRAS_FEATURES = [
     'custom_links',
     'export_templates',
     'job_results',
+    'journaling',
     'tags',
     'webhooks'
 ]

+ 2 - 3
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

+ 3 - 3
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',

+ 9 - 15
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.
     """

+ 2 - 3
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
     )

+ 11 - 2
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
+}

+ 6 - 20
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
@@ -57,21 +55,9 @@ class FeatureQuery:
         return query
 
 
-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))
-        return model_class
-    return wrapper
+def register_features(model, 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].add(model_name)

+ 2 - 4
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

+ 0 - 8
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

+ 0 - 3
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

+ 0 - 3
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

+ 0 - 3
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.

+ 135 - 0
netbox/netbox/models/__init__.py

@@ -0,0 +1,135 @@
+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 BaseModel(
+    CustomFieldsMixin,
+    CustomLinksMixin,
+    CustomValidationMixin,
+    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.
+    """
+    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(BaseModel, ChangeLoggingMixin, BigIDModel):
+    """
+    Primary models represent real objects within the infrastructure being modeled.
+    """
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        abstract = True
+
+
+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.
+    """
+    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(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
+    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',)

+ 72 - 100
netbox/netbox/models.py → netbox/netbox/models/features.py

@@ -1,34 +1,39 @@
 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',
+    'JournalingMixin',
+    '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 +79,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,
@@ -88,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 {<field>: 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()
+        {<CustomField: Customer ID>: 'CYB01'}
+        ```
         """
         from extras.models import CustomField
 
@@ -128,60 +145,48 @@ class CustomFieldsMixin(models.Model):
                 raise ValidationError(f"Missing required custom field '{cf.name}'.")
 
 
-class CustomValidationMixin(models.Model):
+class CustomLinksMixin(models.Model):
     """
-    Enables user-configured validation rules for built-in models by extending the clean() method.
+    Enables support for custom links.
     """
     class Meta:
         abstract = True
 
-    def clean(self):
-        super().clean()
 
-        # Send the post_clean signal
-        post_clean.send(sender=self.__class__, instance=self)
-
-
-class TagsMixin(models.Model):
+class CustomValidationMixin(models.Model):
     """
-    Enable the assignment of Tags.
+    Enables user-configured validation rules for models.
     """
-    tags = TaggableManager(
-        through='extras.TaggedItem'
-    )
-
     class Meta:
         abstract = True
 
+    def clean(self):
+        super().clean()
+
+        # Send the post_clean signal
+        post_clean.send(sender=self.__class__, instance=self)
 
-#
-# Base model classes
 
-class BigIDModel(models.Model):
+class ExportTemplatesMixin(models.Model):
     """
-    Abstract base model for all data objects. Ensures the use of a 64-bit PK.
+    Enables support for export templates.
     """
-    id = models.BigAutoField(
-        primary_key=True
-    )
-
     class Meta:
         abstract = True
 
 
-class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel):
+class JobResultsMixin(models.Model):
     """
-    Base model for all objects which support change logging.
+    Enables support for job results.
     """
-    objects = RestrictedQuerySet.as_manager()
-
     class Meta:
         abstract = True
 
 
-class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel):
+class JournalingMixin(models.Model):
     """
-    Primary models represent real objects within the infrastructure being modeled.
+    Enables support for object journaling. Adds a generic relation (`journal_entries`)
+    to NetBox's JournalEntry model.
     """
     journal_entries = GenericRelation(
         to='extras.JournalEntry',
@@ -189,78 +194,45 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin,
         content_type_field='assigned_object_type'
     )
 
-    objects = RestrictedQuerySet.as_manager()
-
     class Meta:
         abstract = True
 
 
-class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel):
+class TagsMixin(models.Model):
     """
-    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.
+    Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute,
+    which is a `TaggableManager` instance.
     """
-    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
+    tags = TaggableManager(
+        through='extras.TaggedItem'
     )
 
-    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):
+class WebhooksMixin(models.Model):
     """
-    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
+    Enables support for webhooks.
     """
-    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',)
+
+
+FEATURES_MAP = (
+    ('custom_fields', CustomFieldsMixin),
+    ('custom_links', CustomLinksMixin),
+    ('export_templates', ExportTemplatesMixin),
+    ('job_results', JobResultsMixin),
+    ('journaling', JournalingMixin),
+    ('tags', TagsMixin),
+    ('webhooks', WebhooksMixin),
+)
+
+
+@receiver(class_prepared)
+def _register_features(sender, **kwargs):
+    features = {
+        feature for feature, cls in FEATURES_MAP if issubclass(sender, cls)
+    }
+    register_features(sender, features)

+ 2 - 6
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

+ 0 - 3
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

+ 0 - 7
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',

+ 0 - 4
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.

+ 1 - 0
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