From bec7ea7072364d7e99b28968a5e197050b0e5c54 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 24 Feb 2021 21:01:16 -0500 Subject: [PATCH] Standardize model types based on function --- .../migrations/0025_standardize_models.py | 37 ++ netbox/circuits/models.py | 12 +- .../migrations/0123_standardize_models.py | 462 ++++++++++++++++++ netbox/dcim/models/cables.py | 7 +- .../dcim/models/device_component_templates.py | 12 +- netbox/dcim/models/device_components.py | 21 +- netbox/dcim/models/devices.py | 19 +- netbox/dcim/models/power.py | 7 +- netbox/dcim/models/racks.py | 33 +- netbox/dcim/models/sites.py | 28 +- .../migrations/0054_standardize_models.py | 67 +++ netbox/extras/models/__init__.py | 3 +- netbox/extras/models/change_logging.py | 42 +- netbox/extras/models/customfields.py | 5 +- netbox/extras/models/models.py | 14 +- netbox/extras/models/tags.py | 6 +- .../migrations/0044_standardize_models.py | 77 +++ netbox/ipam/models/__init__.py | 17 + netbox/ipam/{models.py => models/ip.py} | 435 +---------------- netbox/ipam/models/services.py | 109 +++++ netbox/ipam/models/vlans.py | 202 ++++++++ netbox/ipam/models/vrfs.py | 139 ++++++ netbox/netbox/models.py | 184 +++++++ .../migrations/0013_standardize_models.py | 37 ++ netbox/secrets/models.py | 12 +- .../migrations/0012_standardize_models.py | 27 + netbox/tenancy/models.py | 24 +- .../migrations/0011_standardize_models.py | 26 + netbox/users/models.py | 7 +- .../migrations/0020_standardize_models.py | 62 +++ netbox/virtualization/models.py | 15 +- 31 files changed, 1569 insertions(+), 579 deletions(-) create mode 100644 netbox/circuits/migrations/0025_standardize_models.py create mode 100644 netbox/dcim/migrations/0123_standardize_models.py create mode 100644 netbox/extras/migrations/0054_standardize_models.py create mode 100644 netbox/ipam/migrations/0044_standardize_models.py create mode 100644 netbox/ipam/models/__init__.py rename netbox/ipam/{models.py => models/ip.py} (64%) create mode 100644 netbox/ipam/models/services.py create mode 100644 netbox/ipam/models/vlans.py create mode 100644 netbox/ipam/models/vrfs.py create mode 100644 netbox/netbox/models.py create mode 100644 netbox/secrets/migrations/0013_standardize_models.py create mode 100644 netbox/tenancy/migrations/0012_standardize_models.py create mode 100644 netbox/users/migrations/0011_standardize_models.py create mode 100644 netbox/virtualization/migrations/0020_standardize_models.py diff --git a/netbox/circuits/migrations/0025_standardize_models.py b/netbox/circuits/migrations/0025_standardize_models.py new file mode 100644 index 000000000..2b1d2664e --- /dev/null +++ b/netbox/circuits/migrations/0025_standardize_models.py @@ -0,0 +1,37 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0024_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='circuittype', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='circuit', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittermination', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='provider', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 3d6d5d232..e371df547 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -4,8 +4,9 @@ from taggit.managers import TaggableManager from dcim.fields import ASNField from dcim.models import CableTermination, PathEndpoint -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object from .choices import * @@ -21,7 +22,7 @@ __all__ = ( @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Provider(ChangeLoggedModel, CustomFieldModel): +class Provider(PrimaryModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -93,7 +94,8 @@ class Provider(ChangeLoggedModel, CustomFieldModel): ) -class CircuitType(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named "Long Haul," "Metro," or "Out-of-Band". @@ -133,7 +135,7 @@ class CircuitType(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Circuit(ChangeLoggedModel, CustomFieldModel): +class Circuit(PrimaryModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured @@ -233,7 +235,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): return self._get_termination('Z') -class CircuitTermination(PathEndpoint, CableTermination): +class CircuitTermination(BigIDModel, PathEndpoint, CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/dcim/migrations/0123_standardize_models.py b/netbox/dcim/migrations/0123_standardize_models.py new file mode 100644 index 000000000..5050e1a26 --- /dev/null +++ b/netbox/dcim/migrations/0123_standardize_models.py @@ -0,0 +1,462 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0122_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleserverport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicebay', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicebay', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='devicebay', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicerole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='frontport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='frontport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='frontport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='frontporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='frontporttemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='frontporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='interface', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='interfacetemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='inventoryitem', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='manufacturer', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='platform', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='poweroutlet', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='poweroutlet', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='poweroutlet', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='powerport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='powerport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='powerport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='powerporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='powerporttemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='powerporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rackgroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rackrole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rearport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rearport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rearport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rearporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rearporttemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rearporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='region', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='cable', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='cablepath', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='device', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebay', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicerole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicetype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interface', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitem', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='manufacturer', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='platform', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerfeed', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlet', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerpanel', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rack', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackgroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackreservation', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackrole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='region', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='site', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualchassis', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 6a530bb49..06ae150d4 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -12,8 +12,9 @@ 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.models import ChangeLoggedModel, CustomFieldModel, TaggedItem +from extras.models import TaggedItem from extras.utils import extras_features +from netbox.models import BigIDModel, PrimaryModel from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters @@ -32,7 +33,7 @@ __all__ = ( # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Cable(ChangeLoggedModel, CustomFieldModel): +class Cable(PrimaryModel): """ A physical connection between two endpoints. """ @@ -305,7 +306,7 @@ class Cable(ChangeLoggedModel, CustomFieldModel): return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] -class CablePath(models.Model): +class CablePath(BigIDModel): """ A CablePath instance represents the physical path from an origin to a destination, including all intermediate elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index e52fe2602..7b718936d 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -5,6 +5,8 @@ from django.db import models from dcim.choices import * from dcim.constants import * from extras.models import ObjectChange +from extras.utils import extras_features +from netbox.models import PrimaryModel from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.ordering import naturalize_interface @@ -26,7 +28,7 @@ __all__ = ( ) -class ComponentTemplateModel(models.Model): +class ComponentTemplateModel(PrimaryModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -82,6 +84,7 @@ class ComponentTemplateModel(models.Model): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class ConsolePortTemplate(ComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. @@ -105,6 +108,7 @@ class ConsolePortTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class ConsoleServerPortTemplate(ComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. @@ -128,6 +132,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class PowerPortTemplate(ComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. @@ -174,6 +179,7 @@ class PowerPortTemplate(ComponentTemplateModel): }) +@extras_features('custom_fields', 'export_templates', 'webhooks') class PowerOutletTemplate(ComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. @@ -225,6 +231,7 @@ class PowerOutletTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class InterfaceTemplate(ComponentTemplateModel): """ A template for a physical data interface on a new Device. @@ -259,6 +266,7 @@ class InterfaceTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class FrontPortTemplate(ComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -319,6 +327,7 @@ class FrontPortTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class RearPortTemplate(ComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. @@ -349,6 +358,7 @@ class RearPortTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay 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 452aacb56..f78363ba9 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -13,6 +13,7 @@ from dcim.constants import * from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import PrimaryModel from utilities.fields import NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -37,7 +38,7 @@ __all__ = ( ) -class ComponentModel(models.Model): +class ComponentModel(PrimaryModel): """ An abstract model inherited by any model which has a parent Device. """ @@ -198,7 +199,7 @@ class PathEndpoint(models.Model): # Console ports # -@extras_features('export_templates', 'webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class ConsolePort(CableTermination, PathEndpoint, ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. @@ -234,7 +235,7 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): # Console server ports # -@extras_features('webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. @@ -270,7 +271,7 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): # Power ports # -@extras_features('export_templates', 'webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class PowerPort(CableTermination, PathEndpoint, ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. @@ -379,7 +380,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): # Power outlets # -@extras_features('webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. @@ -479,7 +480,7 @@ class BaseInterface(models.Model): return super().save(*args, **kwargs) -@extras_features('export_templates', 'webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. @@ -624,7 +625,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): # Pass-through ports # -@extras_features('webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class FrontPort(CableTermination, ComponentModel): """ A pass-through port on the front of a Device. @@ -687,7 +688,7 @@ class FrontPort(CableTermination, ComponentModel): }) -@extras_features('webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class RearPort(CableTermination, ComponentModel): """ A pass-through port on the rear of a Device. @@ -740,7 +741,7 @@ class RearPort(CableTermination, ComponentModel): # Device bays # -@extras_features('webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -800,7 +801,7 @@ class DeviceBay(ComponentModel): # Inventory items # -@extras_features('export_templates', 'webhooks') +@extras_features('custom_fields', 'export_templates', '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 29818ab98..e73e4e73c 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -13,9 +13,10 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem +from extras.models import ConfigContextModel, TaggedItem from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet @@ -36,8 +37,8 @@ __all__ = ( # Device Types # -@extras_features('export_templates', 'webhooks') -class Manufacturer(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ @@ -76,7 +77,7 @@ class Manufacturer(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class DeviceType(ChangeLoggedModel, CustomFieldModel): +class DeviceType(PrimaryModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -338,7 +339,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): # Devices # -class DeviceRole(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to @@ -385,7 +387,8 @@ class DeviceRole(ChangeLoggedModel): ) -class Platform(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by @@ -449,7 +452,7 @@ class Platform(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): +class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -882,7 +885,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VirtualChassis(ChangeLoggedModel, CustomFieldModel): +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 1215ced4c..25b13f10b 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,8 +6,9 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem +from extras.models import TaggedItem from extras.utils import extras_features +from netbox.models import PrimaryModel from utilities.querysets import RestrictedQuerySet from utilities.validators import ExclusionValidator from .device_components import CableTermination, PathEndpoint @@ -23,7 +24,7 @@ __all__ = ( # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class PowerPanel(ChangeLoggedModel, CustomFieldModel): +class PowerPanel(PrimaryModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -74,7 +75,7 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldModel): +class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): """ An electrical circuit delivered from a PowerPanel. """ diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index dfaf7da61..c4ec76525 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -10,14 +10,15 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Sum from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey +from mptt.models import TreeForeignKey from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.elevations import RackElevationSVG -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet @@ -39,8 +40,8 @@ __all__ = ( # Racks # -@extras_features('export_templates') -class RackGroup(MPTTModel, ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class RackGroup(NestedGroupModel): """ Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that @@ -70,8 +71,6 @@ class RackGroup(MPTTModel, ChangeLoggedModel): blank=True ) - objects = TreeManager() - csv_headers = ['site', 'parent', 'name', 'slug', 'description'] class Meta: @@ -81,12 +80,6 @@ class RackGroup(MPTTModel, ChangeLoggedModel): ['site', 'slug'], ] - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) @@ -99,15 +92,6 @@ class RackGroup(MPTTModel, ChangeLoggedModel): self.description, ) - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) - ) - def clean(self): super().clean() @@ -116,7 +100,8 @@ class RackGroup(MPTTModel, ChangeLoggedModel): raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})") -class RackRole(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. """ @@ -159,7 +144,7 @@ class RackRole(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Rack(ChangeLoggedModel, CustomFieldModel): +class Rack(PrimaryModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a RackGroup. @@ -550,7 +535,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class RackReservation(ChangeLoggedModel, CustomFieldModel): +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 923b33124..92d8e1b26 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -1,15 +1,16 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey +from mptt.models import TreeForeignKey from taggit.managers import TaggableManager from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * from dcim.fields import ASNField -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.mptt import TreeManager @@ -25,8 +26,8 @@ __all__ = ( # Regions # -@extras_features('export_templates', 'webhooks') -class Region(MPTTModel, ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Region(NestedGroupModel): """ Sites can be grouped within geographic Regions. """ @@ -51,16 +52,8 @@ class Region(MPTTModel, ChangeLoggedModel): blank=True ) - objects = TreeManager() - csv_headers = ['name', 'slug', 'parent', 'description'] - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return "{}?region={}".format(reverse('dcim:site_list'), self.slug) @@ -78,22 +71,13 @@ class Region(MPTTModel, ChangeLoggedModel): Q(region__in=self.get_descendants()) ).count() - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) - ) - # # Sites # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Site(ChangeLoggedModel, CustomFieldModel): +class Site(PrimaryModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). diff --git a/netbox/extras/migrations/0054_standardize_models.py b/netbox/extras/migrations/0054_standardize_models.py new file mode 100644 index 000000000..a836f365f --- /dev/null +++ b/netbox/extras/migrations/0054_standardize_models.py @@ -0,0 +1,67 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0053_rename_webhook_obj_type'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='configcontext', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customfield', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customlink', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='exporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='imageattachment', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='jobresult', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='objectchange', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tag', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='taggeditem', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='webhook', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index c6191bbd2..daeed8223 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,4 +1,4 @@ -from .change_logging import ChangeLoggedModel, ObjectChange +from .change_logging import ObjectChange from .customfields import CustomField, CustomFieldModel from .models import ( ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script, @@ -7,7 +7,6 @@ from .models import ( from .tags import Tag, TaggedItem __all__ = ( - 'ChangeLoggedModel', 'ConfigContext', 'ConfigContextModel', 'CustomField', diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 52ffd38f9..a06043e7b 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -4,48 +4,12 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse -from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object from extras.choices import * +from netbox.models import BigIDModel +from utilities.querysets import RestrictedQuerySet -# -# Change logging -# - -class ChangeLoggedModel(models.Model): - """ - An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be - null to facilitate adding these fields to existing instances via a database migration. - """ - created = models.DateField( - auto_now_add=True, - blank=True, - null=True - ) - last_updated = models.DateTimeField( - auto_now=True, - blank=True, - null=True - ) - - class Meta: - abstract = True - - def to_objectchange(self, action): - """ - Return a new ObjectChange representing a change made to this object. This will typically be called automatically - by ChangeLoggingMiddleware. - """ - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self) - ) - - -class ObjectChange(models.Model): +class ObjectChange(BigIDModel): """ Record a change to an object and the user account associated with that change. A change record may optionally indicate an object related to the one being changed. For example, a change to an interface may also indicate the diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index a69816d21..05ac2a222 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -12,12 +12,13 @@ from django.utils.safestring import mark_safe from extras.choices import * from extras.utils import FeatureQuery +from netbox.models import BigIDModel from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex -class CustomFieldModel(models.Model): +class CustomFieldModel(BigIDModel): """ Abstract class for any model which may have custom fields associated with it. """ @@ -77,7 +78,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -class CustomField(models.Model): +class CustomField(BigIDModel): 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 4917a7e44..d60a2cc96 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -14,9 +14,9 @@ from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * -from extras.models import ChangeLoggedModel from extras.querysets import ConfigContextQuerySet from extras.utils import extras_features, FeatureQuery, image_upload +from netbox.models import BigIDModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from utilities.utils import deepmerge, render_jinja2 @@ -25,7 +25,7 @@ from utilities.utils import deepmerge, render_jinja2 # Webhooks # -class Webhook(models.Model): +class Webhook(BigIDModel): """ 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. @@ -158,7 +158,7 @@ class Webhook(models.Model): # Custom links # -class CustomLink(models.Model): +class CustomLink(BigIDModel): """ 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. @@ -210,7 +210,7 @@ class CustomLink(models.Model): # Export templates # -class ExportTemplate(models.Model): +class ExportTemplate(BigIDModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, @@ -285,7 +285,7 @@ class ExportTemplate(models.Model): # Image attachments # -class ImageAttachment(models.Model): +class ImageAttachment(BigIDModel): """ An uploaded image which is associated with an object. """ @@ -361,7 +361,7 @@ class ImageAttachment(models.Model): # Config contexts # -class ConfigContext(ChangeLoggedModel): +class ConfigContext(PrimaryModel): """ 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 @@ -526,7 +526,7 @@ class Report(models.Model): # Job results # -class JobResult(models.Model): +class JobResult(BigIDModel): """ This model stores the results from running a user-defined report. """ diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 4b5acdc76..22e9815a9 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from extras.models import ChangeLoggedModel +from netbox.models import BigIDModel, CoreModel from utilities.choices import ColorChoices from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet @@ -12,7 +12,7 @@ from utilities.querysets import RestrictedQuerySet # Tags # -class Tag(TagBase, ChangeLoggedModel): +class Tag(TagBase, CoreModel): color = ColorField( default=ColorChoices.COLOR_GREY ) @@ -44,7 +44,7 @@ class Tag(TagBase, ChangeLoggedModel): ) -class TaggedItem(GenericTaggedItemBase): +class TaggedItem(BigIDModel, GenericTaggedItemBase): tag = models.ForeignKey( to=Tag, related_name="%(app_label)s_%(class)s_items", diff --git a/netbox/ipam/migrations/0044_standardize_models.py b/netbox/ipam/migrations/0044_standardize_models.py new file mode 100644 index 000000000..2762c9973 --- /dev/null +++ b/netbox/ipam/migrations/0044_standardize_models.py @@ -0,0 +1,77 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0043_add_tenancy_to_aggregates'), + ] + + operations = [ + migrations.AddField( + model_name='rir', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='role', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='vlangroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='aggregate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='ipaddress', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='prefix', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rir', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='role', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='routetarget', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='service', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlan', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlangroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vrf', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py new file mode 100644 index 000000000..7eecf8cfc --- /dev/null +++ b/netbox/ipam/models/__init__.py @@ -0,0 +1,17 @@ +from .ip import * +from .services import * +from .vlans import * +from .vrfs import * + +__all__ = ( + 'Aggregate', + 'IPAddress', + 'Prefix', + 'RIR', + 'Role', + 'RouteTarget', + 'Service', + 'VLAN', + 'VLANGroup', + 'VRF', +) diff --git a/netbox/ipam/models.py b/netbox/ipam/models/ip.py similarity index 64% rename from netbox/ipam/models.py rename to netbox/ipam/models/ip.py index e8b3dff0a..d8d118a99 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models/ip.py @@ -2,26 +2,25 @@ import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError -from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import F from django.urls import reverse from taggit.managers import TaggableManager -from dcim.models import Device, Interface -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from dcim.models import Device +from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel +from ipam.choices import * +from ipam.constants import * +from ipam.fields import IPNetworkField, IPAddressField +from ipam.managers import IPAddressManager +from ipam.querysets import PrefixQuerySet +from ipam.validators import DNSValidator from utilities.querysets import RestrictedQuerySet -from utilities.utils import array_to_string, serialize_object -from virtualization.models import VirtualMachine, VMInterface -from .choices import * -from .constants import * -from .fields import IPNetworkField, IPAddressField -from .managers import IPAddressManager -from .querysets import PrefixQuerySet -from .validators import DNSValidator +from utilities.utils import serialize_object +from virtualization.models import VirtualMachine __all__ = ( @@ -30,139 +29,11 @@ __all__ = ( 'Prefix', 'RIR', 'Role', - 'RouteTarget', - 'Service', - 'VLAN', - 'VLANGroup', - 'VRF', ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VRF(ChangeLoggedModel, CustomFieldModel): - """ - A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing - table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF - are said to exist in the "global" table.) - """ - name = models.CharField( - max_length=100 - ) - rd = models.CharField( - max_length=VRF_RD_MAX_LENGTH, - unique=True, - blank=True, - null=True, - verbose_name='Route distinguisher', - help_text='Unique route distinguisher (as defined in RFC 4364)' - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='vrfs', - blank=True, - null=True - ) - enforce_unique = models.BooleanField( - default=True, - verbose_name='Enforce unique space', - help_text='Prevent duplicate prefixes/IP addresses within this VRF' - ) - description = models.CharField( - max_length=200, - blank=True - ) - import_targets = models.ManyToManyField( - to='ipam.RouteTarget', - related_name='importing_vrfs', - blank=True - ) - export_targets = models.ManyToManyField( - to='ipam.RouteTarget', - related_name='exporting_vrfs', - blank=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] - clone_fields = [ - 'tenant', 'enforce_unique', 'description', - ] - - class Meta: - ordering = ('name', 'rd', 'pk') # (name, rd) may be non-unique - verbose_name = 'VRF' - verbose_name_plural = 'VRFs' - - def __str__(self): - return self.display_name or super().__str__() - - def get_absolute_url(self): - return reverse('ipam:vrf', args=[self.pk]) - - def to_csv(self): - return ( - self.name, - self.rd, - self.tenant.name if self.tenant else None, - self.enforce_unique, - self.description, - ) - - @property - def display_name(self): - if self.rd: - return f'{self.name} ({self.rd})' - return self.name - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class RouteTarget(ChangeLoggedModel, CustomFieldModel): - """ - A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. - """ - name = models.CharField( - max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) - unique=True, - help_text='Route target value (formatted in accordance with RFC 4360)' - ) - description = models.CharField( - max_length=200, - blank=True - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='route_targets', - blank=True, - null=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['name', 'description', 'tenant'] - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('ipam:routetarget', args=[self.pk]) - - def to_csv(self): - return ( - self.name, - self.description, - self.tenant.name if self.tenant else None, - ) - - -class RIR(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class RIR(OrganizationalModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918. @@ -210,7 +81,7 @@ class RIR(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Aggregate(ChangeLoggedModel, CustomFieldModel): +class Aggregate(PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -317,7 +188,8 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): return int(float(child_prefixes.size) / self.prefix.size * 100) -class Role(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or "Management." @@ -358,7 +230,7 @@ class Role(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Prefix(ChangeLoggedModel, CustomFieldModel): +class Prefix(PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be @@ -616,7 +488,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class IPAddress(ChangeLoggedModel, CustomFieldModel): +class IPAddress(PrimaryModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like @@ -831,274 +703,3 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): def get_role_class(self): return IPAddressRoleChoices.CSS_CLASSES.get(self.role) - - -class VLANGroup(ChangeLoggedModel): - """ - A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. - """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='vlan_groups', - blank=True, - null=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['name', 'slug', 'site', 'description'] - - class Meta: - ordering = ('site', 'name', 'pk') # (site, name) may be non-unique - unique_together = [ - ['site', 'name'], - ['site', 'slug'], - ] - verbose_name = 'VLAN group' - verbose_name_plural = 'VLAN groups' - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('ipam:vlangroup_vlans', args=[self.pk]) - - def to_csv(self): - return ( - self.name, - self.slug, - self.site.name if self.site else None, - self.description, - ) - - def get_next_available_vid(self): - """ - Return the first available VLAN ID (1-4094) in the group. - """ - vlan_ids = VLAN.objects.filter(group=self).values_list('vid', flat=True) - for i in range(1, 4095): - if i not in vlan_ids: - return i - return None - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VLAN(ChangeLoggedModel, CustomFieldModel): - """ - A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned - to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, - within which all VLAN IDs and names but be unique. - - Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero - or more Prefixes assigned to it. - """ - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='vlans', - blank=True, - null=True - ) - group = models.ForeignKey( - to='ipam.VLANGroup', - on_delete=models.PROTECT, - related_name='vlans', - blank=True, - null=True - ) - vid = models.PositiveSmallIntegerField( - verbose_name='ID', - validators=[MinValueValidator(1), MaxValueValidator(4094)] - ) - name = models.CharField( - max_length=64 - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='vlans', - blank=True, - null=True - ) - status = models.CharField( - max_length=50, - choices=VLANStatusChoices, - default=VLANStatusChoices.STATUS_ACTIVE - ) - role = models.ForeignKey( - to='ipam.Role', - on_delete=models.SET_NULL, - related_name='vlans', - blank=True, - null=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] - clone_fields = [ - 'site', 'group', 'tenant', 'status', 'role', 'description', - ] - - class Meta: - ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique - unique_together = [ - ['group', 'vid'], - ['group', 'name'], - ] - verbose_name = 'VLAN' - verbose_name_plural = 'VLANs' - - def __str__(self): - return self.display_name or super().__str__() - - def get_absolute_url(self): - return reverse('ipam:vlan', args=[self.pk]) - - def clean(self): - super().clean() - - # Validate VLAN group - if self.group and self.group.site != self.site: - raise ValidationError({ - 'group': "VLAN group must belong to the assigned site ({}).".format(self.site) - }) - - def to_csv(self): - return ( - self.site.name if self.site else None, - self.group.name if self.group else None, - self.vid, - self.name, - self.tenant.name if self.tenant else None, - self.get_status_display(), - self.role.name if self.role else None, - self.description, - ) - - @property - def display_name(self): - return f'{self.name} ({self.vid})' - - def get_status_class(self): - return VLANStatusChoices.CSS_CLASSES.get(self.status) - - def get_interfaces(self): - # Return all device interfaces assigned to this VLAN - return Interface.objects.filter( - Q(untagged_vlan_id=self.pk) | - Q(tagged_vlans=self.pk) - ).distinct() - - def get_vminterfaces(self): - # Return all VM interfaces assigned to this VLAN - return VMInterface.objects.filter( - Q(untagged_vlan_id=self.pk) | - Q(tagged_vlans=self.pk) - ).distinct() - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Service(ChangeLoggedModel, CustomFieldModel): - """ - A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may - optionally be tied to one or more specific IPAddresses belonging to its parent. - """ - device = models.ForeignKey( - to='dcim.Device', - on_delete=models.CASCADE, - related_name='services', - verbose_name='device', - null=True, - blank=True - ) - virtual_machine = models.ForeignKey( - to='virtualization.VirtualMachine', - on_delete=models.CASCADE, - related_name='services', - null=True, - blank=True - ) - name = models.CharField( - max_length=100 - ) - protocol = models.CharField( - max_length=50, - choices=ServiceProtocolChoices - ) - ports = ArrayField( - base_field=models.PositiveIntegerField( - validators=[ - MinValueValidator(SERVICE_PORT_MIN), - MaxValueValidator(SERVICE_PORT_MAX) - ] - ), - verbose_name='Port numbers' - ) - ipaddresses = models.ManyToManyField( - to='ipam.IPAddress', - related_name='services', - blank=True, - verbose_name='IP addresses' - ) - description = models.CharField( - max_length=200, - blank=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description'] - - class Meta: - ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique - - def __str__(self): - return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' - - def get_absolute_url(self): - return reverse('ipam:service', args=[self.pk]) - - @property - def parent(self): - return self.device or self.virtual_machine - - def clean(self): - super().clean() - - # A Service must belong to a Device *or* to a VirtualMachine - if self.device and self.virtual_machine: - raise ValidationError("A service cannot be associated with both a device and a virtual machine.") - if not self.device and not self.virtual_machine: - raise ValidationError("A service must be associated with either a device or a virtual machine.") - - def to_csv(self): - return ( - self.device.name if self.device else None, - self.virtual_machine.name if self.virtual_machine else None, - self.name, - self.get_protocol_display(), - self.ports, - self.description, - ) - - @property - def port_list(self): - return array_to_string(self.ports) diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py new file mode 100644 index 000000000..57338cce0 --- /dev/null +++ b/netbox/ipam/models/services.py @@ -0,0 +1,109 @@ +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.urls import reverse +from taggit.managers import TaggableManager + +from extras.models import TaggedItem +from extras.utils import extras_features +from ipam.choices import * +from ipam.constants import * +from netbox.models import PrimaryModel +from utilities.querysets import RestrictedQuerySet +from utilities.utils import array_to_string + + +__all__ = ( + 'Service', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Service(PrimaryModel): + """ + A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may + optionally be tied to one or more specific IPAddresses belonging to its parent. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='services', + verbose_name='device', + null=True, + blank=True + ) + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='services', + null=True, + blank=True + ) + name = models.CharField( + max_length=100 + ) + protocol = models.CharField( + max_length=50, + choices=ServiceProtocolChoices + ) + ports = ArrayField( + base_field=models.PositiveIntegerField( + validators=[ + MinValueValidator(SERVICE_PORT_MIN), + MaxValueValidator(SERVICE_PORT_MAX) + ] + ), + verbose_name='Port numbers' + ) + ipaddresses = models.ManyToManyField( + to='ipam.IPAddress', + related_name='services', + blank=True, + verbose_name='IP addresses' + ) + description = models.CharField( + max_length=200, + blank=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description'] + + class Meta: + ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique + + def __str__(self): + return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' + + def get_absolute_url(self): + return reverse('ipam:service', args=[self.pk]) + + @property + def parent(self): + return self.device or self.virtual_machine + + def clean(self): + super().clean() + + # A Service must belong to a Device *or* to a VirtualMachine + if self.device and self.virtual_machine: + raise ValidationError("A service cannot be associated with both a device and a virtual machine.") + if not self.device and not self.virtual_machine: + raise ValidationError("A service must be associated with either a device or a virtual machine.") + + def to_csv(self): + return ( + self.device.name if self.device else None, + self.virtual_machine.name if self.virtual_machine else None, + self.name, + self.get_protocol_display(), + self.ports, + self.description, + ) + + @property + def port_list(self): + return array_to_string(self.ports) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py new file mode 100644 index 000000000..131212564 --- /dev/null +++ b/netbox/ipam/models/vlans.py @@ -0,0 +1,202 @@ +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.urls import reverse +from taggit.managers import TaggableManager + +from dcim.models import Interface +from extras.models import TaggedItem +from extras.utils import extras_features +from ipam.choices import * +from ipam.constants import * +from netbox.models import OrganizationalModel, PrimaryModel +from utilities.querysets import RestrictedQuerySet +from virtualization.models import VMInterface + + +__all__ = ( + 'VLAN', + 'VLANGroup', +) + + +@extras_features('custom_fields', 'export_templates', 'webhooks') +class VLANGroup(OrganizationalModel): + """ + A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. + """ + name = models.CharField( + max_length=100 + ) + slug = models.SlugField( + max_length=100 + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='vlan_groups', + blank=True, + null=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'slug', 'site', 'description'] + + class Meta: + ordering = ('site', 'name', 'pk') # (site, name) may be non-unique + unique_together = [ + ['site', 'name'], + ['site', 'slug'], + ] + verbose_name = 'VLAN group' + verbose_name_plural = 'VLAN groups' + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:vlangroup_vlans', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.slug, + self.site.name if self.site else None, + self.description, + ) + + def get_next_available_vid(self): + """ + Return the first available VLAN ID (1-4094) in the group. + """ + vlan_ids = VLAN.objects.filter(group=self).values_list('vid', flat=True) + for i in range(1, 4095): + if i not in vlan_ids: + return i + return None + + +@extras_features('custom_fields', 'custom_links', 'export_templates', '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 + to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, + within which all VLAN IDs and names but be unique. + + Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero + or more Prefixes assigned to it. + """ + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + group = models.ForeignKey( + to='ipam.VLANGroup', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + vid = models.PositiveSmallIntegerField( + verbose_name='ID', + validators=[MinValueValidator(1), MaxValueValidator(4094)] + ) + name = models.CharField( + max_length=64 + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + status = models.CharField( + max_length=50, + choices=VLANStatusChoices, + default=VLANStatusChoices.STATUS_ACTIVE + ) + role = models.ForeignKey( + to='ipam.Role', + on_delete=models.SET_NULL, + related_name='vlans', + blank=True, + null=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + clone_fields = [ + 'site', 'group', 'tenant', 'status', 'role', 'description', + ] + + class Meta: + ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique + unique_together = [ + ['group', 'vid'], + ['group', 'name'], + ] + verbose_name = 'VLAN' + verbose_name_plural = 'VLANs' + + def __str__(self): + return self.display_name or super().__str__() + + def get_absolute_url(self): + return reverse('ipam:vlan', args=[self.pk]) + + def clean(self): + super().clean() + + # Validate VLAN group + if self.group and self.group.site != self.site: + raise ValidationError({ + 'group': "VLAN group must belong to the assigned site ({}).".format(self.site) + }) + + def to_csv(self): + return ( + self.site.name if self.site else None, + self.group.name if self.group else None, + self.vid, + self.name, + self.tenant.name if self.tenant else None, + self.get_status_display(), + self.role.name if self.role else None, + self.description, + ) + + @property + def display_name(self): + return f'{self.name} ({self.vid})' + + def get_status_class(self): + return VLANStatusChoices.CSS_CLASSES.get(self.status) + + def get_interfaces(self): + # Return all device interfaces assigned to this VLAN + return Interface.objects.filter( + Q(untagged_vlan_id=self.pk) | + Q(tagged_vlans=self.pk) + ).distinct() + + def get_vminterfaces(self): + # Return all VM interfaces assigned to this VLAN + return VMInterface.objects.filter( + Q(untagged_vlan_id=self.pk) | + Q(tagged_vlans=self.pk) + ).distinct() diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py new file mode 100644 index 000000000..bcd5a456a --- /dev/null +++ b/netbox/ipam/models/vrfs.py @@ -0,0 +1,139 @@ +from django.db import models +from django.urls import reverse +from taggit.managers import TaggableManager + +from extras.models import TaggedItem +from extras.utils import extras_features +from ipam.constants import * +from netbox.models import PrimaryModel +from utilities.querysets import RestrictedQuerySet + + +__all__ = ( + 'RouteTarget', + 'VRF', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class VRF(PrimaryModel): + """ + A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing + table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF + are said to exist in the "global" table.) + """ + name = models.CharField( + max_length=100 + ) + rd = models.CharField( + max_length=VRF_RD_MAX_LENGTH, + unique=True, + blank=True, + null=True, + verbose_name='Route distinguisher', + help_text='Unique route distinguisher (as defined in RFC 4364)' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vrfs', + blank=True, + null=True + ) + enforce_unique = models.BooleanField( + default=True, + verbose_name='Enforce unique space', + help_text='Prevent duplicate prefixes/IP addresses within this VRF' + ) + description = models.CharField( + max_length=200, + blank=True + ) + import_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='importing_vrfs', + blank=True + ) + export_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='exporting_vrfs', + blank=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] + clone_fields = [ + 'tenant', 'enforce_unique', 'description', + ] + + class Meta: + ordering = ('name', 'rd', 'pk') # (name, rd) may be non-unique + verbose_name = 'VRF' + verbose_name_plural = 'VRFs' + + def __str__(self): + return self.display_name or super().__str__() + + def get_absolute_url(self): + return reverse('ipam:vrf', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.rd, + self.tenant.name if self.tenant else None, + self.enforce_unique, + self.description, + ) + + @property + def display_name(self): + if self.rd: + return f'{self.name} ({self.rd})' + return self.name + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class RouteTarget(PrimaryModel): + """ + A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. + """ + name = models.CharField( + max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) + unique=True, + help_text='Route target value (formatted in accordance with RFC 4360)' + ) + description = models.CharField( + max_length=200, + blank=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='route_targets', + blank=True, + null=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'description', 'tenant'] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:routetarget', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.description, + self.tenant.name if self.tenant else None, + ) diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py new file mode 100644 index 000000000..c0b0ce7ae --- /dev/null +++ b/netbox/netbox/models.py @@ -0,0 +1,184 @@ +from collections import OrderedDict + +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 utilities.mptt import TreeManager +from utilities.utils import serialize_object + +__all__ = ( + 'BigIDModel', + 'NestedGroupModel', + 'OrganizationalModel', + 'PrimaryModel', +) + + +class BigIDModel(models.Model): + """ + Abstract base model for all Schematic data objects. Ensures the use of a 64-bit PK. + """ + id = models.BigAutoField( + primary_key=True + ) + + class Meta: + abstract = True + + +class CoreModel(BigIDModel): + """ + Base class for all core objects. Provides the following: + - Change logging + - Custom field support + """ + created = models.DateField( + auto_now_add=True, + blank=True, + null=True + ) + last_updated = models.DateTimeField( + auto_now=True, + blank=True, + null=True + ) + + class Meta: + abstract = True + + def to_objectchange(self, action): + """ + Return a new ObjectChange representing a change made to this object. This will typically be called automatically + by ChangeLoggingMiddleware. + """ + from extras.models import ObjectChange + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + object_data=serialize_object(self) + ) + + +class PrimaryModel(CoreModel): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + custom_field_data = models.JSONField( + encoder=DjangoJSONEncoder, + blank=True, + default=dict + ) + + class Meta: + abstract = True + + @property + def cf(self): + """ + Convenience wrapper for custom field data. + """ + return self.custom_field_data + + def get_custom_fields(self): + """ + Return a dictionary of custom fields for a single object in the form {: value}. + """ + from extras.models import CustomField + + fields = CustomField.objects.get_for_model(self) + return OrderedDict([ + (field, self.custom_field_data.get(field.name)) for field in fields + ]) + + def clean(self): + super().clean() + from extras.models import CustomField + + custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)} + + # Validate all field values + for field_name, value in self.custom_field_data.items(): + if field_name not in custom_fields: + raise ValidationError(f"Unknown field name '{field_name}' in custom field data.") + try: + custom_fields[field_name].validate(value) + except ValidationError as e: + raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}") + + # Check for missing required values + for cf in custom_fields.values(): + if cf.required and cf.name not in self.custom_field_data: + raise ValidationError(f"Missing required custom field '{cf.name}'.") + + +class NestedGroupModel(PrimaryModel, 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 to_objectchange(self, action): + # Remove MPTT-internal fields + from extras.models import ObjectChange + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) + ) + + +class OrganizationalModel(PrimaryModel): + """ + 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 + ) + + class Meta: + abstract = True + ordering = ('name',) diff --git a/netbox/secrets/migrations/0013_standardize_models.py b/netbox/secrets/migrations/0013_standardize_models.py new file mode 100644 index 000000000..9de8dec95 --- /dev/null +++ b/netbox/secrets/migrations/0013_standardize_models.py @@ -0,0 +1,37 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0012_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='secretrole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='secret', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='secretrole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sessionkey', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='userkey', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 0fa6fd04e..091b67cc6 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -14,8 +14,9 @@ from django.urls import reverse from django.utils.encoding import force_bytes from taggit.managers import TaggableManager -from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem +from extras.models import TaggedItem from extras.utils import extras_features +from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -31,7 +32,7 @@ __all__ = ( ) -class UserKey(models.Model): +class UserKey(BigIDModel): """ A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's @@ -164,7 +165,7 @@ class UserKey(models.Model): self.save() -class SessionKey(models.Model): +class SessionKey(BigIDModel): """ A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets. """ @@ -234,7 +235,8 @@ class SessionKey(models.Model): return session_key -class SecretRole(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class SecretRole(OrganizationalModel): """ A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles such as "Login Credentials" or "SNMP Communities." @@ -274,7 +276,7 @@ class SecretRole(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Secret(ChangeLoggedModel, CustomFieldModel): +class Secret(PrimaryModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to exactly diff --git a/netbox/tenancy/migrations/0012_standardize_models.py b/netbox/tenancy/migrations/0012_standardize_models.py new file mode 100644 index 000000000..7ce55cf42 --- /dev/null +++ b/netbox/tenancy/migrations/0012_standardize_models.py @@ -0,0 +1,27 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0011_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='tenantgroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='tenant', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tenantgroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 3ba644c09..e83f4bdd2 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -3,11 +3,11 @@ from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import NestedGroupModel, PrimaryModel from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object __all__ = ( @@ -16,7 +16,8 @@ __all__ = ( ) -class TenantGroup(MPTTModel, ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class TenantGroup(NestedGroupModel): """ An arbitrary collection of Tenants. """ @@ -48,12 +49,6 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): class Meta: ordering = ['name'] - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug) @@ -65,18 +60,9 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): self.description, ) - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) - ) - @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Tenant(ChangeLoggedModel, CustomFieldModel): +class Tenant(PrimaryModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. diff --git a/netbox/users/migrations/0011_standardize_models.py b/netbox/users/migrations/0011_standardize_models.py new file mode 100644 index 000000000..abfbd8702 --- /dev/null +++ b/netbox/users/migrations/0011_standardize_models.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0010_update_jsonfield'), + ] + + operations = [ + migrations.AlterField( + model_name='objectpermission', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='token', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='userconfig', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index b25a75134..529732e65 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -11,6 +11,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict @@ -50,7 +51,7 @@ class AdminUser(User): # User preferences # -class UserConfig(models.Model): +class UserConfig(BigIDModel): """ This model stores arbitrary user-specific preferences in a JSON data structure. """ @@ -175,7 +176,7 @@ def create_userconfig(instance, created, **kwargs): # REST API # -class Token(models.Model): +class Token(BigIDModel): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. @@ -233,7 +234,7 @@ class Token(models.Model): # Permissions # -class ObjectPermission(models.Model): +class ObjectPermission(BigIDModel): """ A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects identified by ORM query parameters. diff --git a/netbox/virtualization/migrations/0020_standardize_models.py b/netbox/virtualization/migrations/0020_standardize_models.py new file mode 100644 index 000000000..15585100d --- /dev/null +++ b/netbox/virtualization/migrations/0020_standardize_models.py @@ -0,0 +1,62 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0019_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='clustergroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='clustertype', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='vminterface', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='vminterface', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='vminterface', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='cluster', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustergroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustertype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualmachine', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vminterface', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index edca7e1fe..a6a63ab3b 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -6,9 +6,10 @@ from django.urls import reverse from taggit.managers import TaggableManager from dcim.models import BaseInterface, Device -from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ConfigContextModel, ObjectChange, TaggedItem from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar @@ -30,7 +31,8 @@ __all__ = ( # Cluster types # -class ClusterType(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class ClusterType(OrganizationalModel): """ A type of Cluster. """ @@ -72,7 +74,8 @@ class ClusterType(ChangeLoggedModel): # Cluster groups # -class ClusterGroup(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. """ @@ -115,7 +118,7 @@ class ClusterGroup(ChangeLoggedModel): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Cluster(ChangeLoggedModel, CustomFieldModel): +class Cluster(PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -199,7 +202,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): +class VirtualMachine(PrimaryModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ @@ -371,7 +374,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # @extras_features('export_templates', 'webhooks') -class VMInterface(BaseInterface): +class VMInterface(PrimaryModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE,