From 0969c458b377b07396af6d3a8d6c6eb6ee9f79f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 30 Mar 2018 10:39:22 -0400 Subject: [PATCH 001/159] Closes #1842: Implement support for Django 2.0 --- netbox/dcim/migrations/0056_django2.py | 24 +++++++++++++++ netbox/dcim/models.py | 2 ++ netbox/extras/migrations/0011_django2.py | 29 +++++++++++++++++++ netbox/extras/tests/test_customfields.py | 16 +++++----- netbox/netbox/settings.py | 1 - netbox/secrets/api/views.py | 2 +- netbox/utilities/api.py | 2 +- netbox/utilities/forms.py | 2 +- netbox/utilities/middleware.py | 2 +- .../virtualization/migrations/0005_django2.py | 19 ++++++++++++ requirements.txt | 2 +- 11 files changed, 87 insertions(+), 14 deletions(-) create mode 100644 netbox/dcim/migrations/0056_django2.py create mode 100644 netbox/extras/migrations/0011_django2.py create mode 100644 netbox/virtualization/migrations/0005_django2.py diff --git a/netbox/dcim/migrations/0056_django2.py b/netbox/dcim/migrations/0056_django2.py new file mode 100644 index 000000000..bb7af920e --- /dev/null +++ b/netbox/dcim/migrations/0056_django2.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.3 on 2018-03-30 14:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0055_virtualchassis_ordering'), + ] + + operations = [ + migrations.AlterField( + model_name='interface', + name='untagged_vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'), + ), + migrations.AlterField( + model_name='platform', + name='manufacturer', + field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='platforms', to='dcim.Manufacturer'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index ac1affdef..1bf15a411 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -804,6 +804,7 @@ class Platform(models.Model): slug = models.SlugField(unique=True) manufacturer = models.ForeignKey( to='Manufacturer', + on_delete=models.PROTECT, related_name='platforms', blank=True, null=True, @@ -1373,6 +1374,7 @@ class Interface(models.Model): ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', + on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Untagged VLAN', diff --git a/netbox/extras/migrations/0011_django2.py b/netbox/extras/migrations/0011_django2.py new file mode 100644 index 000000000..f8e0954d6 --- /dev/null +++ b/netbox/extras/migrations/0011_django2.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.3 on 2018-03-30 14:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0010_customfield_filter_logic'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='obj_type', + field=models.ManyToManyField(help_text='The object(s) to which this field applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'tenant', 'cluster', 'virtualmachine')}, related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'), + ), + migrations.AlterField( + model_name='customfieldchoice', + name='field', + field=models.ForeignKey(limit_choices_to={'type': 600}, on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField'), + ), + migrations.AlterField( + model_name='exporttemplate', + name='content_type', + field=models.ForeignKey(limit_choices_to={'model__in': ['provider', 'circuit', 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', 'consoleport', 'powerport', 'interfaceconnection', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'tenant', 'cluster', 'virtualmachine']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + ] diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 84aaa76b2..b10db514e 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -45,7 +45,7 @@ class CustomFieldTest(TestCase): # Create a custom field cf = CustomField(type=data['field_type'], name='my_field', required=False) cf.save() - cf.obj_type = [obj_type] + cf.obj_type.set([obj_type]) cf.save() # Assign a value to the first Site @@ -73,7 +73,7 @@ class CustomFieldTest(TestCase): # Create a custom field cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False) cf.save() - cf.obj_type = [obj_type] + cf.obj_type.set([obj_type]) cf.save() # Create some choices for the field @@ -115,37 +115,37 @@ class CustomFieldAPITest(HttpStatusMixin, APITestCase): # Text custom field self.cf_text = CustomField(type=CF_TYPE_TEXT, name='magic_word') self.cf_text.save() - self.cf_text.obj_type = [content_type] + self.cf_text.obj_type.set([content_type]) self.cf_text.save() # Integer custom field self.cf_integer = CustomField(type=CF_TYPE_INTEGER, name='magic_number') self.cf_integer.save() - self.cf_integer.obj_type = [content_type] + self.cf_integer.obj_type.set([content_type]) self.cf_integer.save() # Boolean custom field self.cf_boolean = CustomField(type=CF_TYPE_BOOLEAN, name='is_magic') self.cf_boolean.save() - self.cf_boolean.obj_type = [content_type] + self.cf_boolean.obj_type.set([content_type]) self.cf_boolean.save() # Date custom field self.cf_date = CustomField(type=CF_TYPE_DATE, name='magic_date') self.cf_date.save() - self.cf_date.obj_type = [content_type] + self.cf_date.obj_type.set([content_type]) self.cf_date.save() # URL custom field self.cf_url = CustomField(type=CF_TYPE_URL, name='magic_url') self.cf_url.save() - self.cf_url.obj_type = [content_type] + self.cf_url.obj_type.set([content_type]) self.cf_url.save() # Select custom field self.cf_select = CustomField(type=CF_TYPE_SELECT, name='magic_choice') self.cf_select.save() - self.cf_select.obj_type = [content_type] + self.cf_select.obj_type.set([content_type]) self.cf_select.save() self.cf_select_choice1 = CustomFieldChoice(field=self.cf_select, value='Foo') self.cf_select_choice1.save() diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e40106a21..8abc92e23 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -154,7 +154,6 @@ MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index d2fb2ef00..807a87b42 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -68,7 +68,7 @@ class SecretViewSet(ModelViewSet): super(SecretViewSet, self).initial(request, *args, **kwargs) - if request.user.is_authenticated(): + if request.user.is_authenticated: # Read session key from HTTP cookie or header if it has been provided. The session key must be provided in # order to encrypt/decrypt secrets. diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 5c78dacc4..c54379dff 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -33,7 +33,7 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): def has_permission(self, request, view): if not settings.LOGIN_REQUIRED: return True - return request.user.is_authenticated() + return request.user.is_authenticated # diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 15fb69f7f..d64af0105 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -325,7 +325,7 @@ class CSVChoiceField(forms.ChoiceField): """ def __init__(self, choices, *args, **kwargs): - super(CSVChoiceField, self).__init__(choices, *args, **kwargs) + super(CSVChoiceField, self).__init__(choices=choices, *args, **kwargs) self.choices = [(label, label) for value, label in choices] self.choice_values = {label: value for value, label in choices} diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index 64fb70a07..47fa48c90 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -20,7 +20,7 @@ class LoginRequiredMiddleware(object): self.get_response = get_response def __call__(self, request): - if LOGIN_REQUIRED and not request.user.is_authenticated(): + if LOGIN_REQUIRED and not request.user.is_authenticated: # Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API # performs its own authentication. api_path = reverse('api-root') diff --git a/netbox/virtualization/migrations/0005_django2.py b/netbox/virtualization/migrations/0005_django2.py new file mode 100644 index 000000000..e79a55350 --- /dev/null +++ b/netbox/virtualization/migrations/0005_django2.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.3 on 2018-03-30 14:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0004_virtualmachine_add_role'), + ] + + operations = [ + migrations.AlterField( + model_name='virtualmachine', + name='role', + field=models.ForeignKey(blank=True, limit_choices_to={'vm_role': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'), + ), + ] diff --git a/requirements.txt b/requirements.txt index 5b7b3e73e..d6b63b1bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django>=1.11,<2.0 +Django>=2.0.3 django-cors-headers>=2.1.0 django-debug-toolbar>=1.9.0 django-filter>=1.1.0 From 0bb632c6429ac95ac9c075a93974f6ae86c9bed5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 30 Mar 2018 10:54:35 -0400 Subject: [PATCH 002/159] Allow Django 1.11 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d6b63b1bd..288830b74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django>=2.0.3 +Django>=1.11 django-cors-headers>=2.1.0 django-debug-toolbar>=1.9.0 django-filter>=1.1.0 From 9725f19bae03e4fee07a014c9b342e6cea5eebcc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 30 Mar 2018 13:57:26 -0400 Subject: [PATCH 003/159] Code formatting cleanup --- netbox/circuits/models.py | 160 +++++-- netbox/dcim/models.py | 741 +++++++++++++++++++++++++------- netbox/extras/models.py | 193 +++++++-- netbox/ipam/models.py | 318 +++++++++++--- netbox/secrets/models.py | 88 +++- netbox/tenancy/models.py | 44 +- netbox/users/models.py | 31 +- netbox/virtualization/models.py | 10 +- 8 files changed, 1252 insertions(+), 333 deletions(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index a65fe3063..4df845bd8 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -7,8 +7,7 @@ from django.utils.encoding import python_2_unicode_compatible from dcim.constants import STATUS_CLASSES from dcim.fields import ASNField -from extras.models import CustomFieldModel, CustomFieldValue -from tenancy.models import Tenant +from extras.models import CustomFieldModel from utilities.models import CreatedUpdatedModel from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES @@ -19,15 +18,43 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): 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. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - asn = ASNField(blank=True, null=True, verbose_name='ASN') - account = models.CharField(max_length=30, blank=True, verbose_name='Account number') - portal_url = models.URLField(blank=True, verbose_name='Portal') - noc_contact = models.TextField(blank=True, verbose_name='NOC contact') - admin_contact = models.TextField(blank=True, verbose_name='Admin contact') - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + asn = ASNField( + blank=True, + null=True, + verbose_name='ASN' + ) + account = models.CharField( + max_length=30, + blank=True, + verbose_name='Account number' + ) + portal_url = models.URLField( + blank=True, + verbose_name='Portal' + ) + noc_contact = models.TextField( + blank=True, + verbose_name='NOC contact' + ) + admin_contact = models.TextField( + blank=True, + verbose_name='Admin contact' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] @@ -59,8 +86,13 @@ class CircuitType(models.Model): 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". """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) csv_headers = ['name', 'slug'] @@ -87,16 +119,52 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device interface, but this is not required. Circuit port speed and commit rate are measured in Kbps. """ - cid = models.CharField(max_length=50, verbose_name='Circuit ID') - provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT) - type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT) - status = models.PositiveSmallIntegerField(choices=CIRCUIT_STATUS_CHOICES, default=CIRCUIT_STATUS_ACTIVE) - tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT) - install_date = models.DateField(blank=True, null=True, verbose_name='Date installed') - commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)') - description = models.CharField(max_length=100, blank=True) - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + cid = models.CharField( + max_length=50, + verbose_name='Circuit ID' + ) + provider = models.ForeignKey( + to='circuits.Provider', + on_delete=models.PROTECT, + related_name='circuits' + ) + type = models.ForeignKey( + to='CircuitType', + on_delete=models.PROTECT, + related_name='circuits' + ) + status = models.PositiveSmallIntegerField( + choices=CIRCUIT_STATUS_CHOICES, + default=CIRCUIT_STATUS_ACTIVE + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='circuits', + blank=True, + null=True + ) + install_date = models.DateField( + blank=True, + null=True, + verbose_name='Date installed' + ) + commit_rate = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name='Commit rate (Kbps)') + description = models.CharField( + max_length=100, + blank=True + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', @@ -145,19 +213,47 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible class CircuitTermination(models.Model): - circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE) - term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination') - site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT) - interface = models.OneToOneField( - 'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT + circuit = models.ForeignKey( + to='circuits.Circuit', + on_delete=models.CASCADE, + related_name='terminations' + ) + term_side = models.CharField( + max_length=1, + choices=TERM_SIDE_CHOICES, + verbose_name='Termination' + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='circuit_terminations' + ) + interface = models.OneToOneField( + to='dcim.Interface', + on_delete=models.PROTECT, + related_name='circuit_termination', + blank=True, + null=True + ) + port_speed = models.PositiveIntegerField( + verbose_name='Port speed (Kbps)' ) - port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)') upstream_speed = models.PositiveIntegerField( - blank=True, null=True, verbose_name='Upstream speed (Kbps)', + blank=True, + null=True, + verbose_name='Upstream speed (Kbps)', help_text='Upstream speed, if different from port speed' ) - xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID') - pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)') + xconnect_id = models.CharField( + max_length=50, + blank=True, + verbose_name='Cross-connect ID' + ) + pp_info = models.CharField( + max_length=100, + blank=True, + verbose_name='Patch panel/port(s)' + ) class Meta: ordering = ['circuit', 'term_side'] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 1bf15a411..8c47c7ba6 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -17,9 +17,8 @@ from mptt.models import MPTTModel, TreeForeignKey from timezone_field import TimeZoneField from circuits.models import Circuit -from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment +from extras.models import CustomFieldModel from extras.rpc import RPC_CLIENTS -from tenancy.models import Tenant from utilities.fields import ColorField, NullableCharField from utilities.managers import NaturalOrderByManager from utilities.models import CreatedUpdatedModel @@ -38,10 +37,20 @@ class Region(MPTTModel): Sites can be grouped within geographic Regions. """ parent = TreeForeignKey( - 'self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.CASCADE + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True ) - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) csv_headers = ['name', 'slug', 'parent'] @@ -78,23 +87,78 @@ class Site(CreatedUpdatedModel, CustomFieldModel): 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). """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE) - region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL) - tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT) - facility = models.CharField(max_length=50, blank=True) - asn = ASNField(blank=True, null=True, verbose_name='ASN') - time_zone = TimeZoneField(blank=True) - description = models.CharField(max_length=100, blank=True) - physical_address = models.CharField(max_length=200, blank=True) - shipping_address = models.CharField(max_length=200, blank=True) - contact_name = models.CharField(max_length=50, blank=True) - contact_phone = models.CharField(max_length=20, blank=True) - contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail") - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - images = GenericRelation(ImageAttachment) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + status = models.PositiveSmallIntegerField( + choices=SITE_STATUS_CHOICES, + default=SITE_STATUS_ACTIVE + ) + region = models.ForeignKey( + to='dcim.Region', + on_delete=models.SET_NULL, + related_name='sites', + blank=True, + null=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='sites', + blank=True, + null=True + ) + facility = models.CharField( + max_length=50, + blank=True + ) + asn = ASNField( + blank=True, + null=True, + verbose_name='ASN' + ) + time_zone = TimeZoneField( + blank=True + ) + description = models.CharField( + max_length=100, + blank=True + ) + physical_address = models.CharField( + max_length=200, + blank=True + ) + shipping_address = models.CharField( + max_length=200, + blank=True + ) + contact_name = models.CharField( + max_length=50, + blank=True + ) + contact_phone = models.CharField( + max_length=20, + blank=True + ) + contact_email = models.EmailField( + blank=True, + verbose_name='Contact E-mail' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + images = GenericRelation( + to='extras.ImageAttachment' + ) objects = SiteManager() @@ -171,9 +235,15 @@ class RackGroup(models.Model): example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor. """ - name = models.CharField(max_length=50) + name = models.CharField( + max_length=50 + ) slug = models.SlugField() - site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='rack_groups' + ) csv_headers = ['site', 'name', 'slug'] @@ -203,8 +273,13 @@ class RackRole(models.Model): """ Racks can be organized by functional role, similar to Devices. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) color = ColorField() csv_headers = ['name', 'slug', 'color'] @@ -238,23 +313,79 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): 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. """ - name = models.CharField(max_length=50) - facility_id = NullableCharField(max_length=50, blank=True, null=True, verbose_name='Facility ID') - site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT) - group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL) - tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT) - role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT) - serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') - type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type') - width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width', - help_text='Rail-to-rail width') - u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)', - validators=[MinValueValidator(1), MaxValueValidator(100)]) - desc_units = models.BooleanField(default=False, verbose_name='Descending units', - help_text='Units are numbered top-to-bottom') - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - images = GenericRelation(ImageAttachment) + name = models.CharField( + max_length=50 + ) + facility_id = NullableCharField( + max_length=50, + blank=True, + null=True, + verbose_name='Facility ID' + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='racks' + ) + group = models.ForeignKey( + to='dcim.RackGroup', + on_delete=models.SET_NULL, + related_name='racks', + blank=True, + null=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='racks', + blank=True, + null=True + ) + role = models.ForeignKey( + to='dcim.RackRole', + on_delete=models.PROTECT, + related_name='racks', + blank=True, + null=True + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name='Serial number' + ) + type = models.PositiveSmallIntegerField( + choices=RACK_TYPE_CHOICES, + blank=True, + null=True, + verbose_name='Type' + ) + width = models.PositiveSmallIntegerField( + choices=RACK_WIDTH_CHOICES, + default=RACK_WIDTH_19IN, + verbose_name='Width', + help_text='Rail-to-rail width' + ) + u_height = models.PositiveSmallIntegerField( + default=42, + verbose_name='Height (U)', + validators=[MinValueValidator(1), MaxValueValidator(100)] + ) + desc_units = models.BooleanField( + default=False, + verbose_name='Descending units', + help_text='Units are numbered top-to-bottom' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + images = GenericRelation( + to='extras.ImageAttachment' + ) objects = RackManager() @@ -438,12 +569,31 @@ class RackReservation(models.Model): """ One or more reserved units within a Rack. """ - rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE) - units = ArrayField(models.PositiveSmallIntegerField()) - created = models.DateTimeField(auto_now_add=True) - tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT) - user = models.ForeignKey(User, on_delete=models.PROTECT) - description = models.CharField(max_length=100) + rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.CASCADE, + related_name='reservations' + ) + units = ArrayField( + base_field=models.PositiveSmallIntegerField() + ) + created = models.DateTimeField( + auto_now_add=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='rackreservations', + blank=True, + null=True + ) + user = models.ForeignKey( + to=User, + on_delete=models.PROTECT + ) + description = models.CharField( + max_length=100 + ) class Meta: ordering = ['created'] @@ -496,8 +646,13 @@ class Manufacturer(models.Model): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) csv_headers = ['name', 'slug'] @@ -533,27 +688,63 @@ class DeviceType(models.Model, CustomFieldModel): When a new Device of this type is created, the appropriate console, power, and interface objects (as defined by the DeviceType) are automatically created as well. """ - manufacturer = models.ForeignKey('Manufacturer', related_name='device_types', on_delete=models.PROTECT) - model = models.CharField(max_length=50) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='device_types' + ) + model = models.CharField( + max_length=50 + ) slug = models.SlugField() - part_number = models.CharField(max_length=50, blank=True, help_text="Discrete part number (optional)") - u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1) - is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth", - help_text="Device consumes both front and rear rack faces") - interface_ordering = models.PositiveSmallIntegerField(choices=IFACE_ORDERING_CHOICES, - default=IFACE_ORDERING_POSITION) - is_console_server = models.BooleanField(default=False, verbose_name='Is a console server', - help_text="This type of device has console server ports") - is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU', - help_text="This type of device has power outlets") - is_network_device = models.BooleanField(default=True, verbose_name='Is a network device', - help_text="This type of device has network interfaces") - subdevice_role = models.NullBooleanField(default=None, verbose_name='Parent/child status', - choices=SUBDEVICE_ROLE_CHOICES, - help_text="Parent devices house child devices in device bays. Select " - "\"None\" if this device type is neither a parent nor a child.") - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + part_number = models.CharField( + max_length=50, + blank=True, + help_text='Discrete part number (optional)' + ) + u_height = models.PositiveSmallIntegerField( + default=1, + verbose_name='Height (U)' + ) + is_full_depth = models.BooleanField( + default=True, + verbose_name='Is full depth', + help_text='Device consumes both front and rear rack faces' + ) + interface_ordering = models.PositiveSmallIntegerField( + choices=IFACE_ORDERING_CHOICES, + default=IFACE_ORDERING_POSITION + ) + is_console_server = models.BooleanField( + default=False, + verbose_name='Is a console server', + help_text='This type of device has console server ports' + ) + is_pdu = models.BooleanField( + default=False, + verbose_name='Is a PDU', + help_text='This type of device has power outlets' + ) + is_network_device = models.BooleanField( + default=True, + verbose_name='Is a network device', + help_text='This type of device has network interfaces' + ) + subdevice_role = models.NullBooleanField( + default=None, + verbose_name='Parent/child status', + choices=SUBDEVICE_ROLE_CHOICES, + help_text='Parent devices house child devices in device bays. Select ' + '"None" if this device type is neither a parent nor a child.' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) csv_headers = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', @@ -658,8 +849,14 @@ class ConsolePortTemplate(models.Model): """ A template for a ConsolePort to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='console_port_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='console_port_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -674,8 +871,14 @@ class ConsoleServerPortTemplate(models.Model): """ A template for a ConsoleServerPort to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='cs_port_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='cs_port_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -690,8 +893,14 @@ class PowerPortTemplate(models.Model): """ A template for a PowerPort to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='power_port_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='power_port_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -706,8 +915,14 @@ class PowerOutletTemplate(models.Model): """ A template for a PowerOutlet to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='power_outlet_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='power_outlet_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -722,10 +937,22 @@ class InterfaceTemplate(models.Model): """ A template for a physical data interface on a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=64) - form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) - mgmt_only = models.BooleanField(default=False, verbose_name='Management only') + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='interface_templates' + ) + name = models.CharField( + max_length=64 + ) + form_factor = models.PositiveSmallIntegerField( + choices=IFACE_FF_CHOICES, + default=IFACE_FF_10GE_SFP_PLUS + ) + mgmt_only = models.BooleanField( + default=False, + verbose_name='Management only' + ) objects = InterfaceQuerySet.as_manager() @@ -742,8 +969,14 @@ class DeviceBayTemplate(models.Model): """ A template for a DeviceBay to be created for a new parent Device. """ - device_type = models.ForeignKey('DeviceType', related_name='device_bay_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='device_bay_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -764,13 +997,18 @@ class DeviceRole(models.Model): color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to virtual machines as well. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) color = ColorField() vm_role = models.BooleanField( default=True, - verbose_name="VM Role", - help_text="Virtual machines may be assigned to this role" + verbose_name='VM Role', + help_text='Virtual machines may be assigned to this role' ) csv_headers = ['name', 'slug', 'color', 'vm_role'] @@ -800,27 +1038,32 @@ class Platform(models.Model): NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by specifying a NAPALM driver. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) manufacturer = models.ForeignKey( - to='Manufacturer', + to='dcim.Manufacturer', on_delete=models.PROTECT, related_name='platforms', blank=True, null=True, - help_text="Optionally limit this platform to devices of a certain manufacturer" + help_text='Optionally limit this platform to devices of a certain manufacturer' ) napalm_driver = models.CharField( max_length=50, blank=True, verbose_name='NAPALM driver', - help_text="The name of the NAPALM driver to use when interacting with devices" + help_text='The name of the NAPALM driver to use when interacting with devices' ) rpc_client = models.CharField( max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, - verbose_name="Legacy RPC client" + verbose_name='Legacy RPC client' ) csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver'] @@ -862,30 +1105,93 @@ class Device(CreatedUpdatedModel, CustomFieldModel): by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the creation of a Device. """ - device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT) - device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT) - tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT) - platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL) - name = NullableCharField(max_length=64, blank=True, null=True, unique=True) - serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.PROTECT, + related_name='instances' + ) + device_role = models.ForeignKey( + to='dcim.DeviceRole', + on_delete=models.PROTECT, + related_name='devices' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='devices', + blank=True, + null=True + ) + platform = models.ForeignKey( + to='dcim.Platform', + on_delete=models.SET_NULL, + related_name='devices', + blank=True, + null=True + ) + name = NullableCharField( + max_length=64, + blank=True, + null=True, + unique=True + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name='Serial number' + ) asset_tag = NullableCharField( - max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name='Asset tag', help_text='A unique tag used to identify this device' ) - site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT) - rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='devices' + ) + rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.PROTECT, + related_name='devices', + blank=True, + null=True + ) position = models.PositiveSmallIntegerField( - blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)', + blank=True, + null=True, + validators=[MinValueValidator(1)], + verbose_name='Position (U)', help_text='The lowest-numbered unit occupied by the device' ) - face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face') - status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status') + face = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=RACK_FACE_CHOICES, + verbose_name='Rack face' + ) + status = models.PositiveSmallIntegerField( + choices=DEVICE_STATUS_CHOICES, + default=DEVICE_STATUS_ACTIVE, + verbose_name='Status' + ) primary_ip4 = models.OneToOneField( - 'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True, + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='primary_ip4_for', + blank=True, + null=True, verbose_name='Primary IPv4' ) primary_ip6 = models.OneToOneField( - 'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True, + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='primary_ip6_for', + blank=True, + null=True, verbose_name='Primary IPv6' ) cluster = models.ForeignKey( @@ -912,9 +1218,17 @@ class Device(CreatedUpdatedModel, CustomFieldModel): null=True, validators=[MaxValueValidator(255)] ) - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - images = GenericRelation(ImageAttachment) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + images = GenericRelation( + to='extras.ImageAttachment' + ) objects = DeviceManager() @@ -1169,11 +1483,26 @@ class ConsolePort(models.Model): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ - device = models.ForeignKey('Device', related_name='console_ports', on_delete=models.CASCADE) - name = models.CharField(max_length=50) - cs_port = models.OneToOneField('ConsoleServerPort', related_name='connected_console', on_delete=models.SET_NULL, - verbose_name='Console server port', blank=True, null=True) - connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='console_ports' + ) + name = models.CharField( + max_length=50 + ) + cs_port = models.OneToOneField( + to='dcim.ConsoleServerPort', + on_delete=models.SET_NULL, + related_name='connected_console', + verbose_name='Console server port', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED + ) csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] @@ -1216,8 +1545,14 @@ class ConsoleServerPort(models.Model): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ - device = models.ForeignKey('Device', related_name='cs_ports', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='cs_ports' + ) + name = models.CharField( + max_length=50 + ) objects = ConsoleServerPortManager() @@ -1251,11 +1586,25 @@ class PowerPort(models.Model): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ - device = models.ForeignKey('Device', related_name='power_ports', on_delete=models.CASCADE) - name = models.CharField(max_length=50) - power_outlet = models.OneToOneField('PowerOutlet', related_name='connected_port', on_delete=models.SET_NULL, - blank=True, null=True) - connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='power_ports' + ) + name = models.CharField( + max_length=50 + ) + power_outlet = models.OneToOneField( + to='dcim.PowerOutlet', + on_delete=models.SET_NULL, + related_name='connected_port', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED + ) csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] @@ -1298,8 +1647,14 @@ class PowerOutlet(models.Model): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ - device = models.ForeignKey('Device', related_name='power_outlets', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='power_outlets' + ) + name = models.CharField( + max_length=50 + ) objects = PowerOutletManager() @@ -1356,17 +1711,35 @@ class Interface(models.Model): blank=True, verbose_name='Parent LAG' ) - name = models.CharField(max_length=64) - form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) - enabled = models.BooleanField(default=True) - mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address') - mtu = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU') + name = models.CharField( + max_length=64 + ) + form_factor = models.PositiveSmallIntegerField( + choices=IFACE_FF_CHOICES, + default=IFACE_FF_10GE_SFP_PLUS + ) + enabled = models.BooleanField( + default=True + ) + mac_address = MACAddressField( + null=True, + blank=True, + verbose_name='MAC Address' + ) + mtu = models.PositiveSmallIntegerField( + blank=True, + null=True, + verbose_name='MTU' + ) mgmt_only = models.BooleanField( default=False, verbose_name='OOB Management', - help_text="This interface is used only for out-of-band management" + help_text='This interface is used only for out-of-band management' + ) + description = models.CharField( + max_length=100, + blank=True ) - description = models.CharField(max_length=100, blank=True) mode = models.PositiveSmallIntegerField( choices=IFACE_MODE_CHOICES, blank=True, @@ -1375,16 +1748,16 @@ class Interface(models.Model): untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, + related_name='interfaces_as_untagged', null=True, blank=True, - verbose_name='Untagged VLAN', - related_name='interfaces_as_untagged' + verbose_name='Untagged VLAN' ) tagged_vlans = models.ManyToManyField( to='ipam.VLAN', + related_name='interfaces_as_tagged', blank=True, - verbose_name='Tagged VLANs', - related_name='interfaces_as_tagged' + verbose_name='Tagged VLANs' ) objects = InterfaceQuerySet.as_manager() @@ -1525,10 +1898,21 @@ class InterfaceConnection(models.Model): An InterfaceConnection represents a symmetrical, one-to-one connection between two Interfaces. There is no significant difference between the interface_a and interface_b fields. """ - interface_a = models.OneToOneField('Interface', related_name='connected_as_a', on_delete=models.CASCADE) - interface_b = models.OneToOneField('Interface', related_name='connected_as_b', on_delete=models.CASCADE) - connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED, - verbose_name='Status') + interface_a = models.OneToOneField( + to='dcim.Interface', + on_delete=models.CASCADE, + related_name='connected_as_a' + ) + interface_b = models.OneToOneField( + to='dcim.Interface', + on_delete=models.CASCADE, + related_name='connected_as_b' + ) + connection_status = models.BooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED, + verbose_name='Status' + ) csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] @@ -1560,10 +1944,22 @@ class DeviceBay(models.Model): """ An empty space within a Device which can house a child device """ - device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE) - name = models.CharField(max_length=50, verbose_name='Name') - installed_device = models.OneToOneField('Device', related_name='parent_bay', on_delete=models.SET_NULL, blank=True, - null=True) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='device_bays' + ) + name = models.CharField( + max_length=50, + verbose_name='Name' + ) + installed_device = models.OneToOneField( + to='dcim.Device', + on_delete=models.SET_NULL, + related_name='parent_bay', + blank=True, + null=True + ) class Meta: ordering = ['device', 'name'] @@ -1598,20 +1994,55 @@ class InventoryItem(models.Model): An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. InventoryItems are used only for inventory purposes. """ - device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE) - parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE) - name = models.CharField(max_length=50, verbose_name='Name') - manufacturer = models.ForeignKey( - 'Manufacturer', models.PROTECT, related_name='inventory_items', blank=True, null=True + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='inventory_items' + ) + parent = models.ForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='child_items', + blank=True, + null=True + ) + name = models.CharField( + max_length=50, + verbose_name='Name' + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='inventory_items', + blank=True, + null=True + ) + part_id = models.CharField( + max_length=50, + verbose_name='Part ID', + blank=True + ) + serial = models.CharField( + max_length=50, + verbose_name='Serial number', + blank=True ) - part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True) - serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True) asset_tag = NullableCharField( - max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', + max_length=50, + unique=True, + blank=True, + null=True, + verbose_name='Asset tag', help_text='A unique tag used to identify this item' ) - discovered = models.BooleanField(default=False, verbose_name='Discovered') - description = models.CharField(max_length=100, blank=True) + discovered = models.BooleanField( + default=False, + verbose_name='Discovered' + ) + description = models.CharField( + max_length=100, + blank=True + ) csv_headers = [ 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 75945adcd..55db7ec25 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -73,7 +73,8 @@ class CustomField(models.Model): label = models.CharField( max_length=50, blank=True, - help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)' + help_text='Name of the field as displayed to users (if not provided, ' + 'the field\'s name will be used)' ) description = models.CharField( max_length=100, @@ -81,17 +82,20 @@ class CustomField(models.Model): ) required = models.BooleanField( default=False, - help_text='If true, this field is required when creating new objects or editing an existing object.' + help_text='If true, this field is required when creating new objects ' + 'or editing an existing object.' ) filter_logic = models.PositiveSmallIntegerField( choices=CF_FILTER_CHOICES, default=CF_FILTER_LOOSE, - help_text="Loose matches any instance of a given string; exact matches the entire field." + help_text='Loose matches any instance of a given string; exact ' + 'matches the entire field.' ) default = models.CharField( max_length=100, blank=True, - help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.' + help_text='Default value for the field. Use "true" or "false" for ' + 'booleans. N/A for selection fields.' ) weight = models.PositiveSmallIntegerField( default=100, @@ -143,11 +147,24 @@ class CustomField(models.Model): @python_2_unicode_compatible class CustomFieldValue(models.Model): - field = models.ForeignKey('CustomField', related_name='values', on_delete=models.CASCADE) - obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT) + field = models.ForeignKey( + to='extras.CustomField', + on_delete=models.CASCADE, + related_name='values' + ) + obj_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + related_name='+' + ) obj_id = models.PositiveIntegerField() - obj = GenericForeignKey('obj_type', 'obj_id') - serialized_value = models.CharField(max_length=255) + obj = GenericForeignKey( + ct_field='obj_type', + fk_field='obj_id' + ) + serialized_value = models.CharField( + max_length=255 + ) class Meta: ordering = ['obj_type', 'obj_id'] @@ -174,10 +191,19 @@ class CustomFieldValue(models.Model): @python_2_unicode_compatible class CustomFieldChoice(models.Model): - field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT}, - on_delete=models.CASCADE) - value = models.CharField(max_length=100) - weight = models.PositiveSmallIntegerField(default=100, help_text="Higher weights appear lower in the list") + field = models.ForeignKey( + to='extras.CustomField', + on_delete=models.CASCADE, + related_name='choices', + limit_choices_to={'type': CF_TYPE_SELECT} + ) + value = models.CharField( + max_length=100 + ) + weight = models.PositiveSmallIntegerField( + default=100, + help_text='Higher weights appear lower in the list' + ) class Meta: ordering = ['field', 'weight', 'value'] @@ -203,11 +229,24 @@ class CustomFieldChoice(models.Model): @python_2_unicode_compatible class Graph(models.Model): - type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) - weight = models.PositiveSmallIntegerField(default=1000) - name = models.CharField(max_length=100, verbose_name='Name') - source = models.CharField(max_length=500, verbose_name='Source URL') - link = models.URLField(verbose_name='Link URL', blank=True) + type = models.PositiveSmallIntegerField( + choices=GRAPH_TYPE_CHOICES + ) + weight = models.PositiveSmallIntegerField( + default=1000 + ) + name = models.CharField( + max_length=100, + verbose_name='Name' + ) + source = models.CharField( + max_length=500, + verbose_name='Source URL' + ) + link = models.URLField( + blank=True, + verbose_name='Link URL' + ) class Meta: ordering = ['type', 'weight', 'name'] @@ -233,13 +272,26 @@ class Graph(models.Model): @python_2_unicode_compatible class ExportTemplate(models.Model): content_type = models.ForeignKey( - ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}, on_delete=models.CASCADE + to=ContentType, + on_delete=models.CASCADE, + limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS} + ) + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True ) - name = models.CharField(max_length=100) - description = models.CharField(max_length=200, blank=True) template_code = models.TextField() - mime_type = models.CharField(max_length=15, blank=True) - file_extension = models.CharField(max_length=15, blank=True) + mime_type = models.CharField( + max_length=15, + blank=True + ) + file_extension = models.CharField( + max_length=15, + blank=True + ) class Meta: ordering = ['content_type', 'name'] @@ -278,25 +330,35 @@ class ExportTemplate(models.Model): @python_2_unicode_compatible class TopologyMap(models.Model): - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) type = models.PositiveSmallIntegerField( choices=TOPOLOGYMAP_TYPE_CHOICES, default=TOPOLOGYMAP_TYPE_NETWORK ) site = models.ForeignKey( to='dcim.Site', + on_delete=models.CASCADE, related_name='topology_maps', blank=True, - null=True, - on_delete=models.CASCADE + null=True ) device_patterns = models.TextField( - help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will " - "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. " - "Devices will be rendered in the order they are defined." + help_text='Identify devices to include in the diagram using regular ' + 'expressions, one per line. Each line will result in a new ' + 'tier of the drawing. Separate multiple regexes within a ' + 'line using semicolons. Devices will be rendered in the ' + 'order they are defined.' + ) + description = models.CharField( + max_length=100, + blank=True ) - description = models.CharField(max_length=100, blank=True) class Meta: ordering = ['name'] @@ -432,14 +494,29 @@ class ImageAttachment(models.Model): """ An uploaded image which is associated with an object. """ - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) object_id = models.PositiveIntegerField() - parent = GenericForeignKey('content_type', 'object_id') - image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width') + parent = GenericForeignKey( + ct_field='content_type', + fk_field='object_id' + ) + image = models.ImageField( + upload_to=image_upload, + height_field='image_height', + width_field='image_width' + ) image_height = models.PositiveSmallIntegerField() image_width = models.PositiveSmallIntegerField() - name = models.CharField(max_length=50, blank=True) - created = models.DateTimeField(auto_now_add=True) + name = models.CharField( + max_length=50, + blank=True + ) + created = models.DateTimeField( + auto_now_add=True + ) class Meta: ordering = ['name'] @@ -482,9 +559,20 @@ class ReportResult(models.Model): """ This model stores the results from running a user-defined report. """ - report = models.CharField(max_length=255, unique=True) - created = models.DateTimeField(auto_now_add=True) - user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True) + report = models.CharField( + max_length=255, + unique=True + ) + created = models.DateTimeField( + auto_now_add=True + ) + user = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) failed = models.BooleanField() data = JSONField() @@ -544,12 +632,29 @@ class UserAction(models.Model): """ A record of an action (add, edit, or delete) performed on an object by a User. """ - time = models.DateTimeField(auto_now_add=True, editable=False) - user = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE) - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField(blank=True, null=True) - action = models.PositiveSmallIntegerField(choices=ACTION_CHOICES) - message = models.TextField(blank=True) + time = models.DateTimeField( + auto_now_add=True, + editable=False + ) + user = models.ForeignKey( + to=User, + on_delete=models.CASCADE, + related_name='actions' + ) + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField( + blank=True, + null=True + ) + action = models.PositiveSmallIntegerField( + choices=ACTION_CHOICES + ) + message = models.TextField( + blank=True + ) objects = UserActionManager() diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 9aea44229..2f83bb0f2 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -12,8 +12,7 @@ from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from dcim.models import Interface -from extras.models import CustomFieldModel, CustomFieldValue -from tenancy.models import Tenant +from extras.models import CustomFieldModel from utilities.models import CreatedUpdatedModel from .constants import * from .fields import IPNetworkField, IPAddressField @@ -27,13 +26,35 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): 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=50) - rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher') - tenant = models.ForeignKey(Tenant, related_name='vrfs', blank=True, null=True, on_delete=models.PROTECT) - 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=100, blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + name = models.CharField( + max_length=50 + ) + rd = models.CharField( + max_length=21, + unique=True, + verbose_name='Route distinguisher' + ) + 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=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] @@ -70,10 +91,18 @@ class RIR(models.Model): 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. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - is_private = models.BooleanField(default=False, verbose_name='Private', - help_text='IP space managed by this RIR is considered private') + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + is_private = models.BooleanField( + default=False, + verbose_name='Private', + help_text='IP space managed by this RIR is considered private' + ) csv_headers = ['name', 'slug', 'is_private'] @@ -102,12 +131,29 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): 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. """ - family = models.PositiveSmallIntegerField(choices=AF_CHOICES) + family = models.PositiveSmallIntegerField( + choices=AF_CHOICES + ) prefix = IPNetworkField() - rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR') - date_added = models.DateField(blank=True, null=True) - description = models.CharField(max_length=100, blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + rir = models.ForeignKey( + to='ipam.RIR', + on_delete=models.PROTECT, + related_name='aggregates', + verbose_name='RIR' + ) + date_added = models.DateField( + blank=True, + null=True + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) csv_headers = ['prefix', 'rir', 'date_added', 'description'] @@ -178,9 +224,16 @@ class Role(models.Model): A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or "Management." """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - weight = models.PositiveSmallIntegerField(default=1000) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + weight = models.PositiveSmallIntegerField( + default=1000 + ) csv_headers = ['name', 'slug', 'weight'] @@ -205,22 +258,71 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be assigned to a VLAN where appropriate. """ - family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False) - prefix = IPNetworkField(help_text="IPv4 or IPv6 network with mask") - site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True) - vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, - verbose_name='VRF') - tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT) - vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, - verbose_name='VLAN') - status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=PREFIX_STATUS_ACTIVE, - help_text="Operational status of this prefix") - role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True, - help_text="The primary function of this prefix") - is_pool = models.BooleanField(verbose_name='Is a pool', default=False, - help_text="All IP addresses within this prefix are considered usable") - description = models.CharField(max_length=100, blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + family = models.PositiveSmallIntegerField( + choices=AF_CHOICES, + editable=False + ) + prefix = IPNetworkField( + help_text='IPv4 or IPv6 network with mask' + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='prefixes', + blank=True, + null=True + ) + vrf = models.ForeignKey( + to='ipam.VRF', + on_delete=models.PROTECT, + related_name='prefixes', + blank=True, + null=True, + verbose_name='VRF' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='prefixes', + blank=True, + null=True + ) + vlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.PROTECT, + related_name='prefixes', + blank=True, + null=True, + verbose_name='VLAN' + ) + status = models.PositiveSmallIntegerField( + choices=PREFIX_STATUS_CHOICES, + default=PREFIX_STATUS_ACTIVE, + verbose_name='Status', + help_text='Operational status of this prefix' + ) + role = models.ForeignKey( + to='ipam.Role', + on_delete=models.SET_NULL, + related_name='prefixes', + blank=True, + null=True, + help_text='The primary function of this prefix' + ) + is_pool = models.BooleanField( + verbose_name='Is a pool', + default=False, + help_text='All IP addresses within this prefix are considered usable' + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) objects = PrefixQuerySet.as_manager() @@ -400,25 +502,66 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): for example, when mapping public addresses to private addresses. When an Interface has been assigned an IPAddress which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP. """ - family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False) - address = IPAddressField(help_text="IPv4 or IPv6 address (with mask)") - vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, - verbose_name='VRF') - tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT) + family = models.PositiveSmallIntegerField( + choices=AF_CHOICES, + editable=False + ) + address = IPAddressField( + help_text='IPv4 or IPv6 address (with mask)' + ) + vrf = models.ForeignKey( + to='ipam.VRF', + on_delete=models.PROTECT, + related_name='ip_addresses', + blank=True, + null=True, + verbose_name='VRF' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='ip_addresses', + blank=True, + null=True + ) status = models.PositiveSmallIntegerField( - 'Status', choices=IPADDRESS_STATUS_CHOICES, default=IPADDRESS_STATUS_ACTIVE, + choices=IPADDRESS_STATUS_CHOICES, + default=IPADDRESS_STATUS_ACTIVE, + verbose_name='Status', help_text='The operational status of this IP' ) role = models.PositiveSmallIntegerField( - 'Role', choices=IPADDRESS_ROLE_CHOICES, blank=True, null=True, help_text='The functional role of this IP' + verbose_name='Role', + choices=IPADDRESS_ROLE_CHOICES, + blank=True, + null=True, + help_text='The functional role of this IP' + ) + interface = models.ForeignKey( + to='dcim.Interface', + on_delete=models.CASCADE, + related_name='ip_addresses', + blank=True, + null=True + ) + nat_inside = models.OneToOneField( + to='self', + on_delete=models.SET_NULL, + related_name='nat_outside', + blank=True, + null=True, + verbose_name='NAT (Inside)', + help_text='The IP for which this address is the "outside" IP' + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' ) - interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, - null=True) - nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, - null=True, verbose_name='NAT (Inside)', - help_text="The IP for which this address is the \"outside\" IP") - description = models.CharField(max_length=100, blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = IPAddressManager() @@ -509,9 +652,17 @@ class VLANGroup(models.Model): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. """ - name = models.CharField(max_length=50) + name = models.CharField( + max_length=50 + ) slug = models.SlugField() - site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='vlan_groups', + blank=True, + null=True + ) csv_headers = ['name', 'slug', 'site'] @@ -558,18 +709,55 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): 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('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True) - group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) - vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[ - MinValueValidator(1), - MaxValueValidator(4094) - ]) - name = models.CharField(max_length=64) - tenant = models.ForeignKey(Tenant, related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) - status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1) - role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True) - description = models.CharField(max_length=100, blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + 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.PositiveSmallIntegerField( + choices=VLAN_STATUS_CHOICES, + default=1, + verbose_name='Status' + ) + role = models.ForeignKey( + to='ipam.Role', + on_delete=models.SET_NULL, + related_name='vlans', + blank=True, + null=True + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index e1f367d03..e39d46eef 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -13,7 +13,6 @@ from django.db import models from django.urls import reverse from django.utils.encoding import force_bytes, python_2_unicode_compatible -from dcim.models import Device from utilities.models import CreatedUpdatedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -54,9 +53,21 @@ class UserKey(CreatedUpdatedModel): copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's matching (private) decryption key. """ - user = models.OneToOneField(User, related_name='user_key', editable=False, on_delete=models.CASCADE) - public_key = models.TextField(verbose_name='RSA public key') - master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False) + user = models.OneToOneField( + to=User, + on_delete=models.CASCADE, + related_name='user_key', + editable=False + ) + public_key = models.TextField( + verbose_name='RSA public key' + ) + master_key_cipher = models.BinaryField( + max_length=512, + blank=True, + null=True, + editable=False + ) objects = UserKeyQuerySet.as_manager() @@ -172,10 +183,23 @@ class SessionKey(models.Model): """ A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets. """ - userkey = models.OneToOneField(UserKey, related_name='session_key', on_delete=models.CASCADE, editable=False) - cipher = models.BinaryField(max_length=512, editable=False) - hash = models.CharField(max_length=128, editable=False) - created = models.DateTimeField(auto_now_add=True) + userkey = models.OneToOneField( + to='secrets.UserKey', + on_delete=models.CASCADE, + related_name='session_key', + editable=False + ) + cipher = models.BinaryField( + max_length=512, + editable=False + ) + hash = models.CharField( + max_length=128, + editable=False + ) + created = models.DateTimeField( + auto_now_add=True + ) key = None @@ -234,10 +258,23 @@ class SecretRole(models.Model): By default, only superusers will have access to decrypt Secrets. To allow other users to decrypt Secrets, grant them access to the appropriate SecretRoles either individually or by group. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - users = models.ManyToManyField(User, related_name='secretroles', blank=True) - groups = models.ManyToManyField(Group, related_name='secretroles', blank=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + users = models.ManyToManyField( + to=User, + related_name='secretroles', + blank=True + ) + groups = models.ManyToManyField( + to=Group, + related_name='secretroles', + blank=True + ) csv_headers = ['name', 'slug'] @@ -276,11 +313,28 @@ class Secret(CreatedUpdatedModel): A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis. """ - device = models.ForeignKey(Device, related_name='secrets', on_delete=models.CASCADE) - role = models.ForeignKey('SecretRole', related_name='secrets', on_delete=models.PROTECT) - name = models.CharField(max_length=100, blank=True) - ciphertext = models.BinaryField(editable=False, max_length=65568) # 16B IV + 2B pad length + {62-65550}B padded - hash = models.CharField(max_length=128, editable=False) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='secrets' + ) + role = models.ForeignKey( + to='secrets.SecretRole', + on_delete=models.PROTECT, + related_name='secrets' + ) + name = models.CharField( + max_length=100, + blank=True + ) + ciphertext = models.BinaryField( + max_length=65568, # 16B IV + 2B pad length + {62-65550}B padded + editable=False + ) + hash = models.CharField( + max_length=128, + editable=False + ) plaintext = None csv_headers = ['device', 'role', 'name', 'plaintext'] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 1fea2ceaf..9df714680 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -5,7 +5,7 @@ from django.db import models from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible -from extras.models import CustomFieldModel, CustomFieldValue +from extras.models import CustomFieldModel from utilities.models import CreatedUpdatedModel @@ -14,8 +14,13 @@ class TenantGroup(models.Model): """ An arbitrary collection of Tenants. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) csv_headers = ['name', 'slug'] @@ -41,12 +46,33 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel): A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. """ - name = models.CharField(max_length=30, unique=True) - slug = models.SlugField(unique=True) - group = models.ForeignKey('TenantGroup', related_name='tenants', blank=True, null=True, on_delete=models.SET_NULL) - description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)") - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + name = models.CharField( + max_length=30, + unique=True + ) + slug = models.SlugField( + unique=True + ) + group = models.ForeignKey( + to='tenancy.TenantGroup', + on_delete=models.SET_NULL, + related_name='tenants', + blank=True, + null=True + ) + description = models.CharField( + max_length=100, + blank=True, + help_text='Long-form name (optional)' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) csv_headers = ['name', 'slug', 'group', 'description', 'comments'] diff --git a/netbox/users/models.py b/netbox/users/models.py index 02f5bc0a0..b3698d925 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -16,12 +16,31 @@ class Token(models.Model): 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. """ - user = models.ForeignKey(User, related_name='tokens', on_delete=models.CASCADE) - created = models.DateTimeField(auto_now_add=True) - expires = models.DateTimeField(blank=True, null=True) - key = models.CharField(max_length=40, unique=True, validators=[MinLengthValidator(40)]) - write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key") - description = models.CharField(max_length=100, blank=True) + user = models.ForeignKey( + to=User, + on_delete=models.CASCADE, + related_name='tokens' + ) + created = models.DateTimeField( + auto_now_add=True + ) + expires = models.DateTimeField( + blank=True, + null=True + ) + key = models.CharField( + max_length=40, + unique=True, + validators=[MinLengthValidator(40)] + ) + write_enabled = models.BooleanField( + default=True, + help_text='Permit create/update/delete operations using this key' + ) + description = models.CharField( + max_length=100, + blank=True + ) class Meta: default_permissions = [] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 0a6abc400..b58cf93e8 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -8,7 +8,7 @@ from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from dcim.models import Device -from extras.models import CustomFieldModel, CustomFieldValue +from extras.models import CustomFieldModel from utilities.models import CreatedUpdatedModel from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES @@ -119,7 +119,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): blank=True ) custom_field_values = GenericRelation( - to=CustomFieldValue, + to='extras.CustomFieldValue', content_type_field='obj_type', object_id_field='obj_id' ) @@ -167,7 +167,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): A virtual machine which runs inside a Cluster. """ cluster = models.ForeignKey( - to=Cluster, + to='virtualization.Cluster', on_delete=models.PROTECT, related_name='virtual_machines' ) @@ -196,9 +196,9 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): ) role = models.ForeignKey( to='dcim.DeviceRole', - limit_choices_to={'vm_role': True}, on_delete=models.PROTECT, related_name='virtual_machines', + limit_choices_to={'vm_role': True}, blank=True, null=True ) @@ -237,7 +237,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): blank=True ) custom_field_values = GenericRelation( - to=CustomFieldValue, + to='extras.CustomFieldValue', content_type_field='obj_type', object_id_field='obj_id' ) From 72c518bcb7f9b353359b6560c26722464be930cc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Apr 2018 15:10:06 -0400 Subject: [PATCH 004/159] Updated tests for recently added model fields --- netbox/circuits/tests/test_api.py | 6 +++++- netbox/dcim/tests/test_api.py | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 1228aafaa..39a2d69f2 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -5,7 +5,7 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from circuits.constants import TERM_SIDE_A, TERM_SIDE_Z +from circuits.constants import CIRCUIT_STATUS_ACTIVE, TERM_SIDE_A, TERM_SIDE_Z from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.models import Site from extras.constants import GRAPH_TYPE_PROVIDER @@ -231,6 +231,7 @@ class CircuitTest(HttpStatusMixin, APITestCase): 'cid': 'TEST0004', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, } url = reverse('circuits-api:circuit-list') @@ -250,16 +251,19 @@ class CircuitTest(HttpStatusMixin, APITestCase): 'cid': 'TEST0004', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, }, { 'cid': 'TEST0005', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, }, { 'cid': 'TEST0006', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, }, ] diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 37743b499..b32d7e7a0 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -6,7 +6,8 @@ from rest_framework import status from rest_framework.test import APITestCase from dcim.constants import ( - IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, + IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SITE_STATUS_ACTIVE, SUBDEVICE_ROLE_CHILD, + SUBDEVICE_ROLE_PARENT, ) from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, @@ -168,6 +169,7 @@ class SiteTest(HttpStatusMixin, APITestCase): 'name': 'Test Site 4', 'slug': 'test-site-4', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, } url = reverse('dcim-api:site-list') @@ -187,16 +189,19 @@ class SiteTest(HttpStatusMixin, APITestCase): 'name': 'Test Site 4', 'slug': 'test-site-4', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, }, { 'name': 'Test Site 5', 'slug': 'test-site-5', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, }, { 'name': 'Test Site 6', 'slug': 'test-site-6', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, }, ] From db3cbaf83bc5938ee7b513091e5664e96378d957 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Apr 2018 15:39:14 -0400 Subject: [PATCH 005/159] Introduced WritableNestedSerializer --- netbox/utilities/api.py | 112 +++++++++++++++++++++++----------------- 1 file changed, 64 insertions(+), 48 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index c54379dff..3f01da7a9 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -5,6 +5,7 @@ import pytz from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.db.models import ManyToManyField from django.http import Http404 from rest_framework import mixins @@ -36,6 +37,64 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): return request.user.is_authenticated +# +# Fields +# + +class ChoiceFieldSerializer(Field): + """ + Represent a ChoiceField as {'value': , 'label': }. + """ + def __init__(self, choices, **kwargs): + self._choices = dict() + for k, v in choices: + # Unpack grouped choices + if type(v) in [list, tuple]: + for k2, v2 in v: + self._choices[k2] = v2 + else: + self._choices[k] = v + super(ChoiceFieldSerializer, self).__init__(**kwargs) + + def to_representation(self, obj): + return {'value': obj, 'label': self._choices[obj]} + + def to_internal_value(self, data): + return data + + +class ContentTypeFieldSerializer(Field): + """ + Represent a ContentType as '.' + """ + def to_representation(self, obj): + return "{}.{}".format(obj.app_label, obj.model) + + def to_internal_value(self, data): + app_label, model = data.split('.') + try: + return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) + except ContentType.DoesNotExist: + raise ValidationError("Invalid content type") + + +class TimeZoneField(Field): + """ + Represent a pytz time zone. + """ + + def to_representation(self, obj): + return obj.zone if obj else None + + def to_internal_value(self, data): + if not data: + return "" + try: + return pytz.timezone(str(data)) + except pytz.exceptions.UnknownTimeZoneError: + raise ValidationError('Invalid time zone "{}"'.format(data)) + + # # Serializers # @@ -67,58 +126,15 @@ class ValidatedModelSerializer(ModelSerializer): return data -class ChoiceFieldSerializer(Field): +class WritableNestedSerializer(ModelSerializer): """ - Represent a ChoiceField as {'value': , 'label': }. + Returns a nested representation of an object on read, but accepts only a primary key on write. """ - def __init__(self, choices, **kwargs): - self._choices = dict() - for k, v in choices: - # Unpack grouped choices - if type(v) in [list, tuple]: - for k2, v2 in v: - self._choices[k2] = v2 - else: - self._choices[k] = v - super(ChoiceFieldSerializer, self).__init__(**kwargs) - - def to_representation(self, obj): - return {'value': obj, 'label': self._choices[obj]} - def to_internal_value(self, data): - return self._choices.get(data) - - -class ContentTypeFieldSerializer(Field): - """ - Represent a ContentType as '.' - """ - def to_representation(self, obj): - return "{}.{}".format(obj.app_label, obj.model) - - def to_internal_value(self, data): - app_label, model = data.split('.') try: - return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) - except ContentType.DoesNotExist: - raise ValidationError("Invalid content type") - - -class TimeZoneField(Field): - """ - Represent a pytz time zone. - """ - - def to_representation(self, obj): - return obj.zone if obj else None - - def to_internal_value(self, data): - if not data: - return "" - try: - return pytz.timezone(str(data)) - except pytz.exceptions.UnknownTimeZoneError: - raise ValidationError('Invalid time zone "{}"'.format(data)) + return self.Meta.model.objects.get(pk=data) + except ObjectDoesNotExist: + raise ValidationError("Invalid ID") # From 7241783249eb3b751d73de836d6266209ceaf462 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Apr 2018 17:01:24 -0400 Subject: [PATCH 006/159] Started merging writable serializers (WIP) --- netbox/circuits/api/serializers.py | 45 +-- netbox/circuits/api/views.py | 3 - netbox/dcim/api/serializers.py | 441 +++++++++-------------------- netbox/dcim/api/views.py | 23 -- netbox/dcim/tests/test_api.py | 18 +- netbox/extras/api/serializers.py | 67 ++--- netbox/extras/api/views.py | 3 - netbox/tenancy/api/serializers.py | 15 +- netbox/tenancy/api/views.py | 1 - netbox/users/api/serializers.py | 5 +- 10 files changed, 187 insertions(+), 434 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index db550a63b..af56aef47 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -7,7 +7,7 @@ from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer # @@ -24,7 +24,7 @@ class ProviderSerializer(CustomFieldModelSerializer): ] -class NestedProviderSerializer(serializers.ModelSerializer): +class NestedProviderSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') class Meta: @@ -32,16 +32,6 @@ class NestedProviderSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritableProviderSerializer(CustomFieldModelSerializer): - - class Meta: - model = Provider - fields = [ - 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - 'custom_fields', 'created', 'last_updated', - ] - - # # Circuit types # @@ -53,7 +43,7 @@ class CircuitTypeSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedCircuitTypeSerializer(serializers.ModelSerializer): +class NestedCircuitTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') class Meta: @@ -67,9 +57,9 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer): class CircuitSerializer(CustomFieldModelSerializer): provider = NestedProviderSerializer() - status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES) + status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False) type = NestedCircuitTypeSerializer() - tenant = NestedTenantSerializer() + tenant = NestedTenantSerializer(required=False) class Meta: model = Circuit @@ -79,7 +69,7 @@ class CircuitSerializer(CustomFieldModelSerializer): ] -class NestedCircuitSerializer(serializers.ModelSerializer): +class NestedCircuitSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') class Meta: @@ -87,33 +77,14 @@ class NestedCircuitSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'cid'] -class WritableCircuitSerializer(CustomFieldModelSerializer): - - class Meta: - model = Circuit - fields = [ - 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', - 'comments', 'custom_fields', 'created', 'last_updated', - ] - - # # Circuit Terminations # -class CircuitTerminationSerializer(serializers.ModelSerializer): +class CircuitTerminationSerializer(ValidatedModelSerializer): circuit = NestedCircuitSerializer() site = NestedSiteSerializer() - interface = InterfaceSerializer() - - class Meta: - model = CircuitTermination - fields = [ - 'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - ] - - -class WritableCircuitTerminationSerializer(ValidatedModelSerializer): + interface = InterfaceSerializer(required=False) class Meta: model = CircuitTermination diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 9b75bc184..d70a0596c 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -30,7 +30,6 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): class ProviderViewSet(CustomFieldModelViewSet): queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer - write_serializer_class = serializers.WritableProviderSerializer filter_class = filters.ProviderFilter @detail_route() @@ -61,7 +60,6 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.select_related('type', 'tenant', 'provider') serializer_class = serializers.CircuitSerializer - write_serializer_class = serializers.WritableCircuitSerializer filter_class = filters.CircuitFilter @@ -72,5 +70,4 @@ class CircuitViewSet(CustomFieldModelViewSet): class CircuitTerminationViewSet(ModelViewSet): queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device') serializer_class = serializers.CircuitTerminationSerializer - write_serializer_class = serializers.WritableCircuitTerminationSerializer filter_class = filters.CircuitTerminationFilter diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d458bc646..f791a83de 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -20,7 +20,7 @@ from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from users.api.serializers import NestedUserSerializer -from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer +from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer, WritableNestedSerializer from virtualization.models import Cluster @@ -28,7 +28,7 @@ from virtualization.models import Cluster # Regions # -class NestedRegionSerializer(serializers.ModelSerializer): +class NestedRegionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') class Meta: @@ -37,14 +37,7 @@ class NestedRegionSerializer(serializers.ModelSerializer): class RegionSerializer(serializers.ModelSerializer): - parent = NestedRegionSerializer() - - class Meta: - model = Region - fields = ['id', 'name', 'slug', 'parent'] - - -class WritableRegionSerializer(ValidatedModelSerializer): + parent = NestedRegionSerializer(required=False) class Meta: model = Region @@ -56,9 +49,9 @@ class WritableRegionSerializer(ValidatedModelSerializer): # class SiteSerializer(CustomFieldModelSerializer): - status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES) - region = NestedRegionSerializer() - tenant = NestedTenantSerializer() + status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES, required=False) + region = NestedRegionSerializer(required=False) + tenant = NestedTenantSerializer(required=False) time_zone = TimeZoneField(required=False) class Meta: @@ -71,7 +64,7 @@ class SiteSerializer(CustomFieldModelSerializer): ] -class NestedSiteSerializer(serializers.ModelSerializer): +class NestedSiteSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') class Meta: @@ -79,23 +72,11 @@ class NestedSiteSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritableSiteSerializer(CustomFieldModelSerializer): - time_zone = TimeZoneField(required=False) - - class Meta: - model = Site - fields = [ - 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', - 'custom_fields', 'created', 'last_updated', - ] - - # # Rack groups # -class RackGroupSerializer(serializers.ModelSerializer): +class RackGroupSerializer(ValidatedModelSerializer): site = NestedSiteSerializer() class Meta: @@ -103,7 +84,7 @@ class RackGroupSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site'] -class NestedRackGroupSerializer(serializers.ModelSerializer): +class NestedRackGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') class Meta: @@ -111,13 +92,6 @@ class NestedRackGroupSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritableRackGroupSerializer(ValidatedModelSerializer): - - class Meta: - model = RackGroup - fields = ['id', 'name', 'slug', 'site'] - - # # Rack roles # @@ -129,7 +103,7 @@ class RackRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'color'] -class NestedRackRoleSerializer(serializers.ModelSerializer): +class NestedRackRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') class Meta: @@ -143,11 +117,11 @@ class NestedRackRoleSerializer(serializers.ModelSerializer): class RackSerializer(CustomFieldModelSerializer): site = NestedSiteSerializer() - group = NestedRackGroupSerializer() - tenant = NestedTenantSerializer() - role = NestedRackRoleSerializer() - type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES) - width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES) + group = NestedRackGroupSerializer(required=False) + tenant = NestedTenantSerializer(required=False) + role = NestedRackRoleSerializer(required=False) + type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False) + width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False) class Meta: model = Rack @@ -155,24 +129,6 @@ class RackSerializer(CustomFieldModelSerializer): 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', ] - - -class NestedRackSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') - - class Meta: - model = Rack - fields = ['id', 'url', 'name', 'display_name'] - - -class WritableRackSerializer(CustomFieldModelSerializer): - - class Meta: - model = Rack - fields = [ - 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', - 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', - ] # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This # prevents facility_id from being interpreted as a required field. validators = [ @@ -188,16 +144,24 @@ class WritableRackSerializer(CustomFieldModelSerializer): validator(data) # Enforce model validation - super(WritableRackSerializer, self).validate(data) + super(RackSerializer, self).validate(data) return data +class NestedRackSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') + + class Meta: + model = Rack + fields = ['id', 'url', 'name', 'display_name'] + + # # Rack units # -class NestedDeviceSerializer(serializers.ModelSerializer): +class NestedDeviceSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') class Meta: @@ -219,23 +183,16 @@ class RackUnitSerializer(serializers.Serializer): # Rack reservations # -class RackReservationSerializer(serializers.ModelSerializer): +class RackReservationSerializer(ValidatedModelSerializer): rack = NestedRackSerializer() user = NestedUserSerializer() - tenant = NestedTenantSerializer() + tenant = NestedTenantSerializer(required=False) class Meta: model = RackReservation fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description'] -class WritableRackReservationSerializer(ValidatedModelSerializer): - - class Meta: - model = RackReservation - fields = ['id', 'rack', 'units', 'user', 'description'] - - # # Manufacturers # @@ -247,7 +204,7 @@ class ManufacturerSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedManufacturerSerializer(serializers.ModelSerializer): +class NestedManufacturerSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') class Meta: @@ -261,8 +218,8 @@ class NestedManufacturerSerializer(serializers.ModelSerializer): class DeviceTypeSerializer(CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() - interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES) - subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES) + interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES, required=False) + subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False) instance_count = serializers.IntegerField(source='instances.count', read_only=True) class Meta: @@ -274,30 +231,20 @@ class DeviceTypeSerializer(CustomFieldModelSerializer): ] -class NestedDeviceTypeSerializer(serializers.ModelSerializer): +class NestedDeviceTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') - manufacturer = NestedManufacturerSerializer() + manufacturer = NestedManufacturerSerializer(read_only=True) class Meta: model = DeviceType fields = ['id', 'url', 'manufacturer', 'model', 'slug'] -class WritableDeviceTypeSerializer(CustomFieldModelSerializer): - - class Meta: - model = DeviceType - fields = [ - 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', - 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', - ] - - # # Console port templates # -class ConsolePortTemplateSerializer(serializers.ModelSerializer): +class ConsolePortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -305,18 +252,11 @@ class ConsolePortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableConsolePortTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = ConsolePortTemplate - fields = ['id', 'device_type', 'name'] - - # # Console server port templates # -class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): +class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -324,18 +264,11 @@ class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableConsoleServerPortTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = ConsoleServerPortTemplate - fields = ['id', 'device_type', 'name'] - - # # Power port templates # -class PowerPortTemplateSerializer(serializers.ModelSerializer): +class PowerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -343,18 +276,11 @@ class PowerPortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritablePowerPortTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = PowerPortTemplate - fields = ['id', 'device_type', 'name'] - - # # Power outlet templates # -class PowerOutletTemplateSerializer(serializers.ModelSerializer): +class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -362,27 +288,13 @@ class PowerOutletTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritablePowerOutletTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = PowerOutletTemplate - fields = ['id', 'device_type', 'name'] - - # # Interface templates # -class InterfaceTemplateSerializer(serializers.ModelSerializer): +class InterfaceTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) - - class Meta: - model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] - - -class WritableInterfaceTemplateSerializer(ValidatedModelSerializer): + form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False) class Meta: model = InterfaceTemplate @@ -393,7 +305,7 @@ class WritableInterfaceTemplateSerializer(ValidatedModelSerializer): # Device bay templates # -class DeviceBayTemplateSerializer(serializers.ModelSerializer): +class DeviceBayTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -401,13 +313,6 @@ class DeviceBayTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableDeviceBayTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = DeviceBayTemplate - fields = ['id', 'device_type', 'name'] - - # # Device roles # @@ -419,7 +324,7 @@ class DeviceRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'color', 'vm_role'] -class NestedDeviceRoleSerializer(serializers.ModelSerializer): +class NestedDeviceRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') class Meta: @@ -431,15 +336,15 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer): # Platforms # -class PlatformSerializer(serializers.ModelSerializer): - manufacturer = NestedManufacturerSerializer() +class PlatformSerializer(ValidatedModelSerializer): + manufacturer = NestedManufacturerSerializer(required=False) class Meta: model = Platform fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] -class NestedPlatformSerializer(serializers.ModelSerializer): +class NestedPlatformSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') class Meta: @@ -447,13 +352,6 @@ class NestedPlatformSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritablePlatformSerializer(ValidatedModelSerializer): - - class Meta: - model = Platform - fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] - - # # Devices # @@ -489,18 +387,18 @@ class DeviceVirtualChassisSerializer(serializers.ModelSerializer): class DeviceSerializer(CustomFieldModelSerializer): device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() - tenant = NestedTenantSerializer() - platform = NestedPlatformSerializer() + tenant = NestedTenantSerializer(required=False) + platform = NestedPlatformSerializer(required=False) site = NestedSiteSerializer() - rack = NestedRackSerializer() - face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES) - status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES) - primary_ip = DeviceIPAddressSerializer() - primary_ip4 = DeviceIPAddressSerializer() - primary_ip6 = DeviceIPAddressSerializer() + rack = NestedRackSerializer(required=False) + face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES, required=False) + status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES, required=False) + primary_ip = DeviceIPAddressSerializer(read_only=True) + primary_ip4 = DeviceIPAddressSerializer(required=False) + primary_ip6 = DeviceIPAddressSerializer(required=False) parent_device = serializers.SerializerMethodField() - cluster = NestedClusterSerializer() - virtual_chassis = DeviceVirtualChassisSerializer() + cluster = NestedClusterSerializer(required=False) + virtual_chassis = DeviceVirtualChassisSerializer(required=False) class Meta: model = Device @@ -510,27 +408,6 @@ class DeviceSerializer(CustomFieldModelSerializer): 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated', ] - - def get_parent_device(self, obj): - try: - device_bay = obj.parent_bay - except DeviceBay.DoesNotExist: - return None - context = {'request': self.context['request']} - data = NestedDeviceSerializer(instance=device_bay.device, context=context).data - data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data - return data - - -class WritableDeviceSerializer(CustomFieldModelSerializer): - - class Meta: - model = Device - fields = [ - 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', - 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', - 'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated', - ] validators = [] def validate(self, data): @@ -542,16 +419,26 @@ class WritableDeviceSerializer(CustomFieldModelSerializer): validator(data) # Enforce model validation - super(WritableDeviceSerializer, self).validate(data) + super(DeviceSerializer, self).validate(data) return data + def get_parent_device(self, obj): + try: + device_bay = obj.parent_bay + except DeviceBay.DoesNotExist: + return None + context = {'request': self.context['request']} + data = NestedDeviceSerializer(instance=device_bay.device, context=context).data + data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data + return data + # # Console server ports # -class ConsoleServerPortSerializer(serializers.ModelSerializer): +class ConsoleServerPortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() class Meta: @@ -560,27 +447,22 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer): read_only_fields = ['connected_console'] -class WritableConsoleServerPortSerializer(ValidatedModelSerializer): +class NestedConsoleServerPortSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') + device = NestedDeviceSerializer(read_only=True) class Meta: model = ConsoleServerPort - fields = ['id', 'device', 'name'] + fields = ['id', 'url', 'device', 'name'] # # Console ports # -class ConsolePortSerializer(serializers.ModelSerializer): +class ConsolePortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - cs_port = ConsoleServerPortSerializer() - - class Meta: - model = ConsolePort - fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] - - -class WritableConsolePortSerializer(ValidatedModelSerializer): + cs_port = NestedConsoleServerPortSerializer(required=False) class Meta: model = ConsolePort @@ -591,7 +473,7 @@ class WritableConsolePortSerializer(ValidatedModelSerializer): # Power outlets # -class PowerOutletSerializer(serializers.ModelSerializer): +class PowerOutletSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() class Meta: @@ -600,27 +482,22 @@ class PowerOutletSerializer(serializers.ModelSerializer): read_only_fields = ['connected_port'] -class WritablePowerOutletSerializer(ValidatedModelSerializer): +class NestedPowerOutletSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') + device = NestedDeviceSerializer(read_only=True) class Meta: model = PowerOutlet - fields = ['id', 'device', 'name'] + fields = ['id', 'url', 'device', 'name'] # # Power ports # -class PowerPortSerializer(serializers.ModelSerializer): +class PowerPortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - power_outlet = PowerOutletSerializer() - - class Meta: - model = PowerPort - fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] - - -class WritablePowerPortSerializer(ValidatedModelSerializer): + power_outlet = NestedPowerOutletSerializer(required=False) class Meta: model = PowerPort @@ -631,12 +508,13 @@ class WritablePowerPortSerializer(ValidatedModelSerializer): # Interfaces # -class NestedInterfaceSerializer(serializers.ModelSerializer): +class NestedInterfaceSerializer(WritableNestedSerializer): + device = NestedDeviceSerializer(read_only=True) url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') class Meta: model = Interface - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'device', 'name'] class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): @@ -647,8 +525,8 @@ class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'cid'] -class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer): - circuit = InterfaceNestedCircuitSerializer() +class InterfaceCircuitTerminationSerializer(WritableNestedSerializer): + circuit = InterfaceNestedCircuitSerializer(read_only=True) class Meta: model = CircuitTermination @@ -658,7 +536,7 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer): # Cannot import ipam.api.NestedVLANSerializer due to circular dependency -class InterfaceVLANSerializer(serializers.ModelSerializer): +class InterfaceVLANSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') class Meta: @@ -666,16 +544,16 @@ class InterfaceVLANSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'vid', 'name', 'display_name'] -class InterfaceSerializer(serializers.ModelSerializer): +class InterfaceSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) - lag = NestedInterfaceSerializer() + form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False) + lag = NestedInterfaceSerializer(required=False) is_connected = serializers.SerializerMethodField(read_only=True) interface_connection = serializers.SerializerMethodField(read_only=True) - circuit_termination = InterfaceCircuitTerminationSerializer() - untagged_vlan = InterfaceVLANSerializer() - mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES) - tagged_vlans = InterfaceVLANSerializer(many=True) + circuit_termination = InterfaceCircuitTerminationSerializer(required=False) + untagged_vlan = InterfaceVLANSerializer(required=False) + mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES, required=False) + tagged_vlans = InterfaceVLANSerializer(many=True, required=False) class Meta: model = Interface @@ -684,51 +562,6 @@ class InterfaceSerializer(serializers.ModelSerializer): 'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans', ] - def get_is_connected(self, obj): - """ - Return True if the interface has a connected interface or circuit termination. - """ - if obj.connection: - return True - try: - circuit_termination = obj.circuit_termination - return True - except CircuitTermination.DoesNotExist: - pass - return False - - def get_interface_connection(self, obj): - if obj.connection: - return OrderedDict(( - ('interface', PeerInterfaceSerializer(obj.connected_interface, context=self.context).data), - ('status', obj.connection.connection_status), - )) - return None - - -class PeerInterfaceSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') - device = NestedDeviceSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) - lag = NestedInterfaceSerializer() - - class Meta: - model = Interface - fields = [ - 'id', 'url', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', - 'description', - ] - - -class WritableInterfaceSerializer(ValidatedModelSerializer): - - class Meta: - model = Interface - fields = [ - 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', - ] - def validate(self, data): # All associated VLANs be global or assigned to the parent device's site. @@ -746,23 +579,58 @@ class WritableInterfaceSerializer(ValidatedModelSerializer): "be global.".format(vlan) }) - return super(WritableInterfaceSerializer, self).validate(data) + return super(InterfaceSerializer, self).validate(data) + + def get_is_connected(self, obj): + """ + Return True if the interface has a connected interface or circuit termination. + """ + if obj.connection: + return True + try: + circuit_termination = obj.circuit_termination + return True + except CircuitTermination.DoesNotExist: + pass + return False + + def get_interface_connection(self, obj): + if obj.connection: + return OrderedDict(( + ('interface', NestedInterfaceSerializer(obj.connected_interface, context=self.context).data), + ('status', obj.connection.connection_status), + )) + return None + + +# class PeerInterfaceSerializer(serializers.ModelSerializer): +# url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') +# device = NestedDeviceSerializer() +# form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) +# lag = NestedInterfaceSerializer() +# +# class Meta: +# model = Interface +# fields = [ +# 'id', 'url', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', +# 'description', +# ] # # Device bays # -class DeviceBaySerializer(serializers.ModelSerializer): +class DeviceBaySerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - installed_device = NestedDeviceSerializer() + installed_device = NestedDeviceSerializer(required=False) class Meta: model = DeviceBay fields = ['id', 'device', 'name', 'installed_device'] -class NestedDeviceBaySerializer(serializers.ModelSerializer): +class NestedDeviceBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') class Meta: @@ -770,32 +638,15 @@ class NestedDeviceBaySerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name'] -class WritableDeviceBaySerializer(ValidatedModelSerializer): - - class Meta: - model = DeviceBay - fields = ['id', 'device', 'name', 'installed_device'] - - # # Inventory items # -class InventoryItemSerializer(serializers.ModelSerializer): +class InventoryItemSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - manufacturer = NestedManufacturerSerializer() - - class Meta: - model = InventoryItem - fields = [ - 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', - 'description', - ] - - -class WritableInventoryItemSerializer(ValidatedModelSerializer): # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) + manufacturer = NestedManufacturerSerializer() class Meta: model = InventoryItem @@ -809,17 +660,17 @@ class WritableInventoryItemSerializer(ValidatedModelSerializer): # Interface connections # -class InterfaceConnectionSerializer(serializers.ModelSerializer): - interface_a = PeerInterfaceSerializer() - interface_b = PeerInterfaceSerializer() - connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES) +class InterfaceConnectionSerializer(ValidatedModelSerializer): + interface_a = NestedInterfaceSerializer() + interface_b = NestedInterfaceSerializer() + connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES, required=False) class Meta: model = InterfaceConnection fields = ['id', 'interface_a', 'interface_b', 'connection_status'] -class NestedInterfaceConnectionSerializer(serializers.ModelSerializer): +class NestedInterfaceConnectionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail') class Meta: @@ -827,18 +678,11 @@ class NestedInterfaceConnectionSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'connection_status'] -class WritableInterfaceConnectionSerializer(ValidatedModelSerializer): - - class Meta: - model = InterfaceConnection - fields = ['id', 'interface_a', 'interface_b', 'connection_status'] - - # # Virtual chassis # -class VirtualChassisSerializer(serializers.ModelSerializer): +class VirtualChassisSerializer(ValidatedModelSerializer): master = NestedDeviceSerializer() class Meta: @@ -846,16 +690,9 @@ class VirtualChassisSerializer(serializers.ModelSerializer): fields = ['id', 'master', 'domain'] -class NestedVirtualChassisSerializer(serializers.ModelSerializer): +class NestedVirtualChassisSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') class Meta: model = VirtualChassis fields = ['id', 'url'] - - -class WritableVirtualChassisSerializer(ValidatedModelSerializer): - - class Meta: - model = VirtualChassis - fields = ['id', 'master', 'domain'] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 13f68639f..5ef4b1de7 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -52,7 +52,6 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet): class RegionViewSet(ModelViewSet): queryset = Region.objects.all() serializer_class = serializers.RegionSerializer - write_serializer_class = serializers.WritableRegionSerializer filter_class = filters.RegionFilter @@ -63,7 +62,6 @@ class RegionViewSet(ModelViewSet): class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.select_related('region', 'tenant') serializer_class = serializers.SiteSerializer - write_serializer_class = serializers.WritableSiteSerializer filter_class = filters.SiteFilter @detail_route() @@ -84,7 +82,6 @@ class SiteViewSet(CustomFieldModelViewSet): class RackGroupViewSet(ModelViewSet): queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer - write_serializer_class = serializers.WritableRackGroupSerializer filter_class = filters.RackGroupFilter @@ -105,7 +102,6 @@ class RackRoleViewSet(ModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.select_related('site', 'group__site', 'tenant') serializer_class = serializers.RackSerializer - write_serializer_class = serializers.WritableRackSerializer filter_class = filters.RackFilter @detail_route() @@ -136,7 +132,6 @@ class RackViewSet(CustomFieldModelViewSet): class RackReservationViewSet(ModelViewSet): queryset = RackReservation.objects.select_related('rack', 'user', 'tenant') serializer_class = serializers.RackReservationSerializer - write_serializer_class = serializers.WritableRackReservationSerializer filter_class = filters.RackReservationFilter # Assign user from request @@ -161,7 +156,6 @@ class ManufacturerViewSet(ModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer') serializer_class = serializers.DeviceTypeSerializer - write_serializer_class = serializers.WritableDeviceTypeSerializer filter_class = filters.DeviceTypeFilter @@ -172,42 +166,36 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): class ConsolePortTemplateViewSet(ModelViewSet): queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.ConsolePortTemplateSerializer - write_serializer_class = serializers.WritableConsolePortTemplateSerializer filter_class = filters.ConsolePortTemplateFilter class ConsoleServerPortTemplateViewSet(ModelViewSet): queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.ConsoleServerPortTemplateSerializer - write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer filter_class = filters.ConsoleServerPortTemplateFilter class PowerPortTemplateViewSet(ModelViewSet): queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.PowerPortTemplateSerializer - write_serializer_class = serializers.WritablePowerPortTemplateSerializer filter_class = filters.PowerPortTemplateFilter class PowerOutletTemplateViewSet(ModelViewSet): queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.PowerOutletTemplateSerializer - write_serializer_class = serializers.WritablePowerOutletTemplateSerializer filter_class = filters.PowerOutletTemplateFilter class InterfaceTemplateViewSet(ModelViewSet): queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.InterfaceTemplateSerializer - write_serializer_class = serializers.WritableInterfaceTemplateSerializer filter_class = filters.InterfaceTemplateFilter class DeviceBayTemplateViewSet(ModelViewSet): queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer - write_serializer_class = serializers.WritableDeviceBayTemplateSerializer filter_class = filters.DeviceBayTemplateFilter @@ -228,7 +216,6 @@ class DeviceRoleViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet): queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer - write_serializer_class = serializers.WritablePlatformSerializer filter_class = filters.PlatformFilter @@ -244,7 +231,6 @@ class DeviceViewSet(CustomFieldModelViewSet): 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', ) serializer_class = serializers.DeviceSerializer - write_serializer_class = serializers.WritableDeviceSerializer filter_class = filters.DeviceFilter @detail_route(url_path='napalm') @@ -318,35 +304,30 @@ class DeviceViewSet(CustomFieldModelViewSet): class ConsolePortViewSet(ModelViewSet): queryset = ConsolePort.objects.select_related('device', 'cs_port__device') serializer_class = serializers.ConsolePortSerializer - write_serializer_class = serializers.WritableConsolePortSerializer filter_class = filters.ConsolePortFilter class ConsoleServerPortViewSet(ModelViewSet): queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device') serializer_class = serializers.ConsoleServerPortSerializer - write_serializer_class = serializers.WritableConsoleServerPortSerializer filter_class = filters.ConsoleServerPortFilter class PowerPortViewSet(ModelViewSet): queryset = PowerPort.objects.select_related('device', 'power_outlet__device') serializer_class = serializers.PowerPortSerializer - write_serializer_class = serializers.WritablePowerPortSerializer filter_class = filters.PowerPortFilter class PowerOutletViewSet(ModelViewSet): queryset = PowerOutlet.objects.select_related('device', 'connected_port__device') serializer_class = serializers.PowerOutletSerializer - write_serializer_class = serializers.WritablePowerOutletSerializer filter_class = filters.PowerOutletFilter class InterfaceViewSet(ModelViewSet): queryset = Interface.objects.select_related('device') serializer_class = serializers.InterfaceSerializer - write_serializer_class = serializers.WritableInterfaceSerializer filter_class = filters.InterfaceFilter @detail_route() @@ -363,14 +344,12 @@ class InterfaceViewSet(ModelViewSet): class DeviceBayViewSet(ModelViewSet): queryset = DeviceBay.objects.select_related('installed_device') serializer_class = serializers.DeviceBaySerializer - write_serializer_class = serializers.WritableDeviceBaySerializer filter_class = filters.DeviceBayFilter class InventoryItemViewSet(ModelViewSet): queryset = InventoryItem.objects.select_related('device', 'manufacturer') serializer_class = serializers.InventoryItemSerializer - write_serializer_class = serializers.WritableInventoryItemSerializer filter_class = filters.InventoryItemFilter @@ -393,7 +372,6 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): class InterfaceConnectionViewSet(ModelViewSet): queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device') serializer_class = serializers.InterfaceConnectionSerializer - write_serializer_class = serializers.WritableInterfaceConnectionSerializer filter_class = filters.InterfaceConnectionFilter @@ -404,7 +382,6 @@ class InterfaceConnectionViewSet(ModelViewSet): class VirtualChassisViewSet(ModelViewSet): queryset = VirtualChassis.objects.all() serializer_class = serializers.VirtualChassisSerializer - write_serializer_class = serializers.WritableVirtualChassisSerializer # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index b32d7e7a0..6642be440 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from django.contrib.auth.models import User +from django.test.utils import override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase @@ -2321,6 +2322,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertEqual(interface4.device_id, data['device']) self.assertEqual(interface4.name, data['name']) + @override_settings(DEBUG=True) def test_create_interface_with_802_1q(self): data = { @@ -2368,6 +2370,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data[1]['name'], data[1]['name']) self.assertEqual(response.data[2]['name'], data[2]['name']) + @override_settings(DEBUG=True) def test_create_interface_802_1q_bulk(self): data = [ @@ -2852,9 +2855,9 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(InterfaceConnection.objects.count(), 6) - self.assertEqual(response.data[0]['interface_a'], data[0]['interface_a']) - self.assertEqual(response.data[1]['interface_a'], data[1]['interface_a']) - self.assertEqual(response.data[2]['interface_a'], data[2]['interface_a']) + for i in range(0, 3): + self.assertEqual(response.data[i]['interface_a']['id'], data[i]['interface_a']) + self.assertEqual(response.data[i]['interface_b']['id'], data[i]['interface_b']) def test_update_interfaceconnection(self): @@ -3052,12 +3055,9 @@ class VirtualChassisTest(HttpStatusMixin, APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(VirtualChassis.objects.count(), 5) - self.assertEqual(response.data[0]['master'], data[0]['master']) - self.assertEqual(response.data[0]['domain'], data[0]['domain']) - self.assertEqual(response.data[1]['master'], data[1]['master']) - self.assertEqual(response.data[1]['domain'], data[1]['domain']) - self.assertEqual(response.data[2]['master'], data[2]['master']) - self.assertEqual(response.data[2]['domain'], data[2]['domain']) + for i in range(0, 3): + self.assertEqual(response.data[i]['master']['id'], data[i]['master']) + self.assertEqual(response.data[i]['domain'], data[i]['domain']) def test_update_virtualchassis(self): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 6c3cdd409..8678d42a2 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -15,7 +15,7 @@ from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, Val # Graphs # -class GraphSerializer(serializers.ModelSerializer): +class GraphSerializer(ValidatedModelSerializer): type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) class Meta: @@ -23,13 +23,6 @@ class GraphSerializer(serializers.ModelSerializer): fields = ['id', 'type', 'weight', 'name', 'source', 'link'] -class WritableGraphSerializer(serializers.ModelSerializer): - - class Meta: - model = Graph - fields = ['id', 'type', 'weight', 'name', 'source', 'link'] - - class RenderedGraphSerializer(serializers.ModelSerializer): embed_url = serializers.SerializerMethodField() embed_link = serializers.SerializerMethodField() @@ -50,7 +43,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer): # Export templates # -class ExportTemplateSerializer(serializers.ModelSerializer): +class ExportTemplateSerializer(ValidatedModelSerializer): class Meta: model = ExportTemplate @@ -61,7 +54,7 @@ class ExportTemplateSerializer(serializers.ModelSerializer): # Topology maps # -class TopologyMapSerializer(serializers.ModelSerializer): +class TopologyMapSerializer(ValidatedModelSerializer): site = NestedSiteSerializer() class Meta: @@ -69,23 +62,34 @@ class TopologyMapSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] -class WritableTopologyMapSerializer(serializers.ModelSerializer): - - class Meta: - model = TopologyMap - fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] - - # # Image attachments # -class ImageAttachmentSerializer(serializers.ModelSerializer): - parent = serializers.SerializerMethodField() +class ImageAttachmentSerializer(ValidatedModelSerializer): + content_type = ContentTypeFieldSerializer() + parent = serializers.SerializerMethodField(read_only=True) class Meta: model = ImageAttachment - fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created'] + fields = [ + 'id', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created', + ] + + def validate(self, data): + + # Validate that the parent object exists + try: + data['content_type'].get_object_for_this_type(id=data['object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) + ) + + # Enforce model validation + super(ImageAttachmentSerializer, self).validate(data) + + return data def get_parent(self, obj): @@ -102,29 +106,6 @@ class ImageAttachmentSerializer(serializers.ModelSerializer): return serializer(obj.parent, context={'request': self.context['request']}).data -class WritableImageAttachmentSerializer(ValidatedModelSerializer): - content_type = ContentTypeFieldSerializer() - - class Meta: - model = ImageAttachment - fields = ['id', 'content_type', 'object_id', 'name', 'image'] - - def validate(self, data): - - # Validate that the parent object exists - try: - data['content_type'].get_object_for_this_type(id=data['object_id']) - except ObjectDoesNotExist: - raise serializers.ValidationError( - "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) - ) - - # Enforce model validation - super(WritableImageAttachmentSerializer, self).validate(data) - - return data - - # # Reports # diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 252c2d12c..047abcb44 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -67,7 +67,6 @@ class CustomFieldModelViewSet(ModelViewSet): class GraphViewSet(ModelViewSet): queryset = Graph.objects.all() serializer_class = serializers.GraphSerializer - write_serializer_class = serializers.WritableGraphSerializer filter_class = filters.GraphFilter @@ -88,7 +87,6 @@ class ExportTemplateViewSet(ModelViewSet): class TopologyMapViewSet(ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer - write_serializer_class = serializers.WritableTopologyMapSerializer filter_class = filters.TopologyMapFilter @detail_route() @@ -118,7 +116,6 @@ class TopologyMapViewSet(ModelViewSet): class ImageAttachmentViewSet(ModelViewSet): queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer - write_serializer_class = serializers.WritableImageAttachmentSerializer # diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 454e41c52..3a6e1fb4b 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -4,7 +4,7 @@ from rest_framework import serializers from extras.api.customfields import CustomFieldModelSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import ValidatedModelSerializer +from utilities.api import ValidatedModelSerializer, WritableNestedSerializer # @@ -18,7 +18,7 @@ class TenantGroupSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedTenantGroupSerializer(serializers.ModelSerializer): +class NestedTenantGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') class Meta: @@ -31,23 +31,16 @@ class NestedTenantGroupSerializer(serializers.ModelSerializer): # class TenantSerializer(CustomFieldModelSerializer): - group = NestedTenantGroupSerializer() + group = NestedTenantGroupSerializer(required=False) class Meta: model = Tenant fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated'] -class NestedTenantSerializer(serializers.ModelSerializer): +class NestedTenantSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') class Meta: model = Tenant fields = ['id', 'url', 'name', 'slug'] - - -class WritableTenantSerializer(CustomFieldModelSerializer): - - class Meta: - model = Tenant - fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated'] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 26f9bc71e..1ebd95500 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -32,5 +32,4 @@ class TenantGroupViewSet(ModelViewSet): class TenantViewSet(CustomFieldModelViewSet): queryset = Tenant.objects.select_related('group') serializer_class = serializers.TenantSerializer - write_serializer_class = serializers.WritableTenantSerializer filter_class = filters.TenantFilter diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 80f79516c..861bdade9 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,10 +1,11 @@ from __future__ import unicode_literals from django.contrib.auth.models import User -from rest_framework import serializers + +from utilities.api import WritableNestedSerializer -class NestedUserSerializer(serializers.ModelSerializer): +class NestedUserSerializer(WritableNestedSerializer): class Meta: model = User From 821fb1e01e040e646f1980ff0abfa8c3160e0ae9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 5 Apr 2018 14:12:43 -0400 Subject: [PATCH 007/159] Finished merging writable serializers --- netbox/circuits/api/serializers.py | 4 +- netbox/dcim/api/serializers.py | 56 +++----- netbox/ipam/api/serializers.py | 167 +++++++---------------- netbox/ipam/api/views.py | 15 +- netbox/secrets/api/serializers.py | 15 +- netbox/secrets/api/views.py | 1 - netbox/utilities/api.py | 12 +- netbox/virtualization/api/serializers.py | 62 +++------ netbox/virtualization/api/views.py | 3 - 9 files changed, 105 insertions(+), 230 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index af56aef47..ded67c934 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -59,7 +59,7 @@ class CircuitSerializer(CustomFieldModelSerializer): provider = NestedProviderSerializer() status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False) type = NestedCircuitTypeSerializer() - tenant = NestedTenantSerializer(required=False) + tenant = NestedTenantSerializer(required=False, allow_null=True) class Meta: model = Circuit @@ -84,7 +84,7 @@ class NestedCircuitSerializer(WritableNestedSerializer): class CircuitTerminationSerializer(ValidatedModelSerializer): circuit = NestedCircuitSerializer() site = NestedSiteSerializer() - interface = InterfaceSerializer(required=False) + interface = InterfaceSerializer(required=False, allow_null=True) class Meta: model = CircuitTermination diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f791a83de..7c5191477 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -37,7 +37,7 @@ class NestedRegionSerializer(WritableNestedSerializer): class RegionSerializer(serializers.ModelSerializer): - parent = NestedRegionSerializer(required=False) + parent = NestedRegionSerializer(required=False, allow_null=True) class Meta: model = Region @@ -50,8 +50,8 @@ class RegionSerializer(serializers.ModelSerializer): class SiteSerializer(CustomFieldModelSerializer): status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES, required=False) - region = NestedRegionSerializer(required=False) - tenant = NestedTenantSerializer(required=False) + region = NestedRegionSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) class Meta: @@ -117,9 +117,9 @@ class NestedRackRoleSerializer(WritableNestedSerializer): class RackSerializer(CustomFieldModelSerializer): site = NestedSiteSerializer() - group = NestedRackGroupSerializer(required=False) - tenant = NestedTenantSerializer(required=False) - role = NestedRackRoleSerializer(required=False) + group = NestedRackGroupSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + role = NestedRackRoleSerializer(required=False, allow_null=True) type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False) width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False) @@ -186,7 +186,7 @@ class RackUnitSerializer(serializers.Serializer): class RackReservationSerializer(ValidatedModelSerializer): rack = NestedRackSerializer() user = NestedUserSerializer() - tenant = NestedTenantSerializer(required=False) + tenant = NestedTenantSerializer(required=False, allow_null=True) class Meta: model = RackReservation @@ -337,7 +337,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer): # class PlatformSerializer(ValidatedModelSerializer): - manufacturer = NestedManufacturerSerializer(required=False) + manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) class Meta: model = Platform @@ -387,18 +387,18 @@ class DeviceVirtualChassisSerializer(serializers.ModelSerializer): class DeviceSerializer(CustomFieldModelSerializer): device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() - tenant = NestedTenantSerializer(required=False) - platform = NestedPlatformSerializer(required=False) + tenant = NestedTenantSerializer(required=False, allow_null=True) + platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() - rack = NestedRackSerializer(required=False) + rack = NestedRackSerializer(required=False, allow_null=True) face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES, required=False) status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES, required=False) primary_ip = DeviceIPAddressSerializer(read_only=True) - primary_ip4 = DeviceIPAddressSerializer(required=False) - primary_ip6 = DeviceIPAddressSerializer(required=False) + primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = DeviceIPAddressSerializer(required=False, allow_null=True) parent_device = serializers.SerializerMethodField() - cluster = NestedClusterSerializer(required=False) - virtual_chassis = DeviceVirtualChassisSerializer(required=False) + cluster = NestedClusterSerializer(required=False, allow_null=True) + virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True) class Meta: model = Device @@ -462,7 +462,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer): class ConsolePortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - cs_port = NestedConsoleServerPortSerializer(required=False) + cs_port = NestedConsoleServerPortSerializer(required=False, allow_null=True) class Meta: model = ConsolePort @@ -497,7 +497,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer): class PowerPortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - power_outlet = NestedPowerOutletSerializer(required=False) + power_outlet = NestedPowerOutletSerializer(required=False, allow_null=True) class Meta: model = PowerPort @@ -547,11 +547,11 @@ class InterfaceVLANSerializer(WritableNestedSerializer): class InterfaceSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False) - lag = NestedInterfaceSerializer(required=False) + lag = NestedInterfaceSerializer(required=False, allow_null=True) is_connected = serializers.SerializerMethodField(read_only=True) interface_connection = serializers.SerializerMethodField(read_only=True) - circuit_termination = InterfaceCircuitTerminationSerializer(required=False) - untagged_vlan = InterfaceVLANSerializer(required=False) + circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True) + untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES, required=False) tagged_vlans = InterfaceVLANSerializer(many=True, required=False) @@ -603,27 +603,13 @@ class InterfaceSerializer(ValidatedModelSerializer): return None -# class PeerInterfaceSerializer(serializers.ModelSerializer): -# url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') -# device = NestedDeviceSerializer() -# form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) -# lag = NestedInterfaceSerializer() -# -# class Meta: -# model = Interface -# fields = [ -# 'id', 'url', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', -# 'description', -# ] - - # # Device bays # class DeviceBaySerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - installed_device = NestedDeviceSerializer(required=False) + installed_device = NestedDeviceSerializer(required=False, allow_null=True) class Meta: model = DeviceBay diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 2eca51895..02680bd69 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -14,7 +14,7 @@ from ipam.constants import ( ) from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer from virtualization.api.serializers import NestedVirtualMachineSerializer @@ -23,7 +23,7 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer # class VRFSerializer(CustomFieldModelSerializer): - tenant = NestedTenantSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) class Meta: model = VRF @@ -33,7 +33,7 @@ class VRFSerializer(CustomFieldModelSerializer): ] -class NestedVRFSerializer(serializers.ModelSerializer): +class NestedVRFSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') class Meta: @@ -41,15 +41,6 @@ class NestedVRFSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'rd'] -class WritableVRFSerializer(CustomFieldModelSerializer): - - class Meta: - model = VRF - fields = [ - 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated', - ] - - # # Roles # @@ -61,7 +52,7 @@ class RoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'weight'] -class NestedRoleSerializer(serializers.ModelSerializer): +class NestedRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') class Meta: @@ -80,7 +71,7 @@ class RIRSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'is_private'] -class NestedRIRSerializer(serializers.ModelSerializer): +class NestedRIRSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') class Meta: @@ -100,9 +91,10 @@ class AggregateSerializer(CustomFieldModelSerializer): fields = [ 'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated', ] + read_only_fields = ['family'] -class NestedAggregateSerializer(serializers.ModelSerializer): +class NestedAggregateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') class Meta(AggregateSerializer.Meta): @@ -110,34 +102,12 @@ class NestedAggregateSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'prefix'] -class WritableAggregateSerializer(CustomFieldModelSerializer): - - class Meta: - model = Aggregate - fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated'] - - # # VLAN groups # -class VLANGroupSerializer(serializers.ModelSerializer): - site = NestedSiteSerializer() - - class Meta: - model = VLANGroup - fields = ['id', 'name', 'slug', 'site'] - - -class NestedVLANGroupSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') - - class Meta: - model = VLANGroup - fields = ['id', 'url', 'name', 'slug'] - - -class WritableVLANGroupSerializer(serializers.ModelSerializer): +class VLANGroupSerializer(ValidatedModelSerializer): + site = NestedSiteSerializer(required=False, allow_null=True) class Meta: model = VLANGroup @@ -154,21 +124,29 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer): validator(data) # Enforce model validation - super(WritableVLANGroupSerializer, self).validate(data) + super(VLANGroupSerializer, self).validate(data) return data +class NestedVLANGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') + + class Meta: + model = VLANGroup + fields = ['id', 'url', 'name', 'slug'] + + # # VLANs # class VLANSerializer(CustomFieldModelSerializer): - site = NestedSiteSerializer() - group = NestedVLANGroupSerializer() - tenant = NestedTenantSerializer() - status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES) - role = NestedRoleSerializer() + site = NestedSiteSerializer(required=False, allow_null=True) + group = NestedVLANGroupSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False) + role = NestedRoleSerializer(required=False, allow_null=True) class Meta: model = VLAN @@ -176,24 +154,6 @@ class VLANSerializer(CustomFieldModelSerializer): 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', 'custom_fields', 'created', 'last_updated', ] - - -class NestedVLANSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') - - class Meta: - model = VLAN - fields = ['id', 'url', 'vid', 'name', 'display_name'] - - -class WritableVLANSerializer(CustomFieldModelSerializer): - - class Meta: - model = VLAN - fields = [ - 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created', - 'last_updated', - ] validators = [] def validate(self, data): @@ -206,22 +166,30 @@ class WritableVLANSerializer(CustomFieldModelSerializer): validator(data) # Enforce model validation - super(WritableVLANSerializer, self).validate(data) + super(VLANSerializer, self).validate(data) return data +class NestedVLANSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') + + class Meta: + model = VLAN + fields = ['id', 'url', 'vid', 'name', 'display_name'] + + # # Prefixes # class PrefixSerializer(CustomFieldModelSerializer): - site = NestedSiteSerializer() - vrf = NestedVRFSerializer() - tenant = NestedTenantSerializer() - vlan = NestedVLANSerializer() - status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES) - role = NestedRoleSerializer() + site = NestedSiteSerializer(required=False, allow_null=True) + vrf = NestedVRFSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + vlan = NestedVLANSerializer(required=False, allow_null=True) + status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False) + role = NestedRoleSerializer(required=False, allow_null=True) class Meta: model = Prefix @@ -229,9 +197,10 @@ class PrefixSerializer(CustomFieldModelSerializer): 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', 'custom_fields', 'created', 'last_updated', ] + read_only_fields = ['family'] -class NestedPrefixSerializer(serializers.ModelSerializer): +class NestedPrefixSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') class Meta: @@ -239,16 +208,6 @@ class NestedPrefixSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'prefix'] -class WritablePrefixSerializer(CustomFieldModelSerializer): - - class Meta: - model = Prefix - fields = [ - 'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - 'custom_fields', 'created', 'last_updated', - ] - - class AvailablePrefixSerializer(serializers.Serializer): def to_representation(self, instance): @@ -288,11 +247,11 @@ class IPAddressInterfaceSerializer(serializers.ModelSerializer): class IPAddressSerializer(CustomFieldModelSerializer): - vrf = NestedVRFSerializer() - tenant = NestedTenantSerializer() - status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES) - role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES) - interface = IPAddressInterfaceSerializer() + vrf = NestedVRFSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False) + role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False) + interface = IPAddressInterfaceSerializer(required=False, allow_null=True) class Meta: model = IPAddress @@ -300,9 +259,10 @@ class IPAddressSerializer(CustomFieldModelSerializer): 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', 'nat_outside', 'custom_fields', 'created', 'last_updated', ] + read_only_fields = ['family'] -class NestedIPAddressSerializer(serializers.ModelSerializer): +class NestedIPAddressSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') class Meta: @@ -310,18 +270,8 @@ class NestedIPAddressSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'address'] -IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer() -IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer() - - -class WritableIPAddressSerializer(CustomFieldModelSerializer): - - class Meta: - model = IPAddress - fields = [ - 'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', - 'custom_fields', 'created', 'last_updated', - ] +IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer(required=False, allow_null=True) +IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer(read_only=True) class AvailableIPSerializer(serializers.Serializer): @@ -342,22 +292,11 @@ class AvailableIPSerializer(serializers.Serializer): # Services # -class ServiceSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer() - virtual_machine = NestedVirtualMachineSerializer() +class ServiceSerializer(ValidatedModelSerializer): + device = NestedDeviceSerializer(required=False, allow_null=True) + virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) - ipaddresses = NestedIPAddressSerializer(many=True) - - class Meta: - model = Service - fields = [ - 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created', - 'last_updated', - ] - - -# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError. -class WritableServiceSerializer(serializers.ModelSerializer): + ipaddresses = NestedIPAddressSerializer(many=True, required=False) class Meta: model = Service diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f6a55b618..abbe6e2b1 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -35,7 +35,6 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet): class VRFViewSet(CustomFieldModelViewSet): queryset = VRF.objects.select_related('tenant') serializer_class = serializers.VRFSerializer - write_serializer_class = serializers.WritableVRFSerializer filter_class = filters.VRFFilter @@ -56,7 +55,6 @@ class RIRViewSet(ModelViewSet): class AggregateViewSet(CustomFieldModelViewSet): queryset = Aggregate.objects.select_related('rir') serializer_class = serializers.AggregateSerializer - write_serializer_class = serializers.WritableAggregateSerializer filter_class = filters.AggregateFilter @@ -77,7 +75,6 @@ class RoleViewSet(ModelViewSet): class PrefixViewSet(CustomFieldModelViewSet): queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') serializer_class = serializers.PrefixSerializer - write_serializer_class = serializers.WritablePrefixSerializer filter_class = filters.PrefixFilter @detail_route(url_path='available-prefixes', methods=['get', 'post']) @@ -120,9 +117,9 @@ class PrefixViewSet(CustomFieldModelViewSet): # Initialize the serializer with a list or a single object depending on what was requested if isinstance(request.data, list): - serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True) + serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True) else: - serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0]) + serializer = serializers.PrefixSerializer(data=requested_prefixes[0]) # Create the new Prefix(es) if serializer.is_valid(): @@ -177,9 +174,9 @@ class PrefixViewSet(CustomFieldModelViewSet): # Initialize the serializer with a list or a single object depending on what was requested if isinstance(request.data, list): - serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True) + serializer = serializers.IPAddressSerializer(data=requested_ips, many=True) else: - serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0]) + serializer = serializers.IPAddressSerializer(data=requested_ips[0]) # Create the new IP address(es) if serializer.is_valid(): @@ -223,7 +220,6 @@ class IPAddressViewSet(CustomFieldModelViewSet): 'nat_outside' ) serializer_class = serializers.IPAddressSerializer - write_serializer_class = serializers.WritableIPAddressSerializer filter_class = filters.IPAddressFilter @@ -234,7 +230,6 @@ class IPAddressViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(ModelViewSet): queryset = VLANGroup.objects.select_related('site') serializer_class = serializers.VLANGroupSerializer - write_serializer_class = serializers.WritableVLANGroupSerializer filter_class = filters.VLANGroupFilter @@ -245,7 +240,6 @@ class VLANGroupViewSet(ModelViewSet): class VLANViewSet(CustomFieldModelViewSet): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') serializer_class = serializers.VLANSerializer - write_serializer_class = serializers.WritableVLANSerializer filter_class = filters.VLANFilter @@ -256,5 +250,4 @@ class VLANViewSet(CustomFieldModelViewSet): class ServiceViewSet(ModelViewSet): queryset = Service.objects.select_related('device') serializer_class = serializers.ServiceSerializer - write_serializer_class = serializers.WritableServiceSerializer filter_class = filters.ServiceFilter diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index a4e61a018..aca91920a 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -5,7 +5,7 @@ from rest_framework.validators import UniqueTogetherValidator from dcim.api.serializers import NestedDeviceSerializer from secrets.models import Secret, SecretRole -from utilities.api import ValidatedModelSerializer +from utilities.api import ValidatedModelSerializer, WritableNestedSerializer # @@ -19,7 +19,7 @@ class SecretRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedSecretRoleSerializer(serializers.ModelSerializer): +class NestedSecretRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') class Meta: @@ -31,16 +31,9 @@ class NestedSecretRoleSerializer(serializers.ModelSerializer): # Secrets # -class SecretSerializer(serializers.ModelSerializer): +class SecretSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() role = NestedSecretRoleSerializer() - - class Meta: - model = Secret - fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated'] - - -class WritableSecretSerializer(serializers.ModelSerializer): plaintext = serializers.CharField() class Meta: @@ -64,6 +57,6 @@ class WritableSecretSerializer(serializers.ModelSerializer): validator(data) # Enforce model validation - super(WritableSecretSerializer, self).validate(data) + super(SecretSerializer, self).validate(data) return data diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 807a87b42..9bc52f9f0 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -51,7 +51,6 @@ class SecretViewSet(ModelViewSet): 'role__users', 'role__groups', ) serializer_class = serializers.SecretSerializer - write_serializer_class = serializers.WritableSecretSerializer filter_class = filters.SecretFilter master_key = None diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 3f01da7a9..63ce23db1 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -131,6 +131,8 @@ class WritableNestedSerializer(ModelSerializer): Returns a nested representation of an object on read, but accepts only a primary key on write. """ def to_internal_value(self, data): + if data is None: + return None try: return self.Meta.model.objects.get(pk=data) except ObjectDoesNotExist: @@ -148,16 +150,8 @@ class ModelViewSet(mixins.CreateModelMixin, mixins.ListModelMixin, GenericViewSet): """ - Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality: - 1. Use an alternate serializer (if provided) for write operations - 2. Accept either a single object or a list of objects to create + Accept either a single object or a list of objects to create. """ - def get_serializer_class(self): - # Check for a different serializer to use for write operations - if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'): - return self.write_serializer_class - return self.serializer_class - def get_serializer(self, *args, **kwargs): # If a list of objects has been provided, initialize the serializer with many=True if isinstance(kwargs.get('data', {}), list): diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 7e2ec1690..267526fe0 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -8,7 +8,7 @@ from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer from virtualization.constants import VM_STATUS_CHOICES from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -24,7 +24,7 @@ class ClusterTypeSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedClusterTypeSerializer(serializers.ModelSerializer): +class NestedClusterTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') class Meta: @@ -43,7 +43,7 @@ class ClusterGroupSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedClusterGroupSerializer(serializers.ModelSerializer): +class NestedClusterGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') class Meta: @@ -57,15 +57,15 @@ class NestedClusterGroupSerializer(serializers.ModelSerializer): class ClusterSerializer(CustomFieldModelSerializer): type = NestedClusterTypeSerializer() - group = NestedClusterGroupSerializer() - site = NestedSiteSerializer() + group = NestedClusterGroupSerializer(required=False, allow_null=True) + site = NestedSiteSerializer(required=False, allow_null=True) class Meta: model = Cluster fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated'] -class NestedClusterSerializer(serializers.ModelSerializer): +class NestedClusterSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') class Meta: @@ -73,13 +73,6 @@ class NestedClusterSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name'] -class WritableClusterSerializer(CustomFieldModelSerializer): - - class Meta: - model = Cluster - fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated'] - - # # Virtual machines # @@ -94,14 +87,14 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer): class VirtualMachineSerializer(CustomFieldModelSerializer): - status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES) - cluster = NestedClusterSerializer() - role = NestedDeviceRoleSerializer() - tenant = NestedTenantSerializer() - platform = NestedPlatformSerializer() - primary_ip = VirtualMachineIPAddressSerializer() - primary_ip4 = VirtualMachineIPAddressSerializer() - primary_ip6 = VirtualMachineIPAddressSerializer() + status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES, required=False) + cluster = NestedClusterSerializer(required=False, allow_null=True) + role = NestedDeviceRoleSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + platform = NestedPlatformSerializer(required=False, allow_null=True) + primary_ip = VirtualMachineIPAddressSerializer(read_only=True) + primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) class Meta: model = VirtualMachine @@ -111,7 +104,7 @@ class VirtualMachineSerializer(CustomFieldModelSerializer): ] -class NestedVirtualMachineSerializer(serializers.ModelSerializer): +class NestedVirtualMachineSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') class Meta: @@ -119,22 +112,13 @@ class NestedVirtualMachineSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name'] -class WritableVirtualMachineSerializer(CustomFieldModelSerializer): - - class Meta: - model = VirtualMachine - fields = [ - 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', - 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated', - ] - - # # VM interfaces # -class InterfaceSerializer(serializers.ModelSerializer): +class InterfaceSerializer(ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() + form_factor = serializers.IntegerField(default=IFACE_FF_VIRTUAL) class Meta: model = Interface @@ -143,19 +127,9 @@ class InterfaceSerializer(serializers.ModelSerializer): ] -class NestedInterfaceSerializer(serializers.ModelSerializer): +class NestedInterfaceSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail') class Meta: model = Interface fields = ['id', 'url', 'name'] - - -class WritableInterfaceSerializer(ValidatedModelSerializer): - form_factor = serializers.IntegerField(default=IFACE_FF_VIRTUAL) - - class Meta: - model = Interface - fields = [ - 'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', - ] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 149bb3145..fae8b9232 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -37,7 +37,6 @@ class ClusterGroupViewSet(ModelViewSet): class ClusterViewSet(CustomFieldModelViewSet): queryset = Cluster.objects.select_related('type', 'group') serializer_class = serializers.ClusterSerializer - write_serializer_class = serializers.WritableClusterSerializer filter_class = filters.ClusterFilter @@ -48,12 +47,10 @@ class ClusterViewSet(CustomFieldModelViewSet): class VirtualMachineViewSet(CustomFieldModelViewSet): queryset = VirtualMachine.objects.all() serializer_class = serializers.VirtualMachineSerializer - write_serializer_class = serializers.WritableVirtualMachineSerializer filter_class = filters.VirtualMachineFilter class InterfaceViewSet(ModelViewSet): queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine') serializer_class = serializers.InterfaceSerializer - write_serializer_class = serializers.WritableInterfaceSerializer filter_class = filters.InterfaceFilter From c72d70d114419d33941407fee97c9a0606da17d6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 5 Apr 2018 16:26:29 -0400 Subject: [PATCH 008/159] Removed nested serializers for ManyToMany relationships temporarily --- netbox/dcim/api/serializers.py | 1 - netbox/dcim/tests/test_api.py | 13 ++++--------- netbox/ipam/api/serializers.py | 1 - 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7c5191477..249379f4f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -553,7 +553,6 @@ class InterfaceSerializer(ValidatedModelSerializer): circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True) untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES, required=False) - tagged_vlans = InterfaceVLANSerializer(many=True, required=False) class Meta: model = Interface diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 6642be440..069445774 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2402,15 +2402,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 6) - self.assertEqual(response.data[0]['name'], data[0]['name']) - self.assertEqual(response.data[1]['name'], data[1]['name']) - self.assertEqual(response.data[2]['name'], data[2]['name']) - self.assertEqual(len(response.data[0]['tagged_vlans']), 1) - self.assertEqual(len(response.data[1]['tagged_vlans']), 1) - self.assertEqual(len(response.data[2]['tagged_vlans']), 1) - self.assertEqual(response.data[0]['untagged_vlan'], self.vlan2.id) - self.assertEqual(response.data[1]['untagged_vlan'], self.vlan2.id) - self.assertEqual(response.data[2]['untagged_vlan'], self.vlan2.id) + for i in range(0, 3): + self.assertEqual(response.data[i]['name'], data[i]['name']) + self.assertEqual(response.data[i]['tagged_vlans'], data[i]['tagged_vlans']) + self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan']) def test_update_interface(self): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 02680bd69..a60c7321a 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -296,7 +296,6 @@ class ServiceSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) - ipaddresses = NestedIPAddressSerializer(many=True, required=False) class Meta: model = Service From 9de1a8c36311738b4463355766ddb4cf12e0e31a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Apr 2018 12:42:25 -0400 Subject: [PATCH 009/159] Introduced SerializedPKRelatedField to represent serialized ManyToManyFields --- netbox/dcim/api/serializers.py | 12 ++++++++++-- netbox/dcim/tests/test_api.py | 22 +++++++++------------- netbox/ipam/api/serializers.py | 8 +++++++- netbox/utilities/api.py | 16 +++++++++++++++- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 249379f4f..45689a397 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -20,7 +20,9 @@ from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from users.api.serializers import NestedUserSerializer -from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ( + ChoiceFieldSerializer, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, WritableNestedSerializer, +) from virtualization.models import Cluster @@ -551,8 +553,14 @@ class InterfaceSerializer(ValidatedModelSerializer): is_connected = serializers.SerializerMethodField(read_only=True) interface_connection = serializers.SerializerMethodField(read_only=True) circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True) - untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES, required=False) + untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) + tagged_vlans = SerializedPKRelatedField( + queryset=VLAN.objects.all(), + serializer=InterfaceVLANSerializer, + required=False, + many=True + ) class Meta: model = Interface diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 069445774..6614f8068 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals from django.contrib.auth.models import User -from django.test.utils import override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase @@ -2322,15 +2321,14 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertEqual(interface4.device_id, data['device']) self.assertEqual(interface4.name, data['name']) - @override_settings(DEBUG=True) def test_create_interface_with_802_1q(self): data = { 'device': self.device.pk, 'name': 'Test Interface 4', 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan3.id, 'tagged_vlans': [self.vlan1.id, self.vlan2.id], - 'untagged_vlan': self.vlan3.id } url = reverse('dcim-api:interface-list') @@ -2338,11 +2336,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 4) - interface5 = Interface.objects.get(pk=response.data['id']) - self.assertEqual(interface5.device_id, data['device']) - self.assertEqual(interface5.name, data['name']) - self.assertEqual(interface5.tagged_vlans.count(), 2) - self.assertEqual(interface5.untagged_vlan.id, data['untagged_vlan']) + self.assertEqual(response.data['device']['id'], data['device']) + self.assertEqual(response.data['name'], data['name']) + self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan']) + self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans']) def test_create_interface_bulk(self): @@ -2370,7 +2367,6 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data[1]['name'], data[1]['name']) self.assertEqual(response.data[2]['name'], data[2]['name']) - @override_settings(DEBUG=True) def test_create_interface_802_1q_bulk(self): data = [ @@ -2378,22 +2374,22 @@ class InterfaceTest(HttpStatusMixin, APITestCase): 'device': self.device.pk, 'name': 'Test Interface 4', 'mode': IFACE_MODE_TAGGED, - 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], }, { 'device': self.device.pk, 'name': 'Test Interface 5', 'mode': IFACE_MODE_TAGGED, - 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], }, { 'device': self.device.pk, 'name': 'Test Interface 6', 'mode': IFACE_MODE_TAGGED, - 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], }, ] @@ -2404,7 +2400,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertEqual(Interface.objects.count(), 6) for i in range(0, 3): self.assertEqual(response.data[i]['name'], data[i]['name']) - self.assertEqual(response.data[i]['tagged_vlans'], data[i]['tagged_vlans']) + self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans']) self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan']) def test_update_interface(self): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index a60c7321a..6fb9d3ba4 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -14,7 +14,7 @@ from ipam.constants import ( ) from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ChoiceFieldSerializer, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer from virtualization.api.serializers import NestedVirtualMachineSerializer @@ -296,6 +296,12 @@ class ServiceSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) + ipaddresses = SerializedPKRelatedField( + queryset=IPAddress.objects.all(), + serializer=NestedIPAddressSerializer, + required=False, + many=True + ) class Meta: model = Service diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 63ce23db1..40d111269 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -11,6 +11,7 @@ from django.http import Http404 from rest_framework import mixins from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission +from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.viewsets import GenericViewSet, ViewSet @@ -82,7 +83,6 @@ class TimeZoneField(Field): """ Represent a pytz time zone. """ - def to_representation(self, obj): return obj.zone if obj else None @@ -95,6 +95,20 @@ class TimeZoneField(Field): raise ValidationError('Invalid time zone "{}"'.format(data)) +class SerializedPKRelatedField(PrimaryKeyRelatedField): + """ + Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related + objects in a ManyToManyField while still allowing a set of primary keys to be written. + """ + def __init__(self, serializer, **kwargs): + self.serializer = serializer + self.pk_field = kwargs.pop('pk_field', None) + super(SerializedPKRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + return self.serializer(value, context={'request': self.context['request']}).data + + # # Serializers # From aeaa47e91df5d287d02bea2a33e9ee4d74b5b56b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Apr 2018 14:40:16 -0400 Subject: [PATCH 010/159] Avoid a bug in DRF v3.8.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 288830b74..1f8aca440 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ django-filter>=1.1.0 django-mptt>=0.9.0 django-tables2>=1.19.0 django-timezone-field>=2.0 -djangorestframework>=3.7.7 +djangorestframework>=3.7.7,<3.8.2 drf-yasg[validation]>=1.4.4 graphviz>=0.8.2 Markdown>=2.6.11 From b0dafcf50f66dac20d3f13cc15a8d80fd3ab3113 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 8 May 2018 16:28:26 -0400 Subject: [PATCH 011/159] Initial work on implementing django-taggit for #132 --- netbox/dcim/api/serializers.py | 18 ++++++++++++------ netbox/dcim/forms.py | 18 +++++++++++++----- netbox/dcim/models.py | 6 ++++++ netbox/netbox/settings.py | 1 + netbox/templates/dcim/device.html | 4 ++++ netbox/templates/dcim/device_edit.html | 1 + netbox/templates/dcim/devicetype.html | 4 ++++ netbox/templates/dcim/devicetype_edit.html | 1 + netbox/templates/dcim/rack.html | 4 ++++ netbox/templates/dcim/rack_edit.html | 1 + netbox/templates/dcim/site.html | 4 ++++ netbox/templates/dcim/site_edit.html | 1 + netbox/utilities/api.py | 17 ++++++++++++++++- 13 files changed, 68 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 45689a397..4c3e81861 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -4,6 +4,7 @@ from collections import OrderedDict from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator +from taggit.models import Tag from circuits.models import Circuit, CircuitTermination from dcim.constants import ( @@ -21,7 +22,8 @@ from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from users.api.serializers import NestedUserSerializer from utilities.api import ( - ChoiceFieldSerializer, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, WritableNestedSerializer, + ChoiceFieldSerializer, SerializedPKRelatedField, TagField, TimeZoneField, ValidatedModelSerializer, + WritableNestedSerializer, ) from virtualization.models import Cluster @@ -55,14 +57,15 @@ class SiteSerializer(CustomFieldModelSerializer): region = NestedRegionSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Site fields = [ 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', - 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', - 'count_circuits', + 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', + 'count_devices', 'count_circuits', ] @@ -124,12 +127,13 @@ class RackSerializer(CustomFieldModelSerializer): role = NestedRackRoleSerializer(required=False, allow_null=True) type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False) width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Rack fields = [ 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', + 'u_height', 'desc_units', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This # prevents facility_id from being interpreted as a required field. @@ -223,12 +227,13 @@ class DeviceTypeSerializer(CustomFieldModelSerializer): interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES, required=False) subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False) instance_count = serializers.IntegerField(source='instances.count', read_only=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = DeviceType fields = [ 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', - 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'tags', 'custom_fields', 'instance_count', ] @@ -401,13 +406,14 @@ class DeviceSerializer(CustomFieldModelSerializer): parent_device = serializers.SerializerMethodField() cluster = NestedClusterSerializer(required=False, allow_null=True) virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Device fields = [ 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 05dc0ea6f..1d836028e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import User from django.contrib.postgres.forms.array import SimpleArrayField from django.db.models import Count, Q from mptt.forms import TreeNodeChoiceField +from taggit.forms import TagField from timezone_field import TimeZoneFormField from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm @@ -108,12 +109,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) slug = SlugField() comments = CommentField() + tags = TagField(required=False) class Meta: model = Site fields = [ 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'tags', ] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), @@ -274,12 +277,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): ) ) comments = CommentField() + tags = TagField(required=False) class Meta: model = Rack fields = [ 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width', - 'u_height', 'desc_units', 'comments', + 'u_height', 'desc_units', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -485,11 +489,14 @@ class ManufacturerCSVForm(forms.ModelForm): class DeviceTypeForm(BootstrapMixin, CustomFieldForm): slug = SlugField(slug_source='model') + tags = TagField(required=False) class Meta: model = DeviceType - fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', - 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments'] + fields = [ + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', + 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', 'tags', + ] labels = { 'interface_ordering': 'Order interfaces by', } @@ -772,12 +779,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): ) ) comments = CommentField() + tags = TagField(required=False) class Meta: model = Device fields = [ - 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', - 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', + 'name', 'device_role', 'tags', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', + 'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', ] help_texts = { 'device_role': "The function this device serves", diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 8c47c7ba6..27d752352 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -14,6 +14,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from mptt.models import MPTTModel, TreeForeignKey +from taggit.managers import TaggableManager from timezone_field import TimeZoneField from circuits.models import Circuit @@ -161,6 +162,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): ) objects = SiteManager() + tags = TaggableManager() csv_headers = [ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', @@ -388,6 +390,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): ) objects = RackManager() + tags = TaggableManager() csv_headers = [ 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', @@ -746,6 +749,8 @@ class DeviceType(models.Model, CustomFieldModel): object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', @@ -1231,6 +1236,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): ) objects = DeviceManager() + tags = TaggableManager() csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7a90506b4..ad313a593 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -133,6 +133,7 @@ INSTALLED_APPS = ( 'django_tables2', 'mptt', 'rest_framework', + 'taggit', 'timezone_field', 'circuits', 'dcim', diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index e2253d4f4..69c408494 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -96,6 +96,10 @@ {% endif %} + + Tags + {{ device.tags.all|join:" " }} + {% if vc_members %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 07206ca27..1b7a8a9a8 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -7,6 +7,7 @@
{% render_field form.name %} {% render_field form.device_role %} + {% render_field form.tags %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 12281734b..f169d9c8c 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -73,6 +73,10 @@ Interface Ordering {{ devicetype.get_interface_ordering_display }} + + Tags + {{ devicetype.tags.all|join:" " }} + Instances {{ devicetype.instances.count }} diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html index d2a107607..2d7a5b132 100644 --- a/netbox/templates/dcim/devicetype_edit.html +++ b/netbox/templates/dcim/devicetype_edit.html @@ -12,6 +12,7 @@ {% render_field form.u_height %} {% render_field form.is_full_depth %} {% render_field form.interface_ordering %} + {% render_field form.tags %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 28a9dfb6f..3acd0ad46 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -114,6 +114,10 @@ {% endif %} + + Tags + {{ rack.tags.all|join:" " }} + Devices diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 4ab129a1d..0e50e5b8b 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -11,6 +11,7 @@ {% render_field form.group %} {% render_field form.role %} {% render_field form.serial %} + {% render_field form.tags %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index b14c2019d..4eaaf08cf 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -133,6 +133,10 @@ {% endif %} + + Tags + {{ site.tags.all|join:" " }} +
diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index 399551434..49a3f7241 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -13,6 +13,7 @@ {% render_field form.asn %} {% render_field form.time_zone %} {% render_field form.description %} + {% render_field form.tags %}
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 40d111269..61be3bc63 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -13,7 +13,7 @@ from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.response import Response -from rest_framework.serializers import Field, ModelSerializer, ValidationError +from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError from rest_framework.viewsets import GenericViewSet, ViewSet WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] @@ -42,6 +42,21 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): # Fields # +class TagField(RelatedField): + """ + Represent a writable list of Tags associated with an object (use with many=True). + """ + + def to_internal_value(self, data): + obj = self.parent.parent.instance + content_type = ContentType.objects.get_for_model(obj) + tag, _ = Tag.objects.get_or_create(content_type=content_type, object_id=obj.pk, name=data) + return tag + + def to_representation(self, value): + return value.name + + class ChoiceFieldSerializer(Field): """ Represent a ChoiceField as {'value': , 'label': }. From 9b3869790d49fa7f9a372dbefeca150520de6728 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 10 May 2018 12:53:11 -0400 Subject: [PATCH 012/159] Implemented tags for all primary models --- netbox/circuits/api/serializers.py | 9 +++-- netbox/circuits/forms.py | 7 ++-- netbox/circuits/models.py | 5 +++ netbox/dcim/forms.py | 4 +-- netbox/ipam/api/serializers.py | 23 +++++++++---- netbox/ipam/forms.py | 19 ++++++++--- netbox/ipam/models.py | 9 +++++ netbox/secrets/api/serializers.py | 6 ++-- netbox/secrets/forms.py | 4 ++- netbox/secrets/models.py | 3 ++ netbox/templates/circuits/circuit.html | 4 +++ netbox/templates/circuits/circuit_edit.html | 6 ++++ netbox/templates/circuits/provider.html | 4 +++ netbox/templates/circuits/provider_edit.html | 6 ++++ netbox/templates/dcim/device_edit.html | 6 ++++ netbox/templates/dcim/devicetype_edit.html | 7 +++- netbox/templates/dcim/rack_edit.html | 7 +++- netbox/templates/dcim/site_edit.html | 7 +++- netbox/templates/ipam/aggregate.html | 4 +++ netbox/templates/ipam/aggregate_edit.html | 6 ++++ netbox/templates/ipam/ipaddress.html | 4 +++ netbox/templates/ipam/ipaddress_edit.html | 6 ++++ netbox/templates/ipam/prefix.html | 4 +++ netbox/templates/ipam/prefix_edit.html | 6 ++++ netbox/templates/ipam/vlan.html | 4 +++ netbox/templates/ipam/vlan_edit.html | 6 ++++ netbox/templates/ipam/vrf.html | 4 +++ netbox/templates/ipam/vrf_edit.html | 6 ++++ netbox/templates/secrets/secret.html | 4 +++ netbox/templates/secrets/secret_edit.html | 6 ++++ netbox/templates/tenancy/tenant.html | 4 +++ netbox/templates/tenancy/tenant_edit.html | 6 ++++ netbox/templates/virtualization/cluster.html | 4 +++ .../virtualization/cluster_edit.html | 34 +++++++++++++++++++ .../virtualization/virtualmachine.html | 4 +++ .../virtualization/virtualmachine_edit.html | 6 ++++ netbox/tenancy/api/serializers.py | 9 +++-- netbox/tenancy/forms.py | 4 ++- netbox/tenancy/models.py | 3 ++ netbox/virtualization/api/serializers.py | 13 ++++--- netbox/virtualization/forms.py | 7 ++-- netbox/virtualization/models.py | 5 +++ netbox/virtualization/views.py | 1 + 43 files changed, 262 insertions(+), 34 deletions(-) create mode 100644 netbox/templates/virtualization/cluster_edit.html diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ded67c934..c42edb5ae 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,13 +1,14 @@ from __future__ import unicode_literals from rest_framework import serializers +from taggit.models import Tag from circuits.constants import CIRCUIT_STATUS_CHOICES from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer # @@ -15,11 +16,12 @@ from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, Writa # class ProviderSerializer(CustomFieldModelSerializer): + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Provider fields = [ - 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -60,12 +62,13 @@ class CircuitSerializer(CustomFieldModelSerializer): status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False) type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Circuit fields = [ 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', - 'comments', 'custom_fields', 'created', 'last_updated', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index bfcfa7187..7207e7648 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from django import forms from django.db.models import Count +from taggit.forms import TagField from dcim.models import Site, Device, Interface, Rack from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm @@ -22,10 +23,11 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider class ProviderForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() + tags = TagField(required=False) class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags'] widgets = { 'noc_contact': SmallTextarea(attrs={'rows': 5}), 'admin_contact': SmallTextarea(attrs={'rows': 5}), @@ -102,12 +104,13 @@ class CircuitTypeCSVForm(forms.ModelForm): class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): comments = CommentField() + tags = TagField(required=False) class Meta: model = Circuit fields = [ 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', - 'comments', + 'comments', 'tags', ] help_texts = { 'cid': "Unique circuit ID", diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 4df845bd8..cb79b35a4 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible +from taggit.managers import TaggableManager from dcim.constants import STATUS_CLASSES from dcim.fields import ASNField @@ -56,6 +57,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] class Meta: @@ -166,6 +169,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', ] diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1d836028e..fe8476d72 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -784,8 +784,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = Device fields = [ - 'name', 'device_role', 'tags', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', - 'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', + 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', + 'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags', ] help_texts = { 'device_role': "The function this device serves", diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 6fb9d3ba4..f7969fbc3 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -5,6 +5,7 @@ from collections import OrderedDict from rest_framework import serializers from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator +from taggit.models import Tag from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer from dcim.models import Interface @@ -14,7 +15,9 @@ from ipam.constants import ( ) from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ( + ChoiceFieldSerializer, SerializedPKRelatedField, TagField, ValidatedModelSerializer, WritableNestedSerializer, +) from virtualization.api.serializers import NestedVirtualMachineSerializer @@ -24,12 +27,13 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer class VRFSerializer(CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = VRF fields = [ - 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created', - 'last_updated', + 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields', + 'created', 'last_updated', ] @@ -85,11 +89,13 @@ class NestedRIRSerializer(WritableNestedSerializer): class AggregateSerializer(CustomFieldModelSerializer): rir = NestedRIRSerializer() + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Aggregate fields = [ - 'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', ] read_only_fields = ['family'] @@ -147,11 +153,12 @@ class VLANSerializer(CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False) role = NestedRoleSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = VLAN fields = [ - 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', + 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name', 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -190,12 +197,13 @@ class PrefixSerializer(CustomFieldModelSerializer): vlan = NestedVLANSerializer(required=False, allow_null=True) status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False) role = NestedRoleSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Prefix fields = [ 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - 'custom_fields', 'created', 'last_updated', + 'tags', 'custom_fields', 'created', 'last_updated', ] read_only_fields = ['family'] @@ -252,12 +260,13 @@ class IPAddressSerializer(CustomFieldModelSerializer): status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False) role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False) interface = IPAddressInterfaceSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = IPAddress fields = [ 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', - 'nat_outside', 'custom_fields', 'created', 'last_updated', + 'nat_outside', 'tags', 'custom_fields', 'created', 'last_updated', ] read_only_fields = ['family'] diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 3353d981f..82ebfe724 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals from django import forms from django.core.exceptions import MultipleObjectsReturned from django.db.models import Count +from taggit.forms import TagField from dcim.models import Site, Rack, Device, Interface from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm @@ -32,10 +33,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)] # class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): + tags = TagField(required=False) class Meta: model = VRF - fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant'] + fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags'] labels = { 'rd': "RD", } @@ -121,10 +123,11 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # class AggregateForm(BootstrapMixin, CustomFieldForm): + tags = TagField(required=False) class Meta: model = Aggregate - fields = ['prefix', 'rir', 'date_added', 'description'] + fields = ['prefix', 'rir', 'date_added', 'description', 'tags'] help_texts = { 'prefix': "IPv4 or IPv6 network", 'rir': "Regional Internet Registry responsible for this prefix", @@ -228,10 +231,14 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name' ) ) + tags = TagField(required=False) class Meta: model = Prefix - fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant'] + fields = [ + 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant', + 'tags', + ] def __init__(self, *args, **kwargs): @@ -455,12 +462,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) ) ) primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM') + tags = TagField(required=False) class Meta: model = IPAddress fields = [ 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site', - 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', + 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags', ] def __init__(self, *args, **kwargs): @@ -780,10 +788,11 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): api_url='/api/ipam/vlan-groups/?site_id={{site}}', ) ) + tags = TagField(required=False) class Meta: model = VLAN - fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant'] + fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags'] help_texts = { 'site': "Leave blank if this VLAN spans multiple sites", 'group': "VLAN group (optional)", diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 2f83bb0f2..65a9cce55 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -10,6 +10,7 @@ from django.db.models import Q from django.db.models.expressions import RawSQL from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible +from taggit.managers import TaggableManager from dcim.models import Interface from extras.models import CustomFieldModel @@ -56,6 +57,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] class Meta: @@ -155,6 +158,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = ['prefix', 'rir', 'date_added', 'description'] class Meta: @@ -325,6 +330,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): ) objects = PrefixQuerySet.as_manager() + tags = TaggableManager() csv_headers = [ 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', @@ -564,6 +570,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): ) objects = IPAddressManager() + tags = TaggableManager() csv_headers = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', @@ -759,6 +766,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] class Meta: diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index aca91920a..0e24281bb 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -2,10 +2,11 @@ from __future__ import unicode_literals from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator +from taggit.models import Tag from dcim.api.serializers import NestedDeviceSerializer from secrets.models import Secret, SecretRole -from utilities.api import ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer # @@ -35,10 +36,11 @@ class SecretSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() role = NestedSecretRoleSerializer() plaintext = serializers.CharField() + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Secret - fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated'] + fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'created', 'last_updated'] validators = [] def validate(self, data): diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 8f8107805..863d1dfde 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -4,6 +4,7 @@ from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from django import forms from django.db.models import Count +from taggit.forms import TagField from dcim.models import Device from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField @@ -70,10 +71,11 @@ class SecretForm(BootstrapMixin, forms.ModelForm): label='Plaintext (verify)', widget=forms.PasswordInput() ) + tags = TagField(required=False) class Meta: model = Secret - fields = ['role', 'name', 'plaintext', 'plaintext2'] + fields = ['role', 'name', 'plaintext', 'plaintext2', 'tags'] def __init__(self, *args, **kwargs): diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index e39d46eef..dcb38db70 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -12,6 +12,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.encoding import force_bytes, python_2_unicode_compatible +from taggit.managers import TaggableManager from utilities.models import CreatedUpdatedModel from .exceptions import InvalidKey @@ -336,6 +337,8 @@ class Secret(CreatedUpdatedModel): editable=False ) + tags = TaggableManager() + plaintext = None csv_headers = ['device', 'role', 'name', 'plaintext'] diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 1133f41f3..34d467c89 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -110,6 +110,10 @@ {% endif %} + + Tags + {{ circuit.tags.all|join:" " }} +
{% with circuit.get_custom_fields as custom_fields %} diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 8503e68f6..06ad65241 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/netbox/templates/circuits/circuit_edit.html @@ -44,6 +44,12 @@ {% render_field form.comments %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} {% block javascript %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 6dcccfd8d..583d1da4d 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -102,6 +102,10 @@ {% endif %} + + Tags + {{ provider.tags.all|join:" " }} + Circuits diff --git a/netbox/templates/circuits/provider_edit.html b/netbox/templates/circuits/provider_edit.html index 4fb3889b1..dfa239e40 100644 --- a/netbox/templates/circuits/provider_edit.html +++ b/netbox/templates/circuits/provider_edit.html @@ -33,4 +33,10 @@ {% render_field form.comments %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 1b7a8a9a8..460d96423 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -84,4 +84,10 @@ {% render_field form.comments %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html index 2d7a5b132..e69077ad9 100644 --- a/netbox/templates/dcim/devicetype_edit.html +++ b/netbox/templates/dcim/devicetype_edit.html @@ -12,7 +12,6 @@ {% render_field form.u_height %} {% render_field form.is_full_depth %} {% render_field form.interface_ordering %} - {% render_field form.tags %}
@@ -38,4 +37,10 @@ {% render_field form.comments %}
+
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 0e50e5b8b..b9526a3ac 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -11,7 +11,6 @@ {% render_field form.group %} {% render_field form.role %} {% render_field form.serial %} - {% render_field form.tags %}
@@ -44,4 +43,10 @@ {% render_field form.comments %}
+
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index 49a3f7241..ad7932642 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -13,7 +13,6 @@ {% render_field form.asn %} {% render_field form.time_zone %} {% render_field form.description %} - {% render_field form.tags %}
@@ -47,4 +46,10 @@ {% render_field form.comments %}
+
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 63731755c..de32e9c00 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -81,6 +81,10 @@ {% endif %} + + Tags + {{ aggregate.tags.all|join:" " }} + diff --git a/netbox/templates/ipam/aggregate_edit.html b/netbox/templates/ipam/aggregate_edit.html index be499a509..3cb83ab54 100644 --- a/netbox/templates/ipam/aggregate_edit.html +++ b/netbox/templates/ipam/aggregate_edit.html @@ -19,4 +19,10 @@ {% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 1509f35cb..c6002eb02 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -133,6 +133,10 @@ {% endif %} + + Tags + {{ ipaddress.tags.all|join:" " }} + {% with ipaddress.get_custom_fields as custom_fields %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index d0dad69ee..72fc02a1e 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -66,6 +66,12 @@ {% render_field form.nat_inside %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 11c5fc405..466fcc92d 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -121,6 +121,10 @@ {% endif %} + + Tags + {{ prefix.tags.all|join:" " }} + Utilization {% utilization_graph prefix.get_utilization %} diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html index 938a75da3..333cf1229 100644 --- a/netbox/templates/ipam/prefix_edit.html +++ b/netbox/templates/ipam/prefix_edit.html @@ -28,6 +28,12 @@ {% render_field form.tenant %}
+
+
Tags
+
+ {% render_field form.tags %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 971c3359f..817f0e6b5 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -80,6 +80,10 @@ N/A {% endif %} + + + Tags + {{ vlan.tags.all|join:" " }}
diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 3bfb7783e..7862d4de9 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -21,6 +21,12 @@ {% render_field form.tenant %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index e041ce73a..51088a0ec 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -77,6 +77,10 @@ N/A {% endif %} + + + Tags + {{ vrf.tags.all|join:" " }}
diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html index 63052129c..95a89a6ca 100644 --- a/netbox/templates/ipam/vrf_edit.html +++ b/netbox/templates/ipam/vrf_edit.html @@ -18,6 +18,12 @@ {% render_field form.tenant %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 66c844ebf..e9e333ee7 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -55,6 +55,10 @@ {% endif %} + + Tags + {{ secret.tags.all|join:" " }} +
diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 920409177..87ee3b426 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -54,6 +54,12 @@ {% render_field form.plaintext2 %} +
+
Tags
+
+ {% render_field form.tags %} +
+
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index d5eb7df98..bf7f5ed67 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -68,6 +68,10 @@ {% endif %} + + Tags + {{ tenant.tags.all|join:" " }} +
{% with tenant.get_custom_fields as custom_fields %} diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html index b2c472a1c..9cc0aa53b 100644 --- a/netbox/templates/tenancy/tenant_edit.html +++ b/netbox/templates/tenancy/tenant_edit.html @@ -26,4 +26,10 @@ {% render_field form.comments %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 08251e2fa..05031dff0 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -76,6 +76,10 @@ {% endif %} + + Tags + {{ cluster.tags.all|join:" " }} + Virtual Machines {{ cluster.virtual_machines.count }} diff --git a/netbox/templates/virtualization/cluster_edit.html b/netbox/templates/virtualization/cluster_edit.html new file mode 100644 index 000000000..93fe197ec --- /dev/null +++ b/netbox/templates/virtualization/cluster_edit.html @@ -0,0 +1,34 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Cluster
+
+ {% render_field form.name %} + {% render_field form.type %} + {% render_field form.group %} + {% render_field form.site %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +
+
Comments
+
+ {% render_field form.comments %} +
+
+
+
Tags
+
+ {% render_field form.tags %} +
+
+{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 944792705..c8d119528 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -121,6 +121,10 @@ {% endif %} + + Tags + {{ vm.tags.all|join:" " }} + {% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %} diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html index 706591ab4..0fa7e07fb 100644 --- a/netbox/templates/virtualization/virtualmachine_edit.html +++ b/netbox/templates/virtualization/virtualmachine_edit.html @@ -54,4 +54,10 @@ {% render_field form.comments %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 3a6e1fb4b..c7b94e7e9 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,10 +1,11 @@ from __future__ import unicode_literals from rest_framework import serializers +from taggit.models import Tag from extras.api.customfields import CustomFieldModelSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer # @@ -32,10 +33,14 @@ class NestedTenantGroupSerializer(WritableNestedSerializer): class TenantSerializer(CustomFieldModelSerializer): group = NestedTenantGroupSerializer(required=False) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Tenant - fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated'] + fields = [ + 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', + ] class NestedTenantSerializer(WritableNestedSerializer): diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 4ea6c57ba..123b2bc24 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from django import forms from django.db.models import Count +from taggit.forms import TagField from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from utilities.forms import ( @@ -40,10 +41,11 @@ class TenantGroupCSVForm(forms.ModelForm): class TenantForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() + tags = TagField(required=False) class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'description', 'comments'] + fields = ['name', 'slug', 'group', 'description', 'comments', 'tags'] class TenantCSVForm(forms.ModelForm): diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 9df714680..f006e512d 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible +from taggit.managers import TaggableManager from extras.models import CustomFieldModel from utilities.models import CreatedUpdatedModel @@ -74,6 +75,8 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = ['name', 'slug', 'group', 'description', 'comments'] class Meta: diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 8cee708ba..15ed39abf 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,14 +1,15 @@ from __future__ import unicode_literals from rest_framework import serializers +from taggit.models import Tag from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer -from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES +from dcim.constants import IFACE_MODE_CHOICES from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer from virtualization.constants import VM_STATUS_CHOICES from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -59,10 +60,13 @@ class ClusterSerializer(CustomFieldModelSerializer): type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Cluster - fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated'] + fields = [ + 'id', 'name', 'type', 'group', 'site', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] class NestedClusterSerializer(WritableNestedSerializer): @@ -95,12 +99,13 @@ class VirtualMachineSerializer(CustomFieldModelSerializer): primary_ip = VirtualMachineIPAddressSerializer(read_only=True) primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = VirtualMachine fields = [ 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'vcpus', 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated', + 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 4dfea1b42..b973ed5cb 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -4,6 +4,7 @@ from django import forms from django.core.exceptions import ValidationError from django.db.models import Count from mptt.forms import TreeNodeChoiceField +from taggit.forms import TagField from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL from dcim.forms import INTERFACE_MODE_HELP_TEXT @@ -78,10 +79,11 @@ class ClusterGroupCSVForm(forms.ModelForm): class ClusterForm(BootstrapMixin, CustomFieldForm): comments = CommentField(widget=SmallTextarea) + tags = TagField(required=False) class Meta: model = Cluster - fields = ['name', 'type', 'group', 'site', 'comments'] + fields = ['name', 'type', 'group', 'site', 'comments', 'tags'] class ClusterCSVForm(forms.ModelForm): @@ -244,12 +246,13 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): api_url='/api/virtualization/clusters/?group_id={{cluster_group}}' ) ) + tags = TagField(required=False) class Meta: model = VirtualMachine fields = [ 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', - 'vcpus', 'memory', 'disk', 'comments', + 'vcpus', 'memory', 'disk', 'comments', 'tags', ] def __init__(self, *args, **kwargs): diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index b58cf93e8..e34512410 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible +from taggit.managers import TaggableManager from dcim.models import Device from extras.models import CustomFieldModel @@ -124,6 +125,8 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = ['name', 'type', 'group', 'site', 'comments'] class Meta: @@ -242,6 +245,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = [ 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ] diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 6de6b86c7..96c57c29b 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -126,6 +126,7 @@ class ClusterView(View): class ClusterCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_cluster' + template_name = 'virtualization/cluster_edit.html' model = Cluster model_form = forms.ClusterForm From 5247f10d7e0924b089187f420f9156e2e1acb350 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 May 2018 10:14:56 -0400 Subject: [PATCH 013/159] Removed redundant tags field --- netbox/templates/dcim/device_edit.html | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 460d96423..d39c01482 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -7,7 +7,6 @@
{% render_field form.name %} {% render_field form.device_role %} - {% render_field form.tags %}
From e6b3983a4e37ab2e2ed96c410cad844b9282c821 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 May 2018 11:09:11 -0400 Subject: [PATCH 014/159] Added template tag for tag links --- netbox/templates/circuits/circuit.html | 8 +++++++- netbox/templates/circuits/provider.html | 8 +++++++- netbox/templates/dcim/device.html | 8 +++++++- netbox/templates/dcim/devicetype.html | 8 +++++++- netbox/templates/dcim/rack.html | 8 +++++++- netbox/templates/dcim/site.html | 8 +++++++- netbox/templates/ipam/aggregate.html | 9 ++++++++- netbox/templates/ipam/ipaddress.html | 9 ++++++++- netbox/templates/ipam/prefix.html | 8 +++++++- netbox/templates/ipam/vlan.html | 9 ++++++++- netbox/templates/ipam/vrf.html | 9 ++++++++- netbox/templates/secrets/secret.html | 9 ++++++++- netbox/templates/tenancy/tenant.html | 8 +++++++- netbox/templates/utilities/templatetags/tag.html | 1 + netbox/templates/virtualization/cluster.html | 8 +++++++- netbox/templates/virtualization/virtualmachine.html | 8 +++++++- netbox/utilities/templatetags/helpers.py | 12 +++++++++++- 17 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 netbox/templates/utilities/templatetags/tag.html diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 34d467c89..509c6da89 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -112,7 +112,13 @@ Tags - {{ circuit.tags.all|join:" " }} + + {% for tag in circuit.tags.all %} + {% tag 'circuits:circuit_list' tag %} + {% empty %} + N/A + {% endfor %} +
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 583d1da4d..e19175c7f 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -104,7 +104,13 @@ Tags - {{ provider.tags.all|join:" " }} + + {% for tag in provider.tags.all %} + {% tag 'circuits:provider_list' tag %} + {% empty %} + N/A + {% endfor %} + Circuits diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 69c408494..1b1d3d23a 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -98,7 +98,13 @@ Tags - {{ device.tags.all|join:" " }} + + {% for tag in device.tags.all %} + {% tag 'dcim:device_list' tag %} + {% empty %} + N/A + {% endfor %} + diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index f169d9c8c..27d2e3694 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -75,7 +75,13 @@ Tags - {{ devicetype.tags.all|join:" " }} + + {% for tag in devicetype.tags.all %} + {% tag 'dcim:devicetype_list' tag %} + {% empty %} + N/A + {% endfor %} + Instances diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 3acd0ad46..82348e6fe 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -116,7 +116,13 @@ Tags - {{ rack.tags.all|join:" " }} + + {% for tag in rack.tags.all %} + {% tag 'dcim:rack_list' tag %} + {% empty %} + N/A + {% endfor %} + Devices diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 4eaaf08cf..a882d77c8 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -135,7 +135,13 @@ Tags - {{ site.tags.all|join:" " }} + + {% for tag in site.tags.all %} + {% tag 'dcim:site_list' tag %} + {% empty %} + N/A + {% endfor %} + diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index de32e9c00..a7711354d 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load helpers %} {% block content %}
@@ -83,7 +84,13 @@ Tags - {{ aggregate.tags.all|join:" " }} + + {% for tag in aggregate.tags.all %} + {% tag 'ipam:aggregate_list' tag %} + {% empty %} + N/A + {% endfor %} +
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index c6002eb02..da0fc6923 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load helpers %} {% block content %}
@@ -135,7 +136,13 @@ Tags - {{ ipaddress.tags.all|join:" " }} + + {% for tag in ipaddress.tags.all %} + {% tag 'ipam:ipaddress_list' tag %} + {% empty %} + N/A + {% endfor %} +
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 466fcc92d..29e9c07a0 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -123,7 +123,13 @@ Tags - {{ prefix.tags.all|join:" " }} + + {% for tag in prefix.tags.all %} + {% tag 'ipam:prefix_list' tag %} + {% empty %} + N/A + {% endfor %} + Utilization diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 817f0e6b5..ac874282f 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load helpers %} {% block content %} {% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %} @@ -83,7 +84,13 @@ Tags - {{ vlan.tags.all|join:" " }} + + {% for tag in vlan.tags.all %} + {% tag 'ipam:vlan_list' tag %} + {% empty %} + N/A + {% endfor %} + diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 51088a0ec..fa51a18f8 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load helpers %} {% block content %}
@@ -80,7 +81,13 @@ Tags - {{ vrf.tags.all|join:" " }} + + {% for tag in vrf.tags.all %} + {% tag 'ipam:vrf_list' tag %} + {% empty %} + N/A + {% endfor %} +
diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index e9e333ee7..4863fdeb1 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -1,5 +1,6 @@ {% extends '_base.html' %} {% load static from staticfiles %} +{% load helpers %} {% load secret_helpers %} {% block content %} @@ -57,7 +58,13 @@ Tags - {{ secret.tags.all|join:" " }} + + {% for tag in secret.tags.all %} + {% tag 'secrets:secret_list' tag %} + {% empty %} + N/A + {% endfor %} + diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index bf7f5ed67..fbbac175a 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -70,7 +70,13 @@ Tags - {{ tenant.tags.all|join:" " }} + + {% for tag in tenant.tags.all %} + {% tag 'tenancy:tenant_list' tag %} + {% empty %} + N/A + {% endfor %} + diff --git a/netbox/templates/utilities/templatetags/tag.html b/netbox/templates/utilities/templatetags/tag.html new file mode 100644 index 000000000..79e1627db --- /dev/null +++ b/netbox/templates/utilities/templatetags/tag.html @@ -0,0 +1 @@ +{{ tag }} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 05031dff0..9b1621530 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -78,7 +78,13 @@ Tags - {{ cluster.tags.all|join:" " }} + + {% for tag in cluster.tags.all %} + {% tag 'virtualization:cluster_list' tag %} + {% empty %} + N/A + {% endfor %} + Virtual Machines diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index c8d119528..430caafca 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -123,7 +123,13 @@ Tags - {{ vm.tags.all|join:" " }} + + {% for tag in vm.tags.all %} + {% tag 'virtualization:vm_list' tag %} + {% empty %} + N/A + {% endfor %} + diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 7d79a5f2a..1380941b3 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals import datetime -import pytz from django import template from django.utils.safestring import mark_safe @@ -160,3 +159,14 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90): 'warning_threshold': warning_threshold, 'danger_threshold': danger_threshold, } + + +@register.inclusion_tag('utilities/templatetags/tag.html') +def tag(url_name, tag): + """ + Display a link to the given object list filtered by a specific Tag slug. + """ + return { + 'url_name': url_name, + 'tag': tag, + } From 01896091379255025cf743279f0056d3fd119e2a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 May 2018 12:35:04 -0400 Subject: [PATCH 015/159] Fixes URL name --- netbox/templates/virtualization/virtualmachine.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 430caafca..3d8d0d05a 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -125,7 +125,7 @@ Tags {% for tag in vm.tags.all %} - {% tag 'virtualization:vm_list' tag %} + {% tag 'virtualization:virtualmachine_list' tag %} {% empty %} N/A {% endfor %} From 1d1553275e92bf00ca2dd4b69b69d86a478bba6b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 May 2018 15:43:21 -0400 Subject: [PATCH 016/159] Added tags panel to object list view --- netbox/templates/circuits/circuit_list.html | 1 + netbox/templates/circuits/provider_list.html | 1 + netbox/templates/dcim/device_list.html | 1 + netbox/templates/dcim/devicetype_list.html | 1 + netbox/templates/dcim/rack_list.html | 1 + netbox/templates/dcim/site_list.html | 1 + netbox/templates/inc/tags_panel.html | 13 +++++++++++++ netbox/templates/ipam/aggregate_list.html | 1 + netbox/templates/ipam/ipaddress_list.html | 1 + netbox/templates/ipam/prefix_list.html | 1 + netbox/templates/ipam/vlan_list.html | 1 + netbox/templates/ipam/vrf_list.html | 1 + netbox/templates/secrets/secret_list.html | 1 + netbox/templates/tenancy/tenant_list.html | 1 + netbox/templates/virtualization/cluster_list.html | 1 + .../virtualization/virtualmachine_list.html | 1 + netbox/utilities/views.py | 9 ++++++++- 17 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 netbox/templates/inc/tags_panel.html diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html index f05552f7d..81e09c32b 100644 --- a/netbox/templates/circuits/circuit_list.html +++ b/netbox/templates/circuits/circuit_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/circuits/provider_list.html b/netbox/templates/circuits/provider_list.html index cb7aab406..a0036f46c 100644 --- a/netbox/templates/circuits/provider_list.html +++ b/netbox/templates/circuits/provider_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index f96b27309..4bae11781 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/dcim/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html index 91745082a..eb901f5a0 100644 --- a/netbox/templates/dcim/devicetype_list.html +++ b/netbox/templates/dcim/devicetype_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html index d5734ee2b..e61f4eadf 100644 --- a/netbox/templates/dcim/rack_list.html +++ b/netbox/templates/dcim/rack_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/dcim/site_list.html b/netbox/templates/dcim/site_list.html index 7baa76dad..50066186d 100644 --- a/netbox/templates/dcim/site_list.html +++ b/netbox/templates/dcim/site_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/inc/tags_panel.html b/netbox/templates/inc/tags_panel.html new file mode 100644 index 000000000..baeee72ac --- /dev/null +++ b/netbox/templates/inc/tags_panel.html @@ -0,0 +1,13 @@ +{% load helpers %} + +
+
+ + Tags +
+
+ {% for tag in tags %} + {{ tag }} {{ tag.count }} + {% endfor %} +
+
diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html index 73da9695d..33db74e5c 100644 --- a/netbox/templates/ipam/aggregate_list.html +++ b/netbox/templates/ipam/aggregate_list.html @@ -17,6 +17,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
Statistics diff --git a/netbox/templates/ipam/ipaddress_list.html b/netbox/templates/ipam/ipaddress_list.html index 5f8fdeb88..418b807bd 100644 --- a/netbox/templates/ipam/ipaddress_list.html +++ b/netbox/templates/ipam/ipaddress_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html index d65904595..3ce9d4a9c 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -21,6 +21,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/ipam/vlan_list.html b/netbox/templates/ipam/vlan_list.html index 24e12595b..d734db8d2 100644 --- a/netbox/templates/ipam/vlan_list.html +++ b/netbox/templates/ipam/vlan_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html index 23bd16495..670f0ee5d 100644 --- a/netbox/templates/ipam/vrf_list.html +++ b/netbox/templates/ipam/vrf_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html index 6dd92cd89..0a70e1087 100644 --- a/netbox/templates/secrets/secret_list.html +++ b/netbox/templates/secrets/secret_list.html @@ -14,6 +14,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html index e6fd61c37..176231507 100644 --- a/netbox/templates/tenancy/tenant_list.html +++ b/netbox/templates/tenancy/tenant_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/virtualization/cluster_list.html b/netbox/templates/virtualization/cluster_list.html index 08f62e6ba..84513dbb1 100644 --- a/netbox/templates/virtualization/cluster_list.html +++ b/netbox/templates/virtualization/cluster_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 30ed76dae..bf2961fd8 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index d060e53d7..769954fea 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -8,7 +8,7 @@ from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError -from django.db.models import ProtectedError +from django.db.models import Count, ProtectedError from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.shortcuts import get_object_or_404, redirect, render from django.template.exceptions import TemplateSyntaxError @@ -119,6 +119,12 @@ class ObjectListView(View): if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.columns.show('pk') + # Construct queryset for tags list + if hasattr(model, 'tags'): + tags = model.tags.annotate(count=Count('taggit_taggeditem_items')) + else: + tags = None + # Apply the request context paginate = { 'klass': EnhancedPaginator, @@ -131,6 +137,7 @@ class ObjectListView(View): 'table': table, 'permissions': permissions, 'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None, + 'tags': tags, } context.update(self.extra_context()) From b3350490e7d6d0f6e47f1db3762509627846d8fe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 May 2018 16:24:15 -0400 Subject: [PATCH 017/159] Implemented tag filtering --- netbox/circuits/filters.py | 6 ++++++ netbox/dcim/filters.py | 12 ++++++++++++ netbox/ipam/filters.py | 15 +++++++++++++++ netbox/secrets/filters.py | 3 +++ netbox/templates/inc/tags_panel.html | 2 +- netbox/tenancy/filters.py | 3 +++ netbox/virtualization/filters.py | 6 ++++++ 7 files changed, 46 insertions(+), 1 deletion(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index ca66be406..79efdc950 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -28,6 +28,9 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Provider @@ -103,6 +106,9 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Circuit diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 0d5455aa0..63091c2a8 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -82,6 +82,9 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Site @@ -179,6 +182,9 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Role (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Rack @@ -286,6 +292,9 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = DeviceType @@ -497,6 +506,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): queryset=VirtualChassis.objects.all(), label='Virtual chassis (ID)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Device diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 005d44a84..db2806b77 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -30,6 +30,9 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) def search(self, queryset, name, value): if not value.strip(): @@ -69,6 +72,9 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='RIR (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Aggregate @@ -167,6 +173,9 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=PREFIX_STATUS_CHOICES, null_value=None ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Prefix @@ -289,6 +298,9 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): role = django_filters.MultipleChoiceFilter( choices=IPADDRESS_ROLE_CHOICES ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = IPAddress @@ -394,6 +406,9 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=VLAN_STATUS_CHOICES, null_value=None ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = VLAN diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 6578eb4b8..2499fa2bb 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -41,6 +41,9 @@ class SecretFilter(django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Secret diff --git a/netbox/templates/inc/tags_panel.html b/netbox/templates/inc/tags_panel.html index baeee72ac..a7923fbed 100644 --- a/netbox/templates/inc/tags_panel.html +++ b/netbox/templates/inc/tags_panel.html @@ -7,7 +7,7 @@
{% for tag in tags %} - {{ tag }} {{ tag.count }} + {{ tag }} {{ tag.count }} {% endfor %}
diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 330ab7f56..7eccff5d3 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -31,6 +31,9 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Group (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Tenant diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 53c3f18d9..6af4e4a22 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -63,6 +63,9 @@ class ClusterFilter(CustomFieldFilterSet): to_field_name='slug', label='Site (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Cluster @@ -154,6 +157,9 @@ class VirtualMachineFilter(CustomFieldFilterSet): to_field_name='slug', label='Platform (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = VirtualMachine From 601fb418b5ca9d30250da0754bf18a3be6941533 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 May 2018 10:51:40 -0400 Subject: [PATCH 018/159] Tweaked ordering of tags list --- netbox/utilities/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 769954fea..5d913a706 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -121,7 +121,7 @@ class ObjectListView(View): # Construct queryset for tags list if hasattr(model, 'tags'): - tags = model.tags.annotate(count=Count('taggit_taggeditem_items')) + tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('-count', 'name') else: tags = None From 918339cfa8a6e8f85f24d17fad7eb152daabc962 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 May 2018 11:19:47 -0400 Subject: [PATCH 019/159] Tweak formatting of message to handle translation strings --- netbox/utilities/views.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 5d913a706..fd085a1e7 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -202,13 +202,16 @@ class ObjectEditView(GetReturnURLMixin, View): obj_created = not form.instance.pk obj = form.save() - msg = 'Created ' if obj_created else 'Modified ' - msg += self.model._meta.verbose_name + msg = '{} {}'.format( + 'Created' if obj_created else 'Modified', + self.model._meta.verbose_name + ) if hasattr(obj, 'get_absolute_url'): msg = '{} {}'.format(msg, obj.get_absolute_url(), escape(obj)) else: msg = '{} {}'.format(msg, escape(obj)) messages.success(request, mark_safe(msg)) + if obj_created: UserAction.objects.log_create(request.user, obj, msg) else: From 03a1c48b54d0bc4e62ef452b0919548f0241edfb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 May 2018 12:22:46 -0400 Subject: [PATCH 020/159] Added list and utility views for tags --- netbox/extras/forms.py | 24 ++++++++++++++- netbox/extras/tables.py | 28 ++++++++++++++++++ netbox/extras/urls.py | 6 ++++ netbox/extras/views.py | 42 +++++++++++++++++++++++++-- netbox/templates/extras/tag_list.html | 11 +++++++ netbox/templates/inc/nav_menu.html | 5 +++- 6 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 netbox/extras/tables.py create mode 100644 netbox/templates/extras/tag_list.html diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index a923ae596..9088d1b3d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -4,12 +4,17 @@ from collections import OrderedDict from django import forms from django.contrib.contenttypes.models import ContentType +from taggit.models import Tag -from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField +from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField, SlugField from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL from .models import CustomField, CustomFieldValue, ImageAttachment +# +# Custom fields +# + def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False): """ Retrieve all CustomFields applicable to the given ContentType @@ -162,6 +167,23 @@ class CustomFieldFilterForm(forms.Form): self.fields[name] = field +# +# Tags +# +# + +class TagForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() + + class Meta: + model = Tag + fields = ['name', 'slug'] + + +# +# Image attachments +# + class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): class Meta: diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py new file mode 100644 index 000000000..921b9f273 --- /dev/null +++ b/netbox/extras/tables.py @@ -0,0 +1,28 @@ +from __future__ import unicode_literals + +import django_tables2 as tables +from taggit.models import Tag + +from utilities.tables import BaseTable, ToggleColumn + +TAG_ACTIONS = """ +{% if perms.taggit.change_tag %} + +{% endif %} +{% if perms.taggit.delete_tag %} + +{% endif %} +""" + + +class TagTable(BaseTable): + pk = ToggleColumn() + actions = tables.TemplateColumn( + template_code=TAG_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = Tag + fields = ('pk', 'name', 'items') diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 13e50a229..d3c200334 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -7,6 +7,12 @@ from extras import views app_name = 'extras' urlpatterns = [ + # Tags + url(r'^tags/$', views.TagListView.as_view(), name='tag_list'), + url(r'^tags/(?P[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'), + url(r'^tags/(?P[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'), + url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + # Image attachments url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 3f7c0435b..130437356 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -2,16 +2,52 @@ from __future__ import unicode_literals from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db.models import Count from django.http import Http404 -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import get_object_or_404, redirect, render, reverse from django.utils.safestring import mark_safe from django.views.generic import View +from taggit.models import Tag from utilities.forms import ConfirmationForm -from utilities.views import ObjectDeleteView, ObjectEditView -from .forms import ImageAttachmentForm +from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView +from .forms import ImageAttachmentForm, TagForm from .models import ImageAttachment, ReportResult, UserAction from .reports import get_report, get_reports +from .tables import TagTable + + +# +# Tags +# + +class TagListView(ObjectListView): + queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name') + table = TagTable + template_name = 'extras/tag_list.html' + + +class TagEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'taggit.change_tag' + model = Tag + model_form = TagForm + + def get_return_url(self, request, obj): + return reverse('extras:tag', kwargs={'slug': obj.slug}) + + +class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'taggit.delete_tag' + model = Tag + default_return_url = 'extras:tag_list' + + +class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'circuits.delete_circuittype' + cls = Tag + queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name') + table = TagTable + default_return_url = 'extras:tag_list' # diff --git a/netbox/templates/extras/tag_list.html b/netbox/templates/extras/tag_list.html new file mode 100644 index 000000000..3136991a0 --- /dev/null +++ b/netbox/templates/extras/tag_list.html @@ -0,0 +1,11 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +

{% block title %}Tags{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %} +
+
+{% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index a85647993..2c47ad85b 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -16,7 +16,7 @@ - {% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %}
{% if perms.dcim.change_devicetype %} @@ -28,8 +27,8 @@ {% endif %}
{% endif %} -

{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}

+{% include 'inc/created_updated.html' with obj=devicetype %}
From 503efe2d9d68d61116b13c448d0e333657cdc033 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 30 May 2018 13:59:00 -0400 Subject: [PATCH 028/159] Miscellaneous cleanup of the Webhook model --- netbox/extras/migrations/0012_webhooks.py | 16 ++++++------- netbox/extras/models.py | 28 ++++++++++++----------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/netbox/extras/migrations/0012_webhooks.py b/netbox/extras/migrations/0012_webhooks.py index 8ccee6ce8..568c13c65 100644 --- a/netbox/extras/migrations/0012_webhooks.py +++ b/netbox/extras/migrations/0012_webhooks.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.10 on 2018-05-23 16:35 +# Generated by Django 1.11.12 on 2018-05-30 17:55 from __future__ import unicode_literals from django.db import migrations, models @@ -18,15 +18,15 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=150, unique=True)), - ('type_create', models.BooleanField(default=False, help_text='A POST will be sent to the URL when the object type(s) is created.')), - ('type_update', models.BooleanField(default=False, help_text='A POST will be sent to the URL when the object type(s) is updated.')), - ('type_delete', models.BooleanField(default=False, help_text='A POST will be sent to the URL when the object type(s) is deleted.')), - ('payload_url', models.CharField(max_length=500, verbose_name='A POST will be sent to this URL based on the webhook criteria.')), + ('type_create', models.BooleanField(default=False, help_text='Call this webhook when a matching object is created.')), + ('type_update', models.BooleanField(default=False, help_text='Call this webhook when a matching object is updated.')), + ('type_delete', models.BooleanField(default=False, help_text='Call this webhook when a matching object is deleted.')), + ('payload_url', models.CharField(help_text='A POST will be sent to this URL when the webhook is called.', max_length=500, verbose_name='URL')), ('http_content_type', models.PositiveSmallIntegerField(choices=[(1, 'application/json'), (2, 'application/x-www-form-urlencoded')], default=1)), - ('secret', models.CharField(blank=True, help_text="When provided the request will include a 'X-Hook-Signature' header which is a HMAC hex digest of the payload body using the secret as the key. The secret is not transmitted in the request.", max_length=255)), + ('secret', models.CharField(blank=True, help_text="When provided, the request will include a 'X-Hook-Signature' header containing a HMAC hex digest of the payload body using the secret as the key. The secret is not transmitted in the request.", max_length=255)), ('enabled', models.BooleanField(default=True)), - ('ssl_verification', models.BooleanField(default=True, help_text='By default, use of proper SSL is verified. Disable with caution!')), - ('obj_type', models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object(s)')), + ('ssl_verification', models.BooleanField(default=True, help_text='Enable SSL certificate verification. Disable with caution!')), + ('obj_type', models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object types')), ], ), migrations.AlterUniqueTogether( diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 1a1e13ec5..865ff9fbb 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -25,16 +25,18 @@ from .constants import * # Webhooks # +@python_2_unicode_compatible class Webhook(models.Model): """ - Webhook model that represents all the details for an endoint and how to make a request to - that endpoint with the configured payload. + 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. + Each Webhook can be limited to firing only on certain actions or certain object types. """ obj_type = models.ManyToManyField( - ContentType, + to=ContentType, related_name='webhooks', - verbose_name='Object(s)', + verbose_name='Object types', limit_choices_to={'model__in': WEBHOOK_MODELS}, help_text="The object(s) to which this Webhook applies." ) @@ -44,19 +46,20 @@ class Webhook(models.Model): ) type_create = models.BooleanField( default=False, - help_text="A POST will be sent to the URL when the object type(s) is created." + help_text="Call this webhook when a matching object is created." ) type_update = models.BooleanField( default=False, - help_text="A POST will be sent to the URL when the object type(s) is updated." + help_text="Call this webhook when a matching object is updated." ) type_delete = models.BooleanField( default=False, - help_text="A POST will be sent to the URL when the object type(s) is deleted." + help_text="Call this webhook when a matching object is deleted." ) payload_url = models.CharField( max_length=500, - verbose_name="A POST will be sent to this URL based on the webhook criteria." + verbose_name='URL', + help_text="A POST will be sent to this URL when the webhook is called." ) http_content_type = models.PositiveSmallIntegerField( choices=WEBHOOK_CT_CHOICES, @@ -65,8 +68,8 @@ class Webhook(models.Model): secret = models.CharField( max_length=255, blank=True, - help_text="When provided the request will include a 'X-Hook-Signature' " - "header which is a HMAC hex digest of the payload body using " + help_text="When provided, the request will include a 'X-Hook-Signature' " + "header containing a HMAC hex digest of the payload body using " "the secret as the key. The secret is not transmitted in " "the request." ) @@ -75,7 +78,7 @@ class Webhook(models.Model): ) ssl_verification = models.BooleanField( default=True, - help_text="By default, use of proper SSL is verified. Disable with caution!" + help_text="Enable SSL certificate verification. Disable with caution!" ) class Meta: @@ -88,10 +91,9 @@ class Webhook(models.Model): """ Validate model """ - if not self.type_create and not self.type_delete and not self.type_update: raise ValidationError( - "You must select at least one type. Either create, update, or delete." + "You must select at least one type: create, update, and/or delete." ) From efa118c3c836adb4a08a18435f05cfb1f836fd90 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 30 May 2018 14:51:59 -0400 Subject: [PATCH 029/159] Tweaked webhooks and Redis settings --- docs/configuration/optional-settings.md | 94 ++++++++++--------- docs/data-model/extras.md | 5 +- .../{webhook-backend.md => webhooks.md} | 2 +- netbox/extras/__init__.py | 2 +- netbox/extras/apps.py | 26 +++-- netbox/extras/webhooks.py | 8 +- netbox/netbox/configuration.example.py | 13 ++- netbox/netbox/settings.py | 26 +++-- netbox/netbox/urls.py | 2 +- 9 files changed, 95 insertions(+), 83 deletions(-) rename docs/miscellaneous/{webhook-backend.md => webhooks.md} (97%) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 58672b68c..600927394 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -207,50 +207,6 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv --- -## Redis Connection Settings - -The following settings are defined in the `REDIS` dictionary, much like the regular database settings. - -## DB - -Default: 0 - -When `WEBHOOK_BACKEND_ENABLED` is `True` connect to the redis database with this ID. This is used in conjunction with the webhook backend. See the docs section on the webhook backend [here](../miscellaneous/webhook-backend/) for more information on setup and use. - ---- - -## DEFAULT_TIMEOUT - -Default: 300 - -When `WEBHOOK_BACKEND_ENABLED` is `True` use this value as the redis timeout. This is used in conjunction with the webhook backend. See the docs section on the webhook backend [here](../miscellaneous/webhook-backend/) for more information on setup and use. - ---- - -## HOST - -Default: localhost - -When `WEBHOOK_BACKEND_ENABLED` is `True` connect to this redis server host. This is used in conjunction with the webhook backend. See the docs section on the webhook backend [here](../miscellaneous/webhook-backend/) for more information on setup and use. - ---- - -## PASSWORD - -Default: N/A (empty string value) - -When `WEBHOOK_BACKEND_ENABLED` is `True` use this password to connect to the redis server. This is used in conjunction with the webhook backend. See the docs section on the webhook backend [here](../miscellaneous/webhook-backend/) for more information on setup and use. - ---- - -## PORT - -Default: 6379 - -When `WEBHOOK_BACKEND_ENABLED` is `True` use this port to connect to the redis server. This is used in conjunction with the webhook backend. See the docs section on the webhook backend [here](../miscellaneous/webhook-backend/) for more information on setup and use. - ---- - ## REPORTS_ROOT Default: $BASE_DIR/netbox/reports/ @@ -267,11 +223,11 @@ The time zone NetBox will use when dealing with dates and times. It is recommend --- -## WEBHOOK_BACKEND_ENABLED +## WEBHOOKS_ENABLED Default: False -Enable this option to run the webhook backend. See the docs section on the webhook backend [here](../miscellaneous/webhook-backend/) for more information on setup and use. +Enable this option to run the webhook backend. See the docs section on the webhook backend [here](../miscellaneous/webhooks/) for more information on setup and use. --- @@ -289,3 +245,49 @@ SHORT_TIME_FORMAT = 'H:i:s' # 13:23:00 DATETIME_FORMAT = 'N j, Y g:i a' # June 26, 2016 1:23 p.m. SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-27 13:23 ``` + +--- + +## Redis Connection Settings + +[Redis](https://redis.io/) is a key-value store which functions as a very lightweight database. It is required when enabling NetBox [webhooks](../miscellaneous/webhooks/). A Redis connection is configured using a dictionary similar to the following: + +``` +REDIS = { + 'HOST': 'localhost', + 'PORT': 6379, + 'PASSWORD': '', + 'DATABASE': 0, + 'DEFAULT_TIMEOUT': 300, +} +``` + +### DATABASE + +Default: 0 + +The Redis database ID. + +### DEFAULT_TIMEOUT + +Default: 300 + +The timeout value to use when connecting to the Redis server (in seconds). + +### HOST + +Default: localhost + +The hostname or IP address of the Redis server. + +### PORT + +Default: 6379 + +The TCP port to use when connecting to the Redis server. + +### PASSWORD + +Default: None + +The password to use when authenticating to the Redis server (optional). diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index a1ea91e57..83f50df65 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -133,7 +133,6 @@ Certain objects within NetBox (namely sites, racks, and devices) can have photos # Webhooks -When the [webhook backend](../miscellaneous/webhook-backend/) is enabled, webhooks define how NetBox should react to events surrounding certain models. The webhook model defines a payload URL and event types to which a set of models should be registered. These event types include `Create`, `Update`, and `Delete`. Upon a matching event, a POST request is sent to the payload URL. An optional `secret` can be configured which will append a `X-Hook-Signature` header to the request, consisting of a HMAC (sha512) hex digest of the request body using the secret as the key. You may also allow a webhook to use insecure ssl. +A webhook defines an HTTP request that is sent to an external application when certain types of objects are created, updated, and/or deleted in NetBox. When a webhook is triggered, a POST request is sent to its configured URL. This request will include a full representation of the object being modified for consumption by the receiver. -!!! warning - Using insecure ssl is generally a bad idea but is allowed as invalid ssl is commonly used in internal IT environments. Using insecure ssl in the webhook means ssl verification when making the POST request will not occur. +An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content. diff --git a/docs/miscellaneous/webhook-backend.md b/docs/miscellaneous/webhooks.md similarity index 97% rename from docs/miscellaneous/webhook-backend.md rename to docs/miscellaneous/webhooks.md index b1d9b1135..82864f31d 100644 --- a/docs/miscellaneous/webhook-backend.md +++ b/docs/miscellaneous/webhooks.md @@ -132,7 +132,7 @@ Then, restart the supervisor service to detect the changes: act on all netbox processes (netbox-core and netbox-webhook-backend in this case). Now you need only add the configuration settings to connect to redis and enable the webhook backend feature. -- In your `configuration.py` Set [WEBHOOK_BACKEND_ENABLED](../configuration/optional-settings/#webhook_backend_enabled) to `True`. +- In your `configuration.py` Set [WEBHOOKS_ENABLED](../configuration/optional-settings/#webhooks_enabled) to `True`. - If needed, set the optional redis connection settings. By default, they will allow connecting to DB 0 on a locally installed redis server with no password. - [REDIS_DB](../configuration/optional-settings/#redis_db) - [REDIS_DEFAULT_TIMEOUT](../configuration/optional-settings/#redis_default_timeout) diff --git a/netbox/extras/__init__.py b/netbox/extras/__init__.py index 5fdeea52b..c7e9c66ad 100644 --- a/netbox/extras/__init__.py +++ b/netbox/extras/__init__.py @@ -5,7 +5,7 @@ from django.core.exceptions import ImproperlyConfigured default_app_config = 'extras.apps.ExtrasConfig' # check that django-rq is installed and we can connect to redis -if settings.WEBHOOK_BACKEND_ENABLED: +if settings.WEBHOOKS_ENABLED: try: import django_rq except ImportError: diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index d27962ade..1cac98a23 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -1,8 +1,6 @@ from __future__ import unicode_literals from django.apps import AppConfig -from django.core.cache import caches -from django.db.utils import ProgrammingError from django.core.exceptions import ImproperlyConfigured from django.conf import settings @@ -13,17 +11,25 @@ class ExtrasConfig(AppConfig): def ready(self): import extras.signals - # check that we can connect to redis - if settings.WEBHOOK_BACKEND_ENABLED: + # Check that we can connect to the configured Redis database if webhooks are enabled. + if settings.WEBHOOKS_ENABLED: try: import redis - rs = redis.Redis(settings.REDIS_HOST, - settings.REDIS_PORT, - settings.REDIS_DB, - settings.REDIS_PASSWORD or None) + except ImportError: + raise ImproperlyConfigured( + "WEBHOOKS_ENABLED is True but the redis Python package is not installed. (Try 'pip install " + "redis'.)" + ) + try: + rs = redis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=settings.REDIS_DATABASE, + password=settings.REDIS_PASSWORD or None, + ) rs.ping() except redis.exceptions.ConnectionError: raise ImproperlyConfigured( - "Unable to connect to the redis database. You must provide " - "connection settings to redis per the documentation." + "Unable to connect to the Redis database. Check that the Redis configuration has been defined in " + "configuration.py." ) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 3560bece5..2357e6289 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -64,7 +64,7 @@ def post_save_receiver(sender, instance, created, **kwargs): Receives post_save signals from registered models. If the webhook backend is enabled, queue any webhooks that apply to the event. """ - if settings.WEBHOOK_BACKEND_ENABLED: + if settings.WEBHOOKS_ENABLED: signal_received_timestamp = time.time() webhook_cache = get_or_set_webhook_cache() # look for any webhooks that match this event @@ -88,7 +88,7 @@ def post_delete_receiver(sender, instance, **kwargs): Receives post_delete signals from registered models. If the webhook backend is enabled, queue any webhooks that apply to the event. """ - if settings.WEBHOOK_BACKEND_ENABLED: + if settings.WEBHOOKS_ENABLED: signal_received_timestamp = time.time() webhook_cache = get_or_set_webhook_cache() obj_type = ContentType.objects.get_for_model(sender) @@ -103,7 +103,7 @@ def bulk_operation_receiver(sender, **kwargs): Receives bulk_operation_signal signals from registered models. If the webhook backend is enabled, queue any webhooks that apply to the event. """ - if settings.WEBHOOK_BACKEND_ENABLED: + if settings.WEBHOOKS_ENABLED: signal_received_timestamp = time.time() event = kwargs['event'] webhook_cache = get_or_set_webhook_cache() @@ -132,7 +132,7 @@ def register_signals(senders): Take a list of senders (Models) and register them to the post_save and post_delete signal receivers. """ - if settings.WEBHOOK_BACKEND_ENABLED: + if settings.WEBHOOKS_ENABLED: # only register signals if the backend is enabled # this reduces load by not firing signals if the # webhook backend feature is disabled diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 1cfcfcf56..27a615c32 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -118,18 +118,17 @@ PAGINATE_COUNT = 50 # prefer IPv4 instead. PREFER_IPV4 = False -# The Webhook event backend is disabled by default. Set this to True to enable it. Besure to follow the documentation -# on first enabling the required components for the webhook backend. -WEBHOOK_BACKEND_ENABLED = False +# The Webhook event backend is disabled by default. Set this to True to enable it. Note that this requires a Redis +# database be configured and accessible by NetBox (see `REDIS` below). +WEBHOOKS_ENABLED = False -# Redis settings. Redis is used in webhook backend so WEBHOOK_BACKEND_ENABLED must be enabled for these -# to mean anything. Please refer to the netbox documentation on the webhook backend. +# Redis database settings (optional). A Redis database is required only if the webhooks backend is enabled. REDIS = { 'HOST': 'localhost', 'PORT': 6379, - 'DEFAULT_TIMEOUT': 300, 'PASSWORD': '', - 'DB': 0, + 'DATABASE': 0, + 'DEFAULT_TIMEOUT': 300, } # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7aa91092f..44299998e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -64,13 +64,13 @@ NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') -WEBHOOK_BACKEND_ENABLED = getattr(configuration, 'WEBHOOK_BACKEND_ENABLED', False) REDIS = getattr(configuration, 'REDIS', {}) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') +WEBHOOKS_ENABLED = getattr(configuration, 'WEBHOOKS_ENABLED', False) CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS @@ -112,11 +112,17 @@ DATABASES = { } # Redis -REDIS_HOST = REDIS.get('REDIS_HOST', 'localhost') -REDIS_PORT = REDIS.get('REDIS_PORT', 6379) -REDIS_DEFAULT_TIMEOUT = REDIS.get('REDIS_DEFAULT_TIMEOUT', 300) -REDIS_PASSWORD = REDIS.get('REDIS_PASSWORD', '') -REDIS_DB = REDIS.get('REDIS_DB', 0) +REDIS_HOST = REDIS.get('HOST', 'localhost') +REDIS_PORT = REDIS.get('PORT', 6379) +REDIS_PASSWORD = REDIS.get('PASSWORD', '') +REDIS_DATABASE = REDIS.get('DATABASE', 0) +REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300) + +print(REDIS_HOST) +print(REDIS_PORT) +print(REDIS_PASSWORD) +print(REDIS_DATABASE) +print(REDIS_DEFAULT_TIMEOUT) # Email EMAIL_HOST = EMAIL.get('SERVER') @@ -156,8 +162,8 @@ INSTALLED_APPS = [ 'drf_yasg', ] -# only load django-rq if the webhook backend is enabled -if WEBHOOK_BACKEND_ENABLED: +# Only load django-rq if the webhook backend is enabled +if WEBHOOKS_ENABLED: INSTALLED_APPS.append('django_rq') # Middleware @@ -259,12 +265,12 @@ REST_FRAMEWORK = { 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', } -# Django RQ (Webhook backend) +# Django RQ (Webhooks backend) RQ_QUEUES = { 'default': { 'HOST': REDIS_HOST, 'PORT': REDIS_PORT, - 'DB': REDIS_DB, + 'DB': REDIS_DATABASE, 'PASSWORD': REDIS_PASSWORD, 'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT, } diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 6ba7d14fc..b6f5ca8e8 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -64,7 +64,7 @@ _patterns = [ ] -if settings.WEBHOOK_BACKEND_ENABLED: +if settings.WEBHOOKS_ENABLED: _patterns += [ url(r'^admin/webhook-backend-status/', include('django_rq.urls')), ] From edd8e9e41e3c24b1ff42e8caa8c7ef5abe028619 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 30 May 2018 16:56:56 -0400 Subject: [PATCH 030/159] Remove print() statements left behind from testing --- netbox/netbox/settings.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 44299998e..7acb611f3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -118,12 +118,6 @@ REDIS_PASSWORD = REDIS.get('PASSWORD', '') REDIS_DATABASE = REDIS.get('DATABASE', 0) REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300) -print(REDIS_HOST) -print(REDIS_PORT) -print(REDIS_PASSWORD) -print(REDIS_DATABASE) -print(REDIS_DEFAULT_TIMEOUT) - # Email EMAIL_HOST = EMAIL.get('SERVER') EMAIL_PORT = EMAIL.get('PORT', 25) From a61473dd986fbaa681e26d46036c4438c795e967 Mon Sep 17 00:00:00 2001 From: Matt Layher Date: Fri, 1 Jun 2018 15:07:18 -0400 Subject: [PATCH 031/159] docs: fix typos and markdownlint warnings in webhooks docs --- docs/miscellaneous/webhooks.md | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/miscellaneous/webhooks.md b/docs/miscellaneous/webhooks.md index 82864f31d..3fe2505b7 100644 --- a/docs/miscellaneous/webhooks.md +++ b/docs/miscellaneous/webhooks.md @@ -9,12 +9,15 @@ When enabled, the user may subscribe webhooks to certain model events. These eve The models which may have webhooks registered to them are: DCIM: + - Site - Rack - RackGroup - Device - Interface + IPAM: + - VRF - IPAddress - Prefix @@ -22,13 +25,19 @@ IPAM: - VLAN - VLANGroup - Service + Tenancy: + - Tenant - TenantGroup + Ciruits: + - Circuit - Provider -Virtulization: + +Virtualization: + - Cluster - ClusterGroup - VirtualMachine @@ -41,8 +50,9 @@ Webhooks are created and updated under extras in the admin site. ### Request -The webhook POST request is structured as so (assuming `application/json` as the Content-Type: -``` +The webhook POST request is structured as so (assuming `application/json` as the Content-Type): + +```no-highlight { "event": "created", "signal_received_timestamp": 1508769597, @@ -54,7 +64,8 @@ The webhook POST request is structured as so (assuming `application/json` as the ``` `data` is the serialized representation of the model instance(s) from the event. The same serializers from the NetBox API are used. So an example of the payload for a Site delete event would be: -``` + +```no-highlight { "event": "deleted", "signal_received_timestamp": 1508781858.544069, @@ -89,17 +100,20 @@ A request is considered successful if the response status code is any one of a l The webhook backend feature is considered an "advanced" feature and requires some extra effort to get it running. This is due the fact that a background worker is needed to process events in a non blocking way, i.e. the webhooks are sent in the background as not to interrupt what a user is doing in the NetBox foreground. -To do this, you must install [Redis](https://redis.io/) or simply be able to connect to an existing redis server. Redis is a lightweight, in memory database. Redis is used as a means of persistance between NetBox and the background worker for the queue of webhooks to be sent. It can be installed through most package managers. +To do this, you must install [Redis](https://redis.io/) or simply be able to connect to an existing redis server. Redis is a lightweight, in memory database. Redis is used as a means of persistence between NetBox and the background worker for the queue of webhooks to be sent. It can be installed through most package managers. + ```no-highlight # apt-get install redis-server ``` The only other component needed is [Django-rq](https://github.com/ui/django-rq) which implements [python-rq](http://python-rq.org/) in a native Django context. This should be done from the same place NetBox is installed, i.e. the same python namespace where you run the upgrade script. Python-rq is a simple background job queueing system sitting on top of redis. + ```no-highlight pip install django-rq ``` As mentioned before, the feature requires running a background process. This means we need to run another process along side the NetBox application. We can do this conveniently by modifying the supervisord unit used to run NetBox. Taking the configuration provided from the [installation guide](../installation/web-server/#supervisord_installation) modify it to look like this: + ```no-highlight [program:netbox-core] command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi @@ -114,6 +128,7 @@ user = www-data [group:netbox] programs=netbox-core,netbox-webhook-backend ``` + !!! note `[program:netbox]` was changed to `[program:netbox-core]` @@ -126,12 +141,13 @@ Then, restart the supervisor service to detect the changes: ``` !!! note - Now any time you start or stop netbox using `supervisorctl`, you will need to refer to the - netbox process as `netbox:*` (before this was just `netbox`). This is due to the fact that + Now any time you start or stop NetBox using `supervisorctl`, you will need to refer to the + NetBox process as `netbox:*` (before this was just `netbox`). This is due to the fact that we are now running multiple processes with supervisor, and `netbox:*` tells supervisor to - act on all netbox processes (netbox-core and netbox-webhook-backend in this case). + act on all NetBox processes (`netbox-core` and `netbox-webhook-backend` in this case). Now you need only add the configuration settings to connect to redis and enable the webhook backend feature. + - In your `configuration.py` Set [WEBHOOKS_ENABLED](../configuration/optional-settings/#webhooks_enabled) to `True`. - If needed, set the optional redis connection settings. By default, they will allow connecting to DB 0 on a locally installed redis server with no password. - [REDIS_DB](../configuration/optional-settings/#redis_db) @@ -148,4 +164,4 @@ Now you may restart NetBox as normal and the webhook backend should start runnin ## Backend Status -Django-rq includes a status page in the admin site which can be used to view the result of processed webhooks and manually retry any failed webhooks. Access it from http://netbox.local/admin/webhook-backend-status/ +Django-rq includes a status page in the admin site which can be used to view the result of processed webhooks and manually retry any failed webhooks. Access it from http://netbox.local/admin/webhook-backend-status/. From ff1217fca917f26136e6637724e1738c7815e474 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Jun 2018 15:44:03 -0400 Subject: [PATCH 032/159] Removed extraneous new_subnet() function on ipam.Prefix --- netbox/ipam/models.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index fe32baaf5..a3d8736c1 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -487,16 +487,6 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): prefix_size -= 2 return int(float(child_count) / prefix_size * 100) - def new_subnet(self): - if self.family == 4: - if self.prefix.prefixlen <= 30: - return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) - return None - if self.family == 6: - if self.prefix.prefixlen <= 126: - return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) - return None - class IPAddressManager(models.Manager): From e3c3e54cbb9a88c2a0e961f91704b1fa1a8a1241 Mon Sep 17 00:00:00 2001 From: Matt Layher Date: Fri, 1 Jun 2018 16:04:44 -0400 Subject: [PATCH 033/159] docs: fix circuits typo --- docs/miscellaneous/webhooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/miscellaneous/webhooks.md b/docs/miscellaneous/webhooks.md index 3fe2505b7..2c68053ae 100644 --- a/docs/miscellaneous/webhooks.md +++ b/docs/miscellaneous/webhooks.md @@ -31,7 +31,7 @@ Tenancy: - Tenant - TenantGroup -Ciruits: +Circuits: - Circuit - Provider From e4f336a843adee098913b872e1de22b7281ed5f7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 Jun 2018 14:32:41 -0400 Subject: [PATCH 034/159] Establish tests for the utilities app --- netbox/circuits/tests/test_api.py | 2 +- netbox/dcim/tests/test_api.py | 2 +- netbox/extras/tests/test_api.py | 2 +- netbox/extras/tests/test_customfields.py | 2 +- netbox/ipam/tests/test_api.py | 2 +- netbox/secrets/tests/test_api.py | 2 +- netbox/tenancy/tests/test_api.py | 2 +- netbox/utilities/{tests.py => testing.py} | 0 netbox/utilities/tests/__init__.py | 0 netbox/utilities/tests/test_managers.py | 0 netbox/virtualization/tests/test_api.py | 2 +- 11 files changed, 8 insertions(+), 8 deletions(-) rename netbox/utilities/{tests.py => testing.py} (100%) create mode 100644 netbox/utilities/tests/__init__.py create mode 100644 netbox/utilities/tests/test_managers.py diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 39a2d69f2..3c1337ef2 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -11,7 +11,7 @@ from dcim.models import Site from extras.constants import GRAPH_TYPE_PROVIDER from extras.models import Graph from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import HttpStatusMixin class ProviderTest(HttpStatusMixin, APITestCase): diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 6614f8068..1d9ba74ba 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -18,7 +18,7 @@ from dcim.models import ( from ipam.models import VLAN from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import HttpStatusMixin class RegionTest(HttpStatusMixin, APITestCase): diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index f0cdb5dfe..4b6b78891 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -11,7 +11,7 @@ from dcim.models import Device from extras.constants import GRAPH_TYPE_SITE from extras.models import Graph, ExportTemplate from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import HttpStatusMixin class GraphTest(HttpStatusMixin, APITestCase): diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index b10db514e..805ee5543 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -13,7 +13,7 @@ from dcim.models import Site from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL from extras.models import CustomField, CustomFieldValue, CustomFieldChoice from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import HttpStatusMixin class CustomFieldTest(TestCase): diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 505d914a0..27fa92dfd 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -10,7 +10,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.constants import IP_PROTOCOL_TCP, IP_PROTOCOL_UDP from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import HttpStatusMixin class VRFTest(HttpStatusMixin, APITestCase): diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 0bf93eafd..583997612 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -10,7 +10,7 @@ from rest_framework.test import APITestCase from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import HttpStatusMixin # Dummy RSA key pair for testing use only PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index f1238eddb..64fb80f86 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -7,7 +7,7 @@ from rest_framework.test import APITestCase from tenancy.models import Tenant, TenantGroup from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import HttpStatusMixin class TenantGroupTest(HttpStatusMixin, APITestCase): diff --git a/netbox/utilities/tests.py b/netbox/utilities/testing.py similarity index 100% rename from netbox/utilities/tests.py rename to netbox/utilities/testing.py diff --git a/netbox/utilities/tests/__init__.py b/netbox/utilities/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/utilities/tests/test_managers.py b/netbox/utilities/tests/test_managers.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 1f9e72ee5..275d0c123 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -6,7 +6,7 @@ from rest_framework import status from rest_framework.test import APITestCase from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import HttpStatusMixin from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine From 048e843c3925f061e620770477ace9ed4b5e3a33 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 Jun 2018 15:42:10 -0400 Subject: [PATCH 035/159] Added tests for NaturalOrderByManager --- netbox/utilities/tests/test_managers.py | 192 ++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/netbox/utilities/tests/test_managers.py b/netbox/utilities/tests/test_managers.py index e69de29bb..0bafaefde 100644 --- a/netbox/utilities/tests/test_managers.py +++ b/netbox/utilities/tests/test_managers.py @@ -0,0 +1,192 @@ +from __future__ import unicode_literals + +from django.test import TestCase + +from dcim.models import Site + + +class NaturalOrderByManagerTest(TestCase): + """ + Ensure consistent natural ordering given myriad sample data. We use dcim.Site as our guinea pig because it's simple. + """ + + def setUp(self): + return + + def evaluate_ordering(self, names): + + # Create the Sites + Site.objects.bulk_create( + Site(name=name, slug=name.lower()) for name in names + ) + + # Validate forward ordering + self.assertEqual( + names, + list(Site.objects.values_list('name', flat=True)) + ) + + # Validate reverse ordering + self.assertEqual( + list(reversed(names)), + list(Site.objects.reverse().values_list('name', flat=True)) + ) + + def test_leading_digits(self): + + self.evaluate_ordering([ + '1Alpha', + '1Bravo', + '1Charlie', + '9Alpha', + '9Bravo', + '9Charlie', + '10Alpha', + '10Bravo', + '10Charlie', + '99Alpha', + '99Bravo', + '99Charlie', + '100Alpha', + '100Bravo', + '100Charlie', + '999Alpha', + '999Bravo', + '999Charlie', + ]) + + def test_trailing_digits(self): + + self.evaluate_ordering([ + 'Alpha1', + 'Alpha9', + 'Alpha10', + 'Alpha99', + 'Alpha100', + 'Alpha999', + 'Bravo1', + 'Bravo9', + 'Bravo10', + 'Bravo99', + 'Bravo100', + 'Bravo999', + 'Charlie1', + 'Charlie9', + 'Charlie10', + 'Charlie99', + 'Charlie100', + 'Charlie999', + ]) + + def test_leading_and_trailing_digits(self): + + self.evaluate_ordering([ + '1Alpha1', + '1Alpha9', + '1Alpha10', + '1Alpha99', + '1Alpha100', + '1Alpha999', + '1Bravo1', + '1Bravo9', + '1Bravo10', + '1Bravo99', + '1Bravo100', + '1Bravo999', + '1Charlie1', + '1Charlie9', + '1Charlie10', + '1Charlie99', + '1Charlie100', + '1Charlie999', + '9Alpha1', + '9Alpha9', + '9Alpha10', + '9Alpha99', + '9Alpha100', + '9Alpha999', + '9Bravo1', + '9Bravo9', + '9Bravo10', + '9Bravo99', + '9Bravo100', + '9Bravo999', + '9Charlie1', + '9Charlie9', + '9Charlie10', + '9Charlie99', + '9Charlie100', + '9Charlie999', + '10Alpha1', + '10Alpha9', + '10Alpha10', + '10Alpha99', + '10Alpha100', + '10Alpha999', + '10Bravo1', + '10Bravo9', + '10Bravo10', + '10Bravo99', + '10Bravo100', + '10Bravo999', + '10Charlie1', + '10Charlie9', + '10Charlie10', + '10Charlie99', + '10Charlie100', + '10Charlie999', + '99Alpha1', + '99Alpha9', + '99Alpha10', + '99Alpha99', + '99Alpha100', + '99Alpha999', + '99Bravo1', + '99Bravo9', + '99Bravo10', + '99Bravo99', + '99Bravo100', + '99Bravo999', + '99Charlie1', + '99Charlie9', + '99Charlie10', + '99Charlie99', + '99Charlie100', + '99Charlie999', + '100Alpha1', + '100Alpha9', + '100Alpha10', + '100Alpha99', + '100Alpha100', + '100Alpha999', + '100Bravo1', + '100Bravo9', + '100Bravo10', + '100Bravo99', + '100Bravo100', + '100Bravo999', + '100Charlie1', + '100Charlie9', + '100Charlie10', + '100Charlie99', + '100Charlie100', + '100Charlie999', + '999Alpha1', + '999Alpha9', + '999Alpha10', + '999Alpha99', + '999Alpha100', + '999Alpha999', + '999Bravo1', + '999Bravo9', + '999Bravo10', + '999Bravo99', + '999Bravo100', + '999Bravo999', + '999Charlie1', + '999Charlie9', + '999Charlie10', + '999Charlie99', + '999Charlie100', + '999Charlie999', + ]) From 90abeedc3e81fdc95e8b73d098e66c1a4d9c4352 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 Jun 2018 15:10:31 -0400 Subject: [PATCH 036/159] Fix natural ordering within object tables --- netbox/dcim/models.py | 12 +++-------- netbox/dcim/tables.py | 9 +++++--- netbox/utilities/managers.py | 42 ++++++++++++++++++++---------------- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index c29caa189..1209153bc 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -77,9 +77,7 @@ class Region(MPTTModel): # class SiteManager(NaturalOrderByManager): - - def get_queryset(self): - return self.natural_order_by('name') + natural_order_field = 'name' @python_2_unicode_compatible @@ -308,9 +306,7 @@ class RackRole(models.Model): class RackManager(NaturalOrderByManager): - - def get_queryset(self): - return self.natural_order_by('site__name', 'name') + natural_order_field = 'name' @python_2_unicode_compatible @@ -1098,9 +1094,7 @@ class Platform(models.Model): class DeviceManager(NaturalOrderByManager): - - def get_queryset(self): - return self.natural_order_by('name') + natural_order_field = 'name' @python_2_unicode_compatible diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index e71395ebc..159c70db5 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -175,7 +175,7 @@ class RegionTable(BaseTable): class SiteTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3')) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') region = tables.TemplateColumn(template_code=SITE_REGION_LINK) tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -236,7 +236,7 @@ class RackRoleTable(BaseTable): class RackTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3')) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -469,7 +469,10 @@ class PlatformTable(BaseTable): class DeviceTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn(template_code=DEVICE_LINK) + name = tables.TemplateColumn( + order_by=('_nat1', '_nat2', '_nat3'), + template_code=DEVICE_LINK + ) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') tenant = tables.TemplateColumn(template_code=COL_TENANT) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) diff --git a/netbox/utilities/managers.py b/netbox/utilities/managers.py index dc882d462..b1fb09f9d 100644 --- a/netbox/utilities/managers.py +++ b/netbox/utilities/managers.py @@ -4,29 +4,35 @@ from django.db.models import Manager class NaturalOrderByManager(Manager): + """ + Order objects naturally by a designated field. Leading and/or trailing digits of values within this field will be + cast as independent integers and sorted accordingly. For example, "Foo2" will be ordered before "Foo10", even though + the digit 1 is normally ordered before the digit 2. + """ + natural_order_field = None - def natural_order_by(self, *fields): - """ - Attempt to order records naturally by segmenting a field into three parts: + def get_queryset(self): - 1. Leading integer (if any) - 2. Middle portion - 3. Trailing integer (if any) + queryset = super(NaturalOrderByManager, self).get_queryset() - :param fields: The fields on which to order the queryset. The last field in the list will be ordered naturally. - """ db_table = self.model._meta.db_table - primary_field = fields[-1] + db_field = self.natural_order_field - id1 = '_{}_{}1'.format(db_table, primary_field) - id2 = '_{}_{}2'.format(db_table, primary_field) - id3 = '_{}_{}3'.format(db_table, primary_field) - - queryset = super(NaturalOrderByManager, self).get_queryset().extra(select={ - id1: "CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, primary_field), - id2: "SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, primary_field), - id3: "CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, primary_field), + # Append the three subfields derived from the designated natural ordering field + queryset = queryset.extra(select={ + '_nat1': "CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, db_field), + '_nat2': "SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, db_field), + '_nat3': "CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, db_field), }) - ordering = fields[0:-1] + (id1, id2, id3) + + # Replace any instance of the designated natural ordering field with its three subfields + ordering = [] + for field in self.model._meta.ordering: + if field == self.natural_order_field: + ordering.append('_nat1') + ordering.append('_nat2') + ordering.append('_nat3') + else: + ordering.append(field) return queryset.order_by(*ordering) From b556d2d6267f583b49bae4ec6ce223bbb8373375 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Jun 2018 15:40:16 -0400 Subject: [PATCH 037/159] Renamed CreatedUpdatedModel to ChangeLoggedModel and applied it to all primary and organizational models --- .../migrations/0012_change_logging.py | 45 ++++++ netbox/circuits/models.py | 15 +- netbox/dcim/migrations/0059_change_logging.py | 135 ++++++++++++++++++ ...59_devicetype_add_created_updated_times.py | 27 ---- netbox/dcim/models.py | 51 +++---- netbox/ipam/migrations/0023_change_logging.py | 105 ++++++++++++++ netbox/ipam/models.py | 40 +++--- .../secrets/migrations/0005_change_logging.py | 35 +++++ netbox/secrets/models.py | 16 ++- .../tenancy/migrations/0005_change_logging.py | 35 +++++ netbox/tenancy/models.py | 12 +- netbox/utilities/models.py | 18 ++- .../migrations/0007_change_logging.py | 55 +++++++ netbox/virtualization/models.py | 20 ++- 14 files changed, 503 insertions(+), 106 deletions(-) create mode 100644 netbox/circuits/migrations/0012_change_logging.py create mode 100644 netbox/dcim/migrations/0059_change_logging.py delete mode 100644 netbox/dcim/migrations/0059_devicetype_add_created_updated_times.py create mode 100644 netbox/ipam/migrations/0023_change_logging.py create mode 100644 netbox/secrets/migrations/0005_change_logging.py create mode 100644 netbox/tenancy/migrations/0005_change_logging.py create mode 100644 netbox/virtualization/migrations/0007_change_logging.py diff --git a/netbox/circuits/migrations/0012_change_logging.py b/netbox/circuits/migrations/0012_change_logging.py new file mode 100644 index 000000000..db5057858 --- /dev/null +++ b/netbox/circuits/migrations/0012_change_logging.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0011_tags'), + ] + + operations = [ + migrations.AddField( + model_name='circuittype', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='circuittype', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='circuit', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='circuit', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='provider', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='provider', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 0a36ba366..10ca8d7db 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -9,12 +9,12 @@ from taggit.managers import TaggableManager from dcim.constants import STATUS_CLASSES from dcim.fields import ASNField from extras.models import CustomFieldModel -from utilities.models import CreatedUpdatedModel +from utilities.models import ChangeLoggedModel from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES @python_2_unicode_compatible -class Provider(CreatedUpdatedModel, CustomFieldModel): +class Provider(ChangeLoggedModel, CustomFieldModel): """ 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. @@ -59,9 +59,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() - csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] - serializer = 'circuits.api.serializers.ProviderSerializer' + csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] class Meta: ordering = ['name'] @@ -86,7 +85,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class CircuitType(models.Model): +class CircuitType(ChangeLoggedModel): """ 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". @@ -99,6 +98,7 @@ class CircuitType(models.Model): unique=True ) + serializer = 'circuits.api.serializers.CircuitTypeSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -118,7 +118,7 @@ class CircuitType(models.Model): @python_2_unicode_compatible -class Circuit(CreatedUpdatedModel, CustomFieldModel): +class Circuit(ChangeLoggedModel, CustomFieldModel): """ 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. A Circuit may be terminated to a specific device @@ -173,12 +173,11 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() + serializer = 'circuits.api.serializers.CircuitSerializer' csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', ] - serializer = 'circuits.api.serializers.CircuitSerializer' - class Meta: ordering = ['provider', 'cid'] unique_together = ['provider', 'cid'] diff --git a/netbox/dcim/migrations/0059_change_logging.py b/netbox/dcim/migrations/0059_change_logging.py new file mode 100644 index 000000000..b64e2570b --- /dev/null +++ b/netbox/dcim/migrations/0059_change_logging.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0058_relax_rack_naming_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='devicerole', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicerole', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicetype', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicetype', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='manufacturer', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='manufacturer', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='platform', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='platform', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rackgroup', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rackgroup', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rackreservation', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rackrole', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rackrole', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='region', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='region', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='device', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='device', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='rack', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rack', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='rackreservation', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='site', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='site', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0059_devicetype_add_created_updated_times.py b/netbox/dcim/migrations/0059_devicetype_add_created_updated_times.py deleted file mode 100644 index 6a16656ed..000000000 --- a/netbox/dcim/migrations/0059_devicetype_add_created_updated_times.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-05-30 17:30 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0058_relax_rack_naming_constraints'), - ] - - operations = [ - migrations.AddField( - model_name='devicetype', - name='created', - field=models.DateField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='devicetype', - name='last_updated', - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 1209153bc..f430eb095 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -22,7 +22,7 @@ from extras.models import CustomFieldModel from extras.rpc import RPC_CLIENTS from utilities.fields import ColorField, NullableCharField from utilities.managers import NaturalOrderByManager -from utilities.models import CreatedUpdatedModel +from utilities.models import ChangeLoggedModel from .constants import * from .fields import ASNField, MACAddressField from .querysets import InterfaceQuerySet @@ -33,7 +33,7 @@ from .querysets import InterfaceQuerySet # @python_2_unicode_compatible -class Region(MPTTModel): +class Region(ChangeLoggedModel, MPTTModel): """ Sites can be grouped within geographic Regions. """ @@ -53,6 +53,7 @@ class Region(MPTTModel): unique=True ) + serializer = 'dcim.api.serializers.RegionSerializer' csv_headers = ['name', 'slug', 'parent'] class MPTTMeta: @@ -81,7 +82,7 @@ class SiteManager(NaturalOrderByManager): @python_2_unicode_compatible -class Site(CreatedUpdatedModel, CustomFieldModel): +class Site(ChangeLoggedModel, CustomFieldModel): """ 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). @@ -162,13 +163,12 @@ class Site(CreatedUpdatedModel, CustomFieldModel): objects = SiteManager() tags = TaggableManager() + serializer = 'dcim.api.serializers.SiteSerializer' csv_headers = [ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', ] - serializer = 'dcim.api.serializers.SiteSerializer' - class Meta: ordering = ['name'] @@ -231,7 +231,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): # @python_2_unicode_compatible -class RackGroup(models.Model): +class RackGroup(ChangeLoggedModel): """ 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 @@ -247,9 +247,8 @@ class RackGroup(models.Model): related_name='rack_groups' ) - csv_headers = ['site', 'name', 'slug'] - serializer = 'dcim.api.serializers.RackGroupSerializer' + csv_headers = ['site', 'name', 'slug'] class Meta: ordering = ['site', 'name'] @@ -273,7 +272,7 @@ class RackGroup(models.Model): @python_2_unicode_compatible -class RackRole(models.Model): +class RackRole(ChangeLoggedModel): """ Racks can be organized by functional role, similar to Devices. """ @@ -286,6 +285,7 @@ class RackRole(models.Model): ) color = ColorField() + serializer = 'dcim.api.serializers.RackRoleSerializer' csv_headers = ['name', 'slug', 'color'] class Meta: @@ -310,7 +310,7 @@ class RackManager(NaturalOrderByManager): @python_2_unicode_compatible -class Rack(CreatedUpdatedModel, CustomFieldModel): +class Rack(ChangeLoggedModel, CustomFieldModel): """ 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. @@ -392,13 +392,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): objects = RackManager() tags = TaggableManager() + serializer = 'dcim.api.serializers.RackSerializer' csv_headers = [ 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', 'desc_units', 'comments', ] - serializer = 'dcim.api.serializers.RackSerializer' - class Meta: ordering = ['site', 'group', 'name'] unique_together = [ @@ -570,7 +569,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class RackReservation(models.Model): +class RackReservation(ChangeLoggedModel): """ One or more reserved units within a Rack. """ @@ -582,9 +581,6 @@ class RackReservation(models.Model): units = ArrayField( base_field=models.PositiveSmallIntegerField() ) - created = models.DateTimeField( - auto_now_add=True - ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -600,6 +596,8 @@ class RackReservation(models.Model): max_length=100 ) + serializer = 'dcim.api.serializers.RackReservationSerializer' + class Meta: ordering = ['created'] @@ -647,7 +645,7 @@ class RackReservation(models.Model): # @python_2_unicode_compatible -class Manufacturer(models.Model): +class Manufacturer(ChangeLoggedModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ @@ -659,6 +657,7 @@ class Manufacturer(models.Model): unique=True ) + serializer = 'dcim.api.serializers.ManufacturerSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -678,7 +677,7 @@ class Manufacturer(models.Model): @python_2_unicode_compatible -class DeviceType(CreatedUpdatedModel, CustomFieldModel): +class DeviceType(ChangeLoggedModel, CustomFieldModel): """ 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). @@ -753,6 +752,7 @@ class DeviceType(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() + serializer = 'dcim.api.serializers.DeviceTypeSerializer' csv_headers = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', @@ -998,7 +998,7 @@ class DeviceBayTemplate(models.Model): # @python_2_unicode_compatible -class DeviceRole(models.Model): +class DeviceRole(ChangeLoggedModel): """ 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 @@ -1018,6 +1018,7 @@ class DeviceRole(models.Model): help_text='Virtual machines may be assigned to this role' ) + serializer = 'dcim.api.serializers.DeviceRoleSerializer' csv_headers = ['name', 'slug', 'color', 'vm_role'] class Meta: @@ -1039,7 +1040,7 @@ class DeviceRole(models.Model): @python_2_unicode_compatible -class Platform(models.Model): +class Platform(ChangeLoggedModel): """ 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 @@ -1073,6 +1074,7 @@ class Platform(models.Model): verbose_name='Legacy RPC client' ) + serializer = 'dcim.api.serializers.PlatformSerializer' csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver'] class Meta: @@ -1098,7 +1100,7 @@ class DeviceManager(NaturalOrderByManager): @python_2_unicode_compatible -class Device(CreatedUpdatedModel, CustomFieldModel): +class Device(ChangeLoggedModel, CustomFieldModel): """ 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. @@ -1238,13 +1240,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel): objects = DeviceManager() tags = TaggableManager() + serializer = 'dcim.api.serializers.DeviceSerializer' csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', ] - serializer = 'dcim.api.serializers.DeviceSerializer' - class Meta: ordering = ['name'] unique_together = [ @@ -2098,7 +2099,7 @@ class InventoryItem(models.Model): # @python_2_unicode_compatible -class VirtualChassis(models.Model): +class VirtualChassis(ChangeLoggedModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ @@ -2112,6 +2113,8 @@ class VirtualChassis(models.Model): blank=True ) + serializer = 'dcim.api.serializers.VirtualChassisSerializer' + class Meta: ordering = ['master'] verbose_name_plural = 'virtual chassis' diff --git a/netbox/ipam/migrations/0023_change_logging.py b/netbox/ipam/migrations/0023_change_logging.py new file mode 100644 index 000000000..d548fdf15 --- /dev/null +++ b/netbox/ipam/migrations/0023_change_logging.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0022_tags'), + ] + + operations = [ + migrations.AddField( + model_name='rir', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rir', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='role', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='role', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='vlangroup', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='vlangroup', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='aggregate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='aggregate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='ipaddress', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='ipaddress', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='prefix', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='prefix', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='service', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='service', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='vlan', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='vlan', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='vrf', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='vrf', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index a3d8736c1..f1414bd27 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -14,14 +14,14 @@ from taggit.managers import TaggableManager from dcim.models import Interface from extras.models import CustomFieldModel -from utilities.models import CreatedUpdatedModel +from utilities.models import ChangeLoggedModel from .constants import * from .fields import IPNetworkField, IPAddressField from .querysets import PrefixQuerySet @python_2_unicode_compatible -class VRF(CreatedUpdatedModel, CustomFieldModel): +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 @@ -59,9 +59,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() - csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] - serializer = 'ipam.api.serializers.VRFSerializer' + csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] class Meta: ordering = ['name', 'rd'] @@ -91,7 +90,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class RIR(models.Model): +class RIR(ChangeLoggedModel): """ 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. @@ -109,6 +108,7 @@ class RIR(models.Model): help_text='IP space managed by this RIR is considered private' ) + serializer = 'ipam.api.serializers.RIRSerializer' csv_headers = ['name', 'slug', 'is_private'] class Meta: @@ -131,7 +131,7 @@ class RIR(models.Model): @python_2_unicode_compatible -class Aggregate(CreatedUpdatedModel, CustomFieldModel): +class Aggregate(ChangeLoggedModel, CustomFieldModel): """ 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. @@ -162,9 +162,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() - csv_headers = ['prefix', 'rir', 'date_added', 'description'] - serializer = 'ipam.api.serializers.AggregateSerializer' + csv_headers = ['prefix', 'rir', 'date_added', 'description'] class Meta: ordering = ['family', 'prefix'] @@ -228,7 +227,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class Role(models.Model): +class Role(ChangeLoggedModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or "Management." @@ -244,6 +243,7 @@ class Role(models.Model): default=1000 ) + serializer = 'ipam.api.serializers.RoleSerializer' csv_headers = ['name', 'slug', 'weight'] class Meta: @@ -261,7 +261,7 @@ class Role(models.Model): @python_2_unicode_compatible -class Prefix(CreatedUpdatedModel, CustomFieldModel): +class Prefix(ChangeLoggedModel, CustomFieldModel): """ 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 @@ -336,12 +336,11 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): objects = PrefixQuerySet.as_manager() tags = TaggableManager() + serializer = 'ipam.api.serializers.PrefixSerializer' csv_headers = [ 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', ] - serializer = 'ipam.api.serializers.PrefixSerializer' - class Meta: ordering = ['vrf', 'family', 'prefix'] verbose_name_plural = 'prefixes' @@ -503,7 +502,7 @@ class IPAddressManager(models.Manager): @python_2_unicode_compatible -class IPAddress(CreatedUpdatedModel, CustomFieldModel): +class IPAddress(ChangeLoggedModel, CustomFieldModel): """ 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 @@ -578,13 +577,12 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): objects = IPAddressManager() tags = TaggableManager() + serializer = 'ipam.api.serializers.IPAddressSerializer' csv_headers = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', 'description', ] - serializer = 'ipam.api.serializers.IPAddressSerializer' - class Meta: ordering = ['family', 'address'] verbose_name = 'IP address' @@ -663,7 +661,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class VLANGroup(models.Model): +class VLANGroup(ChangeLoggedModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. """ @@ -679,9 +677,8 @@ class VLANGroup(models.Model): null=True ) - csv_headers = ['name', 'slug', 'site'] - serializer = 'ipam.api.serializers.VLANGroupSerializer' + csv_headers = ['name', 'slug', 'site'] class Meta: ordering = ['site', 'name'] @@ -717,7 +714,7 @@ class VLANGroup(models.Model): @python_2_unicode_compatible -class VLAN(CreatedUpdatedModel, CustomFieldModel): +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, @@ -778,9 +775,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() - csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] - serializer = 'ipam.api.serializers.VLANSerializer' + csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] class Meta: ordering = ['site', 'group', 'vid'] @@ -835,7 +831,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class Service(CreatedUpdatedModel): +class Service(ChangeLoggedModel): """ 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. diff --git a/netbox/secrets/migrations/0005_change_logging.py b/netbox/secrets/migrations/0005_change_logging.py new file mode 100644 index 000000000..947087934 --- /dev/null +++ b/netbox/secrets/migrations/0005_change_logging.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:29 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0004_tags'), + ] + + operations = [ + migrations.AddField( + model_name='secretrole', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='secretrole', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='secret', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='secret', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index dcb38db70..3c253c104 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -14,7 +14,7 @@ from django.urls import reverse from django.utils.encoding import force_bytes, python_2_unicode_compatible from taggit.managers import TaggableManager -from utilities.models import CreatedUpdatedModel +from utilities.models import ChangeLoggedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher from .querysets import UserKeyQuerySet @@ -48,12 +48,18 @@ def decrypt_master_key(master_key_cipher, private_key): @python_2_unicode_compatible -class UserKey(CreatedUpdatedModel): +class UserKey(models.Model): """ 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 matching (private) decryption key. """ + created = models.DateField( + auto_now_add=True + ) + last_updated = models.DateTimeField( + auto_now=True + ) user = models.OneToOneField( to=User, on_delete=models.CASCADE, @@ -251,7 +257,7 @@ class SessionKey(models.Model): @python_2_unicode_compatible -class SecretRole(models.Model): +class SecretRole(ChangeLoggedModel): """ A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles such as "Login Credentials" or "SNMP Communities." @@ -277,6 +283,7 @@ class SecretRole(models.Model): blank=True ) + serializer = 'ipam.api.secrets.SecretSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -304,7 +311,7 @@ class SecretRole(models.Model): @python_2_unicode_compatible -class Secret(CreatedUpdatedModel): +class Secret(ChangeLoggedModel): """ 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 a @@ -340,6 +347,7 @@ class Secret(CreatedUpdatedModel): tags = TaggableManager() plaintext = None + serializer = 'ipam.api.secrets.SecretSerializer' csv_headers = ['device', 'role', 'name', 'plaintext'] class Meta: diff --git a/netbox/tenancy/migrations/0005_change_logging.py b/netbox/tenancy/migrations/0005_change_logging.py new file mode 100644 index 000000000..7712e9d02 --- /dev/null +++ b/netbox/tenancy/migrations/0005_change_logging.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_tags'), + ] + + operations = [ + migrations.AddField( + model_name='tenantgroup', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='tenantgroup', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='tenant', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='tenant', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index bc87ccd8c..33073e326 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -7,11 +7,11 @@ from django.utils.encoding import python_2_unicode_compatible from taggit.managers import TaggableManager from extras.models import CustomFieldModel -from utilities.models import CreatedUpdatedModel +from utilities.models import ChangeLoggedModel @python_2_unicode_compatible -class TenantGroup(models.Model): +class TenantGroup(ChangeLoggedModel): """ An arbitrary collection of Tenants. """ @@ -23,9 +23,8 @@ class TenantGroup(models.Model): unique=True ) - csv_headers = ['name', 'slug'] - serializer = 'tenancy.api.serializers.TenantGroupSerializer' + csv_headers = ['name', 'slug'] class Meta: ordering = ['name'] @@ -44,7 +43,7 @@ class TenantGroup(models.Model): @python_2_unicode_compatible -class Tenant(CreatedUpdatedModel, CustomFieldModel): +class Tenant(ChangeLoggedModel, CustomFieldModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. @@ -79,9 +78,8 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() - csv_headers = ['name', 'slug', 'group', 'description', 'comments'] - serializer = 'tenancy.api.serializers.TenantSerializer' + csv_headers = ['name', 'slug', 'group', 'description', 'comments'] class Meta: ordering = ['group', 'name'] diff --git a/netbox/utilities/models.py b/netbox/utilities/models.py index c6768c4c1..9a0c05de5 100644 --- a/netbox/utilities/models.py +++ b/netbox/utilities/models.py @@ -3,9 +3,21 @@ from __future__ import unicode_literals from django.db import models -class CreatedUpdatedModel(models.Model): - created = models.DateField(auto_now_add=True) - last_updated = models.DateTimeField(auto_now=True) +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 diff --git a/netbox/virtualization/migrations/0007_change_logging.py b/netbox/virtualization/migrations/0007_change_logging.py new file mode 100644 index 000000000..954f9f2a9 --- /dev/null +++ b/netbox/virtualization/migrations/0007_change_logging.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0006_tags'), + ] + + operations = [ + migrations.AddField( + model_name='clustergroup', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='clustergroup', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='clustertype', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='clustertype', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='cluster', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='cluster', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='virtualmachine', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='virtualmachine', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 42b6591f4..70a73dc05 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -10,7 +10,7 @@ from taggit.managers import TaggableManager from dcim.models import Device from extras.models import CustomFieldModel -from utilities.models import CreatedUpdatedModel +from utilities.models import ChangeLoggedModel from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES @@ -19,7 +19,7 @@ from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSE # @python_2_unicode_compatible -class ClusterType(models.Model): +class ClusterType(ChangeLoggedModel): """ A type of Cluster. """ @@ -31,6 +31,7 @@ class ClusterType(models.Model): unique=True ) + serializer = 'virtualization.api.serializers.ClusterTypeSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -54,7 +55,7 @@ class ClusterType(models.Model): # @python_2_unicode_compatible -class ClusterGroup(models.Model): +class ClusterGroup(ChangeLoggedModel): """ An organizational group of Clusters. """ @@ -66,9 +67,8 @@ class ClusterGroup(models.Model): unique=True ) - csv_headers = ['name', 'slug'] - serializer = 'virtualization.api.serializers.ClusterGroupSerializer' + csv_headers = ['name', 'slug'] class Meta: ordering = ['name'] @@ -91,7 +91,7 @@ class ClusterGroup(models.Model): # @python_2_unicode_compatible -class Cluster(CreatedUpdatedModel, CustomFieldModel): +class Cluster(ChangeLoggedModel, CustomFieldModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -129,9 +129,8 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() - csv_headers = ['name', 'type', 'group', 'site', 'comments'] - serializer = 'virtualization.api.serializers.ClusterSerializer' + csv_headers = ['name', 'type', 'group', 'site', 'comments'] class Meta: ordering = ['name'] @@ -169,7 +168,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): # @python_2_unicode_compatible -class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): +class VirtualMachine(ChangeLoggedModel, CustomFieldModel): """ A virtual machine which runs inside a Cluster. """ @@ -251,12 +250,11 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() + serializer = 'virtualization.api.serializers.VirtualMachineSerializer' csv_headers = [ 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ] - serializer = 'virtualization.api.serializers.VirtualMachineSerializer' - class Meta: ordering = ['name'] From 33cf227bc8336d8ba30c658d7a2869520930740c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 Jun 2018 17:06:33 -0400 Subject: [PATCH 038/159] Implemented new object change logging to replace UserActions --- netbox/extras/admin.py | 47 +++++++++++++- netbox/extras/constants.py | 10 +++ netbox/extras/middleware.py | 63 ++++++++++++++++++ netbox/extras/migrations/0013_objectchange.py | 37 +++++++++++ netbox/extras/models.py | 65 +++++++++++++++++++ netbox/netbox/settings.py | 1 + 6 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 netbox/extras/middleware.py create mode 100644 netbox/extras/migrations/0013_objectchange.py diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index e96ae9ac8..200387f88 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -5,9 +5,9 @@ from django.contrib import admin from django.utils.safestring import mark_safe from utilities.forms import LaxURLField +from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from .models import ( - CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction, - Webhook + CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction, Webhook, ) @@ -125,6 +125,49 @@ class TopologyMapAdmin(admin.ModelAdmin): } +# +# Change logging +# + +@admin.register(ObjectChange) +class ObjectChangeAdmin(admin.ModelAdmin): + actions = None + fields = ['time', 'content_type', 'display_object', 'action', 'display_user'] + list_display = ['time', 'content_type', 'display_object', 'display_action', 'display_user'] + list_filter = ['time', 'action', 'user__username'] + list_select_related = ['content_type', 'user'] + readonly_fields = fields + search_fields = ['user_name', 'object_repr'] + + def has_add_permission(self, request): + return False + + def display_user(self, obj): + if obj.user is not None: + return obj.user + else: + return '{} (deleted)'.format(obj.user_name) + display_user.short_description = 'user' + + def display_action(self, obj): + icon = { + OBJECTCHANGE_ACTION_CREATE: 'addlink', + OBJECTCHANGE_ACTION_UPDATE: 'changelink', + OBJECTCHANGE_ACTION_DELETE: 'deletelink', + } + return mark_safe('{}'.format(icon[obj.action], obj.get_action_display())) + display_user.short_description = 'action' + + def display_object(self, obj): + if hasattr(obj.changed_object, 'get_absolute_url'): + return mark_safe('{}'.format(obj.changed_object.get_absolute_url(), obj.changed_object)) + elif obj.changed_object is not None: + return obj.changed_object + else: + return '{} (deleted)'.format(obj.object_repr) + display_object.short_description = 'object' + + # # User actions # diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 8a615c076..84c84dfcf 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -66,6 +66,16 @@ TOPOLOGYMAP_TYPE_CHOICES = ( (TOPOLOGYMAP_TYPE_POWER, 'Power'), ) +# Change log actions +OBJECTCHANGE_ACTION_CREATE = 1 +OBJECTCHANGE_ACTION_UPDATE = 2 +OBJECTCHANGE_ACTION_DELETE = 3 +OBJECTCHANGE_ACTION_CHOICES = ( + (OBJECTCHANGE_ACTION_CREATE, 'Created'), + (OBJECTCHANGE_ACTION_UPDATE, 'Updated'), + (OBJECTCHANGE_ACTION_DELETE, 'Deleted'), +) + # User action types ACTION_CREATE = 1 ACTION_IMPORT = 2 diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py new file mode 100644 index 000000000..cf0efdfae --- /dev/null +++ b/netbox/extras/middleware.py @@ -0,0 +1,63 @@ +from __future__ import unicode_literals + +import json + +from django.core.serializers import serialize +from django.db.models.signals import post_delete, post_save +from django.utils.functional import curry, SimpleLazyObject + +from utilities.models import ChangeLoggedModel +from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE +from .models import ObjectChange + + +def record_object_change(user, instance, **kwargs): + """ + Create an ObjectChange in response to an object being created or deleted. + """ + if not isinstance(instance, ChangeLoggedModel): + return + + # Determine what action is being performed. The post_save signal sends a `created` boolean, whereas post_delete + # does not. + if 'created' in kwargs: + action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE + else: + action = OBJECTCHANGE_ACTION_DELETE + + # Serialize the object using Django's built-in JSON serializer, then extract only the `fields` dict. + json_str = serialize('json', [instance]) + object_data = json.loads(json_str)[0]['fields'] + + ObjectChange( + user=user, + changed_object=instance, + action=action, + object_data=object_data + ).save() + + +class ChangeLoggingMiddleware(object): + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + + def get_user(request): + return request.user + + # DRF employs a separate authentication mechanism outside Django's normal request/response cycle, so calling + # request.user in middleware will always return AnonymousUser for API requests. To work around this, we point + # to a lazy object that doesn't resolve the user until after DRF's authentication has been called. For more + # detail, see https://stackoverflow.com/questions/26240832/ + user = SimpleLazyObject(lambda: get_user(request)) + + # Django doesn't provide any request context with the post_save/post_delete signals, so we curry + # record_object_change() to include the user associated with the current request. + _record_object_change = curry(record_object_change, user) + + post_save.connect(_record_object_change) + post_delete.connect(_record_object_change) + + return self.get_response(request) diff --git a/netbox/extras/migrations/0013_objectchange.py b/netbox/extras/migrations/0013_objectchange.py new file mode 100644 index 000000000..fdaf7dfd5 --- /dev/null +++ b/netbox/extras/migrations/0013_objectchange.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 20:05 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0012_webhooks'), + ] + + operations = [ + migrations.CreateModel( + name='ObjectChange', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(auto_now_add=True)), + ('user_name', models.CharField(editable=False, max_length=150)), + ('action', models.PositiveSmallIntegerField(choices=[(1, 'Created'), (2, 'Updated'), (3, 'Deleted')])), + ('object_id', models.PositiveIntegerField()), + ('object_repr', models.CharField(editable=False, max_length=200)), + ('object_data', django.contrib.postgres.fields.jsonb.JSONField(editable=False)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='changes', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-time'], + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 865ff9fbb..e9fb2d543 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -656,6 +656,71 @@ class ReportResult(models.Model): ordering = ['report'] +# +# Change logging +# + +@python_2_unicode_compatible +class ObjectChange(models.Model): + """ + Record a change to an object and the user account associated with that change. + """ + time = models.DateTimeField( + auto_now_add=True, + editable=False + ) + user = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + related_name='changes', + blank=True, + null=True + ) + user_name = models.CharField( + max_length=150, + editable=False + ) + action = models.PositiveSmallIntegerField( + choices=OBJECTCHANGE_ACTION_CHOICES + ) + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField() + changed_object = GenericForeignKey( + ct_field='content_type', + fk_field='object_id' + ) + object_repr = models.CharField( + max_length=200, + editable=False + ) + object_data = JSONField( + editable=False + ) + + class Meta: + ordering = ['-time'] + + def __str__(self): + attribution = 'by {}'.format(self.user_name) if self.user_name else '(no attribution)' + return '{} {} {}'.format( + self.object_repr, + self.get_action_display().lower(), + attribution + ) + + def save(self, *args, **kwargs): + + # Record the user's name and the object's representation as static strings + if self.user is not None: + self.user_name = self.user.username + self.object_repr = str(self.changed_object) + + return super(ObjectChange, self).save(*args, **kwargs) + + # # User actions # diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7acb611f3..83686df94 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -174,6 +174,7 @@ MIDDLEWARE = ( 'utilities.middleware.ExceptionHandlingMiddleware', 'utilities.middleware.LoginRequiredMiddleware', 'utilities.middleware.APIVersionMiddleware', + 'extras.middleware.ChangeLoggingMiddleware', ) ROOT_URLCONF = 'netbox.urls' From 21c4085c51863f589da8e4a7a91ec41425c2b83d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 Jun 2018 13:14:35 -0400 Subject: [PATCH 039/159] Moved object header templates into object base templates --- netbox/dcim/views.py | 4 + netbox/ipam/views.py | 14 +- netbox/templates/_base.html | 1 + netbox/templates/dcim/device.html | 1215 +++++++++-------- netbox/templates/dcim/device_config.html | 3 +- netbox/templates/dcim/device_inventory.html | 137 +- .../templates/dcim/device_lldp_neighbors.html | 3 +- netbox/templates/dcim/device_status.html | 3 +- netbox/templates/dcim/inc/device_header.html | 65 - netbox/templates/ipam/inc/prefix_header.html | 55 - netbox/templates/ipam/inc/vlan_header.html | 46 - netbox/templates/ipam/prefix.html | 343 +++-- netbox/templates/ipam/prefix_ipaddresses.html | 5 +- netbox/templates/ipam/prefix_prefixes.html | 5 +- netbox/templates/ipam/vlan.html | 270 ++-- netbox/templates/ipam/vlan_members.html | 5 +- 16 files changed, 1083 insertions(+), 1091 deletions(-) delete mode 100644 netbox/templates/dcim/inc/device_header.html delete mode 100644 netbox/templates/ipam/inc/prefix_header.html delete mode 100644 netbox/templates/ipam/inc/vlan_header.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6e7aa070c..d4851845a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -945,6 +945,7 @@ class DeviceInventoryView(View): return render(request, 'dcim/device_inventory.html', { 'device': device, 'inventory_items': inventory_items, + 'active_tab': 'inventory', }) @@ -957,6 +958,7 @@ class DeviceStatusView(PermissionRequiredMixin, View): return render(request, 'dcim/device_status.html', { 'device': device, + 'active_tab': 'status', }) @@ -975,6 +977,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): return render(request, 'dcim/device_lldp_neighbors.html', { 'device': device, 'interfaces': interfaces, + 'active_tab': 'lldp-neighbors', }) @@ -987,6 +990,7 @@ class DeviceConfigView(PermissionRequiredMixin, View): return render(request, 'dcim/device_config.html', { 'device': device, + 'active_tab': 'config', }) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1d4575e34..70ef83f49 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -522,6 +522,7 @@ class PrefixPrefixesView(View): 'prefix_table': prefix_table, 'permissions': permissions, 'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix), + 'active_tab': 'prefixes', }) @@ -560,6 +561,7 @@ class PrefixIPAddressesView(View): 'ip_table': ip_table, 'permissions': permissions, 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix), + 'active_tab': 'ip-addresses', }) @@ -859,8 +861,6 @@ class VLANMembersView(View): members = vlan.get_members().select_related('device', 'virtual_machine') members_table = tables.VLANMemberTable(members) - # if request.user.has_perm('dcim.change_interface'): - # members_table.columns.show('pk') paginate = { 'klass': EnhancedPaginator, @@ -868,18 +868,10 @@ class VLANMembersView(View): } RequestConfig(request, paginate).configure(members_table) - # Compile permissions list for rendering the object table - # permissions = { - # 'add': request.user.has_perm('ipam.add_ipaddress'), - # 'change': request.user.has_perm('ipam.change_ipaddress'), - # 'delete': request.user.has_perm('ipam.delete_ipaddress'), - # } - return render(request, 'ipam/vlan_members.html', { 'vlan': vlan, 'members_table': members_table, - # 'permissions': permissions, - # 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix), + 'active_tab': 'members', }) diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index f34c0fbde..27ebb052d 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -34,6 +34,7 @@ {{ message }}
{% endfor %} + {% block header %}{% endblock %} {% block content %}{% endblock %}
{% if settings.BANNER_BOTTOM %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 1b1d3d23a..0cc1e4cf8 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -2,631 +2,696 @@ {% load static from staticfiles %} {% load helpers %} -{% block title %}{{ device }}{% endblock %} +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.dcim.change_device %} + + + Edit this device + + {% endif %} + {% if perms.dcim.delete_device %} + + + Delete this device + + {% endif %} +
+

{% block title %}{{ device }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=device %} + +{% endblock %} {% block content %} -{% include 'dcim/inc/device_header.html' with active_tab='info' %} -
-
-
-
- Device -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Site - {% if device.site.region %} - {{ device.site.region }} - - {% endif %} - {{ device.site }} -
Rack - {% if device.rack %} - {% if device.rack.group %} - {{ device.rack.group }} - - {% endif %} - {{ device.rack }} - {% else %} - None - {% endif %} -
Position - {% if device.parent_bay %} - {% with device.parent_bay.device as parent %} - {{ parent }} {{ device.parent_bay }} - {% if parent.position %} - (U{{ parent.position }} / {{ parent.get_face_display }}) - {% endif %} - {% endwith %} - {% elif device.rack and device.position %} - U{{ device.position }} / {{ device.get_face_display }} - {% elif device.rack and device.device_type.u_height %} - Not racked - {% else %} - N/A - {% endif %} -
Tenant - {% if device.tenant %} - {% if device.tenant.group %} - {{ device.tenant.group }} - - {% endif %} - {{ device.tenant }} - {% else %} - None - {% endif %} -
Device Type - {{ device.device_type.full_name }} ({{ device.device_type.u_height }}U) -
Serial Number - {% if device.serial %} - {{ device.serial }} - {% else %} - N/A - {% endif %} -
Asset Tag - {% if device.asset_tag %} - {{ device.asset_tag }} - {% else %} - N/A - {% endif %} -
Tags - {% for tag in device.tags.all %} - {% tag 'dcim:device_list' tag %} - {% empty %} - N/A - {% endfor %} -
-
- {% if vc_members %} +
+
- Virtual Chassis + Device
- - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - {% for vc_member in vc_members %} - - - - - - - {% endfor %}
DevicePositionMasterPrioritySite + {% if device.site.region %} + {{ device.site.region }} + + {% endif %} + {{ device.site }} +
Rack + {% if device.rack %} + {% if device.rack.group %} + {{ device.rack.group }} + + {% endif %} + {{ device.rack }} + {% else %} + None + {% endif %} +
Position + {% if device.parent_bay %} + {% with device.parent_bay.device as parent %} + {{ parent }} {{ device.parent_bay }} + {% if parent.position %} + (U{{ parent.position }} / {{ parent.get_face_display }}) + {% endif %} + {% endwith %} + {% elif device.rack and device.position %} + U{{ device.position }} / {{ device.get_face_display }} + {% elif device.rack and device.device_type.u_height %} + Not racked + {% else %} + N/A + {% endif %} +
Tenant + {% if device.tenant %} + {% if device.tenant.group %} + {{ device.tenant.group }} + + {% endif %} + {{ device.tenant }} + {% else %} + None + {% endif %} +
Device Type + {{ device.device_type.full_name }} ({{ device.device_type.u_height }}U) +
Serial Number + {% if device.serial %} + {{ device.serial }} + {% else %} + N/A + {% endif %} +
Asset Tag + {% if device.asset_tag %} + {{ device.asset_tag }} + {% else %} + N/A + {% endif %} +
Tags + {% for tag in device.tags.all %} + {% tag 'dcim:device_list' tag %} + {% empty %} + N/A + {% endfor %} +
- {{ vc_member }} - {{ vc_member.vc_position }}{% if device.virtual_chassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|default:"" }}
- + {% if vc_members %} +
+
+ Virtual Chassis +
+ + + + + + + + {% for vc_member in vc_members %} + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vc_member }} + {{ vc_member.vc_position }}{% if device.virtual_chassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|default:"" }}
+ +
+ {% endif %} +
+
+ Management +
+ + + + + + + + + + + + + + + + + + + + + + {% if device.cluster %} + + + + {% endif %} - {% if perms.dcim.delete_virtualchassis %} - - Delete Virtual Chassis - +
Role + {{ device.device_role }} +
Platform + {% if device.platform %} + {{ device.platform }} + {% else %} + None + {% endif %} +
Status + {{ device.get_status_display }} +
Primary IPv4 + {% if device.primary_ip4 %} + {{ device.primary_ip4.address.ip }} + {% if device.primary_ip4.nat_inside %} + (NAT for {{ device.primary_ip4.nat_inside.address.ip }}) + {% elif device.primary_ip4.nat_outside %} + (NAT: {{ device.primary_ip4.nat_outside.address.ip }}) + {% endif %} + {% else %} + N/A + {% endif %} +
Primary IPv6 + {% if device.primary_ip6 %} + {{ device.primary_ip6.address.ip }} + {% if device.primary_ip6.nat_inside %} + (NAT for {{ device.primary_ip6.nat_inside.address.ip }}) + {% elif device.primary_ip6.nat_outside %} + (NAT: {{ device.primary_ip6.nat_outside.address.ip }}) + {% endif %} + {% else %} + N/A + {% endif %} +
Cluster + {% if device.cluster.group %} + {{ device.cluster.group }} + + {% endif %} + {{ device.cluster }} +
+
+ {% with device.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} +
+
+ Comments +
+
+ {% if device.comments %} + {{ device.comments|gfm }} + {% else %} + None {% endif %}
- {% endif %} -
-
- Management -
- - - - - - - - - - - - - - - - - - - - - - {% if device.cluster %} - - - - - {% endif %} -
Role - {{ device.device_role }} -
Platform - {% if device.platform %} - {{ device.platform }} - {% else %} - None - {% endif %} -
Status - {{ device.get_status_display }} -
Primary IPv4 - {% if device.primary_ip4 %} - {{ device.primary_ip4.address.ip }} - {% if device.primary_ip4.nat_inside %} - (NAT for {{ device.primary_ip4.nat_inside.address.ip }}) - {% elif device.primary_ip4.nat_outside %} - (NAT: {{ device.primary_ip4.nat_outside.address.ip }}) - {% endif %} - {% else %} - N/A - {% endif %} -
Primary IPv6 - {% if device.primary_ip6 %} - {{ device.primary_ip6.address.ip }} - {% if device.primary_ip6.nat_inside %} - (NAT for {{ device.primary_ip6.nat_inside.address.ip }}) - {% elif device.primary_ip6.nat_outside %} - (NAT: {{ device.primary_ip6.nat_outside.address.ip }}) - {% endif %} - {% else %} - N/A - {% endif %} -
Cluster - {% if device.cluster.group %} - {{ device.cluster.group }} - - {% endif %} - {{ device.cluster }} -
- {% with device.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} -
-
- Comments -
-
- {% if device.comments %} - {{ device.comments|gfm }} - {% else %} - None +
+
+
+ Console / Power +
+ + {% for cp in console_ports %} + {% include 'dcim/inc/consoleport.html' %} + {% empty %} + {% if device.device_type.console_port_templates.exists %} + + + + {% endif %} + {% endfor %} + {% for pp in power_ports %} + {% include 'dcim/inc/powerport.html' %} + {% empty %} + {% if device.device_type.power_port_templates.exists %} + + + + {% endif %} + {% endfor %} +
+ No console ports defined + {% if perms.dcim.add_consoleport %} + + {% endif %} +
+ No power ports defined + {% if perms.dcim.add_powerport %} + + {% endif %} +
+ {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} + {% endif %}
-
-
-
-
-
- Console / Power -
- - {% for cp in console_ports %} - {% include 'dcim/inc/consoleport.html' %} - {% empty %} - {% if device.device_type.console_port_templates.exists %} - - - + {% if request.user.is_authenticated %} +
+
+ Secrets +
+ {% if secrets %} +
- No console ports defined - {% if perms.dcim.add_consoleport %} - - {% endif %} -
+ {% for secret in secrets %} + {% include 'secrets/inc/secret_tr.html' %} + {% endfor %} +
+ {% else %} +
+ None found +
{% endif %} - {% endfor %} - {% for pp in power_ports %} - {% include 'dcim/inc/powerport.html' %} - {% empty %} - {% if device.device_type.power_port_templates.exists %} - - - No power ports defined - {% if perms.dcim.add_powerport %} - - {% endif %} - - - {% endif %} - {% endfor %} - - {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} - {% endif %} -
- {% if request.user.is_authenticated %}
- Secrets + Services
- {% if secrets %} + {% if services %} - {% for secret in secrets %} - {% include 'secrets/inc/secret_tr.html' %} + {% for service in services %} + {% include 'ipam/inc/service.html' %} {% endfor %}
{% else %}
- None found + None
{% endif %} - {% if perms.secrets.add_secret %} -
- {% csrf_token %} -
+ {% if perms.ipam.add_service %} {% endif %}
- {% endif %} -
-
- Services -
- {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
- {% else %} -
- None -
- {% endif %} - {% if perms.ipam.add_service %} - - {% endif %} -
-
-
- Images -
- {% include 'inc/image_attachments.html' with images=device.images.all %} - {% if perms.extras.add_imageattachment %} - - {% endif %} -
-
-
- Related Devices -
- {% if related_devices %} - - {% for rd in related_devices %} - - - - - - {% endfor %} -
- {{ rd }} - - {% if rd.rack %} - Rack {{ rd.rack }} - {% else %} - - {% endif %} - {{ rd.device_type.full_name }}
- {% else %} -
None found
- {% endif %} -
-
-
-
-
- {% if device_bays or device.device_type.is_parent_device %} - {% if perms.dcim.delete_devicebay %} -
- {% csrf_token %} - {% endif %}
- Device Bays + Images
- - - - {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} - - {% endif %} - - - - - - - {% for devicebay in device_bays %} - {% include 'dcim/inc/devicebay.html' %} - {% empty %} + {% include 'inc/image_attachments.html' with images=device.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} + +
+
+ Related Devices +
+ {% if related_devices %} +
NameInstalled Device
+ {% for rd in related_devices %} - + + + {% endfor %} - -
— No device bays defined — + {{ rd }} + + {% if rd.rack %} + Rack {{ rd.rack }} + {% else %} + + {% endif %} + {{ rd.device_type.full_name }}
- + + {% else %} +
None found
+ {% endif %}
- {% if perms.dcim.delete_devicebay %} -
+
+
+
+
+ {% if device_bays or device.device_type.is_parent_device %} + {% if perms.dcim.delete_devicebay %} +
+ {% csrf_token %} + {% endif %} +
+
+ Device Bays +
+ + + + {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} + + {% endif %} + + + + + + + {% for devicebay in device_bays %} + {% include 'dcim/inc/devicebay.html' %} + {% empty %} + + + + {% endfor %} + +
NameInstalled Device
— No device bays defined —
+ +
+ {% if perms.dcim.delete_devicebay %} +
+ {% endif %} {% endif %} - {% endif %} - {% if interfaces or device.device_type.is_network_device %} - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} -
- {% csrf_token %} - + {% if interfaces or device.device_type.is_network_device %} + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% csrf_token %} + + {% endif %} +
+
+ Interfaces +
+ +
+
+ + + + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% endif %} + + + + + + + + + + + {% for iface in interfaces %} + {% include 'dcim/inc/interface.html' %} + {% empty %} + + + + {% endfor %} + +
NameLAGDescriptionMTUMAC AddressConnection
— No interfaces defined —
+ +
+ {% if perms.dcim.delete_interface %} +
+ {% endif %} {% endif %} -
-
- Interfaces -
- + {% if cs_ports or device.device_type.is_console_server %} + {% if perms.dcim.delete_consoleserverport %} +
+ {% csrf_token %} + {% endif %} +
+
+ Console Server Ports +
+ + + + {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} + + {% endif %} + + + + + + + {% for csp in cs_ports %} + {% include 'dcim/inc/consoleserverport.html' %} + {% empty %} + + + + {% endfor %} + +
NameConnection
— No console server ports defined —
+
- - - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' %} - {% empty %} + {% if perms.dcim.delete_consoleserverport %} + + {% endif %} + {% endif %} + {% if power_outlets or device.device_type.is_pdu %} + {% if perms.dcim.delete_poweroutlet %} + + {% csrf_token %} + {% endif %} +
+
+ Power Outlets +
+
NameLAGDescriptionMTUMAC AddressConnection
+ - + {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} + + {% endif %} + + + - {% endfor %} - -
— No interfaces defined —NameConnection
- -
- {% if perms.dcim.delete_interface %} - - {% endif %} - {% endif %} - {% if cs_ports or device.device_type.is_console_server %} - {% if perms.dcim.delete_consoleserverport %} -
- {% csrf_token %} - {% endif %} -
-
- Console Server Ports + + + {% for po in power_outlets %} + {% include 'dcim/inc/poweroutlet.html' %} + {% empty %} + + — No power outlets defined — + + {% endfor %} + + +
- - - - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - {% endif %} - - - - - - - {% for csp in cs_ports %} - {% include 'dcim/inc/consoleserverport.html' %} - {% empty %} - - - - {% endfor %} - -
NameConnection
— No console server ports defined —
- -
- {% if perms.dcim.delete_consoleserverport %} -
+ {% if perms.dcim.delete_poweroutlet %} + + {% endif %} {% endif %} - {% endif %} - {% if power_outlets or device.device_type.is_pdu %} - {% if perms.dcim.delete_poweroutlet %} -
- {% csrf_token %} - {% endif %} -
-
- Power Outlets -
- - - - {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} - - {% endif %} - - - - - - - {% for po in power_outlets %} - {% include 'dcim/inc/poweroutlet.html' %} - {% empty %} - - - - {% endfor %} - -
NameConnection
— No power outlets defined —
- -
- {% if perms.dcim.delete_poweroutlet %} -
- {% endif %} - {% endif %} -
-
+
+
{% include 'inc/graphs_modal.html' %} {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} diff --git a/netbox/templates/dcim/device_config.html b/netbox/templates/dcim/device_config.html index b62ff0211..210a9379a 100644 --- a/netbox/templates/dcim/device_config.html +++ b/netbox/templates/dcim/device_config.html @@ -1,11 +1,10 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% load staticfiles %} {% block title %}{{ device }} - Config{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} - {% include 'dcim/inc/device_header.html' with active_tab='config' %}
diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index 1db2dcefa..1efbd0fbc 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -1,77 +1,76 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% block title %}{{ device }} - Inventory{% endblock %} {% block content %} -{% include 'dcim/inc/device_header.html' with active_tab='inventory' %} -
-
-
-
- Chassis -
- - - - - - - - - - - - - -
Model{{ device.device_type.full_name }}
Serial Number - {% if device.serial %} - {{ device.serial }} - {% else %} - N/A - {% endif %} -
Asset Tag - {% if device.asset_tag %} - {{ device.asset_tag }} - {% else %} - N/A - {% endif %} -
-
-
-
-
-
- Hardware -
- - - - - - - - - - - - - - - {% for item in inventory_items %} - {% with template_name='dcim/inc/inventoryitem.html' indent=0 %} - {% include template_name %} - {% endwith %} - {% endfor %} - -
NameManufacturerPart NumberSerial NumberAsset TagDescription
- {% if perms.dcim.add_inventoryitem %} - {% endblock %} diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index 0e423ad56..c0c82f459 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -1,10 +1,9 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% block title %}{{ device }} - LLDP Neighbors{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} - {% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
LLDP Neighbors diff --git a/netbox/templates/dcim/device_status.html b/netbox/templates/dcim/device_status.html index 7c62b3971..7743cc635 100644 --- a/netbox/templates/dcim/device_status.html +++ b/netbox/templates/dcim/device_status.html @@ -1,11 +1,10 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% load staticfiles %} {% block title %}{{ device }} - Status{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} - {% include 'dcim/inc/device_header.html' with active_tab='status' %}
diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html deleted file mode 100644 index 92acd297d..000000000 --- a/netbox/templates/dcim/inc/device_header.html +++ /dev/null @@ -1,65 +0,0 @@ -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.dcim.change_device %} - - - Edit this device - - {% endif %} - {% if perms.dcim.delete_device %} - - - Delete this device - -{% endif %} -
-

{{ device }}

-{% include 'inc/created_updated.html' with obj=device %} - diff --git a/netbox/templates/ipam/inc/prefix_header.html b/netbox/templates/ipam/inc/prefix_header.html deleted file mode 100644 index f3c694c64..000000000 --- a/netbox/templates/ipam/inc/prefix_header.html +++ /dev/null @@ -1,55 +0,0 @@ -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %} - - Add Child Prefix - - {% endif %} - {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %} - - - Add an IP Address - - {% endif %} - {% if perms.ipam.change_prefix %} - - - Edit this prefix - - {% endif %} - {% if perms.ipam.delete_prefix %} - - - Delete this prefix - - {% endif %} -
-

{{ prefix }}

-{% include 'inc/created_updated.html' with obj=prefix %} - diff --git a/netbox/templates/ipam/inc/vlan_header.html b/netbox/templates/ipam/inc/vlan_header.html deleted file mode 100644 index bf5d4ccdd..000000000 --- a/netbox/templates/ipam/inc/vlan_header.html +++ /dev/null @@ -1,46 +0,0 @@ -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.ipam.change_vlan %} - - - Edit this VLAN - - {% endif %} - {% if perms.ipam.delete_vlan %} - - - Delete this VLAN - - {% endif %} -
-

{% block title %}VLAN {{ vlan.display_name }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=vlan %} - diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 29e9c07a0..c24e02414 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -1,152 +1,207 @@ {% extends '_base.html' %} {% load helpers %} -{% block title %}{{ prefix }}{% endblock %} +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %} + + Add Child Prefix + + {% endif %} + {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %} + + + Add an IP Address + + {% endif %} + {% if perms.ipam.change_prefix %} + + + Edit this prefix + + {% endif %} + {% if perms.ipam.delete_prefix %} + + + Delete this prefix + + {% endif %} +
+

{% block title %}{{ prefix }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=prefix %} + +{% endblock %} {% block content %} -{% include 'ipam/inc/prefix_header.html' with active_tab='prefix' %} -
-
-
-
- Prefix +
+
+
+
+ Prefix +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Family{{ prefix.get_family_display }}
VRF + {% if prefix.vrf %} + {{ prefix.vrf }} ({{ prefix.vrf.rd }}) + {% else %} + Global + {% endif %} +
Tenant + {% if prefix.tenant %} + {% if prefix.tenant.group %} + {{ prefix.tenant.group }} + + {% endif %} + {{ prefix.tenant }} + {% elif prefix.vrf.tenant %} + {% if prefix.vrf.tenant.group %} + {{ prefix.vrf.tenant.group }} + + {% endif %} + {{ prefix.vrf.tenant }} + + {% else %} + None + {% endif %} +
Aggregate + {% if aggregate %} + {{ aggregate.prefix }} ({{ aggregate.rir }}) + {% else %} + None + {% endif %} +
Site + {% if prefix.site %} + {% if prefix.site.region %} + {{ prefix.site.region }} + + {% endif %} + {{ prefix.site }} + {% else %} + None + {% endif %} +
VLAN + {% if prefix.vlan %} + {% if prefix.vlan.group %} + {{ prefix.vlan.group }} + + {% endif %} + {{ prefix.vlan.display_name }} + {% else %} + None + {% endif %} +
Status + {{ prefix.get_status_display }} +
Role + {% if prefix.role %} + {{ prefix.role }} + {% else %} + None + {% endif %} +
Description + {% if prefix.description %} + {{ prefix.description }} + {% else %} + N/A + {% endif %} +
Is a pool + {% if prefix.is_pool %} + + {% else %} + + {% endif %} +
Tags + {% for tag in prefix.tags.all %} + {% tag 'ipam:prefix_list' tag %} + {% empty %} + N/A + {% endfor %} +
Utilization{% utilization_graph prefix.get_utilization %}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Family{{ prefix.get_family_display }}
VRF - {% if prefix.vrf %} - {{ prefix.vrf }} ({{ prefix.vrf.rd }}) - {% else %} - Global - {% endif %} -
Tenant - {% if prefix.tenant %} - {% if prefix.tenant.group %} - {{ prefix.tenant.group }} - - {% endif %} - {{ prefix.tenant }} - {% elif prefix.vrf.tenant %} - {% if prefix.vrf.tenant.group %} - {{ prefix.vrf.tenant.group }} - - {% endif %} - {{ prefix.vrf.tenant }} - - {% else %} - None - {% endif %} -
Aggregate - {% if aggregate %} - {{ aggregate.prefix }} ({{ aggregate.rir }}) - {% else %} - None - {% endif %} -
Site - {% if prefix.site %} - {% if prefix.site.region %} - {{ prefix.site.region }} - - {% endif %} - {{ prefix.site }} - {% else %} - None - {% endif %} -
VLAN - {% if prefix.vlan %} - {% if prefix.vlan.group %} - {{ prefix.vlan.group }} - - {% endif %} - {{ prefix.vlan.display_name }} - {% else %} - None - {% endif %} -
Status - {{ prefix.get_status_display }} -
Role - {% if prefix.role %} - {{ prefix.role }} - {% else %} - None - {% endif %} -
Description - {% if prefix.description %} - {{ prefix.description }} - {% else %} - N/A - {% endif %} -
Is a pool - {% if prefix.is_pool %} - - {% else %} - - {% endif %} -
Tags - {% for tag in prefix.tags.all %} - {% tag 'ipam:prefix_list' tag %} - {% empty %} - N/A - {% endfor %} -
Utilization{% utilization_graph prefix.get_utilization %}
+ {% with prefix.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} +
- {% with prefix.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} -
-
-
- {% if duplicate_prefix_table.rows %} - {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %} - {% endif %} - {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %} -
-
+
+ {% if duplicate_prefix_table.rows %} + {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %} + {% endif %} + {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %} +
+
{% endblock %} diff --git a/netbox/templates/ipam/prefix_ipaddresses.html b/netbox/templates/ipam/prefix_ipaddresses.html index 02e90569d..1da5b7518 100644 --- a/netbox/templates/ipam/prefix_ipaddresses.html +++ b/netbox/templates/ipam/prefix_ipaddresses.html @@ -1,9 +1,8 @@ -{% extends '_base.html' %} +{% extends 'ipam/prefix.html' %} -{% block title %}{{ prefix }} - IP Addresses{% endblock %} +{% block title %}{{ block.super }} - IP Addresses{% endblock %} {% block content %} - {% include 'ipam/inc/prefix_header.html' with active_tab='ip-addresses' %}
{% include 'utilities/obj_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %} diff --git a/netbox/templates/ipam/prefix_prefixes.html b/netbox/templates/ipam/prefix_prefixes.html index 2535b672d..9cf50a640 100644 --- a/netbox/templates/ipam/prefix_prefixes.html +++ b/netbox/templates/ipam/prefix_prefixes.html @@ -1,9 +1,8 @@ -{% extends '_base.html' %} +{% extends 'ipam/prefix.html' %} -{% block title %}{{ prefix }} - Prefixes{% endblock %} +{% block title %}{{ block.super }} - Prefixes{% endblock %} {% block content %} - {% include 'ipam/inc/prefix_header.html' with active_tab='prefixes' %}
{% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index ac874282f..76ee5f65e 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -1,118 +1,166 @@ {% extends '_base.html' %} {% load helpers %} -{% block content %} -{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %} -
-
-
-
- VLAN -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Site - {% if vlan.site %} - {% if vlan.site.region %} - {{ vlan.site.region }} - - {% endif %} - {{ vlan.site }} - {% else %} - None - {% endif %} -
Group - {% if vlan.group %} - {{ vlan.group }} - {% else %} - None - {% endif %} -
VLAN ID{{ vlan.vid }}
Name{{ vlan.name }}
Tenant - {% if vlan.tenant %} - {% if vlan.tenant.group %} - {{ vlan.tenant.group }} - - {% endif %} - {{ vlan.tenant }} - {% else %} - None - {% endif %} -
Status - {{ vlan.get_status_display }} -
Role - {% if vlan.role %} - {{ vlan.role }} - {% else %} - None - {% endif %} -
Description - {% if vlan.description %} - {{ vlan.description }} - {% else %} - N/A - {% endif %} -
Tags - {% for tag in vlan.tags.all %} - {% tag 'ipam:vlan_list' tag %} - {% empty %} - N/A - {% endfor %} -
+{% block header %} +
+
+
- {% with vlan.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} -
-
-
-
- Prefixes +
+
+
+ + + +
- {% include 'responsive_table.html' with table=prefix_table %} - {% if perms.ipam.add_prefix %} - - {% endif %} +
-
-
+
+
+ {% if perms.ipam.change_vlan %} + + + Edit this VLAN + + {% endif %} + {% if perms.ipam.delete_vlan %} + + + Delete this VLAN + + {% endif %} +
+

{% block title %}VLAN {{ vlan.display_name }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=vlan %} + +{% endblock %} + +{% block content %} +
+
+
+
+ VLAN +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Site + {% if vlan.site %} + {% if vlan.site.region %} + {{ vlan.site.region }} + + {% endif %} + {{ vlan.site }} + {% else %} + None + {% endif %} +
Group + {% if vlan.group %} + {{ vlan.group }} + {% else %} + None + {% endif %} +
VLAN ID{{ vlan.vid }}
Name{{ vlan.name }}
Tenant + {% if vlan.tenant %} + {% if vlan.tenant.group %} + {{ vlan.tenant.group }} + + {% endif %} + {{ vlan.tenant }} + {% else %} + None + {% endif %} +
Status + {{ vlan.get_status_display }} +
Role + {% if vlan.role %} + {{ vlan.role }} + {% else %} + None + {% endif %} +
Description + {% if vlan.description %} + {{ vlan.description }} + {% else %} + N/A + {% endif %} +
Tags + {% for tag in vlan.tags.all %} + {% tag 'ipam:vlan_list' tag %} + {% empty %} + N/A + {% endfor %} +
+
+ {% with vlan.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} +
+
+
+
+ Prefixes +
+ {% include 'responsive_table.html' with table=prefix_table %} + {% if perms.ipam.add_prefix %} + + {% endif %} +
+
+
{% endblock %} diff --git a/netbox/templates/ipam/vlan_members.html b/netbox/templates/ipam/vlan_members.html index 27d5d50f7..9fc803e09 100644 --- a/netbox/templates/ipam/vlan_members.html +++ b/netbox/templates/ipam/vlan_members.html @@ -1,9 +1,8 @@ -{% extends '_base.html' %} +{% extends 'ipam/vlan.html' %} -{% block title %}{{ vlan }} - Members{% endblock %} +{% block title %}{{ block.super }} - Members{% endblock %} {% block content %} - {% include 'ipam/inc/vlan_header.html' with active_tab='members' %}
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %} From 3c2e0b0b173dc50ad50fd504a1e923dd1e3c3063 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 Jun 2018 16:15:14 -0400 Subject: [PATCH 040/159] Added changelog views --- netbox/circuits/urls.py | 5 + netbox/dcim/urls.py | 19 +- netbox/extras/admin.py | 4 +- netbox/extras/models.py | 5 + netbox/extras/views.py | 39 +++- netbox/ipam/urls.py | 11 ++ netbox/secrets/urls.py | 4 + netbox/templates/circuits/circuit.html | 87 +++++---- netbox/templates/circuits/provider.html | 97 +++++----- netbox/templates/dcim/device.html | 11 +- netbox/templates/dcim/devicetype.html | 67 ++++--- netbox/templates/dcim/rack.html | 93 +++++----- netbox/templates/dcim/site.html | 106 ++++++----- netbox/templates/extras/changelog.html | 36 ++++ netbox/templates/ipam/aggregate.html | 85 +++++---- netbox/templates/ipam/ipaddress.html | 89 +++++---- netbox/templates/ipam/prefix.html | 15 +- netbox/templates/ipam/vlan.html | 11 +- netbox/templates/ipam/vrf.html | 83 +++++---- netbox/templates/secrets/secret.html | 61 +++--- netbox/templates/tenancy/tenant.html | 89 +++++---- netbox/templates/virtualization/cluster.html | 89 +++++---- .../virtualization/virtualmachine.html | 173 ++++++++++-------- netbox/tenancy/urls.py | 4 + netbox/virtualization/urls.py | 6 + netbox/virtualization/views.py | 8 +- 26 files changed, 790 insertions(+), 507 deletions(-) create mode 100644 netbox/templates/extras/changelog.html diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 569c1eb9a..6b5529a42 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ChangeLogView from . import views +from .models import Circuit, CircuitType, Provider app_name = 'circuits' urlpatterns = [ @@ -16,6 +18,7 @@ urlpatterns = [ url(r'^providers/(?P[\w-]+)/$', views.ProviderView.as_view(), name='provider'), url(r'^providers/(?P[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'), url(r'^providers/(?P[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'), + url(r'^providers/(?P[\w-]+)/changelog/$', ChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), # Circuit types url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'), @@ -23,6 +26,7 @@ urlpatterns = [ url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), url(r'^circuit-types/(?P[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + url(r'^circuit-types/(?P[\w-]+)/changelog/$', ChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'), @@ -33,6 +37,7 @@ urlpatterns = [ url(r'^circuits/(?P\d+)/$', views.CircuitView.as_view(), name='circuit'), url(r'^circuits/(?P\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'), url(r'^circuits/(?P\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'), + url(r'^circuits/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), url(r'^circuits/(?P\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'), # Circuit terminations diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 5682bd8e7..67e957f9b 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -2,11 +2,14 @@ from __future__ import unicode_literals from django.conf.urls import url -from extras.views import ImageAttachmentEditView +from extras.views import ChangeLogView, ImageAttachmentEditView from ipam.views import ServiceCreateView from secrets.views import secret_add from . import views -from .models import Device, Rack, Site +from .models import ( + Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole, Region, Site, + VirtualChassis, +) app_name = 'dcim' urlpatterns = [ @@ -17,6 +20,7 @@ urlpatterns = [ url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'), url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), url(r'^regions/(?P\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), + url(r'^regions/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), # Sites url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), @@ -26,6 +30,7 @@ urlpatterns = [ url(r'^sites/(?P[\w-]+)/$', views.SiteView.as_view(), name='site'), url(r'^sites/(?P[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), + url(r'^sites/(?P[\w-]+)/changelog/$', ChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), url(r'^sites/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Rack groups @@ -34,6 +39,7 @@ urlpatterns = [ url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), url(r'^rack-groups/(?P\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'), + url(r'^rack-groups/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), # Rack roles url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'), @@ -41,6 +47,7 @@ urlpatterns = [ url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), + url(r'^rack-roles/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), # Rack reservations url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'), @@ -48,6 +55,7 @@ urlpatterns = [ url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), url(r'^rack-reservations/(?P\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), url(r'^rack-reservations/(?P\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), + url(r'^rack-reservations/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), # Racks url(r'^racks/$', views.RackListView.as_view(), name='rack_list'), @@ -59,6 +67,7 @@ urlpatterns = [ url(r'^racks/(?P\d+)/$', views.RackView.as_view(), name='rack'), url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), + url(r'^racks/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), url(r'^racks/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), @@ -68,6 +77,7 @@ urlpatterns = [ url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), url(r'^manufacturers/(?P[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + url(r'^manufacturers/(?P[\w-]+)/changelog/$', ChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'), @@ -78,6 +88,7 @@ urlpatterns = [ url(r'^device-types/(?P\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'), url(r'^device-types/(?P\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), url(r'^device-types/(?P\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), + url(r'^device-types/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), # Console port templates url(r'^device-types/(?P\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'), @@ -110,6 +121,7 @@ urlpatterns = [ url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), url(r'^device-roles/(?P[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + url(r'^device-roles/(?P[\w-]+)/changelog/$', ChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'), @@ -117,6 +129,7 @@ urlpatterns = [ url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'), url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), url(r'^platforms/(?P[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'), + url(r'^platforms/(?P[\w-]+)/changelog/$', ChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'), @@ -128,6 +141,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/$', views.DeviceView.as_view(), name='device'), url(r'^devices/(?P\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), + url(r'^devices/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), url(r'^devices/(?P\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), url(r'^devices/(?P\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), @@ -221,6 +235,7 @@ urlpatterns = [ url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), url(r'^virtual-chassis/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), + url(r'^virtual-chassis/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), url(r'^virtual-chassis/(?P\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), url(r'^virtual-chassis-members/(?P\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 200387f88..3da723b7d 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -132,7 +132,7 @@ class TopologyMapAdmin(admin.ModelAdmin): @admin.register(ObjectChange) class ObjectChangeAdmin(admin.ModelAdmin): actions = None - fields = ['time', 'content_type', 'display_object', 'action', 'display_user'] + fields = ['time', 'content_type', 'display_object', 'action', 'display_user', 'object_data'] list_display = ['time', 'content_type', 'display_object', 'display_action', 'display_user'] list_filter = ['time', 'action', 'user__username'] list_select_related = ['content_type', 'user'] @@ -156,7 +156,7 @@ class ObjectChangeAdmin(admin.ModelAdmin): OBJECTCHANGE_ACTION_DELETE: 'deletelink', } return mark_safe('{}'.format(icon[obj.action], obj.get_action_display())) - display_user.short_description = 'action' + display_action.short_description = 'action' def display_object(self, obj): if hasattr(obj.changed_object, 'get_absolute_url'): diff --git a/netbox/extras/models.py b/netbox/extras/models.py index e9fb2d543..759f90b4b 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from collections import OrderedDict from datetime import date +import json import graphviz from django.contrib.auth.models import User @@ -720,6 +721,10 @@ class ObjectChange(models.Model): return super(ObjectChange, self).save(*args, **kwargs) + @property + def object_data_pretty(self): + return json.dumps(self.object_data, indent=4, sort_keys=True) + # # User actions diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 130437356..d3dd2c3d2 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,7 +1,9 @@ from __future__ import unicode_literals +from django import template from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.contenttypes.models import ContentType from django.db.models import Count from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render, reverse @@ -12,7 +14,7 @@ from taggit.models import Tag from utilities.forms import ConfirmationForm from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView from .forms import ImageAttachmentForm, TagForm -from .models import ImageAttachment, ReportResult, UserAction +from .models import ImageAttachment, ObjectChange, ReportResult, UserAction from .reports import get_report, get_reports from .tables import TagTable @@ -50,6 +52,41 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): default_return_url = 'extras:tag_list' +# +# Change logging +# + +class ChangeLogView(View): + """ + Present a history of changes made to an object. + """ + + def get(self, request, model, **kwargs): + + # Get object my model and kwargs (e.g. slug='foo') + obj = get_object_or_404(model, **kwargs) + + # Gather all changes for this object + content_type = ContentType.objects.get_for_model(model) + changes = ObjectChange.objects.filter(content_type=content_type, object_id=obj.pk) + + # Check whether a header template exists for this model + base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name) + try: + template.loader.get_template(base_template) + object_var = model._meta.model_name + except template.TemplateDoesNotExist: + base_template = '_base.html' + object_var = 'obj' + + return render(request, 'extras/changelog.html', { + object_var: obj, + 'changes': changes, + 'base_template': base_template, + 'active_tab': 'changelog', + }) + + # # Image attachments # diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index aa7c17a5c..89c30a6db 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ChangeLogView from . import views +from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF app_name = 'ipam' @@ -17,6 +19,7 @@ urlpatterns = [ url(r'^vrfs/(?P\d+)/$', views.VRFView.as_view(), name='vrf'), url(r'^vrfs/(?P\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'), url(r'^vrfs/(?P\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'), + url(r'^vrfs/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), # RIRs url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'), @@ -24,6 +27,7 @@ urlpatterns = [ url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'), url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), url(r'^rirs/(?P[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'), + url(r'^vrfs/(?P[\w-]+)/changelog/$', ChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'), @@ -34,6 +38,7 @@ urlpatterns = [ url(r'^aggregates/(?P\d+)/$', views.AggregateView.as_view(), name='aggregate'), url(r'^aggregates/(?P\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'), url(r'^aggregates/(?P\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'), + url(r'^aggregates/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), # Roles url(r'^roles/$', views.RoleListView.as_view(), name='role_list'), @@ -41,6 +46,7 @@ urlpatterns = [ url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'), url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), url(r'^roles/(?P[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'), + url(r'^roles/(?P[\w-]+)/changelog/$', ChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'), @@ -51,6 +57,7 @@ urlpatterns = [ url(r'^prefixes/(?P\d+)/$', views.PrefixView.as_view(), name='prefix'), url(r'^prefixes/(?P\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'), url(r'^prefixes/(?P\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'), + url(r'^prefixes/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), url(r'^prefixes/(?P\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), url(r'^prefixes/(?P\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), @@ -61,6 +68,7 @@ urlpatterns = [ url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), + url(r'^ip-addresses/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), url(r'^ip-addresses/(?P\d+)/$', views.IPAddressView.as_view(), name='ipaddress'), url(r'^ip-addresses/(?P\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), @@ -72,6 +80,7 @@ urlpatterns = [ url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), url(r'^vlan-groups/(?P\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), + url(r'^vlan-groups/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), # VLANs url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'), @@ -83,9 +92,11 @@ urlpatterns = [ url(r'^vlans/(?P\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'), url(r'^vlans/(?P\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), url(r'^vlans/(?P\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), + url(r'^vlans/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), # Services url(r'^services/(?P\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'), url(r'^services/(?P\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'), + url(r'^services/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), ] diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index cd6415719..9c9324c4a 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ChangeLogView from . import views +from .models import Secret, SecretRole app_name = 'secrets' urlpatterns = [ @@ -13,6 +15,7 @@ urlpatterns = [ url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), url(r'^secret-roles/(?P[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + url(r'^secret-roles/(?P[\w-]+)/changelog/$', ChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'), @@ -22,5 +25,6 @@ urlpatterns = [ url(r'^secrets/(?P\d+)/$', views.SecretView.as_view(), name='secret'), url(r'^secrets/(?P\d+)/edit/$', views.secret_edit, name='secret_edit'), url(r'^secrets/(?P\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'), + url(r'^secrets/(?P\d+)/changelog/$', ChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), ] diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 509c6da89..048c16862 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -1,44 +1,57 @@ {% extends '_base.html' %} {% load helpers %} +{% block title %}{{ circuit }}{% endblock %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.circuits.change_circuit %} + + + Edit this circuit + + {% endif %} + {% if perms.circuits.delete_circuit %} + + + Delete this circuit + + {% endif %} +
+

{{ circuit }}

+ {% include 'inc/created_updated.html' with obj=circuit %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.circuits.change_circuit %} - - - Edit this circuit - - {% endif %} - {% if perms.circuits.delete_circuit %} - - - Delete this circuit - - {% endif %} -
-

{% block title %}{{ circuit.provider }} - {{ circuit.cid }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=circuit %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index e19175c7f..d2fed8647 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -2,49 +2,62 @@ {% load static from staticfiles %} {% load helpers %} +{% block title %}{{ provider }}{% endblock %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if show_graphs %} + + {% endif %} + {% if perms.circuits.change_provider %} + + + Edit this provider + + {% endif %} + {% if perms.circuits.delete_provider %} + + + Delete this provider + + {% endif %} +
+

{{ provider }}

+ {% include 'inc/created_updated.html' with obj=provider %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if show_graphs %} - - {% endif %} - {% if perms.circuits.change_provider %} - - - Edit this provider - - {% endif %} - {% if perms.circuits.delete_provider %} - - - Delete this provider - - {% endif %} -
-

{% block title %}{{ provider }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=provider %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 0cc1e4cf8..1c1539f89 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -2,6 +2,8 @@ {% load static from staticfiles %} {% load helpers %} +{% block title %}{{ device }}{% endblock %} + {% block header %}
@@ -45,11 +47,11 @@ {% endif %}
-

{% block title %}{{ device }}{% endblock %}

+

{{ device }}

{% include 'inc/created_updated.html' with obj=device %} - +
  • + +
  • + Services +
  • + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    + {% endif %} {% endblock %} diff --git a/netbox/templates/ipam/service_list.html b/netbox/templates/ipam/service_list.html index 840b071bd..c78b2bba2 100644 --- a/netbox/templates/ipam/service_list.html +++ b/netbox/templates/ipam/service_list.html @@ -1,6 +1,4 @@ {% extends '_base.html' %} -{% load buttons %} -{% load humanize %} {% block content %}

    {% block title %}Services{% endblock %}

    From 6c1b5fdf3af788d4d65348b1999999372b358e1f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Jun 2018 14:00:23 -0400 Subject: [PATCH 052/159] Moved object serialization into a utility function --- netbox/extras/middleware.py | 3 +-- netbox/utilities/models.py | 11 ++--------- netbox/utilities/utils.py | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index f31f1ed87..429d8ed55 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -5,7 +5,6 @@ import uuid from django.db.models.signals import post_delete, post_save from django.utils.functional import curry, SimpleLazyObject -from utilities.models import ChangeLoggedModel from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE @@ -13,7 +12,7 @@ def record_object_change(user, request_id, instance, **kwargs): """ Create an ObjectChange in response to an object being created or deleted. """ - if not isinstance(instance, ChangeLoggedModel): + if not hasattr(instance, 'log_change'): return # Determine what action is being performed. The post_save signal sends a `created` boolean, whereas post_delete diff --git a/netbox/utilities/models.py b/netbox/utilities/models.py index a6b1e900a..4b04c03e1 100644 --- a/netbox/utilities/models.py +++ b/netbox/utilities/models.py @@ -1,11 +1,9 @@ from __future__ import unicode_literals -import json - -from django.core.serializers import serialize from django.db import models from extras.models import ObjectChange +from utilities.utils import serialize_object class ChangeLoggedModel(models.Model): @@ -32,15 +30,10 @@ class ChangeLoggedModel(models.Model): Create a new ObjectChange representing a change made to this object. This will typically be called automatically by extras.middleware.ChangeLoggingMiddleware. """ - - # Serialize the object using Django's built-in JSON serializer, then extract only the `fields` dict. - json_str = serialize('json', [self]) - object_data = json.loads(json_str)[0]['fields'] - ObjectChange( user=user, request_id=request_id, changed_object=self, action=action, - object_data=object_data + object_data=serialize_object(self) ).save() diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index e995c5580..c5ddda907 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,8 +1,10 @@ from __future__ import unicode_literals import datetime +import json import six +from django.core.serializers import serialize from django.http import HttpResponse @@ -82,3 +84,15 @@ def dynamic_import(name): for comp in components[1:]: mod = getattr(mod, comp) return mod + + +def serialize_object(obj, extra=None): + """ + Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like + change logging, not the REST API.) + """ + json_str = serialize('json', [obj]) + data = json.loads(json_str)[0]['fields'] + if extra is not None: + data['extra'] = extra + return data From 2d198403c7819aae989e5e47f69d4ce63545f58c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Jun 2018 15:05:40 -0400 Subject: [PATCH 053/159] Extend ObjectChange to optionally indicate a related object (e.g. a parent device) --- netbox/dcim/models.py | 93 ++++++++++++++++--- netbox/extras/admin.py | 6 +- netbox/extras/filters.py | 2 +- netbox/extras/migrations/0013_objectchange.py | 10 +- netbox/extras/models.py | 49 +++++++--- netbox/extras/tables.py | 5 +- netbox/extras/views.py | 10 +- netbox/templates/extras/objectchange.html | 4 +- 8 files changed, 136 insertions(+), 43 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 5ffac3f5c..606b2edfc 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -18,16 +18,41 @@ from taggit.managers import TaggableManager from timezone_field import TimeZoneField from circuits.models import Circuit -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, ObjectChange from extras.rpc import RPC_CLIENTS from utilities.fields import ColorField, NullableCharField from utilities.managers import NaturalOrderByManager from utilities.models import ChangeLoggedModel +from utilities.utils import serialize_object from .constants import * from .fields import ASNField, MACAddressField from .querysets import InterfaceQuerySet +class ComponentModel(models.Model): + + class Meta: + abstract = True + + def get_component_parent(self): + raise NotImplementedError( + "ComponentModel must implement get_component_parent()" + ) + + def log_change(self, user, request_id, action): + """ + Log an ObjectChange including the parent Device. + """ + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=self.get_component_parent(), + action=action, + object_data=serialize_object(self) + ).save() + + # # Regions # @@ -866,7 +891,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): @python_2_unicode_compatible -class ConsolePortTemplate(models.Model): +class ConsolePortTemplate(ComponentModel): """ A template for a ConsolePort to be created for a new Device. """ @@ -886,9 +911,12 @@ class ConsolePortTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class ConsoleServerPortTemplate(models.Model): +class ConsoleServerPortTemplate(ComponentModel): """ A template for a ConsoleServerPort to be created for a new Device. """ @@ -908,9 +936,12 @@ class ConsoleServerPortTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class PowerPortTemplate(models.Model): +class PowerPortTemplate(ComponentModel): """ A template for a PowerPort to be created for a new Device. """ @@ -930,9 +961,12 @@ class PowerPortTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class PowerOutletTemplate(models.Model): +class PowerOutletTemplate(ComponentModel): """ A template for a PowerOutlet to be created for a new Device. """ @@ -952,9 +986,12 @@ class PowerOutletTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class InterfaceTemplate(models.Model): +class InterfaceTemplate(ComponentModel): """ A template for a physical data interface on a new Device. """ @@ -984,9 +1021,12 @@ class InterfaceTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class DeviceBayTemplate(models.Model): +class DeviceBayTemplate(ComponentModel): """ A template for a DeviceBay to be created for a new parent Device. """ @@ -1006,6 +1046,9 @@ class DeviceBayTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + # # Devices @@ -1502,7 +1545,7 @@ class Device(ChangeLoggedModel, CustomFieldModel): # @python_2_unicode_compatible -class ConsolePort(models.Model): +class ConsolePort(ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -1539,6 +1582,9 @@ class ConsolePort(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def to_csv(self): return ( self.cs_port.device.identifier if self.cs_port else None, @@ -1564,7 +1610,7 @@ class ConsoleServerPortManager(models.Manager): @python_2_unicode_compatible -class ConsoleServerPort(models.Model): +class ConsoleServerPort(ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -1588,6 +1634,9 @@ class ConsoleServerPort(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def clean(self): # Check that the parent device's DeviceType is a console server @@ -1605,7 +1654,7 @@ class ConsoleServerPort(models.Model): # @python_2_unicode_compatible -class PowerPort(models.Model): +class PowerPort(ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -1641,6 +1690,9 @@ class PowerPort(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def to_csv(self): return ( self.power_outlet.device.identifier if self.power_outlet else None, @@ -1666,7 +1718,7 @@ class PowerOutletManager(models.Manager): @python_2_unicode_compatible -class PowerOutlet(models.Model): +class PowerOutlet(ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -1690,6 +1742,9 @@ class PowerOutlet(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def clean(self): # Check that the parent device's DeviceType is a PDU @@ -1707,7 +1762,7 @@ class PowerOutlet(models.Model): # @python_2_unicode_compatible -class Interface(models.Model): +class Interface(ComponentModel): """ A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other Interface via the creation of an InterfaceConnection. @@ -1797,6 +1852,9 @@ class Interface(models.Model): def get_absolute_url(self): return self.parent.get_absolute_url() + def get_component_parent(self): + return self.device or self.virtual_machine + def clean(self): # Check that the parent device's DeviceType is a network device @@ -1867,6 +1925,7 @@ class Interface(models.Model): return super(Interface, self).save(*args, **kwargs) + # TODO: Replace `parent` with get_component_parent() (from ComponentModel) @property def parent(self): return self.device or self.virtual_machine @@ -1977,7 +2036,7 @@ class InterfaceConnection(models.Model): # @python_2_unicode_compatible -class DeviceBay(models.Model): +class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device """ @@ -2008,6 +2067,9 @@ class DeviceBay(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def clean(self): # Validate that the parent Device can have DeviceBays @@ -2026,7 +2088,7 @@ class DeviceBay(models.Model): # @python_2_unicode_compatible -class InventoryItem(models.Model): +class InventoryItem(ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. InventoryItems are used only for inventory purposes. @@ -2095,6 +2157,9 @@ class InventoryItem(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def to_csv(self): return ( self.device.name or '{' + self.device.pk + '}', diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 534057a8d..7d30cff34 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -132,10 +132,10 @@ class TopologyMapAdmin(admin.ModelAdmin): @admin.register(ObjectChange) class ObjectChangeAdmin(admin.ModelAdmin): actions = None - fields = ['time', 'content_type', 'display_object', 'action', 'display_user', 'request_id', 'object_data'] - list_display = ['time', 'content_type', 'display_object', 'display_action', 'display_user', 'request_id'] + fields = ['time', 'changed_object_type', 'display_object', 'action', 'display_user', 'request_id', 'object_data'] + list_display = ['time', 'changed_object_type', 'display_object', 'display_action', 'display_user', 'request_id'] list_filter = ['time', 'action', 'user__username'] - list_select_related = ['content_type', 'user'] + list_select_related = ['changed_object_type', 'user'] readonly_fields = fields search_fields = ['user_name', 'object_repr', 'request_id'] diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index dbdf26c6f..71c9314cd 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -133,7 +133,7 @@ class ObjectChangeFilter(django_filters.FilterSet): class Meta: model = ObjectChange - fields = ['user', 'user_name', 'request_id', 'action', 'content_type', 'object_repr'] + fields = ['user', 'user_name', 'request_id', 'action', 'changed_object_type', 'object_repr'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/migrations/0013_objectchange.py b/netbox/extras/migrations/0013_objectchange.py index a8a7d7ee3..de4762a46 100644 --- a/netbox/extras/migrations/0013_objectchange.py +++ b/netbox/extras/migrations/0013_objectchange.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-06-19 19:34 +# Generated by Django 1.11.12 on 2018-06-22 18:13 from __future__ import unicode_literals from django.conf import settings @@ -11,8 +11,8 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), ('extras', '0012_webhooks'), ] @@ -25,10 +25,12 @@ class Migration(migrations.Migration): ('user_name', models.CharField(editable=False, max_length=150)), ('request_id', models.UUIDField(editable=False)), ('action', models.PositiveSmallIntegerField(choices=[(1, 'Created'), (2, 'Updated'), (3, 'Deleted')])), - ('object_id', models.PositiveIntegerField()), + ('changed_object_id', models.PositiveIntegerField()), + ('related_object_id', models.PositiveIntegerField(blank=True, null=True)), ('object_repr', models.CharField(editable=False, max_length=200)), ('object_data', django.contrib.postgres.fields.jsonb.JSONField(editable=False)), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('changed_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ('related_object_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='changes', to=settings.AUTH_USER_MODEL)), ], options={ diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 2a225b0fe..7b02271c7 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -665,7 +665,9 @@ class ReportResult(models.Model): @python_2_unicode_compatible class ObjectChange(models.Model): """ - Record a change to an object and the user account associated with that change. + 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 + parent device. This will ensure changes made to component models appear in the parent model's changelog. """ time = models.DateTimeField( auto_now_add=True, @@ -688,14 +690,30 @@ class ObjectChange(models.Model): action = models.PositiveSmallIntegerField( choices=OBJECTCHANGE_ACTION_CHOICES ) - content_type = models.ForeignKey( + changed_object_type = models.ForeignKey( to=ContentType, - on_delete=models.CASCADE + on_delete=models.PROTECT, + related_name='+' ) - object_id = models.PositiveIntegerField() + changed_object_id = models.PositiveIntegerField() changed_object = GenericForeignKey( - ct_field='content_type', - fk_field='object_id' + ct_field='changed_object_type', + fk_field='changed_object_id' + ) + related_object_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + related_object_id = models.PositiveIntegerField( + blank=True, + null=True + ) + related_object = GenericForeignKey( + ct_field='related_object_type', + fk_field='related_object_id' ) object_repr = models.CharField( max_length=200, @@ -706,14 +724,17 @@ class ObjectChange(models.Model): ) serializer = 'extras.api.serializers.ObjectChangeSerializer' - csv_headers = ['time', 'user', 'request_id', 'action', 'content_type', 'object_id', 'object_repr', 'object_data'] + csv_headers = [ + 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', + 'related_object_type', 'related_object_id', 'object_repr', 'object_data', + ] class Meta: ordering = ['-time'] def __str__(self): return '{} {} {} by {}'.format( - self.content_type, + self.changed_object_type, self.object_repr, self.get_action_display().lower(), self.user_name @@ -722,8 +743,7 @@ class ObjectChange(models.Model): def save(self, *args, **kwargs): # Record the user's name and the object's representation as static strings - if self.user is not None: - self.user_name = self.user.username + self.user_name = self.user.username self.object_repr = str(self.changed_object) return super(ObjectChange, self).save(*args, **kwargs) @@ -734,11 +754,14 @@ class ObjectChange(models.Model): def to_csv(self): return ( self.time, - self.user or self.user_name, + self.user, + self.user_name, self.request_id, self.get_action_display(), - self.content_type, - self.object_id, + self.changed_object_type, + self.changed_object_id, + self.related_object_type, + self.related_object_id, self.object_repr, self.object_data, ) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index afc5f2a53..bd190c7e5 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -52,6 +52,9 @@ class ObjectChangeTable(BaseTable): action = tables.TemplateColumn( template_code=OBJECTCHANGE_ACTION ) + changed_object_type = tables.Column( + verbose_name='Type' + ) object_repr = tables.TemplateColumn( template_code=OBJECTCHANGE_OBJECT, verbose_name='Object' @@ -62,4 +65,4 @@ class ObjectChangeTable(BaseTable): class Meta(BaseTable.Meta): model = ObjectChange - fields = ('time', 'user_name', 'action', 'content_type', 'object_repr', 'request_id') + fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 34a94b9df..e5e04d06b 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -4,7 +4,7 @@ from django import template from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType -from django.db.models import Count +from django.db.models import Count, Q from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render, reverse from django.utils.safestring import mark_safe @@ -94,13 +94,13 @@ class ObjectChangeLogView(View): # Get object my model and kwargs (e.g. slug='foo') obj = get_object_or_404(model, **kwargs) - # Gather all changes for this object + # Gather all changes for this object (and its related objects) content_type = ContentType.objects.get_for_model(model) objectchanges = ObjectChange.objects.select_related( - 'user', 'content_type' + 'user', 'changed_object_type' ).filter( - content_type=content_type, - object_id=obj.pk + Q(changed_object_type=content_type, changed_object_id=obj.pk) | + Q(related_object_type=content_type, related_object_id=obj.pk) ) objectchanges_table = ObjectChangeTable( data=objectchanges, diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index 144c712a7..df606bacc 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -53,9 +53,9 @@ - Content Type + Object Type - {{ objectchange.content_type }} + {{ objectchange.changed_object_type }} From ce27a1d211d8986089c2da11c34a0828316925ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Jun 2018 15:27:22 -0400 Subject: [PATCH 054/159] serialize_object(): Allow extra data to overwrite existing fields --- netbox/utilities/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index c5ddda907..1eee8527c 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -89,10 +89,10 @@ def dynamic_import(name): def serialize_object(obj, extra=None): """ Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like - change logging, not the REST API.) + change logging, not the REST API.) Optionally include a dictionary to supplement the object data. """ json_str = serialize('json', [obj]) data = json.loads(json_str)[0]['fields'] if extra is not None: - data['extra'] = extra + data.update(extra) return data From 4e6f73e452b6af5ca9cad5eb42b6341bf28ad797 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Jun 2018 15:30:54 -0400 Subject: [PATCH 055/159] Fixed invalid reference to content_type --- netbox/extras/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index e5e04d06b..46cddabf4 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -58,7 +58,7 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class ObjectChangeListView(ObjectListView): - queryset = ObjectChange.objects.select_related('user', 'content_type') + queryset = ObjectChange.objects.select_related('user', 'changed_object_type') filter = filters.ObjectChangeFilter filter_form = ObjectChangeFilterForm table = ObjectChangeTable From 3bdfe9c2493f82f0920fd82a2d634f15f580992d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Jun 2018 16:18:41 -0400 Subject: [PATCH 056/159] Implemented changelog retention setting, automatic purging --- docs/configuration/optional-settings.md | 8 ++++++++ netbox/extras/middleware.py | 15 +++++++++++++++ netbox/netbox/configuration.example.py | 3 +++ netbox/netbox/settings.py | 1 + 4 files changed, 27 insertions(+) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 600927394..ce31cfd46 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -44,6 +44,14 @@ BASE_PATH = 'netbox/' --- +## CHANGELOG_RETENTION + +Default: 90 + +The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain changes in the database indefinitely. (Warning: This will greatly increase database size over time.) + +--- + ## CORS_ORIGIN_ALLOW_ALL Default: False diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index 429d8ed55..e4f04f243 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -1,11 +1,17 @@ from __future__ import unicode_literals +from datetime import timedelta +import logging +import random import uuid +from django.conf import settings from django.db.models.signals import post_delete, post_save +from django.utils import timezone from django.utils.functional import curry, SimpleLazyObject from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE +from .models import ObjectChange def record_object_change(user, request_id, instance, **kwargs): @@ -24,6 +30,15 @@ def record_object_change(user, request_id, instance, **kwargs): instance.log_change(user, request_id, action) + # 1% chance of clearing out expired ObjectChanges + if settings.CHANGELOG_RETENTION and random.randint(1, 100): + cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) + purged_count, _ = ObjectChange.objects.filter( + time__lt=cutoff + ).delete() + logger = logging.getLogger('django') + logger.info("Automatically purged {} changes past the retention period".format(purged_count)) + class ChangeLoggingMiddleware(object): diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 27a615c32..23d6ba221 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -50,6 +50,9 @@ BANNER_LOGIN = '' # BASE_PATH = 'netbox/' BASE_PATH = '' +# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90) +CHANGELOG_RETENTION = 90 + # API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be # allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 83686df94..f526ebf19 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -44,6 +44,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only +CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) From 36971b76518cd03f1a9ff27bfb15a2e42c4d25e2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Jun 2018 16:23:07 -0400 Subject: [PATCH 057/159] Fixed changelog purging frequency --- netbox/extras/middleware.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index e4f04f243..a7dd3b44e 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals from datetime import timedelta -import logging import random import uuid @@ -31,13 +30,11 @@ def record_object_change(user, request_id, instance, **kwargs): instance.log_change(user, request_id, action) # 1% chance of clearing out expired ObjectChanges - if settings.CHANGELOG_RETENTION and random.randint(1, 100): + if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) purged_count, _ = ObjectChange.objects.filter( time__lt=cutoff ).delete() - logger = logging.getLogger('django') - logger.info("Automatically purged {} changes past the retention period".format(purged_count)) class ChangeLoggingMiddleware(object): From 66c491129859acc4796a70068082e0fd71b83b17 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Jun 2018 16:34:07 -0400 Subject: [PATCH 058/159] Fixed Region model declaration --- netbox/dcim/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 606b2edfc..2059fe05c 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -58,7 +58,7 @@ class ComponentModel(models.Model): # @python_2_unicode_compatible -class Region(ChangeLoggedModel, MPTTModel): +class Region(MPTTModel, ChangeLoggedModel): """ Sites can be grouped within geographic Regions. """ From b11c3635b0f1e18d27364dda26fe648c02d865e4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Jun 2018 16:34:38 -0400 Subject: [PATCH 059/159] Corrected import of reverse() for Django 2.0 --- netbox/extras/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 7b02271c7..4b41a523a 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -9,7 +9,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import JSONField -from django.core.urlresolvers import reverse +from django.urls import reverse from django.core.validators import ValidationError from django.db import models from django.db.models import Q From 0af36eb99bc8c31c406c982f6fcb061925ab5203 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Jun 2018 13:12:03 -0400 Subject: [PATCH 060/159] Log interface connection changes --- netbox/dcim/models.py | 46 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 2059fe05c..07387ca0e 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -18,6 +18,7 @@ from taggit.managers import TaggableManager from timezone_field import TimeZoneField from circuits.models import Circuit +from extras.constants import OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from extras.models import CustomFieldModel, ObjectChange from extras.rpc import RPC_CLIENTS from utilities.fields import ColorField, NullableCharField @@ -41,7 +42,7 @@ class ComponentModel(models.Model): def log_change(self, user, request_id, action): """ - Log an ObjectChange including the parent Device. + Log an ObjectChange including the parent Device/VM. """ ObjectChange( user=user, @@ -1925,6 +1926,22 @@ class Interface(ComponentModel): return super(Interface, self).save(*args, **kwargs) + def log_change(self, user, request_id, action): + """ + Include the connected Interface (if any). + """ + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=self.get_component_parent(), + action=action, + object_data=serialize_object(self, extra={ + 'connected_interface': self.connected_interface.pk, + 'connection_status': self.connection.connection_status if self.connection else None, + }) + ).save() + # TODO: Replace `parent` with get_component_parent() (from ComponentModel) @property def parent(self): @@ -2030,6 +2047,33 @@ class InterfaceConnection(models.Model): self.get_connection_status_display(), ) + def log_change(self, user, request_id, action): + """ + Create a new ObjectChange for each of the two affected Interfaces. + """ + interfaces = ( + (self.interface_a, self.interface_b), + (self.interface_b, self.interface_a), + ) + for interface, peer_interface in interfaces: + if action == OBJECTCHANGE_ACTION_DELETE: + connection_data = { + 'connected_interface': None, + } + else: + connection_data = { + 'connected_interface': peer_interface.pk, + 'connection_status': self.connection_status + } + ObjectChange( + user=user, + request_id=request_id, + changed_object=interface, + related_object=interface.parent, + action=OBJECTCHANGE_ACTION_UPDATE, + object_data=serialize_object(interface, extra=connection_data) + ).save() + # # Device bays From 06143b6c701d540068356038130a9496a182cfb5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Jun 2018 13:29:23 -0400 Subject: [PATCH 061/159] Fixes interface logging error --- netbox/dcim/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 07387ca0e..ecb83ecaa 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1937,7 +1937,7 @@ class Interface(ComponentModel): related_object=self.get_component_parent(), action=action, object_data=serialize_object(self, extra={ - 'connected_interface': self.connected_interface.pk, + 'connected_interface': self.connected_interface.pk if self.connection else None, 'connection_status': self.connection.connection_status if self.connection else None, }) ).save() From c13e4858d7c1704ada3d4e1c941bd2b1a6336c88 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Jun 2018 16:02:34 -0400 Subject: [PATCH 062/159] Initial work on config contexts --- netbox/dcim/api/views.py | 6 +- netbox/dcim/models.py | 4 +- netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 12 ++ netbox/extras/admin.py | 12 +- netbox/extras/api/serializers.py | 26 ++- netbox/extras/api/urls.py | 3 + netbox/extras/api/views.py | 12 +- netbox/extras/forms.py | 20 ++- .../extras/migrations/0014_config-contexts.py | 44 +++++ netbox/extras/models.py | 88 ++++++++++ netbox/extras/tables.py | 27 ++- netbox/extras/urls.py | 8 + netbox/extras/views.py | 53 +++++- netbox/templates/dcim/device.html | 3 + .../templates/dcim/device_configcontext.html | 18 ++ netbox/templates/extras/configcontext.html | 154 ++++++++++++++++++ .../templates/extras/configcontext_edit.html | 24 +++ .../templates/extras/configcontext_list.html | 16 ++ netbox/templates/inc/nav_menu.html | 3 + .../virtualization/virtualmachine.html | 3 + .../virtualmachine_configcontext.html | 18 ++ netbox/virtualization/api/views.py | 9 + netbox/virtualization/models.py | 4 +- netbox/virtualization/urls.py | 1 + netbox/virtualization/views.py | 12 ++ 26 files changed, 565 insertions(+), 16 deletions(-) create mode 100644 netbox/extras/migrations/0014_config-contexts.py create mode 100644 netbox/templates/dcim/device_configcontext.html create mode 100644 netbox/templates/extras/configcontext.html create mode 100644 netbox/templates/extras/configcontext_edit.html create mode 100644 netbox/templates/extras/configcontext_list.html create mode 100644 netbox/templates/virtualization/virtualmachine_configcontext.html diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 5ef4b1de7..b99d98477 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals from collections import OrderedDict from django.conf import settings -from django.db import transaction from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import get_object_or_404 from drf_yasg import openapi @@ -233,6 +232,11 @@ class DeviceViewSet(CustomFieldModelViewSet): serializer_class = serializers.DeviceSerializer filter_class = filters.DeviceFilter + @detail_route(url_path='config-context') + def config_context(self, request, pk): + device = get_object_or_404(Device, pk=pk) + return Response(device.get_config_context()) + @detail_route(url_path='napalm') def napalm(self, request, pk): """ diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index ecb83ecaa..6963ddc7f 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -19,7 +19,7 @@ from timezone_field import TimeZoneField from circuits.models import Circuit from extras.constants import OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE -from extras.models import CustomFieldModel, ObjectChange +from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange from extras.rpc import RPC_CLIENTS from utilities.fields import ColorField, NullableCharField from utilities.managers import NaturalOrderByManager @@ -1158,7 +1158,7 @@ class DeviceManager(NaturalOrderByManager): @python_2_unicode_compatible -class Device(ChangeLoggedModel, CustomFieldModel): +class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ 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. diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index de1cbd4cc..6824f620a 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -141,6 +141,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/$', views.DeviceView.as_view(), name='device'), url(r'^devices/(?P\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), + url(r'^devices/(?P\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'), url(r'^devices/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), url(r'^devices/(?P\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), url(r'^devices/(?P\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0e783d39c..be5e3276b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -994,6 +994,18 @@ class DeviceConfigView(PermissionRequiredMixin, View): }) +class DeviceConfigContextView(View): + + def get(self, request, pk): + + device = get_object_or_404(Device, pk=pk) + + return render(request, 'dcim/device_configcontext.html', { + 'device': device, + 'active_tab': 'config-context', + }) + + class DeviceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_device' model = Device diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 7d30cff34..20a8f6f4e 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -7,7 +7,8 @@ from django.utils.safestring import mark_safe from utilities.forms import LaxURLField from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from .models import ( - CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction, Webhook, + ConfigContext, CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction, + Webhook, ) @@ -125,6 +126,15 @@ class TopologyMapAdmin(admin.ModelAdmin): } +# +# Config contexts +# + +@admin.register(ConfigContext) +class ConfigContextAdmin(admin.ModelAdmin): + list_display = ['name', 'weight'] + + # # Change logging # diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 10afee954..a64acc1a4 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -4,10 +4,16 @@ from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from taggit.models import Tag -from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer +from dcim.api.serializers import ( + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, + NestedRegionSerializer, NestedSiteSerializer, +) from dcim.models import Device, Rack, Site -from extras.models import ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction +from extras.models import ( + ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction, +) from extras.constants import * +from tenancy.api.serializers import NestedTenantSerializer from users.api.serializers import NestedUserSerializer from utilities.api import ( ChoiceFieldSerializer, ContentTypeFieldSerializer, get_serializer_for_model, ValidatedModelSerializer, @@ -121,6 +127,22 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): return serializer(obj.parent, context={'request': self.context['request']}).data +# +# Config contexts +# + +class ConfigContextSerializer(ValidatedModelSerializer): + regions = NestedRegionSerializer(many=True) + sites = NestedSiteSerializer(many=True) + roles = NestedDeviceRoleSerializer(many=True) + platforms = NestedPlatformSerializer(many=True) + tenants = NestedTenantSerializer(many=True) + + class Meta: + model = ConfigContext + fields = ['name', 'weight', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data'] + + # # Reports # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 3b4e59ef2..cf61841dd 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -34,6 +34,9 @@ router.register(r'tags', views.TagViewSet) # Image attachments router.register(r'image-attachments', views.ImageAttachmentViewSet) +# Config contexts +router.register(r'config-contexts', views.ConfigContextViewSet) + # Reports router.register(r'reports', views.ReportViewSet, base_name='report') diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index d65a099ad..55e4457ce 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -12,7 +12,8 @@ from taggit.models import Tag from extras import filters from extras.models import ( - CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction, + ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, + UserAction, ) from extras.reports import get_report, get_reports from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet @@ -132,6 +133,15 @@ class ImageAttachmentViewSet(ModelViewSet): serializer_class = serializers.ImageAttachmentSerializer +# +# Config contexts +# + +class ConfigContextViewSet(ModelViewSet): + queryset = ConfigContext.objects.prefetch_related('regions', 'sites', 'roles', 'platforms', 'tenants') + serializer_class = serializers.ConfigContextSerializer + + # # Reports # diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index a39814eb6..bd7ace840 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -5,14 +5,16 @@ from collections import OrderedDict from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from mptt.forms import TreeNodeMultipleChoiceField from taggit.models import Tag +from dcim.models import Region from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, SlugField from .constants import ( CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, OBJECTCHANGE_ACTION_CHOICES, ) -from .models import CustomField, CustomFieldValue, ImageAttachment, ObjectChange +from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange # @@ -174,7 +176,6 @@ class CustomFieldFilterForm(forms.Form): # # Tags # -# class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() @@ -184,6 +185,21 @@ class TagForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +# +# Config contexts +# + +class ConfigContextForm(BootstrapMixin, forms.ModelForm): + regions = TreeNodeMultipleChoiceField( + queryset=Region.objects.all(), + required=False + ) + + class Meta: + model = ConfigContext + fields = ['name', 'weight', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data'] + + # # Image attachments # diff --git a/netbox/extras/migrations/0014_config-contexts.py b/netbox/extras/migrations/0014_config-contexts.py new file mode 100644 index 000000000..5d8d46d6f --- /dev/null +++ b/netbox/extras/migrations/0014_config-contexts.py @@ -0,0 +1,44 @@ +# Generated by Django 2.0.6 on 2018-06-27 17:45 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0005_change_logging'), + ('dcim', '0060_change_logging'), + ('extras', '0013_objectchange'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigContext', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('weight', models.PositiveSmallIntegerField(default=1000)), + ('is_active', models.BooleanField(default=True)), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ('platforms', models.ManyToManyField(blank=True, related_name='_configcontext_platforms_+', to='dcim.Platform')), + ('regions', models.ManyToManyField(blank=True, related_name='_configcontext_regions_+', to='dcim.Region')), + ('roles', models.ManyToManyField(blank=True, related_name='_configcontext_roles_+', to='dcim.DeviceRole')), + ('sites', models.ManyToManyField(blank=True, related_name='_configcontext_sites_+', to='dcim.Site')), + ('tenants', models.ManyToManyField(blank=True, related_name='_configcontext_tenants_+', to='tenancy.Tenant')), + ], + options={ + 'ordering': ['weight', 'name'], + }, + ), + migrations.AlterField( + model_name='customfield', + name='obj_type', + field=models.ManyToManyField(help_text='The object(s) to which this field applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'tenant', 'cluster', 'virtualmachine')}, related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'), + ), + migrations.AlterField( + model_name='webhook', + name='obj_type', + field=models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'rackgroup', 'device', 'interface', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vlangroup', 'vrf', 'service', 'tenant', 'tenantgroup', 'cluster', 'clustergroup', 'virtualmachine')}, related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object types'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 4b41a523a..ff9977160 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -629,6 +629,94 @@ class ImageAttachment(models.Model): return None +# +# Config contexts +# + +class ConfigContext(models.Model): + """ + 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 + will be available to a Device in site A assigned to tenant B. Data is stored in JSON format. + """ + name = models.CharField( + max_length=100, + unique=True + ) + weight = models.PositiveSmallIntegerField( + default=1000 + ) + is_active = models.BooleanField( + default=True, + ) + regions = models.ManyToManyField( + to='dcim.Region', + related_name='+', + blank=True + ) + sites = models.ManyToManyField( + to='dcim.Site', + related_name='+', + blank=True + ) + roles = models.ManyToManyField( + to='dcim.DeviceRole', + related_name='+', + blank=True + ) + platforms = models.ManyToManyField( + to='dcim.Platform', + related_name='+', + blank=True + ) + tenants = models.ManyToManyField( + to='tenancy.Tenant', + related_name='+', + blank=True + ) + data = JSONField() + + class Meta: + ordering = ['weight', 'name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:configcontext', kwargs={'pk': self.pk}) + + +class ConfigContextModel(models.Model): + + class Meta: + abstract = True + + def get_config_context(self): + """ + Return the rendered configuration context for a device or VM. + """ + + # `device_role` for Device; `role` for VirtualMachine + role = getattr(self, 'device_role', None) or self.role + + # Gather all ConfigContexts orders by weight, name + contexts = ConfigContext.objects.filter( + Q(regions=self.site.region) | Q(regions=None), + Q(sites=self.site) | Q(sites=None), + Q(roles=role) | Q(roles=None), + Q(tenants=self.tenant) | Q(tenants=None), + Q(platforms=self.platform) | Q(platforms=None), + is_active=True, + ).order_by('weight', 'name') + + # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs + data = {} + for context in contexts: + data.update(context.data) + + return data + + # # Report results # diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index bd190c7e5..be5d748e3 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -4,7 +4,7 @@ import django_tables2 as tables from taggit.models import Tag from utilities.tables import BaseTable, ToggleColumn -from .models import ObjectChange +from .models import ConfigContext, ObjectChange TAG_ACTIONS = """ {% if perms.taggit.change_tag %} @@ -15,6 +15,15 @@ TAG_ACTIONS = """ {% endif %} """ +CONFIGCONTEXT_ACTIONS = """ +{% if perms.extras.change_configcontext %} + +{% endif %} +{% if perms.extras.delete_configcontext %} + +{% endif %} +""" + OBJECTCHANGE_ACTION = """ {% if record.action == 1 %} Created @@ -44,7 +53,21 @@ class TagTable(BaseTable): class Meta(BaseTable.Meta): model = Tag - fields = ('pk', 'name', 'items') + fields = ('pk', 'name', 'weight') + + +class ConfigContextTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + actions = tables.TemplateColumn( + template_code=CONFIGCONTEXT_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = ConfigContext + fields = ('pk', 'name', 'weight', 'active') class ObjectChangeTable(BaseTable): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index d92303264..e56652280 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -13,6 +13,14 @@ urlpatterns = [ url(r'^tags/(?P[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'), url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + # Config contexts + url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'), + url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'), + url(r'^config-contexts/(?P\d+)/$', views.ConfigContextView.as_view(), name='configcontext'), + url(r'^config-contexts/(?P\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'), + url(r'^config-contexts/(?P\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), + url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), + # Image attachments url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 46cddabf4..8670fdcdb 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -14,10 +14,10 @@ from taggit.models import Tag from utilities.forms import ConfirmationForm from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView from . import filters -from .forms import ObjectChangeFilterForm, ImageAttachmentForm, TagForm -from .models import ImageAttachment, ObjectChange, ReportResult +from .forms import ConfigContextForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm +from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult from .reports import get_report, get_reports -from .tables import ObjectChangeTable, TagTable +from .tables import ConfigContextTable, ObjectChangeTable, TagTable # @@ -53,6 +53,53 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): default_return_url = 'extras:tag_list' +# +# Config contexts +# + +class ConfigContextListView(ObjectListView): + queryset = ConfigContext.objects.all() + table = ConfigContextTable + template_name = 'extras/configcontext_list.html' + + +class ConfigContextView(View): + + def get(self, request, pk): + + configcontext = get_object_or_404(ConfigContext, pk=pk) + + return render(request, 'extras/configcontext.html', { + 'configcontext': configcontext, + }) + + +class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'extras.add_configcontext' + model = ConfigContext + model_form = ConfigContextForm + default_return_url = 'extras:configcontext_list' + template_name = 'extras/configcontext_edit.html' + + +class ConfigContextEditView(ConfigContextCreateView): + permission_required = 'extras.change_configcontext' + + +class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'extras.delete_configcontext' + model = ConfigContext + default_return_url = 'extras:configcontext_list' + + +class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'extras.delete_cconfigcontext' + cls = ConfigContext + queryset = ConfigContext.objects.all() + table = ConfigContextTable + default_return_url = 'extras:configcontext_list' + + # # Change logging # diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 1c1539f89..4984dad95 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -69,6 +69,9 @@ {% include 'dcim/inc/device_napalm_tabs.html' %} {% endif %} {% endif %} + diff --git a/netbox/templates/dcim/device_configcontext.html b/netbox/templates/dcim/device_configcontext.html new file mode 100644 index 000000000..adb00291d --- /dev/null +++ b/netbox/templates/dcim/device_configcontext.html @@ -0,0 +1,18 @@ +{% extends 'dcim/device.html' %} + +{% block title %}{{ device }} - Config Context{% endblock %} + +{% block content %} +
    +
    +
    +
    + Config Context +
    +
    +
    {{ device.get_config_context }}
    +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html new file mode 100644 index 000000000..08d257b5f --- /dev/null +++ b/netbox/templates/extras/configcontext.html @@ -0,0 +1,154 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block header %} +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + {% if perms.extras.change_configcontext %} + + + Edit this config context + + {% endif %} +
    +

    {% block title %}{{ configcontext }}{% endblock %}

    +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Config Context +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Name + {{ configcontext.name }} +
    Weight + {{ configcontext.weight }} +
    Active + {% if configcontext.is_active %} + + + + {% else %} + + + + {% endif %} +
    Regions + {% if configcontext.regions.all %} +
      + {% for region in configcontext.regions.all %} +
    • {{ region }}
    • + {% endfor %} +
    + {% else %} + None + {% endif %} +
    Sites + {% if configcontext.sites.all %} +
      + {% for site in configcontext.sites.all %} +
    • {{ site }}
    • + {% endfor %} +
    + {% else %} + None + {% endif %} +
    Roles + {% if configcontext.roles.all %} +
      + {% for role in configcontext.roles.all %} +
    • {{ role }}
    • + {% endfor %} +
    + {% else %} + None + {% endif %} +
    Platforms + {% if configcontext.platforms.all %} +
      + {% for platform in configcontext.platforms.all %} +
    • {{ platform }}
    • + {% endfor %} +
    + {% else %} + None + {% endif %} +
    Tenants + {% if configcontext.tenants.all %} +
      + {% for tenant in configcontext.tenants.all %} +
    • {{ tenant }}
    • + {% endfor %} +
    + {% else %} + None + {% endif %} +
    +
    +
    +
    +
    +
    + Data +
    +
    +
    {{ configcontext.data }}
    +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html new file mode 100644 index 000000000..a0f428131 --- /dev/null +++ b/netbox/templates/extras/configcontext_edit.html @@ -0,0 +1,24 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
    +
    Config Context
    +
    + {% render_field form.name %} + {% render_field form.weight %} + {% render_field form.is_active %} + {% render_field form.regions %} + {% render_field form.sites %} + {% render_field form.roles %} + {% render_field form.platforms %} + {% render_field form.tenants %} +
    +
    +
    +
    Data
    +
    + {% render_field form.data %} +
    +
    +{% endblock %} diff --git a/netbox/templates/extras/configcontext_list.html b/netbox/templates/extras/configcontext_list.html new file mode 100644 index 000000000..98913d987 --- /dev/null +++ b/netbox/templates/extras/configcontext_list.html @@ -0,0 +1,16 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
    + {% if perms.extras.add_configcontext %} + {% add_button 'extras:configcontext_add' %} + {% endif %} +
    +

    {% block title %}Config Contexts{% endblock %}

    +
    +
    + {% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index ced87768e..aeddf1969 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -63,6 +63,9 @@
  • Tags
  • +
  • + Config Contexts +
  • Reports
  • diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index d6d594b55..9b5dbc471 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -44,6 +44,9 @@ + diff --git a/netbox/templates/virtualization/virtualmachine_configcontext.html b/netbox/templates/virtualization/virtualmachine_configcontext.html new file mode 100644 index 000000000..4218a4161 --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine_configcontext.html @@ -0,0 +1,18 @@ +{% extends 'virtualization/virtualmachine.html' %} + +{% block title %}{{ virtualmachine }} - Config Context{% endblock %} + +{% block content %} +
    +
    +
    +
    + Config Context +
    +
    +
    {{ virtualmachine.get_config_context }}
    +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index fae8b9232..b04248f87 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +from django.shortcuts import get_object_or_404 +from rest_framework.decorators import detail_route +from rest_framework.response import Response + from dcim.models import Interface from extras.api.views import CustomFieldModelViewSet from utilities.api import FieldChoicesViewSet, ModelViewSet @@ -49,6 +53,11 @@ class VirtualMachineViewSet(CustomFieldModelViewSet): serializer_class = serializers.VirtualMachineSerializer filter_class = filters.VirtualMachineFilter + @detail_route(url_path='config-context') + def config_context(self, request, pk): + device = get_object_or_404(VirtualMachine, pk=pk) + return Response(device.get_config_context()) + class InterfaceViewSet(ModelViewSet): queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine') diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 70a73dc05..904d04634 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -9,7 +9,7 @@ from django.utils.encoding import python_2_unicode_compatible from taggit.managers import TaggableManager from dcim.models import Device -from extras.models import CustomFieldModel +from extras.models import ConfigContextModel, CustomFieldModel from utilities.models import ChangeLoggedModel from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES @@ -168,7 +168,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # @python_2_unicode_compatible -class VirtualMachine(ChangeLoggedModel, CustomFieldModel): +class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A virtual machine which runs inside a Cluster. """ diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index d123e7cfa..b03b3bc0a 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -48,6 +48,7 @@ urlpatterns = [ url(r'^virtual-machines/(?P\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'), url(r'^virtual-machines/(?P\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), url(r'^virtual-machines/(?P\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), + url(r'^virtual-machines/(?P\d+)/config-context/$', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), url(r'^virtual-machines/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), url(r'^virtual-machines/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index c3fa97f2f..6286ba1a5 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -269,6 +269,18 @@ class VirtualMachineView(View): }) +class VirtualMachineConfigContextView(View): + + def get(self, request, pk): + + virtualmachine = get_object_or_404(VirtualMachine, pk=pk) + + return render(request, 'virtualization/virtualmachine_configcontext.html', { + 'virtualmachine': virtualmachine, + 'active_tab': 'config-context', + }) + + class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_virtualmachine' model = VirtualMachine From 65e18e057f4bc52c0fbc35feb765cdb2a674514e Mon Sep 17 00:00:00 2001 From: John Anderson Date: Wed, 27 Jun 2018 22:17:17 -0400 Subject: [PATCH 063/159] fixed #2203 --- netbox/extras/apps.py | 2 -- netbox/extras/signals.py | 16 ------------- netbox/extras/webhooks.py | 49 +++++++++++---------------------------- netbox/netbox/settings.py | 8 ------- 4 files changed, 14 insertions(+), 61 deletions(-) delete mode 100644 netbox/extras/signals.py diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 1cac98a23..4520b1923 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -9,8 +9,6 @@ class ExtrasConfig(AppConfig): name = "extras" def ready(self): - import extras.signals - # Check that we can connect to the configured Redis database if webhooks are enabled. if settings.WEBHOOKS_ENABLED: try: diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py deleted file mode 100644 index d676cdf8d..000000000 --- a/netbox/extras/signals.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import unicode_literals - -from django.db.models.signals import post_delete, post_save -from django.dispatch import receiver -from django.core.cache import caches - -from .models import Webhook - - -@receiver((post_save, post_delete), sender=Webhook) -def update_webhook_cache(**kwargs): - """ - When a Webhook has been modified, update the webhook cache. - """ - cache = caches['default'] - cache.set('webhook_cache', Webhook.objects.all()) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 2357e6289..7f77773c8 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -4,30 +4,12 @@ from importlib import import_module from django.db.models.signals import post_save, post_delete from django.conf import settings from django.core.cache import caches +from django.db.models import Q from django.dispatch import Signal from django.contrib.contenttypes.models import ContentType from utilities.utils import dynamic_import -from .models import Webhook - - -# -# Webhooks signals regiters and receivers -# - -def get_or_set_webhook_cache(): - """ - Retrieve the webhook cache. If it is None set it to the current - Webhook queryset - """ - cache = caches['default'] - webhook_cache = cache.get('webhook_cache', None) - - if webhook_cache is None: - webhook_cache = Webhook.objects.all() - cache.set('webhook_cache', webhook_cache) - - return webhook_cache +from extras.models import Webhook def enqueue_webhooks(webhooks, model_class, data, event, signal_received_timestamp): @@ -66,18 +48,17 @@ def post_save_receiver(sender, instance, created, **kwargs): """ if settings.WEBHOOKS_ENABLED: signal_received_timestamp = time.time() - webhook_cache = get_or_set_webhook_cache() # look for any webhooks that match this event updated = not created obj_type = ContentType.objects.get_for_model(sender) - webhooks = [ - x - for x in webhook_cache - if ( - x.enabled and x.type_create == created or x.type_update == updated and - obj_type in x.obj_type.all() - ) - ] + webhooks = Webhook.objects.filter( + Q(enabled=True) & + ( + Q(type_create=created) | + Q(type_update=updated) + ) & + Q(obj_type=obj_type) + ) event = 'created' if created else 'updated' if webhooks: enqueue_webhooks(webhooks, sender, instance, event, signal_received_timestamp) @@ -90,10 +71,9 @@ def post_delete_receiver(sender, instance, **kwargs): """ if settings.WEBHOOKS_ENABLED: signal_received_timestamp = time.time() - webhook_cache = get_or_set_webhook_cache() obj_type = ContentType.objects.get_for_model(sender) # look for any webhooks that match this event - webhooks = [x for x in webhook_cache if x.enabled and x.type_delete and obj_type in x.obj_type.all()] + webhooks = Webhook.objects.filter(enabled=True, type_delete=True, obj_type=obj_type) if webhooks: enqueue_webhooks(webhooks, sender, instance, 'deleted', signal_received_timestamp) @@ -106,15 +86,14 @@ def bulk_operation_receiver(sender, **kwargs): if settings.WEBHOOKS_ENABLED: signal_received_timestamp = time.time() event = kwargs['event'] - webhook_cache = get_or_set_webhook_cache() obj_type = ContentType.objects.get_for_model(sender) # look for any webhooks that match this event if event == 'created': - webhooks = [x for x in webhook_cache if x.enabled and x.type_create and obj_type in x.obj_type.all()] + webhooks = Webhook.objects.filter(enabled=True, type_create=True, obj_type=obj_type) elif event == 'updated': - webhooks = [x for x in webhook_cache if x.enabled and x.type_update and obj_type in x.obj_type.all()] + webhooks = Webhook.objects.filter(enabled=True, type_update=True, obj_type=obj_type) elif event == 'deleted': - webhooks = [x for x in webhook_cache if x.enabled and x.type_delete and obj_type in x.obj_type.all()] + webhooks = Webhook.objects.filter(enabled=True, type_delete=True, obj_type=obj_type) else: webhooks = None diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f526ebf19..90ee8103a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -304,14 +304,6 @@ INTERNAL_IPS = ( '::1', ) -# Django CACHE - local memory cache -CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'webhooks', - } -} - try: HOSTNAME = socket.gethostname() From b952ec73ce597219d788662b6aff610ffda9c7ef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Jun 2018 10:49:52 -0400 Subject: [PATCH 064/159] Introduced the render_json template filter --- netbox/extras/models.py | 4 ---- netbox/templates/dcim/device_configcontext.html | 3 ++- netbox/templates/extras/objectchange.html | 2 +- netbox/utilities/templatetags/helpers.py | 9 +++++++++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index ff9977160..fe80ec09a 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -854,10 +854,6 @@ class ObjectChange(models.Model): self.object_data, ) - @property - def object_data_pretty(self): - return json.dumps(self.object_data, indent=4, sort_keys=True) - # # User actions diff --git a/netbox/templates/dcim/device_configcontext.html b/netbox/templates/dcim/device_configcontext.html index adb00291d..537093edf 100644 --- a/netbox/templates/dcim/device_configcontext.html +++ b/netbox/templates/dcim/device_configcontext.html @@ -1,4 +1,5 @@ {% extends 'dcim/device.html' %} +{% load helpers %} {% block title %}{{ device }} - Config Context{% endblock %} @@ -10,7 +11,7 @@ Config Context
    -
    {{ device.get_config_context }}
    +
    {{ device.get_config_context|render_json }}
    diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index df606bacc..c86a5528d 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -83,7 +83,7 @@ Object Data
    -
    {{ objectchange.object_data_pretty }}
    +
    {{ objectchange.object_data|render_json }}
    diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 1380941b3..9edf4ad36 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import datetime +import json from django import template from django.utils.safestring import mark_safe @@ -46,6 +47,14 @@ def gfm(value): return mark_safe(html) +@register.filter() +def render_json(value): + """ + Render a dictionary as formatted JSON. + """ + return json.dumps(value, indent=4, sort_keys=True) + + @register.filter() def model_name(obj): """ From 62989ecb6e263a901a81aa3adcd32cdb1ab437f2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Jun 2018 13:48:12 -0400 Subject: [PATCH 065/159] Moved object context rendering to ObjectConfigContextView and standardized the template --- netbox/dcim/views.py | 14 +++----- netbox/extras/models.py | 19 +++------- netbox/extras/querysets.py | 23 ++++++++++++ netbox/extras/views.py | 18 ++++++++++ .../templates/dcim/device_configcontext.html | 19 ---------- .../extras/object_configcontext.html | 35 +++++++++++++++++++ .../virtualmachine_configcontext.html | 18 ---------- netbox/virtualization/views.py | 14 +++----- 8 files changed, 88 insertions(+), 72 deletions(-) create mode 100644 netbox/extras/querysets.py delete mode 100644 netbox/templates/dcim/device_configcontext.html create mode 100644 netbox/templates/extras/object_configcontext.html delete mode 100644 netbox/templates/virtualization/virtualmachine_configcontext.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index be5e3276b..a32053463 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -19,6 +19,7 @@ from natsort import natsorted from circuits.models import Circuit from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE +from extras.views import ObjectConfigContextView from ipam.models import Prefix, Service, VLAN from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator @@ -994,16 +995,9 @@ class DeviceConfigView(PermissionRequiredMixin, View): }) -class DeviceConfigContextView(View): - - def get(self, request, pk): - - device = get_object_or_404(Device, pk=pk) - - return render(request, 'dcim/device_configcontext.html', { - 'device': device, - 'active_tab': 'config-context', - }) +class DeviceConfigContextView(ObjectConfigContextView): + object_class = Device + base_template = 'dcim/device.html' class DeviceCreateView(PermissionRequiredMixin, ObjectEditView): diff --git a/netbox/extras/models.py b/netbox/extras/models.py index fe80ec09a..2f84cff8d 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals from collections import OrderedDict from datetime import date -import json import graphviz from django.contrib.auth.models import User @@ -21,6 +20,7 @@ from django.utils.safestring import mark_safe from dcim.constants import CONNECTION_STATUS_CONNECTED from utilities.utils import foreground_color from .constants import * +from .querysets import ConfigContextQuerySet # @@ -676,6 +676,8 @@ class ConfigContext(models.Model): ) data = JSONField() + objects = ConfigContextQuerySet.as_manager() + class Meta: ordering = ['weight', 'name'] @@ -696,22 +698,9 @@ class ConfigContextModel(models.Model): Return the rendered configuration context for a device or VM. """ - # `device_role` for Device; `role` for VirtualMachine - role = getattr(self, 'device_role', None) or self.role - - # Gather all ConfigContexts orders by weight, name - contexts = ConfigContext.objects.filter( - Q(regions=self.site.region) | Q(regions=None), - Q(sites=self.site) | Q(sites=None), - Q(roles=role) | Q(roles=None), - Q(tenants=self.tenant) | Q(tenants=None), - Q(platforms=self.platform) | Q(platforms=None), - is_active=True, - ).order_by('weight', 'name') - # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs data = {} - for context in contexts: + for context in ConfigContext.objects.get_for_object(self): data.update(context.data) return data diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py new file mode 100644 index 000000000..5c450e407 --- /dev/null +++ b/netbox/extras/querysets.py @@ -0,0 +1,23 @@ +from __future__ import unicode_literals + +from django.db.models import Q, QuerySet + + +class ConfigContextQuerySet(QuerySet): + + def get_for_object(self, obj): + """ + Return all applicable ConfigContexts for a given object. Only active ConfigContexts will be included. + """ + + # `device_role` for Device; `role` for VirtualMachine + role = getattr(obj, 'device_role', None) or obj.role + + return self.filter( + Q(regions=obj.site.region) | Q(regions=None), + Q(sites=obj.site) | Q(sites=None), + Q(roles=role) | Q(roles=None), + Q(tenants=obj.tenant) | Q(tenants=None), + Q(platforms=obj.platform) | Q(platforms=None), + is_active=True, + ).order_by('weight', 'name') diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 8670fdcdb..632b21177 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -100,6 +100,24 @@ class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): default_return_url = 'extras:configcontext_list' +class ObjectConfigContextView(View): + object_class = None + base_template = None + + def get(self, request, pk): + + obj = get_object_or_404(self.object_class, pk=pk) + source_contexts = ConfigContext.objects.get_for_object(obj) + + return render(request, 'extras/object_configcontext.html', { + self.object_class._meta.model_name: obj, + 'rendered_context': obj.get_config_context(), + 'source_contexts': source_contexts, + 'base_template': self.base_template, + 'active_tab': 'config-context', + }) + + # # Change logging # diff --git a/netbox/templates/dcim/device_configcontext.html b/netbox/templates/dcim/device_configcontext.html deleted file mode 100644 index 537093edf..000000000 --- a/netbox/templates/dcim/device_configcontext.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'dcim/device.html' %} -{% load helpers %} - -{% block title %}{{ device }} - Config Context{% endblock %} - -{% block content %} -
    -
    -
    -
    - Config Context -
    -
    -
    {{ device.get_config_context|render_json }}
    -
    -
    -
    -
    -{% endblock %} diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html new file mode 100644 index 000000000..a88cbde4d --- /dev/null +++ b/netbox/templates/extras/object_configcontext.html @@ -0,0 +1,35 @@ +{% extends base_template %} +{% load helpers %} + +{% block title %}{{ block.super }} - Config Context{% endblock %} + +{% block content %} +
    +
    +
    +
    + Rendered Context +
    +
    +
    {{ rendered_context|render_json }}
    +
    +
    +
    +
    +
    +
    + Source Contexts +
    + {% for context in source_contexts %} +
    +
    + {{ context.weight }} +
    + {{ context.name }} +
    {{ context.data|render_json }}
    +
    + {% endfor %} +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine_configcontext.html b/netbox/templates/virtualization/virtualmachine_configcontext.html deleted file mode 100644 index 4218a4161..000000000 --- a/netbox/templates/virtualization/virtualmachine_configcontext.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'virtualization/virtualmachine.html' %} - -{% block title %}{{ virtualmachine }} - Config Context{% endblock %} - -{% block content %} -
    -
    -
    -
    - Config Context -
    -
    -
    {{ virtualmachine.get_config_context }}
    -
    -
    -
    -
    -{% endblock %} diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 6286ba1a5..851378b90 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -9,6 +9,7 @@ from django.views.generic import View from dcim.models import Device, Interface from dcim.tables import DeviceTable +from extras.views import ObjectConfigContextView from ipam.models import Service from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, @@ -269,16 +270,9 @@ class VirtualMachineView(View): }) -class VirtualMachineConfigContextView(View): - - def get(self, request, pk): - - virtualmachine = get_object_or_404(VirtualMachine, pk=pk) - - return render(request, 'virtualization/virtualmachine_configcontext.html', { - 'virtualmachine': virtualmachine, - 'active_tab': 'config-context', - }) +class VirtualMachineConfigContextView(ObjectConfigContextView): + object_class = VirtualMachine + base_template = 'virtualization/virtualmachine.html' class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView): From 65dd7a59380cfd06a17b1daffbfc6a106e69ef89 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Jun 2018 14:05:57 -0400 Subject: [PATCH 066/159] Applied JSON rederer to ConfigContext data --- netbox/templates/extras/configcontext.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 08d257b5f..18d466771 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -146,7 +146,7 @@ Data
    -
    {{ configcontext.data }}
    +
    {{ configcontext.data|render_json }}
    From 1edc73179a03c54f74b9346a6a19b1e4eb1726e5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Jun 2018 14:10:20 -0400 Subject: [PATCH 067/159] Sort rendered config context --- netbox/extras/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 2f84cff8d..c8822ff0d 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -703,7 +703,7 @@ class ConfigContextModel(models.Model): for context in ConfigContext.objects.get_for_object(self): data.update(context.data) - return data + return sorted(data) # From ace7e3b1082d259d90b5c333e1901a7312d36ca5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Jun 2018 14:19:26 -0400 Subject: [PATCH 068/159] Fixed is_active table column --- netbox/extras/tables.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index be5d748e3..c22f2977b 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -59,6 +59,9 @@ class TagTable(BaseTable): class ConfigContextTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() + is_active = tables.BooleanColumn( + verbose_name='Active' + ) actions = tables.TemplateColumn( template_code=CONFIGCONTEXT_ACTIONS, attrs={'td': {'class': 'text-right'}}, @@ -67,7 +70,7 @@ class ConfigContextTable(BaseTable): class Meta(BaseTable.Meta): model = ConfigContext - fields = ('pk', 'name', 'weight', 'active') + fields = ('pk', 'name', 'weight', 'is_active') class ObjectChangeTable(BaseTable): From 743cf6d3982009f0a83407a856597025cbec76a8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Jun 2018 09:44:32 -0400 Subject: [PATCH 069/159] Added description to ConfigContext --- netbox/extras/api/serializers.py | 4 +++- netbox/extras/forms.py | 4 +++- .../{0014_config-contexts.py => 0014_configcontexts.py} | 3 ++- netbox/extras/models.py | 4 ++++ netbox/extras/tables.py | 2 +- netbox/templates/extras/configcontext_edit.html | 6 ++++++ netbox/templates/extras/object_configcontext.html | 3 +++ 7 files changed, 22 insertions(+), 4 deletions(-) rename netbox/extras/migrations/{0014_config-contexts.py => 0014_configcontexts.py} (95%) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a64acc1a4..d6a7d5079 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -140,7 +140,9 @@ class ConfigContextSerializer(ValidatedModelSerializer): class Meta: model = ConfigContext - fields = ['name', 'weight', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data'] + fields = [ + 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data', + ] # diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index bd7ace840..ea373cf4f 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -197,7 +197,9 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext - fields = ['name', 'weight', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data'] + fields = [ + 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data', + ] # diff --git a/netbox/extras/migrations/0014_config-contexts.py b/netbox/extras/migrations/0014_configcontexts.py similarity index 95% rename from netbox/extras/migrations/0014_config-contexts.py rename to netbox/extras/migrations/0014_configcontexts.py index 5d8d46d6f..bc12e2cdf 100644 --- a/netbox/extras/migrations/0014_config-contexts.py +++ b/netbox/extras/migrations/0014_configcontexts.py @@ -1,4 +1,4 @@ -# Generated by Django 2.0.6 on 2018-06-27 17:45 +# Generated by Django 2.0.6 on 2018-06-29 13:34 import django.contrib.postgres.fields.jsonb from django.db import migrations, models @@ -20,6 +20,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=100, unique=True)), ('weight', models.PositiveSmallIntegerField(default=1000)), ('is_active', models.BooleanField(default=True)), + ('description', models.CharField(blank=True, max_length=100)), ('data', django.contrib.postgres.fields.jsonb.JSONField()), ('platforms', models.ManyToManyField(blank=True, related_name='_configcontext_platforms_+', to='dcim.Platform')), ('regions', models.ManyToManyField(blank=True, related_name='_configcontext_regions_+', to='dcim.Region')), diff --git a/netbox/extras/models.py b/netbox/extras/models.py index c8822ff0d..f92693b11 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -646,6 +646,10 @@ class ConfigContext(models.Model): weight = models.PositiveSmallIntegerField( default=1000 ) + description = models.CharField( + max_length=100, + blank=True + ) is_active = models.BooleanField( default=True, ) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index c22f2977b..4a7e987cb 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -70,7 +70,7 @@ class ConfigContextTable(BaseTable): class Meta(BaseTable.Meta): model = ConfigContext - fields = ('pk', 'name', 'weight', 'is_active') + fields = ('pk', 'name', 'weight', 'is_active', 'description', 'actions') class ObjectChangeTable(BaseTable): diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index a0f428131..4b7e53044 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -7,7 +7,13 @@
    {% render_field form.name %} {% render_field form.weight %} + {% render_field form.description %} {% render_field form.is_active %} +
    +
    +
    +
    Assignment
    +
    {% render_field form.regions %} {% render_field form.sites %} {% render_field form.roles %} diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html index a88cbde4d..5cc7e7d7f 100644 --- a/netbox/templates/extras/object_configcontext.html +++ b/netbox/templates/extras/object_configcontext.html @@ -26,6 +26,9 @@ {{ context.weight }}
    {{ context.name }} + {% if context.description %} +
    {{ context.description }} + {% endif %}
    {{ context.data|render_json }}
    {% endfor %} From 278bacbce8675b4478ec2bb68079d8a911547fac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Jun 2018 09:53:33 -0400 Subject: [PATCH 070/159] Fixed rendered config context ordering --- netbox/extras/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index f92693b11..ac9a4dd8a 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -703,11 +703,11 @@ class ConfigContextModel(models.Model): """ # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs - data = {} + data = OrderedDict() for context in ConfigContext.objects.get_for_object(self): data.update(context.data) - return sorted(data) + return data # From 78574809789d927df8de1d3ee81320de4c6419a0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Jun 2018 09:56:04 -0400 Subject: [PATCH 071/159] Added missing description field --- netbox/templates/extras/configcontext.html | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 18d466771..c87ff9039 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -53,6 +53,16 @@ {{ configcontext.weight }} + + Description + + {% if configcontext.description %} + {{ configcontext.description }} + {% else %} + N/A + {% endif %} + + Active @@ -67,6 +77,13 @@ {% endif %} + +
    +
    +
    + Assignment +
    + - - - - - - - -
    Regions From f5f16ce64b8c43414ac3307538df6fff835b0449 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Jun 2018 10:40:57 -0400 Subject: [PATCH 072/159] Include custom fields in ObjectChange data --- netbox/utilities/utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 1eee8527c..318b04dad 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -93,6 +93,15 @@ def serialize_object(obj, extra=None): """ json_str = serialize('json', [obj]) data = json.loads(json_str)[0]['fields'] + + # Include any custom fields + if hasattr(obj, 'get_custom_fields'): + data['custom_fields'] = { + field.name: value for field, value in obj.get_custom_fields().items() + } + + # Append any extra data if extra is not None: data.update(extra) + return data From 35d58d2f7c073352fecc328bacc7f9976cc4a27a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Jun 2018 11:21:00 -0400 Subject: [PATCH 073/159] Closes #2029: Added optional NAPALM arguments to Platform model --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/api/views.py | 5 ++++- netbox/dcim/forms.py | 5 ++++- .../migrations/0061_platform_napalm_args.py | 19 +++++++++++++++++++ netbox/dcim/models.py | 11 +++++++++-- 5 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 netbox/dcim/migrations/0061_platform_napalm_args.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d0e134b71..6ef867032 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -346,7 +346,7 @@ class PlatformSerializer(ValidatedModelSerializer): class Meta: model = Platform - fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] + fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'rpc_client'] class NestedPlatformSerializer(WritableNestedSerializer): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index b99d98477..ce89eb69b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -283,12 +283,15 @@ class DeviceViewSet(CustomFieldModelViewSet): # TODO: Improve error handling response = OrderedDict([(m, None) for m in napalm_methods]) ip_address = str(device.primary_ip.address.ip) + optional_args = settings.NAPALM_ARGS.copy() + if device.platform.napalm_args is not None: + optional_args.update(device.platform.napalm_args) d = driver( hostname=ip_address, username=settings.NAPALM_USERNAME, password=settings.NAPALM_PASSWORD, timeout=settings.NAPALM_TIMEOUT, - optional_args=settings.NAPALM_ARGS + optional_args=optional_args ) try: d.open() diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 807caa5ce..5dfd32739 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -746,7 +746,10 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] + fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'rpc_client'] + widgets = { + 'napalm_args': SmallTextarea(), + } class PlatformCSVForm(forms.ModelForm): diff --git a/netbox/dcim/migrations/0061_platform_napalm_args.py b/netbox/dcim/migrations/0061_platform_napalm_args.py new file mode 100644 index 000000000..6da863aec --- /dev/null +++ b/netbox/dcim/migrations/0061_platform_napalm_args.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.6 on 2018-06-29 15:02 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0060_change_logging'), + ] + + operations = [ + migrations.AddField( + model_name='platform', + name='napalm_args', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)', null=True, verbose_name='NAPALM arguments'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 6963ddc7f..befbb5a03 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -6,7 +6,7 @@ from itertools import count, groupby from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.fields import ArrayField, JSONField from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -1125,6 +1125,12 @@ class Platform(ChangeLoggedModel): verbose_name='NAPALM driver', help_text='The name of the NAPALM driver to use when interacting with devices' ) + napalm_args = JSONField( + blank=True, + null=True, + verbose_name='NAPALM arguments', + help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)' + ) rpc_client = models.CharField( max_length=30, choices=RPC_CLIENT_CHOICES, @@ -1133,7 +1139,7 @@ class Platform(ChangeLoggedModel): ) serializer = 'dcim.api.serializers.PlatformSerializer' - csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver'] + csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args'] class Meta: ordering = ['name'] @@ -1150,6 +1156,7 @@ class Platform(ChangeLoggedModel): self.slug, self.manufacturer.name if self.manufacturer else None, self.napalm_driver, + self.napalm_args, ) From b9bdd666daad6b84e2ad85bfeda79aa3dfce69c3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Jun 2018 11:48:21 -0400 Subject: [PATCH 074/159] Closes #2200: Replaced detail_route API view decorator with action (DRF change) --- netbox/circuits/api/views.py | 4 ++-- netbox/dcim/api/views.py | 12 ++++++------ netbox/extras/api/views.py | 6 +++--- netbox/ipam/api/views.py | 6 +++--- netbox/virtualization/api/views.py | 4 ++-- requirements.txt | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index d70a0596c..0c0f5c67e 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from django.shortcuts import get_object_or_404 -from rest_framework.decorators import detail_route +from rest_framework.decorators import action from rest_framework.response import Response from circuits import filters @@ -32,7 +32,7 @@ class ProviderViewSet(CustomFieldModelViewSet): serializer_class = serializers.ProviderSerializer filter_class = filters.ProviderFilter - @detail_route() + @action(detail=True) def graphs(self, request, pk=None): """ A convenience method for rendering graphs for a particular provider. diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ce89eb69b..fb29da2b6 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -8,7 +8,7 @@ from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.openapi import Parameter from drf_yasg.utils import swagger_auto_schema -from rest_framework.decorators import detail_route +from rest_framework.decorators import action from rest_framework.mixins import ListModelMixin from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ViewSet @@ -63,7 +63,7 @@ class SiteViewSet(CustomFieldModelViewSet): serializer_class = serializers.SiteSerializer filter_class = filters.SiteFilter - @detail_route() + @action(detail=True) def graphs(self, request, pk=None): """ A convenience method for rendering graphs for a particular site. @@ -103,7 +103,7 @@ class RackViewSet(CustomFieldModelViewSet): serializer_class = serializers.RackSerializer filter_class = filters.RackFilter - @detail_route() + @action(detail=True) def units(self, request, pk=None): """ List rack units (by rack) @@ -232,12 +232,12 @@ class DeviceViewSet(CustomFieldModelViewSet): serializer_class = serializers.DeviceSerializer filter_class = filters.DeviceFilter - @detail_route(url_path='config-context') + @action(detail=True, url_path='config-context') def config_context(self, request, pk): device = get_object_or_404(Device, pk=pk) return Response(device.get_config_context()) - @detail_route(url_path='napalm') + @action(detail=True, url_path='napalm') def napalm(self, request, pk): """ Execute a NAPALM method on a Device @@ -337,7 +337,7 @@ class InterfaceViewSet(ModelViewSet): serializer_class = serializers.InterfaceSerializer filter_class = filters.InterfaceFilter - @detail_route() + @action(detail=True) def graphs(self, request, pk=None): """ A convenience method for rendering graphs for a particular interface. diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 55e4457ce..a5833de6f 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Count from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 -from rest_framework.decorators import detail_route +from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet @@ -94,7 +94,7 @@ class TopologyMapViewSet(ModelViewSet): serializer_class = serializers.TopologyMapSerializer filter_class = filters.TopologyMapFilter - @detail_route() + @action(detail=True) def render(self, request, pk): tmap = get_object_or_404(TopologyMap, pk=pk) @@ -199,7 +199,7 @@ class ReportViewSet(ViewSet): return Response(serializer.data) - @detail_route(methods=['post']) + @action(detail=True, methods=['post']) def run(self, request, pk): """ Run a Report and create a new ReportResult, overwriting any previous result for the Report. diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index abbe6e2b1..5cdcc5276 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from django.conf import settings from django.shortcuts import get_object_or_404 from rest_framework import status -from rest_framework.decorators import detail_route +from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response @@ -77,7 +77,7 @@ class PrefixViewSet(CustomFieldModelViewSet): serializer_class = serializers.PrefixSerializer filter_class = filters.PrefixFilter - @detail_route(url_path='available-prefixes', methods=['get', 'post']) + @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) def available_prefixes(self, request, pk=None): """ A convenience method for returning available child prefixes within a parent. @@ -137,7 +137,7 @@ class PrefixViewSet(CustomFieldModelViewSet): return Response(serializer.data) - @detail_route(url_path='available-ips', methods=['get', 'post']) + @action(detail=True, url_path='available-ips', methods=['get', 'post']) def available_ips(self, request, pk=None): """ A convenience method for returning available IP addresses within a prefix. By default, the number of IPs diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index b04248f87..60afb3f9a 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from django.shortcuts import get_object_or_404 -from rest_framework.decorators import detail_route +from rest_framework.decorators import action from rest_framework.response import Response from dcim.models import Interface @@ -53,7 +53,7 @@ class VirtualMachineViewSet(CustomFieldModelViewSet): serializer_class = serializers.VirtualMachineSerializer filter_class = filters.VirtualMachineFilter - @detail_route(url_path='config-context') + @action(detail=True, url_path='config-context') def config_context(self, request, pk): device = get_object_or_404(VirtualMachine, pk=pk) return Response(device.get_config_context()) diff --git a/requirements.txt b/requirements.txt index 147b42bc8..6a4e3e7b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ django-mptt>=0.9.0 django-tables2>=1.19.0 django-taggit>=0.22.2 django-timezone-field>=2.0 -djangorestframework>=3.7.7,<3.8.2 +djangorestframework>=3.8.0,<3.8.2 drf-yasg[validation]>=1.4.4 graphviz>=0.8.2 Markdown>=2.6.11 From bf1c7cacc61a9708ee977a878c9ea336a25b81a0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Jun 2018 12:05:56 -0400 Subject: [PATCH 075/159] Improved rendering of boolean fields in tables --- netbox/dcim/tables.py | 10 +++++----- netbox/extras/tables.py | 4 ++-- netbox/ipam/tables.py | 4 ++-- netbox/utilities/tables.py | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index b78c9ce55..46a5da3f3 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -4,7 +4,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform, @@ -346,10 +346,10 @@ class DeviceTypeTable(BaseTable): args=[Accessor('pk')], verbose_name='Device Type' ) - is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') - is_console_server = tables.BooleanColumn(verbose_name='CS') - is_pdu = tables.BooleanColumn(verbose_name='PDU') - is_network_device = tables.BooleanColumn(verbose_name='Net') + is_full_depth = BooleanColumn(verbose_name='Full Depth') + is_console_server = BooleanColumn(verbose_name='CS') + is_pdu = BooleanColumn(verbose_name='PDU') + is_network_device = BooleanColumn(verbose_name='Net') subdevice_role = tables.TemplateColumn( template_code=SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role' diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 4a7e987cb..364da9ebb 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import django_tables2 as tables from taggit.models import Tag -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ToggleColumn from .models import ConfigContext, ObjectChange TAG_ACTIONS = """ @@ -59,7 +59,7 @@ class TagTable(BaseTable): class ConfigContextTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - is_active = tables.BooleanColumn( + is_active = BooleanColumn( verbose_name='Active' ) actions = tables.TemplateColumn( diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 2cb1c6606..e444cfe28 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -5,7 +5,7 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ToggleColumn from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF RIR_UTILIZATION = """ @@ -193,7 +193,7 @@ class VRFTable(BaseTable): class RIRTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn(verbose_name='Name') - is_private = tables.BooleanColumn(verbose_name='Private') + is_private = BooleanColumn(verbose_name='Private') aggregate_count = tables.Column(verbose_name='Aggregates') actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 8694d986b..a9f1044d6 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -31,3 +31,18 @@ class ToggleColumn(tables.CheckBoxColumn): @property def header(self): return mark_safe('') + + +class BooleanColumn(tables.Column): + """ + Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode + character. + """ + def render(self, value): + if value is True: + rendered = '' + elif value is False: + rendered = '' + else: + rendered = '' + return mark_safe(rendered) From cd56e51a615b1869fad0dc810de482fc6e8784ea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 2 Jul 2018 11:54:41 -0400 Subject: [PATCH 076/159] Closes #1851: Standardize usage of GetReturnURLMixin --- netbox/circuits/views.py | 5 +- netbox/dcim/views.py | 36 +++----- netbox/ipam/views.py | 13 +-- netbox/secrets/views.py | 6 +- netbox/templates/import_success.html | 2 +- netbox/templates/utilities/obj_import.html | 2 +- netbox/tenancy/views.py | 5 +- netbox/utilities/views.py | 99 ++++++++-------------- netbox/virtualization/views.py | 8 +- 9 files changed, 57 insertions(+), 119 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 87747f36f..1191c12b0 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -6,7 +6,6 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse from django.views.generic import View from extras.models import Graph, GRAPH_TYPE_PROVIDER @@ -106,9 +105,7 @@ class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuittype' model = CircuitType model_form = forms.CircuitTypeForm - - def get_return_url(self, request, obj): - return reverse('circuits:circuittype_list') + default_return_url = 'circuits:circuittype_list' class CircuitTypeEditView(CircuitTypeCreateView): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a32053463..9520297f1 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,7 +12,7 @@ from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape -from django.utils.http import is_safe_url, urlencode +from django.utils.http import urlencode from django.utils.safestring import mark_safe from django.views.generic import View from natsort import natsorted @@ -38,7 +38,7 @@ from .models import ( ) -class BulkRenameView(View): +class BulkRenameView(GetReturnURLMixin, View): """ An extendable view for renaming device components in bulk. """ @@ -50,10 +50,6 @@ class BulkRenameView(View): model = self.queryset.model - return_url = request.GET.get('return_url') - if not return_url or not is_safe_url(url=return_url, host=request.get_host()): - return_url = 'home' - if '_preview' in request.POST or '_apply' in request.POST: form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) selected_objects = self.queryset.filter(pk__in=form.initial['pk']) @@ -70,7 +66,7 @@ class BulkRenameView(View): len(selected_objects), model._meta.verbose_name_plural )) - return redirect(return_url) + return redirect(self.get_return_url(request)) else: form = self.form(initial={'pk': request.POST.getlist('pk')}) @@ -80,7 +76,7 @@ class BulkRenameView(View): 'form': form, 'obj_type_plural': model._meta.verbose_name_plural, 'selected_objects': selected_objects, - 'return_url': return_url, + 'return_url': self.get_return_url(request), }) @@ -138,9 +134,7 @@ class RegionCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_region' model = Region model_form = forms.RegionForm - - def get_return_url(self, request, obj): - return reverse('dcim:region_list') + default_return_url = 'dcim:region_list' class RegionEditView(RegionCreateView): @@ -252,9 +246,7 @@ class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackgroup' model = RackGroup model_form = forms.RackGroupForm - - def get_return_url(self, request, obj): - return reverse('dcim:rackgroup_list') + default_return_url = 'dcim:rackgroup_list' class RackGroupEditView(RackGroupCreateView): @@ -291,9 +283,7 @@ class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackrole' model = RackRole model_form = forms.RackRoleForm - - def get_return_url(self, request, obj): - return reverse('dcim:rackrole_list') + default_return_url = 'dcim:rackrole_list' class RackRoleEditView(RackRoleCreateView): @@ -515,9 +505,7 @@ class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_manufacturer' model = Manufacturer model_form = forms.ManufacturerForm - - def get_return_url(self, request, obj): - return reverse('dcim:manufacturer_list') + default_return_url = 'dcim:manufacturer_list' class ManufacturerEditView(ManufacturerCreateView): @@ -777,9 +765,7 @@ class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_devicerole' model = DeviceRole model_form = forms.DeviceRoleForm - - def get_return_url(self, request, obj): - return reverse('dcim:devicerole_list') + default_return_url = 'dcim:devicerole_list' class DeviceRoleEditView(DeviceRoleCreateView): @@ -815,9 +801,7 @@ class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_platform' model = Platform model_form = forms.PlatformForm - - def get_return_url(self, request, obj): - return reverse('dcim:platform_list') + default_return_url = 'dcim:platform_list' class PlatformEditView(PlatformCreateView): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 3c88da5fd..6620721a7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -5,7 +5,6 @@ from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse from django.views.generic import View from django_tables2 import RequestConfig @@ -248,9 +247,7 @@ class RIRCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_rir' model = RIR model_form = forms.RIRForm - - def get_return_url(self, request, obj): - return reverse('ipam:rir_list') + default_return_url = 'ipam:rir_list' class RIREditView(RIRCreateView): @@ -401,9 +398,7 @@ class RoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_role' model = Role model_form = forms.RoleForm - - def get_return_url(self, request, obj): - return reverse('ipam:role_list') + default_return_url = 'ipam:role_list' class RoleEditView(RoleCreateView): @@ -799,9 +794,7 @@ class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vlangroup' model = VLANGroup model_form = forms.VLANGroupForm - - def get_return_url(self, request, obj): - return reverse('ipam:vlangroup_list') + default_return_url = 'ipam:vlangroup_list' class VLANGroupEditView(VLANGroupCreateView): diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index dfde0a662..a18d38cce 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -44,9 +44,7 @@ class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'secrets.add_secretrole' model = SecretRole model_form = forms.SecretRoleForm - - def get_return_url(self, request, obj): - return reverse('secrets:secretrole_list') + default_return_url = 'secrets:secretrole_list' class SecretRoleEditView(SecretRoleCreateView): @@ -244,7 +242,7 @@ class SecretBulkImportView(BulkImportView): 'form': self._import_form(request.POST), 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'return_url': self.default_return_url, + 'return_url': self.get_return_url(request), }) diff --git a/netbox/templates/import_success.html b/netbox/templates/import_success.html index 00aadfea7..dba525af5 100644 --- a/netbox/templates/import_success.html +++ b/netbox/templates/import_success.html @@ -8,6 +8,6 @@ Import more {% if return_url %} - View All + View All {% endif %} {% endblock %} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index ced3ee733..89621a3c3 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -22,7 +22,7 @@
    {% if return_url %} - Cancel + Cancel {% endif %}
    diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 99c4acc8a..95b68d4fa 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count, Q from django.shortcuts import get_object_or_404, render -from django.urls import reverse from django.views.generic import View from circuits.models import Circuit @@ -31,9 +30,7 @@ class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'tenancy.add_tenantgroup' model = TenantGroup model_form = forms.TenantGroupForm - - def get_return_url(self, request, obj): - return reverse('tenancy:tenantgroup_list') + default_return_url = 'tenancy:tenantgroup_list' class TenantGroupEditView(TenantGroupCreateView): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 80a4ad086..e9e4b33a1 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -52,14 +52,22 @@ class GetReturnURLMixin(object): """ default_return_url = None - def get_return_url(self, request, obj): + def get_return_url(self, request, obj=None): + + # First, see if `return_url` was specified as a query parameter. Use it only if it's considered safe. query_param = request.GET.get('return_url') if query_param and is_safe_url(url=query_param, host=request.get_host()): return query_param + + # Next, check if the object being modified (if any) has an absolute URL. elif obj.pk and hasattr(obj, 'get_absolute_url'): return obj.get_absolute_url() + + # Fall back to the default URL (if specified) for the view. elif self.default_return_url is not None: return reverse(self.default_return_url) + + # If all else fails, return home. Ideally this should never happen. return reverse('home') @@ -159,7 +167,6 @@ class ObjectEditView(GetReturnURLMixin, View): model: The model of the object being edited model_form: The form used to create or edit the object template_name: The name of the template - default_return_url: The name of the URL used to display a list of this object type """ model = None model_form = None @@ -236,7 +243,6 @@ class ObjectDeleteView(GetReturnURLMixin, View): model: The model of the object being deleted template_name: The name of the template - default_return_url: Name of the URL to which the user is redirected after deleting the object """ model = None template_name = 'utilities/obj_delete.html' @@ -289,20 +295,18 @@ class ObjectDeleteView(GetReturnURLMixin, View): }) -class BulkCreateView(View): +class BulkCreateView(GetReturnURLMixin, View): """ Create new objects in bulk. form: Form class which provides the `pattern` field model_form: The ModelForm used to create individual objects template_name: The name of the template - default_return_url: Name of the URL to which the user is redirected after creating the objects """ form = None model_form = None pattern_target = '' template_name = None - default_return_url = 'home' def get(self, request): @@ -319,7 +323,7 @@ class BulkCreateView(View): 'obj_type': self.model_form._meta.model._meta.verbose_name, 'form': form, 'model_form': model_form, - 'return_url': reverse(self.default_return_url), + 'return_url': self.get_return_url(request), }) def post(self, request): @@ -362,7 +366,7 @@ class BulkCreateView(View): if '_addanother' in request.POST: return redirect(request.path) - return redirect(self.default_return_url) + return redirect(self.get_return_url(request)) except IntegrityError: pass @@ -371,23 +375,21 @@ class BulkCreateView(View): 'form': form, 'model_form': model_form, 'obj_type': model._meta.verbose_name, - 'return_url': reverse(self.default_return_url), + 'return_url': self.get_return_url(request), }) -class BulkImportView(View): +class BulkImportView(GetReturnURLMixin, View): """ Import objects in bulk (CSV format). model_form: The form used to create each imported object table: The django-tables2 Table used to render the list of imported objects template_name: The name of the template - default_return_url: The name of the URL to use for the cancel button widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ model_form = None table = None - default_return_url = None template_name = 'utilities/obj_import.html' widget_attrs = {} @@ -413,7 +415,7 @@ class BulkImportView(View): 'form': self._import_form(), 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'return_url': self.default_return_url, + 'return_url': self.get_return_url(request), }) def post(self, request): @@ -446,7 +448,7 @@ class BulkImportView(View): return render(request, "import_success.html", { 'table': obj_table, - 'return_url': self.default_return_url, + 'return_url': self.get_return_url(request), }) except ValidationError: @@ -456,11 +458,11 @@ class BulkImportView(View): 'form': form, 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'return_url': self.default_return_url, + 'return_url': self.get_return_url(request), }) -class BulkEditView(View): +class BulkEditView(GetReturnURLMixin, View): """ Edit objects in bulk. @@ -471,8 +473,6 @@ class BulkEditView(View): table: The table used to display devices being edited form: The form class used to edit objects in bulk template_name: The name of the template - default_return_url: Name of the URL to which the user is redirected after editing the objects (can be overridden by - POSTing return_url) """ cls = None parent_cls = None @@ -481,10 +481,9 @@ class BulkEditView(View): table = None form = None template_name = 'utilities/obj_bulk_edit.html' - default_return_url = 'home' def get(self, request): - return redirect(self.default_return_url) + return redirect(self.get_return_url(request)) def post(self, request, **kwargs): @@ -494,15 +493,6 @@ class BulkEditView(View): else: parent_obj = None - # Determine URL to redirect users upon modification of objects - posted_return_url = request.POST.get('return_url') - if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()): - return_url = posted_return_url - elif parent_obj: - return_url = parent_obj.get_absolute_url() - else: - return_url = reverse(self.default_return_url) - # Are we editing *all* objects in the queryset or just a selected subset? if request.POST.get('_all') and self.filter is not None: pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs] @@ -559,7 +549,7 @@ class BulkEditView(View): msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural) messages.success(self.request, msg) - return redirect(return_url) + return redirect(self.get_return_url(request)) except ValidationError as e: messages.error(self.request, "{} failed validation: {}".format(obj, e)) @@ -574,17 +564,17 @@ class BulkEditView(View): table = self.table(queryset.filter(pk__in=pk_list), orderable=False) if not table.rows: messages.warning(request, "No {} were selected.".format(self.cls._meta.verbose_name_plural)) - return redirect(return_url) + return redirect(self.get_return_url(request)) return render(request, self.template_name, { 'form': form, 'table': table, 'obj_type_plural': self.cls._meta.verbose_name_plural, - 'return_url': return_url, + 'return_url': self.get_return_url(request), }) -class BulkDeleteView(View): +class BulkDeleteView(GetReturnURLMixin, View): """ Delete objects in bulk. @@ -595,8 +585,6 @@ class BulkDeleteView(View): table: The table used to display devices being deleted form: The form class used to delete objects in bulk template_name: The name of the template - default_return_url: Name of the URL to which the user is redirected after deleting the objects (can be overriden by - POSTing return_url) """ cls = None parent_cls = None @@ -605,10 +593,9 @@ class BulkDeleteView(View): table = None form = None template_name = 'utilities/obj_bulk_delete.html' - default_return_url = 'home' def get(self, request): - return redirect(self.default_return_url) + return redirect(self.get_return_url(request)) def post(self, request, **kwargs): @@ -618,15 +605,6 @@ class BulkDeleteView(View): else: parent_obj = None - # Determine URL to redirect users upon deletion of objects - posted_return_url = request.POST.get('return_url') - if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()): - return_url = posted_return_url - elif parent_obj: - return_url = parent_obj.get_absolute_url() - else: - return_url = reverse(self.default_return_url) - # Are we deleting *all* objects in the queryset or just a selected subset? if request.POST.get('_all'): if self.filter is not None: @@ -648,28 +626,31 @@ class BulkDeleteView(View): deleted_count = queryset.delete()[1][self.cls._meta.label] except ProtectedError as e: handle_protectederror(list(queryset), request, e) - return redirect(return_url) + return redirect(self.get_return_url(request)) msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural) messages.success(request, msg) - return redirect(return_url) + return redirect(self.get_return_url(request)) else: - form = form_cls(initial={'pk': pk_list, 'return_url': return_url}) + form = form_cls(initial={ + 'pk': pk_list, + 'return_url': self.get_return_url(request), + }) # Retrieve objects being deleted queryset = self.queryset or self.cls.objects.all() table = self.table(queryset.filter(pk__in=pk_list), orderable=False) if not table.rows: messages.warning(request, "No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural)) - return redirect(return_url) + return redirect(self.get_return_url(request)) return render(request, self.template_name, { 'form': form, 'parent_obj': parent_obj, 'obj_type_plural': self.cls._meta.verbose_name_plural, 'table': table, - 'return_url': return_url, + 'return_url': self.get_return_url(request), }) def get_form(self): @@ -785,7 +766,7 @@ class ComponentCreateView(View): }) -class BulkComponentCreateView(View): +class BulkComponentCreateView(GetReturnURLMixin, View): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. """ @@ -797,7 +778,6 @@ class BulkComponentCreateView(View): filter = None table = None template_name = 'utilities/obj_bulk_add_component.html' - default_return_url = 'home' def post(self, request): @@ -807,17 +787,10 @@ class BulkComponentCreateView(View): else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] - # Determine URL to redirect users upon modification of objects - posted_return_url = request.POST.get('return_url') - if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()): - return_url = posted_return_url - else: - return_url = reverse(self.default_return_url) - selected_objects = self.parent_model.objects.filter(pk__in=pk_list) if not selected_objects: messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural)) - return redirect(return_url) + return redirect(self.get_return_url(request)) table = self.table(selected_objects) if '_create' in request.POST: @@ -855,7 +828,7 @@ class BulkComponentCreateView(View): len(form.cleaned_data['pk']), self.parent_model._meta.verbose_name_plural )) - return redirect(return_url) + return redirect(self.get_return_url(request)) else: form = self.form(initial={'pk': pk_list}) @@ -864,5 +837,5 @@ class BulkComponentCreateView(View): 'form': form, 'component_name': self.model._meta.verbose_name_plural, 'table': table, - 'return_url': reverse(self.default_return_url), + 'return_url': self.get_return_url(request), }) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 851378b90..167a9fb6b 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -33,9 +33,7 @@ class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_clustertype' model = ClusterType model_form = forms.ClusterTypeForm - - def get_return_url(self, request, obj): - return reverse('virtualization:clustertype_list') + default_return_url = 'virtualization:clustertype_list' class ClusterTypeEditView(ClusterTypeCreateView): @@ -71,9 +69,7 @@ class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_clustergroup' model = ClusterGroup model_form = forms.ClusterGroupForm - - def get_return_url(self, request, obj): - return reverse('virtualization:clustergroup_list') + default_return_url = 'virtualization:clustergroup_list' class ClusterGroupEditView(ClusterGroupCreateView): From 104bd1b45f6a6f3b5d94fef4126c3b49413b33a3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 2 Jul 2018 16:33:18 -0400 Subject: [PATCH 077/159] Closes #2211: Removed Python 2 instructions from the installation docs --- README.md | 2 +- docs/installation/ldap.md | 4 +-- docs/installation/migrating-to-python3.md | 3 ++ docs/installation/netbox.md | 37 ++--------------------- docs/installation/upgrading.md | 2 +- docs/installation/web-server.md | 2 +- 6 files changed, 10 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 9fa3acb94..28673bf36 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode ### Build Status -NetBox is built against both Python 2.7 and 3.5. Python 3.5 is recommended. +NetBox is built against both Python 2.7 and 3.5. Python 3.5 or higher is strongly recommended. | | status | |-------------|------------| diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index d8053da48..8eb4acc4d 100644 --- a/docs/installation/ldap.md +++ b/docs/installation/ldap.md @@ -7,13 +7,13 @@ This guide explains how to implement LDAP authentication using an external serve On Ubuntu: ```no-highlight -sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev +sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev ``` On CentOS: ```no-highlight -sudo yum install -y python-devel openldap-devel +sudo yum install -y openldap-devel ``` ## Install django-auth-ldap diff --git a/docs/installation/migrating-to-python3.md b/docs/installation/migrating-to-python3.md index e99018252..d66544e12 100644 --- a/docs/installation/migrating-to-python3.md +++ b/docs/installation/migrating-to-python3.md @@ -1,5 +1,8 @@ # Migration +!!! warning + Beginning with v2.5, NetBox will no longer support Python 2. It is strongly recommended that you upgrade to Python 3 as soon as possible. + Remove Python 2 packages ```no-highlight diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index e7daba3cd..5577b389b 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -2,43 +2,21 @@ This section of the documentation discusses installing and configuring the NetBox application. -!!! note - Python 3 is strongly encouraged for new installations. Support for Python 2 will be discontinued in the near future. This documentation includes a guide on [migrating from Python 2 to Python 3](migrating-to-python3). - **Ubuntu** -Python 3: - ```no-highlight # apt-get install -y python3 python3-dev python3-setuptools build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev # easy_install3 pip ``` -Python 2: - -```no-highlight -# apt-get install -y python2.7 python-dev python-setuptools build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev -# easy_install pip -``` - **CentOS** -Python 3: - ```no-highlight # yum install -y epel-release # yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config # easy_install-3.4 pip ``` -Python 2: - -```no-highlight -# yum install -y epel-release -# yum install -y gcc python2 python-devel python-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config -# easy_install pip -``` - You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub. ## Option A: Download a Release @@ -97,24 +75,16 @@ Checking connectivity... done. Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.) -Python 3: - ```no-highlight # pip3 install -r requirements.txt ``` -Python 2: - -```no-highlight -# pip install -r requirements.txt -``` - !!! note - If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip -V` or `pip3 -V`. + If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip3 -V`. ### NAPALM Automation -As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: +NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: ```no-highlight # pip3 install napalm @@ -172,9 +142,6 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a # Run Database Migrations -!!! warning - The examples on the rest of this page call the `python3` executable. Replace this with `python2` or `python` if you're using Python 2. - Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example): ```no-highlight diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index b2700596e..a8924e1df 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -65,7 +65,7 @@ Once the new code is in place, run the upgrade script (which may need to be run ``` !!! warning - The upgrade script will prefer Python3 and pip3 if both executables are available. To force it to use Python2 and pip, use the `-2` argument as below. + The upgrade script will prefer Python3 and pip3 if both executables are available. To force it to use Python2 and pip, use the `-2` argument as below. Note that Python 2 will no longer be supported in NetBox v2.5. ```no-highlight # ./upgrade.sh -2 diff --git a/docs/installation/web-server.md b/docs/installation/web-server.md index 39235200b..5886e3323 100644 --- a/docs/installation/web-server.md +++ b/docs/installation/web-server.md @@ -102,7 +102,7 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https # gunicorn Installation -Install gunicorn using `pip3` (Python 3) or `pip` (Python 2): +Install gunicorn: ```no-highlight # pip3 install gunicorn From d70ef4d3b38236979a15c42dee58d1a6ec946689 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 3 Jul 2018 09:47:44 -0400 Subject: [PATCH 078/159] Cleaned up tags table --- netbox/extras/tables.py | 2 +- netbox/extras/views.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 364da9ebb..8e35e5247 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -53,7 +53,7 @@ class TagTable(BaseTable): class Meta(BaseTable.Meta): model = Tag - fields = ('pk', 'name', 'weight') + fields = ('pk', 'name', 'items', 'slug', 'actions') class ConfigContextTable(BaseTable): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 632b21177..4defeb3c1 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -6,7 +6,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Q from django.http import Http404 -from django.shortcuts import get_object_or_404, redirect, render, reverse +from django.shortcuts import get_object_or_404, redirect, render from django.utils.safestring import mark_safe from django.views.generic import View from taggit.models import Tag @@ -34,9 +34,7 @@ class TagEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'taggit.change_tag' model = Tag model_form = TagForm - - def get_return_url(self, request, obj): - return reverse('extras:tag', kwargs={'slug': obj.slug}) + default_return_url = 'extras:tag_list' class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): From 6ddbd79fe69a3da413a2459fa52fe26f7b3a3f5e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 3 Jul 2018 09:49:36 -0400 Subject: [PATCH 079/159] Fixed object return_url resolution for bulk editing --- netbox/utilities/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index e9e4b33a1..85df79f1a 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -60,7 +60,7 @@ class GetReturnURLMixin(object): return query_param # Next, check if the object being modified (if any) has an absolute URL. - elif obj.pk and hasattr(obj, 'get_absolute_url'): + elif obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'): return obj.get_absolute_url() # Fall back to the default URL (if specified) for the view. From 25b36d6d42b021b97e67598632fb234ee54f057f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 3 Jul 2018 12:29:20 -0400 Subject: [PATCH 080/159] Remove separate config-context API endpoints; include rendered config context when retrieving a single device/VM --- netbox/dcim/api/serializers.py | 15 +++++++++++++++ netbox/dcim/api/views.py | 12 +++++++----- netbox/virtualization/api/serializers.py | 13 +++++++++++++ netbox/virtualization/api/views.py | 16 +++++++--------- 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 6ef867032..a0d33d004 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -440,6 +440,21 @@ class DeviceSerializer(CustomFieldModelSerializer): return data +class DeviceWithConfigContextSerializer(DeviceSerializer): + config_context = serializers.SerializerMethodField() + + class Meta(DeviceSerializer.Meta): + fields = [ + 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', + 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', + 'config_context', 'created', 'last_updated', + ] + + def get_config_context(self, obj): + return obj.get_config_context() + + # # Console server ports # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index fdc72020f..e03a0693f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -230,13 +230,15 @@ class DeviceViewSet(CustomFieldModelViewSet): ).prefetch_related( 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', ) - serializer_class = serializers.DeviceSerializer filter_class = filters.DeviceFilter - @action(detail=True, url_path='config-context') - def config_context(self, request, pk): - device = get_object_or_404(Device, pk=pk) - return Response(device.get_config_context()) + def get_serializer_class(self): + """ + Include rendered config context when retrieving a single Device. + """ + if self.action == 'retrieve': + return serializers.DeviceWithConfigContextSerializer + return serializers.DeviceSerializer @action(detail=True, url_path='napalm') def napalm(self, request, pk): diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 15ed39abf..3180fc31c 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -109,6 +109,19 @@ class VirtualMachineSerializer(CustomFieldModelSerializer): ] +class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): + config_context = serializers.SerializerMethodField() + + class Meta(VirtualMachineSerializer.Meta): + fields = [ + 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', + 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', + ] + + def get_config_context(self, obj): + return obj.get_config_context() + + class NestedVirtualMachineSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 60afb3f9a..01b8792c8 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,9 +1,5 @@ from __future__ import unicode_literals -from django.shortcuts import get_object_or_404 -from rest_framework.decorators import action -from rest_framework.response import Response - from dcim.models import Interface from extras.api.views import CustomFieldModelViewSet from utilities.api import FieldChoicesViewSet, ModelViewSet @@ -50,13 +46,15 @@ class ClusterViewSet(CustomFieldModelViewSet): class VirtualMachineViewSet(CustomFieldModelViewSet): queryset = VirtualMachine.objects.all() - serializer_class = serializers.VirtualMachineSerializer filter_class = filters.VirtualMachineFilter - @action(detail=True, url_path='config-context') - def config_context(self, request, pk): - device = get_object_or_404(VirtualMachine, pk=pk) - return Response(device.get_config_context()) + def get_serializer_class(self): + """ + Include rendered config context when retrieving a single VirtualMachine. + """ + if self.action == 'retrieve': + return serializers.VirtualMachineWithConfigContextSerializer + return serializers.VirtualMachineSerializer class InterfaceViewSet(ModelViewSet): From 89e196e86d3b337ff6addb9e0ba289cbd63950d5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 3 Jul 2018 13:40:47 -0400 Subject: [PATCH 081/159] Tweak ConfigContext manager to allow for objects with a regionless site --- netbox/extras/querysets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 5c450e407..c87c7c566 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -14,7 +14,7 @@ class ConfigContextQuerySet(QuerySet): role = getattr(obj, 'device_role', None) or obj.role return self.filter( - Q(regions=obj.site.region) | Q(regions=None), + Q(regions=getattr(obj.site, 'region', None)) | Q(regions=None), Q(sites=obj.site) | Q(sites=None), Q(roles=role) | Q(roles=None), Q(tenants=obj.tenant) | Q(tenants=None), From d1c9a18d04e6bedf320e7f2b394a7cda46c9135e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 3 Jul 2018 14:07:46 -0400 Subject: [PATCH 082/159] Replaced home page user activity with changelog --- netbox/netbox/views.py | 4 ++-- netbox/templates/home.html | 31 ++++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 0f240fff3..48b72050a 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -15,7 +15,7 @@ from circuits.tables import CircuitTable, ProviderTable from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable -from extras.models import ReportResult, TopologyMap, UserAction +from extras.models import ObjectChange, ReportResult, TopologyMap from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable @@ -178,7 +178,7 @@ class HomeView(View): 'stats': stats, 'topology_maps': TopologyMap.objects.filter(site__isnull=True), 'report_results': ReportResult.objects.order_by('-created')[:10], - 'recent_activity': UserAction.objects.select_related('user')[:50] + 'changelog': ObjectChange.objects.select_related('user')[:50] }) diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 46bfdbbd5..0645ec8c1 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -157,7 +157,7 @@ {% for result in report_results %} - + @@ -167,14 +167,31 @@ {% endif %}
    - Recent Activity + Changelog
    - {% for a in recent_activity %} -
    - {{ a.icon }} {{ a.message|safe }}
    - {{ a.user }} - {{ a.time|date:'SHORT_DATETIME_FORMAT' }} -
    + {% for change in changelog %} + {% with action=change.get_action_display|lower %} +
    + {% if action == 'created' %} + + {% elif action == 'updated' %} + + {% elif action == 'deleted' %} + + {% endif %} + {% if change.changed_object.get_absolute_url %} + {{ change.changed_object }} + {% else %} + {{ change.changed_object|default:change.object_repr }} + {% endif %} +
    + + {{ change.user|default:change.user_name }} - + {{ change.time|date:'SHORT_DATETIME_FORMAT' }} + +
    + {% endwith %} {% empty %}
    Welcome to NetBox! {% if perms.add_site %} Add a site to get started.{% endif %} From 3ad8850ada9f965cd3484ee20c9ab37ba82a3ada Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 3 Jul 2018 15:47:32 -0400 Subject: [PATCH 083/159] Fixed required fields on ConfigContextSerializer --- netbox/extras/api/serializers.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index d6a7d5079..896146069 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -132,16 +132,17 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): # class ConfigContextSerializer(ValidatedModelSerializer): - regions = NestedRegionSerializer(many=True) - sites = NestedSiteSerializer(many=True) - roles = NestedDeviceRoleSerializer(many=True) - platforms = NestedPlatformSerializer(many=True) - tenants = NestedTenantSerializer(many=True) + regions = NestedRegionSerializer(required=False, many=True) + sites = NestedSiteSerializer(required=False, many=True) + roles = NestedDeviceRoleSerializer(required=False, many=True) + platforms = NestedPlatformSerializer(required=False, many=True) + tenants = NestedTenantSerializer(required=False, many=True) class Meta: model = ConfigContext fields = [ - 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data', + 'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', + 'data', ] From 49ecf5aa8a5802e30b9a416e366ff74729f7552f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 3 Jul 2018 15:47:54 -0400 Subject: [PATCH 084/159] Wrote tests for config contexts --- netbox/extras/tests/test_api.py | 181 +++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 2 deletions(-) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 4b6b78891..2ff7826d3 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -7,9 +7,9 @@ from rest_framework import status from rest_framework.test import APITestCase from taggit.models import Tag -from dcim.models import Device +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from extras.constants import GRAPH_TYPE_SITE -from extras.models import Graph, ExportTemplate +from extras.models import ConfigContext, Graph, ExportTemplate from users.models import Token from utilities.testing import HttpStatusMixin @@ -320,3 +320,180 @@ class TagTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Tag.objects.count(), 2) + + +class ConfigContextTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.configcontext1 = ConfigContext.objects.create( + name='Test Config Context 1', + weight=100, + data={'foo': 123} + ) + self.configcontext2 = ConfigContext.objects.create( + name='Test Config Context 2', + weight=200, + data={'bar': 456} + ) + self.configcontext3 = ConfigContext.objects.create( + name='Test Config Context 3', + weight=300, + data={'baz': 789} + ) + + def test_get_configcontext(self): + + url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.configcontext1.name) + self.assertEqual(response.data['data'], self.configcontext1.data) + + def test_list_configcontexts(self): + + url = reverse('extras-api:configcontext-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_configcontext(self): + + data = { + 'name': 'Test Config Context 4', + 'weight': 1000, + 'data': {'foo': 'XXX'} + } + + url = reverse('extras-api:configcontext-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConfigContext.objects.count(), 4) + configcontext4 = ConfigContext.objects.get(pk=response.data['id']) + self.assertEqual(configcontext4.name, data['name']) + self.assertEqual(configcontext4.data, data['data']) + + def test_create_configcontext_bulk(self): + + data = [ + { + 'name': 'Test Config Context 4', + 'data': {'more_foo': True}, + }, + { + 'name': 'Test Config Context 5', + 'data': {'more_bar': False}, + }, + { + 'name': 'Test Config Context 6', + 'data': {'more_baz': None}, + }, + ] + + url = reverse('extras-api:configcontext-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConfigContext.objects.count(), 6) + for i in range(0, 3): + self.assertEqual(response.data[i]['name'], data[i]['name']) + self.assertEqual(response.data[i]['data'], data[i]['data']) + + def test_update_configcontext(self): + + data = { + 'name': 'Test Config Context X', + 'weight': 999, + 'data': {'foo': 'XXX'} + } + + url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ConfigContext.objects.count(), 3) + configcontext1 = ConfigContext.objects.get(pk=response.data['id']) + self.assertEqual(configcontext1.name, data['name']) + self.assertEqual(configcontext1.weight, data['weight']) + self.assertEqual(configcontext1.data, data['data']) + + def test_delete_configcontext(self): + + url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ConfigContext.objects.count(), 2) + + def test_render_configcontext_for_object(self): + + # Create a Device for which we'll render a config context + manufacturer = Manufacturer.objects.create( + name='Test Manufacturer', + slug='test-manufacturer' + ) + device_type = DeviceType.objects.create( + manufacturer=manufacturer, + model='Test Device Type' + ) + device_role = DeviceRole.objects.create( + name='Test Role', + slug='test-role' + ) + site = Site.objects.create( + name='Test Site', + slug='test-site' + ) + device = Device.objects.create( + name='Test Device', + device_type=device_type, + device_role=device_role, + site=site + ) + + # Test default config contexts (created at test setup) + rendered_context = device.get_config_context() + self.assertEqual(rendered_context['foo'], 123) + self.assertEqual(rendered_context['bar'], 456) + self.assertEqual(rendered_context['baz'], 789) + + # Add another context specific to the site + configcontext4 = ConfigContext( + name='Test Config Context 4', + data={'site_data': 'ABC'} + ) + configcontext4.save() + configcontext4.sites.add(site) + rendered_context = device.get_config_context() + self.assertEqual(rendered_context['site_data'], 'ABC') + + # Override one of the default contexts + configcontext5 = ConfigContext( + name='Test Config Context 5', + weight=2000, + data={'foo': 999} + ) + configcontext5.save() + configcontext5.sites.add(site) + rendered_context = device.get_config_context() + self.assertEqual(rendered_context['foo'], 999) + + # Add a context which does NOT match our device and ensure it does not apply + site2 = Site.objects.create( + name='Test Site 2', + slug='test-site-2' + ) + configcontext6 = ConfigContext( + name='Test Config Context 6', + weight=2000, + data={'bar': 999} + ) + configcontext6.save() + configcontext6.sites.add(site2) + rendered_context = device.get_config_context() + self.assertEqual(rendered_context['bar'], 456) From 9eb9715e05d550d4f0d3d43f6349198d55852e49 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 3 Jul 2018 17:05:04 -0400 Subject: [PATCH 085/159] Cleaned up IP addresses list under device/VM interfaces --- netbox/project-static/css/base.css | 4 + netbox/templates/dcim/device.html | 4 +- netbox/templates/dcim/inc/interface.html | 122 ++++++++++++++--------- 3 files changed, 80 insertions(+), 50 deletions(-) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index bd1570827..52656a505 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -366,6 +366,10 @@ table.component-list td.subtable td { padding-bottom: 6px; padding-top: 6px; } +table.interface-ips th { + font-size: 80%; + font-weight: normal; +} /* Reports */ table.reports td.method { diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 4984dad95..8a8bf5086 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -762,9 +762,9 @@ $(".interface-toggle").click(function() { $('button.toggle-ips').click(function() { var selected = $(this).attr('selected'); if (selected) { - $('#interfaces_table tr.ipaddress').hide(); + $('#interfaces_table tr.ipaddresses').hide(); } else { - $('#interfaces_table tr.ipaddress').show(); + $('#interfaces_table tr.ipaddresses').show(); } $(this).attr('selected', !selected); $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked'); diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 33e30b126..b254794df 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -131,56 +131,82 @@ {% endif %} -{% for ip in iface.ip_addresses.all %} -
    - {# Placeholder #} - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - {# IP address #} - - - {# Primary, status, role #} - + {# Placeholder #} + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + {% endif %} - {{ ip.get_status_display }} - {% if ip.role %} - {{ ip.get_role_display }} - {% endif %} - - {# VRF #} - + {# IP addresses table #} + + + {% endif %} +{% endwith %} From edf53d451668d2a03b4c462cb353ff38a40079a7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 5 Jul 2018 15:20:13 -0400 Subject: [PATCH 086/159] Flip the order of tag and URL name for the tag template tag --- netbox/templates/circuits/circuit.html | 2 +- netbox/templates/circuits/provider.html | 2 +- netbox/templates/dcim/device.html | 2 +- netbox/templates/dcim/devicetype.html | 2 +- netbox/templates/dcim/rack.html | 2 +- netbox/templates/dcim/site.html | 2 +- netbox/templates/ipam/aggregate.html | 2 +- netbox/templates/ipam/ipaddress.html | 2 +- netbox/templates/ipam/prefix.html | 2 +- netbox/templates/ipam/vlan.html | 2 +- netbox/templates/ipam/vrf.html | 2 +- netbox/templates/secrets/secret.html | 2 +- netbox/templates/tenancy/tenant.html | 2 +- netbox/templates/utilities/templatetags/tag.html | 6 +++++- netbox/templates/virtualization/cluster.html | 2 +- netbox/templates/virtualization/virtualmachine.html | 2 +- netbox/utilities/templatetags/helpers.py | 6 +++--- 17 files changed, 23 insertions(+), 19 deletions(-) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 048c16862..da7332a7c 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -127,7 +127,7 @@ - - - -
    {{ result.report }} {% include 'extras/inc/report_label.html' %}
    - {{ ip }} - - {% if device.primary_ip4 == ip or device.primary_ip6 == ip %} - Primary +{% with ipaddresses=iface.ip_addresses.all %} + {% if ipaddresses %} +
    - {% if ip.vrf %} - {{ ip.vrf.name }} - {% else %} - Global - {% endif %} - + + + + + + + + + + + {% for ip in iface.ip_addresses.all %} + - {# Description #} - + {# IP address #} + - {# Buttons #} - - -{% endfor %} + {# Primary/status/role #} + + + {# VRF #} + + + {# Description #} + + + {# Buttons #} + + + + {% endfor %} +
    IP AddressStatus/RoleVRFDescription
    - {{ ip.description }} - + {{ ip }} + - {% if perms.ipam.change_ipaddress %} - - - - {% endif %} - {% if perms.ipam.delete_ipaddress %} - - - - {% endif %} -
    + {% if device.primary_ip4 == ip or device.primary_ip6 == ip %} + Primary + {% endif %} + {{ ip.get_status_display }} + {% if ip.role %} + {{ ip.get_role_display }} + {% endif %} + + {% if ip.vrf %} + {{ ip.vrf.name }} + {% else %} + Global + {% endif %} + + {% if ip.description %} + {{ ip.description }} + {% else %} + + {% endif %} + + {% if perms.ipam.change_ipaddress %} + + + + {% endif %} + {% if perms.ipam.delete_ipaddress %} + + + + {% endif %} +
    +
    Tags {% for tag in circuit.tags.all %} - {% tag 'circuits:circuit_list' tag %} + {% tag tag 'circuits:circuit_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index d2fed8647..1a8258280 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -119,7 +119,7 @@ Tags {% for tag in provider.tags.all %} - {% tag 'circuits:provider_list' tag %} + {% tag tag 'circuits:provider_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 8a8bf5086..a42fc0a7b 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -173,7 +173,7 @@ Tags {% for tag in device.tags.all %} - {% tag 'dcim:device_list' tag %} + {% tag tag 'dcim:device_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 151e27018..821f00dea 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -89,7 +89,7 @@ Tags {% for tag in devicetype.tags.all %} - {% tag 'dcim:devicetype_list' tag %} + {% tag tag 'dcim:devicetype_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 5ff8a3259..024835d6d 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -129,7 +129,7 @@ Tags {% for tag in rack.tags.all %} - {% tag 'dcim:rack_list' tag %} + {% tag tag 'dcim:rack_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 442242214..9979c4d36 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -149,7 +149,7 @@ Tags {% for tag in site.tags.all %} - {% tag 'dcim:site_list' tag %} + {% tag tag 'dcim:site_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 2a87ae769..1e72e0cf6 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -97,7 +97,7 @@ Tags {% for tag in aggregate.tags.all %} - {% tag 'ipam:aggregate_list' tag %} + {% tag tag 'ipam:aggregate_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index dea0d45ac..d19783152 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -149,7 +149,7 @@ Tags {% for tag in ipaddress.tags.all %} - {% tag 'ipam:ipaddress_list' tag %} + {% tag tag 'ipam:ipaddress_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 0dc5fc338..3813b7b56 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -189,7 +189,7 @@ Tags {% for tag in prefix.tags.all %} - {% tag 'ipam:prefix_list' tag %} + {% tag tag 'ipam:prefix_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 26e51c9e7..8ce695cb2 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -141,7 +141,7 @@ Tags {% for tag in vlan.tags.all %} - {% tag 'ipam:vlan_list' tag %} + {% tag tag 'ipam:vlan_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index b8365463b..4b8f04c43 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -94,7 +94,7 @@ Tags {% for tag in vrf.tags.all %} - {% tag 'ipam:vrf_list' tag %} + {% tag tag 'ipam:vrf_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 6e1175f41..8c23dea04 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -71,7 +71,7 @@ Tags {% for tag in secret.tags.all %} - {% tag 'secrets:secret_list' tag %} + {% tag tag 'secrets:secret_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index ee1691b4d..3559012b3 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -83,7 +83,7 @@ Tags {% for tag in tenant.tags.all %} - {% tag 'tenancy:tenant_list' tag %} + {% tag tag 'tenancy:tenant_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/utilities/templatetags/tag.html b/netbox/templates/utilities/templatetags/tag.html index 79e1627db..09b885d42 100644 --- a/netbox/templates/utilities/templatetags/tag.html +++ b/netbox/templates/utilities/templatetags/tag.html @@ -1 +1,5 @@ -{{ tag }} +{% if url_name %} + {{ tag }} +{% else %} + {{ tag }} +{% endif %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 71699bafe..2fc472877 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -91,7 +91,7 @@ Tags {% for tag in cluster.tags.all %} - {% tag 'virtualization:cluster_list' tag %} + {% tag tag 'virtualization:cluster_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 9b5dbc471..4038c3d7a 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -139,7 +139,7 @@ Tags {% for tag in virtualmachine.tags.all %} - {% tag 'virtualization:virtualmachine_list' tag %} + {% tag tag 'virtualization:virtualmachine_list' %} {% empty %} N/A {% endfor %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 9edf4ad36..39959a668 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -171,11 +171,11 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90): @register.inclusion_tag('utilities/templatetags/tag.html') -def tag(url_name, tag): +def tag(tag, url_name=None): """ - Display a link to the given object list filtered by a specific Tag slug. + Display a tag, optionally linked to a filtered list of objects. """ return { - 'url_name': url_name, 'tag': tag, + 'url_name': url_name, } From 96d81d707411119224fae644dc73ff5ed898f40f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 5 Jul 2018 15:36:25 -0400 Subject: [PATCH 087/159] Include return_url when editing interfaces in bulk --- netbox/templates/dcim/device.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index a42fc0a7b..cf4b76586 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -554,7 +554,7 @@ - {% endif %} From e27765d965ecd94cdbd8e0eb7531e0aa7203044d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 5 Jul 2018 16:51:38 -0400 Subject: [PATCH 088/159] Added autocompletion for tags form widget --- netbox/project-static/js/forms.js | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index f0208df7b..91b83bf2a 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -127,4 +127,54 @@ $(document).ready(function() { }); }); + + // Auto-complete tags + function split_tags(val) { + return val.split(/,\s*/); + } + $("#id_tags") + .on("keydown", function(event) { + if (event.keyCode === $.ui.keyCode.TAB && + $(this).autocomplete("instance").menu.active) { + event.preventDefault(); + } + }) + .autocomplete({ + source: function(request, response) { + $.ajax({ + type: 'GET', + url: netbox_api_path + 'extras/tags/', + data: 'q=' + split_tags(request.term).pop(), + success: function(data) { + var choices = []; + $.each(data.results, function (index, choice) { + choices.push(choice.name); + }); + response(choices); + } + }); + }, + search: function() { + // Need 3 or more characters to begin searching + var term = split_tags(this.value).pop(); + if (term.length < 3) { + return false; + } + }, + focus: function() { + // prevent value inserted on focus + return false; + }, + select: function(event, ui) { + var terms = split_tags(this.value); + // remove the current input + terms.pop(); + // add the selected item + terms.push(ui.item.value); + // add placeholder to get the comma-and-space at the end + terms.push(""); + this.value = terms.join(", "); + return false; + } + }); }); From 208409110f94d47ca9f62309fdae1a9cf82aadc0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 10:00:21 -0400 Subject: [PATCH 089/159] Added bulk tag addition/removal --- netbox/circuits/forms.py | 6 +++--- netbox/dcim/forms.py | 10 +++++----- netbox/extras/forms.py | 11 +++++++++++ netbox/ipam/forms.py | 12 ++++++------ netbox/secrets/forms.py | 3 ++- netbox/tenancy/forms.py | 4 ++-- netbox/utilities/views.py | 6 ++++++ netbox/virtualization/forms.py | 6 +++--- 8 files changed, 38 insertions(+), 20 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 7207e7648..aae8bb5f6 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -5,7 +5,7 @@ from django.db.models import Count from taggit.forms import TagField from dcim.models import Site, Device, Interface, Rack -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -55,7 +55,7 @@ class ProviderCSVForm(forms.ModelForm): } -class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput) asn = forms.IntegerField(required=False, label='ASN') account = forms.CharField(max_length=30, required=False, label='Account number') @@ -158,7 +158,7 @@ class CircuitCSVForm(forms.ModelForm): ] -class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 057fc04c0..d617c6c04 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -10,7 +10,7 @@ from mptt.forms import TreeNodeChoiceField from taggit.forms import TagField from timezone_field import TimeZoneFormField -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm from tenancy.models import Tenant @@ -170,7 +170,7 @@ class SiteCSVForm(forms.ModelForm): } -class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Site.objects.all(), widget=forms.MultipleHiddenInput @@ -403,7 +403,7 @@ class RackCSVForm(forms.ModelForm): ) -class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group') @@ -572,7 +572,7 @@ class DeviceTypeCSVForm(forms.ModelForm): } -class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) u_height = forms.IntegerField(min_value=1, required=False) @@ -1090,7 +1090,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): raise forms.ValidationError("Parent device/bay ({} {}) not found".format(parent, device_bay_name)) -class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role') diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index d50be029a..6e8e90512 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from mptt.forms import TreeNodeMultipleChoiceField +from taggit.forms import TagField from taggit.models import Tag from dcim.models import Region @@ -193,6 +194,16 @@ class TagForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class AddRemoveTagsForm(forms.Form): + + def __init__(self, *args, **kwargs): + super(AddRemoveTagsForm, self).__init__(*args, **kwargs) + + # Add add/remove tags fields + self.fields['add_tags'] = TagField(required=False) + self.fields['remove_tags'] = TagField(required=False) + + # # Config contexts # diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 2b5f8ed7a..8a2f815ad 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -7,7 +7,7 @@ from django.db.models import Count from taggit.forms import TagField from dcim.models import Site, Rack, Device, Interface -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -68,7 +68,7 @@ class VRFCSVForm(forms.ModelForm): } -class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) enforce_unique = forms.NullBooleanField( @@ -153,7 +153,7 @@ class AggregateCSVForm(forms.ModelForm): fields = Aggregate.csv_headers -class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR') date_added = forms.DateField(required=False) @@ -346,7 +346,7 @@ class PrefixCSVForm(forms.ModelForm): raise forms.ValidationError("Multiple VLANs with VID {} found".format(vlan_vid)) -class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') @@ -678,7 +678,7 @@ class IPAddressCSVForm(forms.ModelForm): return ipaddress -class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) @@ -869,7 +869,7 @@ class VLANCSVForm(forms.ModelForm): raise forms.ValidationError("Global VLAN group {} not found".format(group_name)) -class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False) diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 2587f2057..6961baf88 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -7,6 +7,7 @@ from django.db.models import Count from taggit.forms import TagField from dcim.models import Device +from extras.forms import AddRemoveTagsForm from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField from .models import Secret, SecretRole, UserKey @@ -128,7 +129,7 @@ class SecretCSVForm(forms.ModelForm): return s -class SecretBulkEditForm(BootstrapMixin, BulkEditForm): +class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False) name = forms.CharField(max_length=100, required=False) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 123b2bc24..b90934923 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -4,7 +4,7 @@ from django import forms from django.db.models import Count from taggit.forms import TagField -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from utilities.forms import ( APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, SlugField, ) @@ -69,7 +69,7 @@ class TenantCSVForm(forms.ModelForm): } -class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput) group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 85df79f1a..1cafbfc52 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -543,6 +543,12 @@ class BulkEditView(GetReturnURLMixin, View): cfv.value = form.cleaned_data[name] cfv.save() + # Add/remove tags + if form.cleaned_data.get('add_tags', None): + obj.tags.add(*form.cleaned_data['add_tags']) + if form.cleaned_data.get('remove_tags', None): + obj.tags.remove(*form.cleaned_data['remove_tags']) + updated_count += 1 if updated_count: diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index b973ed5cb..10833234b 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -10,7 +10,7 @@ from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGE from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.formfields import MACAddressFormField from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site -from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from ipam.models import IPAddress from tenancy.forms import TenancyForm from tenancy.models import Tenant @@ -119,7 +119,7 @@ class ClusterCSVForm(forms.ModelForm): fields = Cluster.csv_headers -class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput) type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False) group = forms.ModelChoiceField(queryset=ClusterGroup.objects.all(), required=False) @@ -349,7 +349,7 @@ class VirtualMachineCSVForm(forms.ModelForm): fields = VirtualMachine.csv_headers -class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) status = forms.ChoiceField(choices=add_blank_choice(VM_STATUS_CHOICES), required=False, initial='') cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False) From 289a762bf18b87f7cb5b1de6a8d1e5179a134dc6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 10:10:22 -0400 Subject: [PATCH 090/159] Record tags when serializing an object for an ObjectChange --- netbox/utilities/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 318b04dad..45fbf1c7c 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -100,6 +100,10 @@ def serialize_object(obj, extra=None): field.name: value for field, value in obj.get_custom_fields().items() } + # Include any tags + if hasattr(obj, 'tags'): + data['tags'] = [tag.name for tag in obj.tags.all()] + # Append any extra data if extra is not None: data.update(extra) From 29172d045d18294aaed59950b6ec153ad88312e4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 10:11:09 -0400 Subject: [PATCH 091/159] Added missing import statement --- netbox/utilities/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 876d46173..4dc7eb019 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from collections import OrderedDict import pytz +from taggit.models import Tag from django.conf import settings from django.contrib.contenttypes.models import ContentType From f2512c4fdce2877fa5d443ae70234ab26872fee2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 10:15:56 -0400 Subject: [PATCH 092/159] Include link to parent object changelog when viewing a change --- netbox/templates/extras/objectchange.html | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index c86a5528d..21922b60a 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -8,6 +8,7 @@ From 4802e516e52a882b9da1c0ee7e8aba4f5da2f1f2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 10:48:33 -0400 Subject: [PATCH 093/159] Arranged object tags into a separate panel --- netbox/templates/circuits/circuit.html | 11 +----- netbox/templates/circuits/circuit_edit.html | 12 +++---- netbox/templates/circuits/provider.html | 11 +----- netbox/templates/circuits/provider_edit.html | 12 +++---- netbox/templates/dcim/device.html | 11 +----- netbox/templates/dcim/device_edit.html | 12 +++---- netbox/templates/dcim/devicetype.html | 11 +----- netbox/templates/dcim/devicetype_edit.html | 12 +++---- netbox/templates/dcim/rack.html | 35 +++++++------------ netbox/templates/dcim/rack_edit.html | 12 +++---- netbox/templates/dcim/site.html | 11 +----- netbox/templates/dcim/site_edit.html | 12 +++---- netbox/templates/extras/inc/tags_panel.html | 13 +++++++ netbox/templates/ipam/aggregate.html | 11 +----- netbox/templates/ipam/ipaddress.html | 11 +----- netbox/templates/ipam/prefix.html | 12 +------ netbox/templates/ipam/prefix_edit.html | 12 +++---- netbox/templates/ipam/vlan.html | 11 +----- netbox/templates/ipam/vlan_edit.html | 12 +++---- netbox/templates/ipam/vrf.html | 11 +----- netbox/templates/ipam/vrf_edit.html | 12 +++---- netbox/templates/secrets/secret.html | 11 +----- netbox/templates/tenancy/tenant.html | 11 +----- netbox/templates/tenancy/tenant_edit.html | 12 +++---- netbox/templates/virtualization/cluster.html | 11 +----- .../virtualization/cluster_edit.html | 12 +++---- .../virtualization/virtualmachine.html | 11 +----- .../virtualization/virtualmachine_edit.html | 12 +++---- 28 files changed, 112 insertions(+), 235 deletions(-) create mode 100644 netbox/templates/extras/inc/tags_panel.html diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index da7332a7c..450e5a46c 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -123,21 +123,12 @@ {% endif %}
    Tags - {% for tag in circuit.tags.all %} - {% tag tag 'circuits:circuit_list' %} - {% empty %} - N/A - {% endfor %} -
    {% with circuit.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=circuit.tags.all url='circuits:circuit_list' %}
    Comments diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 06ad65241..325d68dea 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/netbox/templates/circuits/circuit_edit.html @@ -38,18 +38,18 @@
    {% endif %} -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    Tags
    {% render_field form.tags %}
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    {% endblock %} {% block javascript %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 1a8258280..d91442728 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -115,16 +115,6 @@ {% endif %}
    Tags - {% for tag in provider.tags.all %} - {% tag tag 'circuits:provider_list' %} - {% empty %} - N/A - {% endfor %} -
    Circuits @@ -136,6 +126,7 @@ {% with provider.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=provider.tags.all url='circuits:provider_list' %}
    Comments diff --git a/netbox/templates/circuits/provider_edit.html b/netbox/templates/circuits/provider_edit.html index dfa239e40..63b7f11b9 100644 --- a/netbox/templates/circuits/provider_edit.html +++ b/netbox/templates/circuits/provider_edit.html @@ -27,16 +27,16 @@
    {% endif %} -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    Tags
    {% render_field form.tags %}
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    {% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index cf4b76586..ac390256e 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -169,16 +169,6 @@ {% endif %}
    Tags - {% for tag in device.tags.all %} - {% tag tag 'dcim:device_list' %} - {% empty %} - N/A - {% endfor %} -
    {% if vc_members %} @@ -295,6 +285,7 @@ {% with device.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=device.tags.all url='dcim:device_list' %}
    Comments diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index d39c01482..23e023c5c 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -77,16 +77,16 @@
    {% endif %} -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    Tags
    {% render_field form.tags %}
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    {% endblock %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 821f00dea..31004f314 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -85,16 +85,6 @@ Interface Ordering {{ devicetype.get_interface_ordering_display }} - - Tags - - {% for tag in devicetype.tags.all %} - {% tag tag 'dcim:devicetype_list' %} - {% empty %} - N/A - {% endfor %} - - Instances {{ devicetype.instances.count }} @@ -171,6 +161,7 @@ {% with devicetype.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=devicetype.tags.all url='dcim:devicetype_list' %}
    Comments diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html index e69077ad9..d0ed2c204 100644 --- a/netbox/templates/dcim/devicetype_edit.html +++ b/netbox/templates/dcim/devicetype_edit.html @@ -31,16 +31,16 @@
    {% endif %} -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    Tags
    {% render_field form.tags %}
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    {% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 024835d6d..48b5e078e 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -125,16 +125,6 @@ {% endif %} - - Tags - - {% for tag in rack.tags.all %} - {% tag tag 'dcim:rack_list' %} - {% empty %} - N/A - {% endfor %} - - Devices @@ -171,6 +161,19 @@ {% with rack.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=rack.tags.all url='dcim:rack_list' %} +
    +
    + Comments +
    +
    + {% if rack.comments %} + {{ rack.comments|gfm }} + {% else %} + None + {% endif %} +
    +
    Non-Racked Devices @@ -212,18 +215,6 @@
    {% endif %}
    -
    -
    - Comments -
    -
    - {% if rack.comments %} - {{ rack.comments|gfm }} - {% else %} - None - {% endif %} -
    -
    Images diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index b9526a3ac..d500a1954 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -37,16 +37,16 @@
    {% endif %} -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    Tags
    {% render_field form.tags %}
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    {% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 9979c4d36..d82093f56 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -145,16 +145,6 @@ {% endif %} - - Tags - - {% for tag in site.tags.all %} - {% tag tag 'dcim:site_list' %} - {% empty %} - N/A - {% endfor %} - -
    @@ -232,6 +222,7 @@ {% with site.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=site.tags.all url='dcim:site_list' %}
    Comments diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index a8d14aca7..5819b964f 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -42,16 +42,16 @@
    {% endif %} -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    Tags
    {% render_field form.tags %}
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    {% endblock %} diff --git a/netbox/templates/extras/inc/tags_panel.html b/netbox/templates/extras/inc/tags_panel.html new file mode 100644 index 000000000..257a1fc22 --- /dev/null +++ b/netbox/templates/extras/inc/tags_panel.html @@ -0,0 +1,13 @@ +{% load helpers %} +
    +
    + Tags +
    +
    + {% for tag in tags %} + {% tag tag url %} + {% empty %} + No tags assigned + {% endfor %} +
    +
    diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 1e72e0cf6..1c6f29694 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -93,16 +93,6 @@ {% endif %} - - Tags - - {% for tag in aggregate.tags.all %} - {% tag tag 'ipam:aggregate_list' %} - {% empty %} - N/A - {% endfor %} - -
    @@ -110,6 +100,7 @@ {% with aggregate.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=aggregate.tags.all url='ipam:aggregate_list' %}
    diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index d19783152..e4f41042a 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -145,21 +145,12 @@ {% endif %} - - Tags - - {% for tag in ipaddress.tags.all %} - {% tag tag 'ipam:ipaddress_list' %} - {% empty %} - N/A - {% endfor %} - -
    {% with ipaddress.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=ipaddress.tags.all url='ipam:ipaddress_list' %}
    {% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 3813b7b56..43f59dd80 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -185,16 +185,6 @@ {% endif %} - - Tags - - {% for tag in prefix.tags.all %} - {% tag tag 'ipam:prefix_list' %} - {% empty %} - N/A - {% endfor %} - - Utilization {% utilization_graph prefix.get_utilization %} @@ -204,7 +194,7 @@ {% with prefix.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} -
    + {% include 'extras/inc/tags_panel.html' with tags=prefix.tags.all url='ipam:prefix_list' %}
    {% if duplicate_prefix_table.rows %} diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html index 333cf1229..401a53e38 100644 --- a/netbox/templates/ipam/prefix_edit.html +++ b/netbox/templates/ipam/prefix_edit.html @@ -28,12 +28,6 @@ {% render_field form.tenant %}
    -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    {% if form.custom_fields %}
    Custom Fields
    @@ -42,4 +36,10 @@
    {% endif %} +
    +
    Tags
    +
    + {% render_field form.tags %} +
    +
    {% endblock %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 8ce695cb2..44c4e318a 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -137,21 +137,12 @@ {% endif %} - - Tags - - {% for tag in vlan.tags.all %} - {% tag tag 'ipam:vlan_list' %} - {% empty %} - N/A - {% endfor %} - -
    {% with vlan.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=vlan.tags.all url='ipam:vlan_list' %}
    diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 7862d4de9..1c191343d 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -21,12 +21,6 @@ {% render_field form.tenant %}
    -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    {% if form.custom_fields %}
    Custom Fields
    @@ -35,4 +29,10 @@
    {% endif %} +
    +
    Tags
    +
    + {% render_field form.tags %} +
    +
    {% endblock %} diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 4b8f04c43..51414a8f5 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -89,22 +89,13 @@ N/A {% endif %} - - - Tags - - {% for tag in vrf.tags.all %} - {% tag tag 'ipam:vrf_list' %} - {% empty %} - N/A - {% endfor %} -
    {% with vrf.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %}
    diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html index 95a89a6ca..a2ff51d9b 100644 --- a/netbox/templates/ipam/vrf_edit.html +++ b/netbox/templates/ipam/vrf_edit.html @@ -18,12 +18,6 @@ {% render_field form.tenant %}
    -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    {% if form.custom_fields %}
    Custom Fields
    @@ -32,4 +26,10 @@
    {% endif %} +
    +
    Tags
    +
    + {% render_field form.tags %} +
    +
    {% endblock %} diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 8c23dea04..05488bba6 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -67,18 +67,9 @@ {% endif %} - - Tags - - {% for tag in secret.tags.all %} - {% tag tag 'secrets:secret_list' %} - {% empty %} - N/A - {% endfor %} - -
    + {% include 'extras/inc/tags_panel.html' with tags=secret.tags.all url='secrets:secret_list' %}
    {% if secret|decryptable_by:request.user %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 3559012b3..c97044742 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -79,21 +79,12 @@ {% endif %} - - Tags - - {% for tag in tenant.tags.all %} - {% tag tag 'tenancy:tenant_list' %} - {% empty %} - N/A - {% endfor %} - -
    {% with tenant.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} + {% include 'extras/inc/tags_panel.html' with tags=tenant.tags.all url='tenancy:tenant_list' %}
    Comments diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html index 9cc0aa53b..31bc73f3e 100644 --- a/netbox/templates/tenancy/tenant_edit.html +++ b/netbox/templates/tenancy/tenant_edit.html @@ -20,16 +20,16 @@
    {% endif %} -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    Tags
    {% render_field form.tags %}
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    {% endblock %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 2fc472877..e09dda127 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -87,16 +87,6 @@ {% endif %} - - Tags - - {% for tag in cluster.tags.all %} - {% tag tag 'virtualization:cluster_list' %} - {% empty %} - N/A - {% endfor %} - - Virtual Machines {{ cluster.virtual_machines.count }} @@ -104,6 +94,7 @@
    {% include 'inc/custom_fields_panel.html' with custom_fields=cluster.get_custom_fields %} + {% include 'extras/inc/tags_panel.html' with tags=cluster.tags.all url='virtualization:cluster_list' %}
    Comments diff --git a/netbox/templates/virtualization/cluster_edit.html b/netbox/templates/virtualization/cluster_edit.html index 93fe197ec..629c779ec 100644 --- a/netbox/templates/virtualization/cluster_edit.html +++ b/netbox/templates/virtualization/cluster_edit.html @@ -19,16 +19,16 @@
    {% endif %} -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    Tags
    {% render_field form.tags %}
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    {% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 4038c3d7a..6323f31cc 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -135,19 +135,10 @@ {% endif %} - - Tags - - {% for tag in virtualmachine.tags.all %} - {% tag tag 'virtualization:virtualmachine_list' %} - {% empty %} - N/A - {% endfor %} - -
    {% include 'inc/custom_fields_panel.html' with custom_fields=virtualmachine.get_custom_fields %} + {% include 'extras/inc/tags_panel.html' with tags=virtualmachine.tags.all url='virtualization:virtualmachine_list' %}
    Comments diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html index 0fa7e07fb..ad49f752d 100644 --- a/netbox/templates/virtualization/virtualmachine_edit.html +++ b/netbox/templates/virtualization/virtualmachine_edit.html @@ -48,16 +48,16 @@
    {% endif %} -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    Tags
    {% render_field form.tags %}
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    {% endblock %} From 663bbd025e10aeec28c056f2023ee9b30e68339e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 11:02:37 -0400 Subject: [PATCH 094/159] Updated custom fields inclusion tag to use 'with' --- netbox/templates/circuits/circuit.html | 4 +--- netbox/templates/circuits/provider.html | 4 +--- netbox/templates/dcim/device.html | 4 +--- netbox/templates/dcim/devicetype.html | 4 +--- netbox/templates/dcim/rack.html | 4 +--- netbox/templates/dcim/site.html | 4 +--- netbox/templates/ipam/aggregate.html | 4 +--- netbox/templates/ipam/ipaddress.html | 4 +--- netbox/templates/ipam/prefix.html | 4 +--- netbox/templates/ipam/service.html | 4 +--- netbox/templates/ipam/vlan.html | 4 +--- netbox/templates/ipam/vrf.html | 4 +--- netbox/templates/tenancy/tenant.html | 4 +--- 13 files changed, 13 insertions(+), 39 deletions(-) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 450e5a46c..d3fdfb306 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -125,9 +125,7 @@
    - {% with circuit.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=circuit.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=circuit.tags.all url='circuits:circuit_list' %}
    diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index d91442728..56b5322d8 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -123,9 +123,7 @@
    - {% with provider.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=provider.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=provider.tags.all url='circuits:provider_list' %}
    diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index ac390256e..623efa9c4 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -282,9 +282,7 @@ {% endif %}
    - {% with device.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=device.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=device.tags.all url='dcim:device_list' %}
    diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 31004f314..26d9077f6 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -158,9 +158,7 @@
    - {% with devicetype.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=devicetype.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=devicetype.tags.all url='dcim:devicetype_list' %}
    diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 48b5e078e..bfb512d03 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -158,9 +158,7 @@
    - {% with rack.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=rack.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=rack.tags.all url='dcim:rack_list' %}
    diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index d82093f56..7a339401d 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -219,9 +219,7 @@
    - {% with site.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=site.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=site.tags.all url='dcim:site_list' %}
    diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 1c6f29694..ace3d87c5 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -97,9 +97,7 @@
    - {% with aggregate.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=aggregate.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=aggregate.tags.all url='ipam:aggregate_list' %}
    diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index e4f41042a..27f1e96ef 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -147,9 +147,7 @@
    - {% with ipaddress.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=ipaddress.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=ipaddress.tags.all url='ipam:ipaddress_list' %}
    diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 43f59dd80..3a35336a0 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -191,9 +191,7 @@
    - {% with prefix.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=prefix.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=prefix.tags.all url='ipam:prefix_list' %}
    diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index c53c6124a..0407cee87 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -80,9 +80,7 @@
    - {% with service.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=service.get_custom_fields %}
    {% endblock %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 44c4e318a..9029ee5d9 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -139,9 +139,7 @@
    - {% with vlan.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=vlan.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=vlan.tags.all url='ipam:vlan_list' %}
    diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 51414a8f5..d577c4ed0 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -92,9 +92,7 @@
    - {% with vrf.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=vrf.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %}
    diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index c97044742..bf92a16c0 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -81,9 +81,7 @@
    - {% with tenant.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with custom_fields=tenant.get_custom_fields %} {% include 'extras/inc/tags_panel.html' with tags=tenant.tags.all url='tenancy:tenant_list' %}
    From df1f33992a62142035574c4b52f0a72628fb4009 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 13:33:54 -0400 Subject: [PATCH 095/159] Adapted change logging to queue changes in thread-local storage and record them at the end of the request --- netbox/extras/middleware.py | 56 ++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index a7dd3b44e..7c44c2804 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -2,20 +2,25 @@ from __future__ import unicode_literals from datetime import timedelta import random +import threading import uuid from django.conf import settings from django.db.models.signals import post_delete, post_save from django.utils import timezone -from django.utils.functional import curry, SimpleLazyObject from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from .models import ObjectChange -def record_object_change(user, request_id, instance, **kwargs): +_thread_locals = threading.local() + + +def mark_object_changed(instance, **kwargs): """ - Create an ObjectChange in response to an object being created or deleted. + Mark an object as having been created, saved, or updated. At the end of the request, this change will be recorded. + We have to wait until the *end* of the request to the serialize the object, because related fields like tags and + custom fields have not yet been updated when the post_save signal is emitted. """ if not hasattr(instance, 'log_change'): return @@ -27,14 +32,7 @@ def record_object_change(user, request_id, instance, **kwargs): else: action = OBJECTCHANGE_ACTION_DELETE - instance.log_change(user, request_id, action) - - # 1% chance of clearing out expired ObjectChanges - if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: - cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) - purged_count, _ = ObjectChange.objects.filter( - time__lt=cutoff - ).delete() + _thread_locals.changed_objects.append((instance, action)) class ChangeLoggingMiddleware(object): @@ -44,22 +42,30 @@ class ChangeLoggingMiddleware(object): def __call__(self, request): - def get_user(request): - return request.user + # Initialize the list of changed objects + _thread_locals.changed_objects = [] - # DRF employs a separate authentication mechanism outside Django's normal request/response cycle, so calling - # request.user in middleware will always return AnonymousUser for API requests. To work around this, we point - # to a lazy object that doesn't resolve the user until after DRF's authentication has been called. For more - # detail, see https://stackoverflow.com/questions/26240832/ - user = SimpleLazyObject(lambda: get_user(request)) + # Assign a random unique ID to the request. This will be used to associate multiple object changes made during + # the same request. + request.id = uuid.uuid4() - request_id = uuid.uuid4() + # Connect mark_object_changed to the post_save and post_delete receivers + post_save.connect(mark_object_changed, dispatch_uid='record_object_saved') + post_delete.connect(mark_object_changed, dispatch_uid='record_object_deleted') - # Django doesn't provide any request context with the post_save/post_delete signals, so we curry - # record_object_change() to include the user associated with the current request. - _record_object_change = curry(record_object_change, user, request_id) + # Process the request + response = self.get_response(request) - post_save.connect(_record_object_change, dispatch_uid='record_object_saved') - post_delete.connect(_record_object_change, dispatch_uid='record_object_deleted') + # Record object changes + for obj, action in _thread_locals.changed_objects: + if obj.pk: + obj.log_change(request.user, request.id, action) - return self.get_response(request) + # Housekeeping: 1% chance of clearing out expired ObjectChanges + if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: + cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) + purged_count, _ = ObjectChange.objects.filter( + time__lt=cutoff + ).delete() + + return response From bd2219276ff367972b0a43a6c9fc9fb82651c26f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 14:16:16 -0400 Subject: [PATCH 096/159] Force custom field values to strings for object change serialization --- netbox/utilities/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 45fbf1c7c..14c29d211 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -97,7 +97,7 @@ def serialize_object(obj, extra=None): # Include any custom fields if hasattr(obj, 'get_custom_fields'): data['custom_fields'] = { - field.name: value for field, value in obj.get_custom_fields().items() + field.name: str(value) for field, value in obj.get_custom_fields().items() } # Include any tags From 864d49f54d8831bcd823bd73f313e05911a8b49a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 14:21:40 -0400 Subject: [PATCH 097/159] Fixed regex casting to satisfy pycodestyle --- netbox/utilities/managers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/managers.py b/netbox/utilities/managers.py index b1fb09f9d..b112f4fae 100644 --- a/netbox/utilities/managers.py +++ b/netbox/utilities/managers.py @@ -20,9 +20,9 @@ class NaturalOrderByManager(Manager): # Append the three subfields derived from the designated natural ordering field queryset = queryset.extra(select={ - '_nat1': "CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, db_field), - '_nat2': "SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, db_field), - '_nat3': "CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, db_field), + '_nat1': r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, db_field), + '_nat2': r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, db_field), + '_nat3': r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, db_field), }) # Replace any instance of the designated natural ordering field with its three subfields From 0c4495eb395ab6ce3af61649190280911b9a31ea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 14:25:04 -0400 Subject: [PATCH 098/159] Order tags by name only, not item count --- netbox/utilities/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 1cafbfc52..e480410e4 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -130,7 +130,7 @@ class ObjectListView(View): # Construct queryset for tags list if hasattr(model, 'tags'): - tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('-count', 'name') + tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('name') else: tags = None From 43ed38a6e9d83e5841db5211066d601007045c63 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 15:36:28 -0400 Subject: [PATCH 099/159] Enabled tags for device components and virtual chassis --- netbox/dcim/api/serializers.py | 23 ++++++++++++----- netbox/dcim/forms.py | 24 +++++++++++------ netbox/dcim/migrations/0057_tags.py | 40 +++++++++++++++++++++++++++++ netbox/dcim/models.py | 13 ++++++++++ 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index a0d33d004..9c134bc94 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -461,10 +461,11 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): class ConsoleServerPortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = ConsoleServerPort - fields = ['id', 'device', 'name', 'connected_console'] + fields = ['id', 'device', 'name', 'connected_console', 'tags'] read_only_fields = ['connected_console'] @@ -484,10 +485,11 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer): class ConsolePortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() cs_port = NestedConsoleServerPortSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = ConsolePort - fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] + fields = ['id', 'device', 'name', 'cs_port', 'connection_status', 'tags'] # @@ -496,10 +498,11 @@ class ConsolePortSerializer(ValidatedModelSerializer): class PowerOutletSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = PowerOutlet - fields = ['id', 'device', 'name', 'connected_port'] + fields = ['id', 'device', 'name', 'connected_port', 'tags'] read_only_fields = ['connected_port'] @@ -519,10 +522,11 @@ class NestedPowerOutletSerializer(WritableNestedSerializer): class PowerPortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() power_outlet = NestedPowerOutletSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = PowerPort - fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] + fields = ['id', 'device', 'name', 'power_outlet', 'connection_status', 'tags'] # @@ -580,12 +584,14 @@ class InterfaceSerializer(ValidatedModelSerializer): required=False, many=True ) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Interface fields = [ 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans', + 'tags', ] def validate(self, data): @@ -637,10 +643,11 @@ class InterfaceSerializer(ValidatedModelSerializer): class DeviceBaySerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = DeviceBay - fields = ['id', 'device', 'name', 'installed_device'] + fields = ['id', 'device', 'name', 'installed_device', 'tags'] class NestedDeviceBaySerializer(WritableNestedSerializer): @@ -660,12 +667,13 @@ class InventoryItemSerializer(ValidatedModelSerializer): # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) manufacturer = NestedManufacturerSerializer() + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = InventoryItem fields = [ 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', - 'description', + 'description', 'tags', ] @@ -712,10 +720,11 @@ class ContextualInterfaceConnectionSerializer(serializers.ModelSerializer): class VirtualChassisSerializer(ValidatedModelSerializer): master = NestedDeviceSerializer() + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = VirtualChassis - fields = ['id', 'master', 'domain'] + fields = ['id', 'master', 'domain', 'tags'] class NestedVirtualChassisSerializer(WritableNestedSerializer): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d617c6c04..a159e19e4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1180,10 +1180,11 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): # class ConsolePortForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = ConsolePort - fields = ['device', 'name'] + fields = ['device', 'name', 'tags'] widgets = { 'device': forms.HiddenInput(), } @@ -1349,10 +1350,11 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF # class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = ConsoleServerPort - fields = ['device', 'name'] + fields = ['device', 'name', 'tags'] widgets = { 'device': forms.HiddenInput(), } @@ -1445,10 +1447,11 @@ class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): # class PowerPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = PowerPort - fields = ['device', 'name'] + fields = ['device', 'name', 'tags'] widgets = { 'device': forms.HiddenInput(), } @@ -1614,10 +1617,11 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor # class PowerOutletForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = PowerOutlet - fields = ['device', 'name'] + fields = ['device', 'name', 'tags'] widgets = { 'device': forms.HiddenInput(), } @@ -1710,12 +1714,13 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # class InterfaceForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = Interface fields = [ 'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', + 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2082,10 +2087,11 @@ class InterfaceConnectionCSVForm(forms.ModelForm): # class DeviceBayForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = DeviceBay - fields = ['device', 'name'] + fields = ['device', 'name', 'tags'] widgets = { 'device': forms.HiddenInput(), } @@ -2143,10 +2149,11 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): # class InventoryItemForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = InventoryItem - fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] + fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags'] class InventoryItemCSVForm(forms.ModelForm): @@ -2202,10 +2209,11 @@ class DeviceSelectionForm(forms.Form): class VirtualChassisForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = VirtualChassis - fields = ['master', 'domain'] + fields = ['master', 'domain', 'tags'] widgets = { 'master': SelectWithPK, } diff --git a/netbox/dcim/migrations/0057_tags.py b/netbox/dcim/migrations/0057_tags.py index 04db38d5d..b0cccfdf3 100644 --- a/netbox/dcim/migrations/0057_tags.py +++ b/netbox/dcim/migrations/0057_tags.py @@ -34,4 +34,44 @@ class Migration(migrations.Migration): name='tags', field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), ), + migrations.AddField( + model_name='consoleport', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='consoleserverport', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='devicebay', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='interface', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='inventoryitem', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='poweroutlet', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='powerport', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='virtualchassis', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index c2b471e89..a7e5c9ee7 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1584,6 +1584,8 @@ class ConsolePort(ComponentModel): default=CONNECTION_STATUS_CONNECTED ) + tags = TaggableManager() + csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] class Meta: @@ -1638,6 +1640,7 @@ class ConsoleServerPort(ComponentModel): ) objects = ConsoleServerPortManager() + tags = TaggableManager() class Meta: unique_together = ['device', 'name'] @@ -1692,6 +1695,8 @@ class PowerPort(ComponentModel): default=CONNECTION_STATUS_CONNECTED ) + tags = TaggableManager() + csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] class Meta: @@ -1746,6 +1751,7 @@ class PowerOutlet(ComponentModel): ) objects = PowerOutletManager() + tags = TaggableManager() class Meta: unique_together = ['device', 'name'] @@ -1853,6 +1859,7 @@ class Interface(ComponentModel): ) objects = InterfaceQuerySet.as_manager() + tags = TaggableManager() serializer = 'dcim.api.serializers.InterfaceSerializer' @@ -2114,6 +2121,8 @@ class DeviceBay(ComponentModel): null=True ) + tags = TaggableManager() + class Meta: ordering = ['device', 'name'] unique_together = ['device', 'name'] @@ -2200,6 +2209,8 @@ class InventoryItem(ComponentModel): blank=True ) + tags = TaggableManager() + csv_headers = [ 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', ] @@ -2249,6 +2260,8 @@ class VirtualChassis(ChangeLoggedModel): blank=True ) + tags = TaggableManager() + serializer = 'dcim.api.serializers.VirtualChassisSerializer' class Meta: From 484a74defd173852e3e3dd1604204cc07565da1d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Jul 2018 16:16:23 -0400 Subject: [PATCH 100/159] Misc cleanup for config contexts --- netbox/extras/forms.py | 3 ++- .../templates/extras/object_configcontext.html | 4 ++++ netbox/utilities/forms.py | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 6e8e90512..16c38e61d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -11,7 +11,7 @@ from taggit.forms import TagField from taggit.models import Tag from dcim.models import Region -from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, SlugField +from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, JSONField, SlugField from .constants import ( CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, OBJECTCHANGE_ACTION_CHOICES, @@ -213,6 +213,7 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Region.objects.all(), required=False ) + data = JSONField() class Meta: model = ConfigContext diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html index 5cc7e7d7f..81f8e1780 100644 --- a/netbox/templates/extras/object_configcontext.html +++ b/netbox/templates/extras/object_configcontext.html @@ -31,6 +31,10 @@ {% endif %}
    {{ context.data|render_json }}
    + {% empty %} +
    + None found +
    {% endfor %}
    diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 305c12442..9a4284054 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -6,6 +6,7 @@ import re from django import forms from django.conf import settings +from django.contrib.postgres.forms import JSONField as _JSONField from django.db.models import Count from django.urls import reverse_lazy from mptt.forms import TreeNodeMultipleChoiceField @@ -536,6 +537,22 @@ class LaxURLField(forms.URLField): default_validators = [EnhancedURLValidator()] +class JSONField(_JSONField): + """ + Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. + """ + def __init__(self, *args, **kwargs): + super(JSONField, self).__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Enter context data in JSON format.' + self.widget.attrs['placeholder'] = '' + + def prepare_value(self, value): + if value is None: + return '' + return super(JSONField, self).prepare_value(value) + + # # Forms # From f048cf36ce2aa2b50dd1193eb18aa3635eb9c3ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Jul 2018 15:30:54 -0400 Subject: [PATCH 101/159] Implemented a view for interfaces --- netbox/dcim/models.py | 6 +- netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 42 +++ netbox/ipam/tables.py | 35 +++ netbox/templates/dcim/device.html | 3 +- netbox/templates/dcim/inc/interface.html | 13 +- netbox/templates/dcim/interface.html | 275 ++++++++++++++++++ .../virtualization/virtualmachine.html | 5 +- 8 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 netbox/templates/dcim/interface.html diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index a7e5c9ee7..0eea17c32 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1871,7 +1871,7 @@ class Interface(ComponentModel): return self.name def get_absolute_url(self): - return self.parent.get_absolute_url() + return reverse('dcim:interface', kwargs={'pk': self.pk}) def get_component_parent(self): return self.device or self.virtual_machine @@ -1967,6 +1967,10 @@ class Interface(ComponentModel): def parent(self): return self.device or self.virtual_machine + @property + def is_connectable(self): + return self.form_factor not in NONCONNECTABLE_IFACE_TYPES + @property def is_virtual(self): return self.form_factor in VIRTUAL_IFACE_TYPES diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 6824f620a..9910c60da 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -199,6 +199,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), url(r'^devices/(?P\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'), url(r'^interface-connections/(?P\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'), + url(r'^interfaces/(?P\d+)/$', views.InterfaceView.as_view(), name='interface'), url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), url(r'^interfaces/(?P\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9520297f1..737a33977 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,6 +21,7 @@ from circuits.models import Circuit from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.views import ObjectConfigContextView from ipam.models import Prefix, Service, VLAN +from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( @@ -1616,6 +1617,47 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Interfaces # +class InterfaceView(View): + + def get(self, request, pk): + + interface = get_object_or_404(Interface, pk=pk) + + # Get connected interface + connected_interface = interface.connected_interface + if connected_interface is None and hasattr(interface, 'circuit_termination'): + peer_termination = interface.circuit_termination.get_peer_termination() + if peer_termination is not None: + connected_interface = peer_termination.interface + + # Get assigned IP addresses + ipaddress_table = InterfaceIPAddressTable( + data=interface.ip_addresses.select_related('vrf', 'tenant'), + orderable=False + ) + + # Get assigned VLANs and annotate whether each is tagged or untagged + vlans = [] + if interface.untagged_vlan is not None: + vlans.append(interface.untagged_vlan) + vlans[0].tagged = False + for vlan in interface.tagged_vlans.select_related('site', 'group', 'tenant', 'role'): + vlan.tagged = True + vlans.append(vlan) + vlan_table = InterfaceVLANTable( + interface=interface, + data=vlans, + orderable=False + ) + + return render(request, 'dcim/interface.html', { + 'interface': interface, + 'connected_interface': connected_interface, + 'ipaddress_table': ipaddress_table, + 'vlan_table': vlan_table, + }) + + class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interface' parent_model = Device diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index e444cfe28..2d6ec11a4 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -342,6 +342,20 @@ class IPAddressAssignTable(BaseTable): orderable = False +class InterfaceIPAddressTable(BaseTable): + """ + List IP addresses assigned to a specific Interface. + """ + address = tables.TemplateColumn(IPADDRESS_ASSIGN_LINK, verbose_name='IP Address') + vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') + status = tables.TemplateColumn(STATUS_LABEL) + tenant = tables.TemplateColumn(template_code=TENANT_LINK) + + class Meta(BaseTable.Meta): + model = IPAddress + fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description') + + # # VLAN groups # @@ -403,6 +417,27 @@ class VLANMemberTable(BaseTable): fields = ('parent', 'name', 'untagged', 'actions') +class InterfaceVLANTable(BaseTable): + """ + List VLANs assigned to a specific Interface. + """ + vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID') + tagged = BooleanColumn() + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) + group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') + tenant = tables.TemplateColumn(template_code=COL_TENANT) + status = tables.TemplateColumn(STATUS_LABEL) + role = tables.TemplateColumn(VLAN_ROLE_LINK) + + class Meta(BaseTable.Meta): + model = VLAN + fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description') + + def __init__(self, interface, *args, **kwargs): + self.interface = interface + super(InterfaceVLANTable, self).__init__(*args, **kwargs) + + # # Services # diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 623efa9c4..d0751f363 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -522,8 +522,7 @@ Name LAG Description - MTU - MAC Address + Mode Connection diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index b254794df..6fd0bd52e 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -11,7 +11,7 @@ - {{ iface }} + {{ iface }} @@ -23,13 +23,10 @@ {# Description #} - {{ iface.description }} + {{ iface.description|default:"—" }} - {# MTU #} - {{ iface.mtu|default:"" }} - - {# MAC address #} - {{ iface.mac_address|default:"" }} + {# 802.1Q mode #} + {{ iface.get_mode_display }} {# Connection or type #} {% if iface.is_lag %} @@ -141,7 +138,7 @@ {% endif %} {# IP addresses table #} - + diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html new file mode 100644 index 000000000..d0f9b82c0 --- /dev/null +++ b/netbox/templates/dcim/interface.html @@ -0,0 +1,275 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block header %} +
    +
    + +
    +
    +
    + {% if perms.dcim.change_interface %} + + Edit this interface + + {% endif %} + {% if perms.dcim.delete_interface %} + + Delete this interface + + {% endif %} +
    +

    {% block title %}{{ interface.parent }} / {{ interface.name }}{% endblock %}

    +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Interface +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {% if interface.device %}Device{% else %}Virtual Machine{% endif %} + {{ interface.parent }} +
    Name{{ interface.name }}
    Type{{ interface.get_form_factor_display }}
    Enabled + {% if interface.enabled %} + + {% else %} + + {% endif %} +
    LAG + {% if interface.lag%} + {{ interface.lag }} + {% else %} + None + {% endif %} +
    Description + {% if interface.description %} + {{ interface.description }} + {% else %} + N/A + {% endif %} +
    MTU + {% if interface.mtu %} + {{ interface.mtu }} + {% else %} + N/A + {% endif %} +
    MAC Address + {% if interface.mac_address %} + {{ interface.mac_address }} + {% else %} + N/A + {% endif %} +
    802.1Q Mode{{ interface.get_mode_display }}
    + + {% include 'extras/inc/tags_panel.html' with tags=interface.tags.all %} + +
    + {% if interface.is_connectable %} +
    +
    + Connected Interface +
    + {% if connected_interface %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if interface.connection %} + + + + + {% endif %} +
    {% if connected_interface.device %}Device{% else %}Virtual Machine{% endif %} + {{ connected_interface.parent }} +
    Name{{ connected_interface.name }}
    Type{{ connected_interface.get_form_factor_display }}
    Enabled + {% if connected_interface.enabled %} + + {% else %} + + {% endif %} +
    LAG + {% if connected_interface.lag%} + {{ connected_interface.lag }} + {% else %} + None + {% endif %} +
    Description + {% if connected_interface.description %} + {{ connected_interface.description }} + {% else %} + N/A + {% endif %} +
    MTU + {% if connected_interface.mtu %} + {{ connected_interface.mtu }} + {% else %} + N/A + {% endif %} +
    MAC Address + {% if connected_interface.mac_address %} + {{ connected_interface.mac_address }} + {% else %} + N/A + {% endif %} +
    802.1Q Mode{{ connected_interface.get_mode_display }}
    Connection Status + {% if interface.connection.connection_status %} + {{ interface.connection.get_connection_status_display }} + {% else %} + {{ interface.connection.get_connection_status_display }} + {% endif %} +
    + {% else %} +
    + No connected interface +
    + {% endif %} +
    +
    +
    + Circuit Termination +
    + + {% if interface.circuit_termination %} + + + + + + + + + {% else %} + + + + {% endif %} +
    Circuit{{ interface.circuit_termination.circuit }}
    Side{{ interface.circuit_termination.term_side }}
    None
    +
    + {% endif %} + {% if interface.is_lag %} +
    +
    LAG Members
    + + + + + + + + + + {% for member in interface.member_interfaces.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
    ParentInterfaceType
    + {{ member.parent }} + + {{ member }} + + {{ member.get_form_factor_display }} +
    No member interfaces
    +
    + {% endif %} +
    + +
    +
    + {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
    +
    +
    +
    + {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 6323f31cc..9c6c8b0e3 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -261,8 +261,7 @@ Name LAG Description - MTU - MAC Address + Mode Connection @@ -272,7 +271,7 @@ {% include 'dcim/inc/interface.html' with device=virtualmachine %} {% empty %} - — No interfaces defined — + — No interfaces defined — {% endfor %} From f7f7764a6e72caa0836263a8db56162f68d99081 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Jul 2018 11:34:35 -0400 Subject: [PATCH 102/159] Miscellaneous cleanup for tags --- netbox/dcim/filters.py | 9 +++++++++ netbox/ipam/filters.py | 3 +++ netbox/ipam/forms.py | 3 ++- netbox/ipam/models.py | 2 ++ netbox/templates/dcim/interface_edit.html | 6 ++++++ netbox/templates/dcim/virtualchassis_list.html | 1 + netbox/templates/ipam/service_edit.html | 6 ++++++ netbox/templates/ipam/service_list.html | 1 + 8 files changed, 30 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 63091c2a8..1162f66a5 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -558,6 +558,9 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class ConsolePortFilter(DeviceComponentFilterSet): @@ -616,6 +619,9 @@ class InterfaceFilter(django_filters.FilterSet): method='_mac_address', label='MAC address', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Interface @@ -722,6 +728,9 @@ class VirtualChassisFilter(django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = VirtualChassis diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 88463b336..0a8606e52 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -466,6 +466,9 @@ class ServiceFilter(django_filters.FilterSet): to_field_name='name', label='Virtual machine (name)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Service diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 8a2f815ad..8209b2ffa 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -918,10 +918,11 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): # class ServiceForm(BootstrapMixin, CustomFieldForm): + tags = TagField(required=False) class Meta: model = Service - fields = ['name', 'protocol', 'port', 'ipaddresses', 'description'] + fields = ['name', 'protocol', 'port', 'ipaddresses', 'description', 'tags'] help_texts = { 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " "reachable via all IPs assigned to the device.", diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index e1bd93f97..242acf184 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -877,6 +877,8 @@ class Service(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) + tags = TaggableManager() + serializer = 'ipam.api.serializers.ServiceSerializer' class Meta: diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 0e212cf3e..6423c61c2 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -16,6 +16,12 @@ {% render_field form.mode %} +
    +
    Tags
    +
    + {% render_field form.tags %} +
    +
    {% if obj.mode %}
    802.1Q VLANs
    diff --git a/netbox/templates/dcim/virtualchassis_list.html b/netbox/templates/dcim/virtualchassis_list.html index e8d4f3366..756bc5f28 100644 --- a/netbox/templates/dcim/virtualchassis_list.html +++ b/netbox/templates/dcim/virtualchassis_list.html @@ -9,6 +9,7 @@
    {% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
    {% endblock %} diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index b3cf5571a..521aec137 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -40,4 +40,10 @@ {% endif %} +
    +
    Tags
    +
    + {% render_field form.tags %} +
    +
    {% endblock %} diff --git a/netbox/templates/ipam/service_list.html b/netbox/templates/ipam/service_list.html index c78b2bba2..5cfc3e12d 100644 --- a/netbox/templates/ipam/service_list.html +++ b/netbox/templates/ipam/service_list.html @@ -8,6 +8,7 @@
    {% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
    {% endblock %} From 50f4c746887b3360877666728291607535f6013c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Jul 2018 13:08:23 -0400 Subject: [PATCH 103/159] Tweaked migration to include Service model --- netbox/ipam/migrations/0022_tags.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/ipam/migrations/0022_tags.py b/netbox/ipam/migrations/0022_tags.py index fe5c113b1..14a508317 100644 --- a/netbox/ipam/migrations/0022_tags.py +++ b/netbox/ipam/migrations/0022_tags.py @@ -29,6 +29,11 @@ class Migration(migrations.Migration): name='tags', field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), ), + migrations.AddField( + model_name='service', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), migrations.AddField( model_name='vlan', name='tags', From abd5f17916637b02810ddb8e61271744a407097f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Jul 2018 13:09:13 -0400 Subject: [PATCH 104/159] Enabled webhooks for all (and only) primary models --- netbox/extras/constants.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 6807af9d9..d4fd29119 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -119,9 +119,9 @@ WEBHOOK_CT_CHOICES = ( # Models which support registered webhooks WEBHOOK_MODELS = ( 'provider', 'circuit', # Circuits - 'site', 'rack', 'rackgroup', 'device', 'interface', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vlangroup', 'vrf', # IPAM - 'service', - 'tenant', 'tenantgroup', # Tenancy - 'cluster', 'clustergroup', 'virtualmachine', # Virtualization + 'site', 'rack', 'devicetype', 'device', 'virtualchassis', # DCIM + 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM + 'secret', # Secrets + 'tenant', # Tenancy + 'cluster', 'virtualmachine', # Virtualization ) From 931c58bc9a9f8216887ff76a4e162b9588dc7ab5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Jul 2018 13:16:34 -0400 Subject: [PATCH 105/159] Enabled export templates for VRFs --- netbox/extras/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index d4fd29119..2663d81c3 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -51,7 +51,7 @@ EXPORTTEMPLATE_MODELS = [ 'provider', 'circuit', # Circuits 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM 'consoleport', 'powerport', 'interfaceconnection', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM + 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM 'tenant', # Tenancy 'cluster', 'virtualmachine', # Virtualization ] From bbaa3a2058741f75369147f42486527968e5a51b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Jul 2018 13:46:30 -0400 Subject: [PATCH 106/159] Enabled change logging for circuit terminations --- netbox/circuits/models.py | 16 +++++++++++++++- netbox/templates/extras/objectchange.html | 8 +++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 10ca8d7db..055ea65b1 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -8,8 +8,9 @@ from taggit.managers import TaggableManager from dcim.constants import STATUS_CLASSES from dcim.fields import ASNField -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, ObjectChange from utilities.models import ChangeLoggedModel +from utilities.utils import serialize_object from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES @@ -270,6 +271,19 @@ class CircuitTermination(models.Model): def __str__(self): return '{} (Side {})'.format(self.circuit, self.get_term_side_display()) + def log_change(self, user, request_id, action): + """ + Reference the parent circuit when recording the change. + """ + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=self.circuit, + action=action, + object_data=serialize_object(self) + ).save() + def get_peer_termination(self): peer_side = 'Z' if self.term_side == 'A' else 'A' try: diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index 21922b60a..306aeeee0 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -8,7 +8,13 @@
    From 85efdf8e003141655dd89417d5f36b397e8e4da6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Jul 2018 13:54:22 -0400 Subject: [PATCH 107/159] Cleaned up objectchange table --- netbox/extras/tables.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 8e35e5247..9f983583a 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -24,6 +24,10 @@ CONFIGCONTEXT_ACTIONS = """ {% endif %} """ +OBJECTCHANGE_TIME = """ +{{ value|date:"SHORT_DATETIME_FORMAT" }} +""" + OBJECTCHANGE_ACTION = """ {% if record.action == 1 %} Created @@ -42,6 +46,10 @@ OBJECTCHANGE_OBJECT = """ {% endif %} """ +OBJECTCHANGE_REQUEST_ID = """ +{{ value }} +""" + class TagTable(BaseTable): pk = ToggleColumn() @@ -74,7 +82,9 @@ class ConfigContextTable(BaseTable): class ObjectChangeTable(BaseTable): - time = tables.LinkColumn() + time = tables.TemplateColumn( + template_code=OBJECTCHANGE_TIME + ) action = tables.TemplateColumn( template_code=OBJECTCHANGE_ACTION ) @@ -85,7 +95,8 @@ class ObjectChangeTable(BaseTable): template_code=OBJECTCHANGE_OBJECT, verbose_name='Object' ) - request_id = tables.Column( + request_id = tables.TemplateColumn( + template_code=OBJECTCHANGE_REQUEST_ID, verbose_name='Request ID' ) From 67dbe02deb6012891d9c6609f003d1a8cb8150ac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Jul 2018 16:55:23 -0400 Subject: [PATCH 108/159] Enabled export templates for virtual chassis --- netbox/dcim/models.py | 7 +++++++ netbox/extras/constants.py | 2 +- netbox/templates/dcim/virtualchassis_list.html | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 0eea17c32..9a1f71847 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -2267,6 +2267,7 @@ class VirtualChassis(ChangeLoggedModel): tags = TaggableManager() serializer = 'dcim.api.serializers.VirtualChassisSerializer' + csv_headers = ['master', 'domain'] class Meta: ordering = ['master'] @@ -2286,3 +2287,9 @@ class VirtualChassis(ChangeLoggedModel): raise ValidationError({ 'master': "The selected master is not assigned to this virtual chassis." }) + + def to_csv(self): + return ( + self.master, + self.domain, + ) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 2663d81c3..e1e12b78f 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -50,7 +50,7 @@ GRAPH_TYPE_CHOICES = ( EXPORTTEMPLATE_MODELS = [ 'provider', 'circuit', # Circuits 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM - 'consoleport', 'powerport', 'interfaceconnection', # DCIM + 'consoleport', 'powerport', 'interfaceconnection', 'virtualchassis', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM 'tenant', # Tenancy 'cluster', 'virtualmachine', # Virtualization diff --git a/netbox/templates/dcim/virtualchassis_list.html b/netbox/templates/dcim/virtualchassis_list.html index 756bc5f28..3f1781d95 100644 --- a/netbox/templates/dcim/virtualchassis_list.html +++ b/netbox/templates/dcim/virtualchassis_list.html @@ -1,7 +1,11 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %} +
    + {% export_button content_type %} +

    {% block title %}Virtual Chassis{% endblock %}

    From 81b1d54859947be16b8ac9a2c3e31f1878ce20ff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Jul 2018 16:59:22 -0400 Subject: [PATCH 109/159] Enabled export templates for services --- netbox/extras/constants.py | 2 +- netbox/ipam/models.py | 11 +++++++++++ netbox/templates/ipam/service_list.html | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index e1e12b78f..116e9a6f1 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -51,7 +51,7 @@ EXPORTTEMPLATE_MODELS = [ 'provider', 'circuit', # Circuits 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM 'consoleport', 'powerport', 'interfaceconnection', 'virtualchassis', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM + 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM 'tenant', # Tenancy 'cluster', 'virtualmachine', # Virtualization ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 242acf184..103a895ff 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -880,6 +880,7 @@ class Service(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() serializer = 'ipam.api.serializers.ServiceSerializer' + csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description'] class Meta: ordering = ['protocol', 'port'] @@ -901,3 +902,13 @@ class Service(ChangeLoggedModel, CustomFieldModel): 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.port, + self.description, + ) diff --git a/netbox/templates/ipam/service_list.html b/netbox/templates/ipam/service_list.html index 5cfc3e12d..d2e67a000 100644 --- a/netbox/templates/ipam/service_list.html +++ b/netbox/templates/ipam/service_list.html @@ -1,6 +1,10 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %} +
    + {% export_button content_type %} +

    {% block title %}Services{% endblock %}

    From d334bd4477adb57ffd3c9a4a512d8007492bfb20 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Jul 2018 13:26:26 -0400 Subject: [PATCH 110/159] Corrected exception handling in get_serializer_for_model() --- netbox/utilities/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 4dc7eb019..0b0384653 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -37,7 +37,7 @@ def get_serializer_for_model(model, prefix=''): ) try: return dynamic_import(serializer_name) - except ImportError: + except AttributeError: return None From 57487f38de8caf51c019abc0494851c6023d7cfa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Jul 2018 13:29:57 -0400 Subject: [PATCH 111/159] Link to related object if changed object has no URL --- netbox/extras/tables.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 9f983583a..dd73bfe3e 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -41,6 +41,8 @@ OBJECTCHANGE_ACTION = """ OBJECTCHANGE_OBJECT = """ {% if record.action != 3 and record.changed_object.get_absolute_url %} {{ record.object_repr }} +{% elif record.action != 3 and record.related_object.get_absolute_url %} + {{ record.object_repr }} {% else %} {{ record.object_repr }} {% endif %} From 92de67a2ae3e1a0313a05fd34ba58dacf9288cba Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Jul 2018 13:34:00 -0400 Subject: [PATCH 112/159] Enabled webhooks for device components --- netbox/extras/constants.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 116e9a6f1..00a5c9979 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -120,6 +120,8 @@ WEBHOOK_CT_CHOICES = ( WEBHOOK_MODELS = ( 'provider', 'circuit', # Circuits 'site', 'rack', 'devicetype', 'device', 'virtualchassis', # DCIM + 'consoleport', 'consoleserverport', 'powerport', 'poweroutlet', + 'interface', 'devicebay', 'inventoryitem', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM 'secret', # Secrets 'tenant', # Tenancy From ea090236166078ef159600d30ca87c96e54b20b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Jul 2018 13:54:50 -0400 Subject: [PATCH 113/159] Webhook admin form cleanup --- netbox/extras/admin.py | 5 +++-- netbox/extras/migrations/0012_webhooks.py | 4 ++-- netbox/extras/models.py | 6 ++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 20a8f6f4e..2c5de5054 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -25,8 +25,9 @@ def order_content_types(field): # class WebhookForm(forms.ModelForm): - - payload_url = LaxURLField() + payload_url = LaxURLField( + label='URL' + ) class Meta: model = Webhook diff --git a/netbox/extras/migrations/0012_webhooks.py b/netbox/extras/migrations/0012_webhooks.py index 568c13c65..70c8e9c14 100644 --- a/netbox/extras/migrations/0012_webhooks.py +++ b/netbox/extras/migrations/0012_webhooks.py @@ -22,10 +22,10 @@ class Migration(migrations.Migration): ('type_update', models.BooleanField(default=False, help_text='Call this webhook when a matching object is updated.')), ('type_delete', models.BooleanField(default=False, help_text='Call this webhook when a matching object is deleted.')), ('payload_url', models.CharField(help_text='A POST will be sent to this URL when the webhook is called.', max_length=500, verbose_name='URL')), - ('http_content_type', models.PositiveSmallIntegerField(choices=[(1, 'application/json'), (2, 'application/x-www-form-urlencoded')], default=1)), + ('http_content_type', models.PositiveSmallIntegerField(choices=[(1, 'application/json'), (2, 'application/x-www-form-urlencoded')], default=1, verbose_name='HTTP content type')), ('secret', models.CharField(blank=True, help_text="When provided, the request will include a 'X-Hook-Signature' header containing a HMAC hex digest of the payload body using the secret as the key. The secret is not transmitted in the request.", max_length=255)), ('enabled', models.BooleanField(default=True)), - ('ssl_verification', models.BooleanField(default=True, help_text='Enable SSL certificate verification. Disable with caution!')), + ('ssl_verification', models.BooleanField(default=True, help_text='Enable SSL certificate verification. Disable with caution!', verbose_name='SSL verification')), ('obj_type', models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object types')), ], ), diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 5b7a2f456..9e1bd2724 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -65,7 +65,8 @@ class Webhook(models.Model): ) http_content_type = models.PositiveSmallIntegerField( choices=WEBHOOK_CT_CHOICES, - default=WEBHOOK_CT_JSON + default=WEBHOOK_CT_JSON, + verbose_name='HTTP content type' ) secret = models.CharField( max_length=255, @@ -80,11 +81,12 @@ class Webhook(models.Model): ) ssl_verification = models.BooleanField( default=True, + verbose_name='SSL verification', help_text="Enable SSL certificate verification. Disable with caution!" ) class Meta: - unique_together = ('payload_url', 'type_create', "type_update", "type_delete",) + unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',) def __str__(self): return self.name From 69ddf046b03e77c7672a56bd67db5cde3430f5d0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Jul 2018 14:53:57 -0400 Subject: [PATCH 114/159] Quick docs update (more to come prior to the v2.4 release) --- docs/data-model/extras.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index 83f50df65..5de043eab 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -1,5 +1,9 @@ This section entails features of NetBox which are not crucial to its primary functions, but provide additional value. +# Tags + +Tags are freeform labels which can be assigned to a variety of objects in NetBox. Tags can be used to categorize and filter objects in addition to built-in and custom fields. Each tag consists of a text label, as well as an auto-generated URL-friendly slug value. Objects can be filtered by the tags assigned to them. Tags can be used across different object types. + # Custom Fields Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. @@ -27,6 +31,10 @@ When a single object is edited, the form will include any custom fields which ha When editing multiple objects, custom field values are saved in bulk. There is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field. +# Contextual Configuration Data + +Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. (For example, you might want to associate a set of syslog servers for all devices at a particular site.) Context data enables the association of arbitrary data (expressed in JSON format) to devices and virtual machines grouped by region, site, role, platform, and/or tenancy. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. + # Export Templates NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. From 277197edd44b9944ccf2d3b21ca7cf54f8693f86 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Jul 2018 16:21:46 -0400 Subject: [PATCH 115/159] Split webhooks documentation into the data model and installation sections --- docs/data-model/extras.md | 54 ++++++++++- docs/installation/netbox.md | 39 +++++++- docs/installation/upgrading.md | 6 ++ docs/installation/web-server.md | 5 + docs/miscellaneous/webhooks.md | 167 -------------------------------- 5 files changed, 102 insertions(+), 169 deletions(-) delete mode 100644 docs/miscellaneous/webhooks.md diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index 5de043eab..6b7e0d2d9 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -141,6 +141,58 @@ Certain objects within NetBox (namely sites, racks, and devices) can have photos # Webhooks -A webhook defines an HTTP request that is sent to an external application when certain types of objects are created, updated, and/or deleted in NetBox. When a webhook is triggered, a POST request is sent to its configured URL. This request will include a full representation of the object being modified for consumption by the receiver. +A webhook defines an HTTP request that is sent to an external application when certain types of objects are created, updated, and/or deleted in NetBox. When a webhook is triggered, a POST request is sent to its configured URL. This request will include a full representation of the object being modified for consumption by the receiver. Webhooks are configured via the admin UI under Extras > Webhooks. An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content. + +## Requests + +The webhook POST request is structured as so (assuming `application/json` as the Content-Type): + +```no-highlight +{ + "event": "created", + "signal_received_timestamp": 1508769597, + "model": "Site" + "data": { + ... + } +} +``` + +`data` is the serialized representation of the model instance(s) from the event. The same serializers from the NetBox API are used. So an example of the payload for a Site delete event would be: + +```no-highlight +{ + "event": "deleted", + "signal_received_timestamp": 1508781858.544069, + "model": "Site", + "data": { + "asn": None, + "comments": "", + "contact_email": "", + "contact_name": "", + "contact_phone": "", + "count_circuits": 0, + "count_devices": 0, + "count_prefixes": 0, + "count_racks": 0, + "count_vlans": 0, + "custom_fields": {}, + "facility": "", + "id": 54, + "name": "test", + "physical_address": "", + "region": None, + "shipping_address": "", + "slug": "test", + "tenant": None + } +} +``` + +A request is considered successful if the response status code is any one of a list of "good" statuses defined in the [requests library](https://github.com/requests/requests/blob/205755834d34a8a6ecf2b0b5b2e9c3e6a7f4e4b6/requests/models.py#L688), otherwise the request is marked as having failed. The user may manually retry a failed request. + +## Backend Status + +Django-rq includes a status page in the admin site which can be used to view the result of processed webhooks and manually retry any failed webhooks. Access it from http://netbox.local/admin/webhook-backend-status/. diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index 5577b389b..8f59adc29 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -82,7 +82,7 @@ Install the required Python packages using pip. (If you encounter any compilatio !!! note If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip3 -V`. -### NAPALM Automation +### NAPALM Automation (Optional) NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: @@ -90,6 +90,28 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati # pip3 install napalm ``` +### Webhooks (Optional) + +[Webhooks](../data-model/extras/#webhooks) allow NetBox to integrate with external services by pushing out a notification each time a relevant object is created, updated, or deleted. Enabling the webhooks feature requires [Redis](https://redis.io/), a lightweight in-memory database. You may opt to install a Redis sevice locally (see below) or connect to an external one. + +**Ubuntu** + +```no-highlight +# apt-get install -y redis-server +``` + +**CentOS** + +```no-highlight +# yum install -y redis +``` + +Enabling webhooks also requires installing the [`django-rq`](https://github.com/ui/django-rq) package. This allows NetBox to use the Redis database as a queue for outgoing webhooks. + +```no-highlight +# pip3 install django-rq +``` + # Configuration Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. @@ -140,6 +162,21 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a !!! note In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state. +## Webhooks Configuration + +If you have opted to enable the webhooks, set `WEBHOOKS_ENABLED = True` and define the relevant `REDIS` database parameters. Below is an example: + +```python +WEBHOOKS_ENABLED = True +REDIS = { + 'HOST': 'localhost', + 'PORT': 6379, + 'PASSWORD': '', + 'DATABASE': 0, + 'DEFAULT_TIMEOUT': 300, +} +``` + # Run Database Migrations Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example): diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index a8924e1df..bca60ca89 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -92,3 +92,9 @@ Finally, restart the WSGI service to run the new code. If you followed this guid ```no-highlight # sudo supervisorctl restart netbox ``` + +If using webhooks, also restart the Redis worker: + +```no-highlight +# sudo supervisorctl restart netbox-rqworker +``` diff --git a/docs/installation/web-server.md b/docs/installation/web-server.md index 5886e3323..9ed8fdd74 100644 --- a/docs/installation/web-server.md +++ b/docs/installation/web-server.md @@ -133,6 +133,11 @@ Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi directory = /opt/netbox/netbox/ user = www-data + +[program:netbox-rqworker] +command = python3 /opt/netbox/netbox/manage.py rqworker +directory = /opt/netbox/netbox/ +user = www-data ``` Then, restart the supervisor service to detect and run the gunicorn service: diff --git a/docs/miscellaneous/webhooks.md b/docs/miscellaneous/webhooks.md deleted file mode 100644 index 2c68053ae..000000000 --- a/docs/miscellaneous/webhooks.md +++ /dev/null @@ -1,167 +0,0 @@ -# NetBox Webhook Backend - -NetBox includes the ability to send outbound requests to external webhooks upon certain model events occuring, however this functionality is disabled by default and requires some admin interaction to setup. - -When enabled, the user may subscribe webhooks to certain model events. These events include when a model is either created, updated, or deleted. More than one webhook my be registered to a particular model and/or event type. - -## Allowed Models - -The models which may have webhooks registered to them are: - -DCIM: - -- Site -- Rack -- RackGroup -- Device -- Interface - -IPAM: - -- VRF -- IPAddress -- Prefix -- Aggregate -- VLAN -- VLANGroup -- Service - -Tenancy: - -- Tenant -- TenantGroup - -Circuits: - -- Circuit -- Provider - -Virtualization: - -- Cluster -- ClusterGroup -- VirtualMachine - -## Defining Webhooks - -The [webhook model](../data-model/extras/#webhooks) is used to define a webhook. In general an event type, registered models, and payload url are needed. When a matching event on a registered model occurs, a HTTP POST request is made to the payload url. - -Webhooks are created and updated under extras in the admin site. - -### Request - -The webhook POST request is structured as so (assuming `application/json` as the Content-Type): - -```no-highlight -{ - "event": "created", - "signal_received_timestamp": 1508769597, - "model": "Site" - "data": { - ... - } -} -``` - -`data` is the serialized representation of the model instance(s) from the event. The same serializers from the NetBox API are used. So an example of the payload for a Site delete event would be: - -```no-highlight -{ - "event": "deleted", - "signal_received_timestamp": 1508781858.544069, - "model": "Site", - "data": { - "asn": None, - "comments": "", - "contact_email": "", - "contact_name": "", - "contact_phone": "", - "count_circuits": 0, - "count_devices": 0, - "count_prefixes": 0, - "count_racks": 0, - "count_vlans": 0, - "custom_fields": {}, - "facility": "", - "id": 54, - "name": "test", - "physical_address": "", - "region": None, - "shipping_address": "", - "slug": "test", - "tenant": None - } -} -``` - -A request is considered successful if the response status code is any one of a list of "good" statuses defined in the [requests library](https://github.com/requests/requests/blob/205755834d34a8a6ecf2b0b5b2e9c3e6a7f4e4b6/requests/models.py#L688), otherwise the request is marked as having failed. The user may manually retry a failed request. - -## Installation - -The webhook backend feature is considered an "advanced" feature and requires some extra effort to get it running. This is due the fact that a background worker is needed to process events in a non blocking way, i.e. the webhooks are sent in the background as not to interrupt what a user is doing in the NetBox foreground. - -To do this, you must install [Redis](https://redis.io/) or simply be able to connect to an existing redis server. Redis is a lightweight, in memory database. Redis is used as a means of persistence between NetBox and the background worker for the queue of webhooks to be sent. It can be installed through most package managers. - -```no-highlight -# apt-get install redis-server -``` - -The only other component needed is [Django-rq](https://github.com/ui/django-rq) which implements [python-rq](http://python-rq.org/) in a native Django context. This should be done from the same place NetBox is installed, i.e. the same python namespace where you run the upgrade script. Python-rq is a simple background job queueing system sitting on top of redis. - -```no-highlight -pip install django-rq -``` - -As mentioned before, the feature requires running a background process. This means we need to run another process along side the NetBox application. We can do this conveniently by modifying the supervisord unit used to run NetBox. Taking the configuration provided from the [installation guide](../installation/web-server/#supervisord_installation) modify it to look like this: - -```no-highlight -[program:netbox-core] -command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi -directory = /opt/netbox/netbox/ -user = www-data - -[program:netbox-webhook-backend] -command = python3 /opt/netbox/netbox/manage.py rqworker -directory = /opt/netbox/netbox/ -user = www-data - -[group:netbox] -programs=netbox-core,netbox-webhook-backend -``` - -!!! note - `[program:netbox]` was changed to `[program:netbox-core]` - -This allows you to control both the NetBox application and the background worker as one unit. - -Then, restart the supervisor service to detect the changes: - -```no-highlight -# service supervisor restart -``` - -!!! note - Now any time you start or stop NetBox using `supervisorctl`, you will need to refer to the - NetBox process as `netbox:*` (before this was just `netbox`). This is due to the fact that - we are now running multiple processes with supervisor, and `netbox:*` tells supervisor to - act on all NetBox processes (`netbox-core` and `netbox-webhook-backend` in this case). - -Now you need only add the configuration settings to connect to redis and enable the webhook backend feature. - -- In your `configuration.py` Set [WEBHOOKS_ENABLED](../configuration/optional-settings/#webhooks_enabled) to `True`. -- If needed, set the optional redis connection settings. By default, they will allow connecting to DB 0 on a locally installed redis server with no password. - - [REDIS_DB](../configuration/optional-settings/#redis_db) - - [REDIS_DEFAULT_TIMEOUT](../configuration/optional-settings/#redis_default_timeout) - - [REDIS_HOST](../configuration/optional-settings/#redis_host) - - [REDIS_PASSWORD](../configuration/optional-settings/#redis_password) - - [REDIS_PORT](../configuration/optional-settings/#redis_port) - -Now you may restart NetBox as normal and the webhook backend should start running! - -```no-highlight -# sudo supervisorctl restart netbox:* -``` - -## Backend Status - -Django-rq includes a status page in the admin site which can be used to view the result of processed webhooks and manually retry any failed webhooks. Access it from http://netbox.local/admin/webhook-backend-status/. From 8bc8cf5e2deb8b619a697120d985ae343c75a9c3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Jul 2018 16:33:06 -0400 Subject: [PATCH 116/159] Include django-rq queues link in admin UI until we implement something cleaner --- netbox/netbox/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8fef2c573..f39a949af 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -271,6 +271,7 @@ RQ_QUEUES = { 'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT, } } +RQ_SHOW_ADMIN_LINK = True # drf_yasg settings for Swagger SWAGGER_SETTINGS = { From 9e2ac7b3f4b3009515de0c1bc35660ba1171c2e6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Jul 2018 17:09:21 -0400 Subject: [PATCH 117/159] Cleaned up imports --- netbox/extras/models.py | 2 +- netbox/extras/webhooks.py | 10 ++++------ netbox/extras/webhooks_worker.py | 5 ++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 9e1bd2724..512c7906b 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -8,12 +8,12 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import JSONField -from django.urls import reverse from django.core.validators import ValidationError from django.db import models from django.db.models import Q from django.http import HttpResponse from django.template import Template, Context +from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from django.utils.safestring import mark_safe diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 7f77773c8..ca390a093 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -1,15 +1,13 @@ import time -from importlib import import_module -from django.db.models.signals import post_save, post_delete from django.conf import settings -from django.core.cache import caches -from django.db.models import Q -from django.dispatch import Signal from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from django.db.models.signals import post_save, post_delete +from django.dispatch import Signal -from utilities.utils import dynamic_import from extras.models import Webhook +from utilities.utils import dynamic_import def enqueue_webhooks(webhooks, model_class, data, event, signal_received_timestamp): diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 6d346a51f..c764375d7 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -1,8 +1,7 @@ -import requests -import hmac import hashlib -from rq.utils import import_attribute +import hmac +import requests from django_rq import job from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED From 0c0799f3bf0b889295c4ed345cfc44a532841881 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Jul 2018 09:43:57 -0400 Subject: [PATCH 118/159] Closes #1739: Enabled custom fields for secrets --- netbox/extras/constants.py | 1 + netbox/secrets/api/serializers.py | 7 +++++-- netbox/secrets/filters.py | 3 ++- netbox/secrets/forms.py | 11 ++++++----- netbox/secrets/models.py | 9 ++++++++- netbox/templates/secrets/secret.html | 3 ++- netbox/templates/secrets/secret_edit.html | 8 ++++++++ 7 files changed, 32 insertions(+), 10 deletions(-) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 00a5c9979..7ca48f1d7 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -6,6 +6,7 @@ CUSTOMFIELD_MODELS = ( 'provider', 'circuit', # Circuits 'site', 'rack', 'devicetype', 'device', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM + 'secret', # Secrets 'tenant', # Tenancy 'cluster', 'virtualmachine', # Virtualization ) diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 0e24281bb..3c24edf2b 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -5,6 +5,7 @@ from rest_framework.validators import UniqueTogetherValidator from taggit.models import Tag from dcim.api.serializers import NestedDeviceSerializer +from extras.api.customfields import CustomFieldModelSerializer from secrets.models import Secret, SecretRole from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer @@ -32,7 +33,7 @@ class NestedSecretRoleSerializer(WritableNestedSerializer): # Secrets # -class SecretSerializer(ValidatedModelSerializer): +class SecretSerializer(CustomFieldModelSerializer): device = NestedDeviceSerializer() role = NestedSecretRoleSerializer() plaintext = serializers.CharField() @@ -40,7 +41,9 @@ class SecretSerializer(ValidatedModelSerializer): class Meta: model = Secret - fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'created', 'last_updated'] + fields = [ + 'id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated', + ] validators = [] def validate(self, data): diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 2499fa2bb..f43a82b22 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -4,6 +4,7 @@ import django_filters from django.db.models import Q from dcim.models import Device +from extras.filters import CustomFieldFilterSet from utilities.filters import NumericInFilter from .models import Secret, SecretRole @@ -15,7 +16,7 @@ class SecretRoleFilter(django_filters.FilterSet): fields = ['name', 'slug'] -class SecretFilter(django_filters.FilterSet): +class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 6961baf88..566abc0ee 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -7,8 +7,8 @@ from django.db.models import Count from taggit.forms import TagField from dcim.models import Device -from extras.forms import AddRemoveTagsForm -from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField +from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldForm +from utilities.forms import BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField from .models import Secret, SecretRole, UserKey @@ -59,7 +59,7 @@ class SecretRoleCSVForm(forms.ModelForm): # Secrets # -class SecretForm(BootstrapMixin, forms.ModelForm): +class SecretForm(BootstrapMixin, CustomFieldForm): plaintext = forms.CharField( max_length=65535, required=False, @@ -129,7 +129,7 @@ class SecretCSVForm(forms.ModelForm): return s -class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False) name = forms.CharField(max_length=100, required=False) @@ -138,7 +138,8 @@ class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): nullable_fields = ['name'] -class SecretFilterForm(BootstrapMixin, forms.Form): +class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Secret q = forms.CharField(required=False, label='Search') role = FilterChoiceField( queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index bd1c6e878..9cb2b3892 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -8,12 +8,14 @@ from Crypto.Util import strxor from django.conf import settings from django.contrib.auth.hashers import make_password, check_password from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.encoding import force_bytes, python_2_unicode_compatible from taggit.managers import TaggableManager +from extras.models import CustomFieldModel from utilities.models import ChangeLoggedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -311,7 +313,7 @@ class SecretRole(ChangeLoggedModel): @python_2_unicode_compatible -class Secret(ChangeLoggedModel): +class Secret(ChangeLoggedModel, CustomFieldModel): """ 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 a @@ -343,6 +345,11 @@ class Secret(ChangeLoggedModel): max_length=128, editable=False ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) tags = TaggableManager() diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 05488bba6..5b670f2c8 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -69,7 +69,7 @@
    - {% include 'extras/inc/tags_panel.html' with tags=secret.tags.all url='secrets:secret_list' %} + {% include 'inc/custom_fields_panel.html' with custom_fields=secret.get_custom_fields %}
    {% if secret|decryptable_by:request.user %} @@ -101,6 +101,7 @@ You do not have permission to decrypt this secret.
    {% endif %} + {% include 'extras/inc/tags_panel.html' with tags=secret.tags.all url='secrets:secret_list' %}
    diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 87ee3b426..2d2fc4644 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -54,6 +54,14 @@ {% render_field form.plaintext2 %} + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    + {% endif %}
    Tags
    From 6cc9ceb19889a59b7947b63efe3dc0a7aebf5d44 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Jul 2018 10:25:16 -0400 Subject: [PATCH 119/159] Enabled bulk add/remove tags for interfaces --- netbox/dcim/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 0ff58918b..521c3c858 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1881,7 +1881,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form): self.fields['lag'].queryset = Interface.objects.none() -class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): +class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) From 5cf38b5ce90e95e247c43c0456e910847ec7ec83 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Jul 2018 10:34:50 -0400 Subject: [PATCH 120/159] Enabled export templates for secrets --- netbox/extras/constants.py | 1 + netbox/secrets/models.py | 8 ++++++++ netbox/templates/secrets/secret_list.html | 1 + 3 files changed, 10 insertions(+) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 7ca48f1d7..9707d9121 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -53,6 +53,7 @@ EXPORTTEMPLATE_MODELS = [ 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM 'consoleport', 'powerport', 'interfaceconnection', 'virtualchassis', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM + 'secret', # Secrets 'tenant', # Tenancy 'cluster', 'virtualmachine', # Virtualization ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 9cb2b3892..464129975 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -376,6 +376,14 @@ class Secret(ChangeLoggedModel, CustomFieldModel): def get_absolute_url(self): return reverse('secrets:secret', args=[self.pk]) + def to_csv(self): + return ( + self.device, + self.role, + self.name, + self.plaintext or '', + ) + def _pad(self, s): """ Prepend the length of the plaintext (2B) and pad with garbage to a multiple of 16B (minimum of 64B). diff --git a/netbox/templates/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html index 0a70e1087..a41c0b4ee 100644 --- a/netbox/templates/secrets/secret_list.html +++ b/netbox/templates/secrets/secret_list.html @@ -6,6 +6,7 @@ {% if perms.secrets.add_secret %} {% import_button 'secrets:secret_import' %} {% endif %} + {% export_button content_type %}

    {% block title %}Secrets{% endblock %}

    From df6c5dfac584c1fc051ac3a23727be013c9e1014 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Jul 2018 11:10:13 -0400 Subject: [PATCH 121/159] Established base requirements and pinned package versions for release --- base_requirements.txt | 21 +++++++++++++++++++++ requirements.txt | 41 +++++++++++++++++++++-------------------- 2 files changed, 42 insertions(+), 20 deletions(-) create mode 100644 base_requirements.txt diff --git a/base_requirements.txt b/base_requirements.txt new file mode 100644 index 000000000..90c3aa371 --- /dev/null +++ b/base_requirements.txt @@ -0,0 +1,21 @@ +Django +django-cors-headers +django-debug-toolbar +django-filter==1.1.0 +django-mptt +django-tables2 +django-taggit +django-timezone-field +djangorestframework==3.8.1 +drf-yasg[validation] +graphviz +Markdown +natsort +ncclient +netaddr +paramiko +Pillow +psycopg2-binary +py-gfm +pycryptodome +xmltodict diff --git a/requirements.txt b/requirements.txt index f50f96e33..4d3782e77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,22 @@ -Django>=1.11 -django-cors-headers>=2.1.0 -django-debug-toolbar>=1.9.0 +Django>=1.11,<2.1 +django-cors-headers==2.3.0 +django-debug-toolbar==1.9.1 django-filter==1.1.0 -django-mptt>=0.9.0 -django-tables2>=1.19.0 -django-taggit>=0.22.2 -django-timezone-field>=2.0 -djangorestframework>=3.8.0,<3.8.2 -drf-yasg[validation]>=1.4.4 -graphviz>=0.8.2 -Markdown>=2.6.11 -natsort>=5.2.0 -ncclient==0.5.3 -netaddr==0.7.18 -paramiko>=2.4.0 -Pillow>=5.0.0 -psycopg2-binary>=2.7.4 -py-gfm>=0.1.3 -pycryptodome>=3.4.11 -xmltodict>=0.11.0 +django-mptt==0.9.0 +django-tables2==1.21.2 +django-taggit==0.22.2 +django-timezone-field==2.1 +djangorestframework==3.8.1 +drf-yasg[validation]==1.9.1 +graphviz==0.8.4 +Markdown==2.6.11 +natsort==5.3.3 +ncclient==0.6.0 +netaddr==0.7.19 +paramiko==2.4.1 +Pillow==5.2.0 +psycopg2-binary==2.7.5 +py-gfm==0.1.3 +pycryptodome==3.6.4 +xmltodict==0.11.0 + From 89373624330e10354145812fc76e4b5eea7614ae Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Jul 2018 11:22:25 -0400 Subject: [PATCH 122/159] Release v2.4-beta1 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f39a949af..9d7bd23f0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.4-dev' +VERSION = '2.4-beta1' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From d77214fef6e223bc78754a560910485f4eae03a9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Jul 2018 17:23:10 -0400 Subject: [PATCH 123/159] Started v2.4 documentation refresh --- docs/additional-features/context-data.md | 3 + docs/additional-features/custom-fields.md | 26 +++ docs/additional-features/export-templates.md | 52 +++++ docs/additional-features/graphs.md | 25 +++ .../netbox-shell.md} | 0 .../reports.md | 0 docs/additional-features/tags.md | 24 +++ docs/additional-features/topology-maps.md | 17 ++ docs/additional-features/webhooks.md | 57 +++++ docs/configuration/index.html | 16 ++ docs/configuration/optional-settings.md | 2 +- ...atory-settings.md => required-settings.md} | 2 +- .../circuits.md | 13 +- docs/core-functionality/devices.md | 120 +++++++++++ docs/core-functionality/ipam.md | 93 ++++++++ .../secrets.md | 8 +- docs/core-functionality/services.md | 5 + docs/core-functionality/sites-and-racks.md | 49 +++++ docs/core-functionality/tenancy.md | 20 ++ docs/core-functionality/virtual-machines.md | 27 +++ docs/core-functionality/vlans.md | 15 ++ docs/data-model/dcim.md | 124 ----------- docs/data-model/extras.md | 198 ------------------ docs/data-model/ipam.md | 99 --------- docs/data-model/tenancy.md | 20 -- docs/data-model/virtualization.md | 29 --- docs/development/index.md | 10 + docs/index.md | 16 +- .../{postgresql.md => 1-postgresql.md} | 0 docs/installation/{netbox.md => 2-netbox.md} | 0 .../{web-server.md => 3-http-daemon.md} | 0 docs/installation/{ldap.md => 4-ldap.md} | 0 docs/installation/index.md | 14 ++ mkdocs.yml | 71 ++++--- 34 files changed, 636 insertions(+), 519 deletions(-) create mode 100644 docs/additional-features/context-data.md create mode 100644 docs/additional-features/custom-fields.md create mode 100644 docs/additional-features/export-templates.md create mode 100644 docs/additional-features/graphs.md rename docs/{miscellaneous/shell.md => additional-features/netbox-shell.md} (100%) rename docs/{miscellaneous => additional-features}/reports.md (100%) create mode 100644 docs/additional-features/tags.md create mode 100644 docs/additional-features/topology-maps.md create mode 100644 docs/additional-features/webhooks.md create mode 100644 docs/configuration/index.html rename docs/configuration/{mandatory-settings.md => required-settings.md} (89%) rename docs/{data-model => core-functionality}/circuits.md (68%) create mode 100644 docs/core-functionality/devices.md create mode 100644 docs/core-functionality/ipam.md rename docs/{data-model => core-functionality}/secrets.md (81%) create mode 100644 docs/core-functionality/services.md create mode 100644 docs/core-functionality/sites-and-racks.md create mode 100644 docs/core-functionality/tenancy.md create mode 100644 docs/core-functionality/virtual-machines.md create mode 100644 docs/core-functionality/vlans.md delete mode 100644 docs/data-model/dcim.md delete mode 100644 docs/data-model/extras.md delete mode 100644 docs/data-model/ipam.md delete mode 100644 docs/data-model/tenancy.md delete mode 100644 docs/data-model/virtualization.md create mode 100644 docs/development/index.md rename docs/installation/{postgresql.md => 1-postgresql.md} (100%) rename docs/installation/{netbox.md => 2-netbox.md} (100%) rename docs/installation/{web-server.md => 3-http-daemon.md} (100%) rename docs/installation/{ldap.md => 4-ldap.md} (100%) create mode 100644 docs/installation/index.md diff --git a/docs/additional-features/context-data.md b/docs/additional-features/context-data.md new file mode 100644 index 000000000..d8cff36f2 --- /dev/null +++ b/docs/additional-features/context-data.md @@ -0,0 +1,3 @@ +# Contextual Configuration Data + +Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. (For example, you might want to associate a set of syslog servers for all devices at a particular site.) Context data enables the association of arbitrary data (expressed in JSON format) to devices and virtual machines grouped by region, site, role, platform, and/or tenancy. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. diff --git a/docs/additional-features/custom-fields.md b/docs/additional-features/custom-fields.md new file mode 100644 index 000000000..01414062c --- /dev/null +++ b/docs/additional-features/custom-fields.md @@ -0,0 +1,26 @@ +# Custom Fields + +Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. + +However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data. + +Custom fields must be created through the admin UI under Extras > Custom Fields. To create a new custom field, select the object(s) to which you want it to apply, and the type of field it will be. NetBox supports six field types: + +* Free-form text (up to 255 characters) +* Integer +* Boolean (true/false) +* Date +* URL +* Selection + +Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form. + +Marking the field as required will require the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.) + +When creating a selection field, you should create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically. + +## Using Custom Fields + +When a single object is edited, the form will include any custom fields which have been defined for the object type. These fields are included in the "Custom Fields" panel. On the backend, each custom field value is saved separately from the core object as an independent database call, so it's best to avoid adding too many custom fields per object. + +When editing multiple objects, custom field values are saved in bulk. There is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field. diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md new file mode 100644 index 000000000..41bc11a6e --- /dev/null +++ b/docs/additional-features/export-templates.md @@ -0,0 +1,52 @@ +# Export Templates + +NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. + +Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. + +Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: + +``` +{% for rack in queryset %} +Rack: {{ rack.name }} +Site: {{ rack.site.name }} +Height: {{ rack.u_height }}U +{% endfor %} +``` + +To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. + +A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. + +## Example + +Here's an example device export template that will generate a simple Nagios configuration from a list of devices. + +``` +{% for device in queryset %}{% if device.status and device.primary_ip %}define host{ + use generic-switch + host_name {{ device.name }} + address {{ device.primary_ip.address.ip }} +} +{% endif %}{% endfor %} +``` + +The generated output will look something like this: + +``` +define host{ + use generic-switch + host_name switch1 + address 192.0.2.1 +} +define host{ + use generic-switch + host_name switch2 + address 192.0.2.2 +} +define host{ + use generic-switch + host_name switch3 + address 192.0.2.3 +} +``` diff --git a/docs/additional-features/graphs.md b/docs/additional-features/graphs.md new file mode 100644 index 000000000..7b37276e8 --- /dev/null +++ b/docs/additional-features/graphs.md @@ -0,0 +1,25 @@ +# Graphs + +NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters: + +* **Type:** Site, provider, or interface. This determines in which view the graph will be displayed. +* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name. +* **Name:** The title to display above the graph. +* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. +* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`. + +## Examples + +You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this: + +``` +https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m +``` + +You can define several graphs to provide multiple contexts when viewing an object. For example: + +``` +https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m +https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h +https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m +``` diff --git a/docs/miscellaneous/shell.md b/docs/additional-features/netbox-shell.md similarity index 100% rename from docs/miscellaneous/shell.md rename to docs/additional-features/netbox-shell.md diff --git a/docs/miscellaneous/reports.md b/docs/additional-features/reports.md similarity index 100% rename from docs/miscellaneous/reports.md rename to docs/additional-features/reports.md diff --git a/docs/additional-features/tags.md b/docs/additional-features/tags.md new file mode 100644 index 000000000..63bb22132 --- /dev/null +++ b/docs/additional-features/tags.md @@ -0,0 +1,24 @@ +# Tags + +Tags are free-form text labels which can be applied to a variety of objects within NetBox. Tags are created on-demand. Use commas to separate tags when adding multiple tags to an object, e.g. `Inventoried, Monitored`. Use double quotes around a multi-word tag when adding only one tag, e.g. `"Core Switch"`. + +Each tag has a label and a URL-friendly slug. For example, the slug for a tag named "Dunder Mifflin, Inc." would be `dunder-mifflin-inc`. The slug is generated automatically and makes them easier to work with as URL parameters. + +Objects can be filtered by the tags they have applied. For example, the following API request will retrieve all devices tagged as "monitored": + +``` +GET /api/dcim/devices/?tag=monitored +``` + +Tags are included in the API representation of an object as a list of plain strings: + +``` +{ + ... + "tags": [ + "Core Switch", + "Monitored" + ], + ... +} +``` diff --git a/docs/additional-features/topology-maps.md b/docs/additional-features/topology-maps.md new file mode 100644 index 000000000..21bbe404d --- /dev/null +++ b/docs/additional-features/topology-maps.md @@ -0,0 +1,17 @@ +# Topology Maps + +NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps. + +Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend infrastructure). + +To define the scope of a topology map, decide which devices you want to include. The map will only include interface connections with both points terminated on an included device. Specify the devices to include in the **device patterns** field by entering a list of [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) matching device names. For example, if you wanted to include "mgmt-switch1" through "mgmt-switch99", you might use the regex `mgmt-switch\d+`. + +Each line of the **device patterns** field represents a hierarchical layer within the topology map. For example, you might map a traditional network with core, distribution, and access tiers like this: + +``` +core-switch-[abcd] +dist-switch\d +access-switch\d+;oob-switch\d+ +``` + +Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those. diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md new file mode 100644 index 000000000..0e74640fa --- /dev/null +++ b/docs/additional-features/webhooks.md @@ -0,0 +1,57 @@ +# Webhooks + +A webhook defines an HTTP request that is sent to an external application when certain types of objects are created, updated, and/or deleted in NetBox. When a webhook is triggered, a POST request is sent to its configured URL. This request will include a full representation of the object being modified for consumption by the receiver. Webhooks are configured via the admin UI under Extras > Webhooks. + +An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content. + +## Requests + +The webhook POST request is structured as so (assuming `application/json` as the Content-Type): + +```no-highlight +{ + "event": "created", + "signal_received_timestamp": 1508769597, + "model": "Site" + "data": { + ... + } +} +``` + +`data` is the serialized representation of the model instance(s) from the event. The same serializers from the NetBox API are used. So an example of the payload for a Site delete event would be: + +```no-highlight +{ + "event": "deleted", + "signal_received_timestamp": 1508781858.544069, + "model": "Site", + "data": { + "asn": None, + "comments": "", + "contact_email": "", + "contact_name": "", + "contact_phone": "", + "count_circuits": 0, + "count_devices": 0, + "count_prefixes": 0, + "count_racks": 0, + "count_vlans": 0, + "custom_fields": {}, + "facility": "", + "id": 54, + "name": "test", + "physical_address": "", + "region": None, + "shipping_address": "", + "slug": "test", + "tenant": None + } +} +``` + +A request is considered successful if the response status code is any one of a list of "good" statuses defined in the [requests library](https://github.com/requests/requests/blob/205755834d34a8a6ecf2b0b5b2e9c3e6a7f4e4b6/requests/models.py#L688), otherwise the request is marked as having failed. The user may manually retry a failed request. + +## Backend Status + +Django-rq includes a status page in the admin site which can be used to view the result of processed webhooks and manually retry any failed webhooks. Access it from http://netbox.local/admin/webhook-backend-status/. diff --git a/docs/configuration/index.html b/docs/configuration/index.html new file mode 100644 index 000000000..90b4881e2 --- /dev/null +++ b/docs/configuration/index.html @@ -0,0 +1,16 @@ +# NetBox Configuration + +NetBox's local configuration is stored in `netbox/netbox/configuration.py`. An example configuration is provided at `netbox/netbox/configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. + +While NetBox has many configuration settings, only a few of them must be defined at the time of installation. + +* [Required settings](configuration/required-settings.md) +* [Optional settings](configuration/optional-settings.md) + +## Changing the Configuration + +Configuration settings may be changed at any time. However, the NetBox service must be restarted before the changes will take effect: + +```no-highlight +# sudo supervsiorctl restart netbox +``` diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index ce31cfd46..b4de6fe7b 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -1,4 +1,4 @@ -The following are optional settings which may be declared in `netbox/netbox/configuration.py`. +# Optional Configuration Settings ## ADMINS diff --git a/docs/configuration/mandatory-settings.md b/docs/configuration/required-settings.md similarity index 89% rename from docs/configuration/mandatory-settings.md rename to docs/configuration/required-settings.md index 8d96cf3a7..fb08e643b 100644 --- a/docs/configuration/mandatory-settings.md +++ b/docs/configuration/required-settings.md @@ -1,4 +1,4 @@ -NetBox's local configuration is held in `netbox/netbox/configuration.py`. An example configuration is provided at `netbox/netbox/configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. +# Required Configuration Settings ## ALLOWED_HOSTS diff --git a/docs/data-model/circuits.md b/docs/core-functionality/circuits.md similarity index 68% rename from docs/data-model/circuits.md rename to docs/core-functionality/circuits.md index 301400c38..e56c9d8c6 100644 --- a/docs/data-model/circuits.md +++ b/docs/core-functionality/circuits.md @@ -1,18 +1,16 @@ -The circuits component of NetBox deals with the management of long-haul Internet and private transit links and providers. - # Providers A provider is any entity which provides some form of connectivity. While this obviously includes carriers which offer Internet and private transit service, it might also include Internet exchange (IX) points and even organizations with whom you peer directly. -Each provider may be assigned an autonomous system number (ASN), an account number, and contact information. +Each provider may be assigned an autonomous system number (ASN), an account number, and relevant contact information. --- # Circuits -A circuit represents a single physical data link connecting two endpoints. Each circuit belongs to a provider and must be assigned a circuit ID which is unique to that provider. +A circuit represents a single _physical_ link connecting exactly two endpoints. (A circuit with more than two endpoints is a virtual circuit, which is not currently supported by NetBox.) Each circuit belongs to a provider and must be assigned a circuit ID which is unique to that provider. -### Circuit Types +## Circuit Types Circuits are classified by type. For example, you might define circuit types for: @@ -23,7 +21,7 @@ Circuits are classified by type. For example, you might define circuit types for Circuit types are fully customizable. -### Circuit Terminations +## Circuit Terminations A circuit may have one or two terminations, annotated as the "A" and "Z" sides of the circuit. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites. @@ -31,3 +29,6 @@ Each circuit termination is tied to a site, and optionally to a specific device !!! note A circuit represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit. + +!!! note + A circuit may terminate only to a physical interface. Circuits may not terminate to LAG interfaces, which are virtual interfaces: You must define each physical circuit within a service bundle separately and terminate it to its actual physical interface. diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md new file mode 100644 index 000000000..0a03efb4c --- /dev/null +++ b/docs/core-functionality/devices.md @@ -0,0 +1,120 @@ +# Device Types + +A device type represents a particular make and model of hardware that exists in the real world. Device types define the physical attributes of a device (rack height and depth) and its individual components (console, power, and network interfaces). + +Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type at the time of creation. (However, changes made to a device type will **not** apply to instances of that device type retroactively.) + +The device type model includes three flags which inform what type of components may be added to it: + +* `is_console_server`: This device type has console server ports +* `is_pdu`: This device type has power outlets +* `is_network_device`: This device type has network interfaces + +Some devices house child devices which share physical resources, like space and power, but which functional independently from one another. A common example of this is blade server chassis. Each device type is designated as one of the following: + +* A parent device (which has device bays) +* A child device (which must be installed in a device bay) +* Neither + +!!! note + This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. + +## Manufacturers + +Each device type must be assigned to a manufacturer. The model number of a device type must be unique to its manufacturer. + +## Component Templates + +Each device type is assigned a number of component templates which define the physical components within a device. These are: + +* Console ports +* Console server ports +* Power ports +* Power outlets +* Network interfaces +* Device bays (which house child devices) + +Whenever a new device is created, its components are automatically created per the templates assigned to its device type. For example, a Juniper EX4300-48T device type might have the following component templates defined: + +* One template for a console port ("Console") +* Two templates for power ports ("PSU0" and "PSU1") +* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47") +* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3") + +Once component templates have been created, every new device that you create as an instance of this type will automatically be assigned each of the components listed above. + +!!! note + Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices. + +--- + +# Devices + +Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and can be half depth or full depth. A device may have a height of 0U: These devices do not consume vertical rack space and cannot be assigned to a particular rack unit. A common example of a 0U device is a vertically-mounted PDU. + +When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 is said to be mounted in U8. This logic applies to racks with both ascending and descending unit numbering. + +A device is said to be full depth if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede airflow. + +## Device Roles + +Devices can be organized by functional roles. These roles are fully cusomizable. For example, you might create roles for core switches, distribution switches, and access switches. + +--- + +# Device Components + +There are six types of device components which comprise all of the interconnection logic with NetBox: + +* Console ports +* Console server ports +* Power ports +* Power outlets +* Network interfaces +* Device bays + +## Console + +Console ports connect only to console server ports. Console connections can be marked as either *planned* or *connected*. + +## Power + +Power ports connect only to power outlets. Power connections can be marked as either *planned* or *connected*. + +## Interfaces + +Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. Each type of connection can be classified as either *planned* or *connected*. + +Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. + +Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Fields are also provided to store an interface's MTU and MAC address. + +VLANs can be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) + +## Device Bays + +Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations, but they are included in the "Non-Racked Devices" list within the rack view. + +--- + +# Platforms + +A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15. + +The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. See the [API documentation](api/napalm-integration.md) for more information on NAPALM integration. + +The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. + +--- + +# Inventory Items + +Inventory items represent hardware components installed within a device, such as a power supply or CPU. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Like device types, each item can optionally be assigned a manufacturer. + +--- + +# Virtual Chassis + +A virtual chassis represents a set of devices which share a single control plane: a stack of switches which are managed as a single device, for example. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management. + +It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently. diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md new file mode 100644 index 000000000..df9b3b435 --- /dev/null +++ b/docs/core-functionality/ipam.md @@ -0,0 +1,93 @@ +# Aggregates + +The first step to documenting your IP space is to define its scope by creating aggregates. Aggregates establish the root of your IP address hierarchy by defining the top-level allocations that you're interested in managing. Most organizations will want to track some commonly-used private IP spaces, such as: + +* 10.0.0.0/8 (RFC 1918) +* 100.64.0.0/10 (RFC 6598) +* 172.16.0.0/20 (RFC 1918) +* 192.168.0.0/16 (RFC 1918) +* One or more /48s within fd00::/8 (IPv6 unique local addressing) + +In addition to one or more of these, you'll want to create an aggregate for each globally-routable space your organization has been allocated. These aggregates should match the allocations recorded in public WHOIS databases. + +Each IP prefix will be automatically arranged under its parent aggregate if one exists. Note that it's advised to create aggregates only for IP ranges actually allocated to your organization (or marked for private use): There is no need to define aggregates for provider-assigned space which is only used on Internet circuits, for example. + +Aggregates cannot overlap with one another: They can only exist side-by-side. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix and automatically grouped under 10.0.0.0/8. Remember, the purpose of aggregates is to establish the root of your IP addressing hierarchy. + +## Regional Internet Registries (RIRs) + +[Regional Internet registries](https://en.wikipedia.org/wiki/Regional_Internet_registry) are responsible for the allocation of globally-routable address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for internal use, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space. There also exist lower-tier registries which serve a particular geographic area. + +Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own). The RIR model includes a boolean flag which indicates whether the RIR allocates only private IP space. + +For example, suppose your organization has been allocated 104.131.0.0/16 by ARIN. It also makes use of RFC 1918 addressing internally. You would first create RIRs named ARIN and RFC 1918, then create an aggregate for each of these top-level prefixes, assigning it to its respective RIR. + +--- + +# Prefixes + +A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 192.0.2.0/24). A prefix entails only the "network portion" of an IP address: All bits in the address not covered by the mask must be zero. (In other words, a prefix cannot be a specific IP address.) + +Prefixes are automatically arranged by their parent aggregates. Additionally, each prefix can be assigned to a particular site and VRF (routing table). All prefixes not assigned to a VRF will appear in the "global" table. + +Each prefix can be assigned a status and a role. These terms are often used interchangeably so it's important to recognize the difference between them. The **status** defines a prefix's operational state. Statuses are hard-coded in NetBox and can be one of the following: + +* Container - A summary of child prefixes +* Active - Provisioned and in use +* Reserved - Designated for future use +* Deprecated - No longer in use + +On the other hand, a prefix's **role** defines its function. Role assignment is optional and roles are fully customizable. For example, you might create roles to differentiate between production and development infrastructure. + +A prefix may also be assigned to a VLAN. This association is helpful for identifying which prefixes are included when reviewing a list of VLANs. + +The prefix model include a "pool" flag. If enabled, NetBox will treat this prefix as a range (such as a NAT pool) wherein every IP address is valid and assignable. This logic is used for identifying available IP addresses within a prefix. If this flag is disabled, NetBox will assume that the first and last (broadcast) address within the prefix are unusable. + +--- + +# IP Addresses + +An IP address comprises a single host address (either IPv4 or IPv6) and its subnet mask. Its mask should match exactly how the IP address is configured on an interface in the real world. + +Like prefixes, an IP address can optionally be assigned to a VRF (otherwise, it will appear in the "global" table). IP addresses are automatically organized under parent prefixes within their respective VRFs. + +Also like prefixes, each IP address can be assigned a status and a role. Statuses are hard-coded in NetBox and include the following: + +* Active +* Reserved +* Deprecated +* DHCP + +IP address roles are also hard-coded, and can be used to indicate a special condition of the IP address. Role assignment is optional. Available roles include: + +* Loopback +* Secondary +* Anycast +* VIP +* VRRP +* HSRP +* GLBP + +An IP address can be assigned to a device or virtual machine interface, and an interface may have multiple IP addresses assigned to it. Further, each device and virtual machine may have one of its interface IPs designated as its primary IP address (one for IPv4 and one for IPv6). + +## Network Address Translation (NAT) + +An IP address can be designated as the network address translation (NAT) inside IP address for exactly one other IP address. This is useful primarily to denote a translation between public and private IP addresses. This relationship is followed in both directions: For example, if 10.0.0.1 is assigned as the inside IP for 192.0.2.1, 192.0.2.1 will be displayed as the outside IP for 10.0.0.1. + +!!! note + NetBox does not support tracking one-to-many NAT relationships (also called port address translation). This type of policy requires additional logic to model and cannot be fully represented by IP address alone. + +--- + +# Virtual Routing and Forwarding (VRF) + +A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain. Each VRF is essentially a separate routing table. VRFs are commonly used to isolate customers or organizations from one another within a network, or to route overlapping address space (e.g. multiple instances of the 10.0.0.0/8 space). + +Each VRF is assigned a unique name and route distinguisher (RD). The RD is expected to take one of the forms prescribed in [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), however its formatting is not strictly enforced. + +Each prefix and IP address may be assigned to one (and only one) VRF. If you have a prefix or IP address which exists in multiple VRFs, you will need to create a separate instance of it in NetBox for each VRF. Any IP prefix or address not assigned to a VRF is said to belong to the "global" table. + +By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This behavior can be disabled by setting the "enforce unique" flag on the VRF model. + +!!! note + Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting. diff --git a/docs/data-model/secrets.md b/docs/core-functionality/secrets.md similarity index 81% rename from docs/data-model/secrets.md rename to docs/core-functionality/secrets.md index 31c73bc92..36b232648 100644 --- a/docs/data-model/secrets.md +++ b/docs/core-functionality/secrets.md @@ -1,14 +1,12 @@ -"Secrets" are small amounts of data that must be kept confidential; for example, passwords and SNMP community strings. NetBox provides encrypted storage of secret data. - # Secrets -A secret represents a single credential or other string which must be stored securely. Each secret is assigned to a device within NetBox. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext. +A secret represents a single credential or other sensitive string of characters which must be stored securely. Each secret is assigned to a device within NetBox. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext. Each secret can also store an optional name parameter, which is not encrypted. This may be useful for storing user names. -### Roles +## Roles -Each secret is assigned a functional role which indicates what it is used for. Typical roles might include: +Each secret is assigned a functional role which indicates what it is used for. Secret roles are customizable. Typical roles might include: * Login credentials * SNMP community strings diff --git a/docs/core-functionality/services.md b/docs/core-functionality/services.md new file mode 100644 index 000000000..00d523041 --- /dev/null +++ b/docs/core-functionality/services.md @@ -0,0 +1,5 @@ +# Services + +A service represents a layer four TCP or UDP service available on a device or virtual machine. Each service includes a name, protocol, and port number; for example, "SSH (TCP/22)" or "DNS (UDP/53)." + +A service may optionally be bound to one or more specific IP addresses belonging to its parent device or VM. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.) diff --git a/docs/core-functionality/sites-and-racks.md b/docs/core-functionality/sites-and-racks.md new file mode 100644 index 000000000..bf3c473fd --- /dev/null +++ b/docs/core-functionality/sites-and-racks.md @@ -0,0 +1,49 @@ +# Sites + +How you choose to use sites will depend on the nature of your organization, but typically a site will equate to a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities. + +Each site must be assigned one of the following operational statuses: + +* Active +* Planned +* Retired + +The site model provides a facility ID field which can be used to annotate a facility ID (such as a datacenter name) associated with the site. Each site may also have an autonomous system (AS) number and time zone associated with it. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.) + +The site model also includes several fields for storing contact and address information. + +## Regions + +Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned. + +--- + +# Racks + +The rack model represents a physical two- or four-post equipment rack in which equipment is mounted. Each rack must be assigned to a site. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending or descending order. + +Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, "M204.313") whereas internally you refer to is simply as "R113." A unique serial number may also be associated with each rack. + +A rack must be designated as one of the following types: + +* 2-post frame +* 4-post frame +* 4-post cabinet +* Wall-mounted frame +* Wall-mounted cabinet + +Each rack has two faces (front and rear) on which devices can be mounted. Rail-to-rail width may be 19 or 23 inches. + +## Rack Groups + +Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. + +Each rack group must be assigned to a parent site. Hierarchical recursion of rack groups is not currently supported. + +## Rack Roles + +Each rack can optionally be assigned a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable. + +## Rack Space Reservations + +Users can reserve units within a rack for future use. Multiple non-contiguous rack units can be associated with a single reservation (but reservations cannot span multiple racks). A rack reservation may optionally designate a specific tenant. diff --git a/docs/core-functionality/tenancy.md b/docs/core-functionality/tenancy.md new file mode 100644 index 000000000..9355cc1d9 --- /dev/null +++ b/docs/core-functionality/tenancy.md @@ -0,0 +1,20 @@ +# Tenants + +A tenant represents a discrete entity for administrative purposes. Typically, tenants are used to represent individual customers or internal departments within an organization. The following objects can be assigned to tenants: + +* Sites +* Racks +* Rack reservations +* Devices +* VRFs +* Prefixes +* IP addresses +* VLANs +* Circuits +* Virtual machines + +If a prefix or IP address is not assigned to a tenant, it will appear to inherit the tenant to which its parent VRF is assigned, if any. + +### Tenant Groups + +Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional. diff --git a/docs/core-functionality/virtual-machines.md b/docs/core-functionality/virtual-machines.md new file mode 100644 index 000000000..1957c114e --- /dev/null +++ b/docs/core-functionality/virtual-machines.md @@ -0,0 +1,27 @@ +# Clusters + +A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned to a group and/or site. + +Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. + +## Cluster Types + +A cluster type represents a technology or mechanism by which a cluster is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider. + +## Cluster Groups + +Cluster groups may be created for the purpose of organizing clusters. The assignment of clusters to groups is optional. + +--- + +# Virtual Machines + +A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster. + +Like devices, each VM can be assigned a platform and have interfaces created on it. VM interfaces behave similarly to device interfaces, and can be assigned IP addresses, VLANs, and services. However, given their virtual nature, they cannot be connected to other interfaces. Unlike physical devices, VMs cannot be assigned console or power ports, device bays, or inventory items. + +The following resources can be defined for each VM: + +* vCPU count +* Memory (MB) +* Disk space (GB) diff --git a/docs/core-functionality/vlans.md b/docs/core-functionality/vlans.md new file mode 100644 index 000000000..a6babed44 --- /dev/null +++ b/docs/core-functionality/vlans.md @@ -0,0 +1,15 @@ +# VLANs + +A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site and/or VLAN group. + +Each VLAN must be assigned one of the following operational statuses: + +* Active +* Reserved +* Deprecated + +Each VLAN may also be assigned a functional role. Prefixes and VLANs share the same set of customizable roles. + +## VLAN Groups + +VLAN groups can be used to organize VLANs within NetBox. Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md deleted file mode 100644 index 5c9097569..000000000 --- a/docs/data-model/dcim.md +++ /dev/null @@ -1,124 +0,0 @@ -Data center infrastructure management (DCIM) entails all physical assets: sites, racks, devices, cabling, etc. - -# Sites - -How you choose to use sites will depend on the nature of your organization, but typically a site will equate to a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities. - -Sites can be assigned an optional facility ID to identify the actual facility housing colocated equipment, and an Autonomous System (AS) number. - -### Regions - -Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned. - ---- - -# Racks - -The rack model represents a physical two- or four-post equipment rack in which equipment is mounted. Each rack is assigned to a site. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U, but NetBox allows you to define racks of arbitrary height. Each rack has two faces (front and rear) on which devices can be mounted. - -Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, "M204.313") whereas internally you refer to is simply as "R113." The facility ID can alternatively be used to store a rack's serial number. - -The available rack types include 2- and 4-post frames, 4-post cabinet, and wall-mounted frame and cabinet. Rail-to-rail width may be 19 or 23 inches. - -### Rack Groups - -Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. - -Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not supported. - -### Rack Roles - -Each rack can optionally be assigned a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable. - -### Rack Space Reservations - -Users can reserve units within a rack for future use. Multiple non-contiguous rack units can be associated with a single reservation (but reservations cannot span multiple racks). - ---- - -# Device Types - -A device type represents a particular hardware model that exists in the real world. Device types describe the physical attributes of a device (rack height and depth), its class (e.g. console server, PDU, etc.), and its individual components (console, power, and data). - -Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type. - -A device type can be a parent, child, or neither. Parent devices house child devices in device bays. This relationship is used to model things like blade servers, where child devices function independently but share physical resources like rack space and power. Note that this is **not** intended to model chassis-based devices, wherein child members share a common control plane. - -### Manufacturers - -Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. The model number of a device type must be unique to its manufacturer. - -### Component Templates - -Each device type is assigned a number of component templates which define the physical interfaces a device has. These are: - -* Console ports -* Console server ports -* Power ports -* Power outlets -* Interfaces -* Device bays - -Whenever a new device is created, it is automatically assigned components per the templates assigned to its device type. For example, a Juniper EX4300-48T device type might have the following component templates: - -* One template for a console port ("Console") -* Two templates for power ports ("PSU0" and "PSU1") -* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47") -* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3") - -Once component templates have been created, every new device that you create as an instance of this type will automatically be assigned each of the components listed above. - -!!! note - Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components of existing devices individually. - ---- - -# Devices - -Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and depth. 0U devices which can be installed in a rack but don't consume vertical rack space (such as a vertically-mounted power distribution unit) can also be defined. - -When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 shows as being mounted in U8. This logic applies to racks with both ascending and descending unit numbering. - -A device is said to be "full depth" if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede air flow. - -### Roles - -NetBox allows for the definition of arbitrary device roles by which devices can be organized. For example, you might create roles for core switches, distribution switches, and access switches. In the interest of simplicity, a device can belong to only one role. - -### Platforms - -A device's platform is used to denote the type of software running on it. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15. - -The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. - -### Inventory Items - -Inventory items represent hardware components installed within a device, such as a power supply or CPU. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each item can optionally be assigned a manufacturer. - -!!! note - Prior to version 2.0, inventory items were called modules. - -### Components - -There are six types of device components which comprise all of the interconnection logic with NetBox: - -* Console ports -* Console server ports -* Power ports -* Power outlets -* Interfaces -* Device bays - -Console ports connect only to console server ports, and power ports connect only to power outlets. Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. (The relationship between two interfaces is actually represented in the database by an InterfaceConnection object, but this is transparent to the user.) Each type of connection can be classified as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed. - -Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description. - -Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view. - ---- - -# Virtual Chassis - -A virtual chassis represents a set of devices which share a single control plane: for example, a stack of switches which are managed as a single device. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management. - -It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently. diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md deleted file mode 100644 index 6b7e0d2d9..000000000 --- a/docs/data-model/extras.md +++ /dev/null @@ -1,198 +0,0 @@ -This section entails features of NetBox which are not crucial to its primary functions, but provide additional value. - -# Tags - -Tags are freeform labels which can be assigned to a variety of objects in NetBox. Tags can be used to categorize and filter objects in addition to built-in and custom fields. Each tag consists of a text label, as well as an auto-generated URL-friendly slug value. Objects can be filtered by the tags assigned to them. Tags can be used across different object types. - -# Custom Fields - -Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. - -However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data. - -Custom fields must be created through the admin UI under Extras > Custom Fields. To create a new custom field, select the object(s) to which you want it to apply, and the type of field it will be. NetBox supports six field types: - -* Free-form text (up to 255 characters) -* Integer -* Boolean (true/false) -* Date -* URL -* Selection - -Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form. - -Marking the field as required will require the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.) - -When creating a selection field, you should create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically. - -## Using Custom Fields - -When a single object is edited, the form will include any custom fields which have been defined for the object type. These fields are included in the "Custom Fields" panel. On the backend, each custom field value is saved separately from the core object as an independent database call, so it's best to avoid adding too many custom fields per object. - -When editing multiple objects, custom field values are saved in bulk. There is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field. - -# Contextual Configuration Data - -Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. (For example, you might want to associate a set of syslog servers for all devices at a particular site.) Context data enables the association of arbitrary data (expressed in JSON format) to devices and virtual machines grouped by region, site, role, platform, and/or tenancy. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. - -# Export Templates - -NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. - -Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. - -Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: - -``` -{% for rack in queryset %} -Rack: {{ rack.name }} -Site: {{ rack.site.name }} -Height: {{ rack.u_height }}U -{% endfor %} -``` - -To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. - -A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. - -## Example - -Here's an example device export template that will generate a simple Nagios configuration from a list of devices. - -``` -{% for device in queryset %}{% if device.status and device.primary_ip %}define host{ - use generic-switch - host_name {{ device.name }} - address {{ device.primary_ip.address.ip }} -} -{% endif %}{% endfor %} -``` - -The generated output will look something like this: - -``` -define host{ - use generic-switch - host_name switch1 - address 192.0.2.1 -} -define host{ - use generic-switch - host_name switch2 - address 192.0.2.2 -} -define host{ - use generic-switch - host_name switch3 - address 192.0.2.3 -} -``` - -# Graphs - -NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters: - -* **Type:** Site, provider, or interface. This determines in which view the graph will be displayed. -* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name. -* **Name:** The title to display above the graph. -* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. -* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`. - -## Examples - -You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this: - -``` -https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m -``` - -You can define several graphs to provide multiple contexts when viewing an object. For example: - -``` -https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m -https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h -https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m -``` - -# Topology Maps - -NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps. - -Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend infrastructure). - -To define the scope of a topology map, decide which devices you want to include. The map will only include interface connections with both points terminated on an included device. Specify the devices to include in the **device patterns** field by entering a list of [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) matching device names. For example, if you wanted to include "mgmt-switch1" through "mgmt-switch99", you might use the regex `mgmt-switch\d+`. - -Each line of the **device patterns** field represents a hierarchical layer within the topology map. For example, you might map a traditional network with core, distribution, and access tiers like this: - -``` -core-switch-[abcd] -dist-switch\d -access-switch\d+;oob-switch\d+ -``` - -Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those. - -# Image Attachments - -Certain objects within NetBox (namely sites, racks, and devices) can have photos or other images attached to them. (Note that _only_ image files are supported.) Each attachment may optionally be assigned a name; if omitted, the attachment will be represented by its file name. - -!!! note - If you experience a server error while attempting to upload an image attachment, verify that the system user NetBox runs as has write permission to the media root directory (`netbox/media/`). - -# Webhooks - -A webhook defines an HTTP request that is sent to an external application when certain types of objects are created, updated, and/or deleted in NetBox. When a webhook is triggered, a POST request is sent to its configured URL. This request will include a full representation of the object being modified for consumption by the receiver. Webhooks are configured via the admin UI under Extras > Webhooks. - -An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content. - -## Requests - -The webhook POST request is structured as so (assuming `application/json` as the Content-Type): - -```no-highlight -{ - "event": "created", - "signal_received_timestamp": 1508769597, - "model": "Site" - "data": { - ... - } -} -``` - -`data` is the serialized representation of the model instance(s) from the event. The same serializers from the NetBox API are used. So an example of the payload for a Site delete event would be: - -```no-highlight -{ - "event": "deleted", - "signal_received_timestamp": 1508781858.544069, - "model": "Site", - "data": { - "asn": None, - "comments": "", - "contact_email": "", - "contact_name": "", - "contact_phone": "", - "count_circuits": 0, - "count_devices": 0, - "count_prefixes": 0, - "count_racks": 0, - "count_vlans": 0, - "custom_fields": {}, - "facility": "", - "id": 54, - "name": "test", - "physical_address": "", - "region": None, - "shipping_address": "", - "slug": "test", - "tenant": None - } -} -``` - -A request is considered successful if the response status code is any one of a list of "good" statuses defined in the [requests library](https://github.com/requests/requests/blob/205755834d34a8a6ecf2b0b5b2e9c3e6a7f4e4b6/requests/models.py#L688), otherwise the request is marked as having failed. The user may manually retry a failed request. - -## Backend Status - -Django-rq includes a status page in the admin site which can be used to view the result of processed webhooks and manually retry any failed webhooks. Access it from http://netbox.local/admin/webhook-backend-status/. diff --git a/docs/data-model/ipam.md b/docs/data-model/ipam.md deleted file mode 100644 index 6848804fd..000000000 --- a/docs/data-model/ipam.md +++ /dev/null @@ -1,99 +0,0 @@ -IP address management (IPAM) entails the allocation of IP networks, addresses, and related numeric resources. - -# VRFs - -A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain within a network. Each VRF is essentially a separate routing table: the same IP prefix or address can exist in multiple VRFs. VRFs are commonly used to isolate customers or organizations from one another within a network. - -Each VRF is assigned a name and a unique route distinguisher (RD). VRFs are an optional feature of NetBox: Any IP prefix or address not assigned to a VRF is said to belong to the "global" table. - -!!! note - By default, NetBox allows for overlapping IP space both in the global table and within each VRF. Unique space enforcement can be toggled per-VRF as well as in the global table using the `ENFORCE_GLOBAL_UNIQUE` configuration setting. - ---- - -# Aggregates - -IP address space is organized as a hierarchy, with more-specific (smaller) prefixes arranged as child nodes under less-specific (larger) prefixes. For example: - -* 10.0.0.0/8 - * 10.1.0.0/16 - * 10.1.2.0/24 - -The root of the IPv4 hierarchy is 0.0.0.0/0, which encompasses all possible IPv4 addresses (and similarly, ::/0 for IPv6). However, even the largest organizations use only a small fraction of the global address space. Therefore, it makes sense to track in NetBox only the address space which is of interest to your organization. - -Aggregates serve as arbitrary top-level nodes in the IP space hierarchy. They allow you to easily construct your IP scheme without any clutter of unused address space. For instance, most organizations utilize some portion of the private IPv4 space set aside in RFC 1918. So, you might define three aggregates for this space: - -* 10.0.0.0/8 -* 172.16.0.0/12 -* 192.168.0.0/16 - -Additionally, you might define an aggregate for each large swath of public IPv4 space your organization uses. You'd also create aggregates for both globally routable and unique local IPv6 space. (Most organizations will not have a need to track IPv6 link local space.) - -Prefixes you create in NetBox (discussed below) will be automatically organized under their respective aggregates. Any space within an aggregate which is not covered by an existing prefix will be annotated as available for allocation. Total utilization for each aggregate is displayed in the aggregates list. - -Aggregates cannot overlap with one another; they can only exist in parallel. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix and automatically grouped under 10.0.0.0/8. - -### RIRs - -Regional Internet Registries (RIRs) are responsible for the allocation of global address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for private or internal use only, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space. - -Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own). Each RIR can be annotated as representing only private space. - ---- - -# Prefixes - -A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 192.0.2.0/24). A prefix entails only the "network portion" of an IP address; all bits in the address not covered by the mask must be zero. - -Each prefix may be assigned to one VRF; prefixes not assigned to a VRF are assigned to the "global" table. Prefixes are also organized under their respective aggregates, irrespective of VRF assignment. - -A prefix may optionally be assigned to one VLAN; a VLAN may have multiple prefixes assigned to it. Each prefix may also be assigned a short description. - -### Statuses - -Each prefix is assigned an operational status. This is one of the following: - -* Container - A summary of child prefixes -* Active - Provisioned and in use -* Reserved - Designated for future use -* Deprecated - No longer in use - -### Roles - -Whereas a status describes a prefix's operational state, a role describes its function. For example, roles might include: - -* Access segment -* Infrastructure -* NAT -* Lab -* Out-of-band - -Role assignment is optional and roles are fully customizable. - ---- - -# IP Addresses - -An IP address comprises a single address (either IPv4 or IPv6) and its subnet mask. Its mask should match exactly how the IP address is configured on an interface in the real world. - -Like prefixes, an IP address can optionally be assigned to a VRF (or it will appear in the "global" table). IP addresses are automatically organized under parent prefixes within their respective VRFs. Each IP address can also be assigned a short description. - -An IP address can be assigned to a device's interface; an interface may have multiple IP addresses assigned to it. Further, each device may have one of its interface IPs designated as its primary IP address (for both IPv4 and IPv6). - -One IP address can be designated as the network address translation (NAT) IP address for exactly one other IP address. This is useful primarily to denote the public address for a private internal IP. Tracking one-to-many NAT (or PAT) assignments is not supported. - ---- - -# VLANs - -A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site and/or VLAN group. Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role, and may include a short description. - -### VLAN Groups - -VLAN groups can be employed for administrative organization within NetBox. Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs, including within a site. - ---- - -# Services - -A service represents a TCP or UDP service available on a device or virtual machine. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to its parent. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.) diff --git a/docs/data-model/tenancy.md b/docs/data-model/tenancy.md deleted file mode 100644 index eb5fda168..000000000 --- a/docs/data-model/tenancy.md +++ /dev/null @@ -1,20 +0,0 @@ -NetBox supports the assignment of resources to tenant organizations. Typically, these are used to represent individual customers of or internal departments within the organization using NetBox. - -# Tenants - -A tenant represents a discrete organization. The following objects can be assigned to tenants: - -* Sites -* Racks -* Devices -* VRFs -* Prefixes -* IP addresses -* VLANs -* Circuits - -If a prefix or IP address is not assigned to a tenant, it will appear to inherit the tenant to which its parent VRF is assigned, if any. - -### Tenant Groups - -Tenants can be grouped by type. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional. diff --git a/docs/data-model/virtualization.md b/docs/data-model/virtualization.md deleted file mode 100644 index d49f7b323..000000000 --- a/docs/data-model/virtualization.md +++ /dev/null @@ -1,29 +0,0 @@ -NetBox supports the definition of virtual machines arranged in clusters. A cluster can optionally have physical host devices associated with it. - -# Clusters - -A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned an organizational group. - -Physical devices (from NetBox's DCIM component) may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. - -### Cluster Types - -A cluster type represents a technology or mechanism by which a cluster is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider. - -### Cluster Groups - -Cluster groups may be created for the purpose of organizing clusters. - ---- - -# Virtual Machines - -A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster. - -Like devices, each VM can have interfaces created on it. These behave similarly to device interfaces, and can be assigned IP addresses, however given their virtual nature they cannot be connected to other interfaces. VMs can also be assigned layer four services. Unlike physical devices, VMs cannot be assigned console or power ports, or device bays. - -The following resources can be defined for each VM: - -* vCPU count -* Memory (MB) -* Disk space (GB) diff --git a/docs/development/index.md b/docs/development/index.md new file mode 100644 index 000000000..f95e92a3e --- /dev/null +++ b/docs/development/index.md @@ -0,0 +1,10 @@ +# NetBox Structure + +NetBox components are arranged into functional subsections called _apps_ (a carryover from Django verancular). Each app holds the models, views, and templates relevant to a particular function: + +* `circuits`: Communications circuits and providers (not to be confused with power circuits) +* `dcim`: Datacenter infrastructure management (sites, racks, and devices) +* `ipam`: IP address management (VRFs, prefixes, IP addresses, and VLANs) +* `secrets`: Encrypted storage of sensitive data (e.g. login credentials) +* `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned +* `virtualization`: Virtual machines and clusters diff --git a/docs/index.md b/docs/index.md index 2e8c8b3dc..a9cbb5af1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,13 +42,15 @@ When given a choice between a relatively simple [80% solution](https://en.wikipe NetBox is built on the [Django](https://djangoproject.com/) Python framework and utilizes a [PostgreSQL](https://www.postgresql.org/) database. It runs as a WSGI service behind your choice of HTTP server. -| Function | Component | -|--------------|-------------------| -| HTTP Service | nginx or Apache | -| WSGI Service | gunicorn or uWSGI | -| Application | Django/Python | -| Database | PostgreSQL 9.4+ | +| Function | Component | +|--------------------|-------------------| +| HTTP service | nginx or Apache | +| WSGI service | gunicorn or uWSGI | +| Application | Django/Python | +| Database | PostgreSQL 9.4+ | +| Task queuing | Redis/django-rq | +| Live device access | NAPALM | # Getting Started -See the [installation guide](installation/postgresql.md) for help getting NetBox up and running quickly. +See the [installation guide](installation/index.md) for help getting NetBox up and running quickly. diff --git a/docs/installation/postgresql.md b/docs/installation/1-postgresql.md similarity index 100% rename from docs/installation/postgresql.md rename to docs/installation/1-postgresql.md diff --git a/docs/installation/netbox.md b/docs/installation/2-netbox.md similarity index 100% rename from docs/installation/netbox.md rename to docs/installation/2-netbox.md diff --git a/docs/installation/web-server.md b/docs/installation/3-http-daemon.md similarity index 100% rename from docs/installation/web-server.md rename to docs/installation/3-http-daemon.md diff --git a/docs/installation/ldap.md b/docs/installation/4-ldap.md similarity index 100% rename from docs/installation/ldap.md rename to docs/installation/4-ldap.md diff --git a/docs/installation/index.md b/docs/installation/index.md new file mode 100644 index 000000000..d0bf121cb --- /dev/null +++ b/docs/installation/index.md @@ -0,0 +1,14 @@ +# Installation + +The following sections detail how to set up a new instance of NetBox: + +1. [PostgreSQL database](installation/1-postgresql.md) +2. [NetBox components](installation/2-netbox.md) +3. [HTTP dameon](installation/3-http-daemon.md) +4. [LDAP authentication](installation/4-ldap.md) (optional) + +# Upgrading + +If you are upgrading from an existing installation, please consult the [upgrading guide](installation/upgrading.md). + +NetBox v2.5 and later requires Python 3. Please see the instruction for [migrating to Python 3](installation/migrating-to-python3.md) if you are still using Python 2. diff --git a/mkdocs.yml b/mkdocs.yml index 430a05ac7..44a5460cc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,35 +1,48 @@ site_name: NetBox +repo_url: https://github.com/digitalocean/netbox pages: - - 'Introduction': 'index.md' - - 'Installation': - - 'PostgreSQL': 'installation/postgresql.md' - - 'NetBox': 'installation/netbox.md' - - 'Web Server': 'installation/web-server.md' - - 'LDAP (Optional)': 'installation/ldap.md' - - 'Upgrading': 'installation/upgrading.md' - - 'Migrating to Python3': 'installation/migrating-to-python3.md' - - 'Configuration': - - 'Mandatory Settings': 'configuration/mandatory-settings.md' - - 'Optional Settings': 'configuration/optional-settings.md' - - 'Data Model': - - 'Circuits': 'data-model/circuits.md' - - 'DCIM': 'data-model/dcim.md' - - 'IPAM': 'data-model/ipam.md' - - 'Secrets': 'data-model/secrets.md' - - 'Tenancy': 'data-model/tenancy.md' - - 'Virtualization': 'data-model/virtualization.md' - - 'Extras': 'data-model/extras.md' - - 'API': - - 'Overview': 'api/overview.md' - - 'Authentication': 'api/authentication.md' - - 'Working with Secrets': 'api/working-with-secrets.md' - - 'Examples': 'api/examples.md' - - 'Miscellaneous': - - 'Reports': 'miscellaneous/reports.md' - - 'Shell': 'miscellaneous/shell.md' - - 'Development': - - 'Utility Views': 'development/utility-views.md' + - Introduction: 'index.md' + - Installation: + - Installing NetBox: 'installation/index.md' + - 1. PostgreSQL: 'installation/1-postgresql.md' + - 2. NetBox: 'installation/2-netbox.md' + - 3. Web Server: 'installation/3-web-server.md' + - 4. LDAP (Optional): 'installation/4-ldap.md' + - Upgrading NetBox: 'installation/upgrading.md' + - Migrating to Python3: 'installation/migrating-to-python3.md' + - Configuration: + - Configuring NetBox: 'configuration/index.md' + - Required Settings: 'configuration/required-settings.md' + - Optional Settings: 'configuration/optional-settings.md' + - Core Functionality: + - IP Address Management: 'core-functionality/ipam.md' + - VLANs: 'core-functionality/vlans.md' + - Sites and Racks: 'core-functionality/sites-and-racks.md' + - Devices: 'core-functionality/devices.md' + - Virtual Machines: 'core-functionality/virtual-machines.md' + - Services: 'core-functionality/services.md' + - Circuits: 'core-functionality/circuits.md' + - Secrets: 'core-functionality/secrets.md' + - Tenancy: 'core-functionality/tenancy.md' + - Additional Features: + - Tags: 'additional-features/tags.md' + - Custom Fields: 'additional-features/custom-fields.md' + - Context Data: 'additional-features/context-data.md' + - Export Templates: 'additional-features/export-templates.md' + - Graphs: 'additional-features/graphs.md' + - Topology Maps: 'additional-features/topology-maps.md' + - Reports: 'additional-features/reports.md' + - Webhooks: 'additional-features/webhooks.md' + - Change Logging: 'additional-features/change-logging.md' + - NetBox Shell: 'additional-features/netbox-shell.md' + - API: + - Overview: 'api/overview.md' + - Authentication: 'api/authentication.md' + - Working with Secrets: 'api/working-with-secrets.md' + - Examples: 'api/examples.md' + - Development: + - Utility Views: 'development/utility-views.md' markdown_extensions: - admonition: From d0349f00ada347a8ce364268be75ce6f362477d0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Jul 2018 09:36:05 -0400 Subject: [PATCH 124/159] Misc cleanup --- docs/additional-features/change-logging.md | 0 docs/configuration/{index.html => index.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/additional-features/change-logging.md rename docs/configuration/{index.html => index.md} (100%) diff --git a/docs/additional-features/change-logging.md b/docs/additional-features/change-logging.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/configuration/index.html b/docs/configuration/index.md similarity index 100% rename from docs/configuration/index.html rename to docs/configuration/index.md From 688c421c55d7c773975e82b519c2ce9a8a28f78d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Jul 2018 17:40:08 -0400 Subject: [PATCH 125/159] Tweaked Python3 guide to be less disruptive --- docs/installation/migrating-to-python3.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/installation/migrating-to-python3.md b/docs/installation/migrating-to-python3.md index d66544e12..b2efadea1 100644 --- a/docs/installation/migrating-to-python3.md +++ b/docs/installation/migrating-to-python3.md @@ -3,34 +3,36 @@ !!! warning Beginning with v2.5, NetBox will no longer support Python 2. It is strongly recommended that you upgrade to Python 3 as soon as possible. -Remove Python 2 packages +## Ubuntu + +Remove the Python2 version of gunicorn: ```no-highlight -# apt-get remove --purge -y python-dev python-pip +# pip uninstall -y gunicorn ``` -Install Python 3 packages +Install Python3 and pip3, Python's package management tool: ```no-highlight -# apt-get install -y python3 python3-dev python3-pip +# apt-get update +# apt-get install -y python3 python3-dev python3-setuptools +# easy_install3 pip ``` -Install Python Packages +Install the Python3 packages required by NetBox: ```no-highlight -# cd /opt/netbox # pip3 install -r requirements.txt ``` -Gunicorn Update +Replace gunicorn with the Python3 version: ```no-highlight -# pip uninstall gunicorn # pip3 install gunicorn ``` -Re-install LDAP Module (optional if using LDAP for auth) +If using LDAP authentication, install the `django-auth-ldap` package: ```no-highlight -sudo pip3 install django-auth-ldap +# pip3 install django-auth-ldap ``` From 9eb433a4eff17dc7b27c192463ccb24ca2de2131 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Jul 2018 15:26:01 -0400 Subject: [PATCH 126/159] Added instructions for replicating NetBox --- docs/administration/replicating-netbox.md | 53 +++++++++++++++++++++++ mkdocs.yml | 3 +- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 docs/administration/replicating-netbox.md diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md new file mode 100644 index 000000000..938ae9a58 --- /dev/null +++ b/docs/administration/replicating-netbox.md @@ -0,0 +1,53 @@ +# Replicating the Database + +NetBox uses [PostgreSQL](https://www.postgresql.org/) for its database, so general PostgreSQL best practices will apply to NetBox. You can dump and restore the database using the `pg_dump` and `psql` utilities, respectively. + +!!! note + The examples below assume that your database is named `netbox`. + +## Export the Database + +```no-highlight +pg_dump netbox > netbox.sql +``` + +## Load an Exported Database + +!!! warning + This will destroy and replace any existing instance of the database. + +```no-highlight +psql -c 'drop database netbox' +psql -c 'create database netbox' +psql netbox < netbox.sql +``` + +Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway. + +## Export the Database Schema + +If you want to export only the database schema, and not the data itself (e.g. for development reference), do the following: + +```no-highlight +pg_dump -s netbox > netbox_schema.sql +``` + +# Replicating Media + +NetBox stored uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files. + +## Archive the Media Directory + +Execute the following command from the root of the NetBox installation path (typically `/opt/netbox`): + +```no-highlight +tar -czf netbox_media.tar.gz netbox/media/ +``` + +## Restore the Media Directory + +To extract the saved archive into a new installation, run the following from the installation root: + +```no-highlight +tar -xf netbox_media.tar.gz +``` diff --git a/mkdocs.yml b/mkdocs.yml index 44a5460cc..44a08c30d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,7 +35,8 @@ pages: - Reports: 'additional-features/reports.md' - Webhooks: 'additional-features/webhooks.md' - Change Logging: 'additional-features/change-logging.md' - - NetBox Shell: 'additional-features/netbox-shell.md' + - Administration: + - Replicating NetBox: 'administration/replicating-netbox.md' - API: - Overview: 'api/overview.md' - Authentication: 'api/authentication.md' From 0907a6ce5bbec7157914c00ca38c312cc9433b58 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Jul 2018 19:44:04 -0400 Subject: [PATCH 127/159] Convert CustomFieldModel from object to abstract model --- netbox/extras/models.py | 5 ++++- netbox/virtualization/migrations/0001_virtualization.py | 3 --- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 512c7906b..8b90bd5c2 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -105,7 +105,10 @@ class Webhook(models.Model): # Custom fields # -class CustomFieldModel(object): +class CustomFieldModel(models.Model): + + class Meta: + abstract = True def cf(self): """ diff --git a/netbox/virtualization/migrations/0001_virtualization.py b/netbox/virtualization/migrations/0001_virtualization.py index cb553cf95..a5c7535cf 100644 --- a/netbox/virtualization/migrations/0001_virtualization.py +++ b/netbox/virtualization/migrations/0001_virtualization.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals from django.db import migrations, models import django.db.models.deletion -import extras.models class Migration(migrations.Migration): @@ -30,7 +29,6 @@ class Migration(migrations.Migration): options={ 'ordering': ['name'], }, - bases=(models.Model, extras.models.CustomFieldModel), ), migrations.CreateModel( name='ClusterGroup', @@ -74,7 +72,6 @@ class Migration(migrations.Migration): options={ 'ordering': ['name'], }, - bases=(models.Model, extras.models.CustomFieldModel), ), migrations.AddField( model_name='cluster', From 97b6038be2fc6e5bfb7307d5991cf5ba7e0c2c5a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Jul 2018 19:56:04 -0400 Subject: [PATCH 128/159] Cleaned up custom fields panel template --- netbox/templates/circuits/circuit.html | 2 +- netbox/templates/circuits/provider.html | 2 +- netbox/templates/dcim/device.html | 2 +- netbox/templates/dcim/devicetype.html | 2 +- netbox/templates/dcim/rack.html | 2 +- netbox/templates/dcim/site.html | 2 +- netbox/templates/inc/custom_fields_panel.html | 58 ++++++++++--------- netbox/templates/ipam/aggregate.html | 2 +- netbox/templates/ipam/ipaddress.html | 2 +- netbox/templates/ipam/prefix.html | 2 +- netbox/templates/ipam/service.html | 3 +- netbox/templates/ipam/vlan.html | 2 +- netbox/templates/ipam/vrf.html | 2 +- netbox/templates/secrets/secret.html | 2 +- netbox/templates/tenancy/tenant.html | 2 +- netbox/templates/virtualization/cluster.html | 2 +- .../virtualization/virtualmachine.html | 2 +- 17 files changed, 47 insertions(+), 44 deletions(-) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index d3fdfb306..5c86cb24e 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -125,7 +125,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=circuit.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=circuit %} {% include 'extras/inc/tags_panel.html' with tags=circuit.tags.all url='circuits:circuit_list' %}
    diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 56b5322d8..4ec9adee1 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -123,7 +123,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=provider.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=provider %} {% include 'extras/inc/tags_panel.html' with tags=provider.tags.all url='circuits:provider_list' %}
    diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 03b55eb25..a915d0c7f 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -282,7 +282,7 @@ {% endif %}
    - {% include 'inc/custom_fields_panel.html' with custom_fields=device.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=device %} {% include 'extras/inc/tags_panel.html' with tags=device.tags.all url='dcim:device_list' %}
    diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 26d9077f6..652c291e6 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -158,7 +158,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=devicetype.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=devicetype %} {% include 'extras/inc/tags_panel.html' with tags=devicetype.tags.all url='dcim:devicetype_list' %}
    diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index bfb512d03..aaebe02da 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -158,7 +158,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=rack.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=rack %} {% include 'extras/inc/tags_panel.html' with tags=rack.tags.all url='dcim:rack_list' %}
    diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 7a339401d..e70f98e71 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -219,7 +219,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=site.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=site %} {% include 'extras/inc/tags_panel.html' with tags=site.tags.all url='dcim:site_list' %}
    diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index 2ed10ee4d..3b82b8ee6 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -1,29 +1,31 @@ -{% if custom_fields %} -
    -
    - Custom Fields +{% with custom_fields=obj.custom_fields %} + {% if custom_fields %} +
    +
    + Custom Fields +
    + + {% for field, value in custom_fields.items %} + + + + + {% endfor %} +
    {{ field }} + {% if field.type == 300 and value == True %} + + {% elif field.type == 300 and value == False %} + + {% elif field.type == 500 and value %} + {{ value|truncatechars:70 }} + {% elif field.type == 200 or value %} + {{ value }} + {% elif field.required %} + Not defined + {% else %} + N/A + {% endif %} +
    - - {% for field, value in custom_fields.items %} - - - - - {% endfor %} -
    {{ field }} - {% if field.type == 300 and value == True %} - - {% elif field.type == 300 and value == False %} - - {% elif field.type == 500 and value %} - {{ value|truncatechars:70 }} - {% elif field.type == 200 or value %} - {{ value }} - {% elif field.required %} - Not defined - {% else %} - N/A - {% endif %} -
    -
    -{% endif %} + {% endif %} +{% endwith %} diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index ace3d87c5..129d9d071 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -97,7 +97,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=aggregate.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=aggregate %} {% include 'extras/inc/tags_panel.html' with tags=aggregate.tags.all url='ipam:aggregate_list' %}
    diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 663a6e55f..a8501c8a0 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -148,7 +148,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=ipaddress.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=ipaddress %} {% include 'extras/inc/tags_panel.html' with tags=ipaddress.tags.all url='ipam:ipaddress_list' %}
    diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index bd6f61c48..f621a0130 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -184,7 +184,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=prefix.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=prefix %} {% include 'extras/inc/tags_panel.html' with tags=prefix.tags.all url='ipam:prefix_list' %}
    diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 0407cee87..a0f1e57a5 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -80,7 +80,8 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=service.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=service %} + {% include 'extras/inc/tags_panel.html' with tags=service.tags.all url='ipam:service_list' %}
    {% endblock %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 9029ee5d9..73e87149b 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -139,7 +139,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=vlan.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=vlan %} {% include 'extras/inc/tags_panel.html' with tags=vlan.tags.all url='ipam:vlan_list' %}
    diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index d577c4ed0..f75b8c7d1 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -92,7 +92,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=vrf.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=vrf %} {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %}
    diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 5b670f2c8..940a87157 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -69,7 +69,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=secret.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=secret %}
    {% if secret|decryptable_by:request.user %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index bf92a16c0..6f2131a51 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -81,7 +81,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=tenant.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=tenant %} {% include 'extras/inc/tags_panel.html' with tags=tenant.tags.all url='tenancy:tenant_list' %}
    diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index e09dda127..69ed4e212 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -93,7 +93,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=cluster.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=cluster %} {% include 'extras/inc/tags_panel.html' with tags=cluster.tags.all url='virtualization:cluster_list' %}
    diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 9c6c8b0e3..97502af5c 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -137,7 +137,7 @@
    - {% include 'inc/custom_fields_panel.html' with custom_fields=virtualmachine.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=virtualmachine %} {% include 'extras/inc/tags_panel.html' with tags=virtualmachine.tags.all url='virtualization:virtualmachine_list' %}
    From beac676a6e78151e1368d3822dfbcb4a3014dd99 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Jul 2018 21:18:10 -0400 Subject: [PATCH 129/159] Cleaned up BulkEditView and BulkDeleteView --- netbox/circuits/views.py | 7 +-- netbox/dcim/views.py | 78 +++++++++++++--------------------- netbox/extras/views.py | 2 - netbox/ipam/views.py | 20 ++------- netbox/secrets/views.py | 3 -- netbox/tenancy/views.py | 3 -- netbox/utilities/views.py | 63 ++++++++++++++------------- netbox/virtualization/views.py | 25 +++++------ 8 files changed, 76 insertions(+), 125 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 1191c12b0..e116e4556 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -76,7 +76,7 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_provider' - cls = Provider + queryset = Provider.objects.all() filter = filters.ProviderFilter table = tables.ProviderTable form = forms.ProviderBulkEditForm @@ -85,7 +85,7 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_provider' - cls = Provider + queryset = Provider.objects.all() filter = filters.ProviderFilter table = tables.ProviderTable default_return_url = 'circuits:provider_list' @@ -121,7 +121,6 @@ class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView): class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuittype' - cls = CircuitType queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable default_return_url = 'circuits:circuittype_list' @@ -193,7 +192,6 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_circuit' - cls = Circuit queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter table = tables.CircuitTable @@ -203,7 +201,6 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuit' - cls = Circuit queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter table = tables.CircuitTable diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4cd302084..eb7f71a25 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -151,7 +151,6 @@ class RegionBulkImportView(PermissionRequiredMixin, BulkImportView): class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_region' - cls = Region queryset = Region.objects.annotate(site_count=Count('sites')) filter = filters.RegionFilter table = tables.RegionTable @@ -223,7 +222,6 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_site' - cls = Site queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter table = tables.SiteTable @@ -263,7 +261,6 @@ class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView): class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackgroup' - cls = RackGroup queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) filter = filters.RackGroupFilter table = tables.RackGroupTable @@ -300,7 +297,6 @@ class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView): class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackrole' - cls = RackRole queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable default_return_url = 'dcim:rackrole_list' @@ -415,7 +411,6 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView): class RackBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rack' - cls = Rack queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.RackFilter table = tables.RackTable @@ -425,7 +420,6 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rack' - cls = Rack queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.RackFilter table = tables.RackTable @@ -473,7 +467,6 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rackreservation' - cls = RackReservation queryset = RackReservation.objects.select_related('rack', 'user') filter = filters.RackReservationFilter table = tables.RackReservationTable @@ -483,7 +476,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackreservation' - cls = RackReservation + queryset = RackReservation.objects.select_related('rack', 'user') filter = filters.RackReservationFilter table = tables.RackReservationTable default_return_url = 'dcim:rackreservation_list' @@ -522,7 +515,6 @@ class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView): class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_manufacturer' - cls = Manufacturer queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) table = tables.ManufacturerTable default_return_url = 'dcim:manufacturer_list' @@ -619,7 +611,6 @@ class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_devicetype' - cls = DeviceType queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter table = tables.DeviceTypeTable @@ -629,7 +620,6 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicetype' - cls = DeviceType queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter table = tables.DeviceTypeTable @@ -652,10 +642,8 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleporttemplate' + queryset = ConsolePortTemplate.objects.all() parent_model = DeviceType - parent_field = 'device_type' - cls = ConsolePortTemplate - parent_cls = DeviceType table = tables.ConsolePortTemplateTable @@ -671,8 +659,8 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverporttemplate' - cls = ConsoleServerPortTemplate - parent_cls = DeviceType + queryset = ConsoleServerPortTemplate.objects.all() + parent_model = DeviceType table = tables.ConsoleServerPortTemplateTable @@ -688,8 +676,8 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerporttemplate' - cls = PowerPortTemplate - parent_cls = DeviceType + queryset = PowerPortTemplate.objects.all() + parent_model = DeviceType table = tables.PowerPortTemplateTable @@ -705,8 +693,8 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlettemplate' - cls = PowerOutletTemplate - parent_cls = DeviceType + queryset = PowerOutletTemplate.objects.all() + parent_model = DeviceType table = tables.PowerOutletTemplateTable @@ -722,16 +710,16 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interfacetemplate' - cls = InterfaceTemplate - parent_cls = DeviceType + queryset = InterfaceTemplate.objects.all() + parent_model = DeviceType table = tables.InterfaceTemplateTable form = forms.InterfaceTemplateBulkEditForm class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interfacetemplate' - cls = InterfaceTemplate - parent_cls = DeviceType + queryset = InterfaceTemplate.objects.all() + parent_model = DeviceType table = tables.InterfaceTemplateTable @@ -747,8 +735,8 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebaytemplate' - cls = DeviceBayTemplate - parent_cls = DeviceType + queryset = DeviceBayTemplate.objects.all() + parent_model = DeviceType table = tables.DeviceBayTemplateTable @@ -782,8 +770,7 @@ class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicerole' - cls = DeviceRole - queryset = DeviceRole.objects.annotate(device_count=Count('devices')) + queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable default_return_url = 'dcim:devicerole_list' @@ -818,8 +805,7 @@ class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView): class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_platform' - cls = Platform - queryset = Platform.objects.annotate(device_count=Count('devices')) + queryset = Platform.objects.all() table = tables.PlatformTable default_return_url = 'dcim:platform_list' @@ -1032,7 +1018,6 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_device' - cls = Device queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filter = filters.DeviceFilter table = tables.DeviceTable @@ -1042,7 +1027,6 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_device' - cls = Device queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filter = filters.DeviceFilter table = tables.DeviceTable @@ -1172,8 +1156,8 @@ class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleport' - cls = ConsolePort - parent_cls = Device + queryset = ConsolePort.objects.all() + parent_model = Device table = tables.ConsolePortTable @@ -1326,8 +1310,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverport' - cls = ConsoleServerPort - parent_cls = Device + queryset = ConsoleServerPort.objects.all() + parent_model = Device table = tables.ConsoleServerPortTable @@ -1454,8 +1438,8 @@ class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerport' - cls = PowerPort - parent_cls = Device + queryset = PowerPort.objects.all() + parent_model = Device table = tables.PowerPortTable @@ -1608,8 +1592,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView) class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlet' - cls = PowerOutlet - parent_cls = Device + queryset = PowerOutlet.objects.all() + parent_model = Device table = tables.PowerOutletTable @@ -1700,8 +1684,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' - cls = Interface - parent_cls = Device + queryset = Interface.objects.all() + parent_model = Device table = tables.InterfaceTable form = forms.InterfaceBulkEditForm @@ -1714,8 +1698,8 @@ class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView): class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' - cls = Interface - parent_cls = Device + queryset = Interface.objects.all() + parent_model = Device table = tables.InterfaceTable @@ -1821,8 +1805,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebay' - cls = DeviceBay - parent_cls = Device + queryset = DeviceBay.objects.all() + parent_model = Device table = tables.DeviceBayTable @@ -2079,7 +2063,6 @@ class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView): class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_inventoryitem' - cls = InventoryItem queryset = InventoryItem.objects.select_related('device', 'manufacturer') filter = filters.InventoryItemFilter table = tables.InventoryItemTable @@ -2089,7 +2072,6 @@ class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_inventoryitem' - cls = InventoryItem queryset = InventoryItem.objects.select_related('device', 'manufacturer') table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 4defeb3c1..7c0ab67d3 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -45,7 +45,6 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuittype' - cls = Tag queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name') table = TagTable default_return_url = 'extras:tag_list' @@ -92,7 +91,6 @@ class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'extras.delete_cconfigcontext' - cls = ConfigContext queryset = ConfigContext.objects.all() table = ConfigContextTable default_return_url = 'extras:configcontext_list' diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6620721a7..e861db1f2 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -138,7 +138,6 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_vrf' - cls = VRF queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter table = tables.VRFTable @@ -148,7 +147,6 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vrf' - cls = VRF queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter table = tables.VRFTable @@ -263,7 +261,6 @@ class RIRBulkImportView(PermissionRequiredMixin, BulkImportView): class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_rir' - cls = RIR queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filter = filters.RIRFilter table = tables.RIRTable @@ -367,7 +364,6 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_aggregate' - cls = Aggregate queryset = Aggregate.objects.select_related('rir') filter = filters.AggregateFilter table = tables.AggregateTable @@ -377,7 +373,6 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_aggregate' - cls = Aggregate queryset = Aggregate.objects.select_related('rir') filter = filters.AggregateFilter table = tables.AggregateTable @@ -414,7 +409,7 @@ class RoleBulkImportView(PermissionRequiredMixin, BulkImportView): class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_role' - cls = Role + queryset = Role.objects.all() table = tables.RoleTable default_return_url = 'ipam:role_list' @@ -588,7 +583,6 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_prefix' - cls = Prefix queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter table = tables.PrefixTable @@ -598,7 +592,6 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_prefix' - cls = Prefix queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter table = tables.PrefixTable @@ -761,7 +754,6 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_ipaddress' - cls = IPAddress queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filter = filters.IPAddressFilter table = tables.IPAddressTable @@ -771,7 +763,6 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_ipaddress' - cls = IPAddress queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filter = filters.IPAddressFilter table = tables.IPAddressTable @@ -810,7 +801,6 @@ class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView): class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlangroup' - cls = VLANGroup queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) filter = filters.VLANGroupFilter table = tables.VLANGroupTable @@ -895,7 +885,6 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_vlan' - cls = VLAN queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.VLANFilter table = tables.VLANTable @@ -905,7 +894,6 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlan' - cls = VLAN queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.VLANFilter table = tables.VLANTable @@ -960,8 +948,7 @@ class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_service' - cls = Service - queryset = Service.objects.all() + queryset = Service.objects.select_related('device', 'virtual_machine') filter = filters.ServiceFilter table = tables.ServiceTable form = forms.ServiceBulkEditForm @@ -970,8 +957,7 @@ class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_service' - cls = Service - queryset = Service.objects.all() + queryset = Service.objects.select_related('device', 'virtual_machine') filter = filters.ServiceFilter table = tables.ServiceTable default_return_url = 'ipam:service_list' diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index a18d38cce..d15c9cbc2 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -60,7 +60,6 @@ class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView): class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secretrole' - cls = SecretRole queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable default_return_url = 'secrets:secretrole_list' @@ -248,7 +247,6 @@ class SecretBulkImportView(BulkImportView): class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'secrets.change_secret' - cls = Secret queryset = Secret.objects.select_related('role', 'device') filter = filters.SecretFilter table = tables.SecretTable @@ -258,7 +256,6 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secret' - cls = Secret queryset = Secret.objects.select_related('role', 'device') filter = filters.SecretFilter table = tables.SecretTable diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 6a56974a4..fdb453665 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -46,7 +46,6 @@ class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView): class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'tenancy.delete_tenantgroup' - cls = TenantGroup queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) table = tables.TenantGroupTable default_return_url = 'tenancy:tenantgroup_list' @@ -115,7 +114,6 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'tenancy.change_tenant' - cls = Tenant queryset = Tenant.objects.select_related('group') filter = filters.TenantFilter table = tables.TenantTable @@ -125,7 +123,6 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'tenancy.delete_tenant' - cls = Tenant queryset = Tenant.objects.select_related('group') filter = filters.TenantFilter table = tables.TenantTable diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index e480410e4..f27838bdd 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -301,6 +301,7 @@ class BulkCreateView(GetReturnURLMixin, View): form: Form class which provides the `pattern` field model_form: The ModelForm used to create individual objects + pattern_target: Name of the field to be evaluated as a pattern (if any) template_name: The name of the template """ form = None @@ -466,17 +467,15 @@ class BulkEditView(GetReturnURLMixin, View): """ Edit objects in bulk. - cls: The model of the objects being edited - parent_cls: The model of the parent object (if any) queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) + parent_model: The model of the parent object (if any) filter: FilterSet to apply when deleting by QuerySet table: The table used to display devices being edited form: The form class used to edit objects in bulk template_name: The name of the template """ - cls = None - parent_cls = None queryset = None + parent_model = None filter = None table = None form = None @@ -487,20 +486,22 @@ class BulkEditView(GetReturnURLMixin, View): def post(self, request, **kwargs): + model = self.queryset.model + # Attempt to derive parent object if a parent class has been given - if self.parent_cls: - parent_obj = get_object_or_404(self.parent_cls, **kwargs) + if self.parent_model: + parent_obj = get_object_or_404(self.parent_model, **kwargs) else: parent_obj = None # Are we editing *all* objects in the queryset or just a selected subset? if request.POST.get('_all') and self.filter is not None: - pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs] + pk_list = [obj.pk for obj in self.filter(request.GET, model.objects.only('pk')).qs] else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] if '_apply' in request.POST: - form = self.form(self.cls, parent_obj, request.POST) + form = self.form(model, parent_obj, request.POST) if form.is_valid(): custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] @@ -512,7 +513,7 @@ class BulkEditView(GetReturnURLMixin, View): with transaction.atomic(): updated_count = 0 - for obj in self.cls.objects.filter(pk__in=pk_list): + for obj in model.objects.filter(pk__in=pk_list): # Update standard fields. If a field is listed in _nullify, delete its value. for name in standard_fields: @@ -524,7 +525,7 @@ class BulkEditView(GetReturnURLMixin, View): obj.save() # Update custom fields - obj_type = ContentType.objects.get_for_model(self.cls) + obj_type = ContentType.objects.get_for_model(model) for name in custom_fields: field = form.fields[name].model if name in form.nullable_fields and name in nullified_fields: @@ -552,7 +553,7 @@ class BulkEditView(GetReturnURLMixin, View): updated_count += 1 if updated_count: - msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural) + msg = 'Updated {} {}'.format(updated_count, model._meta.verbose_name_plural) messages.success(self.request, msg) return redirect(self.get_return_url(request)) @@ -563,19 +564,18 @@ class BulkEditView(GetReturnURLMixin, View): else: initial_data = request.POST.copy() initial_data['pk'] = pk_list - form = self.form(self.cls, parent_obj, initial=initial_data) + form = self.form(model, parent_obj, initial=initial_data) # Retrieve objects being edited - queryset = self.queryset or self.cls.objects.all() - table = self.table(queryset.filter(pk__in=pk_list), orderable=False) + table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) if not table.rows: - messages.warning(request, "No {} were selected.".format(self.cls._meta.verbose_name_plural)) + messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural)) return redirect(self.get_return_url(request)) return render(request, self.template_name, { 'form': form, 'table': table, - 'obj_type_plural': self.cls._meta.verbose_name_plural, + 'obj_type_plural': model._meta.verbose_name_plural, 'return_url': self.get_return_url(request), }) @@ -584,17 +584,15 @@ class BulkDeleteView(GetReturnURLMixin, View): """ Delete objects in bulk. - cls: The model of the objects being deleted - parent_cls: The model of the parent object (if any) queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) + parent_model: The model of the parent object (if any) filter: FilterSet to apply when deleting by QuerySet table: The table used to display devices being deleted form: The form class used to delete objects in bulk template_name: The name of the template """ - cls = None - parent_cls = None queryset = None + parent_model = None filter = None table = None form = None @@ -605,18 +603,20 @@ class BulkDeleteView(GetReturnURLMixin, View): def post(self, request, **kwargs): + model = self.queryset.model + # Attempt to derive parent object if a parent class has been given - if self.parent_cls: - parent_obj = get_object_or_404(self.parent_cls, **kwargs) + if self.parent_model: + parent_obj = get_object_or_404(self.parent_model, **kwargs) else: parent_obj = None # Are we deleting *all* objects in the queryset or just a selected subset? if request.POST.get('_all'): if self.filter is not None: - pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs] + pk_list = [obj.pk for obj in self.filter(request.GET, model.objects.only('pk')).qs] else: - pk_list = self.cls.objects.values_list('pk', flat=True) + pk_list = model.objects.values_list('pk', flat=True) else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] @@ -627,14 +627,14 @@ class BulkDeleteView(GetReturnURLMixin, View): if form.is_valid(): # Delete objects - queryset = self.cls.objects.filter(pk__in=pk_list) + queryset = model.objects.filter(pk__in=pk_list) try: - deleted_count = queryset.delete()[1][self.cls._meta.label] + deleted_count = queryset.delete()[1][model._meta.label] except ProtectedError as e: handle_protectederror(list(queryset), request, e) return redirect(self.get_return_url(request)) - msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural) + msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural) messages.success(request, msg) return redirect(self.get_return_url(request)) @@ -645,16 +645,15 @@ class BulkDeleteView(GetReturnURLMixin, View): }) # Retrieve objects being deleted - queryset = self.queryset or self.cls.objects.all() - table = self.table(queryset.filter(pk__in=pk_list), orderable=False) + table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) if not table.rows: - messages.warning(request, "No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural)) + messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural)) return redirect(self.get_return_url(request)) return render(request, self.template_name, { 'form': form, 'parent_obj': parent_obj, - 'obj_type_plural': self.cls._meta.verbose_name_plural, + 'obj_type_plural': model._meta.verbose_name_plural, 'table': table, 'return_url': self.get_return_url(request), }) @@ -665,7 +664,7 @@ class BulkDeleteView(GetReturnURLMixin, View): """ class BulkDeleteForm(ConfirmationForm): - pk = ModelMultipleChoiceField(queryset=self.cls.objects.all(), widget=MultipleHiddenInput) + pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) if self.form: return self.form diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 167a9fb6b..4ddacce40 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -49,7 +49,6 @@ class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView): class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_clustertype' - cls = ClusterType queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterTypeTable default_return_url = 'virtualization:clustertype_list' @@ -85,7 +84,6 @@ class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView): class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_clustergroup' - cls = ClusterGroup queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterGroupTable default_return_url = 'virtualization:clustergroup_list' @@ -96,7 +94,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class ClusterListView(ObjectListView): - queryset = Cluster.objects.select_related('type', 'group') + queryset = Cluster.objects.select_related('type', 'group', 'site') table = tables.ClusterTable filter = filters.ClusterFilter filter_form = forms.ClusterFilterForm @@ -147,7 +145,7 @@ class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView): class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'virtualization.change_cluster' - cls = Cluster + queryset = Cluster.objects.select_related('type', 'group', 'site') filter = filters.ClusterFilter table = tables.ClusterTable form = forms.ClusterBulkEditForm @@ -156,8 +154,7 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_cluster' - cls = Cluster - queryset = Cluster.objects.all() + queryset = Cluster.objects.select_related('type', 'group', 'site') filter = filters.ClusterFilter table = tables.ClusterTable default_return_url = 'virtualization:cluster_list' @@ -244,7 +241,7 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View): # class VirtualMachineListView(ObjectListView): - queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'primary_ip4', 'primary_ip6') + queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6') filter = filters.VirtualMachineFilter filter_form = forms.VirtualMachineFilterForm table = tables.VirtualMachineDetailTable @@ -298,8 +295,7 @@ class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView): class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'virtualization.change_virtualmachine' - cls = VirtualMachine - queryset = VirtualMachine.objects.select_related('cluster', 'tenant') + queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role') filter = filters.VirtualMachineFilter table = tables.VirtualMachineTable form = forms.VirtualMachineBulkEditForm @@ -308,8 +304,7 @@ class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_virtualmachine' - cls = VirtualMachine - queryset = VirtualMachine.objects.select_related('cluster', 'tenant') + queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role') filter = filters.VirtualMachineFilter table = tables.VirtualMachineTable default_return_url = 'virtualization:virtualmachine_list' @@ -343,16 +338,16 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' - cls = Interface - parent_cls = VirtualMachine + queryset = Interface.objects.all() + parent_model = VirtualMachine table = tables.InterfaceTable form = forms.InterfaceBulkEditForm class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' - cls = Interface - parent_cls = VirtualMachine + queryset = Interface.objects.all() + parent_model = VirtualMachine table = tables.InterfaceTable From 86a67e7f32883bdc8b8942e65f87dfd6f0c5d3d8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 23 Jul 2018 15:02:07 -0400 Subject: [PATCH 130/159] Fixes #2258: Include changed object type on home page changelog --- netbox/templates/home.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 0645ec8c1..090be1350 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load helpers %} {% block content %} {% include 'search_form.html' %} @@ -180,6 +181,7 @@ {% elif action == 'deleted' %} {% endif %} + {{ change.changed_object_type.name|bettertitle }} {% if change.changed_object.get_absolute_url %} {{ change.changed_object }} {% else %} From b7c2a26155d9b1475f436b8d0d2ad8678da41767 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 23 Jul 2018 15:09:37 -0400 Subject: [PATCH 131/159] Closes #2259: Add changelog tab to interface view --- netbox/dcim/urls.py | 5 +++-- netbox/templates/dcim/interface.html | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 9910c60da..7345cdacd 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -7,8 +7,8 @@ from ipam.views import ServiceCreateView from secrets.views import secret_add from . import views from .models import ( - Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole, Region, Site, - VirtualChassis, + Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole, + Region, Site, VirtualChassis, ) app_name = 'dcim' @@ -203,6 +203,7 @@ urlpatterns = [ url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), url(r'^interfaces/(?P\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), + url(r'^interfaces/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), # Device bays diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index d0f9b82c0..2004af1b1 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -28,6 +28,14 @@ {% endif %}

    {% block title %}{{ interface.parent }} / {{ interface.name }}{% endblock %}

    + {% endblock %} {% block content %} From 674a0d481eb22751dc6daedd63f1cffb3c9ea040 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jul 2018 13:42:18 -0400 Subject: [PATCH 132/159] Additional documentation cleanup --- docs/additional-features/change-logging.md | 9 +++++++++ docs/additional-features/context-data.md | 2 +- docs/additional-features/tags.md | 4 ++-- docs/administration/replicating-netbox.md | 2 ++ docs/core-functionality/ipam.md | 2 +- docs/core-functionality/services.md | 2 +- docs/core-functionality/tenancy.md | 2 +- docs/development/index.md | 10 +++++++++- docs/index.md | 4 +++- mkdocs.yml | 1 + 10 files changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/additional-features/change-logging.md b/docs/additional-features/change-logging.md index e69de29bb..b359f9b26 100644 --- a/docs/additional-features/change-logging.md +++ b/docs/additional-features/change-logging.md @@ -0,0 +1,9 @@ +# Change Logging + +Every time an object in NetBox is created, updated, or deleted, a serialized copy of that object is saved to the database, along with meta data including the current time and the user associated with the change. These records form a running changelog both for each individual object as well as NetBox as a whole (Organization > Changelog). + +A serialized representation is included for each object in JSON format. This is similar to how objects are conveyed within the REST API, but does not include any nested representations. For instance, the `tenant` field of a site will record only the tenant's ID, not a representation of the tenant. + +When a request is made, a random request ID is generated and attached to any change records resulting from the request. For example, editing multiple objects in bulk will create a change record for each object, and each of those objects will be assigned the same request ID. This makes it easy to identify all the change records associated with a particular request. + +Change records are exposed in the API via the read-only endpoint `/api/extras/object-changes/`. They may also be exported in CSV format. diff --git a/docs/additional-features/context-data.md b/docs/additional-features/context-data.md index d8cff36f2..cd9f1ceaa 100644 --- a/docs/additional-features/context-data.md +++ b/docs/additional-features/context-data.md @@ -1,3 +1,3 @@ # Contextual Configuration Data -Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. (For example, you might want to associate a set of syslog servers for all devices at a particular site.) Context data enables the association of arbitrary data (expressed in JSON format) to devices and virtual machines grouped by region, site, role, platform, and/or tenancy. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. +Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. For example, you might want to associate a set of syslog servers for all devices at a particular site. Context data enables the association of arbitrary data to devices and virtual machines grouped by region, site, role, platform, and/or tenant. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. diff --git a/docs/additional-features/tags.md b/docs/additional-features/tags.md index 63bb22132..18edcad12 100644 --- a/docs/additional-features/tags.md +++ b/docs/additional-features/tags.md @@ -1,8 +1,8 @@ # Tags -Tags are free-form text labels which can be applied to a variety of objects within NetBox. Tags are created on-demand. Use commas to separate tags when adding multiple tags to an object, e.g. `Inventoried, Monitored`. Use double quotes around a multi-word tag when adding only one tag, e.g. `"Core Switch"`. +Tags are free-form text labels which can be applied to a variety of objects within NetBox. Tags are created on-demand as they are assigned to objects. Use commas to separate tags when adding multiple tags to an object 9for example: `Inventoried, Monitored`). Use double quotes around a multi-word tag when adding only one tag, e.g. `"Core Switch"`. -Each tag has a label and a URL-friendly slug. For example, the slug for a tag named "Dunder Mifflin, Inc." would be `dunder-mifflin-inc`. The slug is generated automatically and makes them easier to work with as URL parameters. +Each tag has a label and a URL-friendly slug. For example, the slug for a tag named "Dunder Mifflin, Inc." would be `dunder-mifflin-inc`. The slug is generated automatically and makes tags easier to work with as URL parameters. Objects can be filtered by the tags they have applied. For example, the following API request will retrieve all devices tagged as "monitored": diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 938ae9a58..c4a916865 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -32,6 +32,8 @@ If you want to export only the database schema, and not the data itself (e.g. fo pg_dump -s netbox > netbox_schema.sql ``` +--- + # Replicating Media NetBox stored uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files. diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index df9b3b435..27bec8e8e 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -58,7 +58,7 @@ Also like prefixes, each IP address can be assigned a status and a role. Statuse * Deprecated * DHCP -IP address roles are also hard-coded, and can be used to indicate a special condition of the IP address. Role assignment is optional. Available roles include: +Each IP address can optionally be assigned a special role. Roles are used to indicate some special attribute of an IP address: for example, it is used as a loopback, or is a virtual IP maintained using VRRP. (Note that this differs in purpose from a _functional_ role, and thus cannot be customized.) Available roles include: * Loopback * Secondary diff --git a/docs/core-functionality/services.md b/docs/core-functionality/services.md index 00d523041..057544a91 100644 --- a/docs/core-functionality/services.md +++ b/docs/core-functionality/services.md @@ -1,5 +1,5 @@ # Services -A service represents a layer four TCP or UDP service available on a device or virtual machine. Each service includes a name, protocol, and port number; for example, "SSH (TCP/22)" or "DNS (UDP/53)." +A service represents a layer four TCP or UDP service available on a device or virtual machine. For example, you might want to document that an HTTP service is running on a device. Each service includes a name, protocol, and port number; for example, "SSH (TCP/22)" or "DNS (UDP/53)." A service may optionally be bound to one or more specific IP addresses belonging to its parent device or VM. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.) diff --git a/docs/core-functionality/tenancy.md b/docs/core-functionality/tenancy.md index 9355cc1d9..e41d4a5b6 100644 --- a/docs/core-functionality/tenancy.md +++ b/docs/core-functionality/tenancy.md @@ -13,7 +13,7 @@ A tenant represents a discrete entity for administrative purposes. Typically, te * Circuits * Virtual machines -If a prefix or IP address is not assigned to a tenant, it will appear to inherit the tenant to which its parent VRF is assigned, if any. +Tenant assignment is used to signify ownership of an object in NetBox. As such, each object may only be owned by a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so tenant assignment would not be appropriate. ### Tenant Groups diff --git a/docs/development/index.md b/docs/development/index.md index f95e92a3e..de074f43d 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -1,10 +1,18 @@ -# NetBox Structure +# NetBox Development + +NetBox is maintained as a [GitHub project](https://github.com/digitalocean/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox. + +All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base. + +# Project Structure NetBox components are arranged into functional subsections called _apps_ (a carryover from Django verancular). Each app holds the models, views, and templates relevant to a particular function: * `circuits`: Communications circuits and providers (not to be confused with power circuits) * `dcim`: Datacenter infrastructure management (sites, racks, and devices) +* `extras`: Additional features not considered part of the core data model * `ipam`: IP address management (VRFs, prefixes, IP addresses, and VLANs) * `secrets`: Encrypted storage of sensitive data (e.g. login credentials) * `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned +* `utilities`: Resources which are not user-facing (extendable classes, etc.) * `virtualization`: Virtual machines and clusters diff --git a/docs/index.md b/docs/index.md index a9cbb5af1..84c39ccde 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,5 @@ +![NetBox](netbox_logo.png "NetBox logo") + # What is NetBox? NetBox is an open source web application designed to help manage and document computer networks. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. It encompasses the following aspects of network management: @@ -10,7 +12,7 @@ NetBox is an open source web application designed to help manage and document co * **Data circuits** - Long-haul communications circuits and providers * **Secrets** - Encrypted storage of sensitive credentials -# What NetBox Isn't +# What NetBox Is Not While NetBox strives to cover many areas of network management, the scope of its feature set is necessarily limited. This ensures that development focuses on core functionality and that scope creep is reasonably contained. To that end, it might help to provide some examples of functionality that NetBox **does not** provide: diff --git a/mkdocs.yml b/mkdocs.yml index 44a08c30d..6b27a8b78 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,7 @@ pages: - Working with Secrets: 'api/working-with-secrets.md' - Examples: 'api/examples.md' - Development: + - Introduction: 'development/index.md' - Utility Views: 'development/utility-views.md' markdown_extensions: From 7476f522e1fc35ecaf4aeb0ff07f59f19ac7ab21 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jul 2018 15:10:43 -0400 Subject: [PATCH 133/159] Fixes #2265: Include parent regions when filtering applicable ConfigContexts (credit: lampwins) --- netbox/extras/querysets.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index c87c7c566..4ee9630cb 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -13,8 +13,15 @@ class ConfigContextQuerySet(QuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role + # Match against the directly assigned region as well as any parent regions. + region = getattr(obj.site, 'region', None) + if region: + regions = region.get_ancestors(include_self=True) + else: + regions = [] + return self.filter( - Q(regions=getattr(obj.site, 'region', None)) | Q(regions=None), + Q(regions__in=regions) | Q(regions=None), Q(sites=obj.site) | Q(sites=None), Q(roles=role) | Q(roles=None), Q(tenants=obj.tenant) | Q(tenants=None), From a1e8397b6b9c6339d4b3adf9b8566f1300cd3b87 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jul 2018 15:47:29 -0400 Subject: [PATCH 134/159] Closes #2229: Allow mapping of ConfigContexts to tenant groups --- netbox/extras/api/serializers.py | 7 ++++--- netbox/extras/forms.py | 3 ++- netbox/extras/migrations/0014_configcontexts.py | 17 ++++++++++++----- netbox/extras/models.py | 5 +++++ netbox/extras/querysets.py | 6 +++++- netbox/templates/extras/configcontext_edit.html | 1 + 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 896146069..feebaba0e 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -13,7 +13,7 @@ from extras.models import ( ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction, ) from extras.constants import * -from tenancy.api.serializers import NestedTenantSerializer +from tenancy.api.serializers import NestedTenantSerializer, NestedTenantGroupSerializer from users.api.serializers import NestedUserSerializer from utilities.api import ( ChoiceFieldSerializer, ContentTypeFieldSerializer, get_serializer_for_model, ValidatedModelSerializer, @@ -136,13 +136,14 @@ class ConfigContextSerializer(ValidatedModelSerializer): sites = NestedSiteSerializer(required=False, many=True) roles = NestedDeviceRoleSerializer(required=False, many=True) platforms = NestedPlatformSerializer(required=False, many=True) + tenant_groups = NestedTenantGroupSerializer(required=False, many=True) tenants = NestedTenantSerializer(required=False, many=True) class Meta: model = ConfigContext fields = [ - 'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', - 'data', + 'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', + 'tenant_groups', 'tenants', 'data', ] diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 16c38e61d..0b8d27233 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -218,7 +218,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext fields = [ - 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data', + 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', + 'tenants', 'data', ] diff --git a/netbox/extras/migrations/0014_configcontexts.py b/netbox/extras/migrations/0014_configcontexts.py index bc12e2cdf..789679e4f 100644 --- a/netbox/extras/migrations/0014_configcontexts.py +++ b/netbox/extras/migrations/0014_configcontexts.py @@ -1,14 +1,15 @@ -# Generated by Django 2.0.6 on 2018-06-29 13:34 +# Generated by Django 2.0.7 on 2018-07-27 19:44 import django.contrib.postgres.fields.jsonb from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ ('tenancy', '0005_change_logging'), - ('dcim', '0060_change_logging'), + ('dcim', '0061_platform_napalm_args'), ('extras', '0013_objectchange'), ] @@ -19,13 +20,14 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=100, unique=True)), ('weight', models.PositiveSmallIntegerField(default=1000)), - ('is_active', models.BooleanField(default=True)), ('description', models.CharField(blank=True, max_length=100)), + ('is_active', models.BooleanField(default=True)), ('data', django.contrib.postgres.fields.jsonb.JSONField()), ('platforms', models.ManyToManyField(blank=True, related_name='_configcontext_platforms_+', to='dcim.Platform')), ('regions', models.ManyToManyField(blank=True, related_name='_configcontext_regions_+', to='dcim.Region')), ('roles', models.ManyToManyField(blank=True, related_name='_configcontext_roles_+', to='dcim.DeviceRole')), ('sites', models.ManyToManyField(blank=True, related_name='_configcontext_sites_+', to='dcim.Site')), + ('tenant_groups', models.ManyToManyField(blank=True, related_name='_configcontext_tenant_groups_+', to='tenancy.TenantGroup')), ('tenants', models.ManyToManyField(blank=True, related_name='_configcontext_tenants_+', to='tenancy.Tenant')), ], options={ @@ -35,11 +37,16 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='customfield', name='obj_type', - field=models.ManyToManyField(help_text='The object(s) to which this field applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'tenant', 'cluster', 'virtualmachine')}, related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'), + field=models.ManyToManyField(help_text='The object(s) to which this field applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine')}, related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'), + ), + migrations.AlterField( + model_name='exporttemplate', + name='content_type', + field=models.ForeignKey(limit_choices_to={'model__in': ['provider', 'circuit', 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', 'consoleport', 'powerport', 'interfaceconnection', 'virtualchassis', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), ), migrations.AlterField( model_name='webhook', name='obj_type', - field=models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'rackgroup', 'device', 'interface', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vlangroup', 'vrf', 'service', 'tenant', 'tenantgroup', 'cluster', 'clustergroup', 'virtualmachine')}, related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object types'), + field=models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'virtualchassis', 'consoleport', 'consoleserverport', 'powerport', 'poweroutlet', 'interface', 'devicebay', 'inventoryitem', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine')}, related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object types'), ), ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 8b90bd5c2..a97eebd74 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -677,6 +677,11 @@ class ConfigContext(models.Model): related_name='+', blank=True ) + tenant_groups = models.ManyToManyField( + to='tenancy.TenantGroup', + related_name='+', + blank=True + ) tenants = models.ManyToManyField( to='tenancy.Tenant', related_name='+', diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 4ee9630cb..bcc6f1e54 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -13,6 +13,9 @@ class ConfigContextQuerySet(QuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role + # Get the group of the assigned tenant, if any + tenant_group = obj.tenant.group if obj.tenant else None + # Match against the directly assigned region as well as any parent regions. region = getattr(obj.site, 'region', None) if region: @@ -24,7 +27,8 @@ class ConfigContextQuerySet(QuerySet): Q(regions__in=regions) | Q(regions=None), Q(sites=obj.site) | Q(sites=None), Q(roles=role) | Q(roles=None), - Q(tenants=obj.tenant) | Q(tenants=None), Q(platforms=obj.platform) | Q(platforms=None), + Q(tenant_groups=tenant_group) | Q(tenant_groups=None), + Q(tenants=obj.tenant) | Q(tenants=None), is_active=True, ).order_by('weight', 'name') diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index 4b7e53044..7a3566a00 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -18,6 +18,7 @@ {% render_field form.sites %} {% render_field form.roles %} {% render_field form.platforms %} + {% render_field form.tenant_groups %} {% render_field form.tenants %}
    From 64e86013bff6107eab1295dca8e84b9b1a11f7fd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jul 2018 16:00:50 -0400 Subject: [PATCH 135/159] Remove admin UI views for ConfigContexts and ObjectChanges --- netbox/extras/admin.py | 52 ------------------------------------------ 1 file changed, 52 deletions(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 2c5de5054..2b140b444 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -127,58 +127,6 @@ class TopologyMapAdmin(admin.ModelAdmin): } -# -# Config contexts -# - -@admin.register(ConfigContext) -class ConfigContextAdmin(admin.ModelAdmin): - list_display = ['name', 'weight'] - - -# -# Change logging -# - -@admin.register(ObjectChange) -class ObjectChangeAdmin(admin.ModelAdmin): - actions = None - fields = ['time', 'changed_object_type', 'display_object', 'action', 'display_user', 'request_id', 'object_data'] - list_display = ['time', 'changed_object_type', 'display_object', 'display_action', 'display_user', 'request_id'] - list_filter = ['time', 'action', 'user__username'] - list_select_related = ['changed_object_type', 'user'] - readonly_fields = fields - search_fields = ['user_name', 'object_repr', 'request_id'] - - def has_add_permission(self, request): - return False - - def display_user(self, obj): - if obj.user is not None: - return obj.user - else: - return '{} (deleted)'.format(obj.user_name) - display_user.short_description = 'user' - - def display_action(self, obj): - icon = { - OBJECTCHANGE_ACTION_CREATE: 'addlink', - OBJECTCHANGE_ACTION_UPDATE: 'changelink', - OBJECTCHANGE_ACTION_DELETE: 'deletelink', - } - return mark_safe('{}'.format(icon[obj.action], obj.get_action_display())) - display_action.short_description = 'action' - - def display_object(self, obj): - if hasattr(obj.changed_object, 'get_absolute_url'): - return mark_safe('{}'.format(obj.changed_object.get_absolute_url(), obj.changed_object)) - elif obj.changed_object is not None: - return obj.changed_object - else: - return '{} (deleted)'.format(obj.object_repr) - display_object.short_description = 'object' - - # # User actions # From 838af2b2d8535fd10b155889513d6918009dc74d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jul 2018 16:15:56 -0400 Subject: [PATCH 136/159] Closes #2264: Added "map it" link for site GPS coordinates --- netbox/templates/dcim/site.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index e70f98e71..4340bea7a 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -181,7 +181,12 @@ GPS Coordinates {% if site.latitude and site.longitude %} - {{ site.latitude }}, {{ site.longitude }} + + {{ site.latitude }}, {{ site.longitude }} {% else %} N/A {% endif %} From c2416411c1ca5b2aa0b5ed7c68788c9e1376f626 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jul 2018 12:41:20 -0400 Subject: [PATCH 137/159] Renamed ChoiceFieldSerializer and ContentTypeFieldSerializer --- netbox/circuits/api/serializers.py | 4 ++-- netbox/dcim/api/serializers.py | 26 ++++++++++++------------ netbox/extras/api/serializers.py | 12 +++++------ netbox/ipam/api/serializers.py | 12 +++++------ netbox/utilities/api.py | 7 +++---- netbox/utilities/custom_inspectors.py | 4 ++-- netbox/virtualization/api/serializers.py | 6 +++--- 7 files changed, 35 insertions(+), 36 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index c42edb5ae..4b2b7fbc3 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -8,7 +8,7 @@ from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ChoiceField, TagField, ValidatedModelSerializer, WritableNestedSerializer # @@ -59,7 +59,7 @@ class NestedCircuitTypeSerializer(WritableNestedSerializer): class CircuitSerializer(CustomFieldModelSerializer): provider = NestedProviderSerializer() - status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False) + status = ChoiceField(choices=CIRCUIT_STATUS_CHOICES, required=False) type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) tags = TagField(queryset=Tag.objects.all(), required=False, many=True) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9c134bc94..2dc69a6f9 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -20,7 +20,7 @@ from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from users.api.serializers import NestedUserSerializer from utilities.api import ( - ChoiceFieldSerializer, SerializedPKRelatedField, TagField, TimeZoneField, ValidatedModelSerializer, + ChoiceField, SerializedPKRelatedField, TagField, TimeZoneField, ValidatedModelSerializer, WritableNestedSerializer, ) from virtualization.models import Cluster @@ -51,7 +51,7 @@ class RegionSerializer(serializers.ModelSerializer): # class SiteSerializer(CustomFieldModelSerializer): - status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES, required=False) + status = ChoiceField(choices=SITE_STATUS_CHOICES, required=False) region = NestedRegionSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) @@ -123,8 +123,8 @@ class RackSerializer(CustomFieldModelSerializer): group = NestedRackGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) role = NestedRackRoleSerializer(required=False, allow_null=True) - type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False) - width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False) + type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False) + width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False) tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: @@ -222,8 +222,8 @@ class NestedManufacturerSerializer(WritableNestedSerializer): class DeviceTypeSerializer(CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() - interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES, required=False) - subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False) + interface_ordering = ChoiceField(choices=IFACE_ORDERING_CHOICES, required=False) + subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False) instance_count = serializers.IntegerField(source='instances.count', read_only=True) tags = TagField(queryset=Tag.objects.all(), required=False, many=True) @@ -299,7 +299,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class InterfaceTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False) + form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) class Meta: model = InterfaceTemplate @@ -396,8 +396,8 @@ class DeviceSerializer(CustomFieldModelSerializer): platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() rack = NestedRackSerializer(required=False, allow_null=True) - face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES, required=False) - status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES, required=False) + face = ChoiceField(choices=RACK_FACE_CHOICES, required=False) + status = ChoiceField(choices=DEVICE_STATUS_CHOICES, required=False) primary_ip = DeviceIPAddressSerializer(read_only=True) primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True) primary_ip6 = DeviceIPAddressSerializer(required=False, allow_null=True) @@ -571,12 +571,12 @@ class InterfaceVLANSerializer(WritableNestedSerializer): class InterfaceSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False) + form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) is_connected = serializers.SerializerMethodField(read_only=True) interface_connection = serializers.SerializerMethodField(read_only=True) circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True) - mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES, required=False) + mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False) untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -684,7 +684,7 @@ class InventoryItemSerializer(ValidatedModelSerializer): class InterfaceConnectionSerializer(ValidatedModelSerializer): interface_a = NestedInterfaceSerializer() interface_b = NestedInterfaceSerializer() - connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES, required=False) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) class Meta: model = InterfaceConnection @@ -704,7 +704,7 @@ class ContextualInterfaceConnectionSerializer(serializers.ModelSerializer): A read-only representation of an InterfaceConnection from the perspective of either of its two connected Interfaces. """ interface = serializers.SerializerMethodField(read_only=True) - connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES, read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: model = InterfaceConnection diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index feebaba0e..3f4ccadc6 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -16,7 +16,7 @@ from extras.constants import * from tenancy.api.serializers import NestedTenantSerializer, NestedTenantGroupSerializer from users.api.serializers import NestedUserSerializer from utilities.api import ( - ChoiceFieldSerializer, ContentTypeFieldSerializer, get_serializer_for_model, ValidatedModelSerializer, + ChoiceField, ContentTypeField, get_serializer_for_model, ValidatedModelSerializer, ) @@ -25,7 +25,7 @@ from utilities.api import ( # class GraphSerializer(ValidatedModelSerializer): - type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) + type = ChoiceField(choices=GRAPH_TYPE_CHOICES) class Meta: model = Graph @@ -35,7 +35,7 @@ class GraphSerializer(ValidatedModelSerializer): class RenderedGraphSerializer(serializers.ModelSerializer): embed_url = serializers.SerializerMethodField() embed_link = serializers.SerializerMethodField() - type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) + type = ChoiceField(choices=GRAPH_TYPE_CHOICES) class Meta: model = Graph @@ -88,7 +88,7 @@ class TagSerializer(ValidatedModelSerializer): # class ImageAttachmentSerializer(ValidatedModelSerializer): - content_type = ContentTypeFieldSerializer() + content_type = ContentTypeField() parent = serializers.SerializerMethodField(read_only=True) class Meta: @@ -188,7 +188,7 @@ class ReportDetailSerializer(ReportSerializer): class ObjectChangeSerializer(serializers.ModelSerializer): user = NestedUserSerializer(read_only=True) - content_type = ContentTypeFieldSerializer(read_only=True) + content_type = ContentTypeField(read_only=True) changed_object = serializers.SerializerMethodField(read_only=True) class Meta: @@ -217,7 +217,7 @@ class ObjectChangeSerializer(serializers.ModelSerializer): class UserActionSerializer(serializers.ModelSerializer): user = NestedUserSerializer() - action = ChoiceFieldSerializer(choices=ACTION_CHOICES) + action = ChoiceField(choices=ACTION_CHOICES) class Meta: model = UserAction diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index bfbc66be3..92526eb56 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -16,7 +16,7 @@ from ipam.constants import ( from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ( - ChoiceFieldSerializer, SerializedPKRelatedField, TagField, ValidatedModelSerializer, WritableNestedSerializer, + ChoiceField, SerializedPKRelatedField, TagField, ValidatedModelSerializer, WritableNestedSerializer, ) from virtualization.api.serializers import NestedVirtualMachineSerializer @@ -151,7 +151,7 @@ class VLANSerializer(CustomFieldModelSerializer): site = NestedSiteSerializer(required=False, allow_null=True) group = NestedVLANGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) - status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False) + status = ChoiceField(choices=VLAN_STATUS_CHOICES, required=False) role = NestedRoleSerializer(required=False, allow_null=True) tags = TagField(queryset=Tag.objects.all(), required=False, many=True) @@ -195,7 +195,7 @@ class PrefixSerializer(CustomFieldModelSerializer): vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True) - status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False) + status = ChoiceField(choices=PREFIX_STATUS_CHOICES, required=False) role = NestedRoleSerializer(required=False, allow_null=True) tags = TagField(queryset=Tag.objects.all(), required=False, many=True) @@ -257,8 +257,8 @@ class IPAddressInterfaceSerializer(serializers.ModelSerializer): class IPAddressSerializer(CustomFieldModelSerializer): vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) - status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False) - role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False) + status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False) + role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False) interface = IPAddressInterfaceSerializer(required=False, allow_null=True) tags = TagField(queryset=Tag.objects.all(), required=False, many=True) @@ -304,7 +304,7 @@ class AvailableIPSerializer(serializers.Serializer): class ServiceSerializer(CustomFieldModelSerializer): device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) - protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) + protocol = ChoiceField(choices=IP_PROTOCOL_CHOICES) ipaddresses = SerializedPKRelatedField( queryset=IPAddress.objects.all(), serializer=NestedIPAddressSerializer, diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 0b0384653..f648d2689 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -63,7 +63,6 @@ class TagField(RelatedField): """ Represent a writable list of Tags associated with an object (use with many=True). """ - def to_internal_value(self, data): obj = self.parent.parent.instance content_type = ContentType.objects.get_for_model(obj) @@ -74,7 +73,7 @@ class TagField(RelatedField): return value.name -class ChoiceFieldSerializer(Field): +class ChoiceField(Field): """ Represent a ChoiceField as {'value': , 'label': }. """ @@ -87,7 +86,7 @@ class ChoiceFieldSerializer(Field): self._choices[k2] = v2 else: self._choices[k] = v - super(ChoiceFieldSerializer, self).__init__(**kwargs) + super(ChoiceField, self).__init__(**kwargs) def to_representation(self, obj): return {'value': obj, 'label': self._choices[obj]} @@ -96,7 +95,7 @@ class ChoiceFieldSerializer(Field): return data -class ContentTypeFieldSerializer(Field): +class ContentTypeField(Field): """ Represent a ContentType as '.' """ diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index b97506b85..ca6e08fc1 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -3,7 +3,7 @@ from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, from rest_framework.fields import ChoiceField from extras.api.customfields import CustomFieldsSerializer -from utilities.api import ChoiceFieldSerializer +from utilities.api import ChoiceField class CustomChoiceFieldInspector(FieldInspector): @@ -12,7 +12,7 @@ class CustomChoiceFieldInspector(FieldInspector): # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) - if isinstance(field, ChoiceFieldSerializer): + if isinstance(field, ChoiceField): value_schema = openapi.Schema(type=openapi.TYPE_INTEGER) choices = list(field._choices.keys()) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 3180fc31c..81385878b 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -9,7 +9,7 @@ from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ChoiceField, TagField, ValidatedModelSerializer, WritableNestedSerializer from virtualization.constants import VM_STATUS_CHOICES from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -91,7 +91,7 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer): class VirtualMachineSerializer(CustomFieldModelSerializer): - status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES, required=False) + status = ChoiceField(choices=VM_STATUS_CHOICES, required=False) cluster = NestedClusterSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) @@ -145,7 +145,7 @@ class InterfaceVLANSerializer(serializers.ModelSerializer): class InterfaceSerializer(serializers.ModelSerializer): virtual_machine = NestedVirtualMachineSerializer() - mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES) + mode = ChoiceField(choices=IFACE_MODE_CHOICES) untagged_vlan = InterfaceVLANSerializer() tagged_vlans = InterfaceVLANSerializer(many=True) From f664998d9b0349b05ba041cbb3383eb2d2815556 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jul 2018 12:49:08 -0400 Subject: [PATCH 138/159] Misc cleanup --- netbox/utilities/api.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index f648d2689..296f1bb10 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -9,18 +9,15 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db.models import ManyToManyField from django.http import Http404 -from rest_framework import mixins from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError -from rest_framework.viewsets import GenericViewSet, ViewSet +from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet from .utils import dynamic_import -WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] - class ServiceUnavailable(APIException): status_code = 503 @@ -188,19 +185,16 @@ class WritableNestedSerializer(ModelSerializer): # Viewsets # -class ModelViewSet(mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - GenericViewSet): +class ModelViewSet(_ModelViewSet): """ Accept either a single object or a list of objects to create. """ def get_serializer(self, *args, **kwargs): + # If a list of objects has been provided, initialize the serializer with many=True if isinstance(kwargs.get('data', {}), list): kwargs['many'] = True + return super(ModelViewSet, self).get_serializer(*args, **kwargs) From f7aa259995e59a1b23639721b20b9d7868cefaab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jul 2018 14:00:37 -0400 Subject: [PATCH 139/159] Misc cleanup --- netbox/utilities/context_processors.py | 3 +++ netbox/utilities/fields.py | 9 +++++++-- netbox/utilities/filters.py | 3 +++ netbox/utilities/forms.py | 27 +++++++++++++++++++------- netbox/utilities/tables.py | 4 +++- netbox/utilities/testing.py | 1 - netbox/utilities/validators.py | 1 - 7 files changed, 36 insertions(+), 12 deletions(-) diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py index 58c8641ec..dab35e982 100644 --- a/netbox/utilities/context_processors.py +++ b/netbox/utilities/context_processors.py @@ -4,6 +4,9 @@ from django.conf import settings as django_settings def settings(request): + """ + Expose Django settings in the template context. Example: {{ settings.DEBUG }} + """ return { 'settings': django_settings, } diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index d0a0c2180..34f59fe16 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -5,7 +5,12 @@ from django.db import models from .forms import ColorSelect -validate_color = RegexValidator('^[0-9a-f]{6}$', 'Enter a valid hexadecimal RGB color code.', 'invalid') + +ColorValidator = RegexValidator( + regex='^[0-9a-f]{6}$', + message='Enter a valid hexadecimal RGB color code.', + code='invalid' +) class NullableCharField(models.CharField): @@ -21,7 +26,7 @@ class NullableCharField(models.CharField): class ColorField(models.CharField): - default_validators = [validate_color] + default_validators = [ColorValidator] description = "A hexadecimal RGB color code" def __init__(self, *args, **kwargs): diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 3e403e676..90cdcd9fc 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -19,6 +19,9 @@ class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): class NullableCharFieldFilter(django_filters.CharFilter): + """ + Allow matching on null field values by passing a special string used to signify NULL. + """ null_value = 'NULL' def filter(self, qs, value): diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 9a4284054..1e6e3c0c4 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -154,6 +154,9 @@ def add_blank_choice(choices): # class SmallTextarea(forms.Textarea): + """ + Subclass used for rendering a smaller textarea element. + """ pass @@ -169,6 +172,9 @@ class ColorSelect(forms.Select): class BulkEditNullBooleanSelect(forms.NullBooleanSelect): + """ + A Select widget for NullBooleanFields + """ def __init__(self, *args, **kwargs): super(BulkEditNullBooleanSelect, self).__init__(*args, **kwargs) @@ -448,7 +454,9 @@ class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): class SlugField(forms.SlugField): - + """ + Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. + """ def __init__(self, slug_source='name', *args, **kwargs): label = kwargs.pop('label', "Slug") help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") @@ -558,11 +566,15 @@ class JSONField(_JSONField): # class BootstrapMixin(forms.BaseForm): - + """ + Add the base Bootstrap CSS classes to form elements. + """ def __init__(self, *args, **kwargs): super(BootstrapMixin, self).__init__(*args, **kwargs) - exempt_widgets = [forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect] + exempt_widgets = [ + forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect + ] for field_name, field in self.fields.items(): if field.widget.__class__ not in exempt_widgets: @@ -632,14 +644,15 @@ class ComponentForm(BootstrapMixin, forms.Form): class BulkEditForm(forms.Form): - + """ + Base form for editing multiple objects in bulk + """ def __init__(self, model, parent_obj=None, *args, **kwargs): super(BulkEditForm, self).__init__(*args, **kwargs) self.model = model self.parent_obj = parent_obj + self.nullable_fields = [] # Copy any nullable fields defined in Meta if hasattr(self.Meta, 'nullable_fields'): - self.nullable_fields = [field for field in self.Meta.nullable_fields] - else: - self.nullable_fields = [] + self.nullable_fields = self.Meta.nullable_fields diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index a9f1044d6..e531b5e32 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -22,7 +22,9 @@ class BaseTable(tables.Table): class ToggleColumn(tables.CheckBoxColumn): - + """ + Extend CheckBoxColumn to add a "toggle all" checkbox in the column header. + """ def __init__(self, *args, **kwargs): default = kwargs.pop('default', '') visible = kwargs.pop('visible', False) diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py index d40202842..fa38166e0 100644 --- a/netbox/utilities/testing.py +++ b/netbox/utilities/testing.py @@ -5,7 +5,6 @@ class HttpStatusMixin(object): """ Custom mixin to provide more detail in the event of an unexpected HTTP response. """ - def assertHttpStatus(self, response, expected_status): err_message = "Expected HTTP status {}; received {}: {}" self.assertEqual(response.status_code, expected_status, err_message.format( diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index dcdb9bc6d..102e368a5 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -9,7 +9,6 @@ class EnhancedURLValidator(URLValidator): """ Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension. """ - class AnyURLScheme(object): """ A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1 From 9876a2efcd86c6c30e6087f9bc3abcd6ef5293a1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jul 2018 14:16:45 -0400 Subject: [PATCH 140/159] Added a style section --- docs/development/index.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/development/index.md b/docs/development/index.md index de074f43d..d9f9df142 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -16,3 +16,10 @@ NetBox components are arranged into functional subsections called _apps_ (a carr * `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned * `utilities`: Resources which are not user-facing (extendable classes, etc.) * `virtualization`: Virtual machines and clusters + +# Style Guide + +NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). The following exceptions are noted: + +* [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`. +* Constants may be imported via wildcard (for example, `from .constants import *`). From 722d0d555473e3fe56b76e52105ed06139fdff84 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Mon, 30 Jul 2018 14:23:49 -0400 Subject: [PATCH 141/159] Webhook signal refactor - fixes #2282 (#2260) Refactor of webhook signaling system to use the same middleware mechanics of Changelogging --- netbox/circuits/apps.py | 5 -- netbox/circuits/models.py | 3 - netbox/dcim/apps.py | 5 -- netbox/dcim/models.py | 15 ---- netbox/extras/middleware.py | 30 +++++-- netbox/extras/models.py | 1 - netbox/extras/webhooks.py | 147 +++++++++---------------------- netbox/extras/webhooks_worker.py | 4 +- netbox/ipam/apps.py | 7 -- netbox/ipam/models.py | 9 -- netbox/netbox/settings.py | 2 +- netbox/secrets/models.py | 2 - netbox/tenancy/apps.py | 7 -- netbox/tenancy/models.py | 2 - netbox/utilities/views.py | 7 -- netbox/virtualization/apps.py | 7 -- netbox/virtualization/models.py | 4 - 17 files changed, 65 insertions(+), 192 deletions(-) diff --git a/netbox/circuits/apps.py b/netbox/circuits/apps.py index 734ba041e..613c347f2 100644 --- a/netbox/circuits/apps.py +++ b/netbox/circuits/apps.py @@ -9,8 +9,3 @@ class CircuitsConfig(AppConfig): def ready(self): import circuits.signals - - # register webhook signals - from extras.webhooks import register_signals - from .models import Circuit, Provider - register_signals([Circuit, Provider]) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 055ea65b1..6a2e55afc 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -60,7 +60,6 @@ class Provider(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() - serializer = 'circuits.api.serializers.ProviderSerializer' csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] class Meta: @@ -99,7 +98,6 @@ class CircuitType(ChangeLoggedModel): unique=True ) - serializer = 'circuits.api.serializers.CircuitTypeSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -174,7 +172,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() - serializer = 'circuits.api.serializers.CircuitSerializer' csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', ] diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index 50cdc3a47..d61a46d98 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -10,8 +10,3 @@ class DCIMConfig(AppConfig): def ready(self): import dcim.signals - - # register webhook signals - from extras.webhooks import register_signals - from .models import Site, Rack, RackGroup, Device, Interface - register_signals([Site, Rack, Device, Interface, RackGroup]) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 72d95eec1..ca18cbb25 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -79,7 +79,6 @@ class Region(MPTTModel, ChangeLoggedModel): unique=True ) - serializer = 'dcim.api.serializers.RegionSerializer' csv_headers = ['name', 'slug', 'parent'] class MPTTMeta: @@ -201,7 +200,6 @@ class Site(ChangeLoggedModel, CustomFieldModel): objects = SiteManager() tags = TaggableManager() - serializer = 'dcim.api.serializers.SiteSerializer' csv_headers = [ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', @@ -287,7 +285,6 @@ class RackGroup(ChangeLoggedModel): related_name='rack_groups' ) - serializer = 'dcim.api.serializers.RackGroupSerializer' csv_headers = ['site', 'name', 'slug'] class Meta: @@ -325,7 +322,6 @@ class RackRole(ChangeLoggedModel): ) color = ColorField() - serializer = 'dcim.api.serializers.RackRoleSerializer' csv_headers = ['name', 'slug', 'color'] class Meta: @@ -432,7 +428,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel): objects = RackManager() tags = TaggableManager() - serializer = 'dcim.api.serializers.RackSerializer' csv_headers = [ 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', 'desc_units', 'comments', @@ -636,8 +631,6 @@ class RackReservation(ChangeLoggedModel): max_length=100 ) - serializer = 'dcim.api.serializers.RackReservationSerializer' - class Meta: ordering = ['created'] @@ -697,7 +690,6 @@ class Manufacturer(ChangeLoggedModel): unique=True ) - serializer = 'dcim.api.serializers.ManufacturerSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -792,7 +784,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() - serializer = 'dcim.api.serializers.DeviceTypeSerializer' csv_headers = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', @@ -1076,7 +1067,6 @@ class DeviceRole(ChangeLoggedModel): help_text='Virtual machines may be assigned to this role' ) - serializer = 'dcim.api.serializers.DeviceRoleSerializer' csv_headers = ['name', 'slug', 'color', 'vm_role'] class Meta: @@ -1135,7 +1125,6 @@ class Platform(ChangeLoggedModel): verbose_name='Legacy RPC client' ) - serializer = 'dcim.api.serializers.PlatformSerializer' csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args'] class Meta: @@ -1302,7 +1291,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): objects = DeviceManager() tags = TaggableManager() - serializer = 'dcim.api.serializers.DeviceSerializer' csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', @@ -1858,8 +1846,6 @@ class Interface(ComponentModel): objects = InterfaceQuerySet.as_manager() tags = TaggableManager() - serializer = 'dcim.api.serializers.InterfaceSerializer' - class Meta: ordering = ['device', 'name'] unique_together = ['device', 'name'] @@ -2263,7 +2249,6 @@ class VirtualChassis(ChangeLoggedModel): tags = TaggableManager() - serializer = 'dcim.api.serializers.VirtualChassisSerializer' csv_headers = ['master', 'domain'] class Meta: diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index 7c44c2804..2b465ec53 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -9,7 +9,11 @@ from django.conf import settings from django.db.models.signals import post_delete, post_save from django.utils import timezone -from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE +from extras.webhooks import enqueue_webhooks +from .constants import ( + OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE, + WEBHOOK_MODELS +) from .models import ObjectChange @@ -18,12 +22,10 @@ _thread_locals = threading.local() def mark_object_changed(instance, **kwargs): """ - Mark an object as having been created, saved, or updated. At the end of the request, this change will be recorded. - We have to wait until the *end* of the request to the serialize the object, because related fields like tags and - custom fields have not yet been updated when the post_save signal is emitted. + Mark an object as having been created, saved, or updated. At the end of the request, this change will be recorded + and/or associated webhooks fired. We have to wait until the *end* of the request to the serialize the object, + because related fields like tags and custom fields have not yet been updated when the post_save signal is emitted. """ - if not hasattr(instance, 'log_change'): - return # Determine what action is being performed. The post_save signal sends a `created` boolean, whereas post_delete # does not. @@ -35,7 +37,12 @@ def mark_object_changed(instance, **kwargs): _thread_locals.changed_objects.append((instance, action)) -class ChangeLoggingMiddleware(object): +class ObjectChangeMiddleware(object): + """ + This middleware intercepts all requests to connects object signals to the Django runtime. The signals collect all + changed objects into a local thread by way of the `mark_object_changed()` receiver. At the end of the request, + the middleware iterates over the objects to process change events like Change Logging and Webhooks. + """ def __init__(self, get_response): self.get_response = get_response @@ -56,11 +63,16 @@ class ChangeLoggingMiddleware(object): # Process the request response = self.get_response(request) - # Record object changes + # Perform change logging and fire Webhook signals for obj, action in _thread_locals.changed_objects: - if obj.pk: + # Log object changes + if obj.pk and hasattr(obj, 'log_change'): obj.log_change(request.user, request.id, action) + # Enqueue Webhooks if they are enabled + if settings.WEBHOOKS_ENABLED and obj.__class__.__name__.lower() in WEBHOOK_MODELS: + enqueue_webhooks(obj, action) + # Housekeeping: 1% chance of clearing out expired ObjectChanges if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index a97eebd74..904381e4d 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -813,7 +813,6 @@ class ObjectChange(models.Model): editable=False ) - serializer = 'extras.api.serializers.ObjectChangeSerializer' csv_headers = [ 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'related_object_type', 'related_object_id', 'object_repr', 'object_data', diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index ca390a093..5acc696f4 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -1,119 +1,54 @@ -import time +import datetime -from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from django.db.models.signals import post_save, post_delete -from django.dispatch import Signal from extras.models import Webhook -from utilities.utils import dynamic_import +from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE +from utilities.api import get_serializer_for_model -def enqueue_webhooks(webhooks, model_class, data, event, signal_received_timestamp): +def enqueue_webhooks(instance, action): """ - Serialize data and enqueue webhooks + Find Webhook(s) assigned to this instance + action and enqueue them + to be processed """ - serializer_context = { - 'request': None, - } + type_create = action == OBJECTCHANGE_ACTION_CREATE + type_update = action == OBJECTCHANGE_ACTION_UPDATE + type_delete = action == OBJECTCHANGE_ACTION_DELETE - if isinstance(data, list): - serializer_property = data[0].serializer - serializer_cls = dynamic_import(serializer_property) - serialized_data = serializer_cls(data, context=serializer_context, many=True) - else: - serializer_property = data.serializer - serializer_cls = dynamic_import(serializer_property) - serialized_data = serializer_cls(data, context=serializer_context) + # Find assigned webhooks + obj_type = ContentType.objects.get_for_model(instance.__class__) + webhooks = Webhook.objects.filter( + Q(enabled=True) & + ( + Q(type_create=type_create) | + Q(type_update=type_update) | + Q(type_delete=type_delete) + ) & + Q(obj_type=obj_type) + ) - from django_rq import get_queue - webhook_queue = get_queue('default') + if webhooks: + # Get the Model's API serializer class and serialize the object + serializer_class = get_serializer_for_model(instance.__class__) + serializer_context = { + 'request': None, + } + serializer = serializer_class(instance, context=serializer_context) - for webhook in webhooks: - webhook_queue.enqueue("extras.webhooks_worker.process_webhook", - webhook, - serialized_data.data, - model_class, - event, - signal_received_timestamp) + # We must only import django_rq if the Webhooks feature is enabled. + # Only if we have gotten to ths point, is the feature enabled + from django_rq import get_queue + webhook_queue = get_queue('default') - -def post_save_receiver(sender, instance, created, **kwargs): - """ - Receives post_save signals from registered models. If the webhook - backend is enabled, queue any webhooks that apply to the event. - """ - if settings.WEBHOOKS_ENABLED: - signal_received_timestamp = time.time() - # look for any webhooks that match this event - updated = not created - obj_type = ContentType.objects.get_for_model(sender) - webhooks = Webhook.objects.filter( - Q(enabled=True) & - ( - Q(type_create=created) | - Q(type_update=updated) - ) & - Q(obj_type=obj_type) - ) - event = 'created' if created else 'updated' - if webhooks: - enqueue_webhooks(webhooks, sender, instance, event, signal_received_timestamp) - - -def post_delete_receiver(sender, instance, **kwargs): - """ - Receives post_delete signals from registered models. If the webhook - backend is enabled, queue any webhooks that apply to the event. - """ - if settings.WEBHOOKS_ENABLED: - signal_received_timestamp = time.time() - obj_type = ContentType.objects.get_for_model(sender) - # look for any webhooks that match this event - webhooks = Webhook.objects.filter(enabled=True, type_delete=True, obj_type=obj_type) - if webhooks: - enqueue_webhooks(webhooks, sender, instance, 'deleted', signal_received_timestamp) - - -def bulk_operation_receiver(sender, **kwargs): - """ - Receives bulk_operation_signal signals from registered models. If the webhook - backend is enabled, queue any webhooks that apply to the event. - """ - if settings.WEBHOOKS_ENABLED: - signal_received_timestamp = time.time() - event = kwargs['event'] - obj_type = ContentType.objects.get_for_model(sender) - # look for any webhooks that match this event - if event == 'created': - webhooks = Webhook.objects.filter(enabled=True, type_create=True, obj_type=obj_type) - elif event == 'updated': - webhooks = Webhook.objects.filter(enabled=True, type_update=True, obj_type=obj_type) - elif event == 'deleted': - webhooks = Webhook.objects.filter(enabled=True, type_delete=True, obj_type=obj_type) - else: - webhooks = None - - if webhooks: - enqueue_webhooks(webhooks, sender, list(kwargs['instances']), event, signal_received_timestamp) - - -# the bulk operation signal is used to overcome signals not being sent for bulk model changes -bulk_operation_signal = Signal(providing_args=["instances", "event"]) -bulk_operation_signal.connect(bulk_operation_receiver) - - -def register_signals(senders): - """ - Take a list of senders (Models) and register them to the post_save - and post_delete signal receivers. - """ - if settings.WEBHOOKS_ENABLED: - # only register signals if the backend is enabled - # this reduces load by not firing signals if the - # webhook backend feature is disabled - - for sender in senders: - post_save.connect(post_save_receiver, sender=sender) - post_delete.connect(post_delete_receiver, sender=sender) + # enqueue the webhooks: + for webhook in webhooks: + webhook_queue.enqueue( + "extras.webhooks_worker.process_webhook", + webhook, + serializer.data, + instance.__class__, + action, + str(datetime.datetime.now()) + ) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index c764375d7..0cd89bd0a 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -4,7 +4,7 @@ import hmac import requests from django_rq import job -from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED +from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJECTCHANGE_ACTION_CHOICES @job('default') @@ -13,7 +13,7 @@ def process_webhook(webhook, data, model_class, event, timestamp): Make a POST request to the defined Webhook """ payload = { - 'event': event, + 'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event], 'timestamp': timestamp, 'model': model_class.__name__, 'data': data diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index 0bde9853a..c944d1b2c 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -6,10 +6,3 @@ from django.apps import AppConfig class IPAMConfig(AppConfig): name = "ipam" verbose_name = "IPAM" - - def ready(self): - - # register webhook signals - from extras.webhooks import register_signals - from .models import Aggregate, Prefix, IPAddress, VLAN, VRF, VLANGroup, Service - register_signals([Aggregate, Prefix, IPAddress, VLAN, VRF, VLANGroup, Service]) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 103a895ff..fb8f97a1a 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -59,7 +59,6 @@ class VRF(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() - serializer = 'ipam.api.serializers.VRFSerializer' csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] class Meta: @@ -108,7 +107,6 @@ class RIR(ChangeLoggedModel): help_text='IP space managed by this RIR is considered private' ) - serializer = 'ipam.api.serializers.RIRSerializer' csv_headers = ['name', 'slug', 'is_private'] class Meta: @@ -162,7 +160,6 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() - serializer = 'ipam.api.serializers.AggregateSerializer' csv_headers = ['prefix', 'rir', 'date_added', 'description'] class Meta: @@ -243,7 +240,6 @@ class Role(ChangeLoggedModel): default=1000 ) - serializer = 'ipam.api.serializers.RoleSerializer' csv_headers = ['name', 'slug', 'weight'] class Meta: @@ -336,7 +332,6 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): objects = PrefixQuerySet.as_manager() tags = TaggableManager() - serializer = 'ipam.api.serializers.PrefixSerializer' csv_headers = [ 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', ] @@ -577,7 +572,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): objects = IPAddressManager() tags = TaggableManager() - serializer = 'ipam.api.serializers.IPAddressSerializer' csv_headers = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', 'description', @@ -677,7 +671,6 @@ class VLANGroup(ChangeLoggedModel): null=True ) - serializer = 'ipam.api.serializers.VLANGroupSerializer' csv_headers = ['name', 'slug', 'site'] class Meta: @@ -775,7 +768,6 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() - serializer = 'ipam.api.serializers.VLANSerializer' csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] class Meta: @@ -879,7 +871,6 @@ class Service(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() - serializer = 'ipam.api.serializers.ServiceSerializer' csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description'] class Meta: diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 9d7bd23f0..6b925b3a0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -175,7 +175,7 @@ MIDDLEWARE = ( 'utilities.middleware.ExceptionHandlingMiddleware', 'utilities.middleware.LoginRequiredMiddleware', 'utilities.middleware.APIVersionMiddleware', - 'extras.middleware.ChangeLoggingMiddleware', + 'extras.middleware.ObjectChangeMiddleware', ) ROOT_URLCONF = 'netbox.urls' diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 464129975..8bbf3d14d 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -285,7 +285,6 @@ class SecretRole(ChangeLoggedModel): blank=True ) - serializer = 'ipam.api.secrets.SecretSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -354,7 +353,6 @@ class Secret(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() plaintext = None - serializer = 'ipam.api.secrets.SecretSerializer' csv_headers = ['device', 'role', 'name', 'plaintext'] class Meta: diff --git a/netbox/tenancy/apps.py b/netbox/tenancy/apps.py index 6e29d4468..df2cd2fbb 100644 --- a/netbox/tenancy/apps.py +++ b/netbox/tenancy/apps.py @@ -5,10 +5,3 @@ from django.apps import AppConfig class TenancyConfig(AppConfig): name = 'tenancy' - - def ready(self): - - # register webhook signals - from extras.webhooks import register_signals - from .models import Tenant, TenantGroup - register_signals([Tenant, TenantGroup]) diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 33073e326..5a22143d3 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -23,7 +23,6 @@ class TenantGroup(ChangeLoggedModel): unique=True ) - serializer = 'tenancy.api.serializers.TenantGroupSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -78,7 +77,6 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() - serializer = 'tenancy.api.serializers.TenantSerializer' csv_headers = ['name', 'slug', 'group', 'description', 'comments'] class Meta: diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index ee01b5ef9..e11d681ef 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -25,7 +25,6 @@ from django.views.generic import View from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate -from extras.webhooks import bulk_operation_signal from utilities.utils import queryset_to_csv from utilities.forms import BootstrapMixin, CSVDataField from .constants import M2M_FIELD_TYPES @@ -757,9 +756,6 @@ class ComponentCreateView(View): field_links.append(field_link) getattr(self.model, field).through.objects.bulk_create(field_links) - # send the bulk operations signal for webhooks - bulk_operation_signal.send(sender=self.model, instances=new_components, event="created") - messages.success(request, "Added {} {} to {}.".format( len(new_components), self.model._meta.verbose_name_plural, parent )) @@ -829,9 +825,6 @@ class BulkComponentCreateView(GetReturnURLMixin, View): if not form.errors: self.model.objects.bulk_create(new_components) - # send the bulk operations signal for webhooks - bulk_operation_signal.send(sender=self.model, instances=new_components, event="created") - messages.success(request, "Added {} {} to {} {}.".format( len(new_components), self.model._meta.verbose_name_plural, diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py index 359d4bede..768508cfb 100644 --- a/netbox/virtualization/apps.py +++ b/netbox/virtualization/apps.py @@ -5,10 +5,3 @@ from django.apps import AppConfig class VirtualizationConfig(AppConfig): name = 'virtualization' - - def ready(self): - - # register webhook signals - from extras.webhooks import register_signals - from .models import Cluster, ClusterGroup, VirtualMachine - register_signals([Cluster, VirtualMachine, ClusterGroup]) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 904d04634..3d8a51fff 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -31,7 +31,6 @@ class ClusterType(ChangeLoggedModel): unique=True ) - serializer = 'virtualization.api.serializers.ClusterTypeSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -67,7 +66,6 @@ class ClusterGroup(ChangeLoggedModel): unique=True ) - serializer = 'virtualization.api.serializers.ClusterGroupSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -129,7 +127,6 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() - serializer = 'virtualization.api.serializers.ClusterSerializer' csv_headers = ['name', 'type', 'group', 'site', 'comments'] class Meta: @@ -250,7 +247,6 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): tags = TaggableManager() - serializer = 'virtualization.api.serializers.VirtualMachineSerializer' csv_headers = [ 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ] From 249c3d0e81aa899ff16b7164752d9fe28863005d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jul 2018 16:33:37 -0400 Subject: [PATCH 142/159] Fixes #2284: Record object deletions before the request finishes --- netbox/extras/middleware.py | 67 +++++++++++++++++++++---------------- netbox/extras/webhooks.py | 5 +++ 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index 2b465ec53..7dfddbad6 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -8,11 +8,11 @@ import uuid from django.conf import settings from django.db.models.signals import post_delete, post_save from django.utils import timezone +from django.utils.functional import curry from extras.webhooks import enqueue_webhooks from .constants import ( OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE, - WEBHOOK_MODELS ) from .models import ObjectChange @@ -20,58 +20,69 @@ from .models import ObjectChange _thread_locals = threading.local() -def mark_object_changed(instance, **kwargs): - """ - Mark an object as having been created, saved, or updated. At the end of the request, this change will be recorded - and/or associated webhooks fired. We have to wait until the *end* of the request to the serialize the object, - because related fields like tags and custom fields have not yet been updated when the post_save signal is emitted. - """ +def cache_changed_object(instance, **kwargs): - # Determine what action is being performed. The post_save signal sends a `created` boolean, whereas post_delete - # does not. - if 'created' in kwargs: - action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE - else: - action = OBJECTCHANGE_ACTION_DELETE + action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE - _thread_locals.changed_objects.append((instance, action)) + # Cache the object for further processing was the response has completed. + _thread_locals.changed_objects.append( + (instance, action) + ) + + +def _record_object_deleted(request, instance, **kwargs): + + # Record that the object was deleted. + if hasattr(instance, 'log_change'): + instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE) + + enqueue_webhooks(instance, OBJECTCHANGE_ACTION_DELETE) class ObjectChangeMiddleware(object): """ - This middleware intercepts all requests to connects object signals to the Django runtime. The signals collect all - changed objects into a local thread by way of the `mark_object_changed()` receiver. At the end of the request, - the middleware iterates over the objects to process change events like Change Logging and Webhooks. - """ + This middleware performs two functions in response to an object being created, updated, or deleted: + 1. Create an ObjectChange to reflect the modification to the object in the changelog. + 2. Enqueue any relevant webhooks. + + The post_save and pre_delete signals are employed to catch object modifications, however changes are recorded a bit + differently for each. Objects being saved are cached into thread-local storage for action *after* the response has + completed. This ensures that serialization of the object is performed only after any related objects (e.g. tags) + have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the + object is recorded before it (and any related objects) are actually deleted from the database. + """ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): - # Initialize the list of changed objects + # Initialize an empty list to cache objects being saved. _thread_locals.changed_objects = [] # Assign a random unique ID to the request. This will be used to associate multiple object changes made during # the same request. request.id = uuid.uuid4() - # Connect mark_object_changed to the post_save and post_delete receivers - post_save.connect(mark_object_changed, dispatch_uid='record_object_saved') - post_delete.connect(mark_object_changed, dispatch_uid='record_object_deleted') + # Signals don't include the request context, so we're currying it into the pre_delete function ahead of time. + record_object_deleted = curry(_record_object_deleted, request) + + # Connect our receivers to the post_save and pre_delete signals. + post_save.connect(cache_changed_object, dispatch_uid='record_object_saved') + post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted') # Process the request response = self.get_response(request) - # Perform change logging and fire Webhook signals + # Create records for any cached objects that were created/updated. for obj, action in _thread_locals.changed_objects: - # Log object changes - if obj.pk and hasattr(obj, 'log_change'): + + # Record the change + if hasattr(obj, 'log_change'): obj.log_change(request.user, request.id, action) - # Enqueue Webhooks if they are enabled - if settings.WEBHOOKS_ENABLED and obj.__class__.__name__.lower() in WEBHOOK_MODELS: - enqueue_webhooks(obj, action) + # Enqueue webhooks + enqueue_webhooks(obj, action) # Housekeeping: 1% chance of clearing out expired ObjectChanges if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 5acc696f4..e1269a3a5 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -1,11 +1,13 @@ import datetime +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db.models import Q from extras.models import Webhook from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from utilities.api import get_serializer_for_model +from .constants import WEBHOOK_MODELS def enqueue_webhooks(instance, action): @@ -13,6 +15,9 @@ def enqueue_webhooks(instance, action): Find Webhook(s) assigned to this instance + action and enqueue them to be processed """ + if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS: + return + type_create = action == OBJECTCHANGE_ACTION_CREATE type_update = action == OBJECTCHANGE_ACTION_UPDATE type_delete = action == OBJECTCHANGE_ACTION_DELETE From d6ce4fc9f353a3b9ae5bf1f700f099f6c22d49b8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Jul 2018 17:19:22 -0400 Subject: [PATCH 143/159] Docs fixes --- docs/configuration/index.md | 4 ++-- docs/installation/index.md | 12 ++++++------ mkdocs.yml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 90b4881e2..97857a4c4 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -4,8 +4,8 @@ NetBox's local configuration is stored in `netbox/netbox/configuration.py`. An e While NetBox has many configuration settings, only a few of them must be defined at the time of installation. -* [Required settings](configuration/required-settings.md) -* [Optional settings](configuration/optional-settings.md) +* [Required settings](required-settings.md) +* [Optional settings](optional-settings.md) ## Changing the Configuration diff --git a/docs/installation/index.md b/docs/installation/index.md index d0bf121cb..ae2ffb612 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -2,13 +2,13 @@ The following sections detail how to set up a new instance of NetBox: -1. [PostgreSQL database](installation/1-postgresql.md) -2. [NetBox components](installation/2-netbox.md) -3. [HTTP dameon](installation/3-http-daemon.md) -4. [LDAP authentication](installation/4-ldap.md) (optional) +1. [PostgreSQL database](1-postgresql.md) +2. [NetBox components](2-netbox.md) +3. [HTTP dameon](3-http-daemon.md) +4. [LDAP authentication](4-ldap.md) (optional) # Upgrading -If you are upgrading from an existing installation, please consult the [upgrading guide](installation/upgrading.md). +If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md). -NetBox v2.5 and later requires Python 3. Please see the instruction for [migrating to Python 3](installation/migrating-to-python3.md) if you are still using Python 2. +NetBox v2.5 and later requires Python 3. Please see the instruction for [migrating to Python 3](migrating-to-python3.md) if you are still using Python 2. diff --git a/mkdocs.yml b/mkdocs.yml index 6b27a8b78..68f6a7cc4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,7 +7,7 @@ pages: - Installing NetBox: 'installation/index.md' - 1. PostgreSQL: 'installation/1-postgresql.md' - 2. NetBox: 'installation/2-netbox.md' - - 3. Web Server: 'installation/3-web-server.md' + - 3. HTTP Daemon: 'installation/3-http-daemon.md' - 4. LDAP (Optional): 'installation/4-ldap.md' - Upgrading NetBox: 'installation/upgrading.md' - Migrating to Python3: 'installation/migrating-to-python3.md' From 45ab08aa767ddd1002968a2cbab0f1f0d2f3ca55 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 31 Jul 2018 11:32:49 -0400 Subject: [PATCH 144/159] Closes #2288: Fix exception when assigning objects to a ConfigContext via the API --- netbox/extras/api/serializers.py | 47 ++++++++++++++++++++++++++------ netbox/extras/tests/test_api.py | 34 ++++++++++++++++++++++- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 3f4ccadc6..d0d2c67b0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -8,15 +8,16 @@ from dcim.api.serializers import ( NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, ) -from dcim.models import Device, Rack, Site +from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from extras.models import ( ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction, ) from extras.constants import * from tenancy.api.serializers import NestedTenantSerializer, NestedTenantGroupSerializer +from tenancy.models import Tenant, TenantGroup from users.api.serializers import NestedUserSerializer from utilities.api import ( - ChoiceField, ContentTypeField, get_serializer_for_model, ValidatedModelSerializer, + ChoiceField, ContentTypeField, get_serializer_for_model, SerializedPKRelatedField, ValidatedModelSerializer, ) @@ -132,12 +133,42 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): # class ConfigContextSerializer(ValidatedModelSerializer): - regions = NestedRegionSerializer(required=False, many=True) - sites = NestedSiteSerializer(required=False, many=True) - roles = NestedDeviceRoleSerializer(required=False, many=True) - platforms = NestedPlatformSerializer(required=False, many=True) - tenant_groups = NestedTenantGroupSerializer(required=False, many=True) - tenants = NestedTenantSerializer(required=False, many=True) + regions = SerializedPKRelatedField( + queryset=Region.objects.all(), + serializer=NestedRegionSerializer, + required=False, + many=True + ) + sites = SerializedPKRelatedField( + queryset=Site.objects.all(), + serializer=NestedSiteSerializer, + required=False, + many=True + ) + roles = SerializedPKRelatedField( + queryset=DeviceRole.objects.all(), + serializer=NestedDeviceRoleSerializer, + required=False, + many=True + ) + platforms = SerializedPKRelatedField( + queryset=Platform.objects.all(), + serializer=NestedPlatformSerializer, + required=False, + many=True + ) + tenant_groups = SerializedPKRelatedField( + queryset=TenantGroup.objects.all(), + serializer=NestedTenantGroupSerializer, + required=False, + many=True + ) + tenants = SerializedPKRelatedField( + queryset=Tenant.objects.all(), + serializer=NestedTenantSerializer, + required=False, + many=True + ) class Meta: model = ConfigContext diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 2ff7826d3..50d62c463 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -7,9 +7,10 @@ from rest_framework import status from rest_framework.test import APITestCase from taggit.models import Tag -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site from extras.constants import GRAPH_TYPE_SITE from extras.models import ConfigContext, Graph, ExportTemplate +from tenancy.models import Tenant, TenantGroup from users.models import Token from utilities.testing import HttpStatusMixin @@ -363,9 +364,28 @@ class ConfigContextTest(HttpStatusMixin, APITestCase): def test_create_configcontext(self): + region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') + region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') + site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + role1 = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1') + role2 = DeviceRole.objects.create(name='Test Role 2', slug='test-role-2') + platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1') + platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2') + tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') + tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') + tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1') + tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2') + data = { 'name': 'Test Config Context 4', 'weight': 1000, + 'regions': [region1.pk, region2.pk], + 'sites': [site1.pk, site2.pk], + 'roles': [role1.pk, role2.pk], + 'platforms': [platform1.pk, platform2.pk], + 'tenant_groups': [tenantgroup1.pk, tenantgroup2.pk], + 'tenants': [tenant1.pk, tenant2.pk], 'data': {'foo': 'XXX'} } @@ -376,6 +396,18 @@ class ConfigContextTest(HttpStatusMixin, APITestCase): self.assertEqual(ConfigContext.objects.count(), 4) configcontext4 = ConfigContext.objects.get(pk=response.data['id']) self.assertEqual(configcontext4.name, data['name']) + self.assertEqual(region1.pk, data['regions'][0]) + self.assertEqual(region2.pk, data['regions'][1]) + self.assertEqual(site1.pk, data['sites'][0]) + self.assertEqual(site2.pk, data['sites'][1]) + self.assertEqual(role1.pk, data['roles'][0]) + self.assertEqual(role2.pk, data['roles'][1]) + self.assertEqual(platform1.pk, data['platforms'][0]) + self.assertEqual(platform2.pk, data['platforms'][1]) + self.assertEqual(tenantgroup1.pk, data['tenant_groups'][0]) + self.assertEqual(tenantgroup2.pk, data['tenant_groups'][1]) + self.assertEqual(tenant1.pk, data['tenants'][0]) + self.assertEqual(tenant2.pk, data['tenants'][1]) self.assertEqual(configcontext4.data, data['data']) def test_create_configcontext_bulk(self): From 2f33e9724d615b8bb7e32bd124d9dd9c3ded7a87 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 31 Jul 2018 16:17:24 -0400 Subject: [PATCH 145/159] force webhooks to use the same JSONEncoder class as DRF - fixes #2137 --- netbox/extras/webhooks_worker.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 0cd89bd0a..91e95baa1 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -1,8 +1,10 @@ import hashlib import hmac - import requests +import json + from django_rq import job +from rest_framework.utils.encoders import JSONEncoder from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJECTCHANGE_ACTION_CHOICES @@ -13,9 +15,9 @@ def process_webhook(webhook, data, model_class, event, timestamp): Make a POST request to the defined Webhook """ payload = { - 'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event], + 'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(), 'timestamp': timestamp, - 'model': model_class.__name__, + 'model': model_class._meta.model_name, 'data': data } headers = { @@ -28,7 +30,7 @@ def process_webhook(webhook, data, model_class, event, timestamp): } if webhook.http_content_type == WEBHOOK_CT_JSON: - params.update({'json': payload}) + params.update({'data': json.dumps(payload, cls=JSONEncoder)}) elif webhook.http_content_type == WEBHOOK_CT_X_WWW_FORM_ENCODED: params.update({'data': payload}) From 0b971aaf848132bc2980a1568933769224776156 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Aug 2018 12:38:57 -0400 Subject: [PATCH 146/159] Extended developer docs --- docs/development/index.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/development/index.md b/docs/development/index.md index d9f9df142..91086c61e 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -2,9 +2,21 @@ NetBox is maintained as a [GitHub project](https://github.com/digitalocean/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox. -All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base. +## Communication -# Project Structure +Communication among developers should always occur via public channels: + +* [GitHub issues](https://github.com/digitalocean/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue. +* [The mailing list](https://groups.google.com/forum/#!forum/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue. +* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. + +## Governance + +NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) model of governance, with [Jeremy Stretch](https://github.com/jeremystretch) ultimately responsible for all changes to the code base. While community contributions are welcomed and encouraged, the lead maintainer's primary role is to ensure the project's long-term maintainability and continued focus on its primary functions (in other words, avoid scope creep). + +## Project Structure + +All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base. NetBox components are arranged into functional subsections called _apps_ (a carryover from Django verancular). Each app holds the models, views, and templates relevant to a particular function: @@ -17,7 +29,7 @@ NetBox components are arranged into functional subsections called _apps_ (a carr * `utilities`: Resources which are not user-facing (extendable classes, etc.) * `virtualization`: Virtual machines and clusters -# Style Guide +## Style Guide NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). The following exceptions are noted: From 4ae7f2337af561a051b25fac24bfecb82396d4b9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Aug 2018 14:01:20 -0400 Subject: [PATCH 147/159] Closes #2213: Added squashed migrations --- ...01_initial_squashed_0010_circuit_status.py | 96 ++++ ...2_1821_squashed_0022_color_names_to_rgb.py | 261 +++++++++++ ...shed_0043_device_component_name_lengths.py | 435 ++++++++++++++++++ ...n_squashed_0055_virtualchassis_ordering.py | 146 ++++++ ..._squashed_0010_customfield_filter_logic.py | 193 ++++++++ ...18_remove_service_uniqueness_constraint.py | 240 ++++++++++ ...n_squashed_0020_ipaddress_add_role_carp.py | 38 ++ ..._initial_squashed_0003_unicode_literals.py | 82 ++++ ...optional_squashed_0003_unicode_literals.py | 28 ++ ...i_tokens_squashed_0002_unicode_literals.py | 35 ++ ...s_squashed_0004_virtualmachine_add_role.py | 34 ++ 11 files changed, 1588 insertions(+) create mode 100644 netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py create mode 100644 netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py create mode 100644 netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py create mode 100644 netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py create mode 100644 netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py create mode 100644 netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py create mode 100644 netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py create mode 100644 netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py create mode 100644 netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py create mode 100644 netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py create mode 100644 netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py diff --git a/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py b/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py new file mode 100644 index 000000000..1ae1c5d45 --- /dev/null +++ b/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:25 +from __future__ import unicode_literals + +import dcim.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('circuits', '0001_initial'), ('circuits', '0002_auto_20160622_1821'), ('circuits', '0003_provider_32bit_asn_support'), ('circuits', '0004_circuit_add_tenant'), ('circuits', '0005_circuit_add_upstream_speed'), ('circuits', '0006_terminations'), ('circuits', '0007_circuit_add_description'), ('circuits', '0008_circuittermination_interface_protect_on_delete'), ('circuits', '0009_unicode_literals'), ('circuits', '0010_circuit_status')] + + dependencies = [ + ('dcim', '0001_initial'), + ('dcim', '0022_color_names_to_rgb'), + ('tenancy', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Provider', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('asn', dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN')), + ('account', models.CharField(blank=True, max_length=30, verbose_name='Account number')), + ('portal_url', models.URLField(blank=True, verbose_name='Portal')), + ('noc_contact', models.TextField(blank=True, verbose_name='NOC contact')), + ('admin_contact', models.TextField(blank=True, verbose_name='Admin contact')), + ('comments', models.TextField(blank=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='CircuitType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Circuit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('cid', models.CharField(max_length=50, verbose_name='Circuit ID')), + ('install_date', models.DateField(blank=True, null=True, verbose_name='Date installed')), + ('commit_rate', models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')), + ('comments', models.TextField(blank=True)), + ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.Provider')), + ('type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.CircuitType')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='tenancy.Tenant')), + ('description', models.CharField(blank=True, max_length=100)), + ('status', models.PositiveSmallIntegerField(choices=[[2, 'Planned'], [3, 'Provisioning'], [1, 'Active'], [4, 'Offline'], [0, 'Deprovisioning'], [5, 'Decommissioned']], default=1)) + ], + options={ + 'ordering': ['provider', 'cid'], + }, + ), + migrations.AlterUniqueTogether( + name='circuit', + unique_together=set([('provider', 'cid')]), + ), + migrations.CreateModel( + name='CircuitTermination', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('term_side', models.CharField(choices=[('A', 'A'), ('Z', 'Z')], max_length=1, verbose_name='Termination')), + ('port_speed', models.PositiveIntegerField(verbose_name='Port speed (Kbps)')), + ('upstream_speed', models.PositiveIntegerField(blank=True, help_text='Upstream speed, if different from port speed', null=True, verbose_name='Upstream speed (Kbps)')), + ('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name='Cross-connect ID')), + ('pp_info', models.CharField(blank=True, max_length=100, verbose_name='Patch panel/port(s)')), + ('circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='circuits.Circuit')), + ('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_termination', to='dcim.Interface')), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.Site')), + ], + options={ + 'ordering': ['circuit', 'term_side'], + }, + ), + migrations.AlterUniqueTogether( + name='circuittermination', + unique_together=set([('circuit', 'term_side')]), + ), + ] diff --git a/netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py b/netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py new file mode 100644 index 000000000..a641c3a2f --- /dev/null +++ b/netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:06 +from __future__ import unicode_literals + +import dcim.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import utilities.fields + + +class Migration(migrations.Migration): + + replaces = [('dcim', '0002_auto_20160622_1821'), ('dcim', '0003_auto_20160628_1721'), ('dcim', '0004_auto_20160701_2049'), ('dcim', '0005_auto_20160706_1722'), ('dcim', '0006_add_device_primary_ip4_ip6'), ('dcim', '0007_device_copy_primary_ip'), ('dcim', '0008_device_remove_primary_ip'), ('dcim', '0009_site_32bit_asn_support'), ('dcim', '0010_devicebay_installed_device_set_null'), ('dcim', '0011_devicetype_part_number'), ('dcim', '0012_site_rack_device_add_tenant'), ('dcim', '0013_add_interface_form_factors'), ('dcim', '0014_rack_add_type_width'), ('dcim', '0015_rack_add_u_height_validator'), ('dcim', '0016_module_add_manufacturer'), ('dcim', '0017_rack_add_role'), ('dcim', '0018_device_add_asset_tag'), ('dcim', '0019_new_iface_form_factors'), ('dcim', '0020_rack_desc_units'), ('dcim', '0021_add_ff_flexstack'), ('dcim', '0022_color_names_to_rgb')] + + dependencies = [ + ('dcim', '0001_initial'), + ('ipam', '0001_initial'), + ('tenancy', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='rack', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cs_port_templates', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='consoleserverport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cs_ports', to='dcim.Device'), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='console_port_templates', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='consoleport', + name='cs_port', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name=b'Console server port'), + ), + migrations.AddField( + model_name='consoleport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='console_ports', to='dcim.Device'), + ), + migrations.AlterUniqueTogether( + name='rackgroup', + unique_together=set([('site', 'name'), ('site', 'slug')]), + ), + migrations.AlterUniqueTogether( + name='rack', + unique_together=set([('site', 'facility_id'), ('site', 'name')]), + ), + migrations.AlterUniqueTogether( + name='powerporttemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='powerport', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='poweroutlettemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='poweroutlet', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='module', + unique_together=set([('device', 'parent', 'name')]), + ), + migrations.AlterUniqueTogether( + name='interfacetemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AddField( + model_name='interface', + name='mac_address', + field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name=b'MAC Address'), + ), + migrations.AlterUniqueTogether( + name='interface', + unique_together=set([('device', 'name')]), + ), + migrations.AddField( + model_name='devicetype', + name='subdevice_role', + field=models.NullBooleanField(choices=[(None, b'None'), (True, b'Parent'), (False, b'Child')], default=None, help_text=b'Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name=b'Parent/child status'), + ), + migrations.AlterUniqueTogether( + name='devicetype', + unique_together=set([('manufacturer', 'slug'), ('manufacturer', 'model')]), + ), + migrations.AlterUniqueTogether( + name='device', + unique_together=set([('rack', 'position', 'face')]), + ), + migrations.AlterUniqueTogether( + name='consoleserverporttemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='consoleserverport', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='consoleporttemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='consoleport', + unique_together=set([('device', 'name')]), + ), + migrations.CreateModel( + name='DeviceBay', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name=b'Name')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bays', to='dcim.Device')), + ('installed_device', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_bay', to='dcim.Device')), + ], + options={ + 'ordering': ['device', 'name'], + }, + ), + migrations.CreateModel( + name='DeviceBayTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bay_templates', to='dcim.DeviceType')), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.AlterUniqueTogether( + name='devicebaytemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='devicebay', + unique_together=set([('device', 'name')]), + ), + migrations.AddField( + model_name='device', + name='primary_ip4', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name=b'Primary IPv4'), + ), + migrations.AddField( + model_name='device', + name='primary_ip6', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name=b'Primary IPv6'), + ), + migrations.AlterField( + model_name='site', + name='asn', + field=dcim.fields.ASNField(blank=True, null=True, verbose_name=b'ASN'), + ), + migrations.AlterField( + model_name='devicebay', + name='installed_device', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parent_bay', to='dcim.Device'), + ), + migrations.AddField( + model_name='devicetype', + name='part_number', + field=models.CharField(blank=True, help_text=b'Discrete part number (optional)', max_length=50), + ), + migrations.AddField( + model_name='device', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='rack', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='site', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='rack', + name='type', + field=models.PositiveSmallIntegerField(blank=True, choices=[(100, b'2-post frame'), (200, b'4-post frame'), (300, b'4-post cabinet'), (1000, b'Wall-mounted frame'), (1100, b'Wall-mounted cabinet')], null=True, verbose_name=b'Type'), + ), + migrations.AddField( + model_name='rack', + name='width', + field=models.PositiveSmallIntegerField(choices=[(19, b'19 inches'), (23, b'23 inches')], default=19, help_text=b'Rail-to-rail width', verbose_name=b'Width'), + ), + migrations.AlterField( + model_name='rack', + name='u_height', + field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name=b'Height (U)'), + ), + migrations.AddField( + model_name='module', + name='manufacturer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='modules', to='dcim.Manufacturer'), + ), + migrations.CreateModel( + name='RackRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('color', utilities.fields.ColorField(max_length=6)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='rack', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='dcim.RackRole'), + ), + migrations.AddField( + model_name='device', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, help_text=b'A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name=b'Asset tag'), + ), + migrations.AddField( + model_name='rack', + name='desc_units', + field=models.BooleanField(default=False, help_text=b'Units are numbered top-to-bottom', verbose_name=b'Descending units'), + ), + migrations.AlterField( + model_name='device', + name='position', + field=models.PositiveSmallIntegerField(blank=True, help_text=b'The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Position (U)'), + ), + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='devicerole', + name='color', + field=utilities.fields.ColorField(max_length=6), + ), + ] diff --git a/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py b/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py new file mode 100644 index 000000000..06902db1c --- /dev/null +++ b/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py @@ -0,0 +1,435 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:13 +from __future__ import unicode_literals + +import dcim.fields +from django.conf import settings +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import utilities.fields + + +def copy_site_from_rack(apps, schema_editor): + Device = apps.get_model('dcim', 'Device') + for device in Device.objects.all(): + device.site = device.rack.site + device.save() + + +def rpc_client_to_napalm_driver(apps, schema_editor): + """ + Migrate legacy RPC clients to their respective NAPALM drivers + """ + Platform = apps.get_model('dcim', 'Platform') + + Platform.objects.filter(rpc_client='juniper-junos').update(napalm_driver='junos') + Platform.objects.filter(rpc_client='cisco-ios').update(napalm_driver='ios') + +class Migration(migrations.Migration): + + replaces = [('dcim', '0023_devicetype_comments'), ('dcim', '0024_site_add_contact_fields'), ('dcim', '0025_devicetype_add_interface_ordering'), ('dcim', '0026_add_rack_reservations'), ('dcim', '0027_device_add_site'), ('dcim', '0028_device_copy_rack_to_site'), ('dcim', '0029_allow_rackless_devices'), ('dcim', '0030_interface_add_lag'), ('dcim', '0031_regions'), ('dcim', '0032_device_increase_name_length'), ('dcim', '0033_rackreservation_rack_editable'), ('dcim', '0034_rename_module_to_inventoryitem'), ('dcim', '0035_device_expand_status_choices'), ('dcim', '0036_add_ff_juniper_vcp'), ('dcim', '0037_unicode_literals'), ('dcim', '0038_wireless_interfaces'), ('dcim', '0039_interface_add_enabled_mtu'), ('dcim', '0040_inventoryitem_add_asset_tag_description'), ('dcim', '0041_napalm_integration'), ('dcim', '0042_interface_ff_10ge_cx4'), ('dcim', '0043_device_component_name_lengths')] + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dcim', '0022_color_names_to_rgb'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='site', + name='contact_email', + field=models.EmailField(blank=True, max_length=254, verbose_name=b'Contact E-mail'), + ), + migrations.AddField( + model_name='site', + name='contact_name', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='site', + name='contact_phone', + field=models.CharField(blank=True, max_length=20), + ), + migrations.AddField( + model_name='devicetype', + name='interface_ordering', + field=models.PositiveSmallIntegerField(choices=[[1, b'Slot/position'], [2, b'Name (alphabetically)']], default=1), + ), + migrations.CreateModel( + name='RackReservation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)), + ('created', models.DateTimeField(auto_now_add=True)), + ('description', models.CharField(max_length=100)), + ('rack', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack')), + ('user', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['created'], + }, + ), + migrations.AddField( + model_name='device', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'), + ), + migrations.RunPython( + code=copy_site_from_rack, + ), + migrations.AlterField( + model_name='device', + name='rack', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'), + ), + migrations.AlterField( + model_name='device', + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'), + ), + migrations.AddField( + model_name='interface', + name='lag', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name='Parent LAG'), + ), + migrations.CreateModel( + name='Region', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('lft', models.PositiveIntegerField(db_index=True, editable=False)), + ('rght', models.PositiveIntegerField(db_index=True, editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(db_index=True, editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.Region')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='site', + name='region', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.Region'), + ), + migrations.AlterField( + model_name='device', + name='name', + field=utilities.fields.NullableCharField(blank=True, max_length=64, null=True, unique=True), + ), + migrations.AlterField( + model_name='rackreservation', + name='rack', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack'), + ), + migrations.RenameModel( + old_name='Module', + new_name='InventoryItem', + ), + migrations.AlterField( + model_name='inventoryitem', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='dcim.Device'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.InventoryItem'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='manufacturer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.Manufacturer'), + ), + migrations.AlterField( + model_name='device', + name='status', + field=models.PositiveIntegerField(choices=[[1, b'Active'], [0, b'Offline'], [2, b'Planned'], [3, b'Staged'], [4, b'Failed'], [5, b'Inventory']], default=1, verbose_name=b'Status'), + ), + migrations.AlterField( + model_name='device', + name='status', + field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [2, 'Planned'], [3, 'Staged'], [4, 'Failed'], [5, 'Inventory']], default=1, verbose_name='Status'), + ), + migrations.AlterField( + model_name='consoleport', + name='connection_status', + field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True), + ), + migrations.AlterField( + model_name='consoleport', + name='cs_port', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name='Console server port'), + ), + migrations.AlterField( + model_name='device', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name='Asset tag'), + ), + migrations.AlterField( + model_name='device', + name='face', + field=models.PositiveSmallIntegerField(blank=True, choices=[[0, 'Front'], [1, 'Rear']], null=True, verbose_name='Rack face'), + ), + migrations.AlterField( + model_name='device', + name='position', + field=models.PositiveSmallIntegerField(blank=True, help_text='The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Position (U)'), + ), + migrations.AlterField( + model_name='device', + name='primary_ip4', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name='Primary IPv4'), + ), + migrations.AlterField( + model_name='device', + name='primary_ip6', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name='Primary IPv6'), + ), + migrations.AlterField( + model_name='device', + name='serial', + field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'), + ), + migrations.AlterField( + model_name='devicebay', + name='name', + field=models.CharField(max_length=50, verbose_name='Name'), + ), + migrations.AlterField( + model_name='devicetype', + name='interface_ordering', + field=models.PositiveSmallIntegerField(choices=[[1, 'Slot/position'], [2, 'Name (alphabetically)']], default=1), + ), + migrations.AlterField( + model_name='devicetype', + name='is_console_server', + field=models.BooleanField(default=False, help_text='This type of device has console server ports', verbose_name='Is a console server'), + ), + migrations.AlterField( + model_name='devicetype', + name='is_full_depth', + field=models.BooleanField(default=True, help_text='Device consumes both front and rear rack faces', verbose_name='Is full depth'), + ), + migrations.AlterField( + model_name='devicetype', + name='is_network_device', + field=models.BooleanField(default=True, help_text='This type of device has network interfaces', verbose_name='Is a network device'), + ), + migrations.AlterField( + model_name='devicetype', + name='is_pdu', + field=models.BooleanField(default=False, help_text='This type of device has power outlets', verbose_name='Is a PDU'), + ), + migrations.AlterField( + model_name='devicetype', + name='part_number', + field=models.CharField(blank=True, help_text='Discrete part number (optional)', max_length=50), + ), + migrations.AlterField( + model_name='devicetype', + name='subdevice_role', + field=models.NullBooleanField(choices=[(None, 'None'), (True, 'Parent'), (False, 'Child')], default=None, help_text='Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name='Parent/child status'), + ), + migrations.AlterField( + model_name='devicetype', + name='u_height', + field=models.PositiveSmallIntegerField(default=1, verbose_name='Height (U)'), + ), + migrations.AlterField( + model_name='interface', + name='mac_address', + field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name='MAC Address'), + ), + migrations.AlterField( + model_name='interface', + name='mgmt_only', + field=models.BooleanField(default=False, help_text='This interface is used only for out-of-band management', verbose_name='OOB Management'), + ), + migrations.AlterField( + model_name='interfaceconnection', + name='connection_status', + field=models.BooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True, verbose_name='Status'), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='mgmt_only', + field=models.BooleanField(default=False, verbose_name='Management only'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='discovered', + field=models.BooleanField(default=False, verbose_name='Discovered'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='name', + field=models.CharField(max_length=50, verbose_name='Name'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='part_id', + field=models.CharField(blank=True, max_length=50, verbose_name='Part ID'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='serial', + field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'), + ), + migrations.AlterField( + model_name='platform', + name='rpc_client', + field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='RPC client'), + ), + migrations.AlterField( + model_name='powerport', + name='connection_status', + field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True), + ), + migrations.AlterField( + model_name='rack', + name='desc_units', + field=models.BooleanField(default=False, help_text='Units are numbered top-to-bottom', verbose_name='Descending units'), + ), + migrations.AlterField( + model_name='rack', + name='facility_id', + field=utilities.fields.NullableCharField(blank=True, max_length=30, null=True, verbose_name='Facility ID'), + ), + migrations.AlterField( + model_name='rack', + name='type', + field=models.PositiveSmallIntegerField(blank=True, choices=[(100, '2-post frame'), (200, '4-post frame'), (300, '4-post cabinet'), (1000, 'Wall-mounted frame'), (1100, 'Wall-mounted cabinet')], null=True, verbose_name='Type'), + ), + migrations.AlterField( + model_name='rack', + name='u_height', + field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Height (U)'), + ), + migrations.AlterField( + model_name='rack', + name='width', + field=models.PositiveSmallIntegerField(choices=[(19, '19 inches'), (23, '23 inches')], default=19, help_text='Rail-to-rail width', verbose_name='Width'), + ), + migrations.AlterField( + model_name='site', + name='asn', + field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'), + ), + migrations.AlterField( + model_name='site', + name='contact_email', + field=models.EmailField(blank=True, max_length=254, verbose_name='Contact E-mail'), + ), + migrations.AddField( + model_name='interface', + name='enabled', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='interface', + name='mtu', + field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU'), + ), + migrations.AddField( + model_name='inventoryitem', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this item', max_length=50, null=True, unique=True, verbose_name='Asset tag'), + ), + migrations.AddField( + model_name='inventoryitem', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterModelOptions( + name='device', + options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + ), + migrations.AddField( + model_name='platform', + name='napalm_driver', + field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices.', max_length=50, verbose_name='NAPALM driver'), + ), + migrations.AlterField( + model_name='platform', + name='rpc_client', + field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='Legacy RPC client'), + ), + migrations.RunPython( + code=rpc_client_to_napalm_driver, + ), + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='consoleport', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='consoleserverport', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='interface', + name='name', + field=models.CharField(max_length=64), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='name', + field=models.CharField(max_length=64), + ), + migrations.AlterField( + model_name='poweroutlet', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='powerport', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='name', + field=models.CharField(max_length=50), + ), + ] diff --git a/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py b/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py new file mode 100644 index 000000000..42fc5f317 --- /dev/null +++ b/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:17 +from __future__ import unicode_literals + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import timezone_field.fields +import utilities.fields + + +class Migration(migrations.Migration): + + replaces = [('dcim', '0044_virtualization'), ('dcim', '0045_devicerole_vm_role'), ('dcim', '0046_rack_lengthen_facility_id'), ('dcim', '0047_more_100ge_form_factors'), ('dcim', '0048_rack_serial'), ('dcim', '0049_rackreservation_change_user'), ('dcim', '0050_interface_vlan_tagging'), ('dcim', '0051_rackreservation_tenant'), ('dcim', '0052_virtual_chassis'), ('dcim', '0053_platform_manufacturer'), ('dcim', '0054_site_status_timezone_description'), ('dcim', '0055_virtualchassis_ordering')] + + dependencies = [ + ('dcim', '0043_device_component_name_lengths'), + ('ipam', '0020_ipaddress_add_role_carp'), + ('virtualization', '0001_virtualization'), + ('tenancy', '0003_unicode_literals'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='cluster', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'), + ), + migrations.AddField( + model_name='interface', + name='virtual_machine', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine'), + ), + migrations.AlterField( + model_name='interface', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'), + ), + migrations.AddField( + model_name='devicerole', + name='vm_role', + field=models.BooleanField(default=True, help_text='Virtual machines may be assigned to this role', verbose_name='VM Role'), + ), + migrations.AlterField( + model_name='rack', + name='facility_id', + field=utilities.fields.NullableCharField(blank=True, max_length=50, null=True, verbose_name='Facility ID'), + ), + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + migrations.AddField( + model_name='rack', + name='serial', + field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'), + ), + migrations.AlterField( + model_name='rackreservation', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='interface', + name='mode', + field=models.PositiveSmallIntegerField(blank=True, choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], null=True), + ), + migrations.AddField( + model_name='interface', + name='tagged_vlans', + field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'), + ), + migrations.AddField( + model_name='interface', + name='untagged_vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'), + ), + migrations.AddField( + model_name='rackreservation', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='rackreservations', to='tenancy.Tenant'), + ), + migrations.CreateModel( + name='VirtualChassis', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('domain', models.CharField(blank=True, max_length=30)), + ('master', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')), + ], + options={ + 'verbose_name_plural': 'virtual chassis', + 'ordering': ['master'], + }, + ), + migrations.AddField( + model_name='device', + name='virtual_chassis', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='dcim.VirtualChassis'), + ), + migrations.AddField( + model_name='device', + name='vc_position', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]), + ), + migrations.AddField( + model_name='device', + name='vc_priority', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]), + ), + migrations.AlterUniqueTogether( + name='device', + unique_together=set([('rack', 'position', 'face'), ('virtual_chassis', 'vc_position')]), + ), + migrations.AddField( + model_name='platform', + name='manufacturer', + field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='platforms', to='dcim.Manufacturer'), + ), + migrations.AlterField( + model_name='platform', + name='napalm_driver', + field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices', max_length=50, verbose_name='NAPALM driver'), + ), + migrations.AddField( + model_name='site', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='site', + name='status', + field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [2, 'Planned'], [4, 'Retired']], default=1), + ), + migrations.AddField( + model_name='site', + name='time_zone', + field=timezone_field.fields.TimeZoneField(blank=True), + ), + ] diff --git a/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py new file mode 100644 index 000000000..0ac826ba4 --- /dev/null +++ b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:19 +from __future__ import unicode_literals + +import re +from distutils.version import StrictVersion + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import connection, migrations, models +import django.db.models.deletion +import extras.models +from django.db.utils import OperationalError + +from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT + + +def verify_postgresql_version(apps, schema_editor): + """ + Verify that PostgreSQL is version 9.4 or higher. + """ + try: + with connection.cursor() as cursor: + cursor.execute("SELECT VERSION()") + row = cursor.fetchone() + pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1) + if StrictVersion(pg_version) < StrictVersion('9.4.0'): + raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version)) + + # Skip if the database is missing (e.g. for CI testing) or misconfigured. + except OperationalError: + pass + + +class Migration(migrations.Migration): + + replaces = [('extras', '0001_initial'), ('extras', '0002_custom_fields'), ('extras', '0003_exporttemplate_add_description'), ('extras', '0004_topologymap_change_comma_to_semicolon'), ('extras', '0005_useraction_add_bulk_create'), ('extras', '0006_add_imageattachments'), ('extras', '0007_unicode_literals'), ('extras', '0008_reports'), ('extras', '0009_topologymap_type'), ('extras', '0010_customfield_filter_logic')] + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dcim', '0002_auto_20160622_1821'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='ExportTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('template_code', models.TextField()), + ('mime_type', models.CharField(blank=True, max_length=15)), + ('file_extension', models.CharField(blank=True, max_length=15)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('description', models.CharField(blank=True, max_length=200)), + ], + options={ + 'ordering': ['content_type', 'name'], + }, + ), + migrations.CreateModel( + name='Graph', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.PositiveSmallIntegerField(choices=[(100, 'Interface'), (200, 'Provider'), (300, 'Site')])), + ('weight', models.PositiveSmallIntegerField(default=1000)), + ('name', models.CharField(max_length=100, verbose_name='Name')), + ('source', models.CharField(max_length=500, verbose_name='Source URL')), + ('link', models.URLField(blank=True, verbose_name='Link URL')), + ], + options={ + 'ordering': ['type', 'weight', 'name'], + }, + ), + migrations.CreateModel( + name='TopologyMap', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('device_patterns', models.TextField(help_text='Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.')), + ('description', models.CharField(blank=True, max_length=100)), + ('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='topology_maps', to='dcim.Site')), + ('type', models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='UserAction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(auto_now_add=True)), + ('object_id', models.PositiveIntegerField(blank=True, null=True)), + ('action', models.PositiveSmallIntegerField(choices=[(1, 'created'), (7, 'bulk created'), (2, 'imported'), (3, 'modified'), (4, 'bulk edited'), (5, 'deleted'), (6, 'bulk deleted')])), + ('message', models.TextField(blank=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-time'], + }, + ), + migrations.AlterUniqueTogether( + name='exporttemplate', + unique_together=set([('content_type', 'name')]), + ), + migrations.CreateModel( + name='CustomField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.PositiveSmallIntegerField(choices=[(100, 'Text'), (200, 'Integer'), (300, 'Boolean (true/false)'), (400, 'Date'), (500, 'URL'), (600, 'Selection')], default=100)), + ('name', models.CharField(max_length=50, unique=True)), + ('label', models.CharField(blank=True, help_text="Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50)), + ('description', models.CharField(blank=True, max_length=100)), + ('required', models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.')), + ('default', models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans.', max_length=100)), + ('weight', models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.')), + ('obj_type', models.ManyToManyField(help_text='The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)')), + ('filter_logic', models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.')), + ], + options={ + 'ordering': ['weight', 'name'], + }, + ), + migrations.CreateModel( + name='CustomFieldChoice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(max_length=100)), + ('weight', models.PositiveSmallIntegerField(default=100, help_text='Higher weights appear lower in the list')), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField')), + ], + options={ + 'ordering': ['field', 'weight', 'value'], + }, + ), + migrations.CreateModel( + name='CustomFieldValue', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('obj_id', models.PositiveIntegerField()), + ('serialized_value', models.CharField(max_length=255)), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='extras.CustomField')), + ('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['obj_type', 'obj_id'], + }, + ), + migrations.AlterUniqueTogether( + name='customfieldvalue', + unique_together=set([('field', 'obj_type', 'obj_id')]), + ), + migrations.AlterUniqueTogether( + name='customfieldchoice', + unique_together=set([('field', 'value')]), + ), + migrations.CreateModel( + name='ImageAttachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('image', models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width')), + ('image_height', models.PositiveSmallIntegerField()), + ('image_width', models.PositiveSmallIntegerField()), + ('name', models.CharField(blank=True, max_length=50)), + ('created', models.DateTimeField(auto_now_add=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.RunPython( + code=verify_postgresql_version, + ), + migrations.CreateModel( + name='ReportResult', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('report', models.CharField(max_length=255, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('failed', models.BooleanField()), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['report'], + }, + ), + ] diff --git a/netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py b/netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py new file mode 100644 index 000000000..c4271ea51 --- /dev/null +++ b/netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:12 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import ipam.fields + + +class Migration(migrations.Migration): + + replaces = [('ipam', '0002_vrf_add_enforce_unique'), ('ipam', '0003_ipam_add_vlangroups'), ('ipam', '0004_ipam_vlangroup_uniqueness'), ('ipam', '0005_auto_20160725_1842'), ('ipam', '0006_vrf_vlan_add_tenant'), ('ipam', '0007_prefix_ipaddress_add_tenant'), ('ipam', '0008_prefix_change_order'), ('ipam', '0009_ipaddress_add_status'), ('ipam', '0010_ipaddress_help_texts'), ('ipam', '0011_rir_add_is_private'), ('ipam', '0012_services'), ('ipam', '0013_prefix_add_is_pool'), ('ipam', '0014_ipaddress_status_add_deprecated'), ('ipam', '0015_global_vlans'), ('ipam', '0016_unicode_literals'), ('ipam', '0017_ipaddress_roles'), ('ipam', '0018_remove_service_uniqueness_constraint')] + + dependencies = [ + ('dcim', '0010_devicebay_installed_device_set_null'), + ('dcim', '0022_color_names_to_rgb'), + ('tenancy', '0001_initial'), + ('ipam', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vrf', + name='enforce_unique', + field=models.BooleanField(default=True, help_text='Prevent duplicate prefixes/IP addresses within this VRF', verbose_name='Enforce unique space'), + ), + migrations.CreateModel( + name='VLANGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('slug', models.SlugField()), + ('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlan_groups', to='dcim.Site')), + ], + options={ + 'ordering': ['site', 'name'], + }, + ), + migrations.AddField( + model_name='vlan', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='ipam.VLANGroup'), + ), + migrations.AlterUniqueTogether( + name='vlangroup', + unique_together=set([('site', 'slug'), ('site', 'name')]), + ), + migrations.AlterModelOptions( + name='vlan', + options={'ordering': ['site', 'group', 'vid'], 'verbose_name': 'VLAN', 'verbose_name_plural': 'VLANs'}, + ), + migrations.AlterModelOptions( + name='vlangroup', + options={'ordering': ['site', 'name'], 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'}, + ), + migrations.AddField( + model_name='vlan', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterUniqueTogether( + name='vlan', + unique_together=set([('group', 'vid'), ('group', 'name')]), + ), + migrations.AlterField( + model_name='vlan', + name='name', + field=models.CharField(max_length=64), + ), + migrations.AddField( + model_name='vlan', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='vrf', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vrfs', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='ipaddress', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='prefix', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='tenancy.Tenant'), + ), + migrations.AlterModelOptions( + name='prefix', + options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'}, + ), + migrations.AddField( + model_name='ipaddress', + name='status', + field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated'), (5, 'DHCP')], default=1, help_text='The operational status of this IP', verbose_name='Status'), + ), + migrations.AlterField( + model_name='ipaddress', + name='address', + field=ipam.fields.IPAddressField(help_text=b'IPv4 or IPv6 address (with mask)'), + ), + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.OneToOneField(blank=True, help_text=b'The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name=b'NAT (Inside)'), + ), + migrations.AddField( + model_name='rir', + name='is_private', + field=models.BooleanField(default=False, help_text='IP space managed by this RIR is considered private', verbose_name='Private'), + ), + migrations.CreateModel( + name='Service', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=30)), + ('protocol', models.PositiveSmallIntegerField(choices=[(6, 'TCP'), (17, 'UDP')])), + ('port', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name='Port number')), + ('description', models.CharField(blank=True, max_length=100)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device')), + ('ipaddresses', models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name='IP addresses')), + ], + options={ + 'ordering': ['device', 'protocol', 'port'], + }, + ), + migrations.AlterUniqueTogether( + name='service', + unique_together=set([('device', 'protocol', 'port')]), + ), + migrations.AddField( + model_name='prefix', + name='is_pool', + field=models.BooleanField(default=False, help_text='All IP addresses within this prefix are considered usable', verbose_name='Is a pool'), + ), + migrations.AlterField( + model_name='prefix', + name='prefix', + field=ipam.fields.IPNetworkField(help_text=b'IPv4 or IPv6 network with mask'), + ), + migrations.AlterField( + model_name='prefix', + name='role', + field=models.ForeignKey(blank=True, help_text=b'The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'), + ), + migrations.AlterField( + model_name='vlan', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='dcim.Site'), + ), + migrations.AlterField( + model_name='aggregate', + name='family', + field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')]), + ), + migrations.AlterField( + model_name='aggregate', + name='rir', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='ipam.RIR', verbose_name='RIR'), + ), + migrations.AlterField( + model_name='ipaddress', + name='address', + field=ipam.fields.IPAddressField(help_text='IPv4 or IPv6 address (with mask)'), + ), + migrations.AlterField( + model_name='ipaddress', + name='family', + field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False), + ), + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.OneToOneField(blank=True, help_text='The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name='NAT (Inside)'), + ), + migrations.AlterField( + model_name='ipaddress', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='ipam.VRF', verbose_name='VRF'), + ), + migrations.AlterField( + model_name='prefix', + name='family', + field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False), + ), + migrations.AlterField( + model_name='prefix', + name='prefix', + field=ipam.fields.IPNetworkField(help_text='IPv4 or IPv6 network with mask'), + ), + migrations.AlterField( + model_name='prefix', + name='role', + field=models.ForeignKey(blank=True, help_text='The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'), + ), + migrations.AlterField( + model_name='prefix', + name='status', + field=models.PositiveSmallIntegerField(choices=[(0, 'Container'), (1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, help_text='Operational status of this prefix', verbose_name='Status'), + ), + migrations.AlterField( + model_name='prefix', + name='vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VLAN', verbose_name='VLAN'), + ), + migrations.AlterField( + model_name='prefix', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VRF', verbose_name='VRF'), + ), + migrations.AlterField( + model_name='vlan', + name='status', + field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, verbose_name='Status'), + ), + migrations.AlterField( + model_name='vlan', + name='vid', + field=models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)], verbose_name='ID'), + ), + migrations.AlterField( + model_name='vrf', + name='rd', + field=models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher'), + ), + migrations.AddField( + model_name='ipaddress', + name='role', + field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP')], help_text='The functional role of this IP', null=True, verbose_name='Role'), + ), + migrations.AlterUniqueTogether( + name='service', + unique_together=set([]), + ), + ] diff --git a/netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py b/netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py new file mode 100644 index 000000000..c8292bbc0 --- /dev/null +++ b/netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:14 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('ipam', '0019_virtualization'), ('ipam', '0020_ipaddress_add_role_carp')] + + dependencies = [ + ('ipam', '0018_remove_service_uniqueness_constraint'), + ('virtualization', '0001_virtualization'), + ] + + operations = [ + migrations.AlterModelOptions( + name='service', + options={'ordering': ['protocol', 'port']}, + ), + migrations.AddField( + model_name='service', + name='virtual_machine', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='virtualization.VirtualMachine'), + ), + migrations.AlterField( + model_name='service', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'), + ), + migrations.AlterField( + model_name='ipaddress', + name='role', + field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP'), (44, 'CARP')], help_text='The functional role of this IP', null=True, verbose_name='Role'), + ), + ] diff --git a/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py b/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py new file mode 100644 index 000000000..fb7d37431 --- /dev/null +++ b/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-08-01 17:45 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('secrets', '0001_initial'), ('secrets', '0002_userkey_add_session_key'), ('secrets', '0003_unicode_literals')] + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dcim', '0002_auto_20160622_1821'), + ('auth', '0007_alter_validators_add_error_messages'), + ] + + operations = [ + migrations.CreateModel( + name='SecretRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('groups', models.ManyToManyField(blank=True, related_name='secretroles', to='auth.Group')), + ('users', models.ManyToManyField(blank=True, related_name='secretroles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Secret', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(blank=True, max_length=100)), + ('ciphertext', models.BinaryField(max_length=65568)), + ('hash', models.CharField(editable=False, max_length=128)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='secrets', to='dcim.Device')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='secrets', to='secrets.SecretRole')), + ], + options={ + 'ordering': ['device', 'role', 'name'], + }, + ), + migrations.CreateModel( + name='UserKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('public_key', models.TextField(verbose_name='RSA public key')), + ('master_key_cipher', models.BinaryField(blank=True, max_length=512, null=True)), + ('user', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_key', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'permissions': (('activate_userkey', 'Can activate user keys for decryption'),), + 'ordering': ['user__username'], + }, + ), + migrations.AlterUniqueTogether( + name='secret', + unique_together=set([('device', 'role', 'name')]), + ), + migrations.CreateModel( + name='SessionKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cipher', models.BinaryField(max_length=512)), + ('hash', models.CharField(editable=False, max_length=128)), + ('created', models.DateTimeField(auto_now_add=True)), + ('userkey', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='session_key', to='secrets.UserKey')), + ], + options={ + 'ordering': ['userkey__user__username'], + }, + ), + ] diff --git a/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py b/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py new file mode 100644 index 000000000..d4258f4dc --- /dev/null +++ b/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:12 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('tenancy', '0002_tenant_group_optional'), ('tenancy', '0003_unicode_literals')] + + dependencies = [ + ('tenancy', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='tenant', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tenants', to='tenancy.TenantGroup'), + ), + migrations.AlterField( + model_name='tenant', + name='description', + field=models.CharField(blank=True, help_text='Long-form name (optional)', max_length=100), + ), + ] diff --git a/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py b/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py new file mode 100644 index 000000000..54a6078a0 --- /dev/null +++ b/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-08-01 17:43 +from __future__ import unicode_literals + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('users', '0001_api_tokens'), ('users', '0002_unicode_literals')] + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Token', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('expires', models.DateTimeField(blank=True, null=True)), + ('key', models.CharField(max_length=40, unique=True, validators=[django.core.validators.MinLengthValidator(40)])), + ('write_enabled', models.BooleanField(default=True, help_text='Permit create/update/delete operations using this key')), + ('description', models.CharField(blank=True, max_length=100)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'default_permissions': [], + }, + ), + ] diff --git a/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py b/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py new file mode 100644 index 000000000..295ec7d17 --- /dev/null +++ b/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:23 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('virtualization', '0002_virtualmachine_add_status'), ('virtualization', '0003_cluster_add_site'), ('virtualization', '0004_virtualmachine_add_role')] + + dependencies = [ + ('dcim', '0044_virtualization'), + ('virtualization', '0001_virtualization'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='status', + field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [3, 'Staged']], default=1, verbose_name='Status'), + ), + migrations.AddField( + model_name='cluster', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='dcim.Site'), + ), + migrations.AddField( + model_name='virtualmachine', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'), + ), + ] From 5e97f87a64e0c6910b46784b3b6d7f4cb125d371 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Aug 2018 14:05:26 -0400 Subject: [PATCH 148/159] PEP8 fix --- ...etype_comments_squashed_0043_device_component_name_lengths.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py b/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py index 06902db1c..a613552ad 100644 --- a/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py +++ b/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py @@ -28,6 +28,7 @@ def rpc_client_to_napalm_driver(apps, schema_editor): Platform.objects.filter(rpc_client='juniper-junos').update(napalm_driver='junos') Platform.objects.filter(rpc_client='cisco-ios').update(napalm_driver='ios') + class Migration(migrations.Migration): replaces = [('dcim', '0023_devicetype_comments'), ('dcim', '0024_site_add_contact_fields'), ('dcim', '0025_devicetype_add_interface_ordering'), ('dcim', '0026_add_rack_reservations'), ('dcim', '0027_device_add_site'), ('dcim', '0028_device_copy_rack_to_site'), ('dcim', '0029_allow_rackless_devices'), ('dcim', '0030_interface_add_lag'), ('dcim', '0031_regions'), ('dcim', '0032_device_increase_name_length'), ('dcim', '0033_rackreservation_rack_editable'), ('dcim', '0034_rename_module_to_inventoryitem'), ('dcim', '0035_device_expand_status_choices'), ('dcim', '0036_add_ff_juniper_vcp'), ('dcim', '0037_unicode_literals'), ('dcim', '0038_wireless_interfaces'), ('dcim', '0039_interface_add_enabled_mtu'), ('dcim', '0040_inventoryitem_add_asset_tag_description'), ('dcim', '0041_napalm_integration'), ('dcim', '0042_interface_ff_10ge_cx4'), ('dcim', '0043_device_component_name_lengths')] From 8f127e86ac76be3137458de033f41992a14a65ec Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Aug 2018 09:38:09 -0400 Subject: [PATCH 149/159] Mark UserAction as deprecated --- netbox/extras/api/views.py | 2 +- netbox/extras/models.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index b081d9ec4..b278f9a7c 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -237,7 +237,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet): class RecentActivityViewSet(ReadOnlyModelViewSet): """ - List all UserActions to provide a log of recent activity. + DEPRECATED: List all UserActions to provide a log of recent activity. """ queryset = UserAction.objects.all() serializer_class = serializers.UserActionSerializer diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 904381e4d..ad4fcdb18 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -903,10 +903,11 @@ class UserActionManager(models.Manager): self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message) +# TODO: Remove UserAction, which has been replaced by ObjectChange. @python_2_unicode_compatible class UserAction(models.Model): """ - A record of an action (add, edit, or delete) performed on an object by a User. + DEPRECATED: A record of an action (add, edit, or delete) performed on an object by a User. """ time = models.DateTimeField( auto_now_add=True, From 42a970e4520e5a7c6b4eb3b871dee9dab71a3488 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Aug 2018 10:09:24 -0400 Subject: [PATCH 150/159] Updated requirements for v2.4 release --- base_requirements.txt | 5 ++++- requirements.txt | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 90c3aa371..0e565c335 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,11 +1,14 @@ -Django +# django-filter-1.1.0 breaks with Django-2.1 +Django>=1.11,<2.1 django-cors-headers django-debug-toolbar +# django-filter-2.0.0 drops Python 2 support (blocked by #2000) django-filter==1.1.0 django-mptt django-tables2 django-taggit django-timezone-field +# https://github.com/encode/django-rest-framework/issues/6053 djangorestframework==3.8.1 drf-yasg[validation] graphviz diff --git a/requirements.txt b/requirements.txt index 4d3782e77..d641c54e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ Django>=1.11,<2.1 -django-cors-headers==2.3.0 +django-cors-headers==2.4.0 django-debug-toolbar==1.9.1 django-filter==1.1.0 -django-mptt==0.9.0 +django-mptt==0.9.1 django-tables2==1.21.2 django-taggit==0.22.2 django-timezone-field==2.1 From 78b8a582f856976f00b3575d2b10e10df8c17c9d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Aug 2018 11:53:31 -0400 Subject: [PATCH 151/159] Added release checklist to development docs --- docs/development/release-checklist.md | 81 +++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 82 insertions(+) create mode 100644 docs/development/release-checklist.md diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md new file mode 100644 index 000000000..cce096b48 --- /dev/null +++ b/docs/development/release-checklist.md @@ -0,0 +1,81 @@ +# Minor Version Bumps + +## Update Requirements + +Required Python packages are maintained in two files. `base_requirements.txt` contains a list of all the packages required by NetBox. Some of them may be pinned to a specific version of the package due to a known issue. For example: + +``` +# https://github.com/encode/django-rest-framework/issues/6053 +djangorestframework==3.8.1 +``` + +The other file is `requirements.txt`, which lists each of the required packages pinned to its current stable version. When NetBox is installed, the Python environment is configured to match this file. This helps ensure that a new release of a dependency doesn't break NetBox. + +Every minor version release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this: + +1. Create a new virtual environment. +2. Install the latest version of all required packages via pip: + +``` +pip install -U -r base_requirements.txt +``` + +3. Run all tests and check that the UI and API function as expected. +4. Update the package versions in `requirements.txt` as appropriate. + +## Update Static Libraries + +Update the following static libraries to their most recent stable release: + +* Bootstrap 3 +* Font Awesome 4 +* jQuery +* jQuery UI + +## Manually Perform a New Install + +Create a new installation of NetBox by following [the current documentation](http://netbox.readthedocs.io/en/latest/). This should be a manual process, so that issues with the documentation can be identified and corrected. + +## Close the Release Milestone + +Close the release milestone on GitHub. Ensure that there are no remaining open issues associated with it. + +--- + +# All Releases + +## Verify CI Build Status + +Ensure that continuous integration testing on the `develop` branch is completing successfully. + +## Update VERSION + +Update the `VERSION` constant in `settings.py` to the new release. + +## Submit a Pull Request + +Submit a pull request title **"Release vX.Y.X"** to merge the `develop` branch into `master`. Include a brief change log listing the features, improvements, and/or bugs addressed in the release. + +Once CI has completed on the PR, merge it. + +## Create a New Release + +Draft a [new release](https://github.com/digitalocean/netbox/releases/new) with the following parameters. + +* **Tag:** Current version (e.g. `v2.3.4`) +* **Target:** `master` +* **Title:** Version and date (e.g. `v2.3.4 - 2018-08-02`) + +Copy the description from the pull request into the release notes. + +## Update the Development Version + +On the `develop` branch, update `VERSION` in `settings.py` to point to the next release. For example, if you just released v2.3.4, set: + +``` +VERSION = 'v2.3.5-dev' +``` + +## Announce the Release + +Announce the release on the [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss). Include a link to the release and the (HTML-formatted) release notes. diff --git a/mkdocs.yml b/mkdocs.yml index 68f6a7cc4..532c60a70 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ pages: - Development: - Introduction: 'development/index.md' - Utility Views: 'development/utility-views.md' + - Release Checklist: 'development/release-checklist.md' markdown_extensions: - admonition: From ab37264ae14c0bfc8579f0c219b9b48fedf45ed0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Aug 2018 14:23:30 -0400 Subject: [PATCH 152/159] Added tests for assigning tags during POST/PATCH --- netbox/extras/tests/test_tags.py | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 netbox/extras/tests/test_tags.py diff --git a/netbox/extras/tests/test_tags.py b/netbox/extras/tests/test_tags.py new file mode 100644 index 000000000..d80184f16 --- /dev/null +++ b/netbox/extras/tests/test_tags.py @@ -0,0 +1,60 @@ +from __future__ import unicode_literals + +from django.contrib.auth.models import User +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from dcim.models import Site +from users.models import Token +from utilities.testing import HttpStatusMixin + + +class TaggedItemTest(HttpStatusMixin, APITestCase): + """ + Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH). + """ + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + def test_create_tagged_item(self): + + data = { + 'name': 'Test Site', + 'slug': 'test-site', + 'tags': ['Foo', 'Bar', 'Baz'] + } + + url = reverse('dcim-api:site-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) + site = Site.objects.get(pk=response.data['id']) + tags = [tag.name for tag in site.tags.all()] + self.assertEqual(sorted(tags), sorted(data['tags'])) + + def test_update_tagged_item(self): + + site = Site.objects.create( + name='Test Site', + slug='test-site', + tags=['Foo', 'Bar', 'Baz'] + ) + + data = { + 'tags': ['Foo', 'Bar', 'New Tag'] + } + + url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) + response = self.client.patch(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) + site = Site.objects.get(pk=response.data['id']) + tags = [tag.name for tag in site.tags.all()] + self.assertEqual(sorted(tags), sorted(data['tags'])) From aee01af2a1d241d9b00b5c76238e9d116f20f521 Mon Sep 17 00:00:00 2001 From: Mike Culbertson Date: Thu, 2 Aug 2018 18:58:14 -0400 Subject: [PATCH 153/159] Updated IPAddressInterfaceSerializer to subclass WritableNestedSerializer Also added readonly args to device and virtual_machine attrs to prevent unnecessary validation --- netbox/ipam/api/serializers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 92526eb56..d6b5fa511 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -234,10 +234,10 @@ class AvailablePrefixSerializer(serializers.Serializer): # IP addresses # -class IPAddressInterfaceSerializer(serializers.ModelSerializer): +class IPAddressInterfaceSerializer(WritableNestedSerializer): url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here - device = NestedDeviceSerializer() - virtual_machine = NestedVirtualMachineSerializer() + device = NestedDeviceSerializer(read_only=True) + virtual_machine = NestedVirtualMachineSerializer(read_only=True) class Meta(InterfaceSerializer.Meta): model = Interface From f1bc88fc0c779389e3b10cf15476c090233f3537 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Aug 2018 09:43:03 -0400 Subject: [PATCH 154/159] Adopt django-taggit-serializer for representation of assigned tags in the API --- base_requirements.txt | 1 + netbox/circuits/api/serializers.py | 12 +++--- netbox/dcim/api/serializers.py | 52 ++++++++++++------------ netbox/ipam/api/serializers.py | 24 +++++------ netbox/netbox/settings.py | 1 + netbox/secrets/api/serializers.py | 8 ++-- netbox/tenancy/api/serializers.py | 8 ++-- netbox/utilities/api.py | 20 ++------- netbox/utilities/utils.py | 4 +- netbox/virtualization/api/serializers.py | 12 +++--- requirements.txt | 1 + 11 files changed, 66 insertions(+), 77 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 0e565c335..6012ffa6c 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -7,6 +7,7 @@ django-filter==1.1.0 django-mptt django-tables2 django-taggit +django-taggit-serializer django-timezone-field # https://github.com/encode/django-rest-framework/issues/6053 djangorestframework==3.8.1 diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 4b2b7fbc3..739fbf8ff 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,22 +1,22 @@ from __future__ import unicode_literals from rest_framework import serializers -from taggit.models import Tag +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from circuits.constants import CIRCUIT_STATUS_CHOICES from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceField, TagField, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer # # Providers # -class ProviderSerializer(CustomFieldModelSerializer): - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) +class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): + tags = TagListSerializerField(required=False) class Meta: model = Provider @@ -57,12 +57,12 @@ class NestedCircuitTypeSerializer(WritableNestedSerializer): # Circuits # -class CircuitSerializer(CustomFieldModelSerializer): +class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): provider = NestedProviderSerializer() status = ChoiceField(choices=CIRCUIT_STATUS_CHOICES, required=False) type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = Circuit diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 2dc69a6f9..12981c6e3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator -from taggit.models import Tag +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from circuits.models import Circuit, CircuitTermination from dcim.constants import ( @@ -20,7 +20,7 @@ from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from users.api.serializers import NestedUserSerializer from utilities.api import ( - ChoiceField, SerializedPKRelatedField, TagField, TimeZoneField, ValidatedModelSerializer, + ChoiceField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, WritableNestedSerializer, ) from virtualization.models import Cluster @@ -50,12 +50,12 @@ class RegionSerializer(serializers.ModelSerializer): # Sites # -class SiteSerializer(CustomFieldModelSerializer): +class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=SITE_STATUS_CHOICES, required=False) region = NestedRegionSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = Site @@ -118,14 +118,14 @@ class NestedRackRoleSerializer(WritableNestedSerializer): # Racks # -class RackSerializer(CustomFieldModelSerializer): +class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer() group = NestedRackGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) role = NestedRackRoleSerializer(required=False, allow_null=True) type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False) width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = Rack @@ -220,12 +220,12 @@ class NestedManufacturerSerializer(WritableNestedSerializer): # Device types # -class DeviceTypeSerializer(CustomFieldModelSerializer): +class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() interface_ordering = ChoiceField(choices=IFACE_ORDERING_CHOICES, required=False) subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False) instance_count = serializers.IntegerField(source='instances.count', read_only=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = DeviceType @@ -389,7 +389,7 @@ class DeviceVirtualChassisSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'master'] -class DeviceSerializer(CustomFieldModelSerializer): +class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) @@ -404,7 +404,7 @@ class DeviceSerializer(CustomFieldModelSerializer): parent_device = serializers.SerializerMethodField() cluster = NestedClusterSerializer(required=False, allow_null=True) virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = Device @@ -459,9 +459,9 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): # Console server ports # -class ConsoleServerPortSerializer(ValidatedModelSerializer): +class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = ConsoleServerPort @@ -482,10 +482,10 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer): # Console ports # -class ConsolePortSerializer(ValidatedModelSerializer): +class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() cs_port = NestedConsoleServerPortSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = ConsolePort @@ -496,9 +496,9 @@ class ConsolePortSerializer(ValidatedModelSerializer): # Power outlets # -class PowerOutletSerializer(ValidatedModelSerializer): +class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = PowerOutlet @@ -519,10 +519,10 @@ class NestedPowerOutletSerializer(WritableNestedSerializer): # Power ports # -class PowerPortSerializer(ValidatedModelSerializer): +class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() power_outlet = NestedPowerOutletSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = PowerPort @@ -569,7 +569,7 @@ class InterfaceVLANSerializer(WritableNestedSerializer): fields = ['id', 'url', 'vid', 'name', 'display_name'] -class InterfaceSerializer(ValidatedModelSerializer): +class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) @@ -584,7 +584,7 @@ class InterfaceSerializer(ValidatedModelSerializer): required=False, many=True ) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = Interface @@ -640,10 +640,10 @@ class InterfaceSerializer(ValidatedModelSerializer): # Device bays # -class DeviceBaySerializer(ValidatedModelSerializer): +class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = DeviceBay @@ -662,12 +662,12 @@ class NestedDeviceBaySerializer(WritableNestedSerializer): # Inventory items # -class InventoryItemSerializer(ValidatedModelSerializer): +class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) manufacturer = NestedManufacturerSerializer() - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = InventoryItem @@ -718,9 +718,9 @@ class ContextualInterfaceConnectionSerializer(serializers.ModelSerializer): # Virtual chassis # -class VirtualChassisSerializer(ValidatedModelSerializer): +class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): master = NestedDeviceSerializer() - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = VirtualChassis diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 92526eb56..4d774706f 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -5,7 +5,7 @@ from collections import OrderedDict from rest_framework import serializers from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator -from taggit.models import Tag +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer from dcim.models import Interface @@ -16,7 +16,7 @@ from ipam.constants import ( from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ( - ChoiceField, SerializedPKRelatedField, TagField, ValidatedModelSerializer, WritableNestedSerializer, + ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, ) from virtualization.api.serializers import NestedVirtualMachineSerializer @@ -25,9 +25,9 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer # VRFs # -class VRFSerializer(CustomFieldModelSerializer): +class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = VRF @@ -87,9 +87,9 @@ class NestedRIRSerializer(WritableNestedSerializer): # Aggregates # -class AggregateSerializer(CustomFieldModelSerializer): +class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): rir = NestedRIRSerializer() - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = Aggregate @@ -147,13 +147,13 @@ class NestedVLANGroupSerializer(WritableNestedSerializer): # VLANs # -class VLANSerializer(CustomFieldModelSerializer): +class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer(required=False, allow_null=True) group = NestedVLANGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=VLAN_STATUS_CHOICES, required=False) role = NestedRoleSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = VLAN @@ -190,14 +190,14 @@ class NestedVLANSerializer(WritableNestedSerializer): # Prefixes # -class PrefixSerializer(CustomFieldModelSerializer): +class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True) status = ChoiceField(choices=PREFIX_STATUS_CHOICES, required=False) role = NestedRoleSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = Prefix @@ -254,13 +254,13 @@ class IPAddressInterfaceSerializer(serializers.ModelSerializer): return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request']) -class IPAddressSerializer(CustomFieldModelSerializer): +class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False) role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False) interface = IPAddressInterfaceSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = IPAddress diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6b925b3a0..c0ccfa12b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -144,6 +144,7 @@ INSTALLED_APPS = [ 'mptt', 'rest_framework', 'taggit', + 'taggit_serializer', 'timezone_field', 'circuits', 'dcim', diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 3c24edf2b..ee7217b63 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -2,12 +2,12 @@ from __future__ import unicode_literals from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator -from taggit.models import Tag +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.serializers import NestedDeviceSerializer from extras.api.customfields import CustomFieldModelSerializer from secrets.models import Secret, SecretRole -from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ValidatedModelSerializer, WritableNestedSerializer # @@ -33,11 +33,11 @@ class NestedSecretRoleSerializer(WritableNestedSerializer): # Secrets # -class SecretSerializer(CustomFieldModelSerializer): +class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer() role = NestedSecretRoleSerializer() plaintext = serializers.CharField() - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = Secret diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index c7b94e7e9..592e35a6e 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,11 +1,11 @@ from __future__ import unicode_literals from rest_framework import serializers -from taggit.models import Tag +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from extras.api.customfields import CustomFieldModelSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ValidatedModelSerializer, WritableNestedSerializer # @@ -31,9 +31,9 @@ class NestedTenantGroupSerializer(WritableNestedSerializer): # Tenants # -class TenantSerializer(CustomFieldModelSerializer): +class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): group = NestedTenantGroupSerializer(required=False) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = Tenant diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 296f1bb10..4dc56cc8a 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals from collections import OrderedDict import pytz -from taggit.models import Tag from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -13,7 +12,7 @@ from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.response import Response -from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError +from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet from .utils import dynamic_import @@ -56,20 +55,6 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): # Fields # -class TagField(RelatedField): - """ - Represent a writable list of Tags associated with an object (use with many=True). - """ - def to_internal_value(self, data): - obj = self.parent.parent.instance - content_type = ContentType.objects.get_for_model(obj) - tag, _ = Tag.objects.get_or_create(content_type=content_type, object_id=obj.pk, name=data) - return tag - - def to_representation(self, value): - return value.name - - class ChoiceField(Field): """ Represent a ChoiceField as {'value': , 'label': }. @@ -147,9 +132,10 @@ class ValidatedModelSerializer(ModelSerializer): """ def validate(self, data): - # Remove custom field data (if any) prior to model validation + # Remove custom fields data and tags (if any) prior to model validation attrs = data.copy() attrs.pop('custom_fields', None) + attrs.pop('tags', None) # Run clean() on an instance of the model if self.instance is None: diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 14c29d211..2ba5fa4ba 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -101,8 +101,8 @@ def serialize_object(obj, extra=None): } # Include any tags - if hasattr(obj, 'tags'): - data['tags'] = [tag.name for tag in obj.tags.all()] + # if hasattr(obj, 'tags'): + # data['tags'] = [tag.name for tag in obj.tags.all()] # Append any extra data if extra is not None: diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 81385878b..57ce1d57a 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from rest_framework import serializers -from taggit.models import Tag +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.constants import IFACE_MODE_CHOICES @@ -9,7 +9,7 @@ from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceField, TagField, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from virtualization.constants import VM_STATUS_CHOICES from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -56,11 +56,11 @@ class NestedClusterGroupSerializer(WritableNestedSerializer): # Clusters # -class ClusterSerializer(CustomFieldModelSerializer): +class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = Cluster @@ -90,7 +90,7 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'address'] -class VirtualMachineSerializer(CustomFieldModelSerializer): +class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=VM_STATUS_CHOICES, required=False) cluster = NestedClusterSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True) @@ -99,7 +99,7 @@ class VirtualMachineSerializer(CustomFieldModelSerializer): primary_ip = VirtualMachineIPAddressSerializer(read_only=True) primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) - tags = TagField(queryset=Tag.objects.all(), required=False, many=True) + tags = TagListSerializerField(required=False) class Meta: model = VirtualMachine diff --git a/requirements.txt b/requirements.txt index d641c54e3..d15bfb19d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ django-filter==1.1.0 django-mptt==0.9.1 django-tables2==1.21.2 django-taggit==0.22.2 +django-taggit-serializer==0.1.7 django-timezone-field==2.1 djangorestframework==3.8.1 drf-yasg[validation]==1.9.1 From 1bdfcd1dbedfc75706d009699901b693546da0e0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Aug 2018 10:45:53 -0400 Subject: [PATCH 155/159] Fixes #2301: Fix model validation on assignment of ManyToMany fields via API patch --- netbox/extras/tests/test_api.py | 5 +++++ netbox/utilities/api.py | 12 +++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 50d62c463..32551aff0 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -438,9 +438,13 @@ class ConfigContextTest(HttpStatusMixin, APITestCase): def test_update_configcontext(self): + region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') + region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') + data = { 'name': 'Test Config Context X', 'weight': 999, + 'regions': [region1.pk, region2.pk], 'data': {'foo': 'XXX'} } @@ -452,6 +456,7 @@ class ConfigContextTest(HttpStatusMixin, APITestCase): configcontext1 = ConfigContext.objects.get(pk=response.data['id']) self.assertEqual(configcontext1.name, data['name']) self.assertEqual(configcontext1.weight, data['weight']) + self.assertEqual(sorted([r.pk for r in configcontext1.regions.all()]), sorted(data['regions'])) self.assertEqual(configcontext1.data, data['data']) def test_delete_configcontext(self): diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 4dc56cc8a..0ce207d6e 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -126,6 +126,8 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField): # Serializers # +# TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant +# way to enforce model validation on the serializer. class ValidatedModelSerializer(ModelSerializer): """ Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation. @@ -137,13 +139,13 @@ class ValidatedModelSerializer(ModelSerializer): attrs.pop('custom_fields', None) attrs.pop('tags', None) + # Skip ManyToManyFields + for field in self.Meta.model._meta.get_fields(): + if isinstance(field, ManyToManyField): + attrs.pop(field.name, None) + # Run clean() on an instance of the model if self.instance is None: - model = self.Meta.model - # Ignore ManyToManyFields for new instances (a PK is needed for validation) - for field in model._meta.get_fields(): - if isinstance(field, ManyToManyField) and field.name in attrs: - attrs.pop(field.name) instance = self.Meta.model(**attrs) else: instance = self.instance From bd5e860be0a6124d1db417dc86ca148ba2e54988 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Aug 2018 11:39:26 -0400 Subject: [PATCH 156/159] Refactored the tests to remove a lot of boilerplate --- netbox/circuits/tests/test_api.py | 29 ++-- netbox/dcim/tests/test_api.py | 196 ++++++++--------------- netbox/extras/tests/test_api.py | 29 ++-- netbox/extras/tests/test_customfields.py | 11 +- netbox/extras/tests/test_tags.py | 11 +- netbox/ipam/tests/test_api.py | 59 +++---- netbox/secrets/tests/test_api.py | 29 ++-- netbox/tenancy/tests/test_api.py | 17 +- netbox/utilities/testing.py | 22 ++- netbox/virtualization/tests/test_api.py | 29 ++-- 10 files changed, 153 insertions(+), 279 deletions(-) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 3c1337ef2..a67dbc4ab 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,26 +1,21 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from circuits.constants import CIRCUIT_STATUS_ACTIVE, TERM_SIDE_A, TERM_SIDE_Z from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.models import Site from extras.constants import GRAPH_TYPE_PROVIDER from extras.models import Graph -from users.models import Token -from utilities.testing import HttpStatusMixin +from utilities.testing import APITestCase -class ProviderTest(HttpStatusMixin, APITestCase): +class ProviderTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ProviderTest, self).setUp() self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') @@ -128,13 +123,11 @@ class ProviderTest(HttpStatusMixin, APITestCase): self.assertEqual(Provider.objects.count(), 2) -class CircuitTypeTest(HttpStatusMixin, APITestCase): +class CircuitTypeTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(CircuitTypeTest, self).setUp() self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1') self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2') @@ -195,13 +188,11 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase): self.assertEqual(CircuitType.objects.count(), 2) -class CircuitTest(HttpStatusMixin, APITestCase): +class CircuitTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(CircuitTest, self).setUp() self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') @@ -303,13 +294,11 @@ class CircuitTest(HttpStatusMixin, APITestCase): self.assertEqual(Circuit.objects.count(), 2) -class CircuitTerminationTest(HttpStatusMixin, APITestCase): +class CircuitTerminationTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(CircuitTerminationTest, self).setUp() provider = Provider.objects.create(name='Test Provider', slug='test-provider') circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1d9ba74ba..6498e7f39 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,9 +1,7 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from dcim.constants import ( IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SITE_STATUS_ACTIVE, SUBDEVICE_ROLE_CHILD, @@ -17,17 +15,14 @@ from dcim.models import ( ) from ipam.models import VLAN from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE -from users.models import Token -from utilities.testing import HttpStatusMixin +from utilities.testing import APITestCase -class RegionTest(HttpStatusMixin, APITestCase): +class RegionTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(RegionTest, self).setUp() self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') @@ -114,13 +109,11 @@ class RegionTest(HttpStatusMixin, APITestCase): self.assertEqual(Region.objects.count(), 2) -class SiteTest(HttpStatusMixin, APITestCase): +class SiteTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(SiteTest, self).setUp() self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') @@ -241,13 +234,11 @@ class SiteTest(HttpStatusMixin, APITestCase): self.assertEqual(Site.objects.count(), 2) -class RackGroupTest(HttpStatusMixin, APITestCase): +class RackGroupTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(RackGroupTest, self).setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -343,13 +334,11 @@ class RackGroupTest(HttpStatusMixin, APITestCase): self.assertEqual(RackGroup.objects.count(), 2) -class RackRoleTest(HttpStatusMixin, APITestCase): +class RackRoleTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(RackRoleTest, self).setUp() self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00') @@ -443,13 +432,11 @@ class RackRoleTest(HttpStatusMixin, APITestCase): self.assertEqual(RackRole.objects.count(), 2) -class RackTest(HttpStatusMixin, APITestCase): +class RackTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(RackTest, self).setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -569,25 +556,22 @@ class RackTest(HttpStatusMixin, APITestCase): self.assertEqual(Rack.objects.count(), 2) -class RackReservationTest(HttpStatusMixin, APITestCase): +class RackReservationTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(RackReservationTest, self).setUp() - self.user1 = user self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.rack1 = Rack.objects.create(site=self.site1, name='Test Rack 1') self.rackreservation1 = RackReservation.objects.create( - rack=self.rack1, units=[1, 2, 3], user=user, description='Reservation #1', + rack=self.rack1, units=[1, 2, 3], user=self.user, description='Reservation #1', ) self.rackreservation2 = RackReservation.objects.create( - rack=self.rack1, units=[4, 5, 6], user=user, description='Reservation #2', + rack=self.rack1, units=[4, 5, 6], user=self.user, description='Reservation #2', ) self.rackreservation3 = RackReservation.objects.create( - rack=self.rack1, units=[7, 8, 9], user=user, description='Reservation #3', + rack=self.rack1, units=[7, 8, 9], user=self.user, description='Reservation #3', ) def test_get_rackreservation(self): @@ -609,7 +593,7 @@ class RackReservationTest(HttpStatusMixin, APITestCase): data = { 'rack': self.rack1.pk, 'units': [10, 11, 12], - 'user': self.user1.pk, + 'user': self.user.pk, 'description': 'Fourth reservation', } @@ -630,19 +614,19 @@ class RackReservationTest(HttpStatusMixin, APITestCase): { 'rack': self.rack1.pk, 'units': [10, 11, 12], - 'user': self.user1.pk, + 'user': self.user.pk, 'description': 'Reservation #4', }, { 'rack': self.rack1.pk, 'units': [13, 14, 15], - 'user': self.user1.pk, + 'user': self.user.pk, 'description': 'Reservation #5', }, { 'rack': self.rack1.pk, 'units': [16, 17, 18], - 'user': self.user1.pk, + 'user': self.user.pk, 'description': 'Reservation #6', }, ] @@ -661,7 +645,7 @@ class RackReservationTest(HttpStatusMixin, APITestCase): data = { 'rack': self.rack1.pk, 'units': [10, 11, 12], - 'user': self.user1.pk, + 'user': self.user.pk, 'description': 'Modified reservation', } @@ -683,13 +667,11 @@ class RackReservationTest(HttpStatusMixin, APITestCase): self.assertEqual(RackReservation.objects.count(), 2) -class ManufacturerTest(HttpStatusMixin, APITestCase): +class ManufacturerTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ManufacturerTest, self).setUp() self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2') @@ -776,13 +758,11 @@ class ManufacturerTest(HttpStatusMixin, APITestCase): self.assertEqual(Manufacturer.objects.count(), 2) -class DeviceTypeTest(HttpStatusMixin, APITestCase): +class DeviceTypeTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(DeviceTypeTest, self).setUp() self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2') @@ -884,13 +864,11 @@ class DeviceTypeTest(HttpStatusMixin, APITestCase): self.assertEqual(DeviceType.objects.count(), 2) -class ConsolePortTemplateTest(HttpStatusMixin, APITestCase): +class ConsolePortTemplateTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ConsolePortTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -986,13 +964,11 @@ class ConsolePortTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(ConsolePortTemplate.objects.count(), 2) -class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase): +class ConsoleServerPortTemplateTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ConsoleServerPortTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1088,13 +1064,11 @@ class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(ConsoleServerPortTemplate.objects.count(), 2) -class PowerPortTemplateTest(HttpStatusMixin, APITestCase): +class PowerPortTemplateTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(PowerPortTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1190,13 +1164,11 @@ class PowerPortTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(PowerPortTemplate.objects.count(), 2) -class PowerOutletTemplateTest(HttpStatusMixin, APITestCase): +class PowerOutletTemplateTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(PowerOutletTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1292,13 +1264,11 @@ class PowerOutletTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(PowerOutletTemplate.objects.count(), 2) -class InterfaceTemplateTest(HttpStatusMixin, APITestCase): +class InterfaceTemplateTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(InterfaceTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1394,13 +1364,11 @@ class InterfaceTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(InterfaceTemplate.objects.count(), 2) -class DeviceBayTemplateTest(HttpStatusMixin, APITestCase): +class DeviceBayTemplateTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(DeviceBayTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1496,13 +1464,11 @@ class DeviceBayTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(DeviceBayTemplate.objects.count(), 2) -class DeviceRoleTest(HttpStatusMixin, APITestCase): +class DeviceRoleTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(DeviceRoleTest, self).setUp() self.devicerole1 = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -1602,13 +1568,11 @@ class DeviceRoleTest(HttpStatusMixin, APITestCase): self.assertEqual(DeviceRole.objects.count(), 2) -class PlatformTest(HttpStatusMixin, APITestCase): +class PlatformTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(PlatformTest, self).setUp() self.platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1') self.platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2') @@ -1695,13 +1659,11 @@ class PlatformTest(HttpStatusMixin, APITestCase): self.assertEqual(Platform.objects.count(), 2) -class DeviceTest(HttpStatusMixin, APITestCase): +class DeviceTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(DeviceTest, self).setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -1823,13 +1785,11 @@ class DeviceTest(HttpStatusMixin, APITestCase): self.assertEqual(Device.objects.count(), 2) -class ConsolePortTest(HttpStatusMixin, APITestCase): +class ConsolePortTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ConsolePortTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -1930,13 +1890,11 @@ class ConsolePortTest(HttpStatusMixin, APITestCase): self.assertEqual(ConsolePort.objects.count(), 2) -class ConsoleServerPortTest(HttpStatusMixin, APITestCase): +class ConsoleServerPortTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ConsoleServerPortTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2033,13 +1991,11 @@ class ConsoleServerPortTest(HttpStatusMixin, APITestCase): self.assertEqual(ConsoleServerPort.objects.count(), 2) -class PowerPortTest(HttpStatusMixin, APITestCase): +class PowerPortTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(PowerPortTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2140,13 +2096,11 @@ class PowerPortTest(HttpStatusMixin, APITestCase): self.assertEqual(PowerPort.objects.count(), 2) -class PowerOutletTest(HttpStatusMixin, APITestCase): +class PowerOutletTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(PowerOutletTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2243,13 +2197,11 @@ class PowerOutletTest(HttpStatusMixin, APITestCase): self.assertEqual(PowerOutlet.objects.count(), 2) -class InterfaceTest(HttpStatusMixin, APITestCase): +class InterfaceTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(InterfaceTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2433,13 +2385,11 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertEqual(Interface.objects.count(), 2) -class DeviceBayTest(HttpStatusMixin, APITestCase): +class DeviceBayTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(DeviceBayTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2548,13 +2498,11 @@ class DeviceBayTest(HttpStatusMixin, APITestCase): self.assertEqual(DeviceBay.objects.count(), 2) -class InventoryItemTest(HttpStatusMixin, APITestCase): +class InventoryItemTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(InventoryItemTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2666,13 +2614,11 @@ class InventoryItemTest(HttpStatusMixin, APITestCase): self.assertEqual(InventoryItem.objects.count(), 2) -class ConsoleConnectionTest(HttpStatusMixin, APITestCase): +class ConsoleConnectionTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ConsoleConnectionTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2709,13 +2655,11 @@ class ConsoleConnectionTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data['count'], 3) -class PowerConnectionTest(HttpStatusMixin, APITestCase): +class PowerConnectionTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(PowerConnectionTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2752,13 +2696,11 @@ class PowerConnectionTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data['count'], 3) -class InterfaceConnectionTest(HttpStatusMixin, APITestCase): +class InterfaceConnectionTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(InterfaceConnectionTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2879,13 +2821,11 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): self.assertEqual(InterfaceConnection.objects.count(), 2) -class ConnectedDeviceTest(HttpStatusMixin, APITestCase): +class ConnectedDeviceTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ConnectedDeviceTest, self).setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -2921,13 +2861,11 @@ class ConnectedDeviceTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data['name'], self.device1.name) -class VirtualChassisTest(HttpStatusMixin, APITestCase): +class VirtualChassisTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(VirtualChassisTest, self).setUp() site = Site.objects.create(name='Test Site', slug='test-site') manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer') diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 32551aff0..3d0e5d1f7 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,27 +1,22 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from taggit.models import Tag from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site from extras.constants import GRAPH_TYPE_SITE from extras.models import ConfigContext, Graph, ExportTemplate from tenancy.models import Tenant, TenantGroup -from users.models import Token -from utilities.testing import HttpStatusMixin +from utilities.testing import APITestCase -class GraphTest(HttpStatusMixin, APITestCase): +class GraphTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(GraphTest, self).setUp() self.graph1 = Graph.objects.create( type=GRAPH_TYPE_SITE, name='Test Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1' @@ -121,13 +116,11 @@ class GraphTest(HttpStatusMixin, APITestCase): self.assertEqual(Graph.objects.count(), 2) -class ExportTemplateTest(HttpStatusMixin, APITestCase): +class ExportTemplateTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ExportTemplateTest, self).setUp() self.content_type = ContentType.objects.get_for_model(Device) self.exporttemplate1 = ExportTemplate.objects.create( @@ -230,13 +223,11 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(ExportTemplate.objects.count(), 2) -class TagTest(HttpStatusMixin, APITestCase): +class TagTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(TagTest, self).setUp() self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1') self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2') @@ -323,13 +314,11 @@ class TagTest(HttpStatusMixin, APITestCase): self.assertEqual(Tag.objects.count(), 2) -class ConfigContextTest(HttpStatusMixin, APITestCase): +class ConfigContextTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ConfigContextTest, self).setUp() self.configcontext1 = ConfigContext.objects.create( name='Test Config Context 1', diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 805ee5543..97eb69cd9 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -2,18 +2,15 @@ from __future__ import unicode_literals from datetime import date -from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from dcim.models import Site from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL from extras.models import CustomField, CustomFieldValue, CustomFieldChoice -from users.models import Token -from utilities.testing import HttpStatusMixin +from utilities.testing import APITestCase class CustomFieldTest(TestCase): @@ -102,13 +99,11 @@ class CustomFieldTest(TestCase): cf.delete() -class CustomFieldAPITest(HttpStatusMixin, APITestCase): +class CustomFieldAPITest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(CustomFieldAPITest, self).setUp() content_type = ContentType.objects.get_for_model(Site) diff --git a/netbox/extras/tests/test_tags.py b/netbox/extras/tests/test_tags.py index d80184f16..d4c0a79c6 100644 --- a/netbox/extras/tests/test_tags.py +++ b/netbox/extras/tests/test_tags.py @@ -1,25 +1,20 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from dcim.models import Site -from users.models import Token -from utilities.testing import HttpStatusMixin +from utilities.testing import APITestCase -class TaggedItemTest(HttpStatusMixin, APITestCase): +class TaggedItemTest(APITestCase): """ Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH). """ def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(TaggedItemTest, self).setUp() def test_create_tagged_item(self): diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 27fa92dfd..f295bee29 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1,25 +1,20 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from netaddr import IPNetwork from rest_framework import status -from rest_framework.test import APITestCase from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.constants import IP_PROTOCOL_TCP, IP_PROTOCOL_UDP from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from users.models import Token -from utilities.testing import HttpStatusMixin +from utilities.testing import APITestCase -class VRFTest(HttpStatusMixin, APITestCase): +class VRFTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(VRFTest, self).setUp() self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') self.vrf2 = VRF.objects.create(name='Test VRF 2', rd='65000:2') @@ -106,13 +101,11 @@ class VRFTest(HttpStatusMixin, APITestCase): self.assertEqual(VRF.objects.count(), 2) -class RIRTest(HttpStatusMixin, APITestCase): +class RIRTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(RIRTest, self).setUp() self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1') self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2') @@ -199,13 +192,11 @@ class RIRTest(HttpStatusMixin, APITestCase): self.assertEqual(RIR.objects.count(), 2) -class AggregateTest(HttpStatusMixin, APITestCase): +class AggregateTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(AggregateTest, self).setUp() self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1') self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2') @@ -294,13 +285,11 @@ class AggregateTest(HttpStatusMixin, APITestCase): self.assertEqual(Aggregate.objects.count(), 2) -class RoleTest(HttpStatusMixin, APITestCase): +class RoleTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(RoleTest, self).setUp() self.role1 = Role.objects.create(name='Test Role 1', slug='test-role-1') self.role2 = Role.objects.create(name='Test Role 2', slug='test-role-2') @@ -387,13 +376,11 @@ class RoleTest(HttpStatusMixin, APITestCase): self.assertEqual(Role.objects.count(), 2) -class PrefixTest(HttpStatusMixin, APITestCase): +class PrefixTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(PrefixTest, self).setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') @@ -614,13 +601,11 @@ class PrefixTest(HttpStatusMixin, APITestCase): self.assertEqual(len(response.data), 8) -class IPAddressTest(HttpStatusMixin, APITestCase): +class IPAddressTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(IPAddressTest, self).setUp() self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') self.ipaddress1 = IPAddress.objects.create(address=IPNetwork('192.168.0.1/24')) @@ -705,13 +690,11 @@ class IPAddressTest(HttpStatusMixin, APITestCase): self.assertEqual(IPAddress.objects.count(), 2) -class VLANGroupTest(HttpStatusMixin, APITestCase): +class VLANGroupTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(VLANGroupTest, self).setUp() self.vlangroup1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1') self.vlangroup2 = VLANGroup.objects.create(name='Test VLAN Group 2', slug='test-vlan-group-2') @@ -798,13 +781,11 @@ class VLANGroupTest(HttpStatusMixin, APITestCase): self.assertEqual(VLANGroup.objects.count(), 2) -class VLANTest(HttpStatusMixin, APITestCase): +class VLANTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(VLANTest, self).setUp() self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1') self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2') @@ -891,13 +872,11 @@ class VLANTest(HttpStatusMixin, APITestCase): self.assertEqual(VLAN.objects.count(), 2) -class ServiceTest(HttpStatusMixin, APITestCase): +class ServiceTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ServiceTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 583997612..985e0ea7f 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -2,15 +2,12 @@ from __future__ import unicode_literals import base64 -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from users.models import Token -from utilities.testing import HttpStatusMixin +from utilities.testing import APITestCase # Dummy RSA key pair for testing use only PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- @@ -52,13 +49,11 @@ qQIDAQAB -----END PUBLIC KEY-----""" -class SecretRoleTest(HttpStatusMixin, APITestCase): +class SecretRoleTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(SecretRoleTest, self).setUp() self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') @@ -145,21 +140,20 @@ class SecretRoleTest(HttpStatusMixin, APITestCase): self.assertEqual(SecretRole.objects.count(), 2) -class SecretTest(HttpStatusMixin, APITestCase): +class SecretTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) + super(SecretTest, self).setUp() - userkey = UserKey(user=user, public_key=PUBLIC_KEY) + userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() self.master_key = userkey.get_master_key(PRIVATE_KEY) session_key = SessionKey(userkey=userkey) session_key.save(self.master_key) self.header = { - 'HTTP_AUTHORIZATION': 'Token {}'.format(token.key), + 'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key), 'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key), } @@ -288,21 +282,20 @@ class SecretTest(HttpStatusMixin, APITestCase): self.assertEqual(Secret.objects.count(), 2) -class GetSessionKeyTest(HttpStatusMixin, APITestCase): +class GetSessionKeyTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) + super(GetSessionKeyTest, self).setUp() - userkey = UserKey(user=user, public_key=PUBLIC_KEY) + userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() master_key = userkey.get_master_key(PRIVATE_KEY) self.session_key = SessionKey(userkey=userkey) self.session_key.save(master_key) self.header = { - 'HTTP_AUTHORIZATION': 'Token {}'.format(token.key), + 'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key), } def test_get_session_key(self): diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 64fb80f86..95e1a6de3 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -1,22 +1,17 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from tenancy.models import Tenant, TenantGroup -from users.models import Token -from utilities.testing import HttpStatusMixin +from utilities.testing import APITestCase -class TenantGroupTest(HttpStatusMixin, APITestCase): +class TenantGroupTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(TenantGroupTest, self).setUp() self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') @@ -103,13 +98,11 @@ class TenantGroupTest(HttpStatusMixin, APITestCase): self.assertEqual(TenantGroup.objects.count(), 2) -class TenantTest(HttpStatusMixin, APITestCase): +class TenantTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(TenantTest, self).setUp() self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py index fa38166e0..dcc564dfa 100644 --- a/netbox/utilities/testing.py +++ b/netbox/utilities/testing.py @@ -1,11 +1,25 @@ from __future__ import unicode_literals +from django.contrib.auth.models import User +from rest_framework.test import APITestCase as _APITestCase + +from users.models import Token + + +class APITestCase(_APITestCase): + + def setUp(self): + """ + Create a superuser and token for API calls. + """ + self.user = User.objects.create(username='testuser', is_superuser=True) + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} -class HttpStatusMixin(object): - """ - Custom mixin to provide more detail in the event of an unexpected HTTP response. - """ def assertHttpStatus(self, response, expected_status): + """ + Provide more detail in the event of an unexpected HTTP response. + """ err_message = "Expected HTTP status {}; received {}: {}" self.assertEqual(response.status_code, expected_status, err_message.format( expected_status, response.status_code, response.data diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 275d0c123..6ecf6ec22 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -1,22 +1,17 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase -from users.models import Token -from utilities.testing import HttpStatusMixin +from utilities.testing import APITestCase from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine -class ClusterTypeTest(HttpStatusMixin, APITestCase): +class ClusterTypeTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ClusterTypeTest, self).setUp() self.clustertype1 = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') self.clustertype2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2') @@ -103,13 +98,11 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase): self.assertEqual(ClusterType.objects.count(), 2) -class ClusterGroupTest(HttpStatusMixin, APITestCase): +class ClusterGroupTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ClusterGroupTest, self).setUp() self.clustergroup1 = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') self.clustergroup2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2') @@ -196,13 +189,11 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase): self.assertEqual(ClusterGroup.objects.count(), 2) -class ClusterTest(HttpStatusMixin, APITestCase): +class ClusterTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(ClusterTest, self).setUp() cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') @@ -301,13 +292,11 @@ class ClusterTest(HttpStatusMixin, APITestCase): self.assertEqual(Cluster.objects.count(), 2) -class VirtualMachineTest(HttpStatusMixin, APITestCase): +class VirtualMachineTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + super(VirtualMachineTest, self).setUp() cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') From 24520717e4df5bcec6114ad76b296a38cbc15bcd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 6 Aug 2018 10:35:51 -0400 Subject: [PATCH 157/159] Fixes #2305: Make VLAN fields optional when creating a VM interface via the API --- netbox/virtualization/api/serializers.py | 25 ++-- netbox/virtualization/tests/test_api.py | 168 +++++++++++++++++++++++ 2 files changed, 184 insertions(+), 9 deletions(-) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 57ce1d57a..9664e9218 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -4,12 +4,12 @@ from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer -from dcim.constants import IFACE_MODE_CHOICES +from dcim.constants import IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer from virtualization.constants import VM_STATUS_CHOICES from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -135,7 +135,7 @@ class NestedVirtualMachineSerializer(WritableNestedSerializer): # # Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency -class InterfaceVLANSerializer(serializers.ModelSerializer): +class InterfaceVLANSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') class Meta: @@ -143,17 +143,24 @@ class InterfaceVLANSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'vid', 'name', 'display_name'] -class InterfaceSerializer(serializers.ModelSerializer): +class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() - mode = ChoiceField(choices=IFACE_MODE_CHOICES) - untagged_vlan = InterfaceVLANSerializer() - tagged_vlans = InterfaceVLANSerializer(many=True) + form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False) + mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False) + untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) + tagged_vlans = SerializedPKRelatedField( + queryset=VLAN.objects.all(), + serializer=InterfaceVLANSerializer, + required=False, + many=True + ) + tags = TagListSerializerField(required=False) class Meta: model = Interface fields = [ - 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan', 'tagged_vlans', - 'description', + 'id', 'virtual_machine', 'name', 'form_factor', 'enabled', 'mtu', 'mac_address', 'description', 'mode', + 'untagged_vlan', 'tagged_vlans', 'tags', ] diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 6ecf6ec22..b397e9dff 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -3,6 +3,9 @@ from __future__ import unicode_literals from django.urls import reverse from rest_framework import status +from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_TAGGED +from dcim.models import Interface +from ipam.models import VLAN from utilities.testing import APITestCase from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -390,3 +393,168 @@ class VirtualMachineTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(VirtualMachine.objects.count(), 2) + + +class InterfaceTest(APITestCase): + + def setUp(self): + + super(InterfaceTest, self).setUp() + + clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') + cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype) + self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') + self.interface1 = Interface.objects.create( + virtual_machine=self.virtualmachine, + name='Test Interface 1', + form_factor=IFACE_FF_VIRTUAL + ) + self.interface2 = Interface.objects.create( + virtual_machine=self.virtualmachine, + name='Test Interface 2', + form_factor=IFACE_FF_VIRTUAL + ) + self.interface3 = Interface.objects.create( + virtual_machine=self.virtualmachine, + name='Test Interface 3', + form_factor=IFACE_FF_VIRTUAL + ) + + self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1) + self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2) + self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3) + + def test_get_interface(self): + + url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.interface1.name) + + def test_list_interfaces(self): + + url = reverse('virtualization-api:interface-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_interface(self): + + data = { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 4', + } + + url = reverse('virtualization-api:interface-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Interface.objects.count(), 4) + interface4 = Interface.objects.get(pk=response.data['id']) + self.assertEqual(interface4.virtual_machine_id, data['virtual_machine']) + self.assertEqual(interface4.name, data['name']) + + def test_create_interface_with_802_1q(self): + + data = { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 4', + 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan3.id, + 'tagged_vlans': [self.vlan1.id, self.vlan2.id], + } + + url = reverse('virtualization-api:interface-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Interface.objects.count(), 4) + self.assertEqual(response.data['virtual_machine']['id'], data['virtual_machine']) + self.assertEqual(response.data['name'], data['name']) + self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan']) + self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans']) + + def test_create_interface_bulk(self): + + data = [ + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 4', + }, + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 5', + }, + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 6', + }, + ] + + url = reverse('virtualization-api:interface-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Interface.objects.count(), 6) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + self.assertEqual(response.data[2]['name'], data[2]['name']) + + def test_create_interface_802_1q_bulk(self): + + data = [ + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 4', + 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], + }, + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 5', + 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], + }, + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 6', + 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], + }, + ] + + url = reverse('virtualization-api:interface-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Interface.objects.count(), 6) + for i in range(0, 3): + self.assertEqual(response.data[i]['name'], data[i]['name']) + self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans']) + self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan']) + + def test_update_interface(self): + + data = { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface X', + } + + url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Interface.objects.count(), 3) + interface1 = Interface.objects.get(pk=response.data['id']) + self.assertEqual(interface1.name, data['name']) + + def test_delete_interface(self): + + url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Interface.objects.count(), 2) From d8580d6174f20c22387b9d8430c065d65dcc7a10 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 6 Aug 2018 11:57:09 -0400 Subject: [PATCH 158/159] Bumped drf-yasg to v1.9.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d15bfb19d..b3bee6b6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ django-taggit==0.22.2 django-taggit-serializer==0.1.7 django-timezone-field==2.1 djangorestframework==3.8.1 -drf-yasg[validation]==1.9.1 +drf-yasg[validation]==1.9.2 graphviz==0.8.4 Markdown==2.6.11 natsort==5.3.3 From af796871aa71c210681f00abf2bf509f813c959e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 6 Aug 2018 12:18:44 -0400 Subject: [PATCH 159/159] Release v2.4.0 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c0ccfa12b..6f1c240aa 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.4-beta1' +VERSION = '2.4.0' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))