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/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..35eb9389f --- /dev/null +++ b/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 diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index c8726f8e6..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index f89bdaea7..585e6d76f 100644 --- a/mkdocs.yml +++ b/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' 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/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 ace49cce5..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 @@ -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) 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 new file mode 100644 index 000000000..2db2e2602 --- /dev/null +++ b/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',) diff --git a/netbox/netbox/models.py b/netbox/netbox/models/features.py similarity index 58% rename from netbox/netbox/models.py rename to netbox/netbox/models/features.py index 3e6ebd8b2..ce3980459 100644 --- a/netbox/netbox/models.py +++ b/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 {: 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 @@ -128,9 +145,17 @@ 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. + Enables user-configured validation rules for models. """ class Meta: abstract = True @@ -142,9 +167,41 @@ 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): + """ + Enables support for job results. + """ + class Meta: + abstract = True + + +class JournalingMixin(models.Model): + """ + Enables support for object journaling. Adds a generic relation (`journal_entries`) + to NetBox's JournalEntry model. + """ + 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. + Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute, + which is a `TaggableManager` instance. """ tags = TaggableManager( through='extras.TaggedItem' @@ -154,113 +211,28 @@ 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), + ('journaling', JournalingMixin), + ('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) 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. 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