From 0969c458b377b07396af6d3a8d6c6eb6ee9f79f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 30 Mar 2018 10:39:22 -0400 Subject: [PATCH 01/33] 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 02/33] 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 03/33] 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 04/33] 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 05/33] 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 06/33] 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 07/33] 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 08/33] 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 09/33] 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 10/33] 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 2c8bea1b5974c99777f17f6d7aec2041cd689cdb Mon Sep 17 00:00:00 2001 From: frankfarmer Date: Mon, 9 Apr 2018 17:42:54 -0700 Subject: [PATCH 11/33] avoid illegal casts on large integers A similar fix was applied in e5e32d82d00e454ba5edf25316828c1cdcd7673e --- netbox/dcim/querysets.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/querysets.py b/netbox/dcim/querysets.py index 3e977ddc6..32275ce01 100644 --- a/netbox/dcim/querysets.py +++ b/netbox/dcim/querysets.py @@ -43,13 +43,13 @@ class InterfaceQuerySet(QuerySet): }[method] TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" - ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)" - SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?([0-9]+)\/') AS integer)" - SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/)([0-9]+)') AS integer), 0)" - POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)" - SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)" - CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)" - VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)" + ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(\d{{1,9}})$') AS integer)" + SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})\/') AS integer)" + SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/)(\d{{1,9}})') AS integer), 0)" + POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{2}}(\d{{1,9}})') AS integer), 0)" + SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{3}}(\d{{1,9}})') AS integer), 0)" + CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)" + VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.(\d{{1,9}})$') AS integer), 0)" fields = { '_type': RawSQL(TYPE_RE.format(sql_col), []), From 81c027e7cffd73238ca108ff3bcf2ae4344a25c4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Apr 2018 12:45:25 -0400 Subject: [PATCH 12/33] Fixes #2023: Manufacturer should not be a required field when importing platforms --- 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 b360108bf..05dc0ea6f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -706,7 +706,7 @@ class PlatformCSVForm(forms.ModelForm): slug = SlugField() manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), - required=True, + required=False, to_field_name='name', help_text='Manufacturer name', error_messages={ From ef84889a573ea06308ade4af02cdcf5aa7c1798a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Apr 2018 12:54:21 -0400 Subject: [PATCH 13/33] Fixes #2022: Show 0 for zero-value fields on CSV export --- 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 c08bfef8c..9e96a66fd 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -14,7 +14,7 @@ def csv_format(data): for value in data: # Represent None or False with empty string - if value in [None, False]: + if value is None or value is False: csv.append('') continue From bcb1d9af0be7ae8bd1236b9b7a0e3c98f41e8f4a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Apr 2018 13:03:20 -0400 Subject: [PATCH 14/33] Fixes #2012: Fixed deselection of an IP address as the primary IP for its parent device/VM --- netbox/ipam/forms.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 5b2c6e672..3353d981f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -508,7 +508,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) ipaddress = super(IPAddressForm, self).save(*args, **kwargs) - # Assign this IPAddress as the primary for the associated Device. + # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. if self.cleaned_data['primary_for_parent']: parent = self.cleaned_data['interface'].parent if ipaddress.address.version == 4: @@ -516,14 +516,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) else: parent.primary_ip6 = ipaddress parent.save() - - # Clear assignment as primary for device if set. elif self.cleaned_data['interface']: parent = self.cleaned_data['interface'].parent - if ipaddress.address.version == 4 and parent.primary_ip4 == self: + if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: parent.primary_ip4 = None parent.save() - elif ipaddress.address.version == 6 and parent.primary_ip6 == self: + elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: parent.primary_ip6 = None parent.save() From b44aa9d32ea654de242215238d8c9131f4594d2d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Apr 2018 12:37:20 -0400 Subject: [PATCH 15/33] Fixes #2014: Allow assignment of VLANs to VM interfaces via the API --- netbox/virtualization/api/serializers.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 7e2ec1690..c03cdc166 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,10 +3,10 @@ from __future__ import unicode_literals from rest_framework import serializers from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer -from dcim.constants import IFACE_FF_VIRTUAL +from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer -from ipam.models import IPAddress +from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer from virtualization.constants import VM_STATUS_CHOICES @@ -133,13 +133,26 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer): # VM interfaces # +# Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency +class InterfaceVLANSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') + + class Meta: + model = VLAN + fields = ['id', 'url', 'vid', 'name', 'display_name'] + + class InterfaceSerializer(serializers.ModelSerializer): virtual_machine = NestedVirtualMachineSerializer() + mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES) + untagged_vlan = InterfaceVLANSerializer() + tagged_vlans = InterfaceVLANSerializer(many=True) class Meta: model = Interface fields = [ - 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'description', + 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan', 'tagged_vlans', + 'description', ] @@ -157,5 +170,6 @@ class WritableInterfaceSerializer(ValidatedModelSerializer): class Meta: model = Interface fields = [ - 'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', + 'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan', + 'tagged_vlans', 'description', ] From 9153c71cbf1a9f7273bc7a29e2505687de201a59 Mon Sep 17 00:00:00 2001 From: "Nicholas St. Germain" Date: Wed, 18 Apr 2018 14:02:40 -0500 Subject: [PATCH 16/33] stop force value split w ArrayFieldSelectMultiple --- netbox/utilities/forms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 15fb69f7f..69b102f5c 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -205,7 +205,8 @@ class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple): def optgroups(self, name, value, attrs=None): # Split the delimited string of values into a list - value = value[0].split(self.delimiter) + if value: + value = value[0].split(self.delimiter) return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs) def value_from_datadict(self, data, files, name): From a7389de109bd2e30ec4b995d646eea1a1c64f849 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 19 Apr 2018 11:07:19 -0400 Subject: [PATCH 17/33] Release v2.3.3 --- 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 e40106a21..7a8dd1a98 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.3.3-dev' +VERSION = '2.3.3' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From e5454d6714a6f3b42166e774e7ed2dd244f63d5a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 19 Apr 2018 11:17:17 -0400 Subject: [PATCH 18/33] Post-release version bump --- 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 7a8dd1a98..b74cbe7f4 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.3.3' +VERSION = '2.3.4-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From b0dafcf50f66dac20d3f13cc15a8d80fd3ab3113 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 8 May 2018 16:28:26 -0400 Subject: [PATCH 19/33] 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 20/33] 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 21/33] 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 22/33] 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 23/33] 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 24/33] 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 25/33] 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 26/33] 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 27/33] 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 28/33] 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 @@