From 0a6ebdee487d10702123de49646317bd0a28a0e7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 24 Feb 2021 19:00:14 -0500 Subject: [PATCH 001/223] Upgrade Django to 3.2b1 --- netbox/extras/querysets.py | 5 +++-- netbox/ipam/tests/test_views.py | 1 + netbox/netbox/settings.py | 2 ++ netbox/utilities/query_functions.py | 12 +----------- requirements.txt | 2 +- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index fbe7fe903d8..3710bec467f 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,7 +1,8 @@ +from django.contrib.postgres.aggregates import JSONBAgg from django.db.models import OuterRef, Subquery, Q from extras.models.tags import TaggedItem -from utilities.query_functions import EmptyGroupByJSONBAgg, OrderableJSONBAgg +from utilities.query_functions import EmptyGroupByJSONBAgg from utilities.querysets import RestrictedQuerySet @@ -47,7 +48,7 @@ class ConfigContextQuerySet(RestrictedQuerySet): if aggregate_data: return queryset.aggregate( - config_context_data=OrderableJSONBAgg('data', ordering=['weight', 'name']) + config_context_data=JSONBAgg('data', ordering=['weight', 'name']) )['config_context_data'] return queryset diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index db96bb896e0..b105ea7d967 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -210,6 +210,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): Role(name='Role 1', slug='role-1'), Role(name='Role 2', slug='role-2'), ) + Role.objects.bulk_create(roles) Prefix.objects.bulk_create([ Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 604b755d812..c1447808ed3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -375,6 +375,8 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH) CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( diff --git a/netbox/utilities/query_functions.py b/netbox/utilities/query_functions.py index abdc61b6b39..8ad7ceeade9 100644 --- a/netbox/utilities/query_functions.py +++ b/netbox/utilities/query_functions.py @@ -1,5 +1,4 @@ from django.contrib.postgres.aggregates import JSONBAgg -from django.contrib.postgres.aggregates.mixins import OrderableAggMixin from django.db.models import F, Func @@ -11,19 +10,10 @@ class CollateAsChar(Func): template = '(%(expressions)s) COLLATE "%(function)s"' -class OrderableJSONBAgg(OrderableAggMixin, JSONBAgg): - """ - TODO in Django 3.2 ordering is supported natively on JSONBAgg so this is no longer needed. - """ - template = '%(function)s(%(distinct)s%(expressions)s %(ordering)s)' - - -class EmptyGroupByJSONBAgg(OrderableJSONBAgg): +class EmptyGroupByJSONBAgg(JSONBAgg): """ JSONBAgg is a builtin aggregation function which means it includes the use of a GROUP BY clause. When used as an annotation for collecting config context data objects, the GROUP BY is incorrect. This subclass overrides the Django ORM aggregation control to remove the GROUP BY. - - TODO in Django 3.2 ordering is supported natively on JSONBAgg so we only need to inherit from JSONBAgg. """ contains_aggregate = False diff --git a/requirements.txt b/requirements.txt index 18ce530fd9f..f9e616c5ebb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==3.1.3 +Django==3.2b1 django-cacheops==5.1 django-cors-headers==3.5.0 django-debug-toolbar==3.1.1 From bec7ea7072364d7e99b28968a5e197050b0e5c54 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 24 Feb 2021 21:01:16 -0500 Subject: [PATCH 002/223] Standardize model types based on function --- .../migrations/0025_standardize_models.py | 37 ++ netbox/circuits/models.py | 12 +- .../migrations/0123_standardize_models.py | 462 ++++++++++++++++++ netbox/dcim/models/cables.py | 7 +- .../dcim/models/device_component_templates.py | 12 +- netbox/dcim/models/device_components.py | 21 +- netbox/dcim/models/devices.py | 19 +- netbox/dcim/models/power.py | 7 +- netbox/dcim/models/racks.py | 33 +- netbox/dcim/models/sites.py | 28 +- .../migrations/0054_standardize_models.py | 67 +++ netbox/extras/models/__init__.py | 3 +- netbox/extras/models/change_logging.py | 42 +- netbox/extras/models/customfields.py | 5 +- netbox/extras/models/models.py | 14 +- netbox/extras/models/tags.py | 6 +- .../migrations/0044_standardize_models.py | 77 +++ netbox/ipam/models/__init__.py | 17 + netbox/ipam/{models.py => models/ip.py} | 435 +---------------- netbox/ipam/models/services.py | 109 +++++ netbox/ipam/models/vlans.py | 202 ++++++++ netbox/ipam/models/vrfs.py | 139 ++++++ netbox/netbox/models.py | 184 +++++++ .../migrations/0013_standardize_models.py | 37 ++ netbox/secrets/models.py | 12 +- .../migrations/0012_standardize_models.py | 27 + netbox/tenancy/models.py | 24 +- .../migrations/0011_standardize_models.py | 26 + netbox/users/models.py | 7 +- .../migrations/0020_standardize_models.py | 62 +++ netbox/virtualization/models.py | 15 +- 31 files changed, 1569 insertions(+), 579 deletions(-) create mode 100644 netbox/circuits/migrations/0025_standardize_models.py create mode 100644 netbox/dcim/migrations/0123_standardize_models.py create mode 100644 netbox/extras/migrations/0054_standardize_models.py create mode 100644 netbox/ipam/migrations/0044_standardize_models.py create mode 100644 netbox/ipam/models/__init__.py rename netbox/ipam/{models.py => models/ip.py} (64%) create mode 100644 netbox/ipam/models/services.py create mode 100644 netbox/ipam/models/vlans.py create mode 100644 netbox/ipam/models/vrfs.py create mode 100644 netbox/netbox/models.py create mode 100644 netbox/secrets/migrations/0013_standardize_models.py create mode 100644 netbox/tenancy/migrations/0012_standardize_models.py create mode 100644 netbox/users/migrations/0011_standardize_models.py create mode 100644 netbox/virtualization/migrations/0020_standardize_models.py diff --git a/netbox/circuits/migrations/0025_standardize_models.py b/netbox/circuits/migrations/0025_standardize_models.py new file mode 100644 index 00000000000..2b1d2664e0e --- /dev/null +++ b/netbox/circuits/migrations/0025_standardize_models.py @@ -0,0 +1,37 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0024_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='circuittype', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='circuit', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittermination', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='provider', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 3d6d5d23282..e371df547f8 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -4,8 +4,9 @@ from taggit.managers import TaggableManager from dcim.fields import ASNField from dcim.models import CableTermination, PathEndpoint -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object from .choices import * @@ -21,7 +22,7 @@ __all__ = ( @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Provider(ChangeLoggedModel, CustomFieldModel): +class Provider(PrimaryModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -93,7 +94,8 @@ class Provider(ChangeLoggedModel, CustomFieldModel): ) -class CircuitType(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named "Long Haul," "Metro," or "Out-of-Band". @@ -133,7 +135,7 @@ class CircuitType(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Circuit(ChangeLoggedModel, CustomFieldModel): +class Circuit(PrimaryModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured @@ -233,7 +235,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): return self._get_termination('Z') -class CircuitTermination(PathEndpoint, CableTermination): +class CircuitTermination(BigIDModel, PathEndpoint, CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/dcim/migrations/0123_standardize_models.py b/netbox/dcim/migrations/0123_standardize_models.py new file mode 100644 index 00000000000..5050e1a268a --- /dev/null +++ b/netbox/dcim/migrations/0123_standardize_models.py @@ -0,0 +1,462 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0122_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleserverport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicebay', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicebay', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='devicebay', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicerole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='frontport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='frontport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='frontport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='frontporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='frontporttemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='frontporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='interface', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='interfacetemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='inventoryitem', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='manufacturer', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='platform', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='poweroutlet', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='poweroutlet', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='poweroutlet', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='powerport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='powerport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='powerport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='powerporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='powerporttemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='powerporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rackgroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rackrole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rearport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rearport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rearport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rearporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rearporttemplate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rearporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='region', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='cable', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='cablepath', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='device', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebay', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicerole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicetype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interface', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitem', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='manufacturer', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='platform', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerfeed', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlet', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerpanel', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rack', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackgroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackreservation', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackrole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='region', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='site', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualchassis', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 6a530bb4934..06ae150d45e 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -12,8 +12,9 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem +from extras.models import TaggedItem from extras.utils import extras_features +from netbox.models import BigIDModel, PrimaryModel from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters @@ -32,7 +33,7 @@ __all__ = ( # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Cable(ChangeLoggedModel, CustomFieldModel): +class Cable(PrimaryModel): """ A physical connection between two endpoints. """ @@ -305,7 +306,7 @@ class Cable(ChangeLoggedModel, CustomFieldModel): return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] -class CablePath(models.Model): +class CablePath(BigIDModel): """ A CablePath instance represents the physical path from an origin to a destination, including all intermediate elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index e52fe2602fd..7b718936d4f 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -5,6 +5,8 @@ from django.db import models from dcim.choices import * from dcim.constants import * from extras.models import ObjectChange +from extras.utils import extras_features +from netbox.models import PrimaryModel from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.ordering import naturalize_interface @@ -26,7 +28,7 @@ __all__ = ( ) -class ComponentTemplateModel(models.Model): +class ComponentTemplateModel(PrimaryModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -82,6 +84,7 @@ class ComponentTemplateModel(models.Model): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class ConsolePortTemplate(ComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. @@ -105,6 +108,7 @@ class ConsolePortTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class ConsoleServerPortTemplate(ComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. @@ -128,6 +132,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class PowerPortTemplate(ComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. @@ -174,6 +179,7 @@ class PowerPortTemplate(ComponentTemplateModel): }) +@extras_features('custom_fields', 'export_templates', 'webhooks') class PowerOutletTemplate(ComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. @@ -225,6 +231,7 @@ class PowerOutletTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class InterfaceTemplate(ComponentTemplateModel): """ A template for a physical data interface on a new Device. @@ -259,6 +266,7 @@ class InterfaceTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class FrontPortTemplate(ComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -319,6 +327,7 @@ class FrontPortTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class RearPortTemplate(ComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. @@ -349,6 +358,7 @@ class RearPortTemplate(ComponentTemplateModel): ) +@extras_features('custom_fields', 'export_templates', 'webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 452aacb5679..f78363ba9b6 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -13,6 +13,7 @@ from dcim.constants import * from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import PrimaryModel from utilities.fields import NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -37,7 +38,7 @@ __all__ = ( ) -class ComponentModel(models.Model): +class ComponentModel(PrimaryModel): """ An abstract model inherited by any model which has a parent Device. """ @@ -198,7 +199,7 @@ class PathEndpoint(models.Model): # Console ports # -@extras_features('export_templates', 'webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class ConsolePort(CableTermination, PathEndpoint, ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. @@ -234,7 +235,7 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): # Console server ports # -@extras_features('webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. @@ -270,7 +271,7 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): # Power ports # -@extras_features('export_templates', 'webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class PowerPort(CableTermination, PathEndpoint, ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. @@ -379,7 +380,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): # Power outlets # -@extras_features('webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. @@ -479,7 +480,7 @@ class BaseInterface(models.Model): return super().save(*args, **kwargs) -@extras_features('export_templates', 'webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. @@ -624,7 +625,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): # Pass-through ports # -@extras_features('webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class FrontPort(CableTermination, ComponentModel): """ A pass-through port on the front of a Device. @@ -687,7 +688,7 @@ class FrontPort(CableTermination, ComponentModel): }) -@extras_features('webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class RearPort(CableTermination, ComponentModel): """ A pass-through port on the rear of a Device. @@ -740,7 +741,7 @@ class RearPort(CableTermination, ComponentModel): # Device bays # -@extras_features('webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -800,7 +801,7 @@ class DeviceBay(ComponentModel): # Inventory items # -@extras_features('export_templates', 'webhooks') +@extras_features('custom_fields', 'export_templates', 'webhooks') class InventoryItem(MPTTModel, ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 29818ab9848..e73e4e73cbb 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -13,9 +13,10 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem +from extras.models import ConfigContextModel, TaggedItem from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet @@ -36,8 +37,8 @@ __all__ = ( # Device Types # -@extras_features('export_templates', 'webhooks') -class Manufacturer(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ @@ -76,7 +77,7 @@ class Manufacturer(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class DeviceType(ChangeLoggedModel, CustomFieldModel): +class DeviceType(PrimaryModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -338,7 +339,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): # Devices # -class DeviceRole(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to @@ -385,7 +387,8 @@ class DeviceRole(ChangeLoggedModel): ) -class Platform(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by @@ -449,7 +452,7 @@ class Platform(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): +class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -882,7 +885,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VirtualChassis(ChangeLoggedModel, CustomFieldModel): +class VirtualChassis(PrimaryModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 1215ced4cc4..25b13f10bcf 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,8 +6,9 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem +from extras.models import TaggedItem from extras.utils import extras_features +from netbox.models import PrimaryModel from utilities.querysets import RestrictedQuerySet from utilities.validators import ExclusionValidator from .device_components import CableTermination, PathEndpoint @@ -23,7 +24,7 @@ __all__ = ( # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class PowerPanel(ChangeLoggedModel, CustomFieldModel): +class PowerPanel(PrimaryModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -74,7 +75,7 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldModel): +class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): """ An electrical circuit delivered from a PowerPanel. """ diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index dfaf7da61dc..c4ec765255e 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -10,14 +10,15 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Sum from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey +from mptt.models import TreeForeignKey from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.elevations import RackElevationSVG -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet @@ -39,8 +40,8 @@ __all__ = ( # Racks # -@extras_features('export_templates') -class RackGroup(MPTTModel, ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class RackGroup(NestedGroupModel): """ Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that @@ -70,8 +71,6 @@ class RackGroup(MPTTModel, ChangeLoggedModel): blank=True ) - objects = TreeManager() - csv_headers = ['site', 'parent', 'name', 'slug', 'description'] class Meta: @@ -81,12 +80,6 @@ class RackGroup(MPTTModel, ChangeLoggedModel): ['site', 'slug'], ] - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) @@ -99,15 +92,6 @@ class RackGroup(MPTTModel, ChangeLoggedModel): self.description, ) - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) - ) - def clean(self): super().clean() @@ -116,7 +100,8 @@ class RackGroup(MPTTModel, ChangeLoggedModel): raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})") -class RackRole(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. """ @@ -159,7 +144,7 @@ class RackRole(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Rack(ChangeLoggedModel, CustomFieldModel): +class Rack(PrimaryModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a RackGroup. @@ -550,7 +535,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class RackReservation(ChangeLoggedModel, CustomFieldModel): +class RackReservation(PrimaryModel): """ One or more reserved units within a Rack. """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 923b33124f8..92d8e1b2606 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -1,15 +1,16 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey +from mptt.models import TreeForeignKey from taggit.managers import TaggableManager from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * from dcim.fields import ASNField -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.mptt import TreeManager @@ -25,8 +26,8 @@ __all__ = ( # Regions # -@extras_features('export_templates', 'webhooks') -class Region(MPTTModel, ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Region(NestedGroupModel): """ Sites can be grouped within geographic Regions. """ @@ -51,16 +52,8 @@ class Region(MPTTModel, ChangeLoggedModel): blank=True ) - objects = TreeManager() - csv_headers = ['name', 'slug', 'parent', 'description'] - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return "{}?region={}".format(reverse('dcim:site_list'), self.slug) @@ -78,22 +71,13 @@ class Region(MPTTModel, ChangeLoggedModel): Q(region__in=self.get_descendants()) ).count() - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) - ) - # # Sites # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Site(ChangeLoggedModel, CustomFieldModel): +class Site(PrimaryModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). diff --git a/netbox/extras/migrations/0054_standardize_models.py b/netbox/extras/migrations/0054_standardize_models.py new file mode 100644 index 00000000000..a836f365f49 --- /dev/null +++ b/netbox/extras/migrations/0054_standardize_models.py @@ -0,0 +1,67 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0053_rename_webhook_obj_type'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='configcontext', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customfield', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customlink', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='exporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='imageattachment', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='jobresult', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='objectchange', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tag', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='taggeditem', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='webhook', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index c6191bbd22e..daeed82238e 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,4 +1,4 @@ -from .change_logging import ChangeLoggedModel, ObjectChange +from .change_logging import ObjectChange from .customfields import CustomField, CustomFieldModel from .models import ( ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script, @@ -7,7 +7,6 @@ from .models import ( from .tags import Tag, TaggedItem __all__ = ( - 'ChangeLoggedModel', 'ConfigContext', 'ConfigContextModel', 'CustomField', diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 52ffd38f99a..a06043e7b61 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -4,48 +4,12 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse -from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object from extras.choices import * +from netbox.models import BigIDModel +from utilities.querysets import RestrictedQuerySet -# -# Change logging -# - -class ChangeLoggedModel(models.Model): - """ - An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be - null to facilitate adding these fields to existing instances via a database migration. - """ - created = models.DateField( - auto_now_add=True, - blank=True, - null=True - ) - last_updated = models.DateTimeField( - auto_now=True, - blank=True, - null=True - ) - - class Meta: - abstract = True - - def to_objectchange(self, action): - """ - Return a new ObjectChange representing a change made to this object. This will typically be called automatically - by ChangeLoggingMiddleware. - """ - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self) - ) - - -class ObjectChange(models.Model): +class ObjectChange(BigIDModel): """ Record a change to an object and the user account associated with that change. A change record may optionally indicate an object related to the one being changed. For example, a change to an interface may also indicate the diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index a69816d21de..05ac2a22281 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -12,12 +12,13 @@ from django.utils.safestring import mark_safe from extras.choices import * from extras.utils import FeatureQuery +from netbox.models import BigIDModel from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex -class CustomFieldModel(models.Model): +class CustomFieldModel(BigIDModel): """ Abstract class for any model which may have custom fields associated with it. """ @@ -77,7 +78,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -class CustomField(models.Model): +class CustomField(BigIDModel): content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 4917a7e44ef..d60a2cc96d8 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -14,9 +14,9 @@ from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * -from extras.models import ChangeLoggedModel from extras.querysets import ConfigContextQuerySet from extras.utils import extras_features, FeatureQuery, image_upload +from netbox.models import BigIDModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from utilities.utils import deepmerge, render_jinja2 @@ -25,7 +25,7 @@ from utilities.utils import deepmerge, render_jinja2 # Webhooks # -class Webhook(models.Model): +class Webhook(BigIDModel): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or delete in NetBox. The request will contain a representation of the object, which the remote application can act on. @@ -158,7 +158,7 @@ class Webhook(models.Model): # Custom links # -class CustomLink(models.Model): +class CustomLink(BigIDModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. @@ -210,7 +210,7 @@ class CustomLink(models.Model): # Export templates # -class ExportTemplate(models.Model): +class ExportTemplate(BigIDModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, @@ -285,7 +285,7 @@ class ExportTemplate(models.Model): # Image attachments # -class ImageAttachment(models.Model): +class ImageAttachment(BigIDModel): """ An uploaded image which is associated with an object. """ @@ -361,7 +361,7 @@ class ImageAttachment(models.Model): # Config contexts # -class ConfigContext(ChangeLoggedModel): +class ConfigContext(PrimaryModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B @@ -526,7 +526,7 @@ class Report(models.Model): # Job results # -class JobResult(models.Model): +class JobResult(BigIDModel): """ This model stores the results from running a user-defined report. """ diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 4b5acdc7645..22e9815a9e8 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from extras.models import ChangeLoggedModel +from netbox.models import BigIDModel, CoreModel from utilities.choices import ColorChoices from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet @@ -12,7 +12,7 @@ from utilities.querysets import RestrictedQuerySet # Tags # -class Tag(TagBase, ChangeLoggedModel): +class Tag(TagBase, CoreModel): color = ColorField( default=ColorChoices.COLOR_GREY ) @@ -44,7 +44,7 @@ class Tag(TagBase, ChangeLoggedModel): ) -class TaggedItem(GenericTaggedItemBase): +class TaggedItem(BigIDModel, GenericTaggedItemBase): tag = models.ForeignKey( to=Tag, related_name="%(app_label)s_%(class)s_items", diff --git a/netbox/ipam/migrations/0044_standardize_models.py b/netbox/ipam/migrations/0044_standardize_models.py new file mode 100644 index 00000000000..2762c9973cb --- /dev/null +++ b/netbox/ipam/migrations/0044_standardize_models.py @@ -0,0 +1,77 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0043_add_tenancy_to_aggregates'), + ] + + operations = [ + migrations.AddField( + model_name='rir', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='role', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='vlangroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='aggregate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='ipaddress', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='prefix', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rir', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='role', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='routetarget', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='service', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlan', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlangroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vrf', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py new file mode 100644 index 00000000000..7eecf8cfc94 --- /dev/null +++ b/netbox/ipam/models/__init__.py @@ -0,0 +1,17 @@ +from .ip import * +from .services import * +from .vlans import * +from .vrfs import * + +__all__ = ( + 'Aggregate', + 'IPAddress', + 'Prefix', + 'RIR', + 'Role', + 'RouteTarget', + 'Service', + 'VLAN', + 'VLANGroup', + 'VRF', +) diff --git a/netbox/ipam/models.py b/netbox/ipam/models/ip.py similarity index 64% rename from netbox/ipam/models.py rename to netbox/ipam/models/ip.py index e8b3dff0aac..d8d118a9958 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models/ip.py @@ -2,26 +2,25 @@ import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError -from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import F from django.urls import reverse from taggit.managers import TaggableManager -from dcim.models import Device, Interface -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from dcim.models import Device +from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel +from ipam.choices import * +from ipam.constants import * +from ipam.fields import IPNetworkField, IPAddressField +from ipam.managers import IPAddressManager +from ipam.querysets import PrefixQuerySet +from ipam.validators import DNSValidator from utilities.querysets import RestrictedQuerySet -from utilities.utils import array_to_string, serialize_object -from virtualization.models import VirtualMachine, VMInterface -from .choices import * -from .constants import * -from .fields import IPNetworkField, IPAddressField -from .managers import IPAddressManager -from .querysets import PrefixQuerySet -from .validators import DNSValidator +from utilities.utils import serialize_object +from virtualization.models import VirtualMachine __all__ = ( @@ -30,139 +29,11 @@ __all__ = ( 'Prefix', 'RIR', 'Role', - 'RouteTarget', - 'Service', - 'VLAN', - 'VLANGroup', - 'VRF', ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VRF(ChangeLoggedModel, CustomFieldModel): - """ - A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing - table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF - are said to exist in the "global" table.) - """ - name = models.CharField( - max_length=100 - ) - rd = models.CharField( - max_length=VRF_RD_MAX_LENGTH, - unique=True, - blank=True, - null=True, - verbose_name='Route distinguisher', - help_text='Unique route distinguisher (as defined in RFC 4364)' - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='vrfs', - blank=True, - null=True - ) - enforce_unique = models.BooleanField( - default=True, - verbose_name='Enforce unique space', - help_text='Prevent duplicate prefixes/IP addresses within this VRF' - ) - description = models.CharField( - max_length=200, - blank=True - ) - import_targets = models.ManyToManyField( - to='ipam.RouteTarget', - related_name='importing_vrfs', - blank=True - ) - export_targets = models.ManyToManyField( - to='ipam.RouteTarget', - related_name='exporting_vrfs', - blank=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] - clone_fields = [ - 'tenant', 'enforce_unique', 'description', - ] - - class Meta: - ordering = ('name', 'rd', 'pk') # (name, rd) may be non-unique - verbose_name = 'VRF' - verbose_name_plural = 'VRFs' - - def __str__(self): - return self.display_name or super().__str__() - - def get_absolute_url(self): - return reverse('ipam:vrf', args=[self.pk]) - - def to_csv(self): - return ( - self.name, - self.rd, - self.tenant.name if self.tenant else None, - self.enforce_unique, - self.description, - ) - - @property - def display_name(self): - if self.rd: - return f'{self.name} ({self.rd})' - return self.name - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class RouteTarget(ChangeLoggedModel, CustomFieldModel): - """ - A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. - """ - name = models.CharField( - max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) - unique=True, - help_text='Route target value (formatted in accordance with RFC 4360)' - ) - description = models.CharField( - max_length=200, - blank=True - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='route_targets', - blank=True, - null=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['name', 'description', 'tenant'] - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('ipam:routetarget', args=[self.pk]) - - def to_csv(self): - return ( - self.name, - self.description, - self.tenant.name if self.tenant else None, - ) - - -class RIR(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class RIR(OrganizationalModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918. @@ -210,7 +81,7 @@ class RIR(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Aggregate(ChangeLoggedModel, CustomFieldModel): +class Aggregate(PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -317,7 +188,8 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): return int(float(child_prefixes.size) / self.prefix.size * 100) -class Role(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or "Management." @@ -358,7 +230,7 @@ class Role(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Prefix(ChangeLoggedModel, CustomFieldModel): +class Prefix(PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be @@ -616,7 +488,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class IPAddress(ChangeLoggedModel, CustomFieldModel): +class IPAddress(PrimaryModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like @@ -831,274 +703,3 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): def get_role_class(self): return IPAddressRoleChoices.CSS_CLASSES.get(self.role) - - -class VLANGroup(ChangeLoggedModel): - """ - A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. - """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='vlan_groups', - blank=True, - null=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['name', 'slug', 'site', 'description'] - - class Meta: - ordering = ('site', 'name', 'pk') # (site, name) may be non-unique - unique_together = [ - ['site', 'name'], - ['site', 'slug'], - ] - verbose_name = 'VLAN group' - verbose_name_plural = 'VLAN groups' - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('ipam:vlangroup_vlans', args=[self.pk]) - - def to_csv(self): - return ( - self.name, - self.slug, - self.site.name if self.site else None, - self.description, - ) - - def get_next_available_vid(self): - """ - Return the first available VLAN ID (1-4094) in the group. - """ - vlan_ids = VLAN.objects.filter(group=self).values_list('vid', flat=True) - for i in range(1, 4095): - if i not in vlan_ids: - return i - return None - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VLAN(ChangeLoggedModel, CustomFieldModel): - """ - A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned - to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, - within which all VLAN IDs and names but be unique. - - Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero - or more Prefixes assigned to it. - """ - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='vlans', - blank=True, - null=True - ) - group = models.ForeignKey( - to='ipam.VLANGroup', - on_delete=models.PROTECT, - related_name='vlans', - blank=True, - null=True - ) - vid = models.PositiveSmallIntegerField( - verbose_name='ID', - validators=[MinValueValidator(1), MaxValueValidator(4094)] - ) - name = models.CharField( - max_length=64 - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='vlans', - blank=True, - null=True - ) - status = models.CharField( - max_length=50, - choices=VLANStatusChoices, - default=VLANStatusChoices.STATUS_ACTIVE - ) - role = models.ForeignKey( - to='ipam.Role', - on_delete=models.SET_NULL, - related_name='vlans', - blank=True, - null=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] - clone_fields = [ - 'site', 'group', 'tenant', 'status', 'role', 'description', - ] - - class Meta: - ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique - unique_together = [ - ['group', 'vid'], - ['group', 'name'], - ] - verbose_name = 'VLAN' - verbose_name_plural = 'VLANs' - - def __str__(self): - return self.display_name or super().__str__() - - def get_absolute_url(self): - return reverse('ipam:vlan', args=[self.pk]) - - def clean(self): - super().clean() - - # Validate VLAN group - if self.group and self.group.site != self.site: - raise ValidationError({ - 'group': "VLAN group must belong to the assigned site ({}).".format(self.site) - }) - - def to_csv(self): - return ( - self.site.name if self.site else None, - self.group.name if self.group else None, - self.vid, - self.name, - self.tenant.name if self.tenant else None, - self.get_status_display(), - self.role.name if self.role else None, - self.description, - ) - - @property - def display_name(self): - return f'{self.name} ({self.vid})' - - def get_status_class(self): - return VLANStatusChoices.CSS_CLASSES.get(self.status) - - def get_interfaces(self): - # Return all device interfaces assigned to this VLAN - return Interface.objects.filter( - Q(untagged_vlan_id=self.pk) | - Q(tagged_vlans=self.pk) - ).distinct() - - def get_vminterfaces(self): - # Return all VM interfaces assigned to this VLAN - return VMInterface.objects.filter( - Q(untagged_vlan_id=self.pk) | - Q(tagged_vlans=self.pk) - ).distinct() - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Service(ChangeLoggedModel, CustomFieldModel): - """ - A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may - optionally be tied to one or more specific IPAddresses belonging to its parent. - """ - device = models.ForeignKey( - to='dcim.Device', - on_delete=models.CASCADE, - related_name='services', - verbose_name='device', - null=True, - blank=True - ) - virtual_machine = models.ForeignKey( - to='virtualization.VirtualMachine', - on_delete=models.CASCADE, - related_name='services', - null=True, - blank=True - ) - name = models.CharField( - max_length=100 - ) - protocol = models.CharField( - max_length=50, - choices=ServiceProtocolChoices - ) - ports = ArrayField( - base_field=models.PositiveIntegerField( - validators=[ - MinValueValidator(SERVICE_PORT_MIN), - MaxValueValidator(SERVICE_PORT_MAX) - ] - ), - verbose_name='Port numbers' - ) - ipaddresses = models.ManyToManyField( - to='ipam.IPAddress', - related_name='services', - blank=True, - verbose_name='IP addresses' - ) - description = models.CharField( - max_length=200, - blank=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description'] - - class Meta: - ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique - - def __str__(self): - return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' - - def get_absolute_url(self): - return reverse('ipam:service', args=[self.pk]) - - @property - def parent(self): - return self.device or self.virtual_machine - - def clean(self): - super().clean() - - # A Service must belong to a Device *or* to a VirtualMachine - if self.device and self.virtual_machine: - raise ValidationError("A service cannot be associated with both a device and a virtual machine.") - if not self.device and not self.virtual_machine: - raise ValidationError("A service must be associated with either a device or a virtual machine.") - - def to_csv(self): - return ( - self.device.name if self.device else None, - self.virtual_machine.name if self.virtual_machine else None, - self.name, - self.get_protocol_display(), - self.ports, - self.description, - ) - - @property - def port_list(self): - return array_to_string(self.ports) diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py new file mode 100644 index 00000000000..57338cce044 --- /dev/null +++ b/netbox/ipam/models/services.py @@ -0,0 +1,109 @@ +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.urls import reverse +from taggit.managers import TaggableManager + +from extras.models import TaggedItem +from extras.utils import extras_features +from ipam.choices import * +from ipam.constants import * +from netbox.models import PrimaryModel +from utilities.querysets import RestrictedQuerySet +from utilities.utils import array_to_string + + +__all__ = ( + 'Service', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Service(PrimaryModel): + """ + A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may + optionally be tied to one or more specific IPAddresses belonging to its parent. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='services', + verbose_name='device', + null=True, + blank=True + ) + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='services', + null=True, + blank=True + ) + name = models.CharField( + max_length=100 + ) + protocol = models.CharField( + max_length=50, + choices=ServiceProtocolChoices + ) + ports = ArrayField( + base_field=models.PositiveIntegerField( + validators=[ + MinValueValidator(SERVICE_PORT_MIN), + MaxValueValidator(SERVICE_PORT_MAX) + ] + ), + verbose_name='Port numbers' + ) + ipaddresses = models.ManyToManyField( + to='ipam.IPAddress', + related_name='services', + blank=True, + verbose_name='IP addresses' + ) + description = models.CharField( + max_length=200, + blank=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description'] + + class Meta: + ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique + + def __str__(self): + return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' + + def get_absolute_url(self): + return reverse('ipam:service', args=[self.pk]) + + @property + def parent(self): + return self.device or self.virtual_machine + + def clean(self): + super().clean() + + # A Service must belong to a Device *or* to a VirtualMachine + if self.device and self.virtual_machine: + raise ValidationError("A service cannot be associated with both a device and a virtual machine.") + if not self.device and not self.virtual_machine: + raise ValidationError("A service must be associated with either a device or a virtual machine.") + + def to_csv(self): + return ( + self.device.name if self.device else None, + self.virtual_machine.name if self.virtual_machine else None, + self.name, + self.get_protocol_display(), + self.ports, + self.description, + ) + + @property + def port_list(self): + return array_to_string(self.ports) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py new file mode 100644 index 00000000000..131212564a7 --- /dev/null +++ b/netbox/ipam/models/vlans.py @@ -0,0 +1,202 @@ +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.urls import reverse +from taggit.managers import TaggableManager + +from dcim.models import Interface +from extras.models import TaggedItem +from extras.utils import extras_features +from ipam.choices import * +from ipam.constants import * +from netbox.models import OrganizationalModel, PrimaryModel +from utilities.querysets import RestrictedQuerySet +from virtualization.models import VMInterface + + +__all__ = ( + 'VLAN', + 'VLANGroup', +) + + +@extras_features('custom_fields', 'export_templates', 'webhooks') +class VLANGroup(OrganizationalModel): + """ + A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. + """ + name = models.CharField( + max_length=100 + ) + slug = models.SlugField( + max_length=100 + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='vlan_groups', + blank=True, + null=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'slug', 'site', 'description'] + + class Meta: + ordering = ('site', 'name', 'pk') # (site, name) may be non-unique + unique_together = [ + ['site', 'name'], + ['site', 'slug'], + ] + verbose_name = 'VLAN group' + verbose_name_plural = 'VLAN groups' + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:vlangroup_vlans', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.slug, + self.site.name if self.site else None, + self.description, + ) + + def get_next_available_vid(self): + """ + Return the first available VLAN ID (1-4094) in the group. + """ + vlan_ids = VLAN.objects.filter(group=self).values_list('vid', flat=True) + for i in range(1, 4095): + if i not in vlan_ids: + return i + return None + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class VLAN(PrimaryModel): + """ + A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned + to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, + within which all VLAN IDs and names but be unique. + + Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero + or more Prefixes assigned to it. + """ + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + group = models.ForeignKey( + to='ipam.VLANGroup', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + vid = models.PositiveSmallIntegerField( + verbose_name='ID', + validators=[MinValueValidator(1), MaxValueValidator(4094)] + ) + name = models.CharField( + max_length=64 + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + status = models.CharField( + max_length=50, + choices=VLANStatusChoices, + default=VLANStatusChoices.STATUS_ACTIVE + ) + role = models.ForeignKey( + to='ipam.Role', + on_delete=models.SET_NULL, + related_name='vlans', + blank=True, + null=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + clone_fields = [ + 'site', 'group', 'tenant', 'status', 'role', 'description', + ] + + class Meta: + ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique + unique_together = [ + ['group', 'vid'], + ['group', 'name'], + ] + verbose_name = 'VLAN' + verbose_name_plural = 'VLANs' + + def __str__(self): + return self.display_name or super().__str__() + + def get_absolute_url(self): + return reverse('ipam:vlan', args=[self.pk]) + + def clean(self): + super().clean() + + # Validate VLAN group + if self.group and self.group.site != self.site: + raise ValidationError({ + 'group': "VLAN group must belong to the assigned site ({}).".format(self.site) + }) + + def to_csv(self): + return ( + self.site.name if self.site else None, + self.group.name if self.group else None, + self.vid, + self.name, + self.tenant.name if self.tenant else None, + self.get_status_display(), + self.role.name if self.role else None, + self.description, + ) + + @property + def display_name(self): + return f'{self.name} ({self.vid})' + + def get_status_class(self): + return VLANStatusChoices.CSS_CLASSES.get(self.status) + + def get_interfaces(self): + # Return all device interfaces assigned to this VLAN + return Interface.objects.filter( + Q(untagged_vlan_id=self.pk) | + Q(tagged_vlans=self.pk) + ).distinct() + + def get_vminterfaces(self): + # Return all VM interfaces assigned to this VLAN + return VMInterface.objects.filter( + Q(untagged_vlan_id=self.pk) | + Q(tagged_vlans=self.pk) + ).distinct() diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py new file mode 100644 index 00000000000..bcd5a456a2a --- /dev/null +++ b/netbox/ipam/models/vrfs.py @@ -0,0 +1,139 @@ +from django.db import models +from django.urls import reverse +from taggit.managers import TaggableManager + +from extras.models import TaggedItem +from extras.utils import extras_features +from ipam.constants import * +from netbox.models import PrimaryModel +from utilities.querysets import RestrictedQuerySet + + +__all__ = ( + 'RouteTarget', + 'VRF', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class VRF(PrimaryModel): + """ + A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing + table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF + are said to exist in the "global" table.) + """ + name = models.CharField( + max_length=100 + ) + rd = models.CharField( + max_length=VRF_RD_MAX_LENGTH, + unique=True, + blank=True, + null=True, + verbose_name='Route distinguisher', + help_text='Unique route distinguisher (as defined in RFC 4364)' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vrfs', + blank=True, + null=True + ) + enforce_unique = models.BooleanField( + default=True, + verbose_name='Enforce unique space', + help_text='Prevent duplicate prefixes/IP addresses within this VRF' + ) + description = models.CharField( + max_length=200, + blank=True + ) + import_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='importing_vrfs', + blank=True + ) + export_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='exporting_vrfs', + blank=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] + clone_fields = [ + 'tenant', 'enforce_unique', 'description', + ] + + class Meta: + ordering = ('name', 'rd', 'pk') # (name, rd) may be non-unique + verbose_name = 'VRF' + verbose_name_plural = 'VRFs' + + def __str__(self): + return self.display_name or super().__str__() + + def get_absolute_url(self): + return reverse('ipam:vrf', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.rd, + self.tenant.name if self.tenant else None, + self.enforce_unique, + self.description, + ) + + @property + def display_name(self): + if self.rd: + return f'{self.name} ({self.rd})' + return self.name + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class RouteTarget(PrimaryModel): + """ + A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. + """ + name = models.CharField( + max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) + unique=True, + help_text='Route target value (formatted in accordance with RFC 4360)' + ) + description = models.CharField( + max_length=200, + blank=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='route_targets', + blank=True, + null=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'description', 'tenant'] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:routetarget', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.description, + self.tenant.name if self.tenant else None, + ) diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py new file mode 100644 index 00000000000..c0b0ce7aeab --- /dev/null +++ b/netbox/netbox/models.py @@ -0,0 +1,184 @@ +from collections import OrderedDict + +from django.core.serializers.json import DjangoJSONEncoder +from django.core.validators import ValidationError +from django.db import models +from mptt.models import MPTTModel, TreeForeignKey + +from utilities.mptt import TreeManager +from utilities.utils import serialize_object + +__all__ = ( + 'BigIDModel', + 'NestedGroupModel', + 'OrganizationalModel', + 'PrimaryModel', +) + + +class BigIDModel(models.Model): + """ + Abstract base model for all Schematic data objects. Ensures the use of a 64-bit PK. + """ + id = models.BigAutoField( + primary_key=True + ) + + class Meta: + abstract = True + + +class CoreModel(BigIDModel): + """ + Base class for all core objects. Provides the following: + - Change logging + - Custom field support + """ + created = models.DateField( + auto_now_add=True, + blank=True, + null=True + ) + last_updated = models.DateTimeField( + auto_now=True, + blank=True, + null=True + ) + + class Meta: + abstract = True + + def to_objectchange(self, action): + """ + Return a new ObjectChange representing a change made to this object. This will typically be called automatically + by ChangeLoggingMiddleware. + """ + from extras.models import ObjectChange + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + object_data=serialize_object(self) + ) + + +class PrimaryModel(CoreModel): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + custom_field_data = models.JSONField( + encoder=DjangoJSONEncoder, + blank=True, + default=dict + ) + + class Meta: + abstract = True + + @property + def cf(self): + """ + Convenience wrapper for custom field data. + """ + return self.custom_field_data + + def get_custom_fields(self): + """ + Return a dictionary of custom fields for a single object in the form {: value}. + """ + from extras.models import CustomField + + fields = CustomField.objects.get_for_model(self) + return OrderedDict([ + (field, self.custom_field_data.get(field.name)) for field in fields + ]) + + def clean(self): + super().clean() + from extras.models import CustomField + + custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)} + + # Validate all field values + for field_name, value in self.custom_field_data.items(): + if field_name not in custom_fields: + raise ValidationError(f"Unknown field name '{field_name}' in custom field data.") + try: + custom_fields[field_name].validate(value) + except ValidationError as e: + raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}") + + # Check for missing required values + for cf in custom_fields.values(): + if cf.required and cf.name not in self.custom_field_data: + raise ValidationError(f"Missing required custom field '{cf.name}'.") + + +class NestedGroupModel(PrimaryModel, MPTTModel): + """ + Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest + recursively using MPTT. Within each parent, each child instance must have a unique name. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = TreeManager() + + class Meta: + abstract = True + + class MPTTMeta: + order_insertion_by = ('name',) + + def __str__(self): + return self.name + + def to_objectchange(self, action): + # Remove MPTT-internal fields + from extras.models import ObjectChange + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) + ) + + +class OrganizationalModel(PrimaryModel): + """ + Organizational models are those which are used solely to categorize and qualify other objects, and do not convey + any real information about the infrastructure being modeled (for example, functional device roles). Organizational + models provide the following standard attributes: + - Unique name + - Unique slug (automatically derived from name) + - Optional description + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + abstract = True + ordering = ('name',) diff --git a/netbox/secrets/migrations/0013_standardize_models.py b/netbox/secrets/migrations/0013_standardize_models.py new file mode 100644 index 00000000000..9de8dec9587 --- /dev/null +++ b/netbox/secrets/migrations/0013_standardize_models.py @@ -0,0 +1,37 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0012_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='secretrole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='secret', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='secretrole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sessionkey', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='userkey', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 0fa6fd04ea9..091b67cc62b 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -14,8 +14,9 @@ from django.urls import reverse from django.utils.encoding import force_bytes from taggit.managers import TaggableManager -from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem +from extras.models import TaggedItem from extras.utils import extras_features +from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -31,7 +32,7 @@ __all__ = ( ) -class UserKey(models.Model): +class UserKey(BigIDModel): """ A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's @@ -164,7 +165,7 @@ class UserKey(models.Model): self.save() -class SessionKey(models.Model): +class SessionKey(BigIDModel): """ A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets. """ @@ -234,7 +235,8 @@ class SessionKey(models.Model): return session_key -class SecretRole(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class SecretRole(OrganizationalModel): """ A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles such as "Login Credentials" or "SNMP Communities." @@ -274,7 +276,7 @@ class SecretRole(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Secret(ChangeLoggedModel, CustomFieldModel): +class Secret(PrimaryModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to exactly diff --git a/netbox/tenancy/migrations/0012_standardize_models.py b/netbox/tenancy/migrations/0012_standardize_models.py new file mode 100644 index 00000000000..7ce55cf42ba --- /dev/null +++ b/netbox/tenancy/migrations/0012_standardize_models.py @@ -0,0 +1,27 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0011_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='tenantgroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='tenant', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tenantgroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 3ba644c0969..e83f4bdd2c9 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -3,11 +3,11 @@ from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import NestedGroupModel, PrimaryModel from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object __all__ = ( @@ -16,7 +16,8 @@ __all__ = ( ) -class TenantGroup(MPTTModel, ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class TenantGroup(NestedGroupModel): """ An arbitrary collection of Tenants. """ @@ -48,12 +49,6 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): class Meta: ordering = ['name'] - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug) @@ -65,18 +60,9 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): self.description, ) - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) - ) - @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Tenant(ChangeLoggedModel, CustomFieldModel): +class Tenant(PrimaryModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. diff --git a/netbox/users/migrations/0011_standardize_models.py b/netbox/users/migrations/0011_standardize_models.py new file mode 100644 index 00000000000..abfbd87024c --- /dev/null +++ b/netbox/users/migrations/0011_standardize_models.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0010_update_jsonfield'), + ] + + operations = [ + migrations.AlterField( + model_name='objectpermission', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='token', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='userconfig', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index b25a7513423..529732e6528 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -11,6 +11,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict @@ -50,7 +51,7 @@ class AdminUser(User): # User preferences # -class UserConfig(models.Model): +class UserConfig(BigIDModel): """ This model stores arbitrary user-specific preferences in a JSON data structure. """ @@ -175,7 +176,7 @@ def create_userconfig(instance, created, **kwargs): # REST API # -class Token(models.Model): +class Token(BigIDModel): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. @@ -233,7 +234,7 @@ class Token(models.Model): # Permissions # -class ObjectPermission(models.Model): +class ObjectPermission(BigIDModel): """ A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects identified by ORM query parameters. diff --git a/netbox/virtualization/migrations/0020_standardize_models.py b/netbox/virtualization/migrations/0020_standardize_models.py new file mode 100644 index 00000000000..15585100d9d --- /dev/null +++ b/netbox/virtualization/migrations/0020_standardize_models.py @@ -0,0 +1,62 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0019_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='clustergroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='clustertype', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='vminterface', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='vminterface', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='vminterface', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='cluster', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustergroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustertype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualmachine', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vminterface', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index edca7e1fe1c..a6a63ab3b92 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -6,9 +6,10 @@ from django.urls import reverse from taggit.managers import TaggableManager from dcim.models import BaseInterface, Device -from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ConfigContextModel, ObjectChange, TaggedItem from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar @@ -30,7 +31,8 @@ __all__ = ( # Cluster types # -class ClusterType(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class ClusterType(OrganizationalModel): """ A type of Cluster. """ @@ -72,7 +74,8 @@ class ClusterType(ChangeLoggedModel): # Cluster groups # -class ClusterGroup(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. """ @@ -115,7 +118,7 @@ class ClusterGroup(ChangeLoggedModel): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Cluster(ChangeLoggedModel, CustomFieldModel): +class Cluster(PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -199,7 +202,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): +class VirtualMachine(PrimaryModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ @@ -371,7 +374,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # @extras_features('export_templates', 'webhooks') -class VMInterface(BaseInterface): +class VMInterface(PrimaryModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, From 3208c8317d0cac095cd8486d5fb8d087688d64c1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 24 Feb 2021 21:21:17 -0500 Subject: [PATCH 003/223] Switch docs to mkdocs-material --- docs/installation/1-postgresql.md | 25 +++++++------- docs/installation/2-redis.md | 20 ++++++------ docs/installation/3-netbox.md | 54 +++++++++++++++---------------- docs/requirements.txt | 2 +- mkdocs.yml | 7 ++-- 5 files changed, 55 insertions(+), 53 deletions(-) diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 4ac1104d745..88e15219386 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -7,23 +7,24 @@ This section entails the installation and configuration of a local PostgreSQL da ## Installation -#### Ubuntu +## Installation -Install the PostgreSQL server and client development libraries using `apt`. +=== "Ubuntu" -```no-highlight -sudo apt update -sudo apt install -y postgresql libpq-dev -``` + ```no-highlight + sudo apt update + sudo apt install -y postgresql libpq-dev + ``` -#### CentOS +=== "CentOS" -PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/). + ```no-highlight + sudo yum install -y postgresql-server libpq-devel + sudo postgresql-setup --initdb + ``` -```no-highlight -sudo yum install -y postgresql-server libpq-devel -sudo postgresql-setup --initdb -``` + !!! info + PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/). CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below: diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md index 19ec9073e85..e31873d2b18 100644 --- a/docs/installation/2-redis.md +++ b/docs/installation/2-redis.md @@ -7,19 +7,19 @@ !!! note NetBox v2.9.0 and later require Redis v4.0 or higher. If your distribution does not offer a recent enough release, you will need to build Redis from source. Please see [the Redis installation documentation](https://github.com/redis/redis) for further details. -### Ubuntu +=== "Ubuntu" -```no-highlight -sudo apt install -y redis-server -``` + ```no-highlight + sudo apt install -y redis-server + ``` -### CentOS +=== "CentOS" -```no-highlight -sudo yum install -y redis -sudo systemctl start redis -sudo systemctl enable redis -``` + ```no-highlight + sudo yum install -y redis + sudo systemctl start redis + sudo systemctl enable redis + ``` You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient. diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index f2461b40d30..03e224e1f2e 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -9,17 +9,17 @@ Begin by installing all system packages required by NetBox and its dependencies. !!! note NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. -### Ubuntu +=== Ubuntu -```no-highlight -sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev -``` + ```no-highlight + sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev + ``` -### CentOS +=== CentOS -```no-highlight -sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config -``` + ```no-highlight + sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config + ``` Before continuing with either platform, update pip (Python's package management tool) to its latest release: @@ -57,17 +57,17 @@ sudo mkdir -p /opt/netbox/ && cd /opt/netbox/ If `git` is not already installed, install it: -#### Ubuntu +=== Ubuntu -```no-highlight -sudo apt install -y git -``` + ```no-highlight + sudo apt install -y git + ``` -#### CentOS +=== CentOS -```no-highlight -sudo yum install -y git -``` + ```no-highlight + sudo yum install -y git + ``` Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.) @@ -89,20 +89,20 @@ Checking connectivity... done. Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files. -#### Ubuntu +=== Ubuntu -``` -sudo adduser --system --group netbox -sudo chown --recursive netbox /opt/netbox/netbox/media/ -``` + ``` + sudo adduser --system --group netbox + sudo chown --recursive netbox /opt/netbox/netbox/media/ + ``` -#### CentOS +=== CentOS -``` -sudo groupadd --system netbox -sudo adduser --system -g netbox netbox -sudo chown --recursive netbox /opt/netbox/netbox/media/ -``` + ``` + sudo groupadd --system netbox + sudo adduser --system -g netbox netbox + sudo chown --recursive netbox /opt/netbox/netbox/media/ + ``` ## Configuration diff --git a/docs/requirements.txt b/docs/requirements.txt index 9c863fec79d..18b55d37bc6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -mkdocs==1.1 +mkdocs-material git+https://github.com/cmacmackin/markdown-include.git diff --git a/mkdocs.yml b/mkdocs.yml index 092cb559aa3..233a61fdf78 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,14 +5,15 @@ python: install: - requirements: docs/requirements.txt theme: - name: readthedocs - navigation_depth: 3 + name: material extra_css: - extra.css markdown_extensions: - - admonition: + - admonition - markdown_include.include: headingOffset: 1 + - pymdownx.superfences + - pymdownx.tabbed nav: - Introduction: 'index.md' - Installation: From 992657cbe053560b71f3a0e2ac3fd50d71563276 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Feb 2021 11:47:26 -0500 Subject: [PATCH 004/223] Introduce SelectSpeedWidget --- netbox/project-static/js/forms.js | 6 ++++++ netbox/utilities/forms/widgets.py | 8 ++++++++ .../templates/widgets/select_speed.html | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 netbox/utilities/templates/widgets/select_speed.html diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 0feb7fc2f4e..f189adc1b2c 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -103,6 +103,12 @@ $(document).ready(function() { return data.text; } + // Speed selector + $("a.set_speed").click(function(e) { + e.preventDefault(); + $("#id_" + $(this).attr("target")).val($(this).attr("data")); + }); + // Color Picker $('.netbox-select2-color-picker').select2({ allowClear: true, diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py index 1c456c74c2c..11418fd4205 100644 --- a/netbox/utilities/forms/widgets.py +++ b/netbox/utilities/forms/widgets.py @@ -16,6 +16,7 @@ __all__ = ( 'DatePicker', 'DateTimePicker', 'NumericArrayField', + 'SelectSpeedWidget', 'SelectWithDisabled', 'SelectWithPK', 'SlugWidget', @@ -111,6 +112,13 @@ class ContentTypeSelect(StaticSelect2): option_template_name = 'widgets/select_contenttype.html' +class SelectSpeedWidget(forms.NumberInput): + """ + Speed field with dropdown selections for convenience. + """ + template_name = 'widgets/select_speed.html' + + class NumericArrayField(SimpleArrayField): def to_python(self, value): diff --git a/netbox/utilities/templates/widgets/select_speed.html b/netbox/utilities/templates/widgets/select_speed.html new file mode 100644 index 00000000000..8e259ca8d21 --- /dev/null +++ b/netbox/utilities/templates/widgets/select_speed.html @@ -0,0 +1,20 @@ +
+ {% include 'django/forms/widgets/number.html' %} + + + + +
From 2a517cde9f929846f7bb806dbe0ee37fb17f0832 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Feb 2021 13:08:02 -0500 Subject: [PATCH 005/223] Add support for form fieldsets --- netbox/templates/generic/object_edit.html | 49 +++++++++++++++++-- netbox/utilities/templatetags/form_helpers.py | 8 +++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 965749a6dd6..7056256ecf0 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -30,14 +30,53 @@ {% endif %} {% block form %} -
-
{{ obj_type|capfirst }}
+ {% if form.Meta.fieldsets %} + {# Render grouped fields accoring to Form #} + {% for group, fields in form.Meta.fieldsets %} +
+
{{ group }}
+
+ {% for name in fields %} + {% render_field form|getfield:name %} + {% endfor %} +
+
+ {% endfor %} + {% if form.custom_fields %} +
+
Custom Fields
- {% block form_fields %} - {% render_form form %} - {% endblock %} + {% render_custom_fields form %}
+
+ {% endif %} + {% if form.tags %} +
+
Tags
+
+ {% render_field form.tags %} +
+
+ {% endif %} + {% if form.comments %} +
+
Comments
+
+ {% render_field form.comments %} +
+
+ {% endif %} + {% else %} + {# Render all fields in a single group #} +
+
{{ obj_type|capfirst }}
+
+ {% block form_fields %} + {% render_form form %} + {% endblock %} +
+ {% endif %} {% endblock %}
diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index d3451ce868d..9d46e8c3742 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -4,6 +4,14 @@ from django import template register = template.Library() +@register.filter() +def getfield(form, fieldname): + """ + Return the specified field of a Form. + """ + return form[fieldname] + + @register.inclusion_tag('utilities/render_field.html') def render_field(field, bulk_nullable=False): """ From 42e82f0ead06fd1aa19a7d46ff5b9db6dcff0552 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Feb 2021 13:51:48 -0500 Subject: [PATCH 006/223] Update object edit template to use fieldsets where possible --- netbox/circuits/forms.py | 15 +++- netbox/circuits/views.py | 2 - netbox/dcim/forms.py | 28 +++++++- netbox/dcim/views.py | 5 -- netbox/extras/forms.py | 3 + netbox/extras/views.py | 1 - netbox/ipam/forms.py | 19 +++++ netbox/ipam/views.py | 4 -- netbox/templates/circuits/circuit_edit.html | 62 ----------------- netbox/templates/circuits/provider_edit.html | 42 ----------- netbox/templates/dcim/devicetype_edit.html | 44 ------------ netbox/templates/dcim/powerfeed_edit.html | 52 -------------- netbox/templates/dcim/powerpanel_edit.html | 23 ------- .../templates/dcim/rackreservation_edit.html | 33 --------- netbox/templates/dcim/site_edit.html | 57 --------------- netbox/templates/extras/tag_edit.html | 14 ---- netbox/templates/ipam/aggregate_edit.html | 35 ---------- netbox/templates/ipam/prefix_edit.html | 46 ------------- netbox/templates/ipam/vlan_edit.html | 44 ------------ netbox/templates/ipam/vrf_edit.html | 42 ----------- netbox/templates/tenancy/tenant_edit.html | 35 ---------- .../virtualization/cluster_edit.html | 42 ----------- .../virtualization/virtualmachine_edit.html | 69 ------------------- netbox/tenancy/forms.py | 3 + netbox/tenancy/views.py | 1 - netbox/virtualization/forms.py | 11 +++ netbox/virtualization/views.py | 2 - 27 files changed, 75 insertions(+), 659 deletions(-) delete mode 100644 netbox/templates/circuits/circuit_edit.html delete mode 100644 netbox/templates/circuits/provider_edit.html delete mode 100644 netbox/templates/dcim/devicetype_edit.html delete mode 100644 netbox/templates/dcim/powerfeed_edit.html delete mode 100644 netbox/templates/dcim/powerpanel_edit.html delete mode 100644 netbox/templates/dcim/rackreservation_edit.html delete mode 100644 netbox/templates/dcim/site_edit.html delete mode 100644 netbox/templates/extras/tag_edit.html delete mode 100644 netbox/templates/ipam/aggregate_edit.html delete mode 100644 netbox/templates/ipam/prefix_edit.html delete mode 100644 netbox/templates/ipam/vlan_edit.html delete mode 100644 netbox/templates/ipam/vrf_edit.html delete mode 100644 netbox/templates/tenancy/tenant_edit.html delete mode 100644 netbox/templates/virtualization/cluster_edit.html delete mode 100644 netbox/templates/virtualization/virtualmachine_edit.html diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 4731c9adb23..8c35d972d34 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -9,8 +9,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DatePicker, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, - StaticSelect2Multiple, TagFilterField, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SmallTextarea, SlugField, + StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -33,6 +33,10 @@ class ProviderForm(BootstrapMixin, CustomFieldModelForm): fields = [ 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', ] + fieldsets = ( + ('Provider', ('name', 'slug', 'asn')), + ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), + ) widgets = { 'noc_contact': SmallTextarea( attrs={'rows': 5} @@ -127,7 +131,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): # Circuit types # -class CircuitTypeForm(BootstrapMixin, forms.ModelForm): +class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -171,6 +175,10 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', 'comments', 'tags', ] + fieldsets = ( + ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description')), + ('Tenancy', ('tenant_group', 'tenant')), + ) help_texts = { 'cid': "Unique circuit ID", 'commit_rate': "Committed rate", @@ -178,6 +186,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): widgets = { 'status': StaticSelect2(), 'install_date': DatePicker(), + 'commit_rate': SelectSpeedWidget(), } diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 9fea2665220..eec05dab878 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -52,7 +52,6 @@ class ProviderView(generic.ObjectView): class ProviderEditView(generic.ObjectEditView): queryset = Provider.objects.all() model_form = forms.ProviderForm - template_name = 'circuits/provider_edit.html' class ProviderDeleteView(generic.ObjectDeleteView): @@ -160,7 +159,6 @@ class CircuitView(generic.ObjectView): class CircuitEditView(generic.ObjectEditView): queryset = Circuit.objects.all() model_form = forms.CircuitForm - template_name = 'circuits/circuit_edit.html' class CircuitDeleteView(generic.ObjectDeleteView): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 40c16d59f5a..30c92bad8dc 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -231,6 +231,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', ] + fieldsets = ( + ('Site', ('name', 'slug', 'status', 'region', 'facility', 'asn', 'time_zone', 'description')), + ('Tenancy', ('tenant_group', 'tenant')), + ('Contact Info', ( + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', + )), + ) widgets = { 'physical_address': SmallTextarea( attrs={ @@ -780,8 +788,12 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = RackReservation fields = [ - 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', + 'region', 'site', 'rack_group', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', ] + fieldsets = ( + ('Reservation', ('region', 'site', 'rack_group', 'rack', 'units', 'user', 'description')), + ('Tenancy', ('tenant_group', 'tenant')), + ) class RackReservationCSVForm(CustomFieldModelCSVForm): @@ -941,6 +953,12 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', ] + fieldsets = ( + ('Device Type', ( + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + )), + ('Images', ('front_image', 'rear_image')), + ) widgets = { 'subdevice_role': StaticSelect2(), # Exclude SVG images (unsupported by PIL) @@ -4382,6 +4400,9 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): fields = [ 'region', 'site', 'rack_group', 'name', 'tags', ] + fieldsets = ( + ('Power Panel', ('region', 'site', 'rack_group', 'name')), + ) class PowerPanelCSVForm(CustomFieldModelCSVForm): @@ -4520,6 +4541,11 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', ] + fieldsets = ( + ('Power Panel', ('region', 'site', 'power_panel')), + ('Power Feed', ('rack', 'name', 'status', 'type')), + ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), + ) widgets = { 'status': StaticSelect2(), 'type': StaticSelect2(), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b092be61249..7df0ea45810 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -178,7 +178,6 @@ class SiteView(generic.ObjectView): class SiteEditView(generic.ObjectEditView): queryset = Site.objects.all() model_form = forms.SiteForm - template_name = 'dcim/site_edit.html' class SiteDeleteView(generic.ObjectDeleteView): @@ -421,7 +420,6 @@ class RackReservationView(generic.ObjectView): class RackReservationEditView(generic.ObjectEditView): queryset = RackReservation.objects.all() model_form = forms.RackReservationForm - template_name = 'dcim/rackreservation_edit.html' def alter_obj(self, obj, request, args, kwargs): if not obj.pk: @@ -577,7 +575,6 @@ class DeviceTypeView(generic.ObjectView): class DeviceTypeEditView(generic.ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm - template_name = 'dcim/devicetype_edit.html' class DeviceTypeDeleteView(generic.ObjectDeleteView): @@ -2591,7 +2588,6 @@ class PowerPanelView(generic.ObjectView): class PowerPanelEditView(generic.ObjectEditView): queryset = PowerPanel.objects.all() model_form = forms.PowerPanelForm - template_name = 'dcim/powerpanel_edit.html' class PowerPanelDeleteView(generic.ObjectDeleteView): @@ -2639,7 +2635,6 @@ class PowerFeedView(generic.ObjectView): class PowerFeedEditView(generic.ObjectEditView): queryset = PowerFeed.objects.all() model_form = forms.PowerFeedForm - template_name = 'dcim/powerfeed_edit.html' class PowerFeedDeleteView(generic.ObjectDeleteView): diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 932d07a4d3a..fbafd893be5 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -116,6 +116,9 @@ class TagForm(BootstrapMixin, forms.ModelForm): fields = [ 'name', 'slug', 'color', 'description' ] + fieldsets = ( + ('Tag', ('name', 'slug', 'color', 'description')), + ) class TagCSVForm(CSVModelForm): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 57483345c77..a6315aa5175 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -37,7 +37,6 @@ class TagListView(generic.ObjectListView): class TagEditView(generic.ObjectEditView): queryset = Tag.objects.all() model_form = forms.TagForm - template_name = 'extras/tag_edit.html' class TagDeleteView(generic.ObjectDeleteView): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index e2cb514172e..0c8f7de243c 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -50,6 +50,11 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant', 'tags', ] + fieldsets = ( + ('VRF', ('name', 'rd', 'enforce_unique', 'description')), + ('Route Targets', ('import_targets', 'export_targets')), + ('Tenancy', ('tenant_group', 'tenant')), + ) labels = { 'rd': "RD", } @@ -240,6 +245,10 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): fields = [ 'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags', ] + fieldsets = ( + ('Aggregate', ('prefix', 'rir', 'date_added', 'description')), + ('Tenancy', ('tenant_group', 'tenant')), + ) help_texts = { 'prefix': "IPv4 or IPv6 network", 'rir': "Regional Internet Registry responsible for this prefix", @@ -404,6 +413,11 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant', 'tags', ] + fieldsets = ( + ('Prefix', ('prefix', 'status', 'vrf', 'role', 'description', 'is_pool')), + ('Site/VLAN Assignment', ('region', 'site', 'vlan_group', 'vlan')), + ('Tenancy', ('tenant_group', 'tenant')), + ) widgets = { 'status': StaticSelect2(), } @@ -1163,6 +1177,11 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): fields = [ 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', ] + fieldsets = ( + ('VLAN', ('vid', 'name', 'status', 'role', 'description')), + ('Assignment', ('region', 'site', 'group')), + ('Tenancy', ('tenant_group', 'tenant')), + ) help_texts = { 'site': "Leave blank if this VLAN spans multiple sites", 'group': "VLAN group (optional)", diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 39a840f7ff6..bada223e3e9 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -50,7 +50,6 @@ class VRFView(generic.ObjectView): class VRFEditView(generic.ObjectEditView): queryset = VRF.objects.all() model_form = forms.VRFForm - template_name = 'ipam/vrf_edit.html' class VRFDeleteView(generic.ObjectDeleteView): @@ -245,7 +244,6 @@ class AggregateView(generic.ObjectView): class AggregateEditView(generic.ObjectEditView): queryset = Aggregate.objects.all() model_form = forms.AggregateForm - template_name = 'ipam/aggregate_edit.html' class AggregateDeleteView(generic.ObjectDeleteView): @@ -444,7 +442,6 @@ class PrefixIPAddressesView(generic.ObjectView): class PrefixEditView(generic.ObjectEditView): queryset = Prefix.objects.all() model_form = forms.PrefixForm - template_name = 'ipam/prefix_edit.html' class PrefixDeleteView(generic.ObjectDeleteView): @@ -769,7 +766,6 @@ class VLANVMInterfacesView(generic.ObjectView): class VLANEditView(generic.ObjectEditView): queryset = VLAN.objects.all() model_form = forms.VLANForm - template_name = 'ipam/vlan_edit.html' class VLANDeleteView(generic.ObjectDeleteView): diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html deleted file mode 100644 index fe0c6847d0a..00000000000 --- a/netbox/templates/circuits/circuit_edit.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Circuit
-
- {% render_field form.provider %} - {% render_field form.cid %} - {% render_field form.type %} - {% render_field form.status %} - {% render_field form.install_date %} -
- -
-
- {{ form.commit_rate }} - {% include 'circuits/inc/speed_widget.html' with target_field='commit_rate' %} -
- {{ form.commit_rate.help_text }} -
-
- {% render_field form.description %} -
-
-
-
Tenancy
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-
-
Comments
-
- {% render_field form.comments %} -
-
-{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/circuits/provider_edit.html b/netbox/templates/circuits/provider_edit.html deleted file mode 100644 index ee0a3a285c7..00000000000 --- a/netbox/templates/circuits/provider_edit.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Provider
-
- {% render_field form.name %} - {% render_field form.slug %} - {% render_field form.asn %} -
-
-
-
Support Info
-
- {% render_field form.account %} - {% render_field form.portal_url %} - {% render_field form.noc_contact %} - {% render_field form.admin_contact %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-
-
Comments
-
- {% render_field form.comments %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html deleted file mode 100644 index 5aba04b3988..00000000000 --- a/netbox/templates/dcim/devicetype_edit.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Device Type
-
- {% render_field form.manufacturer %} - {% render_field form.model %} - {% render_field form.slug %} - {% render_field form.part_number %} - {% render_field form.u_height %} - {% render_field form.is_full_depth %} - {% render_field form.subdevice_role %} -
-
-
-
Rack Images
-
- {% render_field form.front_image %} - {% render_field form.rear_image %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-
-
Comments
-
- {% render_field form.comments %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/powerfeed_edit.html b/netbox/templates/dcim/powerfeed_edit.html deleted file mode 100644 index 0a6581444ce..00000000000 --- a/netbox/templates/dcim/powerfeed_edit.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Power Panel
-
- {% render_field form.region %} - {% render_field form.site %} - {% render_field form.power_panel %} -
-
-
-
Power Feed
-
- {% render_field form.rack %} - {% render_field form.name %} - {% render_field form.status %} -
-
-
-
Characteristics
-
- {% render_field form.type %} - {% render_field form.supply %} - {% render_field form.voltage %} - {% render_field form.amperage %} - {% render_field form.phase %} - {% render_field form.max_utilization %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-
-
Comments
-
- {% render_field form.comments %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/powerpanel_edit.html b/netbox/templates/dcim/powerpanel_edit.html deleted file mode 100644 index c1890cd47a6..00000000000 --- a/netbox/templates/dcim/powerpanel_edit.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Power Panel
-
- {% render_field form.region %} - {% render_field form.site %} - {% render_field form.rack_group %} - {% render_field form.name %} - {% render_field form.tags %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/rackreservation_edit.html b/netbox/templates/dcim/rackreservation_edit.html deleted file mode 100644 index 1465dc02deb..00000000000 --- a/netbox/templates/dcim/rackreservation_edit.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Rack Reservation
-
- {% render_field form.region %} - {% render_field form.site %} - {% render_field form.rack_group %} - {% render_field form.rack %} - {% render_field form.units %} - {% render_field form.user %} - {% render_field form.description %} - {% render_field form.tags %} -
-
-
-
Tenant Assignment
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html deleted file mode 100644 index 274863fc4d2..00000000000 --- a/netbox/templates/dcim/site_edit.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Site
-
- {% render_field form.name %} - {% render_field form.slug %} - {% render_field form.status %} - {% render_field form.region %} - {% render_field form.facility %} - {% render_field form.asn %} - {% render_field form.time_zone %} - {% render_field form.description %} -
-
-
-
Tenancy
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
-
-
-
Contact Info
-
- {% render_field form.physical_address %} - {% render_field form.shipping_address %} - {% render_field form.latitude %} - {% render_field form.longitude %} - {% render_field form.contact_name %} - {% render_field form.contact_phone %} - {% render_field form.contact_email %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-
-
Comments
-
- {% render_field form.comments %} -
-
-{% endblock %} diff --git a/netbox/templates/extras/tag_edit.html b/netbox/templates/extras/tag_edit.html deleted file mode 100644 index 1516bde394a..00000000000 --- a/netbox/templates/extras/tag_edit.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Tag
-
- {% render_field form.name %} - {% render_field form.slug %} - {% render_field form.color %} - {% render_field form.description %} -
-
-{% endblock %} diff --git a/netbox/templates/ipam/aggregate_edit.html b/netbox/templates/ipam/aggregate_edit.html deleted file mode 100644 index f27abd6636d..00000000000 --- a/netbox/templates/ipam/aggregate_edit.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Aggregate
-
- {% render_field form.prefix %} - {% render_field form.rir %} - {% render_field form.date_added %} - {% render_field form.description %} -
-
-
-
Tenancy
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-{% endblock %} diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html deleted file mode 100644 index 3505c373b6c..00000000000 --- a/netbox/templates/ipam/prefix_edit.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Prefix
-
- {% render_field form.prefix %} - {% render_field form.status %} - {% render_field form.vrf %} - {% render_field form.role %} - {% render_field form.description %} - {% render_field form.is_pool %} -
-
-
-
Site/VLAN Assignment
-
- {% render_field form.region %} - {% render_field form.site %} - {% render_field form.vlan_group %} - {% render_field form.vlan %} -
-
-
-
Tenancy
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-{% endblock %} diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html deleted file mode 100644 index 54dcf727a83..00000000000 --- a/netbox/templates/ipam/vlan_edit.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
VLAN
-
- {% render_field form.vid %} - {% render_field form.name %} - {% render_field form.status %} - {% render_field form.role %} - {% render_field form.description %} -
-
-
-
Assignment
-
- {% render_field form.region %} - {% render_field form.site %} - {% render_field form.group %} -
-
-
-
Tenancy
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-{% endblock %} diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html deleted file mode 100644 index 189b9c12978..00000000000 --- a/netbox/templates/ipam/vrf_edit.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
VRF
-
- {% render_field form.name %} - {% render_field form.rd %} - {% render_field form.enforce_unique %} - {% render_field form.description %} -
-
-
-
Route Targets
-
- {% render_field form.import_targets %} - {% render_field form.export_targets %} -
-
-
-
Tenancy
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-{% endblock %} diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html deleted file mode 100644 index 8a1d1d1b663..00000000000 --- a/netbox/templates/tenancy/tenant_edit.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load static %} -{% load form_helpers %} - -{% block form %} -
-
Tenant
-
- {% render_field form.name %} - {% render_field form.slug %} - {% render_field form.group %} - {% render_field form.description %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-
-
Comments
-
- {% render_field form.comments %} -
-
-{% endblock %} diff --git a/netbox/templates/virtualization/cluster_edit.html b/netbox/templates/virtualization/cluster_edit.html deleted file mode 100644 index f43fc717f29..00000000000 --- a/netbox/templates/virtualization/cluster_edit.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Cluster
-
- {% render_field form.name %} - {% render_field form.type %} - {% render_field form.group %} - {% render_field form.region %} - {% render_field form.site %} -
-
-
-
Tenancy
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
-
-
Comments
-
- {% render_field form.comments %} -
-
-{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html deleted file mode 100644 index 6bffabaddbb..00000000000 --- a/netbox/templates/virtualization/virtualmachine_edit.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Virtual Machine
-
- {% render_field form.name %} - {% render_field form.role %} -
-
-
-
Cluster
-
- {% render_field form.cluster_group %} - {% render_field form.cluster %} -
-
-
-
Management
-
- {% render_field form.status %} - {% render_field form.platform %} - {% render_field form.primary_ip4 %} - {% render_field form.primary_ip6 %} -
-
-
-
Resources
-
- {% render_field form.vcpus %} - {% render_field form.memory %} - {% render_field form.disk %} -
-
-
-
Tenancy
-
- {% render_field form.tenant_group %} - {% render_field form.tenant %} -
-
- {% if form.custom_fields %} -
-
Custom Fields
-
- {% render_custom_fields form %} -
-
- {% endif %} -
-
Local Config Context Data
-
- {% render_field form.local_context_data %} -
-
-
-
Tags
-
- {% render_field form.tags %} -
-
-
-
Comments
-
- {% render_field form.comments %} -
-
-{% endblock %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index bceab7ce704..ee9adefc030 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -64,6 +64,9 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): fields = ( 'name', 'slug', 'group', 'description', 'comments', 'tags', ) + fieldsets = ( + ('Tenant', ('name', 'slug', 'group', 'description')), + ) class TenantCSVForm(CustomFieldModelCSVForm): diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 9fd77d88e3a..4c72cea4201 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -87,7 +87,6 @@ class TenantView(generic.ObjectView): class TenantEditView(generic.ObjectEditView): queryset = Tenant.objects.all() model_form = forms.TenantForm - template_name = 'tenancy/tenant_edit.html' class TenantDeleteView(generic.ObjectDeleteView): diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index edacb3e0730..7e0c45378a8 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -104,6 +104,10 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): fields = ( 'name', 'type', 'group', 'tenant', 'region', 'site', 'comments', 'tags', ) + fieldsets = ( + ('Cluster', ('name', 'type', 'group', 'region', 'site')), + ('Tenancy', ('tenant_group', 'tenant')), + ) class ClusterCSVForm(CustomFieldModelCSVForm): @@ -321,6 +325,13 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] + fieldsets = ( + ('Virtual Machine', ('name', 'role', 'status')), + ('Cluster', ('cluster_group', 'cluster')), + ('Management', ('platform', 'primary_ip4', 'primary_ip6')), + ('Resources', ('vcpus', 'memory', 'disk')), + ('Config Context', ('local_context_data',)), + ) help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " "config context", diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index dcf9ebcdade..e5fe0585811 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -114,7 +114,6 @@ class ClusterView(generic.ObjectView): class ClusterEditView(generic.ObjectEditView): - template_name = 'virtualization/cluster_edit.html' queryset = Cluster.objects.all() model_form = forms.ClusterForm @@ -276,7 +275,6 @@ class VirtualMachineConfigContextView(ObjectConfigContextView): class VirtualMachineEditView(generic.ObjectEditView): queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineForm - template_name = 'virtualization/virtualmachine_edit.html' class VirtualMachineDeleteView(generic.ObjectDeleteView): From cef8204f4024d49eb99720fac975a70475cffb7f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Feb 2021 14:03:14 -0500 Subject: [PATCH 007/223] Merge tags fields with primary object fields in form display --- netbox/circuits/forms.py | 4 ++-- netbox/dcim/forms.py | 10 +++++----- netbox/ipam/forms.py | 8 ++++---- netbox/templates/dcim/device_edit.html | 7 +------ netbox/templates/dcim/interface_edit.html | 7 +------ netbox/templates/dcim/rack_edit.html | 14 +++++++------- netbox/templates/generic/object_edit.html | 8 -------- netbox/templates/ipam/ipaddress_bulk_add.html | 7 +------ netbox/templates/ipam/ipaddress_edit.html | 7 +------ netbox/templates/ipam/service_edit.html | 7 +------ netbox/templates/secrets/secret_edit.html | 7 +------ .../templates/virtualization/vminterface_edit.html | 7 +------ netbox/tenancy/forms.py | 2 +- netbox/virtualization/forms.py | 4 ++-- 14 files changed, 28 insertions(+), 71 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 8c35d972d34..6a2577edbf3 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -34,7 +34,7 @@ class ProviderForm(BootstrapMixin, CustomFieldModelForm): 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', ] fieldsets = ( - ('Provider', ('name', 'slug', 'asn')), + ('Provider', ('name', 'slug', 'asn', 'tags')), ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), ) widgets = { @@ -176,7 +176,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'comments', 'tags', ] fieldsets = ( - ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description')), + ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) help_texts = { diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 30c92bad8dc..3428dec57f7 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -232,7 +232,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'contact_email', 'comments', 'tags', ] fieldsets = ( - ('Site', ('name', 'slug', 'status', 'region', 'facility', 'asn', 'time_zone', 'description')), + ('Site', ('name', 'slug', 'status', 'region', 'facility', 'asn', 'time_zone', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ('Contact Info', ( 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', @@ -791,7 +791,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'region', 'site', 'rack_group', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', ] fieldsets = ( - ('Reservation', ('region', 'site', 'rack_group', 'rack', 'units', 'user', 'description')), + ('Reservation', ('region', 'site', 'rack_group', 'rack', 'units', 'user', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -955,7 +955,7 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): ] fieldsets = ( ('Device Type', ( - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'tags', )), ('Images', ('front_image', 'rear_image')), ) @@ -4401,7 +4401,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): 'region', 'site', 'rack_group', 'name', 'tags', ] fieldsets = ( - ('Power Panel', ('region', 'site', 'rack_group', 'name')), + ('Power Panel', ('region', 'site', 'rack_group', 'name', 'tags')), ) @@ -4543,7 +4543,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): ] fieldsets = ( ('Power Panel', ('region', 'site', 'power_panel')), - ('Power Feed', ('rack', 'name', 'status', 'type')), + ('Power Feed', ('rack', 'name', 'status', 'type', 'tags')), ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ) widgets = { diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 0c8f7de243c..7e45c30b3f9 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -51,7 +51,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'tags', ] fieldsets = ( - ('VRF', ('name', 'rd', 'enforce_unique', 'description')), + ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')), ('Route Targets', ('import_targets', 'export_targets')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -246,7 +246,7 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags', ] fieldsets = ( - ('Aggregate', ('prefix', 'rir', 'date_added', 'description')), + ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) help_texts = { @@ -414,7 +414,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'tags', ] fieldsets = ( - ('Prefix', ('prefix', 'status', 'vrf', 'role', 'description', 'is_pool')), + ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'description', 'tags')), ('Site/VLAN Assignment', ('region', 'site', 'vlan_group', 'vlan')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -1178,7 +1178,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', ] fieldsets = ( - ('VLAN', ('vid', 'name', 'status', 'role', 'description')), + ('VLAN', ('vid', 'name', 'status', 'role', 'description', 'tags')), ('Assignment', ('region', 'site', 'group')), ('Tenancy', ('tenant_group', 'tenant')), ) diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 023b7e43c05..31b9dddb07a 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 %}
@@ -92,12 +93,6 @@ {% render_field form.local_context_data %}
-
-
Tags
-
- {% render_field form.tags %} -
-
Comments
diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 1f549febd04..a1eb2cee18c 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -24,6 +24,7 @@ {% render_field form.mtu %} {% render_field form.mgmt_only %} {% render_field form.description %} + {% render_field form.tags %}
@@ -34,12 +35,6 @@ {% render_field form.tagged_vlans %}
-
-
Tags
-
- {% render_field form.tags %} -
-
{% endblock %} {% block buttons %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 9ce242191e9..8d23467e32c 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -9,9 +9,15 @@ {% render_field form.site %} {% render_field form.group %} {% render_field form.name %} - {% render_field form.facility_id %} {% render_field form.status %} {% render_field form.role %} + {% render_field form.tags %} + + +
+
Inventory Control
+
+ {% render_field form.facility_id %} {% render_field form.serial %} {% render_field form.asset_tag %}
@@ -52,12 +58,6 @@
{% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
Comments
diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 7056256ecf0..a07b2dfaf7e 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -50,14 +50,6 @@
{% endif %} - {% if form.tags %} -
-
Tags
-
- {% render_field form.tags %} -
-
- {% endif %} {% if form.comments %}
Comments
diff --git a/netbox/templates/ipam/ipaddress_bulk_add.html b/netbox/templates/ipam/ipaddress_bulk_add.html index f019d48b800..db103b43bb3 100644 --- a/netbox/templates/ipam/ipaddress_bulk_add.html +++ b/netbox/templates/ipam/ipaddress_bulk_add.html @@ -17,6 +17,7 @@ {% render_field model_form.role %} {% render_field model_form.vrf %} {% render_field model_form.description %} + {% render_field model_form.tags %}
@@ -26,12 +27,6 @@ {% render_field model_form.tenant %}
-
-
Tags
-
- {% render_field model_form.tags %} -
-
{% if model_form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index eb15a3059a0..33e58ce092d 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -19,6 +19,7 @@ {% render_field form.vrf %} {% render_field form.dns_name %} {% render_field form.description %} + {% render_field form.tags %}
@@ -78,12 +79,6 @@ {% render_field form.nat_inside %}
-
-
Tags
-
- {% render_field form.tags %} -
-
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index 8d6fde9e943..7cc74f450b0 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -31,6 +31,7 @@
{% render_field form.ipaddresses %} {% render_field form.description %} + {% render_field form.tags %} {% if form.custom_fields %} @@ -41,10 +42,4 @@ {% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
{% endblock %} diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 4e2d78042e0..b7e7ab78544 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -39,6 +39,7 @@ {% render_field form.role %} {% render_field form.name %} {% render_field form.userkeys %} + {% render_field form.tags %}
@@ -72,12 +73,6 @@
{% endif %} -
-
Tags
-
- {% render_field form.tags %} -
-
diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index d1fe6d104c9..d4869aaf989 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -20,6 +20,7 @@ {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.description %} + {% render_field form.tags %}
@@ -30,12 +31,6 @@ {% render_field form.tagged_vlans %}
-
-
Tags
-
- {% render_field form.tags %} -
-
{% endblock %} {% block buttons %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index ee9adefc030..97e72bb027c 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -65,7 +65,7 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): 'name', 'slug', 'group', 'description', 'comments', 'tags', ) fieldsets = ( - ('Tenant', ('name', 'slug', 'group', 'description')), + ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), ) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 7e0c45378a8..addfb05cdb8 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -105,7 +105,7 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'name', 'type', 'group', 'tenant', 'region', 'site', 'comments', 'tags', ) fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site')), + ('Cluster', ('name', 'type', 'group', 'region', 'site', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -326,7 +326,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] fieldsets = ( - ('Virtual Machine', ('name', 'role', 'status')), + ('Virtual Machine', ('name', 'role', 'status', 'tags')), ('Cluster', ('cluster_group', 'cluster')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), ('Resources', ('vcpus', 'memory', 'disk')), From d6cf385a3c5363c63007b3d40b8e3152e708170a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Feb 2021 14:12:34 -0500 Subject: [PATCH 008/223] Update CircuitTermination fields to use SelectSpeedWidget --- netbox/circuits/forms.py | 2 + .../circuits/circuittermination_edit.html | 130 +++++------------- .../templates/circuits/inc/speed_widget.html | 17 --- 3 files changed, 40 insertions(+), 109 deletions(-) delete mode 100644 netbox/templates/circuits/inc/speed_widget.html diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 6a2577edbf3..18d49d08e45 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -338,4 +338,6 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): } widgets = { 'term_side': forms.HiddenInput(), + 'port_speed': SelectSpeedWidget(), + 'upstream_speed': SelectSpeedWidget(), } diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 43dba843840..917fca9708d 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -1,97 +1,43 @@ -{% extends 'base.html' %} +{% extends 'generic/object_edit.html' %} {% load static %} {% load form_helpers %} -{% block content %} -
- {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
-
-

{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %}

- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
Location
-
-
- -
-

{{ obj.circuit.provider }}

-
-
-
- -
-

{{ obj.circuit.cid }}

-
-
-
- -
-

{{ form.term_side.value }}

-
-
- {% render_field form.region %} - {% render_field form.site %} -
-
-
-
Termination Details
-
-
- -
-
- {{ form.port_speed }} - {% include 'circuits/inc/speed_widget.html' with target_field='port_speed' %} -
- {{ form.port_speed.help_text }} -
-
-
- -
-
- {{ form.upstream_speed }} - {% include 'circuits/inc/speed_widget.html' with target_field='upstream_speed' %} -
- {{ form.upstream_speed.help_text }} -
-
- {% render_field form.xconnect_id %} - {% render_field form.pp_info %} - {% render_field form.description %} -
-
-
-
-
-
- {% if obj.pk %} - - {% else %} - - {% endif %} - Cancel -
-
-
-{% endblock %} +{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %} -{% block javascript %} - +{% block form %} +
+
Location
+
+
+ +
+

{{ obj.circuit.provider }}

+
+
+
+ +
+

{{ obj.circuit.cid }}

+
+
+
+ +
+

{{ form.term_side.value }}

+
+
+ {% render_field form.region %} + {% render_field form.site %} +
+
+
+
Termination Details
+
+ {% render_field form.port_speed %} + {% render_field form.upstream_speed %} + {% render_field form.xconnect_id %} + {% render_field form.pp_info %} + {% render_field form.description %} +
+
{% endblock %} diff --git a/netbox/templates/circuits/inc/speed_widget.html b/netbox/templates/circuits/inc/speed_widget.html deleted file mode 100644 index 988418945af..00000000000 --- a/netbox/templates/circuits/inc/speed_widget.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - From 664a39911cf28c22c45253e2077693bc2cd39ba6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Feb 2021 15:58:13 -0500 Subject: [PATCH 009/223] Add custom field support for organizational models to API serializers --- netbox/circuits/api/serializers.py | 6 ++-- netbox/circuits/api/views.py | 2 +- netbox/dcim/api/serializers.py | 37 +++++++++++++++--------- netbox/dcim/api/views.py | 12 ++++---- netbox/ipam/api/serializers.py | 25 +++++++++++----- netbox/ipam/api/views.py | 6 ++-- netbox/secrets/api/serializers.py | 6 ++-- netbox/secrets/api/views.py | 3 +- netbox/tenancy/api/serializers.py | 7 +++-- netbox/tenancy/api/views.py | 3 +- netbox/virtualization/api/serializers.py | 12 +++++--- netbox/virtualization/api/views.py | 4 +-- 12 files changed, 77 insertions(+), 46 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 12ec9ba7f0d..17dce662455 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -31,13 +31,15 @@ class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Circuits # -class CircuitTypeSerializer(ValidatedModelSerializer): +class CircuitTypeSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType - fields = ['id', 'url', 'name', 'slug', 'description', 'circuit_count'] + fields = [ + 'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'circuit_count', + ] class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer): diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 736871a733e..c2fe3d089f4 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -34,7 +34,7 @@ class ProviderViewSet(CustomFieldModelViewSet): # Circuit Types # -class CircuitTypeViewSet(ModelViewSet): +class CircuitTypeViewSet(CustomFieldModelViewSet): queryset = CircuitType.objects.annotate( circuit_count=count_related(Circuit, 'type') ) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 6f497bfa6d7..3aa5dea2f44 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -82,7 +82,7 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): # Regions/sites # -class RegionSerializer(serializers.ModelSerializer): +class RegionSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') parent = NestedRegionSerializer(required=False, allow_null=True) site_count = serializers.IntegerField(read_only=True) @@ -90,7 +90,10 @@ class RegionSerializer(serializers.ModelSerializer): class Meta: model = Region - fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'site_count', '_depth'] + fields = [ + 'id', 'url', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'site_count', '_depth', + ] class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): @@ -120,7 +123,7 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Racks # -class RackGroupSerializer(ValidatedModelSerializer): +class RackGroupSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') site = NestedSiteSerializer() parent = NestedRackGroupSerializer(required=False, allow_null=True) @@ -129,16 +132,22 @@ class RackGroupSerializer(ValidatedModelSerializer): class Meta: model = RackGroup - fields = ['id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'rack_count', '_depth'] + fields = [ + 'id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'rack_count', '_depth', + ] -class RackRoleSerializer(ValidatedModelSerializer): +class RackRoleSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole - fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'rack_count'] + fields = [ + 'id', 'url', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated', + 'rack_count', + ] class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): @@ -242,7 +251,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # Device types # -class ManufacturerSerializer(ValidatedModelSerializer): +class ManufacturerSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True) @@ -251,7 +260,8 @@ class ManufacturerSerializer(ValidatedModelSerializer): class Meta: model = Manufacturer fields = [ - 'id', 'url', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count', + 'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'devicetype_count', + 'inventoryitem_count', 'platform_count', ] @@ -378,7 +388,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): # Devices # -class DeviceRoleSerializer(ValidatedModelSerializer): +class DeviceRoleSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -386,11 +396,12 @@ class DeviceRoleSerializer(ValidatedModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count', + 'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', 'last_updated', + 'device_count', 'virtualmachine_count', ] -class PlatformSerializer(ValidatedModelSerializer): +class PlatformSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) @@ -399,8 +410,8 @@ class PlatformSerializer(ValidatedModelSerializer): class Meta: model = Platform fields = [ - 'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count', - 'virtualmachine_count', + 'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'custom_fields', + 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ae39f6ad02d..ae488a1e4e2 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -99,7 +99,7 @@ class PassThroughPortMixin(object): # Regions # -class RegionViewSet(ModelViewSet): +class RegionViewSet(CustomFieldModelViewSet): queryset = Region.objects.add_related_count( Region.objects.all(), Site, @@ -134,7 +134,7 @@ class SiteViewSet(CustomFieldModelViewSet): # Rack groups # -class RackGroupViewSet(ModelViewSet): +class RackGroupViewSet(CustomFieldModelViewSet): queryset = RackGroup.objects.add_related_count( RackGroup.objects.all(), Rack, @@ -150,7 +150,7 @@ class RackGroupViewSet(ModelViewSet): # Rack roles # -class RackRoleViewSet(ModelViewSet): +class RackRoleViewSet(CustomFieldModelViewSet): queryset = RackRole.objects.annotate( rack_count=count_related(Rack, 'role') ) @@ -238,7 +238,7 @@ class RackReservationViewSet(ModelViewSet): # Manufacturers # -class ManufacturerViewSet(ModelViewSet): +class ManufacturerViewSet(CustomFieldModelViewSet): queryset = Manufacturer.objects.annotate( devicetype_count=count_related(DeviceType, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'), @@ -317,7 +317,7 @@ class DeviceBayTemplateViewSet(ModelViewSet): # Device roles # -class DeviceRoleViewSet(ModelViewSet): +class DeviceRoleViewSet(CustomFieldModelViewSet): queryset = DeviceRole.objects.annotate( device_count=count_related(Device, 'device_role'), virtualmachine_count=count_related(VirtualMachine, 'role') @@ -330,7 +330,7 @@ class DeviceRoleViewSet(ModelViewSet): # Platforms # -class PlatformViewSet(ModelViewSet): +class PlatformViewSet(CustomFieldModelViewSet): queryset = Platform.objects.annotate( device_count=count_related(Device, 'platform'), virtualmachine_count=count_related(VirtualMachine, 'platform') diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 9b8d36590fa..719ea51e2eb 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -67,13 +67,16 @@ class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # RIRs/aggregates # -class RIRSerializer(ValidatedModelSerializer): +class RIRSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') aggregate_count = serializers.IntegerField(read_only=True) class Meta: model = RIR - fields = ['id', 'url', 'name', 'slug', 'is_private', 'description', 'aggregate_count'] + fields = [ + 'id', 'url', 'name', 'slug', 'is_private', 'description', 'custom_fields', 'created', 'last_updated', + 'aggregate_count', + ] class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): @@ -85,8 +88,8 @@ class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Aggregate fields = [ - 'id', 'url', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', ] read_only_fields = ['family'] @@ -95,24 +98,30 @@ class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # VLANs # -class RoleSerializer(ValidatedModelSerializer): +class RoleSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') prefix_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) class Meta: model = Role - fields = ['id', 'url', 'name', 'slug', 'weight', 'description', 'prefix_count', 'vlan_count'] + fields = [ + 'id', 'url', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated', + 'prefix_count', 'vlan_count', + ] -class VLANGroupSerializer(ValidatedModelSerializer): +class VLANGroupSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') site = NestedSiteSerializer(required=False, allow_null=True) vlan_count = serializers.IntegerField(read_only=True) class Meta: model = VLANGroup - fields = ['id', 'url', 'name', 'slug', 'site', 'description', 'vlan_count'] + fields = [ + 'id', 'url', 'name', 'slug', 'site', 'description', 'custom_fields', 'created', 'last_updated', + 'vlan_count', + ] validators = [] def validate(self, data): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 16db8f04f3a..b6f0a74638f 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -55,7 +55,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet): # RIRs # -class RIRViewSet(ModelViewSet): +class RIRViewSet(CustomFieldModelViewSet): queryset = RIR.objects.annotate( aggregate_count=count_related(Aggregate, 'rir') ) @@ -77,7 +77,7 @@ class AggregateViewSet(CustomFieldModelViewSet): # Roles # -class RoleViewSet(ModelViewSet): +class RoleViewSet(CustomFieldModelViewSet): queryset = Role.objects.annotate( prefix_count=count_related(Prefix, 'role'), vlan_count=count_related(VLAN, 'role') @@ -282,7 +282,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): # VLAN groups # -class VLANGroupViewSet(ModelViewSet): +class VLANGroupViewSet(CustomFieldModelViewSet): queryset = VLANGroup.objects.prefetch_related('site').annotate( vlan_count=count_related(VLAN, 'group') ) diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index b08b87bc519..627685de0a4 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -15,13 +15,15 @@ from .nested_serializers import * # Secrets # -class SecretRoleSerializer(ValidatedModelSerializer): +class SecretRoleSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') secret_count = serializers.IntegerField(read_only=True) class Meta: model = SecretRole - fields = ['id', 'url', 'name', 'slug', 'description', 'secret_count'] + fields = [ + 'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'secret_count', + ] class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 8c959f90daa..3650abd307b 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -8,6 +8,7 @@ from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet +from extras.api.views import CustomFieldModelViewSet from netbox.api.views import ModelViewSet from secrets import filters from secrets.exceptions import InvalidKey @@ -33,7 +34,7 @@ class SecretsRootView(APIRootView): # Secret Roles # -class SecretRoleViewSet(ModelViewSet): +class SecretRoleViewSet(CustomFieldModelViewSet): queryset = SecretRole.objects.annotate( secret_count=count_related(Secret, 'role') ) diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 4c2f9faee1a..d301ee3fe32 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -11,7 +11,7 @@ from .nested_serializers import * # Tenants # -class TenantGroupSerializer(ValidatedModelSerializer): +class TenantGroupSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') parent = NestedTenantGroupSerializer(required=False, allow_null=True) tenant_count = serializers.IntegerField(read_only=True) @@ -19,7 +19,10 @@ class TenantGroupSerializer(ValidatedModelSerializer): class Meta: model = TenantGroup - fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'tenant_count', '_depth'] + fields = [ + 'id', 'url', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'tenant_count', '_depth', + ] class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 2b7ae836567..3b57e1a02dc 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -4,7 +4,6 @@ from circuits.models import Circuit from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet from ipam.models import IPAddress, Prefix, VLAN, VRF -from netbox.api.views import ModelViewSet from tenancy import filters from tenancy.models import Tenant, TenantGroup from utilities.utils import count_related @@ -24,7 +23,7 @@ class TenancyRootView(APIRootView): # Tenant Groups # -class TenantGroupViewSet(ModelViewSet): +class TenantGroupViewSet(CustomFieldModelViewSet): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), Tenant, diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 518b7086c0b..4883f9f624c 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -18,22 +18,26 @@ from .nested_serializers import * # Clusters # -class ClusterTypeSerializer(ValidatedModelSerializer): +class ClusterTypeSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterType - fields = ['id', 'url', 'name', 'slug', 'description', 'cluster_count'] + fields = [ + 'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'cluster_count', + ] -class ClusterGroupSerializer(ValidatedModelSerializer): +class ClusterGroupSerializer(CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterGroup - fields = ['id', 'url', 'name', 'slug', 'description', 'cluster_count'] + fields = [ + 'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'cluster_count', + ] class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 586ad502815..ea2b33e4f42 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -20,7 +20,7 @@ class VirtualizationRootView(APIRootView): # Clusters # -class ClusterTypeViewSet(ModelViewSet): +class ClusterTypeViewSet(CustomFieldModelViewSet): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') ) @@ -28,7 +28,7 @@ class ClusterTypeViewSet(ModelViewSet): filterset_class = filters.ClusterTypeFilterSet -class ClusterGroupViewSet(ModelViewSet): +class ClusterGroupViewSet(CustomFieldModelViewSet): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') ) From ed059d80d696e355624b24cc744388f6da11ede6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Feb 2021 16:44:16 -0500 Subject: [PATCH 010/223] Introduce OrganizationalModelSerializer --- netbox/circuits/api/serializers.py | 7 ++++--- netbox/dcim/api/serializers.py | 15 ++++++--------- netbox/ipam/api/serializers.py | 9 +++++---- netbox/netbox/api/serializers.py | 4 ++++ netbox/virtualization/api/serializers.py | 7 ++++--- 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 17dce662455..74d8136e078 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -2,11 +2,12 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices from circuits.models import Provider, Circuit, CircuitTermination, CircuitType -from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer -from netbox.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer +from netbox.api import ChoiceField +from netbox.api.serializers import OrganizationalModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -31,7 +32,7 @@ class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Circuits # -class CircuitTypeSerializer(CustomFieldModelSerializer): +class CircuitTypeSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 3aa5dea2f44..7d86d18a1db 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -13,15 +13,12 @@ from dcim.models import ( PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) -from dcim.utils import decompile_path_node from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN -from netbox.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, - WritableNestedSerializer, -) +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField +from netbox.api.serializers import OrganizationalModelSerializer, ValidatedModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model @@ -138,7 +135,7 @@ class RackGroupSerializer(CustomFieldModelSerializer): ] -class RackRoleSerializer(CustomFieldModelSerializer): +class RackRoleSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) @@ -251,7 +248,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # Device types # -class ManufacturerSerializer(CustomFieldModelSerializer): +class ManufacturerSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True) @@ -388,7 +385,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): # Devices # -class DeviceRoleSerializer(CustomFieldModelSerializer): +class DeviceRoleSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -401,7 +398,7 @@ class DeviceRoleSerializer(CustomFieldModelSerializer): ] -class PlatformSerializer(CustomFieldModelSerializer): +class PlatformSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 719ea51e2eb..8688da8f281 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -11,7 +11,8 @@ from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.serializers import OrganizationalModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedVirtualMachineSerializer @@ -67,7 +68,7 @@ class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # RIRs/aggregates # -class RIRSerializer(CustomFieldModelSerializer): +class RIRSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') aggregate_count = serializers.IntegerField(read_only=True) @@ -98,7 +99,7 @@ class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # VLANs # -class RoleSerializer(CustomFieldModelSerializer): +class RoleSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') prefix_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) @@ -111,7 +112,7 @@ class RoleSerializer(CustomFieldModelSerializer): ] -class VLANGroupSerializer(CustomFieldModelSerializer): +class VLANGroupSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') site = NestedSiteSerializer(required=False, allow_null=True) vlan_count = serializers.IntegerField(read_only=True) diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index 3a8641efcb9..02e382d4be5 100644 --- a/netbox/netbox/api/serializers.py +++ b/netbox/netbox/api/serializers.py @@ -35,6 +35,10 @@ class ValidatedModelSerializer(serializers.ModelSerializer): return data +class OrganizationalModelSerializer(ValidatedModelSerializer): + pass + + class WritableNestedSerializer(serializers.ModelSerializer): """ Returns a nested representation of an object on read, but accepts only a primary key on write. diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 4883f9f624c..b8ccb99b0d8 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -7,7 +7,8 @@ from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN -from netbox.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api import ChoiceField, SerializedPKRelatedField +from netbox.api.serializers import OrganizationalModelSerializer, ValidatedModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -18,7 +19,7 @@ from .nested_serializers import * # Clusters # -class ClusterTypeSerializer(CustomFieldModelSerializer): +class ClusterTypeSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') cluster_count = serializers.IntegerField(read_only=True) @@ -29,7 +30,7 @@ class ClusterTypeSerializer(CustomFieldModelSerializer): ] -class ClusterGroupSerializer(CustomFieldModelSerializer): +class ClusterGroupSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') cluster_count = serializers.IntegerField(read_only=True) From 12fbd349628b94c737cf35b1284f076d87c2ef6c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Feb 2021 17:15:07 -0500 Subject: [PATCH 011/223] Introduce NestedGroupModelSerializer --- netbox/circuits/api/serializers.py | 2 +- netbox/dcim/api/serializers.py | 12 +++---- netbox/extras/api/customfields.py | 35 +------------------- netbox/ipam/api/serializers.py | 2 +- netbox/netbox/api/serializers.py | 41 +++++++++++++++++++++++- netbox/secrets/api/serializers.py | 2 +- netbox/tenancy/api/serializers.py | 6 ++-- netbox/virtualization/api/serializers.py | 2 +- 8 files changed, 53 insertions(+), 49 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 74d8136e078..5688bf1c7a5 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -4,7 +4,7 @@ from circuits.choices import CircuitStatusChoices from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer -from extras.api.customfields import CustomFieldModelSerializer +from netbox.api.serializers import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from netbox.api import ChoiceField from netbox.api.serializers import OrganizationalModelSerializer, WritableNestedSerializer diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7d86d18a1db..b4ac77e2dd5 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -13,12 +13,14 @@ from dcim.models import ( PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) -from extras.api.customfields import CustomFieldModelSerializer +from netbox.api.serializers import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField -from netbox.api.serializers import OrganizationalModelSerializer, ValidatedModelSerializer, WritableNestedSerializer +from netbox.api.serializers import ( + NestedGroupModelSerializer, OrganizationalModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, +) from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model @@ -79,11 +81,10 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): # Regions/sites # -class RegionSerializer(CustomFieldModelSerializer): +class RegionSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') parent = NestedRegionSerializer(required=False, allow_null=True) site_count = serializers.IntegerField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = Region @@ -120,12 +121,11 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Racks # -class RackGroupSerializer(CustomFieldModelSerializer): +class RackGroupSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') site = NestedSiteSerializer() parent = NestedRackGroupSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = RackGroup diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index c8c4ba89ee7..5cb1fc27618 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,9 +1,7 @@ from django.contrib.contenttypes.models import ContentType -from rest_framework.fields import CreateOnlyDefault, Field +from rest_framework.fields import Field -from extras.choices import * from extras.models import CustomField -from netbox.api import ValidatedModelSerializer # @@ -56,34 +54,3 @@ class CustomFieldsDataField(Field): data = {**self.parent.instance.custom_field_data, **data} return data - - -class CustomFieldModelSerializer(ValidatedModelSerializer): - """ - Extends ModelSerializer to render any CustomFields and their values associated with an object. - """ - custom_fields = CustomFieldsDataField( - source='custom_field_data', - default=CreateOnlyDefault(CustomFieldDefaultValues()) - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if self.instance is not None: - - # Retrieve the set of CustomFields which apply to this type of object - content_type = ContentType.objects.get_for_model(self.Meta.model) - fields = CustomField.objects.filter(content_types=content_type) - - # Populate CustomFieldValues for each instance from database - if type(self.instance) in (list, tuple): - for obj in self.instance: - self._populate_custom_fields(obj, fields) - else: - self._populate_custom_fields(self.instance, fields) - - def _populate_custom_fields(self, instance, custom_fields): - instance.custom_fields = {} - for field in custom_fields: - instance.custom_fields[field.name] = instance.cf.get(field.name) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 8688da8f281..002ad3b8912 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -6,7 +6,7 @@ from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer -from extras.api.customfields import CustomFieldModelSerializer +from netbox.api.serializers import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index 02e382d4be5..18fc112c8d3 100644 --- a/netbox/netbox/api/serializers.py +++ b/netbox/netbox/api/serializers.py @@ -1,8 +1,12 @@ +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist from django.db.models import ManyToManyField from rest_framework import serializers from rest_framework.exceptions import ValidationError +from rest_framework.fields import CreateOnlyDefault +from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues +from extras.models import CustomField from utilities.utils import dict_to_filter_params @@ -35,10 +39,45 @@ class ValidatedModelSerializer(serializers.ModelSerializer): return data -class OrganizationalModelSerializer(ValidatedModelSerializer): +class CustomFieldModelSerializer(ValidatedModelSerializer): + """ + Extends ModelSerializer to render any CustomFields and their values associated with an object. + """ + custom_fields = CustomFieldsDataField( + source='custom_field_data', + default=CreateOnlyDefault(CustomFieldDefaultValues()) + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance is not None: + + # Retrieve the set of CustomFields which apply to this type of object + content_type = ContentType.objects.get_for_model(self.Meta.model) + fields = CustomField.objects.filter(content_types=content_type) + + # Populate CustomFieldValues for each instance from database + if type(self.instance) in (list, tuple): + for obj in self.instance: + self._populate_custom_fields(obj, fields) + else: + self._populate_custom_fields(self.instance, fields) + + def _populate_custom_fields(self, instance, custom_fields): + instance.custom_fields = {} + for field in custom_fields: + instance.custom_fields[field.name] = instance.cf.get(field.name) + + +class OrganizationalModelSerializer(CustomFieldModelSerializer): pass +class NestedGroupModelSerializer(CustomFieldModelSerializer): + _depth = serializers.IntegerField(source='level', read_only=True) + + class WritableNestedSerializer(serializers.ModelSerializer): """ Returns a nested representation of an object on read, but accepts only a primary key on write. diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 627685de0a4..207836c4c86 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from extras.api.customfields import CustomFieldModelSerializer +from netbox.api.serializers import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from secrets.constants import SECRET_ASSIGNMENT_MODELS from secrets.models import Secret, SecretRole diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index d301ee3fe32..c701e6b3b32 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,8 +1,7 @@ from rest_framework import serializers -from extras.api.customfields import CustomFieldModelSerializer +from netbox.api.serializers import CustomFieldModelSerializer, NestedGroupModelSerializer from extras.api.serializers import TaggedObjectSerializer -from netbox.api import ValidatedModelSerializer from tenancy.models import Tenant, TenantGroup from .nested_serializers import * @@ -11,11 +10,10 @@ from .nested_serializers import * # Tenants # -class TenantGroupSerializer(CustomFieldModelSerializer): +class TenantGroupSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') parent = NestedTenantGroupSerializer(required=False, allow_null=True) tenant_count = serializers.IntegerField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = TenantGroup diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index b8ccb99b0d8..e3b82e40a2c 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.choices import InterfaceModeChoices -from extras.api.customfields import CustomFieldModelSerializer +from netbox.api.serializers import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN From cf7830757724c18c42f827e8db560a16146e4703 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Feb 2021 11:25:23 -0500 Subject: [PATCH 012/223] Update organizational models to use custom field forms --- netbox/circuits/forms.py | 2 +- netbox/dcim/forms.py | 24 ++++++++++++------------ netbox/ipam/forms.py | 16 ++++++++-------- netbox/secrets/forms.py | 4 ++-- netbox/tenancy/forms.py | 4 ++-- netbox/virtualization/forms.py | 8 ++++---- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 18d49d08e45..2e66f99f1d6 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -141,7 +141,7 @@ class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): ] -class CircuitTypeCSVForm(CSVModelForm): +class CircuitTypeCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 3428dec57f7..01704ff5a1b 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -173,7 +173,7 @@ class MACAddressField(forms.Field): # Regions # -class RegionForm(BootstrapMixin, forms.ModelForm): +class RegionForm(BootstrapMixin, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -187,7 +187,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm): ) -class RegionCSVForm(CSVModelForm): +class RegionCSVForm(CustomFieldModelCSVForm): parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, @@ -360,7 +360,7 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # Rack groups # -class RackGroupForm(BootstrapMixin, forms.ModelForm): +class RackGroupForm(BootstrapMixin, CustomFieldModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -390,7 +390,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): ) -class RackGroupCSVForm(CSVModelForm): +class RackGroupCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -440,7 +440,7 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): # Rack roles # -class RackRoleForm(BootstrapMixin, forms.ModelForm): +class RackRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -450,7 +450,7 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): ] -class RackRoleCSVForm(CSVModelForm): +class RackRoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -913,7 +913,7 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): # Manufacturers # -class ManufacturerForm(BootstrapMixin, forms.ModelForm): +class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -923,7 +923,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): ] -class ManufacturerCSVForm(CSVModelForm): +class ManufacturerCSVForm(CustomFieldModelCSVForm): class Meta: model = Manufacturer @@ -1705,7 +1705,7 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm): # Device roles # -class DeviceRoleForm(BootstrapMixin, forms.ModelForm): +class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -1715,7 +1715,7 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): ] -class DeviceRoleCSVForm(CSVModelForm): +class DeviceRoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -1730,7 +1730,7 @@ class DeviceRoleCSVForm(CSVModelForm): # Platforms # -class PlatformForm(BootstrapMixin, forms.ModelForm): +class PlatformForm(BootstrapMixin, CustomFieldModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -1749,7 +1749,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): } -class PlatformCSVForm(CSVModelForm): +class PlatformCSVForm(CustomFieldModelCSVForm): slug = SlugField() manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 7e45c30b3f9..bf3fd1491a8 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -8,8 +8,8 @@ from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, CSVModelForm, - DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField, + add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, DatePicker, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, VirtualMachine, VMInterface @@ -195,7 +195,7 @@ class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilter # RIRs # -class RIRForm(BootstrapMixin, forms.ModelForm): +class RIRForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -205,7 +205,7 @@ class RIRForm(BootstrapMixin, forms.ModelForm): ] -class RIRCSVForm(CSVModelForm): +class RIRCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -332,7 +332,7 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # Roles # -class RoleForm(BootstrapMixin, forms.ModelForm): +class RoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -342,7 +342,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm): ] -class RoleCSVForm(CSVModelForm): +class RoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -1081,7 +1081,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # VLAN groups # -class VLANGroupForm(BootstrapMixin, forms.ModelForm): +class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -1105,7 +1105,7 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): ] -class VLANGroupCSVForm(CSVModelForm): +class VLANGroupCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), required=False, diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index cdd843e2d72..2aa3243ee91 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -43,7 +43,7 @@ def validate_rsa_key(key, is_secret=True): # Secret roles # -class SecretRoleForm(BootstrapMixin, forms.ModelForm): +class SecretRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -51,7 +51,7 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): fields = ('name', 'slug', 'description') -class SecretRoleCSVForm(CSVModelForm): +class SecretRoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 97e72bb027c..1d57f5da815 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -15,7 +15,7 @@ from .models import Tenant, TenantGroup # Tenant groups # -class TenantGroupForm(BootstrapMixin, forms.ModelForm): +class TenantGroupForm(BootstrapMixin, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False @@ -29,7 +29,7 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm): ] -class TenantGroupCSVForm(CSVModelForm): +class TenantGroupCSVForm(CustomFieldModelCSVForm): parent = CSVModelChoiceField( queryset=TenantGroup.objects.all(), required=False, diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index addfb05cdb8..c68f2db44ef 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -27,7 +27,7 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterf # Cluster types # -class ClusterTypeForm(BootstrapMixin, forms.ModelForm): +class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -37,7 +37,7 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm): ] -class ClusterTypeCSVForm(CSVModelForm): +class ClusterTypeCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -49,7 +49,7 @@ class ClusterTypeCSVForm(CSVModelForm): # Cluster groups # -class ClusterGroupForm(BootstrapMixin, forms.ModelForm): +class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -59,7 +59,7 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): ] -class ClusterGroupCSVForm(CSVModelForm): +class ClusterGroupCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: From 7e6cb9d18696eb312954796cda22f19fd1b51c7c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Feb 2021 16:12:52 -0500 Subject: [PATCH 013/223] Closes #1638: Migrate all primary keys to 64-bit integers --- docs/release-notes/index.md | 2 +- docs/release-notes/version-2.11.md | 9 + .../migrations/0025_standardize_models.py | 8 +- .../migrations/0123_standardize_models.py | 288 +----------------- .../dcim/models/device_component_templates.py | 4 +- netbox/dcim/models/device_components.py | 4 +- .../migrations/0054_standardize_models.py | 8 +- netbox/extras/models/models.py | 4 +- netbox/extras/models/tags.py | 4 +- .../migrations/0044_standardize_models.py | 18 +- netbox/netbox/models.py | 54 ++-- .../migrations/0013_standardize_models.py | 8 +- .../migrations/0012_standardize_models.py | 8 +- .../migrations/0011_standardize_models.py | 7 +- netbox/users/models.py | 2 +- .../migrations/0020_standardize_models.py | 28 +- netbox/virtualization/models.py | 4 +- 17 files changed, 72 insertions(+), 388 deletions(-) create mode 100644 docs/release-notes/version-2.11.md diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 8990f83e048..f7f6f36e9db 120000 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -1 +1 @@ -version-2.10.md \ No newline at end of file +version-2.11.md \ No newline at end of file diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md new file mode 100644 index 00000000000..4daeab8bcdd --- /dev/null +++ b/docs/release-notes/version-2.11.md @@ -0,0 +1,9 @@ +# NetBox v2.11 + +## v2.11-beta1 (FUTURE) + +**WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost. + +### Other Changes + +* [#1638](https://github.com/netbox-community/netbox/issues/1638) - Migrate all primary keys to 64-bit integers diff --git a/netbox/circuits/migrations/0025_standardize_models.py b/netbox/circuits/migrations/0025_standardize_models.py index 2b1d2664e0e..f5d348ff350 100644 --- a/netbox/circuits/migrations/0025_standardize_models.py +++ b/netbox/circuits/migrations/0025_standardize_models.py @@ -1,4 +1,5 @@ -import django.core.serializers.json +# Generated by Django 3.2b1 on 2021-02-26 21:11 + from django.db import migrations, models @@ -9,11 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='circuittype', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), migrations.AlterField( model_name='circuit', name='id', diff --git a/netbox/dcim/migrations/0123_standardize_models.py b/netbox/dcim/migrations/0123_standardize_models.py index 5050e1a268a..e9af8c464fd 100644 --- a/netbox/dcim/migrations/0123_standardize_models.py +++ b/netbox/dcim/migrations/0123_standardize_models.py @@ -1,4 +1,5 @@ -import django.core.serializers.json +# Generated by Django 3.2b1 on 2021-02-26 21:11 + from django.db import migrations, models @@ -9,291 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='consoleport', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='consoleport', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='consoleport', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='consoleporttemplate', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='consoleporttemplate', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='consoleporttemplate', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='consoleserverport', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='consoleserverport', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='consoleserverport', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='consoleserverporttemplate', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='consoleserverporttemplate', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='consoleserverporttemplate', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='devicebay', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='devicebay', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='devicebay', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='devicebaytemplate', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='devicebaytemplate', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='devicebaytemplate', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='devicerole', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='frontport', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='frontport', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='frontport', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='frontporttemplate', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='frontporttemplate', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='frontporttemplate', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='interface', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='interface', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='interface', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='interfacetemplate', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='interfacetemplate', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='interfacetemplate', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='inventoryitem', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='inventoryitem', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='inventoryitem', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='manufacturer', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='platform', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='poweroutlet', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='poweroutlet', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='poweroutlet', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='poweroutlettemplate', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='poweroutlettemplate', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='poweroutlettemplate', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='powerport', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='powerport', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='powerport', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='powerporttemplate', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='powerporttemplate', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='powerporttemplate', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='rackgroup', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='rackrole', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='rearport', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='rearport', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='rearport', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='rearporttemplate', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='rearporttemplate', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='rearporttemplate', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - migrations.AddField( - model_name='region', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), migrations.AlterField( model_name='cable', name='id', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 7b718936d4f..d250227b770 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -6,7 +6,7 @@ from dcim.choices import * from dcim.constants import * from extras.models import ObjectChange from extras.utils import extras_features -from netbox.models import PrimaryModel +from netbox.models import BigIDModel from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.ordering import naturalize_interface @@ -28,7 +28,7 @@ __all__ = ( ) -class ComponentTemplateModel(PrimaryModel): +class ComponentTemplateModel(BigIDModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index f78363ba9b6..01d2712a743 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -13,7 +13,7 @@ from dcim.constants import * from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features -from netbox.models import PrimaryModel +from netbox.models import BigIDModel from utilities.fields import NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -38,7 +38,7 @@ __all__ = ( ) -class ComponentModel(PrimaryModel): +class ComponentModel(BigIDModel): """ An abstract model inherited by any model which has a parent Device. """ diff --git a/netbox/extras/migrations/0054_standardize_models.py b/netbox/extras/migrations/0054_standardize_models.py index a836f365f49..b6c95e77def 100644 --- a/netbox/extras/migrations/0054_standardize_models.py +++ b/netbox/extras/migrations/0054_standardize_models.py @@ -1,4 +1,5 @@ -import django.core.serializers.json +# Generated by Django 3.2b1 on 2021-02-26 21:11 + from django.db import migrations, models @@ -9,11 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='configcontext', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), migrations.AlterField( model_name='configcontext', name='id', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index d60a2cc96d8..bf0dfc87388 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -16,7 +16,7 @@ from extras.choices import * from extras.constants import * from extras.querysets import ConfigContextQuerySet from extras.utils import extras_features, FeatureQuery, image_upload -from netbox.models import BigIDModel, PrimaryModel +from netbox.models import BigIDModel, ChangeLoggingMixin from utilities.querysets import RestrictedQuerySet from utilities.utils import deepmerge, render_jinja2 @@ -361,7 +361,7 @@ class ImageAttachment(BigIDModel): # Config contexts # -class ConfigContext(PrimaryModel): +class ConfigContext(ChangeLoggingMixin, BigIDModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 22e9815a9e8..3a5b539b17f 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from netbox.models import BigIDModel, CoreModel +from netbox.models import BigIDModel, ChangeLoggingMixin from utilities.choices import ColorChoices from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet @@ -12,7 +12,7 @@ from utilities.querysets import RestrictedQuerySet # Tags # -class Tag(TagBase, CoreModel): +class Tag(ChangeLoggingMixin, BigIDModel, TagBase): color = ColorField( default=ColorChoices.COLOR_GREY ) diff --git a/netbox/ipam/migrations/0044_standardize_models.py b/netbox/ipam/migrations/0044_standardize_models.py index 2762c9973cb..a4ad77053d5 100644 --- a/netbox/ipam/migrations/0044_standardize_models.py +++ b/netbox/ipam/migrations/0044_standardize_models.py @@ -1,4 +1,5 @@ -import django.core.serializers.json +# Generated by Django 3.2b1 on 2021-02-26 21:11 + from django.db import migrations, models @@ -9,21 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='rir', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='role', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='vlangroup', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), migrations.AlterField( model_name='aggregate', name='id', diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index c0b0ce7aeab..ad11dd18fbc 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -16,23 +16,13 @@ __all__ = ( ) -class BigIDModel(models.Model): - """ - Abstract base model for all Schematic data objects. Ensures the use of a 64-bit PK. - """ - id = models.BigAutoField( - primary_key=True - ) +# +# Mixins +# - class Meta: - abstract = True - - -class CoreModel(BigIDModel): +class ChangeLoggingMixin(models.Model): """ - Base class for all core objects. Provides the following: - - Change logging - - Custom field support + Provides change logging support. """ created = models.DateField( auto_now_add=True, @@ -62,9 +52,9 @@ class CoreModel(BigIDModel): ) -class PrimaryModel(CoreModel): +class CustomFieldsMixin(models.Model): """ - Primary models represent real objects within the infrastructure being modeled. + Provides support for custom fields. """ custom_field_data = models.JSONField( encoder=DjangoJSONEncoder, @@ -114,7 +104,33 @@ class PrimaryModel(CoreModel): raise ValidationError(f"Missing required custom field '{cf.name}'.") -class NestedGroupModel(PrimaryModel, MPTTModel): +# +# Base model classes + +class BigIDModel(models.Model): + """ + Abstract base model for all data objects. Ensures the use of a 64-bit PK. + """ + id = models.BigAutoField( + primary_key=True + ) + + class Meta: + abstract = True + + +class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + # TODO + # tags = TaggableManager(through=TaggedItem) + + class Meta: + abstract = True + + +class NestedGroupModel(ChangeLoggingMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -157,7 +173,7 @@ class NestedGroupModel(PrimaryModel, MPTTModel): ) -class OrganizationalModel(PrimaryModel): +class OrganizationalModel(ChangeLoggingMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/secrets/migrations/0013_standardize_models.py b/netbox/secrets/migrations/0013_standardize_models.py index 9de8dec9587..ad18536dc73 100644 --- a/netbox/secrets/migrations/0013_standardize_models.py +++ b/netbox/secrets/migrations/0013_standardize_models.py @@ -1,4 +1,5 @@ -import django.core.serializers.json +# Generated by Django 3.2b1 on 2021-02-26 21:11 + from django.db import migrations, models @@ -9,11 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='secretrole', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), migrations.AlterField( model_name='secret', name='id', diff --git a/netbox/tenancy/migrations/0012_standardize_models.py b/netbox/tenancy/migrations/0012_standardize_models.py index 7ce55cf42ba..6a277fa7c02 100644 --- a/netbox/tenancy/migrations/0012_standardize_models.py +++ b/netbox/tenancy/migrations/0012_standardize_models.py @@ -1,4 +1,5 @@ -import django.core.serializers.json +# Generated by Django 3.2b1 on 2021-02-26 21:11 + from django.db import migrations, models @@ -9,11 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='tenantgroup', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), migrations.AlterField( model_name='tenant', name='id', diff --git a/netbox/users/migrations/0011_standardize_models.py b/netbox/users/migrations/0011_standardize_models.py index abfbd87024c..3c514dd2a74 100644 --- a/netbox/users/migrations/0011_standardize_models.py +++ b/netbox/users/migrations/0011_standardize_models.py @@ -1,3 +1,5 @@ +# Generated by Django 3.2b1 on 2021-02-26 21:11 + from django.db import migrations, models @@ -18,9 +20,4 @@ class Migration(migrations.Migration): name='id', field=models.BigAutoField(primary_key=True, serialize=False), ), - migrations.AlterField( - model_name='userconfig', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 529732e6528..00e18148c6a 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -51,7 +51,7 @@ class AdminUser(User): # User preferences # -class UserConfig(BigIDModel): +class UserConfig(models.Model): """ This model stores arbitrary user-specific preferences in a JSON data structure. """ diff --git a/netbox/virtualization/migrations/0020_standardize_models.py b/netbox/virtualization/migrations/0020_standardize_models.py index 15585100d9d..93240369881 100644 --- a/netbox/virtualization/migrations/0020_standardize_models.py +++ b/netbox/virtualization/migrations/0020_standardize_models.py @@ -1,4 +1,5 @@ -import django.core.serializers.json +# Generated by Django 3.2b1 on 2021-02-26 21:11 + from django.db import migrations, models @@ -9,31 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='clustergroup', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='clustertype', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='vminterface', - name='created', - field=models.DateField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='vminterface', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), - ), - migrations.AddField( - model_name='vminterface', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), migrations.AlterField( model_name='cluster', name='id', diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index a6a63ab3b92..364dde30bad 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -9,7 +9,7 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel, ObjectChange, TaggedItem from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar @@ -374,7 +374,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): # @extras_features('export_templates', 'webhooks') -class VMInterface(PrimaryModel, BaseInterface): +class VMInterface(BigIDModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, From 1dcd857ca6d287d5c14d94e0c1a849283d8a0cbd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Feb 2021 16:25:37 -0500 Subject: [PATCH 014/223] Closes #5370: Extend custom field support to organizational models --- docs/release-notes/version-2.11.md | 4 +++ .../migrations/0025_standardize_models.py | 8 +++-- .../migrations/0123_standardize_models.py | 33 +++++++++++++++++-- .../migrations/0054_standardize_models.py | 2 -- .../migrations/0044_standardize_models.py | 18 ++++++++-- netbox/netbox/models.py | 4 +-- .../migrations/0013_standardize_models.py | 8 +++-- .../migrations/0012_standardize_models.py | 8 +++-- .../migrations/0011_standardize_models.py | 2 -- .../migrations/0020_standardize_models.py | 13 ++++++-- 10 files changed, 82 insertions(+), 18 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 4daeab8bcdd..4c12ed40aab 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -4,6 +4,10 @@ **WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost. +### Enhancements + +* [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models + ### Other Changes * [#1638](https://github.com/netbox-community/netbox/issues/1638) - Migrate all primary keys to 64-bit integers diff --git a/netbox/circuits/migrations/0025_standardize_models.py b/netbox/circuits/migrations/0025_standardize_models.py index f5d348ff350..2b1d2664e0e 100644 --- a/netbox/circuits/migrations/0025_standardize_models.py +++ b/netbox/circuits/migrations/0025_standardize_models.py @@ -1,5 +1,4 @@ -# Generated by Django 3.2b1 on 2021-02-26 21:11 - +import django.core.serializers.json from django.db import migrations, models @@ -10,6 +9,11 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='circuittype', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), migrations.AlterField( model_name='circuit', name='id', diff --git a/netbox/dcim/migrations/0123_standardize_models.py b/netbox/dcim/migrations/0123_standardize_models.py index e9af8c464fd..4a6fc87b610 100644 --- a/netbox/dcim/migrations/0123_standardize_models.py +++ b/netbox/dcim/migrations/0123_standardize_models.py @@ -1,5 +1,4 @@ -# Generated by Django 3.2b1 on 2021-02-26 21:11 - +import django.core.serializers.json from django.db import migrations, models @@ -10,6 +9,36 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='devicerole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='manufacturer', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='platform', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rackgroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rackrole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='region', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), migrations.AlterField( model_name='cable', name='id', diff --git a/netbox/extras/migrations/0054_standardize_models.py b/netbox/extras/migrations/0054_standardize_models.py index b6c95e77def..c7304334518 100644 --- a/netbox/extras/migrations/0054_standardize_models.py +++ b/netbox/extras/migrations/0054_standardize_models.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2b1 on 2021-02-26 21:11 - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0044_standardize_models.py b/netbox/ipam/migrations/0044_standardize_models.py index a4ad77053d5..2762c9973cb 100644 --- a/netbox/ipam/migrations/0044_standardize_models.py +++ b/netbox/ipam/migrations/0044_standardize_models.py @@ -1,5 +1,4 @@ -# Generated by Django 3.2b1 on 2021-02-26 21:11 - +import django.core.serializers.json from django.db import migrations, models @@ -10,6 +9,21 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='rir', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='role', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='vlangroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), migrations.AlterField( model_name='aggregate', name='id', diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index ad11dd18fbc..74d0184203a 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -130,7 +130,7 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel): abstract = True -class NestedGroupModel(ChangeLoggingMixin, BigIDModel, MPTTModel): +class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -173,7 +173,7 @@ class NestedGroupModel(ChangeLoggingMixin, BigIDModel, MPTTModel): ) -class OrganizationalModel(ChangeLoggingMixin, BigIDModel): +class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/secrets/migrations/0013_standardize_models.py b/netbox/secrets/migrations/0013_standardize_models.py index ad18536dc73..9de8dec9587 100644 --- a/netbox/secrets/migrations/0013_standardize_models.py +++ b/netbox/secrets/migrations/0013_standardize_models.py @@ -1,5 +1,4 @@ -# Generated by Django 3.2b1 on 2021-02-26 21:11 - +import django.core.serializers.json from django.db import migrations, models @@ -10,6 +9,11 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='secretrole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), migrations.AlterField( model_name='secret', name='id', diff --git a/netbox/tenancy/migrations/0012_standardize_models.py b/netbox/tenancy/migrations/0012_standardize_models.py index 6a277fa7c02..7ce55cf42ba 100644 --- a/netbox/tenancy/migrations/0012_standardize_models.py +++ b/netbox/tenancy/migrations/0012_standardize_models.py @@ -1,5 +1,4 @@ -# Generated by Django 3.2b1 on 2021-02-26 21:11 - +import django.core.serializers.json from django.db import migrations, models @@ -10,6 +9,11 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='tenantgroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), migrations.AlterField( model_name='tenant', name='id', diff --git a/netbox/users/migrations/0011_standardize_models.py b/netbox/users/migrations/0011_standardize_models.py index 3c514dd2a74..08d1103ed40 100644 --- a/netbox/users/migrations/0011_standardize_models.py +++ b/netbox/users/migrations/0011_standardize_models.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2b1 on 2021-02-26 21:11 - from django.db import migrations, models diff --git a/netbox/virtualization/migrations/0020_standardize_models.py b/netbox/virtualization/migrations/0020_standardize_models.py index 93240369881..8ccb3df0d05 100644 --- a/netbox/virtualization/migrations/0020_standardize_models.py +++ b/netbox/virtualization/migrations/0020_standardize_models.py @@ -1,5 +1,4 @@ -# Generated by Django 3.2b1 on 2021-02-26 21:11 - +import django.core.serializers.json from django.db import migrations, models @@ -10,6 +9,16 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='clustergroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='clustertype', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), migrations.AlterField( model_name='cluster', name='id', From 6a9b50f95db660415af4f94fe7bfc4923b104aff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Feb 2021 17:23:23 -0500 Subject: [PATCH 015/223] Closes #5873: Use numeric IDs in all object URLs --- docs/release-notes/version-2.11.md | 1 + netbox/circuits/models.py | 2 +- netbox/circuits/tables.py | 7 +++-- netbox/circuits/urls.py | 14 +++++----- netbox/dcim/models/sites.py | 2 +- netbox/dcim/tables/devices.py | 4 +-- netbox/dcim/tables/devicetypes.py | 2 +- netbox/dcim/tables/power.py | 5 ++-- netbox/dcim/tables/racks.py | 6 ++--- netbox/dcim/urls.py | 26 +++++++++---------- netbox/extras/tables.py | 2 +- netbox/extras/urls.py | 6 ++--- netbox/ipam/tables.py | 18 ++++++------- netbox/ipam/urls.py | 12 ++++----- netbox/secrets/tables.py | 2 +- netbox/secrets/urls.py | 6 ++--- netbox/templates/circuits/circuit.html | 2 +- .../circuits/inc/circuit_termination.html | 2 +- netbox/templates/circuits/provider.html | 2 +- netbox/templates/dcim/device.html | 4 +-- netbox/templates/dcim/devicetype.html | 4 +-- netbox/templates/dcim/rack.html | 2 +- netbox/templates/dcim/rackreservation.html | 2 +- netbox/templates/dcim/site.html | 2 +- netbox/templates/extras/tag.html | 2 +- netbox/templates/ipam/prefix.html | 2 +- netbox/templates/ipam/vlan.html | 2 +- netbox/templates/ipam/vlangroup_vlans.html | 2 +- netbox/templates/tenancy/tenant.html | 2 +- netbox/tenancy/models.py | 2 +- netbox/tenancy/tables.py | 4 +-- netbox/tenancy/urls.py | 14 +++++----- netbox/utilities/tables.py | 10 +++---- netbox/utilities/templatetags/buttons.py | 22 +++------------- netbox/utilities/testing/views.py | 10 +------ netbox/virtualization/tables.py | 4 +-- netbox/virtualization/urls.py | 12 ++++----- 37 files changed, 97 insertions(+), 126 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 4c12ed40aab..1dcf4975944 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -11,3 +11,4 @@ ### Other Changes * [#1638](https://github.com/netbox-community/netbox/issues/1638) - Migrate all primary keys to 64-bit integers +* [#5873](https://github.com/netbox-community/netbox/issues/5873) - Use numeric IDs in all object URLs diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index e371df547f8..693fe5ae636 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -79,7 +79,7 @@ class Provider(PrimaryModel): return self.name def get_absolute_url(self): - return reverse('circuits:provider', args=[self.slug]) + return reverse('circuits:provider', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 782b02394e9..e4b859d3cf1 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -39,7 +39,7 @@ class CircuitTypeTable(BaseTable): circuit_count = tables.Column( verbose_name='Circuits' ) - actions = ButtonsColumn(CircuitType, pk_field='slug') + actions = ButtonsColumn(CircuitType) class Meta(BaseTable.Meta): model = CircuitType @@ -56,9 +56,8 @@ class CircuitTable(BaseTable): cid = tables.LinkColumn( verbose_name='ID' ) - provider = tables.LinkColumn( - viewname='circuits:provider', - args=[Accessor('provider__slug')] + provider = tables.Column( + linkify=True ) status = ChoiceFieldColumn() tenant = tables.TemplateColumn( diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index d757fd90dfa..a8306efa8cd 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -14,19 +14,19 @@ urlpatterns = [ path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), - path('providers//', views.ProviderView.as_view(), name='provider'), - path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), - path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), - path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), + path('providers//', views.ProviderView.as_view(), name='provider'), + path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), + path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), + path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'), path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), - path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), - path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), - path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), + path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), + path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 92d8e1b2606..417e0b9148a 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -192,7 +192,7 @@ class Site(PrimaryModel): return self.name def get_absolute_url(self): - return reverse('dcim:site', args=[self.slug]) + return reverse('dcim:site', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index edd9e7a437a..20e04e7ccdb 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -61,7 +61,7 @@ class DeviceRoleTable(BaseTable): ) color = ColorColumn() vm_role = BooleanColumn() - actions = ButtonsColumn(DeviceRole, pk_field='slug') + actions = ButtonsColumn(DeviceRole) class Meta(BaseTable.Meta): model = DeviceRole @@ -85,7 +85,7 @@ class PlatformTable(BaseTable): url_params={'platform': 'slug'}, verbose_name='VMs' ) - actions = ButtonsColumn(Platform, pk_field='slug') + actions = ButtonsColumn(Platform) class Meta(BaseTable.Meta): model = Platform diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index c4d618cd732..c5b8bb70de4 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -37,7 +37,7 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() - actions = ButtonsColumn(Manufacturer, pk_field='slug') + actions = ButtonsColumn(Manufacturer) class Meta(BaseTable.Meta): model = Manufacturer diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index ae5c2a5c816..6d6e2541ba3 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -19,9 +19,8 @@ __all__ = ( class PowerPanelTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] + site = tables.Column( + linkify=True ) powerfeed_count = LinkedCountColumn( viewname='dcim:powerfeed_list', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 775e9007626..1ac0d17bb81 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -29,10 +29,8 @@ class RackGroupTable(BaseTable): orderable=False, attrs={'td': {'class': 'text-nowrap'}} ) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')], - verbose_name='Site' + site = tables.Column( + linkify=True ) rack_count = tables.Column( verbose_name='Racks' diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index d167ebdb714..6cf61df247f 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -27,10 +27,10 @@ urlpatterns = [ path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), - path('sites//', views.SiteView.as_view(), name='site'), - path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), - path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), - path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), + path('sites//', views.SiteView.as_view(), name='site'), + path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), + path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), + path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), path('sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Rack groups @@ -80,9 +80,9 @@ urlpatterns = [ path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'), path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), - path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), - path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), - path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), + path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), + path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), @@ -164,18 +164,18 @@ urlpatterns = [ path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'), path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), - path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), - path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), - path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), + path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), + path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms path('platforms/', views.PlatformListView.as_view(), name='platform_list'), path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'), path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), - path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), - path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), - path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), + path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), + path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), + path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices path('devices/', views.DeviceListView.as_view(), name='device_list'), diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index cccb56c0a15..7aeddb48fb3 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -37,7 +37,7 @@ OBJECTCHANGE_REQUEST_ID = """ class TagTable(BaseTable): pk = ToggleColumn() color = ColorColumn() - actions = ButtonsColumn(Tag, pk_field='slug') + actions = ButtonsColumn(Tag) class Meta(BaseTable.Meta): model = Tag diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 36077dc7a82..d2f0a2eb28f 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -13,9 +13,9 @@ urlpatterns = [ path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), - path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), - path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), - path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), + path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), + path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), + path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), # Config contexts path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 86d7e21ebf7..8101a581d9c 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -111,9 +111,9 @@ VLAN_MEMBER_TAGGED = """ TENANT_LINK = """ {% if record.tenant %} - {{ record.tenant }} + {{ record.tenant }} {% elif record.vrf.tenant %} - {{ record.vrf.tenant }}* + {{ record.vrf.tenant }}* {% else %} — {% endif %} @@ -191,7 +191,7 @@ class RIRTable(BaseTable): url_params={'rir': 'slug'}, verbose_name='Aggregates' ) - actions = ButtonsColumn(RIR, pk_field='slug') + actions = ButtonsColumn(RIR) class Meta(BaseTable.Meta): model = RIR @@ -254,7 +254,7 @@ class RoleTable(BaseTable): url_params={'role': 'slug'}, verbose_name='VLANs' ) - actions = ButtonsColumn(Role, pk_field='slug') + actions = ButtonsColumn(Role) class Meta(BaseTable.Meta): model = Role @@ -444,9 +444,8 @@ class InterfaceIPAddressTable(BaseTable): class VLANGroupTable(BaseTable): pk = ToggleColumn() name = tables.Column(linkify=True) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] + site = tables.Column( + linkify=True ) vlan_count = LinkedCountColumn( viewname='ipam:vlan_list', @@ -474,9 +473,8 @@ class VLANTable(BaseTable): template_code=VLAN_LINK, verbose_name='ID' ) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] + site = tables.Column( + linkify=True ) group = tables.LinkColumn( viewname='ipam:vlangroup_vlans', diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 9b0dc581bdf..db0eec157c5 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -34,9 +34,9 @@ urlpatterns = [ path('rirs/add/', views.RIREditView.as_view(), name='rir_add'), path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), - path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), - path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), - path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), + path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), + path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), + path('rirs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), @@ -54,9 +54,9 @@ urlpatterns = [ path('roles/add/', views.RoleEditView.as_view(), name='role_add'), path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), - path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), - path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), - path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), + path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), + path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), + path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 0d8559a2bb0..dd91985ecc8 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -16,7 +16,7 @@ class SecretRoleTable(BaseTable): url_params={'role': 'slug'}, verbose_name='Secrets' ) - actions = ButtonsColumn(SecretRole, pk_field='slug') + actions = ButtonsColumn(SecretRole) class Meta(BaseTable.Meta): model = SecretRole diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 9dbb5d044c5..62a28587527 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -12,9 +12,9 @@ urlpatterns = [ path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'), path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), - path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), - path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), - path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), + path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), + path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets path('secrets/', views.SecretListView.as_view(), name='secret_list'), diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index c104b154bef..47125da147f 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -74,7 +74,7 @@ Provider - {{ object.provider }} + {{ object.provider }} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 47778893112..e4854e5abbc 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -32,7 +32,7 @@ {% if termination.site.region %} {{ termination.site.region }} / {% endif %} - {{ termination.site }} + {{ termination.site }} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 8778c3ac2ba..7fa512ec636 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -51,7 +51,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 55be343acbb..917384b9807 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -23,7 +23,7 @@ {% if object.site.region %} {{ object.site.region }} / {% endif %} - {{ object.site }} + {{ object.site }} @@ -74,7 +74,7 @@ Device Type - {{ object.device_type.display_name }} ({{ object.device_type.u_height }}U) + {{ object.device_type.display_name }} ({{ object.device_type.u_height }}U) diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 6bc970174cd..110d42c0456 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -55,10 +55,10 @@ {% clone_button object %} {% endif %} {% if perms.dcim.change_devicetype %} - {% edit_button object use_pk=True %} + {% edit_button object %} {% endif %} {% if perms.dcim.delete_devicetype %} - {% delete_button object use_pk=True %} + {% delete_button object %} {% endif %}

{{ object.manufacturer }} {{ object.model }}

diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 6a00308f302..9c0d72a04f6 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -85,7 +85,7 @@ {% if object.site.region %} {{ object.site.region }} / {% endif %} - {{ object.site }} + {{ object.site }} diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 5273b2a3907..4760f419226 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -68,7 +68,7 @@ {% if rack.site.region %} {{ rack.site.region }} / {% endif %} - {{ rack.site }} + {{ rack.site }} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index a0e713fcff6..0ffc7e265a4 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -56,7 +56,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 2ad7cf814e4..2cee8f882b0 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -44,7 +44,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 4b382636b90..d195affb277 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -129,7 +129,7 @@ {% if object.site.region %} {{ object.site.region }} / {% endif %} - {{ object.site }} + {{ object.site }} {% else %} None {% endif %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index d2967ca5619..52286e29dbb 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -81,7 +81,7 @@ {% if object.site.region %} {{ object.site.region }} / {% endif %} - {{ object.site }} + {{ object.site }} {% else %} None {% endif %} diff --git a/netbox/templates/ipam/vlangroup_vlans.html b/netbox/templates/ipam/vlangroup_vlans.html index 490b7ab2c9e..ffb75d3b803 100644 --- a/netbox/templates/ipam/vlangroup_vlans.html +++ b/netbox/templates/ipam/vlangroup_vlans.html @@ -8,7 +8,7 @@ diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e2e66e6b26e..00044f1c3d4 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -51,7 +51,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index e83f4bdd2c9..757afb09eb5 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -105,7 +105,7 @@ class Tenant(PrimaryModel): return self.name def get_absolute_url(self): - return reverse('tenancy:tenant', args=[self.slug]) + return reverse('tenancy:tenant', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 9e8be6b18d4..8872a7012b5 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -12,7 +12,7 @@ MPTT_LINK = """ COL_TENANT = """ {% if record.tenant %} - {{ record.tenant }} + {{ record.tenant }} {% else %} — {% endif %} @@ -35,7 +35,7 @@ class TenantGroupTable(BaseTable): url_params={'group': 'slug'}, verbose_name='Tenants' ) - actions = ButtonsColumn(TenantGroup, pk_field='slug') + actions = ButtonsColumn(TenantGroup) class Meta(BaseTable.Meta): model = TenantGroup diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 372308bb80d..c07df97a6f2 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -12,9 +12,9 @@ urlpatterns = [ path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'), path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), - path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), - path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), - path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), + path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), + path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), + path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), # Tenants path('tenants/', views.TenantListView.as_view(), name='tenant_list'), @@ -22,9 +22,9 @@ urlpatterns = [ path('tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'), path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), - path('tenants//', views.TenantView.as_view(), name='tenant'), - path('tenants//edit/', views.TenantEditView.as_view(), name='tenant_edit'), - path('tenants//delete/', views.TenantDeleteView.as_view(), name='tenant_delete'), - path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), + path('tenants//', views.TenantView.as_view(), name='tenant'), + path('tenants//edit/', views.TenantEditView.as_view(), name='tenant_edit'), + path('tenants//delete/', views.TenantDeleteView.as_view(), name='tenant_delete'), + path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), ] diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index bf087f2c9ec..f2f56778399 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -147,24 +147,23 @@ class ButtonsColumn(tables.TemplateColumn): # Note that braces are escaped to allow for string formatting prior to template rendering template_code = """ {{% if "changelog" in buttons %}} - + {{% endif %}} {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}} - + {{% endif %}} {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}} - + {{% endif %}} """ - def __init__(self, model, *args, pk_field='pk', buttons=None, prepend_template=None, return_url_extra='', - **kwargs): + def __init__(self, model, *args, buttons=None, prepend_template=None, return_url_extra='', **kwargs): if prepend_template: prepend_template = prepend_template.replace('{', '{{') prepend_template = prepend_template.replace('}', '}}') @@ -173,7 +172,6 @@ class ButtonsColumn(tables.TemplateColumn): template_code = self.template_code.format( app_label=model._meta.app_label, model_name=model._meta.model_name, - pk_field=pk_field, buttons=buttons ) diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index e7f00cacfe5..c486dd2e51b 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -40,16 +40,9 @@ def clone_button(instance): @register.inclusion_tag('buttons/edit.html') -def edit_button(instance, use_pk=False): +def edit_button(instance): viewname = _get_viewname(instance, 'edit') - - # Assign kwargs - if hasattr(instance, 'slug') and not use_pk: - kwargs = {'slug': instance.slug} - else: - kwargs = {'pk': instance.pk} - - url = reverse(viewname, kwargs=kwargs) + url = reverse(viewname, kwargs={'pk': instance.pk}) return { 'url': url, @@ -57,16 +50,9 @@ def edit_button(instance, use_pk=False): @register.inclusion_tag('buttons/delete.html') -def delete_button(instance, use_pk=False): +def delete_button(instance): viewname = _get_viewname(instance, 'delete') - - # Assign kwargs - if hasattr(instance, 'slug') and not use_pk: - kwargs = {'slug': instance.slug} - else: - kwargs = {'pk': instance.pk} - - url = reverse(viewname, kwargs=kwargs) + url = reverse(viewname, kwargs={'pk': instance.pk}) return { 'url': url, diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index d380996b7ae..89bac51cfe7 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -5,7 +5,7 @@ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist from django.db.models import ManyToManyField from django.forms.models import model_to_dict from django.test import Client, TestCase as _TestCase, override_settings -from django.urls import reverse, NoReverseMatch +from django.urls import reverse from django.utils.text import slugify from netaddr import IPNetwork from taggit.managers import TaggableManager @@ -205,14 +205,6 @@ class ModelViewTestCase(ModelTestCase): if instance is None: return reverse(url_format.format(action)) - # Attempt to resolve using slug as the unique identifier if one exists - if hasattr(self.model, 'slug'): - try: - return reverse(url_format.format(action), kwargs={'slug': instance.slug}) - except NoReverseMatch: - pass - - # Default to using the numeric PK to retrieve the URL for an object return reverse(url_format.format(action), kwargs={'pk': instance.pk}) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 34a07062349..808832224bc 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -36,7 +36,7 @@ class ClusterTypeTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) - actions = ButtonsColumn(ClusterType, pk_field='slug') + actions = ButtonsColumn(ClusterType) class Meta(BaseTable.Meta): model = ClusterType @@ -54,7 +54,7 @@ class ClusterGroupTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) - actions = ButtonsColumn(ClusterGroup, pk_field='slug') + actions = ButtonsColumn(ClusterGroup) class Meta(BaseTable.Meta): model = ClusterGroup diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 3d6f0756607..703f99b65a5 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -13,18 +13,18 @@ urlpatterns = [ path('cluster-types/add/', views.ClusterTypeEditView.as_view(), name='clustertype_add'), path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), - path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), - path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), - path('cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), + path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), + path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), + path('cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), # Cluster groups path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), path('cluster-groups/add/', views.ClusterGroupEditView.as_view(), name='clustergroup_add'), path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), - path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), - path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), - path('cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), + path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), + path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), + path('cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), # Clusters path('clusters/', views.ClusterListView.as_view(), name='cluster_list'), From d6ee4d58ba61cbd9616b8684fd88c9a044585c8f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Mar 2021 13:07:25 -0500 Subject: [PATCH 016/223] Add custom field support for device component models --- netbox/dcim/api/serializers.py | 28 ++++++------ netbox/dcim/filters.py | 3 +- netbox/dcim/forms.py | 40 +++++++++-------- .../migrations/0123_standardize_models.py | 45 +++++++++++++++++++ netbox/dcim/models/device_components.py | 4 +- netbox/netbox/models.py | 1 + netbox/templates/dcim/consoleport.html | 1 + netbox/templates/dcim/consoleserverport.html | 1 + netbox/templates/dcim/devicebay.html | 1 + netbox/templates/dcim/frontport.html | 1 + netbox/templates/dcim/interface.html | 1 + netbox/templates/dcim/inventoryitem.html | 1 + netbox/templates/dcim/poweroutlet.html | 1 + netbox/templates/dcim/powerport.html | 1 + netbox/templates/dcim/rearport.html | 1 + 15 files changed, 93 insertions(+), 37 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index b4ac77e2dd5..d94d3aa49b0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -49,7 +49,7 @@ class CableTerminationSerializer(serializers.ModelSerializer): return None -class ConnectedEndpointSerializer(ValidatedModelSerializer): +class ConnectedEndpointSerializer(CustomFieldModelSerializer): connected_endpoint_type = serializers.SerializerMethodField(read_only=True) connected_endpoint = serializers.SerializerMethodField(read_only=True) connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) @@ -497,7 +497,7 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial model = ConsoleServerPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', ] @@ -515,7 +515,7 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, model = ConsolePort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', ] @@ -544,7 +544,7 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', + 'connected_endpoint_reachable', 'tags', 'custom_fields', ] @@ -563,7 +563,7 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', + 'connected_endpoint_reachable', 'tags', 'custom_fields', ] @@ -588,7 +588,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'cable', 'cable_peer', 'cable_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'count_ipaddresses', ] @@ -606,7 +606,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co return super().validate(data) -class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer): +class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -616,7 +616,7 @@ class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Val model = RearPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', - 'cable_peer_type', 'tags', + 'cable_peer_type', 'tags', 'custom_fields', ] @@ -631,7 +631,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name', 'label'] -class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer): +class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -642,25 +642,25 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Va model = FrontPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', - 'cable_peer', 'cable_peer_type', 'tags', + 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', ] -class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) class Meta: model = DeviceBay - fields = ['id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags'] + fields = ['id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags', 'custom_fields'] # # Inventory items # -class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() # Provide a default value to satisfy UniqueTogetherValidator @@ -672,7 +672,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): model = InventoryItem fields = [ 'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'discovered', 'description', 'tags', '_depth', + 'discovered', 'description', 'tags', 'custom_fields', '_depth', ] diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 41363c26119..e89f156ac04 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,6 +1,5 @@ import django_filters from django.contrib.auth.models import User -from django.db.models import Count from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet @@ -704,7 +703,7 @@ class DeviceFilterSet( return queryset.exclude(devicebays__isnull=value) -class DeviceComponentFilterSet(django_filters.FilterSet): +class DeviceComponentFilterSet(CustomFieldModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 01704ff5a1b..f55697f2216 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -58,7 +58,7 @@ def get_device_by_name_or_pk(name): return device -class DeviceComponentFilterForm(BootstrapMixin, forms.Form): +class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm): field_order = [ 'q', 'region', 'site' ] @@ -2274,6 +2274,7 @@ class ComponentCreateForm(ComponentForm): """ Base form for the creation of device components (models subclassed from ComponentModel). """ + # TODO: Enable custom field support device = DynamicModelChoiceField( queryset=Device.objects.all(), display_field='display_name' @@ -2289,6 +2290,7 @@ class ComponentCreateForm(ComponentForm): class DeviceBulkAddComponentForm(ComponentForm): + # TODO: Enable custom field support pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() @@ -2318,7 +2320,7 @@ class ConsolePortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class ConsolePortForm(BootstrapMixin, forms.ModelForm): +class ConsolePortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -2365,7 +2367,7 @@ class ConsolePortBulkEditForm( nullable_fields = ('label', 'description') -class ConsolePortCSVForm(CSVModelForm): +class ConsolePortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -2396,7 +2398,7 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): +class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -2443,7 +2445,7 @@ class ConsoleServerPortBulkEditForm( nullable_fields = ('label', 'description') -class ConsoleServerPortCSVForm(CSVModelForm): +class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -2474,7 +2476,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class PowerPortForm(BootstrapMixin, forms.ModelForm): +class PowerPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -2533,7 +2535,7 @@ class PowerPortBulkEditForm( nullable_fields = ('label', 'description') -class PowerPortCSVForm(CSVModelForm): +class PowerPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -2564,7 +2566,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class PowerOutletForm(BootstrapMixin, forms.ModelForm): +class PowerOutletForm(BootstrapMixin, CustomFieldModelForm): power_port = forms.ModelChoiceField( queryset=PowerPort.objects.all(), required=False @@ -2658,7 +2660,7 @@ class PowerOutletBulkEditForm( self.fields['power_port'].widget.attrs['disabled'] = True -class PowerOutletCSVForm(CSVModelForm): +class PowerOutletCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -2738,7 +2740,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): +class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, @@ -2988,7 +2990,7 @@ class InterfaceBulkEditForm( self.cleaned_data['tagged_vlans'] = [] -class InterfaceCSVForm(CSVModelForm): +class InterfaceCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3058,7 +3060,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class FrontPortForm(BootstrapMixin, forms.ModelForm): +class FrontPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -3168,7 +3170,7 @@ class FrontPortBulkEditForm( nullable_fields = ('label', 'description') -class FrontPortCSVForm(CSVModelForm): +class FrontPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3227,7 +3229,7 @@ class RearPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class RearPortForm(BootstrapMixin, forms.ModelForm): +class RearPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -3280,7 +3282,7 @@ class RearPortBulkEditForm( nullable_fields = ('label', 'description') -class RearPortCSVForm(CSVModelForm): +class RearPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3307,7 +3309,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class DeviceBayForm(BootstrapMixin, forms.ModelForm): +class DeviceBayForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -3367,7 +3369,7 @@ class DeviceBayBulkEditForm( nullable_fields = ('label', 'description') -class DeviceBayCSVForm(CSVModelForm): +class DeviceBayCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3417,7 +3419,7 @@ class DeviceBayCSVForm(CSVModelForm): # Inventory items # -class InventoryItemForm(BootstrapMixin, forms.ModelForm): +class InventoryItemForm(BootstrapMixin, CustomFieldModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all(), display_field='display_name' @@ -3477,7 +3479,7 @@ class InventoryItemCreateForm(ComponentCreateForm): ) -class InventoryItemCSVForm(CSVModelForm): +class InventoryItemCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' diff --git a/netbox/dcim/migrations/0123_standardize_models.py b/netbox/dcim/migrations/0123_standardize_models.py index 4a6fc87b610..ca853455907 100644 --- a/netbox/dcim/migrations/0123_standardize_models.py +++ b/netbox/dcim/migrations/0123_standardize_models.py @@ -9,11 +9,41 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='consoleport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleserverport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='devicebay', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), migrations.AddField( model_name='devicerole', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='frontport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='interface', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='inventoryitem', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), migrations.AddField( model_name='manufacturer', name='custom_field_data', @@ -24,6 +54,16 @@ class Migration(migrations.Migration): name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='poweroutlet', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='powerport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), migrations.AddField( model_name='rackgroup', name='custom_field_data', @@ -34,6 +74,11 @@ class Migration(migrations.Migration): name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='rearport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), migrations.AddField( model_name='region', name='custom_field_data', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 01d2712a743..10422a59b48 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -13,7 +13,7 @@ from dcim.constants import * from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features -from netbox.models import BigIDModel +from netbox.models import BigIDModel, CustomFieldsMixin from utilities.fields import NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -38,7 +38,7 @@ __all__ = ( ) -class ComponentModel(BigIDModel): +class ComponentModel(CustomFieldsMixin, BigIDModel): """ An abstract model inherited by any model which has a parent Device. """ diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 74d0184203a..3b97d4ef0f6 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -10,6 +10,7 @@ from utilities.utils import serialize_object __all__ = ( 'BigIDModel', + 'CustomFieldsMixin', 'NestedGroupModel', 'OrganizationalModel', 'PrimaryModel', diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index 5d113c86a68..f1e020fd681 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -34,6 +34,7 @@ + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index b64b4aff2ce..b609eedb28e 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -34,6 +34,7 @@ + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index 365625469b0..13b01981200 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -30,6 +30,7 @@ + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 9dc0bc9f5b5..ba2ba9d190b 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -44,6 +44,7 @@ + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 3ae0733a1d7..8f22d2f6740 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -66,6 +66,7 @@ + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 4e0963f9da0..6eec7f43471 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -62,6 +62,7 @@ + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index e5c9f8093b4..ae09afb3125 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -42,6 +42,7 @@ + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index 7356bafb922..aff32d4940c 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -42,6 +42,7 @@ + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 3198503973b..e4cada913aa 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -38,6 +38,7 @@ + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} From 9db492eb07625e3645519355873cad3b925fd3a7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Mar 2021 13:37:53 -0500 Subject: [PATCH 017/223] Add custom field support to ComponentCreateForm --- netbox/dcim/forms.py | 29 ++++++++++++++++++----------- netbox/extras/forms.py | 31 ++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f55697f2216..031df6dbe4e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -12,8 +12,8 @@ from timezone_field import TimeZoneFormField from circuits.models import Circuit, CircuitTermination, Provider from extras.forms import ( - AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, - LocalConfigContextFilterForm, + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldModelCSVForm, CustomFieldFilterForm, + CustomFieldModelForm, LocalConfigContextFilterForm, ) from extras.models import Tag from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN @@ -22,10 +22,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, - NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, - BOOLEAN_WITH_BLANK_CHOICES, + ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK, + SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * @@ -119,7 +118,7 @@ class InterfaceCommonForm(forms.Form): }) -class ComponentForm(BootstrapMixin, forms.Form): +class ComponentForm(forms.Form): """ Subclass this form when facilitating the creation of one or more device component or component templates based on a name pattern. @@ -1073,7 +1072,7 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): # Device component templates # -class ComponentTemplateCreateForm(ComponentForm): +class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm): """ Base form for the creation of device component templates (subclassed from ComponentTemplateModel). """ @@ -2270,11 +2269,10 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt # Device components # -class ComponentCreateForm(ComponentForm): +class ComponentCreateForm(BootstrapMixin, CustomFieldForm, ComponentForm): """ Base form for the creation of device components (models subclassed from ComponentModel). """ - # TODO: Enable custom field support device = DynamicModelChoiceField( queryset=Device.objects.all(), display_field='display_name' @@ -2289,7 +2287,7 @@ class ComponentCreateForm(ComponentForm): ) -class DeviceBulkAddComponentForm(ComponentForm): +class DeviceBulkAddComponentForm(BootstrapMixin, ComponentForm): # TODO: Enable custom field support pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), @@ -2337,6 +2335,7 @@ class ConsolePortForm(BootstrapMixin, CustomFieldModelForm): class ConsolePortCreateForm(ComponentCreateForm): + model = ConsolePort type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, @@ -2415,6 +2414,7 @@ class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm): class ConsoleServerPortCreateForm(ComponentCreateForm): + model = ConsoleServerPort type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, @@ -2493,6 +2493,7 @@ class PowerPortForm(BootstrapMixin, CustomFieldModelForm): class PowerPortCreateForm(ComponentCreateForm): + model = PowerPort type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False, @@ -2596,6 +2597,7 @@ class PowerOutletForm(BootstrapMixin, CustomFieldModelForm): class PowerOutletCreateForm(ComponentCreateForm): + model = PowerOutlet type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False, @@ -2808,6 +2810,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): + model = Interface type = forms.ChoiceField( choices=InterfaceTypeChoices, widget=StaticSelect2(), @@ -3089,6 +3092,7 @@ class FrontPortForm(BootstrapMixin, CustomFieldModelForm): # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic class FrontPortCreateForm(ComponentCreateForm): + model = FrontPort type = forms.ChoiceField( choices=PortTypeChoices, widget=StaticSelect2(), @@ -3247,6 +3251,7 @@ class RearPortForm(BootstrapMixin, CustomFieldModelForm): class RearPortCreateForm(ComponentCreateForm): + model = RearPort type = forms.ChoiceField( choices=PortTypeChoices, widget=StaticSelect2(), @@ -3326,6 +3331,7 @@ class DeviceBayForm(BootstrapMixin, CustomFieldModelForm): class DeviceBayCreateForm(ComponentCreateForm): + model = DeviceBay field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') @@ -3449,6 +3455,7 @@ class InventoryItemForm(BootstrapMixin, CustomFieldModelForm): class InventoryItemCreateForm(ComponentCreateForm): + model = InventoryItem manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index fbafd893be5..4bb6bbfb39a 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -7,8 +7,8 @@ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - ContentTypeSelect, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, - StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, + CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * @@ -19,8 +19,33 @@ from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, T # Custom fields # -class CustomFieldModelForm(forms.ModelForm): +class CustomFieldForm(forms.Form): + """ + Extend Form to include custom field support. + """ + model = None + def __init__(self, *args, **kwargs): + if self.model is None: + raise NotImplementedError("CustomFieldForm must specify a model class.") + self.custom_fields = [] + + super().__init__(*args, **kwargs) + + # Append relevant custom fields to the form instance + obj_type = ContentType.objects.get_for_model(self.model) + for cf in CustomField.objects.filter(content_types=obj_type): + field_name = 'cf_{}'.format(cf.name) + self.fields[field_name] = cf.to_form_field() + + # Annotate the field in the list of CustomField form fields + self.custom_fields.append(field_name) + + +class CustomFieldModelForm(forms.ModelForm): + """ + Extend ModelForm to include custom field support. + """ def __init__(self, *args, **kwargs): self.obj_type = ContentType.objects.get_for_model(self._meta.model) From 9d526b090797bd789a25790b06cbe04246f841ac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Mar 2021 13:42:31 -0500 Subject: [PATCH 018/223] Add custom field support to component creation forms --- netbox/dcim/forms.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 031df6dbe4e..d2b90896900 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2287,7 +2287,7 @@ class ComponentCreateForm(BootstrapMixin, CustomFieldForm, ComponentForm): ) -class DeviceBulkAddComponentForm(BootstrapMixin, ComponentForm): +class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldForm, ComponentForm): # TODO: Enable custom field support pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), @@ -2348,6 +2348,7 @@ class ConsolePortBulkCreateForm( form_from_model(ConsolePort, ['type']), DeviceBulkAddComponentForm ): + model = ConsolePort field_order = ('name_pattern', 'label_pattern', 'type', 'description', 'tags') @@ -2427,6 +2428,7 @@ class ConsoleServerPortBulkCreateForm( form_from_model(ConsoleServerPort, ['type']), DeviceBulkAddComponentForm ): + model = ConsoleServerPort field_order = ('name_pattern', 'label_pattern', 'type', 'description', 'tags') @@ -2518,6 +2520,7 @@ class PowerPortBulkCreateForm( form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw']), DeviceBulkAddComponentForm ): + model = PowerPort field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') @@ -2627,6 +2630,7 @@ class PowerOutletBulkCreateForm( form_from_model(PowerOutlet, ['type', 'feed_leg']), DeviceBulkAddComponentForm ): + model = PowerOutlet field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags') @@ -2889,6 +2893,7 @@ class InterfaceBulkCreateForm( form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only']), DeviceBulkAddComponentForm ): + model = Interface field_order = ('name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags') @@ -3269,6 +3274,7 @@ class RearPortBulkCreateForm( form_from_model(RearPort, ['type', 'positions']), DeviceBulkAddComponentForm ): + model = RearPort field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'description', 'tags') @@ -3357,6 +3363,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): + model = DeviceBay field_order = ('name_pattern', 'label_pattern', 'description', 'tags') @@ -3506,6 +3513,7 @@ class InventoryItemBulkCreateForm( form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), DeviceBulkAddComponentForm ): + model = InventoryItem field_order = ( 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'tags', From 541852025284886fb43ab7f88405a95889596e5f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Mar 2021 13:57:57 -0500 Subject: [PATCH 019/223] Add custom field support to component bulk edit forms --- netbox/dcim/forms.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d2b90896900..0d8f8eabc28 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2288,7 +2288,6 @@ class ComponentCreateForm(BootstrapMixin, CustomFieldForm, ComponentForm): class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldForm, ComponentForm): - # TODO: Enable custom field support pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() @@ -2356,7 +2355,7 @@ class ConsolePortBulkEditForm( form_from_model(ConsolePort, ['label', 'type', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=ConsolePort.objects.all(), @@ -2364,7 +2363,7 @@ class ConsolePortBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] class ConsolePortCSVForm(CustomFieldModelCSVForm): @@ -2436,7 +2435,7 @@ class ConsoleServerPortBulkEditForm( form_from_model(ConsoleServerPort, ['label', 'type', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPort.objects.all(), @@ -2444,7 +2443,7 @@ class ConsoleServerPortBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): @@ -2528,7 +2527,7 @@ class PowerPortBulkEditForm( form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=PowerPort.objects.all(), @@ -2536,7 +2535,7 @@ class PowerPortBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] class PowerPortCSVForm(CustomFieldModelCSVForm): @@ -2638,7 +2637,7 @@ class PowerOutletBulkEditForm( form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=PowerOutlet.objects.all(), @@ -2652,7 +2651,7 @@ class PowerOutletBulkEditForm( ) class Meta: - nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') + nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2903,7 +2902,7 @@ class InterfaceBulkEditForm( ]), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=Interface.objects.all(), @@ -2944,9 +2943,9 @@ class InterfaceBulkEditForm( ) class Meta: - nullable_fields = ( + nullable_fields = [ 'label', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' - ) + ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -3168,7 +3167,7 @@ class FrontPortBulkEditForm( form_from_model(FrontPort, ['label', 'type', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=FrontPort.objects.all(), @@ -3176,7 +3175,7 @@ class FrontPortBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] class FrontPortCSVForm(CustomFieldModelCSVForm): @@ -3282,7 +3281,7 @@ class RearPortBulkEditForm( form_from_model(RearPort, ['label', 'type', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=RearPort.objects.all(), @@ -3290,7 +3289,7 @@ class RearPortBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] class RearPortCSVForm(CustomFieldModelCSVForm): @@ -3371,7 +3370,7 @@ class DeviceBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=DeviceBay.objects.all(), @@ -3379,7 +3378,7 @@ class DeviceBayBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] class DeviceBayCSVForm(CustomFieldModelCSVForm): @@ -3524,7 +3523,7 @@ class InventoryItemBulkEditForm( form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=InventoryItem.objects.all(), @@ -3536,7 +3535,7 @@ class InventoryItemBulkEditForm( ) class Meta: - nullable_fields = ('label', 'manufacturer', 'part_id', 'description') + nullable_fields = ['label', 'manufacturer', 'part_id', 'description'] class InventoryItemFilterForm(DeviceComponentFilterForm): From fdda30704b6ae19be5a101ed2f053f6ffecee908 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Mar 2021 14:12:09 -0500 Subject: [PATCH 020/223] Add custom fields section to interface edit template --- netbox/templates/dcim/interface_edit.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index a1eb2cee18c..620a3084574 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -35,6 +35,12 @@ {% render_field form.tagged_vlans %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
{% endblock %} {% block buttons %} From 4fb79e6e2a60f9401bde1651517318e3f9bcb2eb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Mar 2021 14:15:41 -0500 Subject: [PATCH 021/223] Changelog for #5401 --- docs/release-notes/version-2.11.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 1dcf4975944..856b61910c2 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -7,6 +7,7 @@ ### Enhancements * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models +* [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models ### Other Changes From 3f216fa4a3c0a1389201d81ae48b2f55941cef4d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Mar 2021 14:33:52 -0500 Subject: [PATCH 022/223] Remove unused CustomFieldModel class --- netbox/extras/models/__init__.py | 3 +- netbox/extras/models/customfields.py | 51 ---------------------------- 2 files changed, 1 insertion(+), 53 deletions(-) diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index daeed82238e..95132e43a40 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,5 +1,5 @@ from .change_logging import ObjectChange -from .customfields import CustomField, CustomFieldModel +from .customfields import CustomField from .models import ( ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script, Webhook, @@ -10,7 +10,6 @@ __all__ = ( 'ConfigContext', 'ConfigContextModel', 'CustomField', - 'CustomFieldModel', 'CustomLink', 'ExportTemplate', 'ImageAttachment', diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 05ac2a22281..ecc11e52b61 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,11 +1,9 @@ import re -from collections import OrderedDict from datetime import datetime, date from django import forms from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField -from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import RegexValidator, ValidationError from django.db import models from django.utils.safestring import mark_safe @@ -18,55 +16,6 @@ from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex -class CustomFieldModel(BigIDModel): - """ - Abstract class for any model which may have custom fields associated with it. - """ - custom_field_data = models.JSONField( - encoder=DjangoJSONEncoder, - blank=True, - default=dict - ) - - class Meta: - abstract = True - - @property - def cf(self): - """ - Convenience wrapper for custom field data. - """ - return self.custom_field_data - - def get_custom_fields(self): - """ - Return a dictionary of custom fields for a single object in the form {: value}. - """ - fields = CustomField.objects.get_for_model(self) - return OrderedDict([ - (field, self.custom_field_data.get(field.name)) for field in fields - ]) - - def clean(self): - super().clean() - - custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)} - - # Validate all field values - for field_name, value in self.custom_field_data.items(): - if field_name not in custom_fields: - raise ValidationError(f"Unknown field name '{field_name}' in custom field data.") - try: - custom_fields[field_name].validate(value) - except ValidationError as e: - raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}") - - # Check for missing required values - for cf in custom_fields.values(): - if cf.required and cf.name not in self.custom_field_data: - raise ValidationError(f"Missing required custom field '{cf.name}'.") - - class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): use_in_migrations = True From 1ddc1a6781faefbcc3c0dc1d80b9419519ce9a96 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Mar 2021 14:51:29 -0500 Subject: [PATCH 023/223] Closes #5451: Add support for multiple-selection custom fields --- docs/additional-features/custom-fields.md | 3 ++ docs/release-notes/version-2.11.md | 1 + netbox/extras/choices.py | 2 ++ netbox/extras/models/customfields.py | 32 +++++++++++++++---- netbox/templates/inc/custom_fields_panel.html | 2 ++ 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/docs/additional-features/custom-fields.md b/docs/additional-features/custom-fields.md index 91bf0337994..5c74c6744b9 100644 --- a/docs/additional-features/custom-fields.md +++ b/docs/additional-features/custom-fields.md @@ -16,6 +16,7 @@ Custom fields must be created through the admin UI under Extras > Custom Fields. * Date: A date in ISO 8601 format (YYYY-MM-DD) * URL: This will be presented as a link in the web UI * Selection: A selection of one of several pre-defined custom choices +* Multiple selection: A selection field which supports the assignment of multiple values Each custom field must have a name; this should be a simple database-friendly string, e.g. `tps_report`. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. @@ -39,6 +40,8 @@ Each custom selection field must have at least two choices. These are specified If a default value is specified for a selection field, it must exactly match one of the provided choices. +The value of a multiple selection field will always return a list, even if only one value is selected. + ## Custom Fields and the REST API When retrieving an object via the REST API, all of its custom data will be included within the `custom_fields` attribute. For example, below is the partial output of a site with two custom fields defined: diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 856b61910c2..20ca17b4e9c 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -8,6 +8,7 @@ * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models * [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models +* [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields ### Other Changes diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 45f8ac31f83..47c3a10398c 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -13,6 +13,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_DATE = 'date' TYPE_URL = 'url' TYPE_SELECT = 'select' + TYPE_MULTISELECT = 'multiselect' CHOICES = ( (TYPE_TEXT, 'Text'), @@ -21,6 +22,7 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_DATE, 'Date'), (TYPE_URL, 'URL'), (TYPE_SELECT, 'Selection'), + (TYPE_MULTISELECT, 'Multiple selection'), ) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index ecc11e52b61..8b97877a47c 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -11,7 +11,9 @@ from django.utils.safestring import mark_safe from extras.choices import * from extras.utils import FeatureQuery from netbox.models import BigIDModel -from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice +from utilities.forms import ( + CSVChoiceField, DatePicker, LaxURLField, StaticSelect2Multiple, StaticSelect2, add_blank_choice, +) from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex @@ -153,7 +155,10 @@ class CustomField(BigIDModel): }) # Choices can be set only on selection fields - if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT: + if self.choices and self.type not in ( + CustomFieldTypeChoices.TYPE_SELECT, + CustomFieldTypeChoices.TYPE_MULTISELECT + ): raise ValidationError({ 'choices': "Choices may be set only for custom selection fields." }) @@ -206,7 +211,7 @@ class CustomField(BigIDModel): field = forms.DateField(required=required, initial=initial, widget=DatePicker()) # Select - elif self.type == CustomFieldTypeChoices.TYPE_SELECT: + elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT): choices = [(c, c) for c in self.choices] default_choice = self.default if self.default in self.choices else None @@ -217,10 +222,16 @@ class CustomField(BigIDModel): if set_initial and default_choice: initial = default_choice - field_class = CSVChoiceField if for_csv_import else forms.ChoiceField - field = field_class( - choices=choices, required=required, initial=initial, widget=StaticSelect2() - ) + if self.type == CustomFieldTypeChoices.TYPE_SELECT: + field_class = CSVChoiceField if for_csv_import else forms.ChoiceField + field = field_class( + choices=choices, required=required, initial=initial, widget=StaticSelect2() + ) + else: + field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField + field = field_class( + choices=choices, required=required, initial=initial, widget=StaticSelect2Multiple() + ) # URL elif self.type == CustomFieldTypeChoices.TYPE_URL: @@ -285,5 +296,12 @@ class CustomField(BigIDModel): f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}" ) + # Validate all selected choices + if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + if not set(value).issubset(self.choices): + raise ValidationError( + f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}" + ) + elif self.required: raise ValidationError("Required field cannot be empty.") diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index d5f858f153c..bd80974ebba 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -15,6 +15,8 @@ {% elif field.type == 'url' and value %} {{ value|truncatechars:70 }} + {% elif field.type == 'multiselect' and value %} + {{ value|join:", " }} {% elif value is not None %} {{ value }} {% elif field.required %} From 07e6abdac4c6ae4aef765e33eae9579c7d291950 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Mar 2021 15:42:39 -0500 Subject: [PATCH 024/223] Closes #5901: Add 'created' and 'last_updated' fields to device component models --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/api/serializers.py | 48 +++-- .../migrations/0123_standardize_models.py | 170 ++++++++++++++++++ .../dcim/models/device_component_templates.py | 4 +- netbox/dcim/models/device_components.py | 4 +- netbox/netbox/models.py | 1 + netbox/templates/dcim/device_component.html | 1 + 7 files changed, 210 insertions(+), 19 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 20ca17b4e9c..7acc24cc56a 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -9,6 +9,7 @@ * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models * [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models * [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields +* [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models ### Other Changes diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d94d3aa49b0..363687d1eb2 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -288,7 +288,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated'] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): @@ -302,7 +302,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated'] class PowerPortTemplateSerializer(ValidatedModelSerializer): @@ -316,7 +316,10 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description'] + fields = [ + 'id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + 'created', 'last_updated', + ] class PowerOutletTemplateSerializer(ValidatedModelSerializer): @@ -338,7 +341,10 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'] + fields = [ + 'id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'created', + 'last_updated', + ] class InterfaceTemplateSerializer(ValidatedModelSerializer): @@ -348,7 +354,9 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): class Meta: model = InterfaceTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description'] + fields = [ + 'id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', 'last_updated', + ] class RearPortTemplateSerializer(ValidatedModelSerializer): @@ -358,7 +366,9 @@ class RearPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = RearPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description'] + fields = [ + 'id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description', 'created', 'last_updated', + ] class FrontPortTemplateSerializer(ValidatedModelSerializer): @@ -369,7 +379,10 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = FrontPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description'] + fields = [ + 'id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', + 'created', 'last_updated', + ] class DeviceBayTemplateSerializer(ValidatedModelSerializer): @@ -378,7 +391,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): class Meta: model = DeviceBayTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'description'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] # @@ -498,6 +511,7 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', + 'created', 'last_updated', ] @@ -516,6 +530,7 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', + 'created', 'last_updated', ] @@ -544,7 +559,7 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -563,7 +578,7 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -589,7 +604,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', - 'count_ipaddresses', + 'created', 'last_updated', 'count_ipaddresses', ] def validate(self, data): @@ -616,7 +631,7 @@ class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Cus model = RearPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', - 'cable_peer_type', 'tags', 'custom_fields', + 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -642,7 +657,7 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Cu model = FrontPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', - 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', + 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -653,7 +668,10 @@ class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = DeviceBay - fields = ['id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags', 'custom_fields'] + fields = [ + 'id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags', 'custom_fields', + 'created', 'last_updated', + ] # @@ -672,7 +690,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer model = InventoryItem fields = [ 'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'discovered', 'description', 'tags', 'custom_fields', '_depth', + 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth', ] diff --git a/netbox/dcim/migrations/0123_standardize_models.py b/netbox/dcim/migrations/0123_standardize_models.py index ca853455907..48e9e65030c 100644 --- a/netbox/dcim/migrations/0123_standardize_models.py +++ b/netbox/dcim/migrations/0123_standardize_models.py @@ -9,41 +9,151 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='consoleport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), migrations.AddField( model_name='consoleport', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='consoleport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), migrations.AddField( model_name='consoleserverport', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='consoleserverport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicebay', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), migrations.AddField( model_name='devicebay', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='devicebay', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), migrations.AddField( model_name='devicerole', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='frontport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), migrations.AddField( model_name='frontport', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='frontport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='frontporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='frontporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), migrations.AddField( model_name='interface', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='interface', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), migrations.AddField( model_name='inventoryitem', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='inventoryitem', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), migrations.AddField( model_name='manufacturer', name='custom_field_data', @@ -54,16 +164,56 @@ class Migration(migrations.Migration): name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='poweroutlet', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), migrations.AddField( model_name='poweroutlet', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='poweroutlet', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='powerport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), migrations.AddField( model_name='powerport', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='powerport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='powerporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='powerporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), migrations.AddField( model_name='rackgroup', name='custom_field_data', @@ -74,11 +224,31 @@ class Migration(migrations.Migration): name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='rearport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), migrations.AddField( model_name='rearport', name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + migrations.AddField( + model_name='rearport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rearporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rearporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), migrations.AddField( model_name='region', name='custom_field_data', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index d250227b770..51956f92445 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -6,7 +6,7 @@ from dcim.choices import * from dcim.constants import * from extras.models import ObjectChange from extras.utils import extras_features -from netbox.models import BigIDModel +from netbox.models import BigIDModel, ChangeLoggingMixin from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.ordering import naturalize_interface @@ -28,7 +28,7 @@ __all__ = ( ) -class ComponentTemplateModel(BigIDModel): +class ComponentTemplateModel(ChangeLoggingMixin, BigIDModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 10422a59b48..f78363ba9b6 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -13,7 +13,7 @@ from dcim.constants import * from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features -from netbox.models import BigIDModel, CustomFieldsMixin +from netbox.models import PrimaryModel from utilities.fields import NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -38,7 +38,7 @@ __all__ = ( ) -class ComponentModel(CustomFieldsMixin, BigIDModel): +class ComponentModel(PrimaryModel): """ An abstract model inherited by any model which has a parent Device. """ diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 3b97d4ef0f6..63965d4e646 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -10,6 +10,7 @@ from utilities.utils import serialize_object __all__ = ( 'BigIDModel', + 'ChangeLoggingMixin', 'CustomFieldsMixin', 'NestedGroupModel', 'OrganizationalModel', diff --git a/netbox/templates/dcim/device_component.html b/netbox/templates/dcim/device_component.html index c0ce453b354..ecd42d7962b 100644 --- a/netbox/templates/dcim/device_component.html +++ b/netbox/templates/dcim/device_component.html @@ -30,6 +30,7 @@ {% endif %}

{% block title %}{{ object.device }} / {{ object }}{% endblock %}

+ {% include 'inc/created_updated.html' %} {% endblock %} From 8be4fbbce3a8c78338f974ba008eb5663670f707 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Mar 2021 15:57:23 -0400 Subject: [PATCH 081/223] Add JournalEntry list view w/filtering --- netbox/extras/filters.py | 30 ++++++++++------ netbox/extras/forms.py | 34 +++++++++++++++++++ netbox/extras/migrations/0058_journalentry.py | 1 + netbox/extras/models/models.py | 1 + netbox/extras/tables.py | 25 ++++++++++++++ netbox/extras/urls.py | 1 + netbox/extras/views.py | 8 +++++ netbox/templates/extras/object_journal.html | 1 + netbox/templates/inc/nav_menu.html | 3 ++ 9 files changed, 94 insertions(+), 10 deletions(-) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 7893b050f9d..afe3bff1646 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -119,22 +119,32 @@ class ImageAttachmentFilterSet(BaseFilterSet): class JournalEntryFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + created = django_filters.DateTimeFromToRangeFilter() assigned_object_type = ContentTypeFilter() - # created_by_id = django_filters.ModelMultipleChoiceFilter( - # queryset=User.objects.all(), - # label='User (ID)', - # ) - # created_by = django_filters.ModelMultipleChoiceFilter( - # field_name='user__username', - # queryset=User.objects.all(), - # to_field_name='username', - # label='User (name)', - # ) + created_by_id = django_filters.ModelMultipleChoiceFilter( + queryset=User.objects.all(), + label='User (ID)', + ) + created_by = django_filters.ModelMultipleChoiceFilter( + field_name='created_by__username', + queryset=User.objects.all(), + to_field_name='username', + label='User (name)', + ) class Meta: model = JournalEntry fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(comments__icontains=value) + class TagFilterSet(BaseFilterSet): q = django_filters.CharFilter( diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index f6a960bd915..a126378fa8f 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -386,6 +386,40 @@ class JournalEntryForm(BootstrapMixin, forms.ModelForm): } +class JournalEntryFilterForm(BootstrapMixin, forms.Form): + model = JournalEntry + q = forms.CharField( + required=False, + label=_('Search') + ) + created_after = forms.DateTimeField( + required=False, + label=_('After'), + widget=DateTimePicker() + ) + created_before = forms.DateTimeField( + required=False, + label=_('Before'), + widget=DateTimePicker() + ) + created_by_id = DynamicModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + label=_('User'), + widget=APISelectMultiple( + api_url='/api/users/users/', + ) + ) + assigned_object_type_id = DynamicModelMultipleChoiceField( + queryset=ContentType.objects.all(), + required=False, + label=_('Object Type'), + widget=APISelectMultiple( + api_url='/api/extras/content-types/', + ) + ) + + # # Change logging # diff --git a/netbox/extras/migrations/0058_journalentry.py b/netbox/extras/migrations/0058_journalentry.py index a3a83cb788d..014b9ad0598 100644 --- a/netbox/extras/migrations/0058_journalentry.py +++ b/netbox/extras/migrations/0058_journalentry.py @@ -23,6 +23,7 @@ class Migration(migrations.Migration): ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), ], options={ + 'verbose_name_plural': 'journal entries', 'ordering': ('-created',), }, ), diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 6970265e978..1bed166f87c 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -405,6 +405,7 @@ class JournalEntry(BigIDModel): class Meta: ordering = ('-created',) + verbose_name_plural = 'journal entries' def __str__(self): return f"{self.created}" diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index ff3befc11ab..99a3a4d71f0 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -98,7 +98,32 @@ class ObjectChangeTable(BaseTable): fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') +class JournalEntryTable(BaseTable): + created = tables.DateTimeColumn( + format=settings.SHORT_DATETIME_FORMAT + ) + assigned_object_type = tables.Column( + verbose_name='Object type' + ) + assigned_object = tables.Column( + linkify=True, + orderable=False, + verbose_name='Object' + ) + actions = ButtonsColumn( + model=JournalEntry, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = JournalEntry + fields = ('created', 'created_by', 'assigned_object_type', 'assigned_object', 'comments', 'actions') + + class ObjectJournalTable(BaseTable): + """ + Used for displaying a set of JournalEntries within the context of a single object. + """ created = tables.DateTimeColumn( format=settings.SHORT_DATETIME_FORMAT ) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 1b28eea841e..6fbfd8bf119 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -32,6 +32,7 @@ urlpatterns = [ path('image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), # Journal entries + path('journal-entries/', views.JournalEntryListView.as_view(), name='journalentry_list'), path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'), path('journal-entries//edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'), path('journal-entries//delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index b41f09af359..b0caab4a677 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -286,6 +286,14 @@ class ImageAttachmentDeleteView(generic.ObjectDeleteView): # Journal entries # +class JournalEntryListView(generic.ObjectListView): + queryset = JournalEntry.objects.all() + filterset = filters.JournalEntryFilterSet + filterset_form = forms.JournalEntryFilterForm + table = tables.JournalEntryTable + action_buttons = ('export',) + + class JournalEntryEditView(generic.ObjectEditView): queryset = JournalEntry.objects.all() model_form = forms.JournalEntryForm diff --git a/netbox/templates/extras/object_journal.html b/netbox/templates/extras/object_journal.html index c643cc5b5f3..5e21b0cb2f3 100644 --- a/netbox/templates/extras/object_journal.html +++ b/netbox/templates/extras/object_journal.html @@ -27,4 +27,5 @@ {% endif %} {% include 'panel_table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} {% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 621251ffb25..4fff1614174 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -520,6 +520,9 @@ From 574a43fff77a0ef104e1ac4b3e1b2872050a3b69 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 11:57:59 -0400 Subject: [PATCH 091/223] Enable attaching circuit terminations to clouds --- netbox/circuits/api/serializers.py | 14 ++- netbox/circuits/filters.py | 4 + netbox/circuits/forms.py | 14 ++- netbox/circuits/migrations/0027_cloud.py | 10 ++ netbox/circuits/models.py | 23 +++- .../circuits/circuittermination_edit.html | 21 +++- netbox/templates/circuits/cloud.html | 6 + .../circuits/inc/circuit_termination.html | 115 ++++++++++-------- 8 files changed, 140 insertions(+), 67 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 556721c94c1..5469049db87 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -63,12 +63,13 @@ class CircuitTypeSerializer(OrganizationalModelSerializer): class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') site = NestedSiteSerializer() + cloud = NestedCloudSerializer() class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'display', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', + 'id', 'url', 'display', 'site', 'cloud', 'port_speed', 'upstream_speed', 'xconnect_id', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', ] @@ -93,13 +94,14 @@ class CircuitSerializer(PrimaryModelSerializer): class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() - site = NestedSiteSerializer() + site = NestedSiteSerializer(required=False) + cloud = NestedCloudSerializer(required=False) cable = NestedCableSerializer(read_only=True) class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', - 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied', + 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'cloud', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied', ] diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 376cc2af7b9..6a6b2c012d6 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -221,6 +221,10 @@ class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, Path to_field_name='slug', label='Site (slug)', ) + cloud_id = django_filters.ModelMultipleChoiceFilter( + queryset=Cloud.objects.all(), + label='Cloud (ID)', + ) class Meta: model = CircuitTermination diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 295a3ea6313..7285dad965c 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -423,13 +423,18 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): query_params={ 'region_id': '$region', 'group_id': '$site_group', - } + }, + required=False + ) + cloud = DynamicModelChoiceField( + queryset=Cloud.objects.all(), + required=False ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'region', 'site_group', 'site', 'mark_connected', 'port_speed', 'upstream_speed', + 'term_side', 'region', 'site_group', 'site', 'cloud', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { @@ -442,3 +447,8 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): 'port_speed': SelectSpeedWidget(), 'upstream_speed': SelectSpeedWidget(), } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['cloud'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) diff --git a/netbox/circuits/migrations/0027_cloud.py b/netbox/circuits/migrations/0027_cloud.py index 36cceb7cad2..889b5151e3e 100644 --- a/netbox/circuits/migrations/0027_cloud.py +++ b/netbox/circuits/migrations/0027_cloud.py @@ -37,4 +37,14 @@ class Migration(migrations.Migration): name='cloud', unique_together={('provider', 'name')}, ), + migrations.AddField( + model_name='circuittermination', + name='cloud', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='circuits.cloud'), + ), + migrations.AlterField( + model_name='circuittermination', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.site'), + ), ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index d2f8a5b1d50..b13dd9603c8 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -300,7 +301,16 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): site = models.ForeignKey( to='dcim.Site', on_delete=models.PROTECT, - related_name='circuit_terminations' + related_name='circuit_terminations', + blank=True, + null=True + ) + cloud = models.ForeignKey( + to=Cloud, + on_delete=models.PROTECT, + related_name='circuit_terminations', + blank=True, + null=True ) port_speed = models.PositiveIntegerField( verbose_name='Port speed (Kbps)', @@ -335,7 +345,16 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): unique_together = ['circuit', 'term_side'] def __str__(self): - return 'Side {}'.format(self.get_term_side_display()) + return f"Side {self.get_term_side_display()}" + + def clean(self): + super().clean() + + # Must define either site *or* cloud + if self.site is None and self.cloud is None: + raise ValidationError("A circuit termination must attach to either a site or a cloud.") + if self.site and self.cloud: + raise ValidationError("A circuit termination cannot attach to both a site and a cloud.") def to_objectchange(self, action): # Annotate the parent Circuit diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 4e737d16d7b..ebad75976e0 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -6,7 +6,7 @@ {% block form %}
-
Location
+
Termination
@@ -26,9 +26,22 @@

{{ form.term_side.value }}

- {% render_field form.region %} - {% render_field form.site_group %} - {% render_field form.site %} + {% with cloud_tab_active=form.initial.cloud %} + +
+
+ {% render_field form.region %} + {% render_field form.site_group %} + {% render_field form.site %} +
+
+ {% render_field form.cloud %} +
+
+ {% endwith %} {% render_field form.mark_connected %}
diff --git a/netbox/templates/circuits/cloud.html b/netbox/templates/circuits/cloud.html index 61dec5ead21..268f6438780 100644 --- a/netbox/templates/circuits/cloud.html +++ b/netbox/templates/circuits/cloud.html @@ -17,6 +17,12 @@ Cloud + + + + diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 762dd166218..acfc4ee22d7 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -26,62 +26,71 @@ {% if termination %}
Provider + {{ object.provider }} +
Name {{ object.name }}
- - - - - - - + + + + + + - + + + {% else %} + + + + + {% endif %}
Site - {% if termination.site.region %} - {{ termination.site.region }} / - {% endif %} - {{ termination.site }} -
Termination - {% if termination.mark_connected %} - - Marked as connected - {% elif termination.cable %} - {% if perms.dcim.delete_cable %} - + {% if termination.site %} +
Site + {% if termination.site.region %} + {{ termination.site.region }} / {% endif %} - {{ termination.cable }} - - - - {% with peer=termination.get_cable_peer %} - to - {% if peer.device %} - {{ peer.device }} - {% elif peer.circuit %} - {{ peer.circuit }} + {{ termination.site }} +
Termination + {% if termination.mark_connected %} + + Marked as connected + {% elif termination.cable %} + {% if perms.dcim.delete_cable %} + {% endif %} - ({{ peer }}) - {% endwith %} - {% else %} - {% if perms.dcim.add_cable %} -
- - - - -
+ {{ termination.cable }} + + + + {% with peer=termination.get_cable_peer %} + to + {% if peer.device %} + {{ peer.device }} + {% elif peer.circuit %} + {{ peer.circuit }} + {% endif %} + ({{ peer }}) + {% endwith %} + {% else %} + {% if perms.dcim.add_cable %} +
+ + + + +
+ {% endif %} + Not defined {% endif %} - Not defined - {% endif %} -
Cloud + {{ termination.cloud }} +
Speed From 872e936924bae39e6316c1b106697cd63b58fe74 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 13:54:05 -0400 Subject: [PATCH 092/223] Add termination FKs on Circuit model --- netbox/circuits/api/views.py | 3 +- netbox/circuits/migrations/0027_cloud.py | 15 ++++++++ .../0028_cache_circuit_terminations.py | 37 ++++++++++++++++++ netbox/circuits/models.py | 38 +++++++++++-------- netbox/circuits/querysets.py | 17 --------- netbox/circuits/signals.py | 16 ++++---- netbox/circuits/tables.py | 16 ++++---- netbox/circuits/views.py | 6 +-- netbox/netbox/constants.py | 2 +- 9 files changed, 96 insertions(+), 54 deletions(-) create mode 100644 netbox/circuits/migrations/0028_cache_circuit_terminations.py delete mode 100644 netbox/circuits/querysets.py diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 373b3e18dde..0adbfcb0e2d 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -48,8 +48,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.prefetch_related( - Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')), - 'type', 'tenant', 'provider', + 'type', 'tenant', 'provider', 'termination_a', 'termination_z' ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer filterset_class = filters.CircuitFilterSet diff --git a/netbox/circuits/migrations/0027_cloud.py b/netbox/circuits/migrations/0027_cloud.py index 889b5151e3e..893371f8f26 100644 --- a/netbox/circuits/migrations/0027_cloud.py +++ b/netbox/circuits/migrations/0027_cloud.py @@ -12,6 +12,7 @@ class Migration(migrations.Migration): ] operations = [ + # Create the new Cloud model migrations.CreateModel( name='Cloud', fields=[ @@ -37,6 +38,8 @@ class Migration(migrations.Migration): name='cloud', unique_together={('provider', 'name')}, ), + + # Add cloud FK to CircuitTermination migrations.AddField( model_name='circuittermination', name='cloud', @@ -47,4 +50,16 @@ class Migration(migrations.Migration): name='site', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.site'), ), + + # Add FKs to CircuitTermination on Circuit + migrations.AddField( + model_name='circuit', + name='termination_a', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'), + ), + migrations.AddField( + model_name='circuit', + name='termination_z', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'), + ), ] diff --git a/netbox/circuits/migrations/0028_cache_circuit_terminations.py b/netbox/circuits/migrations/0028_cache_circuit_terminations.py new file mode 100644 index 00000000000..49631da07b0 --- /dev/null +++ b/netbox/circuits/migrations/0028_cache_circuit_terminations.py @@ -0,0 +1,37 @@ +import sys + +from django.db import migrations + + +def cache_circuit_terminations(apps, schema_editor): + Circuit = apps.get_model('circuits', 'Circuit') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + + if 'test' not in sys.argv: + print(f"\n Caching circuit terminations...", flush=True) + + a_terminations = { + ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='A') + } + z_terminations = { + ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='Z') + } + for circuit in Circuit.objects.all(): + Circuit.objects.filter(pk=circuit.pk).update( + termination_a_id=a_terminations.get(circuit.pk), + termination_z_id=z_terminations.get(circuit.pk), + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0027_cloud'), + ] + + operations = [ + migrations.RunPython( + code=cache_circuit_terminations, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index b13dd9603c8..c2ff711268e 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -9,7 +9,6 @@ from extras.utils import extras_features from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from .choices import * -from .querysets import CircuitQuerySet __all__ = ( @@ -236,7 +235,25 @@ class Circuit(PrimaryModel): blank=True ) - objects = CircuitQuerySet.as_manager() + # Cache associated CircuitTerminations + termination_a = models.ForeignKey( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, + null=True + ) + termination_z = models.ForeignKey( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', @@ -271,20 +288,6 @@ class Circuit(PrimaryModel): def get_status_class(self): return CircuitStatusChoices.CSS_CLASSES.get(self.status) - def _get_termination(self, side): - for ct in self.terminations.all(): - if ct.term_side == side: - return ct - return None - - @property - def termination_a(self): - return self._get_termination('A') - - @property - def termination_z(self): - return self._get_termination('Z') - @extras_features('webhooks') class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): @@ -345,6 +348,9 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): unique_together = ['circuit', 'term_side'] def __str__(self): + if self.site: + return str(self.site) + return str(self.cloud) return f"Side {self.get_term_side_display()}" def clean(self): diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py deleted file mode 100644 index 8a9bd50a407..00000000000 --- a/netbox/circuits/querysets.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.db.models import OuterRef, Subquery - -from utilities.querysets import RestrictedQuerySet - - -class CircuitQuerySet(RestrictedQuerySet): - - def annotate_sites(self): - """ - Annotate the A and Z termination site names for ordering. - """ - from circuits.models import CircuitTermination - _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) - return self.annotate( - a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]), - z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]), - ) diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 86db2140021..7c9832d5b0a 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -1,17 +1,17 @@ -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone from .models import Circuit, CircuitTermination -@receiver((post_save, post_delete), sender=CircuitTermination) +@receiver(post_save, sender=CircuitTermination) def update_circuit(instance, **kwargs): """ - When a CircuitTermination has been modified, update the last_updated time of its parent Circuit. + When a CircuitTermination has been modified, update its parent Circuit. """ - circuits = Circuit.objects.filter(pk=instance.circuit_id) - time = timezone.now() - for circuit in circuits: - circuit.last_updated = time - circuit.save() + fields = { + 'last_updated': timezone.now(), + f'termination_{instance.term_side.lower()}': instance.pk, + } + Circuit.objects.filter(pk=instance.circuit_id).update(**fields) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 94894368ea3..00b4613a74b 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -83,11 +83,11 @@ class CircuitTable(BaseTable): ) status = ChoiceFieldColumn() tenant = TenantColumn() - a_side = tables.Column( - verbose_name='A Side' + termination_a = tables.Column( + verbose_name='Side A' ) - z_side = tables.Column( - verbose_name='Z Side' + termination_z = tables.Column( + verbose_name='Side Z' ) tags = TagColumn( url_name='circuits:circuit_list' @@ -96,7 +96,9 @@ class CircuitTable(BaseTable): class Meta(BaseTable.Meta): model = Circuit fields = ( - 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'install_date', 'commit_rate', - 'description', 'tags', + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', + 'commit_rate', 'description', 'tags', + ) + default_columns = ( + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', ) - default_columns = ('pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'description') diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 2484a84e421..b67bff81bfb 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -33,7 +33,7 @@ class ProviderView(generic.ObjectView): provider=instance ).prefetch_related( 'type', 'tenant', 'terminations__site' - ).annotate_sites() + ) circuits_table = tables.CircuitTable(circuits) circuits_table.columns.hide('provider') @@ -172,8 +172,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'terminations' - ).annotate_sites() + 'provider', 'type', 'tenant', 'termination_a', 'termination_z' + ) filterset = filters.CircuitFilterSet filterset_form = forms.CircuitFilterForm table = tables.CircuitTable diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 2a466b4cd54..797a11965e2 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -40,7 +40,7 @@ SEARCH_TYPES = OrderedDict(( ('circuit', { 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' - ).annotate_sites(), + ), 'filterset': CircuitFilterSet, 'table': CircuitTable, 'url': 'circuits:circuit_list', From 2e97bf48c5ef46f78f19c200b933b55d330bab41 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 14:05:32 -0400 Subject: [PATCH 093/223] Include circuits list on cloud view --- netbox/circuits/views.py | 23 +++++++++++++++++++++++ netbox/templates/circuits/cloud.html | 25 ++++++++++++++++--------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index b67bff81bfb..78069bae081 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,5 +1,6 @@ from django.contrib import messages from django.db import transaction +from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render from django_tables2 import RequestConfig @@ -95,6 +96,28 @@ class CloudListView(generic.ObjectListView): class CloudView(generic.ObjectView): queryset = Cloud.objects.all() + def get_extra_context(self, request, instance): + circuits = Circuit.objects.restrict(request.user, 'view').filter( + Q(termination_a__cloud=instance.pk) | + Q(termination_z__cloud=instance.pk) + ).prefetch_related( + 'type', 'tenant', 'terminations__site' + ) + + circuits_table = tables.CircuitTable(circuits) + circuits_table.columns.hide('termination_a') + circuits_table.columns.hide('termination_z') + + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + } + RequestConfig(request, paginate).configure(circuits_table) + + return { + 'circuits_table': circuits_table, + } + class CloudEditView(generic.ObjectEditView): queryset = Cloud.objects.all() diff --git a/netbox/templates/circuits/cloud.html b/netbox/templates/circuits/cloud.html index 268f6438780..532118bf866 100644 --- a/netbox/templates/circuits/cloud.html +++ b/netbox/templates/circuits/cloud.html @@ -33,6 +33,18 @@
+
+
+ Comments +
+
+ {% if object.comments %} + {{ object.comments|render_markdown }} + {% else %} + None + {% endif %} +
+
{% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:cloud_list' %} {% plugin_left_page object %} @@ -40,18 +52,13 @@
- Comments -
-
- {% if object.comments %} - {{ object.comments|render_markdown }} - {% else %} - None - {% endif %} + Circuits
+ {% include 'inc/table.html' with table=circuits_table %}
+ {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} {% plugin_right_page object %} -
+
From d45a17247d2b6435e3c6635c67f44fc570db0aae Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 14:32:28 -0400 Subject: [PATCH 094/223] Add circuit cloud filters & tests --- netbox/circuits/filters.py | 5 +++ netbox/circuits/forms.py | 11 +++++- netbox/circuits/tests/test_filters.py | 51 ++++++++++++++++++++++----- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 6a6b2c012d6..0efd2f3314c 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -132,6 +132,11 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe to_field_name='slug', label='Provider (slug)', ) + cloud_id = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__cloud', + queryset=Cloud.objects.all(), + label='Cloud (ID)', + ) type_id = django_filters.ModelMultipleChoiceFilter( queryset=CircuitType.objects.all(), label='Circuit type (ID)', diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 7285dad965c..d818ec0f6e4 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -357,7 +357,8 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Circuit field_order = [ - 'q', 'type_id', 'provider_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', 'commit_rate', + 'q', 'type_id', 'provider_id', 'cloud_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', + 'commit_rate', ] q = forms.CharField( required=False, @@ -373,6 +374,14 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, label=_('Provider') ) + cloud_id = DynamicModelMultipleChoiceField( + queryset=Cloud.objects.all(), + required=False, + query_params={ + 'provider_id': '$provider_id' + }, + label=_('Cloud') + ) status = forms.MultipleChoiceField( choices=CircuitStatusChoices, required=False, diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index af465c42765..880139baf7f 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -186,6 +186,13 @@ class CircuitTestCase(TestCase): ) Provider.objects.bulk_create(providers) + clouds = ( + Cloud(name='Cloud 1', provider=providers[1]), + Cloud(name='Cloud 2', provider=providers[1]), + Cloud(name='Cloud 3', provider=providers[1]), + ) + Cloud.objects.bulk_create(clouds) + circuits = ( Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE), Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE), @@ -200,6 +207,9 @@ class CircuitTestCase(TestCase): CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), + CircuitTermination(circuit=circuits[3], cloud=clouds[0], term_side='A'), + CircuitTermination(circuit=circuits[4], cloud=clouds[1], term_side='A'), + CircuitTermination(circuit=circuits[5], cloud=clouds[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -226,6 +236,11 @@ class CircuitTestCase(TestCase): params = {'provider': [provider.slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_cloud(self): + clouds = Cloud.objects.all()[:2] + params = {'cloud_id': [clouds[0].pk, clouds[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): circuit_type = CircuitType.objects.first() params = {'type_id': [circuit_type.pk]} @@ -281,14 +296,14 @@ class CircuitTerminationTestCase(TestCase): def setUpTestData(cls): sites = ( - Site(name='Test Site 1', slug='test-site-1'), - Site(name='Test Site 2', slug='test-site-2'), - Site(name='Test Site 3', slug='test-site-3'), + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) circuit_types = ( - CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'), + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), ) CircuitType.objects.bulk_create(circuit_types) @@ -297,10 +312,20 @@ class CircuitTerminationTestCase(TestCase): ) Provider.objects.bulk_create(providers) + clouds = ( + Cloud(name='Cloud 1', provider=providers[0]), + Cloud(name='Cloud 2', provider=providers[0]), + Cloud(name='Cloud 3', provider=providers[0]), + ) + Cloud.objects.bulk_create(clouds) + circuits = ( - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), ) Circuit.objects.bulk_create(circuits) @@ -311,6 +336,9 @@ class CircuitTerminationTestCase(TestCase): CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'), + CircuitTermination(circuit=circuits[3], cloud=clouds[0], term_side='A'), + CircuitTermination(circuit=circuits[4], cloud=clouds[1], term_side='A'), + CircuitTermination(circuit=circuits[5], cloud=clouds[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -318,7 +346,7 @@ class CircuitTerminationTestCase(TestCase): def test_term_side(self): params = {'term_side': 'A'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_port_speed(self): params = {'port_speed': ['1000', '2000']} @@ -344,6 +372,11 @@ class CircuitTerminationTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_cloud(self): + clouds = Cloud.objects.all()[:2] + params = {'cloud_id': [clouds[0].pk, clouds[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -352,7 +385,7 @@ class CircuitTerminationTestCase(TestCase): params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) class CloudTestCase(TestCase): From 89c487de65df47e9484caed7fc11ebbea64eeb13 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 14:43:07 -0400 Subject: [PATCH 095/223] Documentation and changelog for #5986 --- docs/core-functionality/circuits.md | 1 + docs/models/circuits/circuittermination.md | 6 +++--- docs/models/circuits/cloud.md | 5 +++++ docs/release-notes/version-2.11.md | 8 ++++++++ 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 docs/models/circuits/cloud.md diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md index 43b911308b6..67388dba44f 100644 --- a/docs/core-functionality/circuits.md +++ b/docs/core-functionality/circuits.md @@ -1,6 +1,7 @@ # Circuits {!docs/models/circuits/provider.md!} +{!docs/models/circuits/cloud.md!} --- diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md index 1c0dbfe1893..c1ec09cae3b 100644 --- a/docs/models/circuits/circuittermination.md +++ b/docs/models/circuits/circuittermination.md @@ -2,9 +2,9 @@ The association of a circuit with a particular site and/or device is modeled separately as a circuit termination. A circuit may have up to two terminations, labeled A and Z. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites. -Each circuit termination is tied to a site, and may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. +Each circuit termination is attached to either a site or a cloud. Site terminations may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. -In adherence with NetBox's philosophy of closely modeling the real world, a circuit may terminate only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely. +In adherence with NetBox's philosophy of closely modeling the real world, a circuit may be connected only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely. !!! note - A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. + A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. The cloud model is ideal for representing these networks. diff --git a/docs/models/circuits/cloud.md b/docs/models/circuits/cloud.md new file mode 100644 index 00000000000..c4b3cec5e5f --- /dev/null +++ b/docs/models/circuits/cloud.md @@ -0,0 +1,5 @@ +# Clouds + +A cloud represents an abstract portion of network topology, just like in a topology diagram. For example, a cloud may be used to represent a provider's MPLS network. + +Each cloud must be assigned to a provider. A circuit may terminate to either a cloud or to a site. diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 6bfdd414bdb..c80b74296b3 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -70,6 +70,10 @@ This release introduces the new Site Group model, which can be used to organize The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor. +#### Improved Change Logging ([#5986](https://github.com/netbox-community/netbox/issues/5986)) + +A new cloud model has been introduced for representing the boundary of a network that exists outside the scope of NetBox. This is analogous to using a cloud icon on a topology drawing to represent an abstracted network. Each cloud must be assigned to a provider, and circuits can terminate to either clouds or sites. + ### Enhancements * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models @@ -108,6 +112,10 @@ The ObjectChange model (which is used to record the creation, modification, and * Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied * Renamed RackGroup to Location * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/` +* circuits.CircuitTermination + * Added the `cloud` field +* circuits.Cloud + * Added the `/api/circuits/clouds/` endpoint * dcim.Device * Added the `location` field * dcim.Interface From d45edcd216b81cce9fb37a12f9d80b71973b2358 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 14:49:06 -0400 Subject: [PATCH 096/223] Linkify circuit terminations in table --- netbox/circuits/models.py | 6 +++++- netbox/circuits/tables.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index c2ff711268e..73df7f2d491 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -351,7 +351,11 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): if self.site: return str(self.site) return str(self.cloud) - return f"Side {self.get_term_side_display()}" + + def get_absolute_url(self): + if self.site: + return self.site.get_absolute_url() + return self.cloud.get_absolute_url() def clean(self): super().clean() diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 00b4613a74b..ba113de8c9c 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -84,9 +84,11 @@ class CircuitTable(BaseTable): status = ChoiceFieldColumn() tenant = TenantColumn() termination_a = tables.Column( + linkify=True, verbose_name='Side A' ) termination_z = tables.Column( + linkify=True, verbose_name='Side Z' ) tags = TagColumn( From b6f6293b7631030b7dbc9b68ebfeb9f7c5af1de4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 15:07:22 -0400 Subject: [PATCH 097/223] Prevent the attachment of a Cable to a CircuitTermination on a Cloud --- netbox/dcim/models/cables.py | 10 ++++++++++ netbox/dcim/tests/test_models.py | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index c8166cb4440..c3ee5ae91ad 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -242,6 +242,16 @@ class Cable(PrimaryModel): ): raise ValidationError("A front port cannot be connected to it corresponding rear port") + # A CircuitTermination attached to a Cloud cannot have a Cable + if isinstance(self.termination_a, CircuitTermination) and self.termination_a.cloud is not None: + raise ValidationError({ + 'termination_a_id': "Circuit terminations attached to a cloud may not be cabled." + }) + if isinstance(self.termination_b, CircuitTermination) and self.termination_b.cloud is not None: + raise ValidationError({ + 'termination_b_id': "Circuit terminations attached to a cloud may not be cabled." + }) + # Check for an existing Cable connected to either termination object if self.termination_a.cable not in (None, self): raise ValidationError("{} already has a cable attached (#{})".format( diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 815d867583a..b4454aa8af2 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -479,10 +479,13 @@ class CableTestCase(TestCase): device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 ) self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') + cloud = Cloud.objects.create(name='Cloud 1', provider=self.provider) self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') - self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A') - self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z') + self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') + self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2') + self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A') + self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z') + self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, cloud=cloud, term_side='A') def test_cable_creation(self): """ @@ -552,6 +555,14 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() + def test_cable_cannot_terminate_to_a_cloud_circuittermination(self): + """ + Neither side of a cable can be terminated to a CircuitTermination which is attached to a Cloud + """ + cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3) + with self.assertRaises(ValidationError): + cable.clean() + def test_rearport_connections(self): """ Test various combinations of RearPort connections. From bb00f2ff466d44e369b7e459a50a9cda8af0aef5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Mar 2021 13:02:55 -0400 Subject: [PATCH 098/223] Introduce paginate_table() utility to simplify table pagination --- netbox/circuits/views.py | 17 ++---------- netbox/extras/views.py | 19 ++----------- netbox/ipam/views.py | 51 +++++----------------------------- netbox/netbox/views/generic.py | 11 ++------ netbox/utilities/tables.py | 18 ++++++++++++ 5 files changed, 34 insertions(+), 82 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 78069bae081..1c0a152b2a7 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -2,11 +2,10 @@ from django.contrib import messages from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render -from django_tables2 import RequestConfig from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.tables import paginate_table from utilities.utils import count_related from . import filters, forms, tables from .choices import CircuitTerminationSideChoices @@ -38,12 +37,7 @@ class ProviderView(generic.ObjectView): circuits_table = tables.CircuitTable(circuits) circuits_table.columns.hide('provider') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(circuits_table) + paginate_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -107,12 +101,7 @@ class CloudView(generic.ObjectView): circuits_table = tables.CircuitTable(circuits) circuits_table.columns.hide('termination_a') circuits_table.columns.hide('termination_z') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(circuits_table) + paginate_table(circuits_table, request) return { 'circuits_table': circuits_table, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 976c13760d6..9add772d7d5 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -7,12 +7,11 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.generic import View from django_rq.queues import get_connection -from django_tables2 import RequestConfig from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.tables import paginate_table from utilities.utils import copy_safe_request, count_related, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filters, forms, tables @@ -230,13 +229,7 @@ class ObjectChangeLogView(View): data=objectchanges, orderable=False ) - - # Apply the request context - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(objectchanges_table) + paginate_table(objectchanges_table, request) # Default to using "/.html" as the template, if it exists. Otherwise, # fall back to using base.html. @@ -359,13 +352,7 @@ class ObjectJournalView(View): data=journalentries, orderable=False ) - - # Apply the request context - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(journalentry_table) + paginate_table(journalentry_table, request) if request.user.has_perm('extras.add_journalentry'): form = forms.JournalEntryForm( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index d5332129c59..47eccead1aa 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,11 +1,10 @@ from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render -from django_tables2 import RequestConfig from dcim.models import Device, Interface from netbox.views import generic -from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.tables import paginate_table from utilities.utils import count_related from virtualization.models import VirtualMachine, VMInterface from . import filters, forms, tables @@ -231,12 +230,7 @@ class AggregateView(generic.ObjectView): prefix_table = tables.PrefixDetailTable(child_prefixes) if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): prefix_table.columns.show('pk') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(prefix_table) + paginate_table(prefix_table, request) # Compile permissions list for rendering the object table permissions = { @@ -388,12 +382,7 @@ class PrefixPrefixesView(generic.ObjectView): prefix_table = tables.PrefixDetailTable(child_prefixes) if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): prefix_table.columns.show('pk') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(prefix_table) + paginate_table(prefix_table, request) # Compile permissions list for rendering the object table permissions = { @@ -431,12 +420,7 @@ class PrefixIPAddressesView(generic.ObjectView): ip_table = tables.IPAddressTable(ipaddresses) if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): ip_table.columns.show('pk') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(ip_table) + paginate_table(ip_table, request) # Compile permissions list for rendering the object table permissions = { @@ -534,12 +518,6 @@ class IPAddressView(generic.ObjectView): ) related_ips_table = tables.IPAddressTable(related_ips, orderable=False) - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(related_ips_table) - return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, @@ -703,12 +681,7 @@ class VLANGroupVLANsView(generic.ObjectView): vlan_table.columns.show('pk') vlan_table.columns.hide('site') vlan_table.columns.hide('group') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request), - } - RequestConfig(request, paginate).configure(vlan_table) + paginate_table(vlan_table, request) # Compile permissions list for rendering the object table permissions = { @@ -758,12 +731,7 @@ class VLANInterfacesView(generic.ObjectView): def get_extra_context(self, request, instance): interfaces = instance.get_interfaces().prefetch_related('device') members_table = tables.VLANDevicesTable(interfaces) - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(members_table) + paginate_table(members_table, request) return { 'members_table': members_table, @@ -778,12 +746,7 @@ class VLANVMInterfacesView(generic.ObjectView): def get_extra_context(self, request, instance): interfaces = instance.get_vminterfaces().prefetch_related('virtual_machine') members_table = tables.VLANVirtualMachinesTable(interfaces) - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(members_table) + paginate_table(members_table, request) return { 'members_table': members_table, diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index e27447ad0cd..dadd97b98b0 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -14,7 +14,6 @@ from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe from django.views.generic import View -from django_tables2 import RequestConfig from django_tables2.export import TableExport from extras.models import CustomField, ExportTemplate @@ -23,8 +22,8 @@ from utilities.exceptions import AbortTransaction from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields, ) -from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.tables import paginate_table from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin @@ -195,12 +194,8 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' ) - # Apply the request context - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(table) + # Paginate the objects table + paginate_table(table, request) context = { 'content_type': content_type, diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index e395a18035d..4d5066de82c 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -5,8 +5,11 @@ from django.core.exceptions import FieldDoesNotExist from django.db.models.fields.related import RelatedField from django.urls import reverse from django.utils.safestring import mark_safe +from django_tables2 import RequestConfig from django_tables2.data import TableQuerysetData +from .paginator import EnhancedPaginator, get_paginate_count + class BaseTable(tables.Table): """ @@ -331,3 +334,18 @@ class UtilizationColumn(tables.TemplateColumn): def value(self, value): return f'{value}%' + + +# +# Pagination +# + +def paginate_table(table, request): + """ + Paginate a table given a request context. + """ + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + } + RequestConfig(request, paginate).configure(table) From b7e44a744dc87c34dbcac75c79dea7ec503325f4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Mar 2021 14:44:43 -0400 Subject: [PATCH 099/223] Add dedicated views for organizational models --- netbox/circuits/models.py | 2 +- netbox/circuits/urls.py | 1 + netbox/circuits/views.py | 17 ++++ netbox/dcim/models/devices.py | 7 +- netbox/dcim/models/racks.py | 2 +- netbox/dcim/tables/devices.py | 6 ++ netbox/dcim/urls.py | 4 + netbox/dcim/views.py | 69 ++++++++++++++ netbox/ipam/models/ip.py | 5 +- netbox/ipam/models/vlans.py | 2 +- netbox/ipam/tables.py | 8 +- netbox/ipam/urls.py | 4 +- netbox/ipam/views.py | 89 ++++++++++++------- netbox/secrets/models.py | 2 +- netbox/secrets/urls.py | 1 + netbox/secrets/views.py | 18 ++++ netbox/templates/circuits/circuittype.html | 60 +++++++++++++ netbox/templates/dcim/devicerole.html | 76 ++++++++++++++++ netbox/templates/dcim/manufacturer.html | 60 +++++++++++++ netbox/templates/dcim/platform.html | 68 ++++++++++++++ netbox/templates/dcim/rackrole.html | 66 ++++++++++++++ netbox/templates/ipam/rir.html | 70 +++++++++++++++ netbox/templates/ipam/role.html | 64 +++++++++++++ netbox/templates/ipam/vlangroup.html | 72 +++++++++++++++ netbox/templates/ipam/vlangroup_vlans.html | 24 ----- netbox/templates/secrets/secretrole.html | 60 +++++++++++++ .../virtualization/clustergroup.html | 60 +++++++++++++ .../templates/virtualization/clustertype.html | 60 +++++++++++++ netbox/virtualization/models.py | 4 +- netbox/virtualization/urls.py | 2 + netbox/virtualization/views.py | 35 ++++++++ 31 files changed, 949 insertions(+), 69 deletions(-) create mode 100644 netbox/templates/circuits/circuittype.html create mode 100644 netbox/templates/dcim/devicerole.html create mode 100644 netbox/templates/dcim/manufacturer.html create mode 100644 netbox/templates/dcim/platform.html create mode 100644 netbox/templates/dcim/rackrole.html create mode 100644 netbox/templates/ipam/rir.html create mode 100644 netbox/templates/ipam/role.html create mode 100644 netbox/templates/ipam/vlangroup.html delete mode 100644 netbox/templates/ipam/vlangroup_vlans.html create mode 100644 netbox/templates/secrets/secretrole.html create mode 100644 netbox/templates/virtualization/clustergroup.html create mode 100644 netbox/templates/virtualization/clustertype.html diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 73df7f2d491..b163834e6e1 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -175,7 +175,7 @@ class CircuitType(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug) + return reverse('circuits:circuittype', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index acc3baac581..58f52bb42b5 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -38,6 +38,7 @@ urlpatterns = [ path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), + path('circuit-types//', views.CircuitTypeView.as_view(), name='circuittype'), path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 1c0a152b2a7..6f3c5b2be82 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -147,6 +147,23 @@ class CircuitTypeListView(generic.ObjectListView): table = tables.CircuitTypeTable +class CircuitTypeView(generic.ObjectView): + queryset = CircuitType.objects.all() + + def get_extra_context(self, request, instance): + circuits = Circuit.objects.restrict(request.user, 'view').filter( + type=instance + ) + + circuits_table = tables.CircuitTable(circuits) + circuits_table.columns.hide('type') + paginate_table(circuits_table, request) + + return { + 'circuits_table': circuits_table, + } + + class CircuitTypeEditView(generic.ObjectEditView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeForm diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 7f22d932597..a5efadac56a 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -65,7 +65,7 @@ class Manufacturer(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug) + return reverse('dcim:manufacturer', args=[self.pk]) def to_csv(self): return ( @@ -375,6 +375,9 @@ class DeviceRole(OrganizationalModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('dcim:devicerole', args=[self.pk]) + def to_csv(self): return ( self.name, @@ -436,7 +439,7 @@ class Platform(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?platform={}".format(reverse('dcim:device_list'), self.slug) + return reverse('dcim:platform', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 1942e3cb05a..2869c4265db 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -67,7 +67,7 @@ class RackRole(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?role={}".format(reverse('dcim:rack_list'), self.slug) + return reverse('dcim:rackrole', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c4204dd4ac1..c6ad3ad6d83 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -50,6 +50,9 @@ __all__ = ( class DeviceRoleTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) device_count = LinkedCountColumn( viewname='dcim:device_list', url_params={'role': 'slug'}, @@ -76,6 +79,9 @@ class DeviceRoleTable(BaseTable): class PlatformTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) device_count = LinkedCountColumn( viewname='dcim:device_list', url_params={'platform': 'slug'}, diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index e7c29ae9faa..f07e1911a7b 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -57,6 +57,7 @@ urlpatterns = [ path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), + path('rack-roles//', views.RackRoleView.as_view(), name='rackrole'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), @@ -93,6 +94,7 @@ urlpatterns = [ path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), + path('manufacturers//', views.ManufacturerView.as_view(), name='manufacturer'), path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), @@ -179,6 +181,7 @@ urlpatterns = [ path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), + path('device-roles//', views.DeviceRoleView.as_view(), name='devicerole'), path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), @@ -189,6 +192,7 @@ urlpatterns = [ path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), + path('platforms//', views.PlatformView.as_view(), name='platform'), path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e624b6926aa..e1f24df25d0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,6 +20,7 @@ from secrets.models import Secret from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.tables import paginate_table from utilities.utils import csv_format, count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from virtualization.models import VirtualMachine @@ -341,6 +342,23 @@ class RackRoleListView(generic.ObjectListView): table = tables.RackRoleTable +class RackRoleView(generic.ObjectView): + queryset = RackRole.objects.all() + + def get_extra_context(self, request, instance): + racks = Rack.objects.restrict(request.user, 'view').filter( + role=instance + ) + + racks_table = tables.RackTable(racks) + racks_table.columns.hide('role') + paginate_table(racks_table, request) + + return { + 'racks_table': racks_table, + } + + class RackRoleEditView(generic.ObjectEditView): queryset = RackRole.objects.all() model_form = forms.RackRoleForm @@ -567,6 +585,23 @@ class ManufacturerListView(generic.ObjectListView): table = tables.ManufacturerTable +class ManufacturerView(generic.ObjectView): + queryset = Manufacturer.objects.all() + + def get_extra_context(self, request, instance): + devicetypes = DeviceType.objects.restrict(request.user, 'view').filter( + manufacturer=instance + ) + + devicetypes_table = tables.DeviceTypeTable(devicetypes) + devicetypes_table.columns.hide('manufacturer') + paginate_table(devicetypes_table, request) + + return { + 'devicetypes_table': devicetypes_table, + } + + class ManufacturerEditView(generic.ObjectEditView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerForm @@ -1017,6 +1052,23 @@ class DeviceRoleListView(generic.ObjectListView): table = tables.DeviceRoleTable +class DeviceRoleView(generic.ObjectView): + queryset = DeviceRole.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + device_role=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('device_role') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + } + + class DeviceRoleEditView(generic.ObjectEditView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleForm @@ -1056,6 +1108,23 @@ class PlatformListView(generic.ObjectListView): table = tables.PlatformTable +class PlatformView(generic.ObjectView): + queryset = Platform.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + platform=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('platform') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + } + + class PlatformEditView(generic.ObjectEditView): queryset = Platform.objects.all() model_form = forms.PlatformForm diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 9867f606921..b11a88d5486 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -66,7 +66,7 @@ class RIR(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug) + return reverse('ipam:rir', args=[self.pk]) def to_csv(self): return ( @@ -216,6 +216,9 @@ class Role(OrganizationalModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('ipam:role', args=[self.pk]) + def to_csv(self): return ( self.name, diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 26cb5299fc0..0c184adff35 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -70,7 +70,7 @@ class VLANGroup(OrganizationalModel): return self.name def get_absolute_url(self): - return reverse('ipam:vlangroup_vlans', args=[self.pk]) + return reverse('ipam:vlangroup', args=[self.pk]) def clean(self): super().clean() diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index e25bf5351a8..f41c7cfff17 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -224,6 +224,9 @@ class AggregateDetailTable(AggregateTable): class RoleTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) prefix_count = LinkedCountColumn( viewname='ipam:prefix_list', url_params={'role': 'slug'}, @@ -450,9 +453,8 @@ class VLANTable(BaseTable): site = tables.Column( linkify=True ) - group = tables.LinkColumn( - viewname='ipam:vlangroup_vlans', - args=[Accessor('group__pk')] + group = tables.Column( + linkify=True ) tenant = TenantColumn() status = ChoiceFieldColumn( diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 4b576d21ff8..262537b8bdb 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -37,6 +37,7 @@ urlpatterns = [ path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), path('rirs/edit/', views.RIRBulkEditView.as_view(), name='rir_bulk_edit'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), + path('rirs//', views.RIRView.as_view(), name='rir'), path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), path('rirs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), @@ -59,6 +60,7 @@ urlpatterns = [ path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), path('roles/edit/', views.RoleBulkEditView.as_view(), name='role_bulk_edit'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), + path('roles//', views.RoleView.as_view(), name='role'), path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), @@ -97,9 +99,9 @@ urlpatterns = [ path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), path('vlan-groups/edit/', views.VLANGroupBulkEditView.as_view(), name='vlangroup_bulk_edit'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), + path('vlan-groups//', views.VLANGroupView.as_view(), name='vlangroup'), path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), path('vlan-groups//delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'), - path('vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), path('vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), # VLANs diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 47eccead1aa..b0da44b6db9 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -148,6 +148,23 @@ class RIRListView(generic.ObjectListView): template_name = 'ipam/rir_list.html' +class RIRView(generic.ObjectView): + queryset = RIR.objects.all() + + def get_extra_context(self, request, instance): + aggregates = Aggregate.objects.restrict(request.user, 'view').filter( + rir=instance + ) + + aggregates_table = tables.AggregateTable(aggregates) + aggregates_table.columns.hide('rir') + paginate_table(aggregates_table, request) + + return { + 'aggregates_table': aggregates_table, + } + + class RIREditView(generic.ObjectEditView): queryset = RIR.objects.all() model_form = forms.RIRForm @@ -286,6 +303,23 @@ class RoleListView(generic.ObjectListView): table = tables.RoleTable +class RoleView(generic.ObjectView): + queryset = Role.objects.all() + + def get_extra_context(self, request, instance): + prefixes = Prefix.objects.restrict(request.user, 'view').filter( + role=instance + ) + + prefixes_table = tables.PrefixTable(prefixes) + prefixes_table.columns.hide('role') + paginate_table(prefixes_table, request) + + return { + 'prefixes_table': prefixes_table, + } + + class RoleEditView(generic.ObjectEditView): queryset = Role.objects.all() model_form = forms.RoleForm @@ -633,6 +667,29 @@ class VLANGroupListView(generic.ObjectListView): table = tables.VLANGroupTable +class VLANGroupView(generic.ObjectView): + queryset = VLANGroup.objects.all() + + def get_extra_context(self, request, instance): + vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related( + Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)) + ) + vlans_count = vlans.count() + vlans = add_available_vlans(instance, vlans) + + vlans_table = tables.VLANDetailTable(vlans) + if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): + vlans_table.columns.show('pk') + vlans_table.columns.hide('site') + vlans_table.columns.hide('group') + paginate_table(vlans_table, request) + + return { + 'vlans_count': vlans_count, + 'vlans_table': vlans_table, + } + + class VLANGroupEditView(generic.ObjectEditView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupForm @@ -666,38 +723,6 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView): table = tables.VLANGroupTable -class VLANGroupVLANsView(generic.ObjectView): - queryset = VLANGroup.objects.all() - template_name = 'ipam/vlangroup_vlans.html' - - def get_extra_context(self, request, instance): - vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related( - Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)) - ) - vlans = add_available_vlans(instance, vlans) - - vlan_table = tables.VLANDetailTable(vlans) - if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): - vlan_table.columns.show('pk') - vlan_table.columns.hide('site') - vlan_table.columns.hide('group') - paginate_table(vlan_table, request) - - # Compile permissions list for rendering the object table - permissions = { - 'add': request.user.has_perm('ipam.add_vlan'), - 'change': request.user.has_perm('ipam.change_vlan'), - 'delete': request.user.has_perm('ipam.delete_vlan'), - } - - return { - 'first_available_vlan': instance.get_next_available_vid(), - 'bulk_querystring': f'group_id={instance.pk}', - 'vlan_table': vlan_table, - 'permissions': permissions, - } - - # # VLANs # diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 93224206026..dc3a65747db 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -263,7 +263,7 @@ class SecretRole(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?role={}".format(reverse('secrets:secret_list'), self.slug) + return reverse('secrets:secretrole', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 7c72b848c31..a4707015232 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), path('secret-roles/edit/', views.SecretRoleBulkEditView.as_view(), name='secretrole_bulk_edit'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), + path('secret-roles//', views.SecretRoleView.as_view(), name='secretrole'), path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 0ae2f6231d5..57d64f06427 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -7,6 +7,7 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from netbox.views import generic +from utilities.tables import paginate_table from utilities.utils import count_related from . import filters, forms, tables from .models import SecretRole, Secret, SessionKey, UserKey @@ -33,6 +34,23 @@ class SecretRoleListView(generic.ObjectListView): table = tables.SecretRoleTable +class SecretRoleView(generic.ObjectView): + queryset = SecretRole.objects.all() + + def get_extra_context(self, request, instance): + secrets = Secret.objects.restrict(request.user, 'view').filter( + role=instance + ) + + secrets_table = tables.SecretTable(secrets) + secrets_table.columns.hide('role') + paginate_table(secrets_table, request) + + return { + 'secrets_table': secrets_table, + } + + class SecretRoleEditView(generic.ObjectEditView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleForm diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html new file mode 100644 index 00000000000..aee7bf9442b --- /dev/null +++ b/netbox/templates/circuits/circuittype.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Circuit Types
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Circuit Type +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Circuits + {{ circuits_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Circuits +
    + {% include 'inc/table.html' with table=circuits_table %} + {% if perms.circuits.add_circuit %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html new file mode 100644 index 00000000000..c6cbf49525f --- /dev/null +++ b/netbox/templates/dcim/devicerole.html @@ -0,0 +1,76 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Device Roles
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Device Role +
    + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    VM Role + {% if object.vm_role %} + + {% else %} + + {% endif %} +
    Devices + {{ devices_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html new file mode 100644 index 00000000000..b2ecacbb1ac --- /dev/null +++ b/netbox/templates/dcim/manufacturer.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Manufacturers
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Manufacturer +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Device types + {{ devicetypes_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Device Types +
    + {% include 'inc/table.html' with table=devicetypes_table %} + {% if perms.dcim.add_devicetype %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devicetypes_table.paginator page=devicetypes_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html new file mode 100644 index 00000000000..8a3ff5b6e61 --- /dev/null +++ b/netbox/templates/dcim/platform.html @@ -0,0 +1,68 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Platforms
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Platform +
    + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    NAPALM Driver{{ object.napalm_driver }}
    NAPALM Arguments
    {{ object.napalm_args }}
    Devices + {{ devices_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html new file mode 100644 index 00000000000..89306b481ea --- /dev/null +++ b/netbox/templates/dcim/rackrole.html @@ -0,0 +1,66 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Rack Roles
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Rack Role +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    Racks + {{ racks_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Racks +
    + {% include 'inc/table.html' with table=racks_table %} + {% if perms.dcim.add_rack %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=racks_table.paginator page=racks_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/ipam/rir.html b/netbox/templates/ipam/rir.html new file mode 100644 index 00000000000..332c90da703 --- /dev/null +++ b/netbox/templates/ipam/rir.html @@ -0,0 +1,70 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • RIRs
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + RIR +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Private + {% if object.is_private %} + + {% else %} + + {% endif %} +
    Aggregates + {{ aggregates_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Aggregates +
    + {% include 'inc/table.html' with table=aggregates_table %} + {% if perms.ipam.add_aggregate %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=aggregates_table.paginator page=aggregates_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html new file mode 100644 index 00000000000..307e6e21a1d --- /dev/null +++ b/netbox/templates/ipam/role.html @@ -0,0 +1,64 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Roles
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Role +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Weight{{ object.weight }}
    Prefixes + {{ prefixes_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Prefixes +
    + {% include 'inc/table.html' with table=prefixes_table %} + {% if perms.ipam.add_prefix %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=prefixes_table.paginator page=prefixes_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html new file mode 100644 index 00000000000..e265aa6083a --- /dev/null +++ b/netbox/templates/ipam/vlangroup.html @@ -0,0 +1,72 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • VLAN Groups
  • + {% if object.scope %} +
  • {{ object.scope }}
  • + {% endif %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + VLAN Group +
    + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Scope + {% if object.scope %} + {{ object.scope }} + {% else %} + + {% endif %} +
    VLANs + {{ vlans_count }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + VLANs +
    + {% include 'inc/table.html' with table=vlans_table %} + {% if perms.ipam.add_vlan %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=vlans_table.paginator page=vlans_table.page %} +
    +
    +{% endblock %} + diff --git a/netbox/templates/ipam/vlangroup_vlans.html b/netbox/templates/ipam/vlangroup_vlans.html deleted file mode 100644 index ffb75d3b803..00000000000 --- a/netbox/templates/ipam/vlangroup_vlans.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}{{ object }} - VLANs{% endblock %} - -{% block content %} -
    -
    - -
    -
    - {% include 'ipam/inc/vlangroup_header.html' %} -
    -
    - {% include 'utilities/obj_table.html' with table=vlan_table table_template='panel_table.html' heading='VLANs' bulk_edit_url='ipam:vlan_bulk_edit' bulk_delete_url='ipam:vlan_bulk_delete' %} -
    -
    -{% endblock %} - diff --git a/netbox/templates/secrets/secretrole.html b/netbox/templates/secrets/secretrole.html new file mode 100644 index 00000000000..0e284fb75f9 --- /dev/null +++ b/netbox/templates/secrets/secretrole.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Secret Roles
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Secret Role +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Secrets + {{ secrets_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Secrets +
    + {% include 'inc/table.html' with table=secrets_table %} + {% if perms.secrets.add_secret %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=secrets_table.paginator page=secrets_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html new file mode 100644 index 00000000000..9b2085189d7 --- /dev/null +++ b/netbox/templates/virtualization/clustergroup.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Cluster Groups
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Cluster Group +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Clusters + {{ clusters_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Clusters +
    + {% include 'inc/table.html' with table=clusters_table %} + {% if perms.virtualization.add_cluster %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=clusters_table.paginator page=clusters_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/clustertype.html b/netbox/templates/virtualization/clustertype.html new file mode 100644 index 00000000000..d36934a74d2 --- /dev/null +++ b/netbox/templates/virtualization/clustertype.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Cluster Types
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Cluster Type +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Clusters + {{ clusters_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Clusters +
    + {% include 'inc/table.html' with table=clusters_table %} + {% if perms.virtualization.add_cluster %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=clusters_table.paginator page=clusters_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index e26fc8fc972..a34d096624d 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -59,7 +59,7 @@ class ClusterType(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug) + return reverse('virtualization:clustertype', args=[self.pk]) def to_csv(self): return ( @@ -102,7 +102,7 @@ class ClusterGroup(OrganizationalModel): return self.name def get_absolute_url(self): - return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug) + return reverse('virtualization:clustergroup', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index a036ab0bdc5..17750e806f7 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), path('cluster-types/edit/', views.ClusterTypeBulkEditView.as_view(), name='clustertype_bulk_edit'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), + path('cluster-types//', views.ClusterTypeView.as_view(), name='clustertype'), path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), path('cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), @@ -24,6 +25,7 @@ urlpatterns = [ path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), path('cluster-groups/edit/', views.ClusterGroupBulkEditView.as_view(), name='clustergroup_bulk_edit'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), + path('cluster-groups//', views.ClusterGroupView.as_view(), name='clustergroup'), path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), path('cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index d0bb0cf76c1..a874d2a926f 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,6 +11,7 @@ from ipam.models import IPAddress, Service from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from netbox.views import generic from secrets.models import Secret +from utilities.tables import paginate_table from utilities.utils import count_related from . import filters, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -27,6 +28,23 @@ class ClusterTypeListView(generic.ObjectListView): table = tables.ClusterTypeTable +class ClusterTypeView(generic.ObjectView): + queryset = ClusterType.objects.all() + + def get_extra_context(self, request, instance): + clusters = Cluster.objects.restrict(request.user, 'view').filter( + type=instance + ) + + clusters_table = tables.ClusterTable(clusters) + clusters_table.columns.hide('type') + paginate_table(clusters_table, request) + + return { + 'clusters_table': clusters_table, + } + + class ClusterTypeEditView(generic.ObjectEditView): queryset = ClusterType.objects.all() model_form = forms.ClusterTypeForm @@ -69,6 +87,23 @@ class ClusterGroupListView(generic.ObjectListView): table = tables.ClusterGroupTable +class ClusterGroupView(generic.ObjectView): + queryset = ClusterGroup.objects.all() + + def get_extra_context(self, request, instance): + clusters = Cluster.objects.restrict(request.user, 'view').filter( + group=instance + ) + + clusters_table = tables.ClusterTable(clusters) + clusters_table.columns.hide('group') + paginate_table(clusters_table, request) + + return { + 'clusters_table': clusters_table, + } + + class ClusterGroupEditView(generic.ObjectEditView): queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupForm From 2820d26a0f1898a02220de6a64dae893f58f7471 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Mar 2021 15:07:29 -0400 Subject: [PATCH 100/223] Add dedicated views for nested group models --- netbox/dcim/models/sites.py | 6 +- netbox/dcim/tables/racks.py | 6 +- netbox/dcim/tables/sites.py | 8 ++- netbox/dcim/urls.py | 3 + netbox/dcim/views.py | 51 ++++++++++++++++ netbox/templates/dcim/location.html | 73 +++++++++++++++++++++++ netbox/templates/dcim/region.html | 73 +++++++++++++++++++++++ netbox/templates/dcim/sitegroup.html | 73 +++++++++++++++++++++++ netbox/templates/tenancy/tenantgroup.html | 73 +++++++++++++++++++++++ netbox/tenancy/models.py | 2 +- netbox/tenancy/tables.py | 4 +- netbox/tenancy/urls.py | 1 + netbox/tenancy/views.py | 20 ++++++- 13 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 netbox/templates/dcim/location.html create mode 100644 netbox/templates/dcim/region.html create mode 100644 netbox/templates/dcim/sitegroup.html create mode 100644 netbox/templates/tenancy/tenantgroup.html diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 51cb63d08dd..3f2c209c3de 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -56,7 +56,7 @@ class Region(NestedGroupModel): csv_headers = ['name', 'slug', 'parent', 'description'] def get_absolute_url(self): - return "{}?region={}".format(reverse('dcim:site_list'), self.slug) + return reverse('dcim:region', args=[self.pk]) def to_csv(self): return ( @@ -108,7 +108,7 @@ class SiteGroup(NestedGroupModel): csv_headers = ['name', 'slug', 'parent', 'description'] def get_absolute_url(self): - return "{}?group={}".format(reverse('dcim:site_list'), self.slug) + return reverse('dcim:sitegroup', args=[self.pk]) def to_csv(self): return ( @@ -324,7 +324,7 @@ class Location(NestedGroupModel): ] def get_absolute_url(self): - return "{}?location_id={}".format(reverse('dcim:rack_list'), self.pk) + return reverse('dcim:location', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index eb4b287105d..b56e3bbc4a0 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -19,12 +19,14 @@ __all__ = ( # -# Rack groups +# Locations # class LocationTable(BaseTable): pk = ToggleColumn() - name = MPTTColumn() + name = MPTTColumn( + linkify=True + ) site = tables.Column( linkify=True ) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index e2203725522..b8d06beb6e3 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -17,7 +17,9 @@ __all__ = ( class RegionTable(BaseTable): pk = ToggleColumn() - name = MPTTColumn() + name = MPTTColumn( + linkify=True + ) site_count = tables.Column( verbose_name='Sites' ) @@ -35,7 +37,9 @@ class RegionTable(BaseTable): class SiteGroupTable(BaseTable): pk = ToggleColumn() - name = MPTTColumn() + name = MPTTColumn( + linkify=True + ) site_count = tables.Column( verbose_name='Sites' ) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index f07e1911a7b..b23603c978e 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + path('regions//', views.RegionView.as_view(), name='region'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), @@ -24,6 +25,7 @@ urlpatterns = [ path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'), path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'), path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'), + path('site-groups//', views.SiteGroupView.as_view(), name='sitegroup'), path('site-groups//edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'), path('site-groups//delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'), path('site-groups//changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}), @@ -47,6 +49,7 @@ urlpatterns = [ path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'), path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'), path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'), + path('locations//', views.LocationView.as_view(), name='location'), path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'), path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'), path('locations//changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e1f24df25d0..3bd456bccd1 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -112,6 +112,23 @@ class RegionListView(generic.ObjectListView): table = tables.RegionTable +class RegionView(generic.ObjectView): + queryset = Region.objects.all() + + def get_extra_context(self, request, instance): + sites = Site.objects.restrict(request.user, 'view').filter( + region=instance + ) + + sites_table = tables.SiteTable(sites) + sites_table.columns.hide('region') + paginate_table(sites_table, request) + + return { + 'sites_table': sites_table, + } + + class RegionEditView(generic.ObjectEditView): queryset = Region.objects.all() model_form = forms.RegionForm @@ -169,6 +186,23 @@ class SiteGroupListView(generic.ObjectListView): table = tables.SiteGroupTable +class SiteGroupView(generic.ObjectView): + queryset = SiteGroup.objects.all() + + def get_extra_context(self, request, instance): + sites = Site.objects.restrict(request.user, 'view').filter( + group=instance + ) + + sites_table = tables.SiteTable(sites) + sites_table.columns.hide('group') + paginate_table(sites_table, request) + + return { + 'sites_table': sites_table, + } + + class SiteGroupEditView(generic.ObjectEditView): queryset = SiteGroup.objects.all() model_form = forms.SiteGroupForm @@ -291,6 +325,23 @@ class LocationListView(generic.ObjectListView): table = tables.LocationTable +class LocationView(generic.ObjectView): + queryset = Location.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + location=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('location') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + } + + class LocationEditView(generic.ObjectEditView): queryset = Location.objects.all() model_form = forms.LocationForm diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html new file mode 100644 index 00000000000..c12c9f533c3 --- /dev/null +++ b/netbox/templates/dcim/location.html @@ -0,0 +1,73 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Location
  • + {% for location in object.get_ancestors %} +
  • {{ location }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Location +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Devices + {{ devices_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html new file mode 100644 index 00000000000..c79336962c6 --- /dev/null +++ b/netbox/templates/dcim/region.html @@ -0,0 +1,73 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Region
  • + {% for region in object.get_ancestors %} +
  • {{ region }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Region +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Sites + {{ sites_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Sites +
    + {% include 'inc/table.html' with table=sites_table %} + {% if perms.dcim.add_site %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html new file mode 100644 index 00000000000..95cba7aebb8 --- /dev/null +++ b/netbox/templates/dcim/sitegroup.html @@ -0,0 +1,73 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Site Groups
  • + {% for sitegroup in object.get_ancestors %} +
  • {{ sitegroup }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Site Group +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Sites + {{ sites_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Sites +
    + {% include 'inc/table.html' with table=sites_table %} + {% if perms.dcim.add_site %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/tenancy/tenantgroup.html b/netbox/templates/tenancy/tenantgroup.html new file mode 100644 index 00000000000..4f65ef21a0d --- /dev/null +++ b/netbox/templates/tenancy/tenantgroup.html @@ -0,0 +1,73 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Tenant Groups
  • + {% for tenantgroup in object.get_ancestors %} +
  • {{ tenantgroup }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Tenant Group +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Sites + {{ tenants_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Tenants +
    + {% include 'inc/table.html' with table=tenants_table %} + {% if perms.tenancy.add_tenant %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=tenants_table.paginator page=tenants_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index e5ecec639f0..aa26a60e390 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -45,7 +45,7 @@ class TenantGroup(NestedGroupModel): ordering = ['name'] def get_absolute_url(self): - return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug) + return reverse('tenancy:tenantgroup', args=[self.pk]) def to_csv(self): return ( diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 5b5bc6d73f8..a826fe5a0a8 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -35,7 +35,9 @@ class TenantColumn(tables.TemplateColumn): class TenantGroupTable(BaseTable): pk = ToggleColumn() - name = MPTTColumn() + name = MPTTColumn( + linkify=True + ) tenant_count = LinkedCountColumn( viewname='tenancy:tenant_list', url_params={'group': 'slug'}, diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 1b9dcbe26f2..a3db431da55 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), path('tenant-groups/edit/', views.TenantGroupBulkEditView.as_view(), name='tenantgroup_bulk_edit'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), + path('tenant-groups//', views.TenantGroupView.as_view(), name='tenantgroup'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 11f5ead00d6..206ff6c7ab7 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,9 +1,8 @@ -from django.shortcuts import get_object_or_404, render - from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import IPAddress, Prefix, VLAN, VRF from netbox.views import generic +from utilities.tables import paginate_table from virtualization.models import VirtualMachine, Cluster from . import filters, forms, tables from .models import Tenant, TenantGroup @@ -24,6 +23,23 @@ class TenantGroupListView(generic.ObjectListView): table = tables.TenantGroupTable +class TenantGroupView(generic.ObjectView): + queryset = TenantGroup.objects.all() + + def get_extra_context(self, request, instance): + tenants = Tenant.objects.restrict(request.user, 'view').filter( + group=instance + ) + + tenants_table = tables.TenantTable(tenants) + tenants_table.columns.hide('group') + paginate_table(tenants_table, request) + + return { + 'tenants_table': tenants_table, + } + + class TenantGroupEditView(generic.ObjectEditView): queryset = TenantGroup.objects.all() model_form = forms.TenantGroupForm From 981e7017bbebc35bb1475897e5b8fdcc0935dc06 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Mar 2021 15:15:59 -0400 Subject: [PATCH 101/223] Enable get view tests for organizational objects --- netbox/extras/views.py | 10 ---------- netbox/utilities/testing/views.py | 1 + 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9add772d7d5..841510dd386 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -235,11 +235,6 @@ class ObjectChangeLogView(View): # fall back to using base.html. if self.base_template is None: self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html" - # TODO: This can be removed once an object view has been established for every model. - try: - template.loader.get_template(self.base_template) - except template.TemplateDoesNotExist: - self.base_template = 'base.html' return render(request, 'extras/object_changelog.html', { 'object': obj, @@ -368,11 +363,6 @@ class ObjectJournalView(View): # fall back to using base.html. if self.base_template is None: self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html" - # TODO: This can be removed once an object view has been established for every model. - try: - template.loader.get_template(self.base_template) - except template.TemplateDoesNotExist: - self.base_template = 'base.html' return render(request, 'extras/object_journal.html', { 'object': obj, diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index aa8db346d3d..703780f9523 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1018,6 +1018,7 @@ class ViewTestCases: maxDiff = None class OrganizationalObjectViewTestCase( + GetObjectViewTestCase, GetObjectChangelogViewTestCase, CreateObjectViewTestCase, EditObjectViewTestCase, From 36c903da04986314269942525d4238d26dce5faf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Mar 2021 15:25:18 -0400 Subject: [PATCH 102/223] Add dedicated view for tags --- netbox/extras/models/tags.py | 4 + netbox/extras/tables.py | 3 + netbox/extras/urls.py | 1 + netbox/extras/views.py | 11 +++ netbox/templates/extras/tag.html | 135 ++++++++++--------------------- 5 files changed, 63 insertions(+), 91 deletions(-) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 4c61f7f8887..6268751b298 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,4 +1,5 @@ from django.db import models +from django.urls import reverse from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase @@ -30,6 +31,9 @@ class Tag(ChangeLoggedModel, TagBase): class Meta: ordering = ['name'] + def get_absolute_url(self): + return reverse('extras:tag', args=[self.pk]) + def slugify(self, tag, i=None): # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names) slug = slugify(tag, allow_unicode=True) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 24648b952dd..cd0ecbb369e 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -38,6 +38,9 @@ OBJECTCHANGE_REQUEST_ID = """ class TagTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) color = ColorColumn() actions = ButtonsColumn(Tag) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index ee435307d2a..f38a2ecd35a 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + path('tags//', views.TagView.as_view(), name='tag'), path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 841510dd386..64a28c7ac65 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -34,6 +34,17 @@ class TagListView(generic.ObjectListView): table = tables.TagTable +class TagView(generic.ObjectView): + queryset = Tag.objects.all() + + def get_extra_context(self, request, instance): + tagged_items = TaggedItem.objects.filter(tag=instance) + + return { + 'tagged_item_count': tagged_items.count(), + } + + class TagEditView(generic.ObjectEditView): queryset = Tag.objects.all() model_form = forms.TagForm diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 2cee8f882b0..cd6d09c297f 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -1,98 +1,51 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load helpers %} +{% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% if perms.taggit.change_tag %} - - - Edit this tag - - {% endif %} - {% if perms.taggit.delete_tag %} - - - Delete this tag - - {% endif %} -
    -

    {% block title %}Tag: {{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} - +{% block breadcrumbs %} +
  • Tags
  • +
  • {{ object }}
  • {% endblock %} {% block content %} -
    -
    -
    -
    - Tag -
    - - - - - - - - - - - - - - - - - - - - -
    Name - {{ object.name }} -
    Slug - {{ object.slug }} -
    Tagged Items - {{ items_count }} -
    Color -   -
    Description - {{ object.description|placeholder }} -
    -
    -
    -
    - {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %} - {% include 'inc/paginator.html' with paginator=items_table.paginator page=items_table.page %} -
    +
    +
    +
    +
    + Tag +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    Tagged Items + {{ tagged_item_count }} +
    + {% plugin_left_page object %} +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    {% endblock %} From 1544823d73205ee7d4d276f3316ee7612a84deb4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Mar 2021 16:20:01 -0400 Subject: [PATCH 103/223] Closes #5926: Strip leading/trailing whitespace from TemplateColumns rendered for export --- netbox/utilities/tables.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 4d5066de82c..3d48d9875e1 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import FieldDoesNotExist from django.db.models.fields.related import RelatedField from django.urls import reverse +from django.utils.html import strip_tags from django.utils.safestring import mark_safe from django_tables2 import RequestConfig from django_tables2.data import TableQuerysetData @@ -11,6 +12,18 @@ from django_tables2.data import TableQuerysetData from .paginator import EnhancedPaginator, get_paginate_count +def stripped_value(self, value): + """ + Replaces TemplateColumn's value() method to both strip HTML tags and remove any leading/trailing whitespace. + """ + return strip_tags(value).strip() + + +# TODO: We're monkey-patching TemplateColumn here to strip leading/trailing whitespace. This will no longer +# be necessary if django-tables2 PR #794 is accepted. (See #5926) +tables.TemplateColumn.value = stripped_value + + class BaseTable(tables.Table): """ Default table for object lists From 0364d8cd43656988f0e0d5814a3404e820afa163 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Mar 2021 20:19:19 -0400 Subject: [PATCH 104/223] Closes #6014: Move virtual machine interfaces list to a separate view --- docs/release-notes/version-2.11.md | 1 + .../virtualization/virtualmachine.html | 84 +------------------ .../virtualization/virtualmachine/base.html | 45 ++++++++++ .../virtualmachine/interfaces.html | 54 ++++++++++++ netbox/virtualization/tests/test_views.py | 15 ++++ netbox/virtualization/urls.py | 1 + netbox/virtualization/views.py | 24 ++++++ 7 files changed, 141 insertions(+), 83 deletions(-) create mode 100644 netbox/templates/virtualization/virtualmachine/base.html create mode 100644 netbox/templates/virtualization/virtualmachine/interfaces.html diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index c80b74296b3..360fbff8a62 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -95,6 +95,7 @@ A new cloud model has been introduced for representing the boundary of a network * [#5873](https://github.com/netbox-community/netbox/issues/5873) - Use numeric IDs in all object URLs * [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields * [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead) +* [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved virtual machine interfaces list to a separate view ### REST API Changes diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 43f1fc5e724..71196b45a54 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -1,44 +1,10 @@ -{% extends 'generic/object.html' %} +{% extends 'virtualization/virtualmachine/base.html' %} {% load buttons %} {% load custom_links %} {% load static %} {% load helpers %} {% load plugins %} -{% block breadcrumbs %} - {% if object.cluster %} -
  • {{ object.cluster }}
  • - {% endif %} -
  • {{ object }}
  • -{% endblock %} - -{% block buttons %} - {% if perms.virtualization.add_vminterface %} - - Add Interfaces - - {% endif %} - {{ block.super }} -{% endblock %} - -{% block tabs %} - -{% endblock %} - {% block content %}
    @@ -236,54 +202,6 @@ {% plugin_full_width_page object %}
    -
    -
    -
    - {% csrf_token %} - -
    -
    - Interfaces -
    - {% if request.user.is_authenticated %} - - {% endif %} -
    -
    - -
    -
    - {% include 'responsive_table.html' with table=vminterface_table %} - {% if perms.virtualization.add_vminterface or perms.virtualization.delete_vminterface %} - - {% endif %} -
    -
    - {% table_config_form vminterface_table %} -
    -
    {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html new file mode 100644 index 00000000000..00212aadd93 --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine/base.html @@ -0,0 +1,45 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load custom_links %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Virtual Machines
  • +
  • {{ object.cluster }}
  • +
  • {{ object }}
  • +{% endblock %} + +{% block buttons %} + {% if perms.virtualization.add_vminterface %} + + Add Interfaces + + {% endif %} + {{ block.super }} +{% endblock %} + +{% block tabs %} + +{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine/interfaces.html b/netbox/templates/virtualization/virtualmachine/interfaces.html new file mode 100644 index 00000000000..15d07310cdb --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine/interfaces.html @@ -0,0 +1,54 @@ +{% extends 'virtualization/virtualmachine/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} +{% load static %} + +{% block content %} +
    + {% csrf_token %} +
    +
    + Interfaces +
    + {% if request.user.is_authenticated %} + + {% endif %} +
    +
    + +
    +
    + {% render_table interface_table 'inc/table.html' %} + +
    +
    + {% table_config_form interface_table %} +{% endblock %} + +{% block javascript %} + + + +{% endblock %} diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index cb00557891f..0e1dee9e4f2 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,3 +1,5 @@ +from django.test import override_settings +from django.urls import reverse from netaddr import EUI from dcim.choices import InterfaceModeChoices @@ -196,6 +198,19 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'comments': 'New comments', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_device_interfaces(self): + virtualmachine = VirtualMachine.objects.first() + vminterfaces = ( + VMInterface(virtual_machine=virtualmachine, name='Interface 1'), + VMInterface(virtual_machine=virtualmachine, name='Interface 2'), + VMInterface(virtual_machine=virtualmachine, name='Interface 3'), + ) + VMInterface.objects.bulk_create(vminterfaces) + + url = reverse('virtualization:virtualmachine_interfaces', kwargs={'pk': virtualmachine.pk}) + self.assertHttpStatus(self.client.get(url), 200) + class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = VMInterface diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 17750e806f7..457b730243d 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -51,6 +51,7 @@ urlpatterns = [ path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), path('virtual-machines//', views.VirtualMachineView.as_view(), name='virtualmachine'), + path('virtual-machines//interfaces/', views.VirtualMachineInterfacesView.as_view(), name='virtualmachine_interfaces'), path('virtual-machines//edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), path('virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), path('virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index a874d2a926f..75f2368fb68 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -322,6 +322,30 @@ class VirtualMachineView(generic.ObjectView): } +class VirtualMachineInterfacesView(generic.ObjectView): + queryset = VirtualMachine.objects.all() + template_name = 'virtualization/virtualmachine/interfaces.html' + + def get_extra_context(self, request, instance): + interfaces = instance.interfaces.restrict(request.user, 'view').prefetch_related( + Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), + 'tags', + ) + interface_table = tables.VirtualMachineVMInterfaceTable( + data=interfaces, + user=request.user, + orderable=False + ) + if request.user.has_perm('virtualization.change_vminterface') or \ + request.user.has_perm('virtualization.delete_vminterface'): + interface_table.columns.show('pk') + + return { + 'interface_table': interface_table, + 'active_tab': 'interfaces', + } + + class VirtualMachineConfigContextView(ObjectConfigContextView): queryset = VirtualMachine.objects.annotate_config_context_data() base_template = 'virtualization/virtualmachine.html' From ab612c1ca6e1913595d4007157df8a7d6f222683 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Mar 2021 20:25:55 -0400 Subject: [PATCH 105/223] Update changelog --- docs/release-notes/version-2.11.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 360fbff8a62..89e0ac64883 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -70,7 +70,7 @@ This release introduces the new Site Group model, which can be used to organize The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor. -#### Improved Change Logging ([#5986](https://github.com/netbox-community/netbox/issues/5986)) +#### Cloud Modeling for Circuits ([#5986](https://github.com/netbox-community/netbox/issues/5986)) A new cloud model has been introduced for representing the boundary of a network that exists outside the scope of NetBox. This is analogous to using a cloud icon on a topology drawing to represent an abstracted network. Each cloud must be assigned to a provider, and circuits can terminate to either clouds or sites. @@ -86,6 +86,7 @@ A new cloud model has been introduced for representing the boundary of a network * [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI * [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location * [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models +* [#5971](https://github.com/netbox-community/netbox/issues/5971) - Add dedicated views for organizational models * [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models * [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial vCPU allocations for virtual machines From 0fae7504b31106d701641b55d19ba393d6a97975 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 09:43:21 -0400 Subject: [PATCH 106/223] Add Markdown support for JournalEntry comments --- netbox/extras/tables.py | 47 +++++++++------------ netbox/project-static/css/base.css | 3 ++ netbox/templates/extras/object_journal.html | 10 +++-- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index cd0ecbb369e..b973d16fc73 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -103,32 +103,6 @@ class ObjectChangeTable(BaseTable): fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') -class JournalEntryTable(BaseTable): - pk = ToggleColumn() - created = tables.DateTimeColumn( - format=settings.SHORT_DATETIME_FORMAT - ) - assigned_object_type = ContentTypeColumn( - verbose_name='Object type' - ) - assigned_object = tables.Column( - linkify=True, - orderable=False, - verbose_name='Object' - ) - kind = ChoiceFieldColumn() - actions = ButtonsColumn( - model=JournalEntry, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = JournalEntry - fields = ( - 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions' - ) - - class ObjectJournalTable(BaseTable): """ Used for displaying a set of JournalEntries within the context of a single object. @@ -137,6 +111,9 @@ class ObjectJournalTable(BaseTable): format=settings.SHORT_DATETIME_FORMAT ) kind = ChoiceFieldColumn() + comments = tables.TemplateColumn( + template_code='{% load helpers %}{{ value|render_markdown }}' + ) actions = ButtonsColumn( model=JournalEntry, buttons=('edit', 'delete') @@ -145,3 +122,21 @@ class ObjectJournalTable(BaseTable): class Meta(BaseTable.Meta): model = JournalEntry fields = ('created', 'created_by', 'kind', 'comments', 'actions') + + +class JournalEntryTable(ObjectJournalTable): + pk = ToggleColumn() + assigned_object_type = ContentTypeColumn( + verbose_name='Object type' + ) + assigned_object = tables.Column( + linkify=True, + orderable=False, + verbose_name='Object' + ) + + class Meta(BaseTable.Meta): + model = JournalEntry + fields = ( + 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions' + ) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 8887fb6b602..2efa3978fb5 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -165,6 +165,9 @@ table.attr-table td:nth-child(1) { td.min-width { width: 1%; } +td p:last-child { + margin-bottom: 0; +} /* Paginator */ div.paginator { diff --git a/netbox/templates/extras/object_journal.html b/netbox/templates/extras/object_journal.html index 6c50ca547de..22672628481 100644 --- a/netbox/templates/extras/object_journal.html +++ b/netbox/templates/extras/object_journal.html @@ -18,10 +18,12 @@
    {% render_field form.kind %} {% render_field form.comments %} -
    -
    - - Cancel +
    +
    + + Cancel +
    +
    From e52702f6c261fb95352fb96febf11a71b0add529 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 09:44:04 -0400 Subject: [PATCH 107/223] Fix journal entry table ordering --- netbox/extras/views.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 64a28c7ac65..b4b6ef6289a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -354,10 +354,7 @@ class ObjectJournalView(View): assigned_object_type=content_type, assigned_object_id=obj.pk ) - journalentry_table = tables.ObjectJournalTable( - data=journalentries, - orderable=False - ) + journalentry_table = tables.ObjectJournalTable(journalentries) paginate_table(journalentry_table, request) if request.user.has_perm('extras.add_journalentry'): From 8a8342b1069f53ba1ec8f00307e1f36b95cc9ca4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 09:48:45 -0400 Subject: [PATCH 108/223] Strip microseconds from JournalEntry creation time --- netbox/extras/models/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 61d06d264c8..0ff68556e2e 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -413,7 +413,8 @@ class JournalEntry(BigIDModel): verbose_name_plural = 'journal entries' def __str__(self): - return f"{self.created} - {self.get_kind_display()}" + time_created = self.created.replace(microsecond=0) + return f"{time_created} - {self.get_kind_display()}" def get_kind_class(self): return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) From fe4bf627937294041a2f6c71c05592b75d40e2c0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 09:54:06 -0400 Subject: [PATCH 109/223] Truncate comments in JournalEntryTable --- netbox/extras/tables.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index b973d16fc73..362a57e955f 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -134,6 +134,9 @@ class JournalEntryTable(ObjectJournalTable): orderable=False, verbose_name='Object' ) + comments = tables.TemplateColumn( + template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' + ) class Meta(BaseTable.Meta): model = JournalEntry From 73969755002a32efa79ece527a55f52c9c874a4f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 09:59:44 -0400 Subject: [PATCH 110/223] Update documentation for journal entries --- docs/additional-features/journaling.md | 5 +++++ docs/models/extras/journalentry.md | 5 ----- mkdocs.yml | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 docs/additional-features/journaling.md delete mode 100644 docs/models/extras/journalentry.md diff --git a/docs/additional-features/journaling.md b/docs/additional-features/journaling.md new file mode 100644 index 00000000000..ce126bf27d1 --- /dev/null +++ b/docs/additional-features/journaling.md @@ -0,0 +1,5 @@ +# Journaling + +All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object. + +Each journal entry has a selectable kind (info, success, warning, or danger) and a user-populated `comments` field. Each entry automatically records the date, time, and associated user upon being created. diff --git a/docs/models/extras/journalentry.md b/docs/models/extras/journalentry.md deleted file mode 100644 index c95340a016f..00000000000 --- a/docs/models/extras/journalentry.md +++ /dev/null @@ -1,5 +0,0 @@ -# Journal Entries - -All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire. - -Each journal entry has a user-populated `commnets` field. Each entry records the date and time, associated user, and object automatically upon being created. diff --git a/mkdocs.yml b/mkdocs.yml index 233a61fdf78..3cc502c1d4d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - Custom Links: 'additional-features/custom-links.md' - Custom Scripts: 'additional-features/custom-scripts.md' - Export Templates: 'additional-features/export-templates.md' + - Journaling: 'additional-features/journaling.md' - NAPALM: 'additional-features/napalm.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Reports: 'additional-features/reports.md' From 2c9b791b8550253d5205ade2732f9965039f2d81 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 10:34:31 -0400 Subject: [PATCH 111/223] Improve Interface validation --- netbox/dcim/models/device_components.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4b162b30fde..1f2911d2d84 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -141,7 +141,9 @@ class CableTermination(models.Model): super().clean() if self.mark_connected and self.cable_id: - raise ValidationError("Cannot set mark_connected with a cable connected.") + raise ValidationError({ + "mark_connected": "Cannot mark as connected with a cable attached." + }) def get_cable_peer(self): return self._cable_peer @@ -596,12 +598,15 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): super().clean() # Virtual interfaces cannot be connected - if self.type in NONCONNECTABLE_IFACE_TYPES and ( - self.cable or getattr(self, 'circuit_termination', False) - ): + if not self.is_connectable and self.cable: raise ValidationError({ - 'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " - "Disconnect the interface or choose a suitable type." + 'type': f"{self.get_type_display()} interfaces cannot have a cable attached." + }) + + # Non-connectable interfaces cannot be marked as connected + if not self.is_connectable and self.mark_connected: + raise ValidationError({ + 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." }) # An interface's parent must belong to the same device or virtual chassis From 042f3590c82584ba167c2cc3c40f2b4f5ced7f84 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 10:35:07 -0400 Subject: [PATCH 112/223] Update interfaces documentation --- docs/core-functionality/devices.md | 2 ++ docs/models/dcim/interface.md | 12 ++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 96dcf866db3..e05d6efd392 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -8,6 +8,8 @@ ## Device Components +Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources. + {!docs/models/dcim/consoleport.md!} {!docs/models/dcim/consoleserverport.md!} {!docs/models/dcim/powerport.md!} diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 756e320af13..bd9975a72f3 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -2,11 +2,15 @@ Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). -Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. +!!! note + Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. + +### Interface Types + +Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. Virtual interfaces, such as 802.1Q-tagged subinterfaces, may be assigned to physical parent interfaces. Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. -IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) +### IP Address Assignment -!!! note - Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. +IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) From a9716af0fa750805a7fcfd995cf55e21e9e87caa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 10:53:02 -0400 Subject: [PATCH 113/223] Tweak table display for components marked as connected --- netbox/dcim/tables/devices.py | 22 ++-- netbox/dcim/tables/template_code.py | 162 ++++++++++++++++------------ 2 files changed, 110 insertions(+), 74 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c6ad3ad6d83..ef4e593437a 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -44,6 +44,14 @@ __all__ = ( ) +def get_cabletermination_row_class(record): + if record.mark_connected: + return 'success' + elif record.cable: + return record.cable.get_status_class() + return '' + + # # Device roles # @@ -287,7 +295,7 @@ class DeviceConsolePortTable(ConsolePortTable): ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } @@ -325,7 +333,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } @@ -366,7 +374,7 @@ class DevicePowerPortTable(PowerPortTable): 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } @@ -408,7 +416,7 @@ class DevicePowerOutletTable(PowerOutletTable): 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } @@ -476,7 +484,7 @@ class DeviceInterfaceTable(InterfaceTable): 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '', + 'class': get_cabletermination_row_class, 'data-name': lambda record: record.name, } @@ -524,7 +532,7 @@ class DeviceFrontPortTable(FrontPortTable): 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } @@ -564,7 +572,7 @@ class DeviceRearPortTable(RearPortTable): 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 7449bb9c8ba..4324f802fa6 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -91,16 +91,20 @@ CONSOLEPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -116,16 +120,20 @@ CONSOLESERVERPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -141,15 +149,19 @@ POWERPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -165,9 +177,13 @@ POWEROUTLET_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - + {% if not record.mark_connected %} + + + + {% else %} + + {% endif %} {% endif %} """ @@ -188,17 +204,21 @@ INTERFACE_BUTTONS = """ {% elif record.is_connectable and perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -214,19 +234,23 @@ FRONTPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -242,17 +266,21 @@ REARPORT_BUTTONS = """ {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ From 30e4504ee5d4d619b75fbfd8348cf5c1579c567e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 10:59:06 -0400 Subject: [PATCH 114/223] Enable bulk editing of device location assignment --- netbox/dcim/forms.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 0256cf885e0..e948c7417be 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2113,8 +2113,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): model = Device fields = [ 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', - 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', - 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' + 'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', + 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", @@ -2357,6 +2357,13 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Site.objects.all(), required=False ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False From 93353e94a271d511ecddf1bde7a2f6ff1d86a8ee Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 11:25:22 -0400 Subject: [PATCH 115/223] Note that 'table' is a reserved name for ExportTemplates --- docs/additional-features/export-templates.md | 5 ++++- netbox/extras/models/models.py | 10 +++++++++- netbox/utilities/templates/buttons/export.html | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md index 1e0611f0692..950d02d4aad 100644 --- a/docs/additional-features/export-templates.md +++ b/docs/additional-features/export-templates.md @@ -2,7 +2,10 @@ NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. -Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. +Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. + +!!! note + The name `table` is reserved for internal use. Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/). diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 0ff68556e2e..d54eac0dbae 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -261,7 +261,15 @@ class ExportTemplate(BigIDModel): ] def __str__(self): - return '{}: {}'.format(self.content_type, self.name) + return f"{self.content_type}: {self.name}" + + def clean(self): + super().clean() + + if self.name.lower() == 'table': + raise ValidationError({ + 'name': f'"{self.name}" is a reserved name. Please choose a different name.' + }) def render(self, queryset): """ diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html index 341dca3f0d1..e7e246739f8 100644 --- a/netbox/utilities/templates/buttons/export.html +++ b/netbox/utilities/templates/buttons/export.html @@ -5,7 +5,7 @@
    -
    +
    {% render_field form.scope_type %} {% render_field form.group %}
    -
    +
    {% render_field form.region %} {% render_field form.sitegroup %} {% render_field form.site %} From 08ef5c9a02bffcf1d9c6647aa32f7d7e6517f614 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 11:55:34 -0400 Subject: [PATCH 117/223] Update docs to indicate location assignment ability --- docs/models/dcim/device.md | 2 +- docs/models/dcim/location.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index 2210e40281a..a99078472b2 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -8,7 +8,7 @@ A device is said to be full-depth if its installation on one rack face prevents Each device must be instantiated from a pre-created device type, and its default components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.) -Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific location and/or within a site. A platform, serial number, and asset tag may optionally be assigned to each device. +Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific location and/or rack within a site. A platform, serial number, and asset tag may optionally be assigned to each device. Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed. diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md index 710d8e8fe67..16df208ac83 100644 --- a/docs/models/dcim/location.md +++ b/docs/models/dcim/location.md @@ -1,5 +1,5 @@ # Locations -Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar concept. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. +Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. The name and facility ID of each rack within a location must be unique. (Racks not assigned to the same location may have identical names and/or facility IDs.) From 3135dc42c390c3f0b7bea46d5a8e781e80329969 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 11:56:07 -0400 Subject: [PATCH 118/223] Update changelog --- docs/release-notes/version-2.11.md | 33 +++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 89e0ac64883..1e1b906aaaa 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -4,22 +4,30 @@ **WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost. -**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or -later will be required. +**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or later will be required. + +### Breaking Changes + +* All objects now use numeric IDs in their UI view URLs instead of slugs. You may need to update external references to NetBox objects. (Note that this does _not_ affect the REST API.) +* The UI now uses numeric IDs when filtering object lists. You may need to update external links to filtered object lists. (Note that the slug- and name-based filters will continue to work, however the filter selection fields within the UI will not be automatically populated.) +* The RackGroup model has been renamed to Location (see [#4971](https://github.com/netbox-community/netbox/issues/4971)). Its REST API endpoint has changed from `/api/dcim/rack-groups/` to `/api/dcim/locations/`. +* The foreign key field `group` on dcim.Rack has been renamed to `location`. +* The foreign key field `site` on ipam.VLANGroup has been replaced with the `scope` generic foreign key (see [#5284](https://github.com/netbox-community/netbox/issues/5284)). +* Custom script ObjectVars no longer support the `queryset` parameter: Use `model` instead (see [#5995](https://github.com/netbox-community/netbox/issues/5995)). ### New Features #### Journaling Support ([#151](https://github.com/netbox-community/netbox/issues/151)) -NetBox now supports journaling for all primary objects. The journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire. +NetBox now supports journaling for all primary objects. The journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after some time, journal entries persist for the life of the associated object. #### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519)) -Virtual interfaces can now be assigned to a "parent" physical interface, by setting the `parent` field on the Interface model. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 to the physical interface Gi0/0. +Virtual interfaces can now be assigned to a "parent" physical interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0. #### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451)) -In conjunction with the newly improved change logging functionality ([#5913](https://github.com/netbox-community/netbox/issues/5913)), outgoing webhooks now include a pre- and post-change representation of the modified object. These are available in the rendering context as a dictionary named `snapshots` with keys `prechange` and `postchange`. For example, here are the abridged snapshots resulting from renaming a site and changing its status: +In conjunction with the newly improved change logging functionality ([#5913](https://github.com/netbox-community/netbox/issues/5913)), outgoing webhooks now include both pre- and post-change representations of the modified object. These are available in the rendering context as a dictionary named `snapshots` with keys `prechange` and `postchange`. For example, here are the abridged snapshots resulting from renaming a site and changing its status: ```json "snapshots": { @@ -38,11 +46,11 @@ In conjunction with the newly improved change logging functionality ([#5913](htt } ``` -Note: The pre-change snapshot for an object creation will always be null, as will the post-change snapshot for an object deletion. +Note: The pre-change snapshot for a newly created will always be null, as will the post-change snapshot for a deleted object. #### Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648)) -Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where you don't necessarily know or care what is connected to the far end of a cable, but still need to designate the near end termination. +Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where we don't necessarily know or care what is connected to an attachment point, but still need to reflect the termination as being occupied. In addition to the new `mark_connected` boolean field, the REST API representation of these objects now also includes a read-only boolean field named `_occupied`. This conveniently returns true if either a cable is attached or `mark_connected` is true. @@ -52,7 +60,7 @@ Devices can now be assigned to locations (formerly known as rack groups) within #### Dynamic Object Exports ([#4999](https://github.com/netbox-community/netbox/issues/4999)) -When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the configuration of the current table. For example, if you modify the sites list to display on the site name, tenant, and status, the rendered CSV will include only these columns. +When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the current configuration of the table being viewed. For example, if you modify the sites list to display only the site name, tenant, and status, the rendered CSV will include only these columns, and they will appear in the order chosen. The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v2.12. @@ -64,7 +72,7 @@ For example, a VLAN within a group assigned to a location will be available only #### New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892)) -This release introduces the new Site Group model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user. +This release introduces the new SiteGroup model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by functional role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user. #### Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913)) @@ -72,7 +80,7 @@ The ObjectChange model (which is used to record the creation, modification, and #### Cloud Modeling for Circuits ([#5986](https://github.com/netbox-community/netbox/issues/5986)) -A new cloud model has been introduced for representing the boundary of a network that exists outside the scope of NetBox. This is analogous to using a cloud icon on a topology drawing to represent an abstracted network. Each cloud must be assigned to a provider, and circuits can terminate to either clouds or sites. +A new Cloud model has been introduced to represent the boundary of a network that exists outside the scope of NetBox. This is analogous to using a cloud icon on a topology drawing to represent an abstracted network. Each cloud must be assigned to a provider, and circuits can terminate to either clouds or sites. The use of this model will likely be extended by future releases to support overlay and virtual circuit modeling. ### Enhancements @@ -88,15 +96,16 @@ A new cloud model has been introduced for representing the boundary of a network * [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models * [#5971](https://github.com/netbox-community/netbox/issues/5971) - Add dedicated views for organizational models * [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models -* [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial vCPU allocations for virtual machines +* [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial (decimal) vCPU allocations for virtual machines ### Other Changes * [#1638](https://github.com/netbox-community/netbox/issues/1638) - Migrate all primary keys to 64-bit integers * [#5873](https://github.com/netbox-community/netbox/issues/5873) - Use numeric IDs in all object URLs +* [#5938](https://github.com/netbox-community/netbox/issues/5938) - Deprecated support for Python 3.6 * [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields * [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead) -* [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved virtual machine interfaces list to a separate view +* [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved the virtual machine interfaces list to a separate view ### REST API Changes From 8fa37d3ec8ec94226925f154f73b01f91eefe96d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 12:06:39 -0400 Subject: [PATCH 119/223] Device component lists should link to component-specific device view --- netbox/dcim/tables/devices.py | 54 +++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index ef4e593437a..ed29c72e713 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -263,6 +263,12 @@ class PathEndpointTable(CableTerminationTable): class ConsolePortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_consoleports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:consoleport_list' ) @@ -300,6 +306,12 @@ class DeviceConsolePortTable(ConsolePortTable): class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_consoleserverports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:consoleserverport_list' ) @@ -338,6 +350,12 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): class PowerPortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_powerports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:powerport_list' ) @@ -379,6 +397,12 @@ class DevicePowerPortTable(PowerPortTable): class PowerOutletTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_poweroutlets', + 'args': [Accessor('device_id')], + } + ) power_port = tables.Column( linkify=True ) @@ -436,6 +460,12 @@ class BaseInterfaceTable(BaseTable): class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_interfaces', + 'args': [Accessor('device_id')], + } + ) mgmt_only = BooleanColumn() tags = TagColumn( url_name='dcim:interface_list' @@ -490,6 +520,12 @@ class DeviceInterfaceTable(InterfaceTable): class FrontPortTable(DeviceComponentTable, CableTerminationTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_frontports', + 'args': [Accessor('device_id')], + } + ) rear_port_position = tables.Column( verbose_name='Position' ) @@ -537,6 +573,12 @@ class DeviceFrontPortTable(FrontPortTable): class RearPortTable(DeviceComponentTable, CableTerminationTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_rearports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:rearport_list' ) @@ -577,6 +619,12 @@ class DeviceRearPortTable(RearPortTable): class DeviceBayTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_devicebays', + 'args': [Accessor('device_id')], + } + ) status = tables.TemplateColumn( template_code=DEVICEBAY_STATUS ) @@ -616,6 +664,12 @@ class DeviceDeviceBayTable(DeviceBayTable): class InventoryItemTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_inventory', + 'args': [Accessor('device_id')], + } + ) manufacturer = tables.Column( linkify=True ) From b070be1c41cd4c5f0454f6b45c2140651b16c98b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 14:55:17 -0400 Subject: [PATCH 120/223] Closes #5425: Create separate tabs for VMs and devices under the cluster view --- docs/release-notes/version-2.11.md | 1 + netbox/templates/virtualization/cluster.html | 178 +++++++----------- .../virtualization/cluster/base.html | 55 ++++++ .../virtualization/cluster/devices.html | 25 +++ .../cluster/virtual_machines.html | 15 ++ .../virtualization/virtualmachine/base.html | 5 + netbox/virtualization/tests/test_views.py | 16 +- netbox/virtualization/urls.py | 2 + netbox/virtualization/views.py | 28 ++- 9 files changed, 214 insertions(+), 111 deletions(-) create mode 100644 netbox/templates/virtualization/cluster/base.html create mode 100644 netbox/templates/virtualization/cluster/devices.html create mode 100644 netbox/templates/virtualization/cluster/virtual_machines.html diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 1e1b906aaaa..6500e36e3a4 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -87,6 +87,7 @@ A new Cloud model has been introduced to represent the boundary of a network tha * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models * [#5375](https://github.com/netbox-community/netbox/issues/5375) - Add `speed` attribute to console port models * [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models +* [#5425](https://github.com/netbox-community/netbox/issues/5425) - Create separate tabs for VMs and devices under the cluster view * [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields * [#5608](https://github.com/netbox-community/netbox/issues/5608) - Add REST API endpoint for custom links * [#5610](https://github.com/netbox-community/netbox/issues/5610) - Add REST API endpoint for webhooks diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index c065d52e16a..e21e82eedb1 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -1,117 +1,81 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load custom_links %} +{% extends 'virtualization/cluster/base.html' %} {% load helpers %} {% load plugins %} -{% block breadcrumbs %} -
  • {{ object.type }}
  • - {% if object.group %} -
  • {{ object.group }}
  • - {% endif %} -
  • {{ object }}
  • -{% endblock %} - {% block content %}
    -
    -
    -
    - Cluster -
    - - - - - - - - - - - - - - - - - - - - - - - - - -
    Name{{ object.name }}
    Type{{ object.type }}
    Group - {% if object.group %} - {{ object.group }} - {% else %} - None - {% endif %} -
    Tenant - {% if object.tenant %} - {{ object.tenant }} - {% else %} - None - {% endif %} -
    Site - {% if object.site %} - {{ object.site }} - {% else %} - None - {% endif %} -
    Virtual Machines{{ object.virtual_machines.count }}
    -
    - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %} -
    -
    - Comments -
    -
    - {% if object.comments %} - {{ object.comments|render_markdown }} - {% else %} - None - {% endif %} -
    -
    - {% plugin_left_page object %} +
    +
    +
    + Cluster +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Type{{ object.type }}
    Group + {% if object.group %} + {{ object.group }} + {% else %} + None + {% endif %} +
    Tenant + {% if object.tenant %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
    Site + {% if object.site %} + {{ object.site }} + {% else %} + None + {% endif %} +
    Virtual Machines{{ object.virtual_machines.count }}
    -
    -
    -
    - Host Devices -
    - {% if perms.virtualization.change_cluster %} -
    - {% csrf_token %} - {% endif %} - {% include 'responsive_table.html' with table=device_table %} - {% if perms.virtualization.change_cluster %} - -
    - {% endif %} -
    - {% plugin_right_page object %} -
    + {% include 'inc/custom_fields_panel.html' %} +
    +
    + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %} +
    +
    + Comments +
    +
    + {% if object.comments %} + {{ object.comments|render_markdown }} + {% else %} + None + {% endif %} +
    +
    + {% plugin_left_page object %} +
    -
    - {% plugin_full_width_page object %} -
    +
    + {% plugin_full_width_page object %} +
    {% endblock %} diff --git a/netbox/templates/virtualization/cluster/base.html b/netbox/templates/virtualization/cluster/base.html new file mode 100644 index 00000000000..3e90eb17d93 --- /dev/null +++ b/netbox/templates/virtualization/cluster/base.html @@ -0,0 +1,55 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load custom_links %} +{% load plugins %} + +{% block breadcrumbs %} +
  • {{ object.type }}
  • + {% if object.group %} +
  • {{ object.group }}
  • + {% endif %} +
  • {{ object }}
  • +{% endblock %} + +{% block buttons %} + {% if perms.virtualization.change_cluster and perms.virtualization.add_virtualmachine %} + + Add Virtual Machine + + {% endif %} + {% if perms.virtualization.change_cluster %} + + Assign Device + + {% endif %} + {{ block.super }} +{% endblock %} + +{% block tabs %} + +{% endblock %} diff --git a/netbox/templates/virtualization/cluster/devices.html b/netbox/templates/virtualization/cluster/devices.html new file mode 100644 index 00000000000..f0de81e3921 --- /dev/null +++ b/netbox/templates/virtualization/cluster/devices.html @@ -0,0 +1,25 @@ +{% extends 'virtualization/cluster/base.html' %} +{% load helpers %} + +{% block content %} +
    +
    +
    +
    + Host Devices +
    +
    + {% csrf_token %} + {% include 'responsive_table.html' with table=devices_table %} + {% if perms.virtualization.change_cluster %} + + {% endif %} +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/cluster/virtual_machines.html b/netbox/templates/virtualization/cluster/virtual_machines.html new file mode 100644 index 00000000000..5c8902bc751 --- /dev/null +++ b/netbox/templates/virtualization/cluster/virtual_machines.html @@ -0,0 +1,15 @@ +{% extends 'virtualization/cluster/base.html' %} +{% load helpers %} + +{% block content %} +
    +
    +
    +
    + Virtual Machines +
    + {% include 'responsive_table.html' with table=virtualmachines_table %} +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html index 00212aadd93..88f7da1decc 100644 --- a/netbox/templates/virtualization/virtualmachine/base.html +++ b/netbox/templates/virtualization/virtualmachine/base.html @@ -36,6 +36,11 @@ Config Context {% endif %} + {% if perms.extras.view_journalentry %} + + {% endif %} {% if perms.extras.view_objectchange %}
  • Circuits
  • -
  • {{ object.provider }}
  • +
  • {{ object.provider }}
  • {{ object.cid }}
  • {% endblock %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index bb467e6221b..85cd4352c4f 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -45,7 +45,7 @@ Circuits - {{ circuits_table.rows|length }} + {{ circuits_table.rows|length }} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 81d8bd9b3d5..6c5d8588ef5 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -145,7 +145,7 @@ Role - {{ object.device_role }} + {{ object.device_role }} diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index b9409472b0a..27cc3e99266 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -7,7 +7,7 @@ {% block breadcrumbs %}
  • Devices
  • -
  • {{ object.site }}
  • +
  • {{ object.site }}
  • {% if object.parent_bay %}
  • {{ object.parent_bay.device }}
  • {{ object.parent_bay }}
  • diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index dd7734b6b56..95dd8909e43 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -7,7 +7,7 @@ {% block breadcrumbs %}
  • Device Types
  • -
  • {{ object.manufacturer }}
  • +
  • {{ object.manufacturer }}
  • {{ object.model }}
  • {% endblock %} @@ -66,7 +66,7 @@ - + diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index 8a3ff5b6e61..f620bc4c900 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -23,9 +23,19 @@ + + + + - + diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 8812157ef60..3bb0084a31b 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -8,7 +8,7 @@ {% block breadcrumbs %}
  • Racks
  • -
  • {{ object.site }}
  • +
  • {{ object.site }}
  • {% if object.group %} {% for group in object.group.get_ancestors %}
  • {{ group }}
  • diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 2affb794613..06e14e7db65 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -42,7 +42,7 @@ diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 21678fa7953..6887c01ca05 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -7,7 +7,7 @@ {% block breadcrumbs %}
  • Virtual Chassis
  • {% if object.master %} -
  • {{ object.master.site }}
  • +
  • {{ object.master.site }}
  • {% endif %}
  • {{ object }}
  • {% endblock %} diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 69403f9b6df..468531b553c 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -6,7 +6,7 @@ {% block breadcrumbs %}
  • Aggregates
  • -
  • {{ object.rir }}
  • +
  • {{ object.rir }}
  • {{ object }}
  • {% endblock %} @@ -30,7 +30,7 @@ diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 51152a99ba1..ec3745bbf67 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -128,7 +128,7 @@ - + diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 2dd7ac7b0e4..684b1d8b194 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -5,7 +5,7 @@ {% block breadcrumbs %}
  • Tenants
  • {% if object.group %} -
  • {{ object.group }}
  • +
  • {{ object.group }}
  • {% endif %}
  • {{ object }}
  • {% endblock %} @@ -57,47 +57,47 @@ diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 71196b45a54..03512d02f55 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -27,7 +27,7 @@
    Manufacturer{{ object.manufacturer }}{{ object.manufacturer }}
    Model NameDescription {{ object.description|placeholder }}
    Manufacturer + {% if object.manufacturer %} + {{ object.manufacturer }} + {% else %} + None + {% endif %} +
    NAPALM Driver{{ object.napalm_driver }}{{ object.napalm_driver|placeholder }}
    NAPALM ArgumentsGroup {% if rack.group %} - {{ rack.group }} + {{ rack.group }} {% else %} None {% endif %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 2d0d7b1a43f..b68fc1f0255 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -180,27 +180,27 @@ @@ -227,7 +227,7 @@ All racks {{ stats.rack_count }} - +
    RIR - {{ object.rir }} + {{ object.rir }}
    Role {% if object.role %} - {{ object.role }} + {{ object.role }} {% else %} None {% endif %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index f4db12f2586..91dd9f1d8c0 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -7,10 +7,10 @@ {% block breadcrumbs %}
  • VLANs
  • {% if object.site %} -
  • {{ object.site }}
  • +
  • {{ object.site }}
  • {% endif %} {% if object.group %} -
  • {{ object.group }}
  • +
  • {{ object.group }}
  • {% endif %}
  • {{ object }}
  • {% endblock %} @@ -96,7 +96,7 @@
    Role {% if object.role %} - {{ object.role }} + {{ object.role }} {% else %} None {% endif %} diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 3c0d1977d4b..7b03bfc8096 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -6,7 +6,7 @@ {% block breadcrumbs %}
  • Secrets
  • -
  • {{ object.role }}
  • +
  • {{ object.role }}
  • {{ object.assigned_object }}
  • {{ object }}
  • {% endblock %} @@ -36,7 +36,9 @@
    Role{{ object.role }} + {{ object.role }} +
    Name Role {% if object.role %} - {{ object.role }} + {{ object.role }} {% else %} None {% endif %} @@ -37,7 +37,7 @@ Platform {% if object.platform %} - {{ object.platform }} + {{ object.platform }} {% else %} None {% endif %} From 1e3c7e1a878654101e1a616b68462b5b84507436 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 16:23:26 -0400 Subject: [PATCH 124/223] Fix form errors --- netbox/dcim/forms.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e948c7417be..dca1b86c3f9 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -949,7 +949,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Rack.objects.all(), query_params={ 'site_id': '$site', - 'location_id': 'location', + 'location_id': '$location', } ) units = NumericArrayField( @@ -4766,7 +4766,6 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, query_params={ 'region_id': '$region', 'group_id': '$site_group', @@ -4790,7 +4789,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): 'region', 'site_group', 'site', 'location', 'name', 'tags', ] fieldsets = ( - ('Power Panel', ('region', 'site', 'location', 'name', 'tags')), + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), ) From 0986fd1081be100a91b21dff97c838d0bc6e6f5e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 16:27:37 -0400 Subject: [PATCH 125/223] Rearrange locations link in navigation menu --- netbox/templates/inc/nav_menu.html | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index fb77dc2b69f..fa44da4176e 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -47,6 +47,15 @@ {% endif %} Site Groups + + {% if perms.dcim.add_location %} +
    + + +
    + {% endif %} + Locations +
  • @@ -58,15 +67,6 @@ {% endif %} Racks - - {% if perms.dcim.add_location %} -
    - - -
    - {% endif %} - Locations - {% if perms.dcim.add_rackrole %}
    From eac53a779b49b80268f417a031f1a6fa18be3251 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 16:43:29 -0400 Subject: [PATCH 126/223] Migrate prefix, VLAN templates to new structure --- netbox/ipam/views.py | 8 ++-- netbox/templates/ipam/prefix.html | 47 +------------------ netbox/templates/ipam/prefix/base.html | 38 +++++++++++++++ .../templates/ipam/prefix/ip_addresses.html | 9 ++++ netbox/templates/ipam/prefix/prefixes.html | 25 ++++++++++ netbox/templates/ipam/prefix_ipaddresses.html | 11 ----- netbox/templates/ipam/prefix_prefixes.html | 11 ----- netbox/templates/ipam/vlan.html | 34 +------------- netbox/templates/ipam/vlan/base.html | 40 ++++++++++++++++ netbox/templates/ipam/vlan/interfaces.html | 9 ++++ netbox/templates/ipam/vlan/vminterfaces.html | 9 ++++ netbox/templates/ipam/vlan_interfaces.html | 9 ---- netbox/templates/ipam/vlan_vminterfaces.html | 9 ---- 13 files changed, 136 insertions(+), 123 deletions(-) create mode 100644 netbox/templates/ipam/prefix/base.html create mode 100644 netbox/templates/ipam/prefix/ip_addresses.html create mode 100644 netbox/templates/ipam/prefix/prefixes.html delete mode 100644 netbox/templates/ipam/prefix_ipaddresses.html delete mode 100644 netbox/templates/ipam/prefix_prefixes.html create mode 100644 netbox/templates/ipam/vlan/base.html create mode 100644 netbox/templates/ipam/vlan/interfaces.html create mode 100644 netbox/templates/ipam/vlan/vminterfaces.html delete mode 100644 netbox/templates/ipam/vlan_interfaces.html delete mode 100644 netbox/templates/ipam/vlan_vminterfaces.html diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index b0da44b6db9..0339aff07ec 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -401,7 +401,7 @@ class PrefixView(generic.ObjectView): class PrefixPrefixesView(generic.ObjectView): queryset = Prefix.objects.all() - template_name = 'ipam/prefix_prefixes.html' + template_name = 'ipam/prefix/prefixes.html' def get_extra_context(self, request, instance): # Child prefixes table @@ -439,7 +439,7 @@ class PrefixPrefixesView(generic.ObjectView): class PrefixIPAddressesView(generic.ObjectView): queryset = Prefix.objects.all() - template_name = 'ipam/prefix_ipaddresses.html' + template_name = 'ipam/prefix/ip_addresses.html' def get_extra_context(self, request, instance): # Find all IPAddresses belonging to this Prefix @@ -751,7 +751,7 @@ class VLANView(generic.ObjectView): class VLANInterfacesView(generic.ObjectView): queryset = VLAN.objects.all() - template_name = 'ipam/vlan_interfaces.html' + template_name = 'ipam/vlan/interfaces.html' def get_extra_context(self, request, instance): interfaces = instance.get_interfaces().prefetch_related('device') @@ -766,7 +766,7 @@ class VLANInterfacesView(generic.ObjectView): class VLANVMInterfacesView(generic.ObjectView): queryset = VLAN.objects.all() - template_name = 'ipam/vlan_vminterfaces.html' + template_name = 'ipam/vlan/vminterfaces.html' def get_extra_context(self, request, instance): interfaces = instance.get_vminterfaces().prefetch_related('virtual_machine') diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index ec3745bbf67..24fe50dc7cd 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -1,52 +1,7 @@ -{% extends 'generic/object.html' %} +{% extends 'ipam/prefix/base.html' %} {% load helpers %} {% load plugins %} -{% block breadcrumbs %} -
  • Prefixes
  • - {% if object.vrf %} -
  • {{ object.vrf }}
  • - {% endif %} -
  • {{ object }}
  • -{% endblock %} - -{% block buttons %} - {% include 'ipam/inc/toggle_available.html' %} - {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %} - - Add Child Prefix - - {% endif %} - {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %} - - - Add an IP Address - - {% endif %} - {{ block.super }} -{% endblock %} - -{% block tabs %} - -{% endblock %} - {% block content %}
    diff --git a/netbox/templates/ipam/prefix/base.html b/netbox/templates/ipam/prefix/base.html new file mode 100644 index 00000000000..f1d5cbc99da --- /dev/null +++ b/netbox/templates/ipam/prefix/base.html @@ -0,0 +1,38 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load custom_links %} + +{% block breadcrumbs %} +
  • Prefixes
  • + {% if object.vrf %} +
  • {{ object.vrf }}
  • + {% endif %} +
  • {{ object }}
  • +{% endblock %} + +{% block tabs %} + +{% endblock %} diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html new file mode 100644 index 00000000000..43cf6bc9d7c --- /dev/null +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -0,0 +1,9 @@ +{% extends 'ipam/prefix/base.html' %} + +{% block content %} +
    +
    + {% include 'utilities/obj_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html new file mode 100644 index 00000000000..61baa2f1e99 --- /dev/null +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -0,0 +1,25 @@ +{% extends 'ipam/prefix/base.html' %} + +{% block buttons %} + {% include 'ipam/inc/toggle_available.html' %} + {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %} + + Add Child Prefix + + {% endif %} + {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %} + + + Add an IP Address + + {% endif %} + {{ block.super }} +{% endblock %} + +{% block content %} +
    +
    + {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %} +
    +
    +{% endblock %} diff --git a/netbox/templates/ipam/prefix_ipaddresses.html b/netbox/templates/ipam/prefix_ipaddresses.html deleted file mode 100644 index 1da5b7518c6..00000000000 --- a/netbox/templates/ipam/prefix_ipaddresses.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'ipam/prefix.html' %} - -{% block title %}{{ block.super }} - IP Addresses{% endblock %} - -{% block content %} -
    -
    - {% include 'utilities/obj_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/ipam/prefix_prefixes.html b/netbox/templates/ipam/prefix_prefixes.html deleted file mode 100644 index 9cf50a64061..00000000000 --- a/netbox/templates/ipam/prefix_prefixes.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'ipam/prefix.html' %} - -{% block title %}{{ block.super }} - Prefixes{% endblock %} - -{% block content %} -
    -
    - {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %} -
    -
    -{% endblock %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 91dd9f1d8c0..bb56954cbda 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -1,39 +1,7 @@ -{% extends 'generic/object.html' %} +{% extends 'ipam/vlan/base.html' %} {% load helpers %} {% load plugins %} -{% block title %}VLAN {{ object.display_name }}{% endblock %} - -{% block breadcrumbs %} -
  • VLANs
  • - {% if object.site %} -
  • {{ object.site }}
  • - {% endif %} - {% if object.group %} -
  • {{ object.group }}
  • - {% endif %} -
  • {{ object }}
  • -{% endblock %} - -{% block tabs %} - -{% endblock %} - {% block content %}
    diff --git a/netbox/templates/ipam/vlan/base.html b/netbox/templates/ipam/vlan/base.html new file mode 100644 index 00000000000..2c2bf1010ac --- /dev/null +++ b/netbox/templates/ipam/vlan/base.html @@ -0,0 +1,40 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block title %}VLAN {{ object.display_name }}{% endblock %} + +{% block breadcrumbs %} +
  • VLANs
  • + {% if object.site %} +
  • {{ object.site }}
  • + {% endif %} + {% if object.group %} +
  • {{ object.group }}
  • + {% endif %} +
  • {{ object }}
  • +{% endblock %} + +{% block tabs %} + +{% endblock %} diff --git a/netbox/templates/ipam/vlan/interfaces.html b/netbox/templates/ipam/vlan/interfaces.html new file mode 100644 index 00000000000..f7b15179f0b --- /dev/null +++ b/netbox/templates/ipam/vlan/interfaces.html @@ -0,0 +1,9 @@ +{% extends 'ipam/vlan/base.html' %} + +{% block content %} +
    +
    + {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Device Interfaces' parent=vlan %} +
    +
    +{% endblock %} diff --git a/netbox/templates/ipam/vlan/vminterfaces.html b/netbox/templates/ipam/vlan/vminterfaces.html new file mode 100644 index 00000000000..6bb222976ac --- /dev/null +++ b/netbox/templates/ipam/vlan/vminterfaces.html @@ -0,0 +1,9 @@ +{% extends 'ipam/vlan/base.html' %} + +{% block content %} +
    +
    + {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Virtual Machine Interfaces' parent=vlan %} +
    +
    +{% endblock %} diff --git a/netbox/templates/ipam/vlan_interfaces.html b/netbox/templates/ipam/vlan_interfaces.html deleted file mode 100644 index d58de30c0de..00000000000 --- a/netbox/templates/ipam/vlan_interfaces.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'ipam/vlan.html' %} - -{% block content %} -
    -
    - {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Device Interfaces' parent=vlan %} -
    -
    -{% endblock %} diff --git a/netbox/templates/ipam/vlan_vminterfaces.html b/netbox/templates/ipam/vlan_vminterfaces.html deleted file mode 100644 index 55ddc82bdaa..00000000000 --- a/netbox/templates/ipam/vlan_vminterfaces.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'ipam/vlan.html' %} - -{% block content %} -
    -
    - {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Virtual Machine Interfaces' parent=vlan %} -
    -
    -{% endblock %} From c7040fd418bc9a9093e734b1608e58d1fa92ab07 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 16:53:41 -0400 Subject: [PATCH 127/223] Closes #6038: Include tagged objects list on tag view --- docs/release-notes/version-2.11.md | 1 + netbox/extras/tables.py | 20 ++++++-------------- netbox/extras/views.py | 6 ++++++ netbox/templates/extras/tag.html | 11 +++++++++-- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 39c38ced21b..edd58a0e8f8 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -99,6 +99,7 @@ A new Cloud model has been introduced to represent the boundary of a network tha * [#5971](https://github.com/netbox-community/netbox/issues/5971) - Add dedicated views for organizational models * [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models * [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial (decimal) vCPU allocations for virtual machines +* [#6038](https://github.com/netbox-community/netbox/issues/6038) - Include tagged objects list on tag view ### Other Changes diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 362a57e955f..70b5c5f70b1 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -6,14 +6,6 @@ from utilities.tables import ( ) from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem -TAGGED_ITEM = """ -{% if value.get_absolute_url %} - {{ value }} -{% else %} - {{ value }} -{% endif %} -""" - CONFIGCONTEXT_ACTIONS = """ {% if perms.extras.change_configcontext %} @@ -50,18 +42,18 @@ class TagTable(BaseTable): class TaggedItemTable(BaseTable): - content_object = tables.TemplateColumn( - template_code=TAGGED_ITEM, + content_type = ContentTypeColumn( + verbose_name='Type' + ) + content_object = tables.Column( + linkify=True, orderable=False, verbose_name='Object' ) - content_type = tables.Column( - verbose_name='Type' - ) class Meta(BaseTable.Meta): model = TaggedItem - fields = ('content_object', 'content_type') + fields = ('content_type', 'content_object') class ConfigContextTable(BaseTable): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index b4b6ef6289a..cd564b8d18e 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -39,8 +39,14 @@ class TagView(generic.ObjectView): def get_extra_context(self, request, instance): tagged_items = TaggedItem.objects.filter(tag=instance) + taggeditem_table = tables.TaggedItemTable( + data=tagged_items, + orderable=False + ) + paginate_table(taggeditem_table, request) return { + 'taggeditem_table': taggeditem_table, 'tagged_item_count': tagged_items.count(), } diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index cd6d09c297f..ae90fdb9c69 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -32,7 +32,7 @@
    Tagged Items - {{ tagged_item_count }} + {{ taggeditem_table.rows|length }}
    @@ -45,7 +45,14 @@
    - {% plugin_full_width_page object %} +
    +
    + Device Types +
    + {% include 'inc/table.html' with table=taggeditem_table %} +
    + {% include 'inc/paginator.html' with paginator=taggeditem_table.paginator page=taggeditem_table.page %} + {% plugin_full_width_page object %}
    {% endblock %} From be3d33eebd6a8001cf6f62cf06e761460cdaf317 Mon Sep 17 00:00:00 2001 From: Tom Grozev <1491414+TomGrozev@users.noreply.github.com> Date: Mon, 29 Mar 2021 21:15:21 +0000 Subject: [PATCH 128/223] Add support for custom fields in tables (#5460) * Add support for custom fields in tables * Fix empty list displays as none Co-authored-by: TomGrozev Co-authored-by: Jeremy Stretch --- netbox/utilities/tables.py | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 3d48d9875e1..7a7ab91edbe 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,7 +1,9 @@ import django_tables2 as tables from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist +from django.db.models import Func, F, Value from django.db.models.fields.related import RelatedField from django.urls import reverse from django.utils.html import strip_tags @@ -9,6 +11,7 @@ from django.utils.safestring import mark_safe from django_tables2 import RequestConfig from django_tables2.data import TableQuerysetData +from .models import CustomField from .paginator import EnhancedPaginator, get_paginate_count @@ -30,12 +33,25 @@ class BaseTable(tables.Table): :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. """ + class Meta: attrs = { 'class': 'table table-hover table-headings', } def __init__(self, *args, user=None, **kwargs): + # Add custom field columns + obj_type = ContentType.objects.get_for_model(self._meta.model) + custom_fields = {} + + for cf in CustomField.objects.filter(content_types=obj_type): + name = 'cf_{}'.format(cf.name) + label = cf.label if cf.label != '' else cf.name + self.base_columns[name] = CustomFieldColumn(verbose_name=label) + custom_fields[name] = cf + self._meta.fields += tuple(custom_fields.keys()) + + # Init table super().__init__(*args, **kwargs) # Set default empty_text if none was provided @@ -73,6 +89,12 @@ class BaseTable(tables.Table): # Dynamically update the table's QuerySet to ensure related fields are pre-fetched if isinstance(self.data, TableQuerysetData): + # Extract custom field values + cf_fields = {} + for key, cf in custom_fields.items(): + cf_fields[key] = Func(F('custom_field_data'), Value(cf.name), function='jsonb_extract_path_text') + self.data.data = self.data.data.annotate(**cf_fields) + prefetch_fields = [] for column in self.columns: if column.visible: @@ -316,6 +338,28 @@ class TagColumn(tables.TemplateColumn): return ",".join([tag.name for tag in value.all()]) +class CustomFieldColumn(tables.Column): + """ + Display custom fields in the appropriate format. + """ + def render(self, record, bound_column, value): + if isinstance(value, list): + if len(value): + template = '' + for v in value: + template += f'{v} ' + else: + template = '' + elif value: + template = value + else: + return self.default + return mark_safe(template) + + def value(self, value): + return value + + class MPTTColumn(tables.TemplateColumn): """ Display a nested hierarchy for MPTT-enabled models. @@ -362,3 +406,4 @@ def paginate_table(table, request): 'per_page': get_paginate_count(request) } RequestConfig(request, paginate).configure(table) + From 7885ec551127fb17031ca25bf06046d10a377699 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Mar 2021 17:51:45 -0400 Subject: [PATCH 129/223] Clean up custom field column implementation --- docs/release-notes/version-2.11.md | 1 + netbox/utilities/tables.py | 47 ++++++++++-------------------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index edd58a0e8f8..89a6a6d1510 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -85,6 +85,7 @@ A new Cloud model has been introduced to represent the boundary of a network tha ### Enhancements * [#4833](https://github.com/netbox-community/netbox/issues/4833) - Allow assigning config contexts by device type +* [#5344](https://github.com/netbox-community/netbox/issues/5344) - Add support for custom fields in tables * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models * [#5375](https://github.com/netbox-community/netbox/issues/5375) - Add `speed` attribute to console port models * [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 7a7ab91edbe..7e9cc9c3007 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -3,15 +3,15 @@ from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist -from django.db.models import Func, F, Value from django.db.models.fields.related import RelatedField from django.urls import reverse from django.utils.html import strip_tags from django.utils.safestring import mark_safe from django_tables2 import RequestConfig from django_tables2.data import TableQuerysetData +from django_tables2.utils import Accessor -from .models import CustomField +from extras.models import CustomField from .paginator import EnhancedPaginator, get_paginate_count @@ -42,21 +42,14 @@ class BaseTable(tables.Table): def __init__(self, *args, user=None, **kwargs): # Add custom field columns obj_type = ContentType.objects.get_for_model(self._meta.model) - custom_fields = {} - for cf in CustomField.objects.filter(content_types=obj_type): - name = 'cf_{}'.format(cf.name) - label = cf.label if cf.label != '' else cf.name - self.base_columns[name] = CustomFieldColumn(verbose_name=label) - custom_fields[name] = cf - self._meta.fields += tuple(custom_fields.keys()) + self.base_columns[f'cf_{cf.name}'] = CustomFieldColumn(cf) - # Init table super().__init__(*args, **kwargs) # Set default empty_text if none was provided if self.empty_text is None: - self.empty_text = 'No {} found'.format(self._meta.model._meta.verbose_name_plural) + self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" # Hide non-default columns default_columns = getattr(self.Meta, 'default_columns', list()) @@ -89,11 +82,6 @@ class BaseTable(tables.Table): # Dynamically update the table's QuerySet to ensure related fields are pre-fetched if isinstance(self.data, TableQuerysetData): - # Extract custom field values - cf_fields = {} - for key, cf in custom_fields.items(): - cf_fields[key] = Func(F('custom_field_data'), Value(cf.name), function='jsonb_extract_path_text') - self.data.data = self.data.data.annotate(**cf_fields) prefetch_fields = [] for column in self.columns: @@ -342,22 +330,18 @@ class CustomFieldColumn(tables.Column): """ Display custom fields in the appropriate format. """ - def render(self, record, bound_column, value): - if isinstance(value, list): - if len(value): - template = '' - for v in value: - template += f'{v} ' - else: - template = '' - elif value: - template = value - else: - return self.default - return mark_safe(template) + def __init__(self, customfield, *args, **kwargs): + self.customfield = customfield + kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}') + if 'verbose_name' not in kwargs: + kwargs['verbose_name'] = customfield.label or customfield.name - def value(self, value): - return value + super().__init__(*args, **kwargs) + + def render(self, value): + if isinstance(value, list): + return ', '.join(v for v in value) + return value or self.default class MPTTColumn(tables.TemplateColumn): @@ -406,4 +390,3 @@ def paginate_table(table, request): 'per_page': get_paginate_count(request) } RequestConfig(request, paginate).configure(table) - From bfa95c16e382c345d1b4b50aa141f1fe961b177a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Mar 2021 11:06:59 -0400 Subject: [PATCH 130/223] Add missing tests for SiteGroup --- netbox/dcim/tests/test_api.py | 29 +++++++++++++++++++++++++++++ netbox/extras/tests/test_models.py | 21 +++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 594a4d4f580..24130c6498e 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -86,6 +86,35 @@ class RegionTest(APIViewTestCases.APIViewTestCase): Region.objects.create(name='Region 3', slug='region-3') +class SiteGroupTest(APIViewTestCases.APIViewTestCase): + model = SiteGroup + brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] + create_data = [ + { + 'name': 'Site Group 4', + 'slug': 'site-group-4', + }, + { + 'name': 'Site Group 5', + 'slug': 'site-group-5', + }, + { + 'name': 'Site Group 6', + 'slug': 'site-group-6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') + SiteGroup.objects.create(name='Site Group 2', slug='site-group-2') + SiteGroup.objects.create(name='Site Group 3', slug='site-group-3') + + class SiteTest(APIViewTestCases.APIViewTestCase): model = Site brief_fields = ['display', 'id', 'name', 'slug', 'url'] diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 645fb861852..10d4168b45c 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Site, Region +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -28,7 +28,8 @@ class ConfigContextTest(TestCase): self.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') self.region = Region.objects.create(name="Region") - self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region) + self.sitegroup = SiteGroup.objects.create(name="Site Group") + self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup) self.platform = Platform.objects.create(name="Platform") self.tenantgroup = TenantGroup.objects.create(name="Tenant Group") self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup) @@ -160,6 +161,14 @@ class ConfigContextTest(TestCase): } ) region_context.regions.add(self.region) + sitegroup_context = ConfigContext.objects.create( + name="sitegroup", + weight=100, + data={ + "sitegroup": 1 + } + ) + sitegroup_context.site_groups.add(self.sitegroup) platform_context = ConfigContext.objects.create( name="platform", weight=100, @@ -224,6 +233,14 @@ class ConfigContextTest(TestCase): } ) region_context.regions.add(self.region) + sitegroup_context = ConfigContext.objects.create( + name="sitegroup", + weight=100, + data={ + "sitegroup": 1 + } + ) + sitegroup_context.site_groups.add(self.sitegroup) platform_context = ConfigContext.objects.create( name="platform", weight=100, From 9ede726eea108e63e0c1ec7af21148ce18c875cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20R=C3=B8dvand?= Date: Tue, 30 Mar 2021 22:27:26 +0200 Subject: [PATCH 131/223] Add boolean as_attachment to Export template --- .../0059_attachment_export_templates.py | 18 ++++++++++++++++++ netbox/extras/models/models.py | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 netbox/extras/migrations/0059_attachment_export_templates.py diff --git a/netbox/extras/migrations/0059_attachment_export_templates.py b/netbox/extras/migrations/0059_attachment_export_templates.py new file mode 100644 index 00000000000..2598aefa806 --- /dev/null +++ b/netbox/extras/migrations/0059_attachment_export_templates.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2b1 on 2021-03-30 20:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0058_journalentry'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='as_attachment', + field=models.BooleanField(default=True), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index d54eac0dbae..8b9c4cc60b8 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -251,6 +251,10 @@ class ExportTemplate(BigIDModel): blank=True, help_text='Extension to append to the rendered filename' ) + as_attachment = models.BooleanField( + default=True, + help_text="Present file as attachment" + ) objects = RestrictedQuerySet.as_manager() @@ -298,7 +302,9 @@ class ExportTemplate(BigIDModel): queryset.model._meta.verbose_name_plural, '.{}'.format(self.file_extension) if self.file_extension else '' ) - response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + + if self.as_attachment: + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) return response From 878154c305f6ad99e0810824f5aa0706ebb9610c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20R=C3=B8dvand?= Date: Tue, 30 Mar 2021 23:10:08 +0200 Subject: [PATCH 132/223] Closes #5830: Add as_attachment field to API serializers and admin view. --- netbox/extras/admin.py | 4 ++-- netbox/extras/api/serializers.py | 2 +- netbox/extras/models/models.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 4e11307fc9f..6e421e679dd 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -193,7 +193,7 @@ class ExportTemplateForm(forms.ModelForm): class ExportTemplateAdmin(admin.ModelAdmin): fieldsets = ( ('Export Template', { - 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension') + 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment') }), ('Content', { 'fields': ('template_code',), @@ -201,7 +201,7 @@ class ExportTemplateAdmin(admin.ModelAdmin): }) ) list_display = [ - 'name', 'content_type', 'description', 'mime_type', 'file_extension', + 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', ] list_filter = [ 'content_type', diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 4a1b154d32b..66627bfbc30 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -116,7 +116,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer): model = ExportTemplate fields = [ 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', + 'file_extension', 'as_attachment', ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 8b9c4cc60b8..226aa135a4f 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -253,7 +253,7 @@ class ExportTemplate(BigIDModel): ) as_attachment = models.BooleanField( default=True, - help_text="Present file as attachment" + help_text="Download file as attachment" ) objects = RestrictedQuerySet.as_manager() From 0eb9f414702d4f54040f9ae00cd351475c3037ae Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Mar 2021 18:59:47 -0400 Subject: [PATCH 133/223] Changelog & docs for #5380 --- docs/additional-features/export-templates.md | 2 ++ docs/release-notes/version-2.11.md | 3 +++ ...xport_templates.py => 0059_exporttemplate_as_attachment.py} | 2 -- 3 files changed, 5 insertions(+), 2 deletions(-) rename netbox/extras/migrations/{0059_attachment_export_templates.py => 0059_exporttemplate_as_attachment.py} (87%) diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md index 950d02d4aad..111f4101d82 100644 --- a/docs/additional-features/export-templates.md +++ b/docs/additional-features/export-templates.md @@ -21,6 +21,8 @@ Height: {{ rack.u_height }}U To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. +The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.) + A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. ## Example diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 89a6a6d1510..1084a8bd884 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -93,6 +93,7 @@ A new Cloud model has been introduced to represent the boundary of a network tha * [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields * [#5608](https://github.com/netbox-community/netbox/issues/5608) - Add REST API endpoint for custom links * [#5610](https://github.com/netbox-community/netbox/issues/5610) - Add REST API endpoint for webhooks +* [#5830](https://github.com/netbox-community/netbox/issues/5830) - Add `as_attachment` to ExportTemplate to control download behavior * [#5891](https://github.com/netbox-community/netbox/issues/5891) - Add `display` field to all REST API serializers * [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI * [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location @@ -149,6 +150,8 @@ A new Cloud model has been introduced to represent the boundary of a network tha * Added new custom field type: `multi-select` * extras.CustomLink * Added the `/api/extras/custom-links/` endpoint +* extras.ExportTemplate + * Added the `as_attachment` boolean field * extras.ObjectChange * Added the `prechange_data` field * Renamed `object_data` to `postchange_data` diff --git a/netbox/extras/migrations/0059_attachment_export_templates.py b/netbox/extras/migrations/0059_exporttemplate_as_attachment.py similarity index 87% rename from netbox/extras/migrations/0059_attachment_export_templates.py rename to netbox/extras/migrations/0059_exporttemplate_as_attachment.py index 2598aefa806..6e6ae0413ac 100644 --- a/netbox/extras/migrations/0059_attachment_export_templates.py +++ b/netbox/extras/migrations/0059_exporttemplate_as_attachment.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2b1 on 2021-03-30 20:16 - from django.db import migrations, models From e1e840eb690816a0b1e2ace4696c2cb49846c739 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Mar 2021 21:23:57 -0400 Subject: [PATCH 134/223] Fix VLANGroup 'add VLAN' button --- netbox/templates/ipam/vlangroup.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index e265aa6083a..3118c1796b6 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -59,7 +59,7 @@ {% include 'inc/table.html' with table=vlans_table %} {% if perms.ipam.add_vlan %} From b91e5763e20cbb5bdc4801fad648f46ca5ee2439 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Mar 2021 21:32:48 -0400 Subject: [PATCH 135/223] Add 'available_on' VLAN filters for devices & VMs --- netbox/dcim/forms.py | 58 +++++++---------------- netbox/ipam/filters.py | 14 ++++++ netbox/ipam/models/vlans.py | 3 +- netbox/ipam/querysets.py | 84 ++++++++++++++++++++++++++++++++++ netbox/virtualization/forms.py | 75 ++++++++---------------------- 5 files changed, 134 insertions(+), 100 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index dca1b86c3f9..667605b5c3f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3077,20 +3077,12 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='Untagged VLAN', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - label='Tagged VLANs', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + label='Tagged VLANs' ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -3124,9 +3116,9 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): self.fields['parent'].widget.add_query_param('device_id', device.pk) self.fields['lag'].widget.add_query_param('device_id', device.pk) - # Add current site to VLANs query params - self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) + # Limit VLAN choices by device + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): @@ -3177,19 +3169,11 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) field_order = ( 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', @@ -3199,12 +3183,10 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Add current site to VLANs query params - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) + # Limit VLAN choices by device + device_id = self.initial.get('device') or self.data.get('device') + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id) class InterfaceBulkCreateForm( @@ -3264,19 +3246,11 @@ class InterfaceBulkEditForm( ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) class Meta: @@ -3293,9 +3267,9 @@ class InterfaceBulkEditForm( self.fields['parent'].widget.add_query_param('device_id', device.pk) self.fields['lag'].widget.add_query_param('device_id', device.pk) - # Add current site to VLANs query params - self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) + # Limit VLAN choices by device + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) else: # See #4523 diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 5c5b9e8d3c1..141d5013968 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -635,6 +635,14 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, choices=VLANStatusChoices, null_value=None ) + available_on_device = django_filters.ModelChoiceFilter( + queryset=Device.objects.all(), + method='get_for_device' + ) + available_on_virtualmachine = django_filters.ModelChoiceFilter( + queryset=VirtualMachine.objects.all(), + method='get_for_virtualmachine' + ) tag = TagFilter() class Meta: @@ -651,6 +659,12 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, pass return queryset.filter(qs_filter) + def get_for_device(self, queryset, name, value): + return queryset.get_for_device(value) + + def get_for_virtualmachine(self, queryset, name, value): + return queryset.get_for_virtualmachine(value) + class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 0c184adff35..040d23746c7 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -9,6 +9,7 @@ from dcim.models import Interface from extras.utils import extras_features from ipam.choices import * from ipam.constants import * +from ipam.querysets import VLANQuerySet from netbox.models import OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from virtualization.models import VMInterface @@ -156,7 +157,7 @@ class VLAN(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() + objects = VLANQuerySet.as_manager() csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] clone_fields = [ diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 102954f138e..a205dca3282 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -1,3 +1,6 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + from utilities.querysets import RestrictedQuerySet @@ -20,3 +23,84 @@ class PrefixQuerySet(RestrictedQuerySet): 'AND COALESCE(U1."vrf_id", 0) = COALESCE("ipam_prefix"."vrf_id", 0))', } ) + + +class VLANQuerySet(RestrictedQuerySet): + + def get_for_device(self, device): + """ + Return all VLANs available to the specified Device. + """ + from .models import VLANGroup + + # Find all relevant VLANGroups + q = Q() + if device.site.region: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), + scope_id__in=device.site.region.get_ancestors(include_self=True) + ) + if device.site.group: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), + scope_id__in=device.site.group.get_ancestors(include_self=True) + ) + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), + scope_id=device.site_id + ) + if device.location: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'location'), + scope_id__in=device.location.get_ancestors(include_self=True) + ) + if device.rack: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'rack'), + scope_id=device.rack_id + ) + + return self.filter( + Q(group__in=VLANGroup.objects.filter(q)) | + Q(site=device.site) | + Q(group__isnull=True, site__isnull=True) # Global VLANs + ) + + def get_for_virtualmachine(self, vm): + """ + Return all VLANs available to the specified VirtualMachine. + """ + from .models import VLANGroup + + # Find all relevant VLANGroups + q = Q() + if vm.cluster.site: + if vm.cluster.site.region: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), + scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True) + ) + if vm.cluster.site.group: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), + scope_id__in=vm.cluster.site.group.get_ancestors(include_self=True) + ) + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), + scope_id=vm.cluster.site_id + ) + if vm.cluster.group: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'), + scope_id=vm.cluster.group_id + ) + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'), + scope_id=vm.cluster_id + ) + + return self.filter( + Q(group__in=VLANGroup.objects.filter(q)) | + Q(site=vm.cluster.site) | + Q(group__isnull=True, site__isnull=True) # Global VLANs + ) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 95e1d2dc19c..4764e9a8e02 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -606,20 +606,12 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='Untagged VLAN', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - label='Tagged VLANs', - brief_mode=False, - query_params={ - 'site_id': 'null', - } + label='Tagged VLANs' ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -646,15 +638,10 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - virtual_machine = VirtualMachine.objects.get( - pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine') - ) - - # Add current site to VLANs query params - site = virtual_machine.site - if site: - self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + # Limit VLAN choices by virtual machine + vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') + self.fields['untagged_vlan'].widget.add_query_param('available_on_vm', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_vm', vm_id) class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): @@ -689,19 +676,11 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -711,15 +690,10 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - virtual_machine = VirtualMachine.objects.get( - pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine') - ) - - # Add current site to VLANs query params - site = virtual_machine.site - if site: - self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + # Limit VLAN choices by virtual machine + vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') + self.fields['untagged_vlan'].widget.add_query_param('available_on_vm', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_vm', vm_id) class VMInterfaceCSVForm(CSVModelForm): @@ -777,19 +751,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False, - brief_mode=False, - query_params={ - 'site_id': 'null', - } + required=False ) class Meta: @@ -800,15 +766,10 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit available VLANs based on the parent VirtualMachine - if 'virtual_machine' in self.initial: - parent_obj = VirtualMachine.objects.filter(pk=self.initial['virtual_machine']).first() - - site = getattr(parent_obj.cluster, 'site', None) - if site is not None: - # Add current site to VLANs query params - self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + # Limit VLAN choices by virtual machine + vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') + self.fields['untagged_vlan'].widget.add_query_param('available_on_vm', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_vm', vm_id) class VMInterfaceBulkRenameForm(BulkRenameForm): From a292ff5cc089626d75bc96b4805ae5bc510e2fb3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Mar 2021 21:34:50 -0400 Subject: [PATCH 136/223] Remove brief_mode parameter from DynamicModelChoiceMixin --- netbox/project-static/js/forms.js | 3 --- netbox/utilities/forms/fields.py | 8 +------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index f189adc1b2c..c3cb19bdf4d 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -160,9 +160,6 @@ $(document).ready(function() { offset: offset, }; - // Allow for controlling the brief setting from within APISelect - parameters.brief = ( $(element).is('[data-full]') ? undefined : true ); - // Attach any extra query parameters $.each(element.attributes, function(index, attr){ if (attr.name.includes("data-query-param-")){ diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index fdb0123306d..07481ed60d8 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -271,20 +271,18 @@ class DynamicModelChoiceMixin: :param null_option: The string used to represent a null selection (if any) :param disabled_indicator: The name of the field which, if populated, will disable selection of the choice (optional) - :param brief_mode: Use the "brief" format (?brief=true) when making API requests (default) """ filter = django_filters.ModelChoiceFilter widget = widgets.APISelect # TODO: Remove display_field in v2.12 def __init__(self, display_field='display', query_params=None, initial_params=None, null_option=None, - disabled_indicator=None, brief_mode=True, *args, **kwargs): + disabled_indicator=None, *args, **kwargs): self.display_field = display_field self.query_params = query_params or {} self.initial_params = initial_params or {} self.null_option = null_option self.disabled_indicator = disabled_indicator - self.brief_mode = brief_mode # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference # by widget_attrs() @@ -309,10 +307,6 @@ class DynamicModelChoiceMixin: if self.disabled_indicator is not None: attrs['disabled-indicator'] = self.disabled_indicator - # Toggle brief mode - if not self.brief_mode: - attrs['data-full'] = 'true' - # Attach any static query parameters for key, value in self.query_params.items(): widget.add_query_param(key, value) From d33d9522ccabe029c3758c442a4fa05601969825 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Mar 2021 21:46:56 -0400 Subject: [PATCH 137/223] Fix VM VLAN filtering --- netbox/ipam/querysets.py | 12 +++++++++--- netbox/virtualization/forms.py | 12 ++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index a205dca3282..1a723421dfc 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -60,6 +60,7 @@ class VLANQuerySet(RestrictedQuerySet): scope_id=device.rack_id ) + # Return all applicable VLANs return self.filter( Q(group__in=VLANGroup.objects.filter(q)) | Q(site=device.site) | @@ -98,9 +99,14 @@ class VLANQuerySet(RestrictedQuerySet): scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'), scope_id=vm.cluster_id ) + vlan_groups = VLANGroup.objects.filter(q) - return self.filter( - Q(group__in=VLANGroup.objects.filter(q)) | - Q(site=vm.cluster.site) | + # Return all applicable VLANs + q = ( + Q(group__in=vlan_groups) | Q(group__isnull=True, site__isnull=True) # Global VLANs ) + if vm.cluster.site: + q |= Q(site=vm.cluster.site) + + return self.filter(q) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 4764e9a8e02..2b39e75d30d 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -640,8 +640,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) # Limit VLAN choices by virtual machine vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - self.fields['untagged_vlan'].widget.add_query_param('available_on_vm', vm_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_vm', vm_id) + self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): @@ -692,8 +692,8 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): # Limit VLAN choices by virtual machine vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - self.fields['untagged_vlan'].widget.add_query_param('available_on_vm', vm_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_vm', vm_id) + self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) class VMInterfaceCSVForm(CSVModelForm): @@ -768,8 +768,8 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): # Limit VLAN choices by virtual machine vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - self.fields['untagged_vlan'].widget.add_query_param('available_on_vm', vm_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_vm', vm_id) + self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) class VMInterfaceBulkRenameForm(BulkRenameForm): From ad5e167ad7f328f66b50fa3dd2fc70fea8443be3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 Mar 2021 10:04:44 -0400 Subject: [PATCH 138/223] Remove obsolete grouping logic --- netbox/project-static/js/forms.js | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index c3cb19bdf4d..b2962686a11 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -210,26 +210,7 @@ $(document).ready(function() { // The disabled-indicator equated to true, so we disable this option record.disabled = true; } - - if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) { - results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }; - results[record.site.name + ":" + record.group.name].children.push(record); - } - else if( record.group !== undefined && record.group !== null ) { - results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }; - results[record.group.name].children.push(record); - } - else if( record.site !== undefined && record.site !== null ) { - results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }; - results[record.site.name].children.push(record); - } - else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) { - results['global'] = results['global'] || { text: 'Global', children: [] }; - results['global'].children.push(record); - } - else { - results[idx] = record - } + results[idx] = record; return results; },Object.create(null)); From 9a5f54bdaf27225acbda7d8a517ef6a26bb565db Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 Mar 2021 10:59:15 -0400 Subject: [PATCH 139/223] Add tests for filtering VLANs by device/VM --- netbox/ipam/tests/test_filters.py | 154 +++++++++++++++++++++++++----- 1 file changed, 132 insertions(+), 22 deletions(-) diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 90af26e9bf4..3ea54209c30 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -820,12 +820,93 @@ class VLANTestCase(TestCase): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]), + Site(name='Site 4', slug='site-4', region=regions[0], group=site_groups[0]), + Site(name='Site 5', slug='site-5', region=regions[1], group=site_groups[1]), + Site(name='Site 6', slug='site-6', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + + racks = ( + Rack(name='Rack 1', site=sites[0], location=locations[0]), + Rack(name='Rack 2', site=sites[1], location=locations[1]), + Rack(name='Rack 3', site=sites[2], location=locations[2]), + ) + Rack.objects.bulk_create(racks) + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') + device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + devices = ( + Device(name='Device 1', site=sites[0], location=locations[0], rack=racks[0], device_type=device_type, device_role=device_role), + Device(name='Device 2', site=sites[1], location=locations[1], rack=racks[1], device_type=device_type, device_role=device_role), + Device(name='Device 3', site=sites[2], location=locations[2], rack=racks[2], device_type=device_type, device_role=device_role), + ) + Device.objects.bulk_create(devices) + + cluster_groups = ( + ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), + ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), + ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), + ) + ClusterGroup.objects.bulk_create(cluster_groups) + + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + clusters = ( + Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], site=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], site=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], site=sites[2]), + ) + Cluster.objects.bulk_create(clusters) + + virtual_machines = ( + VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]), + VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]), + VirtualMachine(name='Virtual Machine 3', cluster=clusters[2]), + ) + VirtualMachine.objects.bulk_create(virtual_machines) + + groups = ( + # Scoped VLAN groups + VLANGroup(name='Region 1', slug='region-1', scope=regions[0]), + VLANGroup(name='Region 2', slug='region-2', scope=regions[1]), + VLANGroup(name='Region 3', slug='region-3', scope=regions[2]), + VLANGroup(name='Site Group 1', slug='site-group-1', scope=site_groups[0]), + VLANGroup(name='Site Group 2', slug='site-group-2', scope=site_groups[1]), + VLANGroup(name='Site Group 3', slug='site-group-3', scope=site_groups[2]), + VLANGroup(name='Site 1', slug='site-1', scope=sites[0]), + VLANGroup(name='Site 2', slug='site-2', scope=sites[1]), + VLANGroup(name='Site 3', slug='site-3', scope=sites[2]), + VLANGroup(name='Location 1', slug='location-1', scope=locations[0]), + VLANGroup(name='Location 2', slug='location-2', scope=locations[1]), + VLANGroup(name='Location 3', slug='location-3', scope=locations[2]), + VLANGroup(name='Rack 1', slug='rack-1', scope=racks[0]), + VLANGroup(name='Rack 2', slug='rack-2', scope=racks[1]), + VLANGroup(name='Rack 3', slug='rack-3', scope=racks[2]), + VLANGroup(name='Cluster Group 1', slug='cluster-group-1', scope=cluster_groups[0]), + VLANGroup(name='Cluster Group 2', slug='cluster-group-2', scope=cluster_groups[1]), + VLANGroup(name='Cluster Group 3', slug='cluster-group-3', scope=cluster_groups[2]), + VLANGroup(name='Cluster 1', slug='cluster-1', scope=clusters[0]), + VLANGroup(name='Cluster 2', slug='cluster-2', scope=clusters[1]), + VLANGroup(name='Cluster 3', slug='cluster-3', scope=clusters[2]), + + # General purpose VLAN groups + VLANGroup(name='VLAN Group 1', slug='vlan-group-1'), + VLANGroup(name='VLAN Group 2', slug='vlan-group-2'), + VLANGroup(name='VLAN Group 3', slug='vlan-group-3'), + ) + VLANGroup.objects.bulk_create(groups) + roles = ( Role(name='Role 1', slug='role-1'), Role(name='Role 2', slug='role-2'), @@ -833,13 +914,6 @@ class VLANTestCase(TestCase): ) Role.objects.bulk_create(roles) - groups = ( - VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]), - VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[1]), - VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=None), - ) - VLANGroup.objects.bulk_create(groups) - tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -856,12 +930,38 @@ class VLANTestCase(TestCase): Tenant.objects.bulk_create(tenants) vlans = ( - VLAN(vid=101, name='VLAN 101', site=sites[0], group=groups[0], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE), - VLAN(vid=102, name='VLAN 102', site=sites[0], group=groups[0], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE), - VLAN(vid=201, name='VLAN 201', site=sites[1], group=groups[1], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED), - VLAN(vid=202, name='VLAN 202', site=sites[1], group=groups[1], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED), - VLAN(vid=301, name='VLAN 301', site=sites[2], group=groups[2], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED), - VLAN(vid=302, name='VLAN 302', site=sites[2], group=groups[2], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED), + # Create one VLAN per VLANGroup + VLAN(vid=1, name='Region 1', group=groups[0]), + VLAN(vid=2, name='Region 2', group=groups[1]), + VLAN(vid=3, name='Region 3', group=groups[2]), + VLAN(vid=4, name='Site Group 1', group=groups[3]), + VLAN(vid=5, name='Site Group 2', group=groups[4]), + VLAN(vid=6, name='Site Group 3', group=groups[5]), + VLAN(vid=7, name='Site 1', group=groups[6]), + VLAN(vid=8, name='Site 2', group=groups[7]), + VLAN(vid=9, name='Site 3', group=groups[8]), + VLAN(vid=10, name='Location 1', group=groups[9]), + VLAN(vid=11, name='Location 2', group=groups[10]), + VLAN(vid=12, name='Location 3', group=groups[11]), + VLAN(vid=13, name='Rack 1', group=groups[12]), + VLAN(vid=14, name='Rack 2', group=groups[13]), + VLAN(vid=15, name='Rack 3', group=groups[14]), + VLAN(vid=16, name='Cluster Group 1', group=groups[15]), + VLAN(vid=17, name='Cluster Group 2', group=groups[16]), + VLAN(vid=18, name='Cluster Group 3', group=groups[17]), + VLAN(vid=19, name='Cluster 1', group=groups[18]), + VLAN(vid=20, name='Cluster 2', group=groups[19]), + VLAN(vid=21, name='Cluster 3', group=groups[20]), + + VLAN(vid=101, name='VLAN 101', site=sites[3], group=groups[21], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE), + VLAN(vid=102, name='VLAN 102', site=sites[3], group=groups[21], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE), + VLAN(vid=201, name='VLAN 201', site=sites[4], group=groups[22], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED), + VLAN(vid=202, name='VLAN 202', site=sites[4], group=groups[22], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED), + VLAN(vid=301, name='VLAN 301', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED), + VLAN(vid=302, name='VLAN 302', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED), + + # Create one globally available VLAN + VLAN(vid=1000, name='Global VLAN'), ) VLAN.objects.bulk_create(vlans) @@ -873,7 +973,7 @@ class VLANTestCase(TestCase): params = {'name': ['VLAN 101', 'VLAN 102']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_rd(self): + def test_vid(self): params = {'vid': ['101', '201', '301']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) @@ -892,14 +992,14 @@ class VLANTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_site(self): - sites = Site.objects.all()[:2] - params = {'site_id': [sites[0].pk, sites[1].pk]} + sites = Site.objects.all() + params = {'site_id': [sites[3].pk, sites[4].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'site': [sites[0].slug, sites[1].slug]} + params = {'site': [sites[3].slug, sites[4].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_group(self): - groups = VLANGroup.objects.all()[:2] + groups = VLANGroup.objects.filter(name__startswith='VLAN Group')[:2] params = {'group_id': [groups[0].pk, groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'group': [groups[0].slug, groups[1].slug]} @@ -913,7 +1013,7 @@ class VLANTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_status(self): - params = {'status': [VLANStatusChoices.STATUS_ACTIVE, VLANStatusChoices.STATUS_DEPRECATED]} + params = {'status': [VLANStatusChoices.STATUS_DEPRECATED, VLANStatusChoices.STATUS_RESERVED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_tenant(self): @@ -930,6 +1030,16 @@ class VLANTestCase(TestCase): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_available_on_device(self): + device_id = Device.objects.first().pk + params = {'available_on_device': device_id} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global + + def test_available_on_virtualmachine(self): + vm_id = VirtualMachine.objects.first().pk + params = {'available_on_virtualmachine': vm_id} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global + class ServiceTestCase(TestCase): queryset = Service.objects.all() From 04fc3a5a9e1365ae14c24a3abe2a8c31f2c26600 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 Mar 2021 14:24:05 -0400 Subject: [PATCH 140/223] Closes #6001: Paginate component tables under device views --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/views.py | 9 +++++++++ netbox/templates/dcim/device/consoleports.html | 1 + netbox/templates/dcim/device/consoleserverports.html | 1 + netbox/templates/dcim/device/devicebays.html | 1 + netbox/templates/dcim/device/frontports.html | 1 + netbox/templates/dcim/device/interfaces.html | 1 + netbox/templates/dcim/device/inventory.html | 1 + netbox/templates/dcim/device/poweroutlets.html | 1 + netbox/templates/dcim/device/powerports.html | 1 + netbox/templates/dcim/device/rearports.html | 1 + 11 files changed, 19 insertions(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 1084a8bd884..f0a61bd0a41 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -101,6 +101,7 @@ A new Cloud model has been introduced to represent the boundary of a network tha * [#5971](https://github.com/netbox-community/netbox/issues/5971) - Add dedicated views for organizational models * [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models * [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial (decimal) vCPU allocations for virtual machines +* [#6001](https://github.com/netbox-community/netbox/issues/6001) - Paginate component tables under device views * [#6038](https://github.com/netbox-community/netbox/issues/6038) - Include tagged objects list on tag view ### Other Changes diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3bd456bccd1..a51fb9deb18 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1268,6 +1268,7 @@ class DeviceConsolePortsView(generic.ObjectView): ) if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'): consoleport_table.columns.show('pk') + paginate_table(consoleport_table, request) return { 'consoleport_table': consoleport_table, @@ -1293,6 +1294,7 @@ class DeviceConsoleServerPortsView(generic.ObjectView): if request.user.has_perm('dcim.change_consoleserverport') or \ request.user.has_perm('dcim.delete_consoleserverport'): consoleserverport_table.columns.show('pk') + paginate_table(consoleserverport_table, request) return { 'consoleserverport_table': consoleserverport_table, @@ -1315,6 +1317,7 @@ class DevicePowerPortsView(generic.ObjectView): ) if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'): powerport_table.columns.show('pk') + paginate_table(powerport_table, request) return { 'powerport_table': powerport_table, @@ -1337,6 +1340,7 @@ class DevicePowerOutletsView(generic.ObjectView): ) if request.user.has_perm('dcim.change_poweroutlet') or request.user.has_perm('dcim.delete_poweroutlet'): poweroutlet_table.columns.show('pk') + paginate_table(poweroutlet_table, request) return { 'poweroutlet_table': poweroutlet_table, @@ -1361,6 +1365,7 @@ class DeviceInterfacesView(generic.ObjectView): ) if request.user.has_perm('dcim.change_interface') or request.user.has_perm('dcim.delete_interface'): interface_table.columns.show('pk') + paginate_table(interface_table, request) return { 'interface_table': interface_table, @@ -1383,6 +1388,7 @@ class DeviceFrontPortsView(generic.ObjectView): ) if request.user.has_perm('dcim.change_frontport') or request.user.has_perm('dcim.delete_frontport'): frontport_table.columns.show('pk') + paginate_table(frontport_table, request) return { 'frontport_table': frontport_table, @@ -1403,6 +1409,7 @@ class DeviceRearPortsView(generic.ObjectView): ) if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'): rearport_table.columns.show('pk') + paginate_table(rearport_table, request) return { 'rearport_table': rearport_table, @@ -1425,6 +1432,7 @@ class DeviceDeviceBaysView(generic.ObjectView): ) if request.user.has_perm('dcim.change_devicebay') or request.user.has_perm('dcim.delete_devicebay'): devicebay_table.columns.show('pk') + paginate_table(devicebay_table, request) return { 'devicebay_table': devicebay_table, @@ -1447,6 +1455,7 @@ class DeviceInventoryView(generic.ObjectView): ) if request.user.has_perm('dcim.change_inventoryitem') or request.user.has_perm('dcim.delete_inventoryitem'): inventoryitem_table.columns.show('pk') + paginate_table(inventoryitem_table, request) return { 'inventoryitem_table': inventoryitem_table, diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index 9a0c8d0e824..989970c8216 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -44,6 +44,7 @@
    + {% include 'inc/paginator.html' with paginator=consoleport_table.paginator page=consoleport_table.page %} {% table_config_form consoleport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index 61d3afd8b6d..4aa720e388b 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -44,6 +44,7 @@
    + {% include 'inc/paginator.html' with paginator=consoleserverport_table.paginator page=consoleserverport_table.page %} {% table_config_form consoleserverport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index d23f307e747..e09149f56a1 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -41,6 +41,7 @@
    + {% include 'inc/paginator.html' with paginator=devicebay_table.paginator page=devicebay_table.page %} {% table_config_form devicebay_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index 7084f592efc..ea5b1263567 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -44,6 +44,7 @@ + {% include 'inc/paginator.html' with paginator=frontport_table.paginator page=frontport_table.page %} {% table_config_form frontport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 34897a60159..83c2ae7ed18 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -47,6 +47,7 @@ + {% include 'inc/paginator.html' with paginator=interface_table.paginator page=interface_table.page %} {% table_config_form interface_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 5e52667cb06..14d687ee939 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -41,6 +41,7 @@ + {% include 'inc/paginator.html' with paginator=inventoryitem_table.paginator page=inventoryitem_table.page %} {% table_config_form inventoryitem_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index 8e0cd979001..6d1508fa99f 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -44,6 +44,7 @@ + {% include 'inc/paginator.html' with paginator=poweroutlet_table.paginator page=poweroutlet_table.page %} {% table_config_form poweroutlet_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index 9b56b64a3a9..8464a5fffd0 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -44,6 +44,7 @@ + {% include 'inc/paginator.html' with paginator=powerport_table.paginator page=powerport_table.page %} {% table_config_form powerport_table %} {% endblock %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index eeef667c2c6..b8db06fae37 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -44,6 +44,7 @@ + {% include 'inc/paginator.html' with paginator=rearport_table.paginator page=rearport_table.page %} {% table_config_form rearport_table %} {% endblock %} From 613e0d10b310a6bea714b12a660a5e6caae4b0c5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 Mar 2021 14:24:43 -0400 Subject: [PATCH 141/223] Add link to v2.11 release notes --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 3cc502c1d4d..a2fa4d4fb69 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,6 +77,7 @@ nav: - User Preferences: 'development/user-preferences.md' - Release Checklist: 'development/release-checklist.md' - Release Notes: + - Version 2.11: 'release-notes/version-2.11.md' - Version 2.10: 'release-notes/version-2.10.md' - Version 2.9: 'release-notes/version-2.9.md' - Version 2.8: 'release-notes/version-2.8.md' From d57222328ba4c22289e9cc0507307ce72324cdbb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Apr 2021 10:21:41 -0400 Subject: [PATCH 142/223] Rename Cloud to ProviderNetwork --- docs/core-functionality/circuits.md | 2 +- docs/models/circuits/circuittermination.md | 4 +- docs/models/circuits/cloud.md | 5 -- docs/models/circuits/providernetwork.md | 5 ++ docs/release-notes/version-2.11.md | 10 +-- netbox/circuits/api/nested_serializers.py | 8 +-- netbox/circuits/api/serializers.py | 16 ++--- netbox/circuits/api/urls.py | 4 +- netbox/circuits/api/views.py | 10 +-- netbox/circuits/filters.py | 20 +++--- netbox/circuits/forms.py | 38 ++++++------ ...{0027_cloud.py => 0027_providernetwork.py} | 18 +++--- .../0028_cache_circuit_terminations.py | 2 +- netbox/circuits/models.py | 36 ++++++----- netbox/circuits/tables.py | 8 +-- netbox/circuits/tests/test_api.py | 20 +++--- netbox/circuits/tests/test_filters.py | 62 +++++++++---------- netbox/circuits/tests/test_views.py | 22 +++---- netbox/circuits/urls.py | 22 +++---- netbox/circuits/views.py | 56 ++++++++--------- netbox/dcim/models/cables.py | 10 +-- netbox/dcim/tests/test_models.py | 8 +-- netbox/netbox/constants.py | 16 ++--- .../circuits/circuittermination_edit.html | 12 ++-- .../circuits/inc/circuit_termination.html | 4 +- .../{cloud.html => providernetwork.html} | 8 +-- netbox/templates/inc/nav_menu.html | 10 +-- 27 files changed, 219 insertions(+), 217 deletions(-) delete mode 100644 docs/models/circuits/cloud.md create mode 100644 docs/models/circuits/providernetwork.md rename netbox/circuits/migrations/{0027_cloud.py => 0027_providernetwork.py} (85%) rename netbox/templates/circuits/{cloud.html => providernetwork.html} (86%) diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md index 67388dba44f..51261858c3a 100644 --- a/docs/core-functionality/circuits.md +++ b/docs/core-functionality/circuits.md @@ -1,7 +1,7 @@ # Circuits {!docs/models/circuits/provider.md!} -{!docs/models/circuits/cloud.md!} +{!docs/models/circuits/providernetwork.md!} --- diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md index c1ec09cae3b..beea2f85adc 100644 --- a/docs/models/circuits/circuittermination.md +++ b/docs/models/circuits/circuittermination.md @@ -2,9 +2,9 @@ The association of a circuit with a particular site and/or device is modeled separately as a circuit termination. A circuit may have up to two terminations, labeled A and Z. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites. -Each circuit termination is attached to either a site or a cloud. Site terminations may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. +Each circuit termination is attached to either a site or to a provider network. Site terminations may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. In adherence with NetBox's philosophy of closely modeling the real world, a circuit may be connected only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely. !!! note - A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. The cloud model is ideal for representing these networks. + A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. The provider network model is ideal for representing these networks. diff --git a/docs/models/circuits/cloud.md b/docs/models/circuits/cloud.md deleted file mode 100644 index c4b3cec5e5f..00000000000 --- a/docs/models/circuits/cloud.md +++ /dev/null @@ -1,5 +0,0 @@ -# Clouds - -A cloud represents an abstract portion of network topology, just like in a topology diagram. For example, a cloud may be used to represent a provider's MPLS network. - -Each cloud must be assigned to a provider. A circuit may terminate to either a cloud or to a site. diff --git a/docs/models/circuits/providernetwork.md b/docs/models/circuits/providernetwork.md new file mode 100644 index 00000000000..970a9f8a812 --- /dev/null +++ b/docs/models/circuits/providernetwork.md @@ -0,0 +1,5 @@ +# Provider Network + +This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity. + +Each provider network must be assigned to a provider. A circuit may terminate to either a provider network or to a site. diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index f0a61bd0a41..2043b02e99d 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -78,9 +78,9 @@ This release introduces the new SiteGroup model, which can be used to organize s The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor. -#### Cloud Modeling for Circuits ([#5986](https://github.com/netbox-community/netbox/issues/5986)) +#### Provider Network Modeling ([#5986](https://github.com/netbox-community/netbox/issues/5986)) -A new Cloud model has been introduced to represent the boundary of a network that exists outside the scope of NetBox. This is analogous to using a cloud icon on a topology drawing to represent an abstracted network. Each cloud must be assigned to a provider, and circuits can terminate to either clouds or sites. The use of this model will likely be extended by future releases to support overlay and virtual circuit modeling. +A new provider network model has been introduced to represent the boundary of a network that exists outside the scope of NetBox. Each instance of this model must be assigned to a provider, and circuits can now terminate to either provider networks or to sites. The use of this model will likely be extended by future releases to support overlay and virtual circuit modeling. ### Enhancements @@ -130,9 +130,9 @@ A new Cloud model has been introduced to represent the boundary of a network tha * Renamed RackGroup to Location * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/` * circuits.CircuitTermination - * Added the `cloud` field -* circuits.Cloud - * Added the `/api/circuits/clouds/` endpoint + * Added the `provider_network` field +* circuits.ProviderNetwork + * Added the `/api/circuits/provider-networks/` endpoint * dcim.Device * Added the `location` field * dcim.Interface diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 0fd07d31b73..fccf4a8b6ad 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -7,17 +7,17 @@ __all__ = [ 'NestedCircuitSerializer', 'NestedCircuitTerminationSerializer', 'NestedCircuitTypeSerializer', - 'NestedCloudSerializer', + 'NestedProviderNetworkSerializer', 'NestedProviderSerializer', ] # -# Clouds +# Provider networks # -class NestedCloudSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:cloud-detail') +class NestedProviderNetworkSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') class Meta: model = Provider diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 5469049db87..794235deebe 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -29,15 +29,15 @@ class ProviderSerializer(PrimaryModelSerializer): # -# Clouds +# Provider networks # -class CloudSerializer(PrimaryModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:cloud-detail') +class ProviderNetworkSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') provider = NestedProviderSerializer() class Meta: - model = Cloud + model = ProviderNetwork fields = [ 'id', 'url', 'display', 'provider', 'name', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', @@ -63,12 +63,12 @@ class CircuitTypeSerializer(OrganizationalModelSerializer): class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') site = NestedSiteSerializer() - cloud = NestedCloudSerializer() + provider_network = NestedProviderNetworkSerializer() class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'display', 'site', 'cloud', 'port_speed', 'upstream_speed', 'xconnect_id', + 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', ] @@ -95,13 +95,13 @@ class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializ url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() site = NestedSiteSerializer(required=False) - cloud = NestedCloudSerializer(required=False) + provider_network = NestedProviderNetworkSerializer(required=False) cable = NestedCableSerializer(read_only=True) class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'cloud', 'port_speed', 'upstream_speed', + 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied', ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 4f31806bd9d..5389e0bdeb3 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -13,8 +13,8 @@ router.register('circuit-types', views.CircuitTypeViewSet) router.register('circuits', views.CircuitViewSet) router.register('circuit-terminations', views.CircuitTerminationViewSet) -# Clouds -router.register('clouds', views.CloudViewSet) +# Provider networks +router.register('provider-networks', views.ProviderNetworkViewSet) app_name = 'circuits-api' urlpatterns = router.urls diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 0adbfcb0e2d..83c4a8fa688 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -68,10 +68,10 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet): # -# Clouds +# Provider networks # -class CloudViewSet(CustomFieldModelViewSet): - queryset = Cloud.objects.prefetch_related('tags') - serializer_class = serializers.CloudSerializer - filterset_class = filters.CloudFilterSet +class ProviderNetworkViewSet(CustomFieldModelViewSet): + queryset = ProviderNetwork.objects.prefetch_related('tags') + serializer_class = serializers.ProviderNetworkSerializer + filterset_class = filters.ProviderNetworkFilterSet diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 0efd2f3314c..fa9f964c2c7 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -15,7 +15,7 @@ __all__ = ( 'CircuitFilterSet', 'CircuitTerminationFilterSet', 'CircuitTypeFilterSet', - 'CloudFilterSet', + 'ProviderNetworkFilterSet', 'ProviderFilterSet', ) @@ -80,7 +80,7 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated ) -class CloudFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): +class ProviderNetworkFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -98,7 +98,7 @@ class CloudFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFil tag = TagFilter() class Meta: - model = Cloud + model = ProviderNetwork fields = ['id', 'name'] def search(self, queryset, name, value): @@ -132,10 +132,10 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe to_field_name='slug', label='Provider (slug)', ) - cloud_id = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__cloud', - queryset=Cloud.objects.all(), - label='Cloud (ID)', + provider_network_id = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__provider_network', + queryset=ProviderNetwork.objects.all(), + label='ProviderNetwork (ID)', ) type_id = django_filters.ModelMultipleChoiceFilter( queryset=CircuitType.objects.all(), @@ -226,9 +226,9 @@ class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, Path to_field_name='slug', label='Site (slug)', ) - cloud_id = django_filters.ModelMultipleChoiceFilter( - queryset=Cloud.objects.all(), - label='Cloud (ID)', + provider_network_id = django_filters.ModelMultipleChoiceFilter( + queryset=ProviderNetwork.objects.all(), + label='ProviderNetwork (ID)', ) class Meta: diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index d818ec0f6e4..1b3eb324260 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -129,10 +129,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): # -# Clouds +# Provider networks # -class CloudForm(BootstrapMixin, CustomFieldModelForm): +class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all() ) @@ -143,16 +143,16 @@ class CloudForm(BootstrapMixin, CustomFieldModelForm): ) class Meta: - model = Cloud + model = ProviderNetwork fields = [ 'provider', 'name', 'description', 'comments', 'tags', ] fieldsets = ( - ('Cloud', ('provider', 'name', 'description', 'tags')), + ('Provider Network', ('provider', 'name', 'description', 'tags')), ) -class CloudCSVForm(CustomFieldModelCSVForm): +class ProviderNetworkCSVForm(CustomFieldModelCSVForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', @@ -160,15 +160,15 @@ class CloudCSVForm(CustomFieldModelCSVForm): ) class Meta: - model = Cloud + model = ProviderNetwork fields = [ 'provider', 'name', 'description', 'comments', ] -class CloudBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): +class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( - queryset=Cloud.objects.all(), + queryset=ProviderNetwork.objects.all(), widget=forms.MultipleHiddenInput ) provider = DynamicModelChoiceField( @@ -190,8 +190,8 @@ class CloudBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFo ] -class CloudFilterForm(BootstrapMixin, CustomFieldFilterForm): - model = Cloud +class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = ProviderNetwork field_order = ['q', 'provider_id'] q = forms.CharField( required=False, @@ -357,7 +357,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Circuit field_order = [ - 'q', 'type_id', 'provider_id', 'cloud_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', + 'q', 'type_id', 'provider_id', 'provider_network_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', 'commit_rate', ] q = forms.CharField( @@ -374,13 +374,13 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, label=_('Provider') ) - cloud_id = DynamicModelMultipleChoiceField( - queryset=Cloud.objects.all(), + provider_network_id = DynamicModelMultipleChoiceField( + queryset=ProviderNetwork.objects.all(), required=False, query_params={ 'provider_id': '$provider_id' }, - label=_('Cloud') + label=_('Provider network') ) status = forms.MultipleChoiceField( choices=CircuitStatusChoices, @@ -435,16 +435,16 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): }, required=False ) - cloud = DynamicModelChoiceField( - queryset=Cloud.objects.all(), + provider_network = DynamicModelChoiceField( + queryset=ProviderNetwork.objects.all(), required=False ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'region', 'site_group', 'site', 'cloud', 'mark_connected', 'port_speed', 'upstream_speed', - 'xconnect_id', 'pp_info', 'description', + 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed', + 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", @@ -460,4 +460,4 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['cloud'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) + self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) diff --git a/netbox/circuits/migrations/0027_cloud.py b/netbox/circuits/migrations/0027_providernetwork.py similarity index 85% rename from netbox/circuits/migrations/0027_cloud.py rename to netbox/circuits/migrations/0027_providernetwork.py index 893371f8f26..e8fbdb8d4d6 100644 --- a/netbox/circuits/migrations/0027_cloud.py +++ b/netbox/circuits/migrations/0027_providernetwork.py @@ -12,9 +12,9 @@ class Migration(migrations.Migration): ] operations = [ - # Create the new Cloud model + # Create the new ProviderNetwork model migrations.CreateModel( - name='Cloud', + name='ProviderNetwork', fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), @@ -23,7 +23,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=100)), ('description', models.CharField(blank=True, max_length=200)), ('comments', models.TextField(blank=True)), - ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='clouds', to='circuits.provider')), + ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='networks', to='circuits.provider')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ], options={ @@ -31,19 +31,19 @@ class Migration(migrations.Migration): }, ), migrations.AddConstraint( - model_name='cloud', - constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_cloud_provider_name'), + model_name='providernetwork', + constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_provider_name'), ), migrations.AlterUniqueTogether( - name='cloud', + name='providernetwork', unique_together={('provider', 'name')}, ), - # Add cloud FK to CircuitTermination + # Add ProviderNetwork FK to CircuitTermination migrations.AddField( model_name='circuittermination', - name='cloud', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='circuits.cloud'), + name='provider_network', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='circuits.providernetwork'), ), migrations.AlterField( model_name='circuittermination', diff --git a/netbox/circuits/migrations/0028_cache_circuit_terminations.py b/netbox/circuits/migrations/0028_cache_circuit_terminations.py index 49631da07b0..23734348eca 100644 --- a/netbox/circuits/migrations/0028_cache_circuit_terminations.py +++ b/netbox/circuits/migrations/0028_cache_circuit_terminations.py @@ -26,7 +26,7 @@ def cache_circuit_terminations(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('circuits', '0027_cloud'), + ('circuits', '0027_providernetwork'), ] operations = [ diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index b163834e6e1..10534d1cc17 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -15,7 +15,7 @@ __all__ = ( 'Circuit', 'CircuitTermination', 'CircuitType', - 'Cloud', + 'ProviderNetwork', 'Provider', ) @@ -93,18 +93,22 @@ class Provider(PrimaryModel): # -# Clouds +# Provider networks # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Cloud(PrimaryModel): +class ProviderNetwork(PrimaryModel): + """ + This represents a provider network which exists outside of NetBox, the details of which are unknown or + unimportant to the user. + """ name = models.CharField( max_length=100 ) provider = models.ForeignKey( to='circuits.Provider', on_delete=models.PROTECT, - related_name='clouds' + related_name='networks' ) description = models.CharField( max_length=200, @@ -125,7 +129,7 @@ class Cloud(PrimaryModel): constraints = ( models.UniqueConstraint( fields=('provider', 'name'), - name='circuits_cloud_provider_name' + name='circuits_providernetwork_provider_name' ), ) unique_together = ('provider', 'name') @@ -134,7 +138,7 @@ class Cloud(PrimaryModel): return self.name def get_absolute_url(self): - return reverse('circuits:cloud', args=[self.pk]) + return reverse('circuits:providernetwork', args=[self.pk]) def to_csv(self): return ( @@ -308,8 +312,8 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): blank=True, null=True ) - cloud = models.ForeignKey( - to=Cloud, + provider_network = models.ForeignKey( + to=ProviderNetwork, on_delete=models.PROTECT, related_name='circuit_terminations', blank=True, @@ -348,23 +352,21 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): unique_together = ['circuit', 'term_side'] def __str__(self): - if self.site: - return str(self.site) - return str(self.cloud) + return str(self.site or self.provider_network) def get_absolute_url(self): if self.site: return self.site.get_absolute_url() - return self.cloud.get_absolute_url() + return self.provider_network.get_absolute_url() def clean(self): super().clean() - # Must define either site *or* cloud - if self.site is None and self.cloud is None: - raise ValidationError("A circuit termination must attach to either a site or a cloud.") - if self.site and self.cloud: - raise ValidationError("A circuit termination cannot attach to both a site and a cloud.") + # Must define either site *or* provider network + if self.site is None and self.provider_network is None: + raise ValidationError("A circuit termination must attach to either a site or a provider network.") + if self.site and self.provider_network: + raise ValidationError("A circuit termination cannot attach to both a site and a provider network.") def to_objectchange(self, action): # Annotate the parent Circuit diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index ba113de8c9c..32689741565 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -30,10 +30,10 @@ class ProviderTable(BaseTable): # -# Clouds +# Provider networks # -class CloudTable(BaseTable): +class ProviderNetworkTable(BaseTable): pk = ToggleColumn() name = tables.Column( linkify=True @@ -42,11 +42,11 @@ class CloudTable(BaseTable): linkify=True ) tags = TagColumn( - url_name='circuits:cloud_list' + url_name='circuits:providernetwork_list' ) class Meta(BaseTable.Meta): - model = Cloud + model = ProviderNetwork fields = ('pk', 'name', 'provider', 'description', 'tags') default_columns = ('pk', 'name', 'provider', 'description') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 01e228f761f..424b13d4093 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -180,8 +180,8 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): } -class CloudTest(APIViewTestCases.APIViewTestCase): - model = Cloud +class ProviderNetworkTest(APIViewTestCases.APIViewTestCase): + model = ProviderNetwork brief_fields = ['display', 'id', 'name', 'url'] @classmethod @@ -192,24 +192,24 @@ class CloudTest(APIViewTestCases.APIViewTestCase): ) Provider.objects.bulk_create(providers) - clouds = ( - Cloud(name='Cloud 1', provider=providers[0]), - Cloud(name='Cloud 2', provider=providers[0]), - Cloud(name='Cloud 3', provider=providers[0]), + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[0]), + ProviderNetwork(name='Provider Network 3', provider=providers[0]), ) - Cloud.objects.bulk_create(clouds) + ProviderNetwork.objects.bulk_create(provider_networks) cls.create_data = [ { - 'name': 'Cloud 4', + 'name': 'Provider Network 4', 'provider': providers[0].pk, }, { - 'name': 'Cloud 5', + 'name': 'Provider Network 5', 'provider': providers[0].pk, }, { - 'name': 'Cloud 6', + 'name': 'Provider Network 6', 'provider': providers[0].pk, }, ] diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index 880139baf7f..dca6b317da0 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -186,12 +186,12 @@ class CircuitTestCase(TestCase): ) Provider.objects.bulk_create(providers) - clouds = ( - Cloud(name='Cloud 1', provider=providers[1]), - Cloud(name='Cloud 2', provider=providers[1]), - Cloud(name='Cloud 3', provider=providers[1]), + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[1]), + ProviderNetwork(name='Provider Network 2', provider=providers[1]), + ProviderNetwork(name='Provider Network 3', provider=providers[1]), ) - Cloud.objects.bulk_create(clouds) + ProviderNetwork.objects.bulk_create(provider_networks) circuits = ( Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE), @@ -207,9 +207,9 @@ class CircuitTestCase(TestCase): CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), - CircuitTermination(circuit=circuits[3], cloud=clouds[0], term_side='A'), - CircuitTermination(circuit=circuits[4], cloud=clouds[1], term_side='A'), - CircuitTermination(circuit=circuits[5], cloud=clouds[2], term_side='A'), + CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), + CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), + CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -236,9 +236,9 @@ class CircuitTestCase(TestCase): params = {'provider': [provider.slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - def test_cloud(self): - clouds = Cloud.objects.all()[:2] - params = {'cloud_id': [clouds[0].pk, clouds[1].pk]} + def test_provider_network(self): + provider_networks = ProviderNetwork.objects.all()[:2] + params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): @@ -312,12 +312,12 @@ class CircuitTerminationTestCase(TestCase): ) Provider.objects.bulk_create(providers) - clouds = ( - Cloud(name='Cloud 1', provider=providers[0]), - Cloud(name='Cloud 2', provider=providers[0]), - Cloud(name='Cloud 3', provider=providers[0]), + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[0]), + ProviderNetwork(name='Provider Network 3', provider=providers[0]), ) - Cloud.objects.bulk_create(clouds) + ProviderNetwork.objects.bulk_create(provider_networks) circuits = ( Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'), @@ -336,9 +336,9 @@ class CircuitTerminationTestCase(TestCase): CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'), - CircuitTermination(circuit=circuits[3], cloud=clouds[0], term_side='A'), - CircuitTermination(circuit=circuits[4], cloud=clouds[1], term_side='A'), - CircuitTermination(circuit=circuits[5], cloud=clouds[2], term_side='A'), + CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), + CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), + CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -372,9 +372,9 @@ class CircuitTerminationTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_cloud(self): - clouds = Cloud.objects.all()[:2] - params = {'cloud_id': [clouds[0].pk, clouds[1].pk]} + def test_provider_network(self): + provider_networks = ProviderNetwork.objects.all()[:2] + params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): @@ -388,9 +388,9 @@ class CircuitTerminationTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) -class CloudTestCase(TestCase): - queryset = Cloud.objects.all() - filterset = CloudFilterSet +class ProviderNetworkTestCase(TestCase): + queryset = ProviderNetwork.objects.all() + filterset = ProviderNetworkFilterSet @classmethod def setUpTestData(cls): @@ -402,19 +402,19 @@ class CloudTestCase(TestCase): ) Provider.objects.bulk_create(providers) - clouds = ( - Cloud(name='Cloud 1', provider=providers[0]), - Cloud(name='Cloud 2', provider=providers[1]), - Cloud(name='Cloud 3', provider=providers[2]), + provider_networks = ( + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[1]), + ProviderNetwork(name='Provider Network 3', provider=providers[2]), ) - Cloud.objects.bulk_create(clouds) + ProviderNetwork.objects.bulk_create(provider_networks) def test_id(self): params = {'id': self.queryset.values_list('pk', flat=True)[:2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_name(self): - params = {'name': ['Cloud 1', 'Cloud 2']} + params = {'name': ['Provider Network 1', 'Provider Network 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_provider(self): diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index ba2d4fc220d..20f7b4d8382 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -135,8 +135,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class CloudTestCase(ViewTestCases.PrimaryObjectViewTestCase): - model = Cloud +class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ProviderNetwork @classmethod def setUpTestData(cls): @@ -147,27 +147,27 @@ class CloudTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Provider.objects.bulk_create(providers) - Cloud.objects.bulk_create([ - Cloud(name='Cloud 1', provider=providers[0]), - Cloud(name='Cloud 2', provider=providers[0]), - Cloud(name='Cloud 3', provider=providers[0]), + ProviderNetwork.objects.bulk_create([ + ProviderNetwork(name='Provider Network 1', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[0]), + ProviderNetwork(name='Provider Network 3', provider=providers[0]), ]) tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { - 'name': 'Cloud X', + 'name': 'Provider Network X', 'provider': providers[1].pk, - 'description': 'A new cloud', + 'description': 'A new provider network', 'comments': 'Longer description goes here', 'tags': [t.pk for t in tags], } cls.csv_data = ( "name,provider,description", - "Cloud 4,Provider 1,Foo", - "Cloud 5,Provider 1,Bar", - "Cloud 6,Provider 1,Baz", + "Provider Network 4,Provider 1,Foo", + "Provider Network 5,Provider 1,Bar", + "Provider Network 6,Provider 1,Baz", ) cls.bulk_edit_data = { diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 58f52bb42b5..e634eeeb4ac 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -20,17 +20,17 @@ urlpatterns = [ path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), path('providers//journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}), - # Clouds - path('clouds/', views.CloudListView.as_view(), name='cloud_list'), - path('clouds/add/', views.CloudEditView.as_view(), name='cloud_add'), - path('clouds/import/', views.CloudBulkImportView.as_view(), name='cloud_import'), - path('clouds/edit/', views.CloudBulkEditView.as_view(), name='cloud_bulk_edit'), - path('clouds/delete/', views.CloudBulkDeleteView.as_view(), name='cloud_bulk_delete'), - path('clouds//', views.CloudView.as_view(), name='cloud'), - path('clouds//edit/', views.CloudEditView.as_view(), name='cloud_edit'), - path('clouds//delete/', views.CloudDeleteView.as_view(), name='cloud_delete'), - path('clouds//changelog/', ObjectChangeLogView.as_view(), name='cloud_changelog', kwargs={'model': Cloud}), - path('clouds//journal/', ObjectJournalView.as_view(), name='cloud_journal', kwargs={'model': Cloud}), + # Provider networks + path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'), + path('provider-networks/add/', views.ProviderNetworkEditView.as_view(), name='providernetwork_add'), + path('provider-networks/import/', views.ProviderNetworkBulkImportView.as_view(), name='providernetwork_import'), + path('provider-networks/edit/', views.ProviderNetworkBulkEditView.as_view(), name='providernetwork_bulk_edit'), + path('provider-networks/delete/', views.ProviderNetworkBulkDeleteView.as_view(), name='providernetwork_bulk_delete'), + path('provider-networks//', views.ProviderNetworkView.as_view(), name='providernetwork'), + path('provider-networks//edit/', views.ProviderNetworkEditView.as_view(), name='providernetwork_edit'), + path('provider-networks//delete/', views.ProviderNetworkDeleteView.as_view(), name='providernetwork_delete'), + path('provider-networks//changelog/', ObjectChangeLogView.as_view(), name='providernetwork_changelog', kwargs={'model': ProviderNetwork}), + path('provider-networks//journal/', ObjectJournalView.as_view(), name='providernetwork_journal', kwargs={'model': ProviderNetwork}), # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 6f3c5b2be82..f0aefd346ed 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -77,23 +77,23 @@ class ProviderBulkDeleteView(generic.BulkDeleteView): # -# Clouds +# Provider networks # -class CloudListView(generic.ObjectListView): - queryset = Cloud.objects.all() - filterset = filters.CloudFilterSet - filterset_form = forms.CloudFilterForm - table = tables.CloudTable +class ProviderNetworkListView(generic.ObjectListView): + queryset = ProviderNetwork.objects.all() + filterset = filters.ProviderNetworkFilterSet + filterset_form = forms.ProviderNetworkFilterForm + table = tables.ProviderNetworkTable -class CloudView(generic.ObjectView): - queryset = Cloud.objects.all() +class ProviderNetworkView(generic.ObjectView): + queryset = ProviderNetwork.objects.all() def get_extra_context(self, request, instance): circuits = Circuit.objects.restrict(request.user, 'view').filter( - Q(termination_a__cloud=instance.pk) | - Q(termination_z__cloud=instance.pk) + Q(termination_a__provider_network=instance.pk) | + Q(termination_z__provider_network=instance.pk) ).prefetch_related( 'type', 'tenant', 'terminations__site' ) @@ -108,32 +108,32 @@ class CloudView(generic.ObjectView): } -class CloudEditView(generic.ObjectEditView): - queryset = Cloud.objects.all() - model_form = forms.CloudForm +class ProviderNetworkEditView(generic.ObjectEditView): + queryset = ProviderNetwork.objects.all() + model_form = forms.ProviderNetworkForm -class CloudDeleteView(generic.ObjectDeleteView): - queryset = Cloud.objects.all() +class ProviderNetworkDeleteView(generic.ObjectDeleteView): + queryset = ProviderNetwork.objects.all() -class CloudBulkImportView(generic.BulkImportView): - queryset = Cloud.objects.all() - model_form = forms.CloudCSVForm - table = tables.CloudTable +class ProviderNetworkBulkImportView(generic.BulkImportView): + queryset = ProviderNetwork.objects.all() + model_form = forms.ProviderNetworkCSVForm + table = tables.ProviderNetworkTable -class CloudBulkEditView(generic.BulkEditView): - queryset = Cloud.objects.all() - filterset = filters.CloudFilterSet - table = tables.CloudTable - form = forms.CloudBulkEditForm +class ProviderNetworkBulkEditView(generic.BulkEditView): + queryset = ProviderNetwork.objects.all() + filterset = filters.ProviderNetworkFilterSet + table = tables.ProviderNetworkTable + form = forms.ProviderNetworkBulkEditForm -class CloudBulkDeleteView(generic.BulkDeleteView): - queryset = Cloud.objects.all() - filterset = filters.CloudFilterSet - table = tables.CloudTable +class ProviderNetworkBulkDeleteView(generic.BulkDeleteView): + queryset = ProviderNetwork.objects.all() + filterset = filters.ProviderNetworkFilterSet + table = tables.ProviderNetworkTable # diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 9e5aea7253c..b20fc7080a5 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -242,14 +242,14 @@ class Cable(PrimaryModel): ): raise ValidationError("A front port cannot be connected to it corresponding rear port") - # A CircuitTermination attached to a Cloud cannot have a Cable - if isinstance(self.termination_a, CircuitTermination) and self.termination_a.cloud is not None: + # A CircuitTermination attached to a ProviderNetwork cannot have a Cable + if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None: raise ValidationError({ - 'termination_a_id': "Circuit terminations attached to a cloud may not be cabled." + 'termination_a_id': "Circuit terminations attached to a provider network may not be cabled." }) - if isinstance(self.termination_b, CircuitTermination) and self.termination_b.cloud is not None: + if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None: raise ValidationError({ - 'termination_b_id': "Circuit terminations attached to a cloud may not be cabled." + 'termination_b_id': "Circuit terminations attached to a provider network may not be cabled." }) # Check for an existing Cable connected to either termination object diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index b4454aa8af2..ae280365eac 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -479,13 +479,13 @@ class CableTestCase(TestCase): device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 ) self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') - cloud = Cloud.objects.create(name='Cloud 1', provider=self.provider) + provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.provider) self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2') self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A') self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z') - self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, cloud=cloud, term_side='A') + self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, provider_network=provider_network, term_side='A') def test_cable_creation(self): """ @@ -555,9 +555,9 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() - def test_cable_cannot_terminate_to_a_cloud_circuittermination(self): + def test_cable_cannot_terminate_to_a_provider_network_circuittermination(self): """ - Neither side of a cable can be terminated to a CircuitTermination which is attached to a Cloud + Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork """ cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3) with self.assertRaises(ValidationError): diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 797a11965e2..5568f4e7032 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,8 +1,8 @@ from collections import OrderedDict -from circuits.filters import CircuitFilterSet, CloudFilterSet, ProviderFilterSet -from circuits.models import Circuit, Cloud, Provider -from circuits.tables import CircuitTable, CloudTable, ProviderTable +from circuits.filters import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet +from circuits.models import Circuit, ProviderNetwork, Provider +from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable from dcim.filters import ( CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet, SiteFilterSet, VirtualChassisFilterSet, @@ -45,11 +45,11 @@ SEARCH_TYPES = OrderedDict(( 'table': CircuitTable, 'url': 'circuits:circuit_list', }), - ('cloud', { - 'queryset': Cloud.objects.prefetch_related('provider'), - 'filterset': CloudFilterSet, - 'table': CloudTable, - 'url': 'circuits:cloud_list', + ('providernetwork', { + 'queryset': ProviderNetwork.objects.prefetch_related('provider'), + 'filterset': ProviderNetworkFilterSet, + 'table': ProviderNetworkTable, + 'url': 'circuits:providernetwork_list', }), # DCIM ('site', { diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index ebad75976e0..4034695d5c5 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -26,19 +26,19 @@

    {{ form.term_side.value }}

    - {% with cloud_tab_active=form.initial.cloud %} + {% with providernetwork_tab_active=form.initial.provider_network %}
    -
    +
    {% render_field form.region %} {% render_field form.site_group %} {% render_field form.site %}
    -
    - {% render_field form.cloud %} +
    + {% render_field form.provider_network %}
    {% endwith %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index acfc4ee22d7..5a5a4788df5 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -85,9 +85,9 @@ {% else %} - Cloud + Provider Network - {{ termination.cloud }} + {{ termination.provider_network }} {% endif %} diff --git a/netbox/templates/circuits/cloud.html b/netbox/templates/circuits/providernetwork.html similarity index 86% rename from netbox/templates/circuits/cloud.html rename to netbox/templates/circuits/providernetwork.html index 532118bf866..5bc3ef271d4 100644 --- a/netbox/templates/circuits/cloud.html +++ b/netbox/templates/circuits/providernetwork.html @@ -4,8 +4,8 @@ {% load plugins %} {% block breadcrumbs %} -
  • Clouds
  • -
  • {{ object.provider }}
  • +
  • Provider Networks
  • +
  • {{ object.provider }}
  • {{ object }}
  • {% endblock %} @@ -14,7 +14,7 @@
    - Cloud + Provider Network
    @@ -46,7 +46,7 @@ {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:cloud_list' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:providernetwork_list' %} {% plugin_left_page object %}
    diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index fa44da4176e..0ac1ce3931b 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -465,14 +465,14 @@
    {% endif %} Providers - - {% if perms.circuits.add_cloud %} + + {% if perms.circuits.add_providernetwork %}
    - - + +
    {% endif %} - Clouds + Provider Networks From 96759af86f8a74580626de18f7c5250bc27002ad Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Apr 2021 14:31:10 -0400 Subject: [PATCH 143/223] Closes #6071: Cable traces now traverse circuits --- docs/release-notes/version-2.11.md | 2 + netbox/circuits/api/serializers.py | 7 +- netbox/circuits/api/views.py | 3 +- netbox/circuits/filters.py | 2 +- .../migrations/0029_circuit_tracing.py | 32 +++ netbox/circuits/models.py | 2 +- netbox/circuits/tests/test_filters.py | 6 - netbox/circuits/views.py | 4 - .../dcim/management/commands/trace_paths.py | 2 - netbox/dcim/models/cables.py | 38 ++- netbox/dcim/models/device_components.py | 9 +- netbox/dcim/tables/template_code.py | 6 +- netbox/dcim/tests/test_cablepaths.py | 241 +++++++++++++++--- .../circuits/inc/circuit_termination.html | 15 -- netbox/templates/dcim/cable_trace.html | 4 + netbox/templates/dcim/trace/attachment.html | 5 + netbox/templates/dcim/trace/object.html | 3 + 17 files changed, 302 insertions(+), 79 deletions(-) create mode 100644 netbox/circuits/migrations/0029_circuit_tracing.py create mode 100644 netbox/templates/dcim/trace/attachment.html create mode 100644 netbox/templates/dcim/trace/object.html diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 2043b02e99d..9fa97cf96a0 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -112,6 +112,7 @@ A new provider network model has been introduced to represent the boundary of a * [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields * [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead) * [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved the virtual machine interfaces list to a separate view +* [#6071](https://github.com/netbox-community/netbox/issues/6071) - Cable traces now traverse circuits ### REST API Changes @@ -131,6 +132,7 @@ A new provider network model has been introduced to represent the boundary of a * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/` * circuits.CircuitTermination * Added the `provider_network` field + * Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields * circuits.ProviderNetwork * Added the `/api/circuits/provider-networks/` endpoint * dcim.Device diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 794235deebe..014ec0fc851 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -60,7 +60,7 @@ class CircuitTypeSerializer(OrganizationalModelSerializer): ] -class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer): +class CircuitCircuitTerminationSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') site = NestedSiteSerializer() provider_network = NestedProviderNetworkSerializer() @@ -69,7 +69,6 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEnd model = CircuitTermination fields = [ 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', ] @@ -91,7 +90,7 @@ class CircuitSerializer(PrimaryModelSerializer): ] -class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() site = NestedSiteSerializer(required=False) @@ -103,5 +102,5 @@ class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializ fields = [ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied', + '_occupied', ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 83c4a8fa688..c037bc5fd10 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,4 +1,3 @@ -from django.db.models import Prefetch from rest_framework.routers import APIRootView from circuits import filters @@ -60,7 +59,7 @@ class CircuitViewSet(CustomFieldModelViewSet): class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet): queryset = CircuitTermination.objects.prefetch_related( - 'circuit', 'site', '_path__destination', 'cable' + 'circuit', 'site', 'provider_network', 'cable' ) serializer_class = serializers.CircuitTerminationSerializer filterset_class = filters.CircuitTerminationFilterSet diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index fa9f964c2c7..f5d81c7bddc 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -207,7 +207,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe ).distinct() -class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/circuits/migrations/0029_circuit_tracing.py b/netbox/circuits/migrations/0029_circuit_tracing.py new file mode 100644 index 00000000000..bddb38bb622 --- /dev/null +++ b/netbox/circuits/migrations/0029_circuit_tracing.py @@ -0,0 +1,32 @@ +from django.db import migrations +from django.db.models import Q + + +def delete_obsolete_cablepaths(apps, schema_editor): + """ + Delete all CablePath instances which originate or terminate at a CircuitTermination. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + CablePath = apps.get_model('dcim', 'CablePath') + + ct = ContentType.objects.get_for_model(CircuitTermination) + CablePath.objects.filter(Q(origin_type=ct) | Q(destination_type=ct)).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0028_cache_circuit_terminations'), + ] + + operations = [ + migrations.RemoveField( + model_name='circuittermination', + name='_path', + ), + migrations.RunPython( + code=delete_obsolete_cablepaths, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 10534d1cc17..d838d93d2a7 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -294,7 +294,7 @@ class Circuit(PrimaryModel): @extras_features('webhooks') -class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): +class CircuitTermination(ChangeLoggedModel, CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index dca6b317da0..448e4236864 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -381,12 +381,6 @@ class CircuitTerminationTestCase(TestCase): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) - class ProviderNetworkTestCase(TestCase): queryset = ProviderNetwork.objects.all() diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index f0aefd346ed..92e53c30fce 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -219,8 +219,6 @@ class CircuitView(generic.ObjectView): ).filter( circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A ).first() - if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'): - termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') # Z-side termination termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( @@ -228,8 +226,6 @@ class CircuitView(generic.ObjectView): ).filter( circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z ).first() - if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'): - termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view') return { 'termination_a': termination_a, diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index 06b5bdec030..fd5f9cfab4f 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -2,12 +2,10 @@ from django.core.management.base import BaseCommand from django.core.management.color import no_style from django.db import connection -from circuits.models import CircuitTermination from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort from dcim.signals import create_cablepath ENDPOINT_MODELS = ( - CircuitTermination, ConsolePort, ConsoleServerPort, Interface, diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index b20fc7080a5..806b740548f 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -394,6 +394,8 @@ class CablePath(BigIDModel): """ Create a new CablePath instance as traced from the given path origin. """ + from circuits.models import CircuitTermination + if origin is None or origin.cable is None: return None @@ -441,6 +443,23 @@ class CablePath(BigIDModel): # No corresponding FrontPort found for the RearPort break + # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) + elif isinstance(peer_termination, CircuitTermination): + path.append(object_to_path_node(peer_termination)) + # Get peer CircuitTermination + node = peer_termination.get_peer_termination() + if node: + path.append(object_to_path_node(node)) + if node.provider_network: + destination = node.provider_network + break + elif node.site and not node.cable: + destination = node.site + break + else: + # No peer CircuitTermination exists; halt the trace + break + # Anything else marks the end of the path else: destination = peer_termination @@ -486,15 +505,26 @@ class CablePath(BigIDModel): return path + def get_cable_ids(self): + """ + Return all Cable IDs within the path. + """ + cable_ct = ContentType.objects.get_for_model(Cable).pk + cable_ids = [] + + for node in self.path: + ct, id = decompile_path_node(node) + if ct == cable_ct: + cable_ids.append(id) + + return cable_ids + def get_total_length(self): """ Return a tuple containing the sum of the length of each cable in the path and a flag indicating whether the length is definitive. """ - cable_ids = [ - # Starting from the first element, every third element in the path should be a Cable - decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3) - ] + cable_ids = self.get_cable_ids() cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False) total_length = cables.aggregate(total=Sum('_abs_length'))['total'] is_definitive = len(cables) == len(cable_ids) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 1f2911d2d84..a7357ae79bd 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -160,7 +160,7 @@ class CableTermination(models.Model): class PathEndpoint(models.Model): """ An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically, - these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, PowerFeed, and CircuitTermination. + these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed. `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the @@ -184,10 +184,11 @@ class PathEndpoint(models.Model): # Construct the complete path path = [self, *self._path.get_path()] - while (len(path) + 1) % 3: + if self._path.destination: + path.append(self._path.destination) + while len(path) % 3: # Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort) - path.append(None) - path.append(self._path.destination) + path.insert(-1, None) # Return the path as a list of three-tuples (A termination, cable, B termination) return list(zip(*[iter(path)] * 3)) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 4324f802fa6..2582a7117c3 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,10 +1,12 @@ CABLETERMINATION = """ {% if value %} + {% if value.parent_object %} {{ value.parent_object }} - {{ value }} + {% endif %} + {{ value }} {% else %} - — + — {% endif %} """ diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 37d7014f1fb..4a6c6639fe6 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -229,40 +229,6 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_105_interface_to_circuittermination(self): - """ - [IF1] --C1-- [CT1A] - """ - interface1 = Interface.objects.create(device=self.device, name='Interface 1') - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') - - # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) - cable1.save() - path1 = self.assertPathExists( - origin=interface1, - destination=circuittermination1, - path=(cable1,), - is_active=True - ) - path2 = self.assertPathExists( - origin=circuittermination1, - destination=interface1, - path=(cable1,), - is_active=True - ) - self.assertEqual(CablePath.objects.count(), 2) - interface1.refresh_from_db() - circuittermination1.refresh_from_db() - self.assertPathIsSet(interface1, path1) - self.assertPathIsSet(circuittermination1, path2) - - # Delete cable 1 - cable1.delete() - - # Check that all CablePaths have been deleted - self.assertEqual(CablePath.objects.count(), 0) - def test_201_single_path_via_pass_through(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] @@ -820,6 +786,213 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 1) + def test_208_circuittermination(self): + """ + [IF1] --C1-- [CT1] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + + # Check for incomplete path + self.assertPathExists( + origin=interface1, + destination=None, + path=(cable1, circuittermination1), + is_active=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Delete cable 1 + cable1.delete() + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + self.assertPathIsNotSet(interface1) + + def test_209_circuit_to_interface(self): + """ + [IF1] --C1-- [CT1] [CT2] --C2-- [IF2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + + # Create cables + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + cable2 = Cable(termination_a=circuittermination2, termination_b=interface2) + cable2.save() + + # Check for paths + self.assertPathExists( + origin=interface1, + destination=interface2, + path=(cable1, circuittermination1, circuittermination2, cable2), + is_active=True + ) + self.assertPathExists( + origin=interface2, + destination=interface1, + path=(cable2, circuittermination2, circuittermination1, cable1), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + path1 = self.assertPathExists( + origin=interface1, + destination=self.site, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsNotSet(interface2) + + def test_210_circuit_to_site(self): + """ + [IF1] --C1-- [CT1] [CT2] --> [Site2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + site2 = Site.objects.create(name='Site 2', slug='site-2') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + self.assertPathExists( + origin=interface1, + destination=site2, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Delete cable 1 + cable1.delete() + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + self.assertPathIsNotSet(interface1) + + def test_211_circuit_to_providernetwork(self): + """ + [IF1] --C1-- [CT1] [CT2] --> [PN1] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider) + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z') + + # Create cable 1 + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + self.assertPathExists( + origin=interface1, + destination=providernetwork, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Delete cable 1 + cable1.delete() + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + self.assertPathIsNotSet(interface1) + + def test_212_multiple_paths_via_circuit(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [CT1] [CT2] --C4-- [RP2] [FP2:1] --C5-- [IF3] + [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport2_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1 + ) + frontport2_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 + ) + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + + # Create cables + cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1 + cable1.save() + cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2 + cable2.save() + cable3 = Cable(termination_a=rearport1, termination_b=circuittermination1) # RP1 -> CT1 + cable3.save() + cable4 = Cable(termination_a=rearport2, termination_b=circuittermination2) # RP2 -> CT2 + cable4.save() + cable5 = Cable(termination_a=interface3, termination_b=frontport2_1) # IF3 -> FP2:1 + cable5.save() + cable6 = Cable(termination_a=interface4, termination_b=frontport2_2) # IF4 -> FP2:2 + cable6.save() + self.assertPathExists( + origin=interface1, + destination=interface3, + path=( + cable1, frontport1_1, rearport1, cable3, circuittermination1, circuittermination2, + cable4, rearport2, frontport2_1, cable5 + ), + is_active=True + ) + self.assertPathExists( + origin=interface2, + destination=interface4, + path=( + cable2, frontport1_2, rearport1, cable3, circuittermination1, circuittermination2, + cable4, rearport2, frontport2_2, cable6 + ), + is_active=True + ) + self.assertPathExists( + origin=interface3, + destination=interface1, + path=( + cable5, frontport2_1, rearport2, cable4, circuittermination2, circuittermination1, + cable3, rearport1, frontport1_1, cable1 + ), + is_active=True + ) + self.assertPathExists( + origin=interface4, + destination=interface2, + path=( + cable6, frontport2_2, rearport2, cable4, circuittermination2, circuittermination1, + cable3, rearport1, frontport1_2, cable2 + ), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cables 3-4 + cable3.delete() + cable4.delete() + + # Check for four partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) + self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2] diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 5a5a4788df5..6dc079f0fef 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -104,21 +104,6 @@ {% endif %}
    - - - - diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index db134889fce..b4e3e8d4344 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -27,6 +27,8 @@ {# Cable #} {% if cable %} {% include 'dcim/trace/cable.html' %} + {% elif far_end %} + {% include 'dcim/trace/attachment.html' %} {% endif %} {# Far end #} @@ -43,6 +45,8 @@ {% if forloop.last %} {% include 'dcim/trace/circuit.html' with circuit=far_end.circuit %} {% endif %} + {% elif far_end %} + {% include 'dcim/trace/object.html' with object=far_end %} {% endif %} {% if forloop.last %} diff --git a/netbox/templates/dcim/trace/attachment.html b/netbox/templates/dcim/trace/attachment.html new file mode 100644 index 00000000000..450d74bc830 --- /dev/null +++ b/netbox/templates/dcim/trace/attachment.html @@ -0,0 +1,5 @@ +{% load helpers %} + +
    + Attachment +
    diff --git a/netbox/templates/dcim/trace/object.html b/netbox/templates/dcim/trace/object.html new file mode 100644 index 00000000000..72e5b578712 --- /dev/null +++ b/netbox/templates/dcim/trace/object.html @@ -0,0 +1,3 @@ + From cd64fcac8dd5f9755beef199f0f7bf40fba55201 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Apr 2021 17:21:04 -0400 Subject: [PATCH 144/223] Linkify connected object --- netbox/templates/circuits/inc/circuit_termination.html | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 6dc079f0fef..2832f5cc11d 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -55,13 +55,8 @@ {% with peer=termination.get_cable_peer %} - to - {% if peer.device %} - {{ peer.device }} - {% elif peer.circuit %} - {{ peer.circuit }} - {% endif %} - ({{ peer }}) + to {{ peer.parent_object }} + / {% if peer.get_absolute_url %}{{ peer }}{% else %}{{ peer }}{% endif %} {% endwith %} {% else %} {% if perms.dcim.add_cable %} From 8713ed5d7365a4d42bb330f038942cf465d5e4c7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Apr 2021 20:38:36 -0400 Subject: [PATCH 145/223] Add test for trace through multiple circuits --- netbox/dcim/tests/test_cablepaths.py | 61 ++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 4a6c6639fe6..56134f4d320 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -993,6 +993,67 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + def test_213_multiple_circuits_to_interface(self): + """ + [IF1] --C1-- [CT1] [CT2] --C2-- [CT3] [CT4] --C3-- [IF2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + circuit2 = Circuit.objects.create(provider=self.circuit.provider, type=self.circuit.type, cid='Circuit 2') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='A') + circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='Z') + + # Create cables + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1.save() + cable2 = Cable(termination_a=circuittermination2, termination_b=circuittermination3) + cable2.save() + cable3 = Cable(termination_a=circuittermination4, termination_b=interface2) + cable3.save() + + # Check for paths + self.assertPathExists( + origin=interface1, + destination=interface2, + path=( + cable1, circuittermination1, circuittermination2, cable2, circuittermination3, circuittermination4, + cable3 + ), + is_active=True + ) + self.assertPathExists( + origin=interface2, + destination=interface1, + path=( + cable3, circuittermination4, circuittermination3, cable2, circuittermination2, circuittermination1, + cable1 + ), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + path1 = self.assertPathExists( + origin=interface1, + destination=self.site, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + path2 = self.assertPathExists( + origin=interface2, + destination=self.site, + path=(cable3, circuittermination4, circuittermination3), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2] From e7f10fdaea8014d9ab476d17926278b174d4a042 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Apr 2021 21:03:00 -0400 Subject: [PATCH 146/223] Include termination side in CircuitTermination string repr --- netbox/circuits/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index d838d93d2a7..b2ffb3c09e2 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -352,7 +352,7 @@ class CircuitTermination(ChangeLoggedModel, CableTermination): unique_together = ['circuit', 'term_side'] def __str__(self): - return str(self.site or self.provider_network) + return f'Termination {self.term_side}: {self.site or self.provider_network}' def get_absolute_url(self): if self.site: From 5e4432b9ad3885f1f8b69c08f6cabfcafcab4794 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Apr 2021 21:29:46 -0400 Subject: [PATCH 147/223] Show the last node in a CablePath with no destination --- netbox/dcim/models/cables.py | 7 +++++++ netbox/dcim/tables/devices.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 806b740548f..28d21ff68fc 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -505,6 +505,13 @@ class CablePath(BigIDModel): return path + @property + def last_node(self): + """ + Return either the destination or the last node within the path. + """ + return self.destination or path_node_to_object(self.path[-1]) + def get_cable_ids(self): """ Return all Cable IDs within the path. diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 122eb748981..4aac227e918 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -260,7 +260,7 @@ class CableTerminationTable(BaseTable): class PathEndpointTable(CableTerminationTable): connection = tables.TemplateColumn( - accessor='_path.destination', + accessor='_path.last_node', template_code=CABLETERMINATION, verbose_name='Connection', orderable=False From b77c228853bb34c1a10b261c59eee8ff643bedb8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Apr 2021 09:17:11 -0400 Subject: [PATCH 148/223] Rebuild CablePaths when a CircuitTermination is modified --- netbox/circuits/signals.py | 14 +++++++++++++- netbox/dcim/tests/test_cablepaths.py | 26 +++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 7c9832d5b0a..0a000fb2e9a 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -1,7 +1,8 @@ -from django.db.models.signals import post_save +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.utils import timezone +from dcim.signals import rebuild_paths from .models import Circuit, CircuitTermination @@ -15,3 +16,14 @@ def update_circuit(instance, **kwargs): f'termination_{instance.term_side.lower()}': instance.pk, } Circuit.objects.filter(pk=instance.circuit_id).update(**fields) + + +@receiver((post_save, post_delete), sender=CircuitTermination) +def rebuild_cablepaths(instance, raw=False, **kwargs): + """ + Rebuild any CablePaths which traverse the peer CircuitTermination. + """ + if not raw: + peer_termination = instance.get_peer_termination() + if peer_termination: + rebuild_paths(peer_termination) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 56134f4d320..c0fc89f8379 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -819,15 +819,35 @@ class CablePathTestCase(TestCase): interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') - # Create cables + # Create cable 1 cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) cable1.save() + + # Check for partial path from interface1 + self.assertPathExists( + origin=interface1, + destination=None, + path=(cable1, circuittermination1), + is_active=False + ) + + # Create CT2 + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + + # Check for partial path to site + self.assertPathExists( + origin=interface1, + destination=self.site, + path=(cable1, circuittermination1, circuittermination2), + is_active=True + ) + + # Create cable 2 cable2 = Cable(termination_a=circuittermination2, termination_b=interface2) cable2.save() - # Check for paths + # Check for complete path in each direction self.assertPathExists( origin=interface1, destination=interface2, From a86178f19b4b894225f9859d91fad06a13005c16 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Apr 2021 09:36:14 -0400 Subject: [PATCH 149/223] Simplify VLANGroup scope assignment (WIP) --- netbox/ipam/forms.py | 45 +++++++++-------------- netbox/templates/ipam/vlangroup_edit.html | 28 ++++---------- 2 files changed, 26 insertions(+), 47 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 53a36606f2e..5da20ea4d71 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1144,12 +1144,13 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): 'sites': '$site' } ) - site_group = DynamicModelChoiceField( + sitegroup = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False, initial_params={ 'sites': '$site' - } + }, + label='Site group' ) site = DynamicModelChoiceField( queryset=Site.objects.all(), @@ -1159,7 +1160,7 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): }, query_params={ 'region_id': '$region', - 'group_id': '$site_group', + 'group_id': '$sitegroup', } ) location = DynamicModelChoiceField( @@ -1180,18 +1181,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): 'location_id': '$location', } ) - cluster_group = DynamicModelChoiceField( + clustergroup = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, initial_params={ 'clusters': '$cluster' - } + }, + label='Cluster group' ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), required=False, query_params={ - 'group_id': '$cluster_group', + 'group_id': '$clustergroup', } ) slug = SlugField() @@ -1199,29 +1201,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = VLANGroup fields = [ - 'name', 'slug', 'description', 'region', 'site_group', 'site', 'location', 'rack', 'cluster_group', - 'cluster', + 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', + 'clustergroup', 'cluster', ] + widgets = { + 'scope_type': StaticSelect2, + } def __init__(self, *args, **kwargs): instance = kwargs.get('instance') initial = kwargs.get('initial', {}) if instance is not None and instance.scope: - if type(instance.scope) is Rack: - initial['rack'] = instance.scope - elif type(instance.scope) is Location: - initial['location'] = instance.scope - elif type(instance.scope) is Site: - initial['site'] = instance.scope - elif type(instance.scope) is SiteGroup: - initial['site_group'] = instance.scope - elif type(instance.scope) is Region: - initial['region'] = instance.scope - elif type(instance.scope) is Cluster: - initial['cluster'] = instance.scope - elif type(instance.scope) is ClusterGroup: - initial['cluster_group'] = instance.scope + initial[instance.scope_type.model] = instance.scope kwargs['initial'] = initial @@ -1230,11 +1222,10 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): def clean(self): super().clean() - # Assign scope object - self.instance.scope = self.cleaned_data['rack'] or self.cleaned_data['location'] or \ - self.cleaned_data['site'] or self.cleaned_data['site_group'] or \ - self.cleaned_data['region'] or self.cleaned_data['cluster'] or \ - self.cleaned_data['cluster_group'] or None + # Assign scope based on scope_type + if self.cleaned_data['scope_type']: + scope_field = self.cleaned_data['scope_type'].model + self.instance.scope = self.cleaned_data.get(scope_field) class VLANGroupCSVForm(CustomFieldModelCSVForm): diff --git a/netbox/templates/ipam/vlangroup_edit.html b/netbox/templates/ipam/vlangroup_edit.html index 2afedd98109..0ba825f2ce8 100644 --- a/netbox/templates/ipam/vlangroup_edit.html +++ b/netbox/templates/ipam/vlangroup_edit.html @@ -16,26 +16,14 @@ Scope
    - {% with virtual_tab_active=form.initial.cluster %} - -
    -
    - {% render_field form.region %} - {% render_field form.site_group %} - {% render_field form.site %} - {% render_field form.location %} - {% render_field form.rack %} -
    -
    - {% render_field form.cluster_group %} - {% render_field form.cluster %} -
    -
    - The VLAN group will be limited in scope to the most-specific object selected above. - {% endwith %} + {% render_field form.scope_type %} + {% render_field form.region %} + {% render_field form.sitegroup %} + {% render_field form.site %} + {% render_field form.location %} + {% render_field form.rack %} + {% render_field form.clustergroup %} + {% render_field form.cluster %}
    {% if form.custom_fields %} From 73e9842877bf3bcc011f75a732826d8d2c2f9ef4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Apr 2021 10:17:21 -0400 Subject: [PATCH 150/223] Introduce ContentTypeChoiceField --- netbox/ipam/constants.py | 5 + netbox/ipam/forms.py | 16 +- .../ipam/migrations/0045_vlangroup_scope.py | 2 +- netbox/ipam/models/vlans.py | 4 +- netbox/utilities/forms/fields.py | 177 ++++++++++-------- 5 files changed, 120 insertions(+), 84 deletions(-) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index e8825ad18e6..9dd9328b810 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -59,6 +59,11 @@ IPADDRESS_ROLES_NONUNIQUE = ( VLAN_VID_MIN = 1 VLAN_VID_MAX = 4094 +# models values for ContentTypes which may be VLANGroup scope types +VLANGROUP_SCOPE_TYPES = ( + 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', +) + # # Services diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 5da20ea4d71..18dfd985f9a 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup @@ -9,9 +10,10 @@ from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, DatePicker, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField, - ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, ContentTypeChoiceField, CSVChoiceField, + CSVModelChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, + NumericArrayField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface from .choices import * @@ -1137,6 +1139,10 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), + required=False + ) region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -1223,9 +1229,11 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): super().clean() # Assign scope based on scope_type - if self.cleaned_data['scope_type']: + if self.cleaned_data.get('scope_type'): scope_field = self.cleaned_data['scope_type'].model self.instance.scope = self.cleaned_data.get(scope_field) + else: + self.instance.scope_id = None class VLANGroupCSVForm(CustomFieldModelCSVForm): diff --git a/netbox/ipam/migrations/0045_vlangroup_scope.py b/netbox/ipam/migrations/0045_vlangroup_scope.py index 8795750d25f..c1f3c013f50 100644 --- a/netbox/ipam/migrations/0045_vlangroup_scope.py +++ b/netbox/ipam/migrations/0045_vlangroup_scope.py @@ -23,7 +23,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='vlangroup', name='scope_type', - field=models.ForeignKey(blank=True, limit_choices_to=models.Q(model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster']), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(model__in=('region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), ), migrations.AlterModelOptions( name='vlangroup', diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 040d23746c7..d0f5375e2be 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -35,9 +35,7 @@ class VLANGroup(OrganizationalModel): scope_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=Q( - model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster'] - ), + limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES), blank=True, null=True ) diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 07481ed60d8..001f50bfa24 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -20,6 +20,7 @@ from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern __all__ = ( 'CommentField', + 'ContentTypeChoiceField', 'CSVChoiceField', 'CSVContentTypeField', 'CSVDataField', @@ -36,6 +37,99 @@ __all__ = ( ) +class CommentField(forms.CharField): + """ + A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text. + """ + widget = forms.Textarea + default_label = '' + # TODO: Port Markdown cheat sheet to internal documentation + default_helptext = ' '\ + ''\ + 'Markdown syntax is supported' + + def __init__(self, *args, **kwargs): + required = kwargs.pop('required', False) + label = kwargs.pop('label', self.default_label) + help_text = kwargs.pop('help_text', self.default_helptext) + super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) + + +class SlugField(forms.SlugField): + """ + Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. + """ + def __init__(self, slug_source='name', *args, **kwargs): + label = kwargs.pop('label', "Slug") + help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") + widget = kwargs.pop('widget', widgets.SlugWidget) + super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs) + self.widget.attrs['slug-source'] = slug_source + + +class TagFilterField(forms.MultipleChoiceField): + """ + A filter field for the tags of a model. Only the tags used by a model are displayed. + + :param model: The model of the filter + """ + widget = widgets.StaticSelect2Multiple + + def __init__(self, model, *args, **kwargs): + def get_choices(): + tags = model.tags.annotate( + count=Count('extras_taggeditem_items') + ).order_by('name') + return [ + (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags + ] + + # Choices are fetched each time the form is initialized + super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) + + +class LaxURLField(forms.URLField): + """ + Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names + (e.g. http://myserver/ is valid) + """ + default_validators = [EnhancedURLValidator()] + + +class JSONField(_JSONField): + """ + Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Enter context data in JSON format.' + self.widget.attrs['placeholder'] = '' + + def prepare_value(self, value): + if isinstance(value, InvalidJSONInput): + return value + if value is None: + return '' + return json.dumps(value, sort_keys=True, indent=4) + + +class ContentTypeChoiceField(forms.ModelChoiceField): + + def __init__(self, queryset, *args, **kwargs): + # Order ContentTypes by app_label + queryset = queryset.order_by('app_label', 'model') + super().__init__(queryset, *args, **kwargs) + + def label_from_instance(self, obj): + meta = obj.model_class()._meta + return f'{meta.app_config.verbose_name} > {meta.verbose_name}' + + +# +# CSV fields +# + class CSVDataField(forms.CharField): """ A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first @@ -167,6 +261,10 @@ class CSVContentTypeField(CSVModelChoiceField): raise forms.ValidationError(f'Invalid object type') +# +# Expansion fields +# + class ExpandableNameField(forms.CharField): """ A field which allows for numeric range expansion @@ -212,56 +310,9 @@ class ExpandableIPAddressField(forms.CharField): return [value] -class CommentField(forms.CharField): - """ - A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text. - """ - widget = forms.Textarea - default_label = '' - # TODO: Port Markdown cheat sheet to internal documentation - default_helptext = ' '\ - ''\ - 'Markdown syntax is supported' - - def __init__(self, *args, **kwargs): - required = kwargs.pop('required', False) - label = kwargs.pop('label', self.default_label) - help_text = kwargs.pop('help_text', self.default_helptext) - super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) - - -class SlugField(forms.SlugField): - """ - Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. - """ - def __init__(self, slug_source='name', *args, **kwargs): - label = kwargs.pop('label', "Slug") - help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") - widget = kwargs.pop('widget', widgets.SlugWidget) - super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs) - self.widget.attrs['slug-source'] = slug_source - - -class TagFilterField(forms.MultipleChoiceField): - """ - A filter field for the tags of a model. Only the tags used by a model are displayed. - - :param model: The model of the filter - """ - widget = widgets.StaticSelect2Multiple - - def __init__(self, model, *args, **kwargs): - def get_choices(): - tags = model.tags.annotate( - count=Count('extras_taggeditem_items') - ).order_by('name') - return [ - (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags - ] - - # Choices are fetched each time the form is initialized - super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) - +# +# Dynamic fields +# class DynamicModelChoiceMixin: """ @@ -373,29 +424,3 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip """ filter = django_filters.ModelMultipleChoiceFilter widget = widgets.APISelectMultiple - - -class LaxURLField(forms.URLField): - """ - Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names - (e.g. http://myserver/ is valid) - """ - default_validators = [EnhancedURLValidator()] - - -class JSONField(_JSONField): - """ - Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = 'Enter context data in JSON format.' - self.widget.attrs['placeholder'] = '' - - def prepare_value(self, value): - if isinstance(value, InvalidJSONInput): - return value - if value is None: - return '' - return json.dumps(value, sort_keys=True, indent=4) From d82f2e289ac2409228b95a42ce4147af759b3edb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Apr 2021 10:55:16 -0400 Subject: [PATCH 151/223] Use ContentTypeChoiceField for all ContentType fields --- netbox/extras/admin.py | 53 ++++++++++++-------------------- netbox/ipam/tests/test_views.py | 1 - netbox/users/admin.py | 12 ++++---- netbox/users/constants.py | 8 +++++ netbox/users/models.py | 8 ++--- netbox/utilities/forms/fields.py | 11 ++++++- 6 files changed, 45 insertions(+), 48 deletions(-) create mode 100644 netbox/users/constants.py diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 6e421e679dd..90e7541e217 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,16 +1,10 @@ from django import forms from django.contrib import admin +from django.contrib.contenttypes.models import ContentType -from utilities.forms import LaxURLField +from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook - - -def order_content_types(field): - """ - Order the list of available ContentTypes by application - """ - queryset = field.queryset.order_by('app_label', 'model') - field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset] +from .utils import FeatureQuery # @@ -18,6 +12,10 @@ def order_content_types(field): # class WebhookForm(forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('webhooks') + ) payload_url = LaxURLField( label='URL' ) @@ -26,12 +24,6 @@ class WebhookForm(forms.ModelForm): model = Webhook exclude = () - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if 'content_types' in self.fields: - order_content_types(self.fields['content_types']) - @admin.register(Webhook) class WebhookAdmin(admin.ModelAdmin): @@ -70,6 +62,10 @@ class WebhookAdmin(admin.ModelAdmin): # class CustomFieldForm(forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields') + ) class Meta: model = CustomField @@ -84,11 +80,6 @@ class CustomFieldForm(forms.ModelForm): ) } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - order_content_types(self.fields['content_types']) - @admin.register(CustomField) class CustomFieldAdmin(admin.ModelAdmin): @@ -127,6 +118,10 @@ class CustomFieldAdmin(admin.ModelAdmin): # class CustomLinkForm(forms.ModelForm): + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links') + ) class Meta: model = CustomLink @@ -143,13 +138,6 @@ class CustomLinkForm(forms.ModelForm): 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Format ContentType choices - order_content_types(self.fields['content_type']) - self.fields['content_type'].choices.insert(0, ('', '---------')) - @admin.register(CustomLink) class CustomLinkAdmin(admin.ModelAdmin): @@ -176,18 +164,15 @@ class CustomLinkAdmin(admin.ModelAdmin): # class ExportTemplateForm(forms.ModelForm): + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links') + ) class Meta: model = ExportTemplate exclude = [] - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Format ContentType choices - order_content_types(self.fields['content_type']) - self.fields['content_type'].choices.insert(0, ('', '---------')) - @admin.register(ExportTemplate) class ExportTemplateAdmin(admin.ModelAdmin): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 387bdd2b55a..2704ac1ca6a 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -329,7 +329,6 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.form_data = { 'name': 'VLAN Group X', 'slug': 'vlan-group-x', - 'site': sites[1].pk, 'description': 'A new VLAN group', } diff --git a/netbox/users/admin.py b/netbox/users/admin.py index e1625e1ed7b..5eff6ec22ed 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -4,9 +4,9 @@ from django.contrib.auth.admin import UserAdmin as UserAdmin_ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, ValidationError -from django.db.models import Q -from extras.admin import order_content_types +from utilities.forms.fields import ContentTypeMultipleChoiceField +from .constants import * from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig @@ -126,6 +126,10 @@ class TokenAdmin(admin.ModelAdmin): # class ObjectPermissionForm(forms.ModelForm): + object_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES + ) can_view = forms.BooleanField(required=False) can_add = forms.BooleanField(required=False) can_change = forms.BooleanField(required=False) @@ -153,10 +157,6 @@ class ObjectPermissionForm(forms.ModelForm): # Make the actions field optional since the admin form uses it only for non-CRUD actions self.fields['actions'].required = False - # Format ContentType choices - order_content_types(self.fields['object_types']) - self.fields['object_types'].choices.insert(0, ('', '---------')) - # Order group and user fields self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') diff --git a/netbox/users/constants.py b/netbox/users/constants.py new file mode 100644 index 00000000000..e6917c48244 --- /dev/null +++ b/netbox/users/constants.py @@ -0,0 +1,8 @@ +from django.db.models import Q + + +OBJECTPERMISSION_OBJECT_TYPES = Q( + ~Q(app_label__in=['admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) | + Q(app_label='auth', model__in=['group', 'user']) | + Q(app_label='users', model__in=['objectpermission', 'token']) +) diff --git a/netbox/users/models.py b/netbox/users/models.py index 00e18148c6a..2252d15b6e0 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -6,7 +6,6 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import MinLengthValidator from django.db import models -from django.db.models import Q from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone @@ -14,6 +13,7 @@ from django.utils import timezone from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict +from .constants import * __all__ = ( @@ -251,11 +251,7 @@ class ObjectPermission(BigIDModel): ) object_types = models.ManyToManyField( to=ContentType, - limit_choices_to=Q( - ~Q(app_label__in=['admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) | - Q(app_label='auth', model__in=['group', 'user']) | - Q(app_label='users', model__in=['objectpermission', 'token']) - ), + limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, related_name='object_permissions' ) groups = models.ManyToManyField( diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 001f50bfa24..f0be3319502 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -21,6 +21,7 @@ from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern __all__ = ( 'CommentField', 'ContentTypeChoiceField', + 'ContentTypeMultipleChoiceField', 'CSVChoiceField', 'CSVContentTypeField', 'CSVDataField', @@ -114,7 +115,7 @@ class JSONField(_JSONField): return json.dumps(value, sort_keys=True, indent=4) -class ContentTypeChoiceField(forms.ModelChoiceField): +class ContentTypeChoiceMixin: def __init__(self, queryset, *args, **kwargs): # Order ContentTypes by app_label @@ -126,6 +127,14 @@ class ContentTypeChoiceField(forms.ModelChoiceField): return f'{meta.app_config.verbose_name} > {meta.verbose_name}' +class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField): + pass + + +class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField): + pass + + # # CSV fields # From 6287f75e670f13d73221c488573fb9a3f68e947b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Apr 2021 11:31:46 -0400 Subject: [PATCH 152/223] Toggle VLANGroup scope selector fields --- netbox/ipam/forms.py | 3 +- netbox/templates/ipam/vlangroup_edit.html | 76 +++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 18dfd985f9a..63e0934227f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1141,7 +1141,8 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - required=False + required=False, + widget=StaticSelect2 ) region = DynamicModelChoiceField( queryset=Region.objects.all(), diff --git a/netbox/templates/ipam/vlangroup_edit.html b/netbox/templates/ipam/vlangroup_edit.html index 0ba825f2ce8..ba177a790a0 100644 --- a/netbox/templates/ipam/vlangroup_edit.html +++ b/netbox/templates/ipam/vlangroup_edit.html @@ -35,3 +35,79 @@ {% endif %} {% endblock %} + +{% block javascript %} + +{% endblock %} From eb8e4f64fc4ff15777711409b3e44b5e88bd56ce Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Apr 2021 11:37:09 -0400 Subject: [PATCH 153/223] Restore 'brief' parameter (regression from a292ff5cc089626d75bc96b4805ae5bc510e2fb3) --- netbox/project-static/js/forms.js | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index b2962686a11..b95100accfd 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -158,6 +158,7 @@ $(document).ready(function() { q: params.term, limit: 50, offset: offset, + brief: true, }; // Attach any extra query parameters From e4f3b3447eafc828faea0e1e013b89753403a2d4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Apr 2021 15:21:11 -0400 Subject: [PATCH 154/223] Remove redundant prechange_data assignments --- netbox/extras/signals.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index ba7c725bf6a..0d6295e5b27 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -36,9 +36,6 @@ def _handle_changed_object(request, sender, instance, **kwargs): # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): objectchange = instance.to_objectchange(action) - # TODO: Move this to to_objectchange() - if hasattr(instance, '_prechange_snapshot'): - objectchange.prechange_data = instance._prechange_snapshot objectchange.user = request.user objectchange.request_id = request.id objectchange.save() @@ -65,9 +62,6 @@ def _handle_deleted_object(request, sender, instance, **kwargs): # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE) - # TODO: Move this to to_objectchange() - if hasattr(instance, '_prechange_snapshot'): - objectchange.prechange_data = instance._prechange_snapshot objectchange.user = request.user objectchange.request_id = request.id objectchange.save() From 2b0ccf3acd7af01c1e1ebedc29208960a70112c0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Apr 2021 15:27:00 -0400 Subject: [PATCH 155/223] Provide absolute URL for object search form --- netbox/templates/generic/object.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 15cfcf77968..d9898a0e6cf 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -13,8 +13,7 @@
    - {# TODO: Provide absolute search URL #} -
    +
    From 237dfce8a0365b09f3265589ad2d24e24eb9264e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Apr 2021 15:44:15 -0400 Subject: [PATCH 156/223] Fix tab headers --- docs/installation/3-netbox.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 2f8958b25f8..8c0fd827e60 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -9,13 +9,13 @@ Begin by installing all system packages required by NetBox and its dependencies. !!! note NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. -=== Ubuntu +=== "Ubuntu" ```no-highlight sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev ``` -=== CentOS +=== "CentOS" ```no-highlight sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config @@ -57,13 +57,13 @@ sudo mkdir -p /opt/netbox/ && cd /opt/netbox/ If `git` is not already installed, install it: -=== Ubuntu +=== "Ubuntu" ```no-highlight sudo apt install -y git ``` -=== CentOS +=== "CentOS" ```no-highlight sudo yum install -y git @@ -89,14 +89,14 @@ Checking connectivity... done. Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files. -=== Ubuntu +=== "Ubuntu" ``` sudo adduser --system --group netbox sudo chown --recursive netbox /opt/netbox/netbox/media/ ``` -=== CentOS +=== "CentOS" ``` sudo groupadd --system netbox From ea9e9d7273eff340a85851db1289bbf7c39b6ac7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Apr 2021 16:29:01 -0400 Subject: [PATCH 157/223] Add the models index --- docs/development/models.md | 96 ++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 4 ++ 2 files changed, 100 insertions(+) create mode 100644 docs/development/models.md diff --git a/docs/development/models.md b/docs/development/models.md new file mode 100644 index 00000000000..1e9b17d22fc --- /dev/null +++ b/docs/development/models.md @@ -0,0 +1,96 @@ +# NetBox Models + +## Model Types + +A NetBox model represents a discrete object type such as a device or IP address. Each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type. + +### Features Matrix + +* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log +* [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects +* [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields +* [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models +* Tagging - The models can be tagged with user-defined tags +* [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary +* Nesting - These models can be nested recursively to create a hierarchy + +| Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting | +| ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | +| Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | +| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | | | | +| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | | | :material-check: | +| Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | +| Component Template | :material-check: | :material-check: | :material-check: | | | | | + +## Models Index + +### Primary Models + +* [circuits.Circuit](../models/circuits/circuit.md) +* [circuits.Provider](../models/circuits/provider.md) +* [circuits.ProviderNetwork](../models/circuits/providernetwork.md) +* [dcim.Cable](../models/dcim/cable.md) +* [dcim.Device](../models/dcim/device.md) +* [dcim.DeviceType](../models/dcim/devicetype.md) +* [dcim.PowerFeed](../models/dcim/powerfeed.md) +* [dcim.PowerPanel](../models/dcim/powerpanel.md) +* [dcim.Rack](../models/dcim/rack.md) +* [dcim.RackReservation](../models/dcim/rackreservation.md) +* [dcim.Site](../models/dcim/site.md) +* [dcim.VirtualChassis](../models/dcim/virtualchassis.md) +* [ipam.Aggregate](../models/ipam/aggregate.md) +* [ipam.IPAddress](../models/ipam/ipaddress.md) +* [ipam.Prefix](../models/ipam/prefix.md) +* [ipam.RouteTarget](../models/ipam/routetarget.md) +* [ipam.Service](../models/ipam/service.md) +* [ipam.VLAN](../models/ipam/vlan.md) +* [ipam.VRF](../models/ipam/vrf.md) +* [secrets.Secret](../models/secrets/secret.md) +* [tenancy.Tenant](../models/tenancy/tenant.md) +* [virtualization.Cluster](../models/virtualization/cluster.md) +* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md) + +### Organizational Models + +* [circuits.CircuitType](../models/circuits/circuittype.md) +* [dcim.DeviceRole](../models/dcim/devicerole.md) +* [dcim.Manufacturer](../models/dcim/manufacturer.md) +* [dcim.Platform](../models/dcim/platform.md) +* [dcim.RackRole](../models/dcim/rackrole.md) +* [ipam.RIR](../models/ipam/rir.md) +* [ipam.Role](../models/ipam/role.md) +* [ipam.VLANGroup](../models/ipam/vlangroup.md) +* [secrets.SecretRole](../models/secrets/secretrole.md) +* [virtualization.ClusterGroup](../models/virtualization/clustergroup.md) +* [virtualization.ClusterType](../models/virtualization/clustertype.md) + +### Nested Group Models + +* [dcim.Location](../models/dcim/location.md) (formerly RackGroup) +* [dcim.Region](../models/dcim/region.md) +* [dcim.SiteGroup](../models/dcim/sitegroup.md) +* [tenancy.TenantGroup](../models/tenancy/tenantgroup.md) + +### Component Models + +* [dcim.ConsolePort](../models/dcim/consoleport.md) +* [dcim.ConsoleServerPort](../models/dcim/consoleserverport.md) +* [dcim.DeviceBay](../models/dcim/devicebay.md) +* [dcim.FrontPort](../models/dcim/frontport.md) +* [dcim.Interface](../models/dcim/interface.md) +* [dcim.InventoryItem](../models/dcim/inventoryitem.md) +* [dcim.PowerOutlet](../models/dcim/poweroutlet.md) +* [dcim.PowerPort](../models/dcim/powerport.md) +* [dcim.RearPort](../models/dcim/rearport.md) +* [virtualization.VMInterface](../models/virtualization/vminterface.md) + +### Component Template Models + +* [dcim.ConsolePortTemplate](../models/dcim/consoleporttemplate.md) +* [dcim.ConsoleServerPortTemplate](../models/dcim/consoleserverporttemplate.md) +* [dcim.DeviceBayTemplate](../models/dcim/devicebaytemplate.md) +* [dcim.FrontPortTemplate](../models/dcim/frontporttemplate.md) +* [dcim.InterfaceTemplate](../models/dcim/interfacetemplate.md) +* [dcim.PowerOutletTemplate](../models/dcim/poweroutlettemplate.md) +* [dcim.PowerPortTemplate](../models/dcim/powerporttemplate.md) +* [dcim.RearPortTemplate](../models/dcim/rearporttemplate.md) diff --git a/mkdocs.yml b/mkdocs.yml index a2fa4d4fb69..fb5cf1890ac 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,9 @@ markdown_extensions: - admonition - markdown_include.include: headingOffset: 1 + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg - pymdownx.superfences - pymdownx.tabbed nav: @@ -72,6 +75,7 @@ nav: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' - Style Guide: 'development/style-guide.md' + - Models: 'development/models.md' - Extending Models: 'development/extending-models.md' - Application Registry: 'development/application-registry.md' - User Preferences: 'development/user-preferences.md' From 779837389bd738f7d9b0ad0e4229784576e656a7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Apr 2021 16:59:53 -0400 Subject: [PATCH 158/223] Convert all LinkColumns to Column(linkify=True) --- netbox/circuits/tables.py | 11 ++++++--- netbox/dcim/tables/cables.py | 6 +++-- netbox/dcim/tables/devices.py | 18 ++++++--------- netbox/dcim/tables/devicetypes.py | 4 +++- netbox/dcim/tables/power.py | 8 +++++-- netbox/dcim/tables/sites.py | 4 ++-- netbox/extras/tables.py | 4 +++- netbox/ipam/tables.py | 37 +++++++++++++++++++++---------- netbox/secrets/tables.py | 4 +++- netbox/tenancy/tables.py | 6 +++-- netbox/virtualization/tables.py | 20 ++++++++++++----- 11 files changed, 80 insertions(+), 42 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 32689741565..ba599757a75 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -12,7 +12,9 @@ from .models import * class ProviderTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) circuit_count = tables.Column( accessor=Accessor('count_circuits'), verbose_name='Circuits' @@ -57,7 +59,9 @@ class ProviderNetworkTable(BaseTable): class CircuitTypeTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) circuit_count = tables.Column( verbose_name='Circuits' ) @@ -75,7 +79,8 @@ class CircuitTypeTable(BaseTable): class CircuitTable(BaseTable): pk = ToggleColumn() - cid = tables.LinkColumn( + cid = tables.Column( + linkify=True, verbose_name='ID' ) provider = tables.Column( diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index cdb79f4e128..9509ec2bca9 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -26,9 +26,10 @@ class CableTable(BaseTable): orderable=False, verbose_name='Side A' ) - termination_a = tables.LinkColumn( + termination_a = tables.Column( accessor=Accessor('termination_a'), orderable=False, + linkify=True, verbose_name='Termination A' ) termination_b_parent = tables.TemplateColumn( @@ -37,9 +38,10 @@ class CableTable(BaseTable): orderable=False, verbose_name='Side B' ) - termination_b = tables.LinkColumn( + termination_b = tables.Column( accessor=Accessor('termination_b'), orderable=False, + linkify=True, verbose_name='Termination B' ) status = ChoiceFieldColumn() diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 4aac227e918..6d678172ac9 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -137,11 +137,9 @@ class DeviceTable(BaseTable): device_role = ColoredLabelColumn( verbose_name='Role' ) - device_type = tables.LinkColumn( - viewname='dcim:devicetype', - args=[Accessor('device_type__pk')], - verbose_name='Type', - text=lambda record: record.device_type.display_name + device_type = tables.Column( + linkify=True, + verbose_name='Type' ) if settings.PREFER_IPV4: primary_ip = tables.Column( @@ -163,13 +161,11 @@ class DeviceTable(BaseTable): linkify=True, verbose_name='IPv6 Address' ) - cluster = tables.LinkColumn( - viewname='virtualization:cluster', - args=[Accessor('cluster__pk')] + cluster = tables.Column( + linkify=True ) - virtual_chassis = tables.LinkColumn( - viewname='dcim:virtualchassis', - args=[Accessor('virtual_chassis__pk')] + virtual_chassis = tables.Column( + linkify=True ) vc_position = tables.Column( verbose_name='VC Position' diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index c5b8bb70de4..0a445171d31 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -26,7 +26,9 @@ __all__ = ( class ManufacturerTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) devicetype_count = tables.Column( verbose_name='Device Types' ) diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 9f71e24fc20..1c4d6e9210a 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -16,7 +16,9 @@ __all__ = ( class PowerPanelTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) site = tables.Column( linkify=True ) @@ -43,7 +45,9 @@ class PowerPanelTable(BaseTable): # cannot traverse pass-through ports. class PowerFeedTable(CableTerminationTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) power_panel = tables.Column( linkify=True ) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index b8d06beb6e3..2cee436d7b2 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -57,8 +57,8 @@ class SiteGroupTable(BaseTable): class SiteTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn( - order_by=('_name',) + name = tables.Column( + linkify=True ) status = ChoiceFieldColumn() region = tables.Column( diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 70b5c5f70b1..a8efd8005a0 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -58,7 +58,9 @@ class TaggedItemTable(BaseTable): class ConfigContextTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) is_active = BooleanColumn( verbose_name='Active' ) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index f41c7cfff17..3741610119b 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -112,7 +112,9 @@ VLAN_MEMBER_TAGGED = """ class VRFTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) rd = tables.Column( verbose_name='RD' ) @@ -146,7 +148,9 @@ class VRFTable(BaseTable): class RouteTargetTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) tenant = TenantColumn() tags = TagColumn( url_name='ipam:vrf_list' @@ -164,7 +168,9 @@ class RouteTargetTable(BaseTable): class RIRTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) is_private = BooleanColumn( verbose_name='Private' ) @@ -187,7 +193,8 @@ class RIRTable(BaseTable): class AggregateTable(BaseTable): pk = ToggleColumn() - prefix = tables.LinkColumn( + prefix = tables.Column( + linkify=True, verbose_name='Aggregate' ) tenant = TenantColumn() @@ -395,7 +402,8 @@ class InterfaceIPAddressTable(BaseTable): """ List IP addresses assigned to a specific Interface. """ - address = tables.LinkColumn( + address = tables.Column( + linkify=True, verbose_name='IP Address' ) vrf = tables.TemplateColumn( @@ -492,7 +500,8 @@ class VLANMembersTable(BaseTable): """ Base table for Interface and VMInterface assignments """ - name = tables.LinkColumn( + name = tables.Column( + linkify=True, verbose_name='Interface' ) tagged = tables.TemplateColumn( @@ -502,7 +511,9 @@ class VLANMembersTable(BaseTable): class VLANDevicesTable(VLANMembersTable): - device = tables.LinkColumn() + device = tables.Column( + linkify=True + ) actions = ButtonsColumn(Interface, buttons=['edit']) class Meta(BaseTable.Meta): @@ -511,7 +522,9 @@ class VLANDevicesTable(VLANMembersTable): class VLANVirtualMachinesTable(VLANMembersTable): - virtual_machine = tables.LinkColumn() + virtual_machine = tables.Column( + linkify=True + ) actions = ButtonsColumn(VMInterface, buttons=['edit']) class Meta(BaseTable.Meta): @@ -523,9 +536,8 @@ class InterfaceVLANTable(BaseTable): """ List VLANs assigned to a specific Interface. """ - vid = tables.LinkColumn( - viewname='ipam:vlan', - args=[Accessor('pk')], + vid = tables.Column( + linkify=True, verbose_name='ID' ) tagged = BooleanColumn() @@ -560,7 +572,8 @@ class ServiceTable(BaseTable): name = tables.Column( linkify=True ) - parent = tables.LinkColumn( + parent = tables.Column( + linkify=True, order_by=('device', 'virtual_machine') ) ports = tables.TemplateColumn( diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index dd91985ecc8..b2f8fcc8cba 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -10,7 +10,9 @@ from .models import SecretRole, Secret class SecretRoleTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) secret_count = LinkedCountColumn( viewname='secrets:secret_list', url_params={'role': 'slug'}, diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index a826fe5a0a8..e0637ff3a0a 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -10,7 +10,7 @@ from .models import Tenant, TenantGroup class TenantColumn(tables.TemplateColumn): """ - Render a colored label (e.g. for DeviceRoles). + Include the tenant description. """ template_code = """ {% if record.tenant %} @@ -57,7 +57,9 @@ class TenantGroupTable(BaseTable): class TenantTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) tags = TagColumn( url_name='tenancy:tenant_list' ) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 80e9575055b..c30f1416593 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -32,7 +32,9 @@ VMINTERFACE_BUTTONS = """ class ClusterTypeTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) cluster_count = tables.Column( verbose_name='Clusters' ) @@ -50,7 +52,9 @@ class ClusterTypeTable(BaseTable): class ClusterGroupTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) cluster_count = tables.Column( verbose_name='Clusters' ) @@ -68,7 +72,9 @@ class ClusterGroupTable(BaseTable): class ClusterTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) tenant = tables.Column( linkify=True ) @@ -101,7 +107,9 @@ class ClusterTable(BaseTable): class VirtualMachineTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.Column( + linkify=True + ) status = ChoiceFieldColumn() cluster = tables.Column( linkify=True @@ -156,7 +164,9 @@ class VirtualMachineDetailTable(VirtualMachineTable): class VMInterfaceTable(BaseInterfaceTable): pk = ToggleColumn() - virtual_machine = tables.LinkColumn() + virtual_machine = tables.Column( + linkify=True + ) name = tables.Column( linkify=True ) From d69ec7f8e4ef90b0984b839dbc1c569067cc51eb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Apr 2021 17:02:12 -0400 Subject: [PATCH 159/223] Add manufacturer column to DeviceTable --- netbox/dcim/tables/devices.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 6d678172ac9..7b6fb997d42 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -137,6 +137,10 @@ class DeviceTable(BaseTable): device_role = ColoredLabelColumn( verbose_name='Role' ) + manufacturer = tables.Column( + accessor=Accessor('device_type__manufacturer'), + linkify=True + ) device_type = tables.Column( linkify=True, verbose_name='Type' @@ -180,12 +184,13 @@ class DeviceTable(BaseTable): class Meta(BaseTable.Meta): model = Device fields = ( - 'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site', - 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', - 'virtual_chassis', 'vc_position', 'vc_priority', 'tags', + 'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', + 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'tags', ) default_columns = ( - 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'device_type', 'primary_ip', + 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', + 'primary_ip', ) From f28edd08641fa2a919ab92200dec0c0b57a366a2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Apr 2021 17:14:15 -0400 Subject: [PATCH 160/223] Use CommentField for JournalEntry comments --- netbox/extras/forms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 4cf5023a3ea..977ad9d6820 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -8,7 +8,7 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, + CommentField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup @@ -385,6 +385,7 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): # class JournalEntryForm(BootstrapMixin, forms.ModelForm): + comments = CommentField() class Meta: model = JournalEntry From 72a115b4346008f1300d35df9e35e953c2d2ff68 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Apr 2021 17:33:34 -0400 Subject: [PATCH 161/223] Add child interfaces table to interface view --- netbox/dcim/views.py | 9 +++++++++ netbox/templates/dcim/interface.html | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8d253f825ef..171d0702efa 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1847,6 +1847,14 @@ class InterfaceView(generic.ObjectView): orderable=False ) + # Get child interfaces + child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance) + child_interfaces_tables = tables.InterfaceTable( + child_interfaces, + orderable=False + ) + child_interfaces_tables.columns.hide('device') + # Get assigned VLANs and annotate whether each is tagged or untagged vlans = [] if instance.untagged_vlan is not None: @@ -1863,6 +1871,7 @@ class InterfaceView(generic.ObjectView): return { 'ipaddress_table': ipaddress_table, + 'child_interfaces_table': child_interfaces_tables, 'vlan_table': vlan_table, 'breadcrumb_url': 'dcim:device_interfaces' } diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 8dc8c07a201..b85dafb4b5f 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -3,6 +3,15 @@ {% load plugins %} {% load render_table from django_tables2 %} +{% block buttons %} + {% if perms.dcim.add_interface and not object.is_virtual %} + + Add Child Interface + + {% endif %} + {{ block.super }} +{% endblock %} + {% block content %}
    @@ -266,6 +275,13 @@ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
    + {% if not object.is_virtual %} +
    +
    + {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %} +
    +
    + {% endif %}
    {% plugin_full_width_page object %} From 4883bc3dd445a8353ae1f204d0a1fe3dcc83cb73 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Apr 2021 17:53:00 -0400 Subject: [PATCH 162/223] Empty MAC address should be null --- netbox/dcim/forms.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e8b7f6de459..cfac9402515 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -97,6 +97,11 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm): class InterfaceCommonForm(forms.Form): + mac_address = forms.CharField( + empty_value=None, + required=False, + label='MAC address' + ) def clean(self): super().clean() From 7b8bd2d4cea0be106a507832a82c9a2d27c1f34a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 11:04:12 -0400 Subject: [PATCH 163/223] Location model cleanup --- netbox/dcim/api/serializers.py | 3 ++- netbox/dcim/api/views.py | 10 ++++++++-- netbox/dcim/models/sites.py | 1 + netbox/dcim/views.py | 7 +++++++ netbox/templates/dcim/location.html | 4 ++++ netbox/templates/dcim/site.html | 23 ++++++++++++----------- 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index df7985d8237..8cce25e953b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -134,12 +134,13 @@ class LocationSerializer(NestedGroupModelSerializer): site = NestedSiteSerializer() parent = NestedLocationSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) + device_count = serializers.IntegerField(read_only=True) class Meta: model = Location fields = [ 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created', - 'last_updated', 'rack_count', '_depth', + 'last_updated', 'rack_count', 'device_count', '_depth', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 6aa4fd484e7..25d52f8a932 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -146,7 +146,13 @@ class SiteViewSet(CustomFieldModelViewSet): class LocationViewSet(CustomFieldModelViewSet): queryset = Location.objects.add_related_count( - Location.objects.all(), + Location.objects.add_related_count( + Location.objects.all(), + Device, + 'location', + 'device_count', + cumulative=True + ), Rack, 'location', 'rack_count', @@ -174,7 +180,7 @@ class RackRoleViewSet(CustomFieldModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.prefetch_related( - 'site', 'location__site', 'role', 'tenant', 'tags' + 'site', 'location', 'role', 'tenant', 'tags' ).annotate( device_count=count_related(Device, 'rack'), powerfeed_count=count_related(PowerFeed, 'rack') diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 3f2c209c3de..ce9f7b4f8b1 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -315,6 +315,7 @@ class Location(NestedGroupModel): ) csv_headers = ['site', 'parent', 'name', 'slug', 'description'] + clone_fields = ['site', 'parent', 'description'] class Meta: ordering = ['site', 'name'] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 171d0702efa..728888e5aa3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -272,6 +272,13 @@ class SiteView(generic.ObjectView): 'location', 'rack_count', cumulative=True + ) + locations = Location.objects.add_related_count( + locations, + Device, + 'location', + 'device_count', + cumulative=True ).restrict(request.user, 'view').filter(site=instance) return { diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index c12c9f533c3..8fc465a6b0b 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -26,6 +26,10 @@
    + + + +
    IP Addressing - {% if termination.connected_endpoint %} - {% for ip in termination.ip_addresses %} - {% if not forloop.first %}
    {% endif %} - {{ ip }} ({{ ip.vrf|default:"Global" }}) - {% empty %} - None - {% endfor %} - {% else %} - - {% endif %} -
    Cross-Connect {{ termination.xconnect_id|placeholder }}Description {{ object.description|placeholder }}
    Site{{ object.site }}
    Parent diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index b68fc1f0255..e0c707a4914 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -210,12 +210,22 @@ Locations + + + + + + {% for location in locations %} + + - {% endfor %} - - - - -
    LocationRacksDevices
    - {{ location }} + {{ location }} + + {{ location.rack_count }} + {{ location.device_count }} {{ location.rack_count }} @@ -223,15 +233,6 @@
    All racks{{ stats.rack_count }} - - - -
    From 0a1531ce8a5597333f2ac87fcc795c83a052fd47 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 11:18:30 -0400 Subject: [PATCH 164/223] TenantColumn should export null if no tenant is assigned --- netbox/tenancy/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index e0637ff3a0a..968ba787d67 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -26,7 +26,7 @@ class TenantColumn(tables.TemplateColumn): super().__init__(template_code=self.template_code, *args, **kwargs) def value(self, value): - return str(value) + return str(value) if value else None # From 7949a5e1fdc24c0371bfdf1c227fb47d072f910f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 11:40:46 -0400 Subject: [PATCH 165/223] Migrate VLANGroup site assignments --- .../0046_set_vlangroup_scope_types.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 netbox/ipam/migrations/0046_set_vlangroup_scope_types.py diff --git a/netbox/ipam/migrations/0046_set_vlangroup_scope_types.py b/netbox/ipam/migrations/0046_set_vlangroup_scope_types.py new file mode 100644 index 00000000000..2066259f8d0 --- /dev/null +++ b/netbox/ipam/migrations/0046_set_vlangroup_scope_types.py @@ -0,0 +1,27 @@ +from django.db import migrations + + +def set_scope_types(apps, schema_editor): + """ + Set 'site' as the scope type for all VLANGroups with a scope ID defined. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + VLANGroup = apps.get_model('ipam', 'VLANGroup') + + site_ct = ContentType.objects.get(app_label='dcim', model='site').pk + VLANGroup.objects.filter(scope_id__isnull=False).update( + scope_type=site_ct + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0045_vlangroup_scope'), + ] + + operations = [ + migrations.RunPython( + code=set_scope_types + ), + ] From f0018dcba916c3c8781fe02e8457e016ce614658 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 11:47:25 -0400 Subject: [PATCH 166/223] LinkedCountColumn should always invoke filter by ID --- netbox/dcim/tables/devices.py | 8 ++++---- netbox/ipam/tables.py | 8 ++++---- netbox/secrets/tables.py | 2 +- netbox/tenancy/tables.py | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 7b6fb997d42..cc1d78fa31e 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -63,12 +63,12 @@ class DeviceRoleTable(BaseTable): ) device_count = LinkedCountColumn( viewname='dcim:device_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='Devices' ) vm_count = LinkedCountColumn( viewname='virtualization:virtualmachine_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='VMs' ) color = ColorColumn() @@ -92,12 +92,12 @@ class PlatformTable(BaseTable): ) device_count = LinkedCountColumn( viewname='dcim:device_list', - url_params={'platform': 'slug'}, + url_params={'platform_id': 'pk'}, verbose_name='Devices' ) vm_count = LinkedCountColumn( viewname='virtualization:virtualmachine_list', - url_params={'platform': 'slug'}, + url_params={'platform_id': 'pk'}, verbose_name='VMs' ) actions = ButtonsColumn(Platform) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 3741610119b..6fe8365f557 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -176,7 +176,7 @@ class RIRTable(BaseTable): ) aggregate_count = LinkedCountColumn( viewname='ipam:aggregate_list', - url_params={'rir': 'slug'}, + url_params={'rir_id': 'pk'}, verbose_name='Aggregates' ) actions = ButtonsColumn(RIR) @@ -236,12 +236,12 @@ class RoleTable(BaseTable): ) prefix_count = LinkedCountColumn( viewname='ipam:prefix_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='Prefixes' ) vlan_count = LinkedCountColumn( viewname='ipam:vlan_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='VLANs' ) actions = ButtonsColumn(Role) @@ -434,7 +434,7 @@ class VLANGroupTable(BaseTable): ) vlan_count = LinkedCountColumn( viewname='ipam:vlan_list', - url_params={'group': 'slug'}, + url_params={'group_id': 'pk'}, verbose_name='VLANs' ) actions = ButtonsColumn( diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index b2f8fcc8cba..7e164920a02 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -15,7 +15,7 @@ class SecretRoleTable(BaseTable): ) secret_count = LinkedCountColumn( viewname='secrets:secret_list', - url_params={'role': 'slug'}, + url_params={'role_id': 'pk'}, verbose_name='Secrets' ) actions = ButtonsColumn(SecretRole) diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 968ba787d67..803675bfd78 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -40,7 +40,7 @@ class TenantGroupTable(BaseTable): ) tenant_count = LinkedCountColumn( viewname='tenancy:tenant_list', - url_params={'group': 'slug'}, + url_params={'group_id': 'pk'}, verbose_name='Tenants' ) actions = ButtonsColumn(TenantGroup) From 374cb74978f5955ef4cc509cb3799a9086148ed5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 11:53:40 -0400 Subject: [PATCH 167/223] Filter parent location by ID --- netbox/dcim/forms.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index cfac9402515..2e451ec9243 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -566,7 +566,7 @@ class LocationFilterForm(BootstrapMixin, forms.Form): }, label=_('Site') ) - parent = DynamicModelMultipleChoiceField( + parent_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), required=False, query_params={ @@ -1082,13 +1082,13 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFi location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.prefetch_related('site'), required=False, - label='Location', + label=_('Location'), null_option='None' ) user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), required=False, - label='User', + label=_('User'), widget=APISelectMultiple( api_url='/api/users/users/', ) @@ -2406,14 +2406,16 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - required=False + required=False, + label=_('Region') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, query_params={ 'region_id': '$region_id' - } + }, + label=_('Site') ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), @@ -4351,7 +4353,7 @@ class CableFilterForm(BootstrapMixin, CustomFieldFilterForm): rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, - label='Rack', + label=_('Rack'), null_option='None', query_params={ 'site_id': '$site_id' From 83496c66d19232770ab084b8f4d8e9074d68f8d7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 12:06:59 -0400 Subject: [PATCH 168/223] Move breadcrumb generation to template --- netbox/dcim/views.py | 41 -------------------- netbox/templates/dcim/consoleport.html | 6 +++ netbox/templates/dcim/consoleserverport.html | 6 +++ netbox/templates/dcim/device_component.html | 4 -- netbox/templates/dcim/devicebay.html | 6 +++ netbox/templates/dcim/frontport.html | 6 +++ netbox/templates/dcim/interface.html | 6 +++ netbox/templates/dcim/inventoryitem.html | 6 +++ netbox/templates/dcim/location.html | 2 +- netbox/templates/dcim/poweroutlet.html | 6 +++ netbox/templates/dcim/powerport.html | 6 +++ netbox/templates/dcim/rearport.html | 6 +++ 12 files changed, 55 insertions(+), 46 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 728888e5aa3..bdfa5129d28 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1587,11 +1587,6 @@ class ConsolePortListView(generic.ObjectListView): class ConsolePortView(generic.ObjectView): queryset = ConsolePort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_consoleports' - } - class ConsolePortCreateView(generic.ComponentCreateView): queryset = ConsolePort.objects.all() @@ -1652,11 +1647,6 @@ class ConsoleServerPortListView(generic.ObjectListView): class ConsoleServerPortView(generic.ObjectView): queryset = ConsoleServerPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_consoleserverports' - } - class ConsoleServerPortCreateView(generic.ComponentCreateView): queryset = ConsoleServerPort.objects.all() @@ -1717,11 +1707,6 @@ class PowerPortListView(generic.ObjectListView): class PowerPortView(generic.ObjectView): queryset = PowerPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_powerports' - } - class PowerPortCreateView(generic.ComponentCreateView): queryset = PowerPort.objects.all() @@ -1782,11 +1767,6 @@ class PowerOutletListView(generic.ObjectListView): class PowerOutletView(generic.ObjectView): queryset = PowerOutlet.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_poweroutlets' - } - class PowerOutletCreateView(generic.ComponentCreateView): queryset = PowerOutlet.objects.all() @@ -1880,7 +1860,6 @@ class InterfaceView(generic.ObjectView): 'ipaddress_table': ipaddress_table, 'child_interfaces_table': child_interfaces_tables, 'vlan_table': vlan_table, - 'breadcrumb_url': 'dcim:device_interfaces' } @@ -1943,11 +1922,6 @@ class FrontPortListView(generic.ObjectListView): class FrontPortView(generic.ObjectView): queryset = FrontPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_frontports' - } - class FrontPortCreateView(generic.ComponentCreateView): queryset = FrontPort.objects.all() @@ -2008,11 +1982,6 @@ class RearPortListView(generic.ObjectListView): class RearPortView(generic.ObjectView): queryset = RearPort.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_rearports' - } - class RearPortCreateView(generic.ComponentCreateView): queryset = RearPort.objects.all() @@ -2073,11 +2042,6 @@ class DeviceBayListView(generic.ObjectListView): class DeviceBayView(generic.ObjectView): queryset = DeviceBay.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_devicebays' - } - class DeviceBayCreateView(generic.ComponentCreateView): queryset = DeviceBay.objects.all() @@ -2199,11 +2163,6 @@ class InventoryItemListView(generic.ObjectListView): class InventoryItemView(generic.ObjectView): queryset = InventoryItem.objects.all() - def get_extra_context(self, request, instance): - return { - 'breadcrumb_url': 'dcim:device_inventory' - } - class InventoryItemEditView(generic.ObjectEditView): queryset = InventoryItem.objects.all() diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index ce251670b78..afc34651dc6 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Console Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 69071c9371a..96dfa5761ff 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Console Server Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/device_component.html b/netbox/templates/dcim/device_component.html index a615092b9bd..1a3d0de4a76 100644 --- a/netbox/templates/dcim/device_component.html +++ b/netbox/templates/dcim/device_component.html @@ -9,8 +9,4 @@ {% block breadcrumbs %}
  • Devices
  • {{ object.device }}
  • - {% if breadcrumb_url %} -
  • {{ object|meta:"verbose_name_plural"|bettertitle }}
  • - {% endif %} -
  • {{ object }}
  • {% endblock %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index 13b01981200..18da55ac096 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Device Bays
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 5a84889ab83..28e5c160cb8 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Front Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index b85dafb4b5f..d897b9fa64c 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -3,6 +3,12 @@ {% load plugins %} {% load render_table from django_tables2 %} +{% block breadcrumbs %} + {{ block.super }} +
  • Interfaces
  • +
  • {{ object }}
  • +{% endblock %} + {% block buttons %} {% if perms.dcim.add_interface and not object.is_virtual %} diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 6eec7f43471..35aace97159 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Inventory Items
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 8fc465a6b0b..0371eeef49e 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -3,7 +3,7 @@ {% load plugins %} {% block breadcrumbs %} -
  • Location
  • +
  • Locations
  • {% for location in object.get_ancestors %}
  • {{ location }}
  • {% endfor %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index e76f067b1c4..9a11628c83e 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Power Outlets
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index 635251b0d06..1a7cc24e76c 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Power Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 01eb0e9e614..eb9452a0c18 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -2,6 +2,12 @@ {% load helpers %} {% load plugins %} +{% block breadcrumbs %} + {{ block.super }} +
  • Rear Ports
  • +
  • {{ object }}
  • +{% endblock %} + {% block content %}
    From 266482649fd5b60e7f7e7de1b757e5ec2d10d243 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 12:55:57 -0400 Subject: [PATCH 169/223] Update VLAN documentation --- docs/models/ipam/vlan.md | 2 +- docs/models/ipam/vlangroup.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/models/ipam/vlan.md b/docs/models/ipam/vlan.md index f252204c55d..c7aa0d05f10 100644 --- a/docs/models/ipam/vlan.md +++ b/docs/models/ipam/vlan.md @@ -1,6 +1,6 @@ # VLANs -A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site, tenant, and/or VLAN group. +A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs are arranged into VLAN groups to define scope and to enforce uniqueness. Each VLAN must be assigned one of the following operational statuses: diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 7a0bb80ff1e..819d45982cf 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -1,5 +1,5 @@ # VLAN Groups -VLAN groups can be used to organize VLANs within NetBox. Each group may optionally be assigned to a specific site, but a group cannot belong to multiple sites. +VLAN groups can be used to organize VLANs within NetBox. Each VLAN group can be scoped to a particular region, site group, site, location, rack, cluster group, or cluster. Member VLANs will be available for assignment to devices and/or virtual machines within the specified scope. Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. From d844ee2c79ee6d486471947f3947d2633b7ee053 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 13:05:07 -0400 Subject: [PATCH 170/223] Empty time_zone should be null --- netbox/dcim/forms.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 2e451ec9243..f2cf48d4904 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -309,6 +309,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False ) slug = SlugField() + time_zone = TimeZoneFormField( + choices=add_blank_choice(TimeZoneFormField().choices), + required=False, + widget=StaticSelect2() + ) comments = CommentField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), From 2e07ac5a470b2dfa8f99fff32a60b4e8d9250a69 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 13:18:19 -0400 Subject: [PATCH 171/223] Rearrange provider, provider network templates --- .../circuits/circuittermination_edit.html | 2 +- netbox/templates/circuits/provider.html | 16 +++++++---- .../templates/circuits/providernetwork.html | 28 +++++++++++-------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 4034695d5c5..297e4679803 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -26,6 +26,7 @@

    {{ form.term_side.value }}

    + {% render_field form.mark_connected %} {% with providernetwork_tab_active=form.initial.provider_network %}
    {% endwith %} - {% render_field form.mark_connected %}
    diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 85cd4352c4f..718d7f65e0a 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -10,7 +10,7 @@ {% block content %}
    -
    +
    Provider @@ -50,6 +50,9 @@
    + {% plugin_left_page object %} +
    +
    {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
    @@ -64,9 +67,11 @@ {% endif %}
    - {% plugin_left_page object %} -
    -
    + {% plugin_right_page object %} +
    +
    +
    +
    Circuits @@ -80,8 +85,7 @@
    {% endif %}
    - {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} - {% plugin_right_page object %} + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
    diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html index 5bc3ef271d4..22ab3a7c028 100644 --- a/netbox/templates/circuits/providernetwork.html +++ b/netbox/templates/circuits/providernetwork.html @@ -11,7 +11,7 @@ {% block content %}
    -
    +
    Provider Network @@ -33,6 +33,9 @@
    + {% plugin_left_page object %} +
    +
    Comments @@ -47,18 +50,19 @@
    {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:providernetwork_list' %} - {% plugin_left_page object %} -
    -
    -
    -
    - Circuits -
    - {% include 'inc/table.html' with table=circuits_table %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Circuits +
    + {% include 'inc/table.html' with table=circuits_table %} +
    + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
    - {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} - {% plugin_right_page object %} -
    From aa2beb1d78205f9a7da1a444f7bba1d93af4a190 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 13:53:25 -0400 Subject: [PATCH 172/223] Add tagged items count to tag view --- netbox/extras/views.py | 11 +++++++++-- netbox/templates/extras/tag.html | 23 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index cd564b8d18e..4b62604b924 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,7 +1,6 @@ -from django import template from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.db.models import Q +from django.db.models import Count, Q from django.http import Http404, HttpResponseForbidden from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -45,9 +44,17 @@ class TagView(generic.ObjectView): ) paginate_table(taggeditem_table, request) + object_types = [ + { + 'content_type': ContentType.objects.get(pk=ti['content_type']), + 'item_count': ti['item_count'] + } for ti in tagged_items.values('content_type').annotate(item_count=Count('pk')) + ] + return { 'taggeditem_table': taggeditem_table, 'tagged_item_count': tagged_items.count(), + 'object_types': object_types, } diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index ae90fdb9c69..f8e93599209 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -40,6 +40,27 @@ {% plugin_left_page object %}
    +
    +
    + Tagged Item Types +
    + + {% for object_type in object_types %} + + + + + {% endfor %} +
    {{ object_type.content_type.name|bettertitle }} + {% with viewname=object_type.content_type.model_class|validated_viewname:"list" %} + {% if viewname %} + {{ object_type.item_count }} + {% else %} + {{ object_type.item_count }} + {% endif %} + {% endwith %} +
    +
    {% plugin_right_page object %}
    @@ -47,7 +68,7 @@
    - Device Types + Tagged Items
    {% include 'inc/table.html' with table=taggeditem_table %}
    From 85359bb10fe47a4cde57bbb78d7720381dc60e94 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 14:05:28 -0400 Subject: [PATCH 173/223] Fix ContentType assignment --- netbox/ipam/migrations/0046_set_vlangroup_scope_types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/migrations/0046_set_vlangroup_scope_types.py b/netbox/ipam/migrations/0046_set_vlangroup_scope_types.py index 2066259f8d0..ad636e47f47 100644 --- a/netbox/ipam/migrations/0046_set_vlangroup_scope_types.py +++ b/netbox/ipam/migrations/0046_set_vlangroup_scope_types.py @@ -6,11 +6,11 @@ def set_scope_types(apps, schema_editor): Set 'site' as the scope type for all VLANGroups with a scope ID defined. """ ContentType = apps.get_model('contenttypes', 'ContentType') + Site = apps.get_model('dcim', 'Site') VLANGroup = apps.get_model('ipam', 'VLANGroup') - site_ct = ContentType.objects.get(app_label='dcim', model='site').pk VLANGroup.objects.filter(scope_id__isnull=False).update( - scope_type=site_ct + scope_type=ContentType.objects.get_for_model(Site) ) From 3cfab25f8abee890f264549170b54371439b6cbe Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 14:35:09 -0400 Subject: [PATCH 174/223] Tweak JournalEntry form layout --- netbox/templates/extras/object_journal.html | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/netbox/templates/extras/object_journal.html b/netbox/templates/extras/object_journal.html index 22672628481..6d0a76e737d 100644 --- a/netbox/templates/extras/object_journal.html +++ b/netbox/templates/extras/object_journal.html @@ -15,14 +15,12 @@ {{ field }} {% endfor %}
    -
    +
    {% render_field form.kind %} {% render_field form.comments %} -
    -
    - - Cancel -
    +
    + + Cancel
    From 19cb575b90275afd79798fd74305bd2f2dd9f8b8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 14:48:11 -0400 Subject: [PATCH 175/223] Permit the assignment of virtual interfaces as parents --- netbox/dcim/forms.py | 6 +----- netbox/dcim/models/device_components.py | 8 ++++---- netbox/templates/dcim/interface.html | 10 ++++------ 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f2cf48d4904..207dee0a887 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3148,7 +3148,6 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False, query_params={ 'device_id': '$device', - 'kind': 'physical', } ) lag = DynamicModelChoiceField( @@ -3235,10 +3234,7 @@ class InterfaceBulkEditForm( ) parent = DynamicModelChoiceField( queryset=Interface.objects.all(), - required=False, - query_params={ - 'kind': 'physical', - } + required=False ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a7357ae79bd..1bfa5ddd3c4 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -623,14 +623,14 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): f"is not part of virtual chassis {self.device.virtual_chassis}." }) + # An interface cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({'parent': "An interface cannot be its own parent."}) + # A physical interface cannot have a parent interface if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) - # A virtual interface cannot be a parent interface - if self.parent is not None and self.parent.type == InterfaceTypeChoices.TYPE_VIRTUAL: - raise ValidationError({'parent': "Virtual interfaces may not be parents of other interfaces."}) - # An interface's LAG must belong to the same device or virtual chassis if self.lag and self.lag.device != self.device: if self.device.virtual_chassis is None: diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index d897b9fa64c..9f85196feec 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -281,13 +281,11 @@ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
    - {% if not object.is_virtual %} -
    -
    - {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %} -
    +
    +
    + {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
    - {% endif %} +
    {% plugin_full_width_page object %} From 3ad7622bf039d09f08d93a4c6a385d1577e48aca Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 15:11:29 -0400 Subject: [PATCH 176/223] Catch AttributeError when generating ContentType labels --- netbox/utilities/forms/fields.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index f0be3319502..bb74ead9905 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -123,8 +123,11 @@ class ContentTypeChoiceMixin: super().__init__(queryset, *args, **kwargs) def label_from_instance(self, obj): - meta = obj.model_class()._meta - return f'{meta.app_config.verbose_name} > {meta.verbose_name}' + try: + meta = obj.model_class()._meta + return f'{meta.app_config.verbose_name} > {meta.verbose_name}' + except AttributeError: + return super().label_from_instance(obj) class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField): From ae18693715e8c69917469c5996a370cdf8f960a8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 15:13:35 -0400 Subject: [PATCH 177/223] Add 'add export template' link to dropdown --- netbox/utilities/templates/buttons/export.html | 4 ++++ netbox/utilities/templatetags/buttons.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html index e7e246739f8..5a8f89758e5 100644 --- a/netbox/utilities/templates/buttons/export.html +++ b/netbox/utilities/templates/buttons/export.html @@ -12,5 +12,9 @@
  • {{ et.name }}
  • {% endfor %} {% endif %} + {% if add_exporttemplate_link %} +
  • +
  • Add export template...
  • + {% endif %}
    diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index c486dd2e51b..e7ebd26a010 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -82,13 +82,18 @@ def import_button(url): @register.inclusion_tag('buttons/export.html', takes_context=True) def export_button(context, content_type=None): + add_exporttemplate_link = None + if content_type is not None: user = context['request'].user export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type) + if user.is_staff and user.has_perm('extras.add_exporttemplate'): + add_exporttemplate_link = f"{reverse('admin:extras_exporttemplate_add')}?content_type={content_type.pk}" else: export_templates = [] return { 'url_params': context['request'].GET, 'export_templates': export_templates, + 'add_exporttemplate_link': add_exporttemplate_link, } From a313b675a687fab7c13e889e58a55c662aee531c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 15:24:57 -0400 Subject: [PATCH 178/223] Simplify CircuitTermination display in circuits table --- netbox/circuits/tables.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index ba599757a75..41a3aed7fc5 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -6,6 +6,15 @@ from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagCol from .models import * +CIRCUITTERMINATION_LINK = """ +{% if value.site %} + {{ value.site }} +{% elif value.provider_network %} + {{ value.provider_network }} +{% endif %} +""" + + # # Providers # @@ -88,12 +97,12 @@ class CircuitTable(BaseTable): ) status = ChoiceFieldColumn() tenant = TenantColumn() - termination_a = tables.Column( - linkify=True, + termination_a = tables.TemplateColumn( + template_code=CIRCUITTERMINATION_LINK, verbose_name='Side A' ) - termination_z = tables.Column( - linkify=True, + termination_z = tables.TemplateColumn( + template_code=CIRCUITTERMINATION_LINK, verbose_name='Side Z' ) tags = TagColumn( From 71022d58d30ca6469308f17256248deef23b1fe9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 16:35:01 -0400 Subject: [PATCH 179/223] Site is required when creating devices --- netbox/dcim/forms.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 207dee0a887..52c2e9ec796 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2036,7 +2036,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, query_params={ 'region_id': '$region', 'group_id': '$site_group', From e543b305c313b66636acb27ecc19e03b100106c0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 5 Apr 2021 21:46:48 -0400 Subject: [PATCH 180/223] Docs cleanup --- docs/installation/1-postgresql.md | 14 ++++++-------- docs/installation/3-netbox.md | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 1db490798b9..39008f18875 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -7,8 +7,6 @@ This section entails the installation and configuration of a local PostgreSQL da ## Installation -## Installation - === "Ubuntu" ```no-highlight @@ -26,14 +24,14 @@ This section entails the installation and configuration of a local PostgreSQL da !!! info PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/). -CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below: + CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below: -```no-highlight -host all all 127.0.0.1/32 md5 -host all all ::1/128 md5 -``` + ```no-highlight + host all all 127.0.0.1/32 md5 + host all all ::1/128 md5 + ``` -Then, start the service and enable it to run at boot: +Once PostgreSQL has been installed, start the service and enable it to run at boot: ```no-highlight sudo systemctl start postgresql diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 8c0fd827e60..649898e0a77 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -264,7 +264,7 @@ Quit the server with CONTROL-C. Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, . You should be greeted with the NetBox home page. -!!! warning +!!! danger The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.** !!! warning From 838200219f31916e39009503c2b59e617a129fd5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 6 Apr 2021 09:42:36 -0400 Subject: [PATCH 181/223] Include child regions, site groups --- netbox/dcim/tables/sites.py | 12 +++++++++--- netbox/dcim/views.py | 26 ++++++++++++++++++++++++-- netbox/templates/dcim/region.html | 15 ++++++++++++++- netbox/templates/dcim/sitegroup.html | 21 +++++++++++++++++---- 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 2cee436d7b2..288fa5753d9 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -2,7 +2,9 @@ import django_tables2 as tables from dcim.models import Region, Site, SiteGroup from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MPTTColumn, TagColumn, ToggleColumn +from utilities.tables import ( + BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, +) __all__ = ( 'RegionTable', @@ -20,7 +22,9 @@ class RegionTable(BaseTable): name = MPTTColumn( linkify=True ) - site_count = tables.Column( + site_count = LinkedCountColumn( + viewname='dcim:site_list', + url_params={'region_id': 'pk'}, verbose_name='Sites' ) actions = ButtonsColumn(Region) @@ -40,7 +44,9 @@ class SiteGroupTable(BaseTable): name = MPTTColumn( linkify=True ) - site_count = tables.Column( + site_count = LinkedCountColumn( + viewname='dcim:site_list', + url_params={'group_id': 'pk'}, verbose_name='Sites' ) actions = ButtonsColumn(SiteGroup) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bdfa5129d28..d892c082370 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -116,15 +116,26 @@ class RegionView(generic.ObjectView): queryset = Region.objects.all() def get_extra_context(self, request, instance): + child_regions = Region.objects.add_related_count( + Region.objects.all(), + Site, + 'region', + 'site_count', + cumulative=True + ).restrict(request.user, 'view').filter( + parent__in=instance.get_descendants(include_self=True) + ) + child_regions_table = tables.RegionTable(child_regions) + sites = Site.objects.restrict(request.user, 'view').filter( region=instance ) - sites_table = tables.SiteTable(sites) sites_table.columns.hide('region') paginate_table(sites_table, request) return { + 'child_regions_table': child_regions_table, 'sites_table': sites_table, } @@ -190,15 +201,26 @@ class SiteGroupView(generic.ObjectView): queryset = SiteGroup.objects.all() def get_extra_context(self, request, instance): + child_groups = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ).restrict(request.user, 'view').filter( + parent__in=instance.get_descendants(include_self=True) + ) + child_groups_table = tables.SiteGroupTable(child_groups) + sites = Site.objects.restrict(request.user, 'view').filter( group=instance ) - sites_table = tables.SiteTable(sites) sites_table.columns.hide('group') paginate_table(sites_table, request) return { + 'child_groups_table': child_groups_table, 'sites_table': sites_table, } diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index c79336962c6..1e2d395ddc5 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -44,10 +44,23 @@
    + {% include 'inc/custom_fields_panel.html' %} {% plugin_left_page object %}
    - {% include 'inc/custom_fields_panel.html' %} +
    +
    + Child Regions +
    + {% include 'inc/table.html' with table=child_regions_table %} + {% if perms.dcim.add_region %} + + {% endif %} +
    {% plugin_right_page object %}
    diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 95cba7aebb8..2de17c02539 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -44,10 +44,23 @@
    + {% include 'inc/custom_fields_panel.html' %} {% plugin_left_page object %}
    - {% include 'inc/custom_fields_panel.html' %} +
    +
    + Child Groups +
    + {% include 'inc/table.html' with table=child_groups_table %} + {% if perms.dcim.add_sitegroup %} + + {% endif %} +
    {% plugin_right_page object %}
    @@ -65,9 +78,9 @@
    {% endif %} - - {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} - {% plugin_full_width_page object %} + + {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %} {% endblock %} From 8f674aede9900dea4430b2d87b639b905827c722 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 6 Apr 2021 09:45:59 -0400 Subject: [PATCH 182/223] Bump Django to 3.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9b883b554fc..0e20388fe95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==3.2b1 +Django==3.2 django-cacheops==5.1 django-cors-headers==3.7.0 django-debug-toolbar==3.2 From 0635e7ae1e57ec9abc1138e95c7e3c2905f314f0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 6 Apr 2021 11:36:30 -0400 Subject: [PATCH 183/223] Update dependencies for v2.11-beta1 --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0e20388fe95..cb277e9165f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,17 +6,17 @@ django-filter==2.4.0 django-mptt==0.12.0 django-pglocks==1.0.4 django-prometheus==2.1.0 -django-rq==2.4.0 +django-rq==2.4.1 django-tables2==2.3.4 django-taggit==1.3.0 django-timezone-field==4.1.2 djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 -gunicorn==20.0.4 +gunicorn==20.1.0 Jinja2==2.11.3 Markdown==3.3.4 netaddr==0.8.0 -Pillow==8.1.2 +Pillow==8.2.0 psycopg2-binary==2.8.6 pycryptodome==3.10.1 PyYAML==5.4.1 From f1e2b994569062b054d27232a1a2a6a7199e75e1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 6 Apr 2021 11:45:32 -0400 Subject: [PATCH 184/223] Release v2.11-beta1 --- docs/release-notes/version-2.11.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 9fa97cf96a0..bf1914ba42f 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -1,6 +1,6 @@ # NetBox v2.11 -## v2.11-beta1 (FUTURE) +## v2.11-beta1 (2021-04-07) **WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost. From b5ad29e3f27e48d96936faddda5894b29a217913 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Apr 2021 15:17:02 -0400 Subject: [PATCH 185/223] Fixes #6100: Fix VM interfaces table "add interfaces" link --- docs/release-notes/version-2.11.md | 10 +++++++++- netbox/netbox/settings.py | 2 +- .../virtualization/virtualmachine/interfaces.html | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index bf1914ba42f..b3f68f8e9e6 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -1,6 +1,14 @@ # NetBox v2.11 -## v2.11-beta1 (2021-04-07) +## v2.11.0 (FUTURE) + +### Bug Fixes (from Beta) + +* [#6100](https://github.com/netbox-community/netbox/issues/6100) - Fix VM interfaces table "add interfaces" link + +--- + +## v2.11-beta1 (2021-04-06) **WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost. diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bd9b0e44c57..dd3de6fa09e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.11-beta1' +VERSION = '2.11.0-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/templates/virtualization/virtualmachine/interfaces.html b/netbox/templates/virtualization/virtualmachine/interfaces.html index 15d07310cdb..f45c990eb3d 100644 --- a/netbox/templates/virtualization/virtualmachine/interfaces.html +++ b/netbox/templates/virtualization/virtualmachine/interfaces.html @@ -35,7 +35,7 @@ {% endif %} {% if perms.virtualization.add_vminterface %} From 59e185b781fcab297fa0d21a8efcbcf7695dfecf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Apr 2021 15:40:03 -0400 Subject: [PATCH 186/223] Fixes #6104: Fix location column on racks table --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/tables/racks.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index b3f68f8e9e6..9e8ae26e720 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -5,6 +5,7 @@ ### Bug Fixes (from Beta) * [#6100](https://github.com/netbox-community/netbox/issues/6100) - Fix VM interfaces table "add interfaces" link +* [#6104](https://github.com/netbox-community/netbox/issues/6104) - Fix location column on racks table --- diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index b56e3bbc4a0..b549bad103c 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -71,7 +71,7 @@ class RackTable(BaseTable): order_by=('_name',), linkify=True ) - group = tables.Column( + location = tables.Column( linkify=True ) site = tables.Column( @@ -88,10 +88,10 @@ class RackTable(BaseTable): class Meta(BaseTable.Meta): model = Rack fields = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', + 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', ) - default_columns = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height') + default_columns = ('pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height') class RackDetailTable(RackTable): @@ -113,11 +113,11 @@ class RackDetailTable(RackTable): class Meta(RackTable.Meta): fields = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', + 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', ) default_columns = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', + 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', ) From 38b09dc6101fd17476deb13979666f251e5bf473 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Apr 2021 16:26:16 -0400 Subject: [PATCH 187/223] Fixes #6105: Hide checkboxes for VMs under cluster VMs view --- docs/release-notes/version-2.11.md | 1 + netbox/virtualization/views.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 9e8ae26e720..b8260601008 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -6,6 +6,7 @@ * [#6100](https://github.com/netbox-community/netbox/issues/6100) - Fix VM interfaces table "add interfaces" link * [#6104](https://github.com/netbox-community/netbox/issues/6104) - Fix location column on racks table +* [#6105](https://github.com/netbox-community/netbox/issues/6105) - Hide checkboxes for VMs under cluster VMs view --- diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 4a6ad66b57f..6bf9eb6dd72 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -163,8 +163,6 @@ class ClusterVirtualMachinesView(generic.ObjectView): def get_extra_context(self, request, instance): virtualmachines = VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=instance) virtualmachines_table = tables.VirtualMachineTable(virtualmachines, orderable=False) - if request.user.has_perm('virtualization.change_cluster'): - virtualmachines_table.columns.show('pk') return { 'virtualmachines_table': virtualmachines_table, From 81193eb55054763ede647b0eb27cf61632b5ba3d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Apr 2021 16:36:09 -0400 Subject: [PATCH 188/223] Fixes #6106: Allow assigning a virtual interface as the parent of an existing interface --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/forms.py | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index b8260601008..c474bd6b380 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -7,6 +7,7 @@ * [#6100](https://github.com/netbox-community/netbox/issues/6100) - Fix VM interfaces table "add interfaces" link * [#6104](https://github.com/netbox-community/netbox/issues/6104) - Fix location column on racks table * [#6105](https://github.com/netbox-community/netbox/issues/6105) - Hide checkboxes for VMs under cluster VMs view +* [#6106](https://github.com/netbox-community/netbox/issues/6106) - Allow assigning a virtual interface as the parent of an existing interface --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 52c2e9ec796..5e322b3401e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3072,10 +3072,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent interface', - query_params={ - 'kind': 'physical', - } + label='Parent interface' ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), From 4f7626828a688d1ba25e9e73834568b4e8e943f9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Apr 2021 16:58:40 -0400 Subject: [PATCH 189/223] Fixes #6107: Fix rack selection field on device form --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/forms.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index c474bd6b380..9de6d2356b6 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -8,6 +8,7 @@ * [#6104](https://github.com/netbox-community/netbox/issues/6104) - Fix location column on racks table * [#6105](https://github.com/netbox-community/netbox/issues/6105) - Hide checkboxes for VMs under cluster VMs view * [#6106](https://github.com/netbox-community/netbox/issues/6106) - Allow assigning a virtual interface as the parent of an existing interface +* [#6107](https://github.com/netbox-community/netbox/issues/6107) - Fix rack selection field on device form --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 5e322b3401e..9437225de4c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2056,7 +2056,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False, query_params={ 'site_id': '$site', - 'location_id': 'location', + 'location_id': '$location', } ) position = forms.IntegerField( From 05d8a06cd5f62052d4ed35015906f7ca43a2b0e5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 8 Apr 2021 10:08:50 -0400 Subject: [PATCH 190/223] Closes #6109: Add device counts to locations table --- docs/release-notes/version-2.11.md | 4 ++++ netbox/dcim/tables/racks.py | 34 +++--------------------------- netbox/dcim/tables/sites.py | 33 ++++++++++++++++++++++++++++- netbox/dcim/views.py | 10 +++++++-- 4 files changed, 47 insertions(+), 34 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 9de6d2356b6..8183431bc48 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -2,6 +2,10 @@ ## v2.11.0 (FUTURE) +### Enhancements (from Beta) + +* [#6109](https://github.com/netbox-community/netbox/issues/6109) - Add device counts to locations table + ### Bug Fixes (from Beta) * [#6100](https://github.com/netbox-community/netbox/issues/6100) - Fix VM interfaces table "add interfaces" link diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index b549bad103c..3a63eef1ec5 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -1,49 +1,21 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from dcim.models import Rack, Location, RackReservation, RackRole +from dcim.models import Rack, RackReservation, RackRole from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MPTTColumn, - TagColumn, ToggleColumn, UtilizationColumn, + BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, + ToggleColumn, UtilizationColumn, ) -from .template_code import LOCATION_ELEVATIONS __all__ = ( 'RackTable', 'RackDetailTable', - 'LocationTable', 'RackReservationTable', 'RackRoleTable', ) -# -# Locations -# - -class LocationTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( - linkify=True - ) - site = tables.Column( - linkify=True - ) - rack_count = tables.Column( - verbose_name='Racks' - ) - actions = ButtonsColumn( - model=Location, - prepend_template=LOCATION_ELEVATIONS - ) - - class Meta(BaseTable.Meta): - model = Location - fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions') - default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions') - - # # Rack roles # diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 288fa5753d9..b7d46eba5a2 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,12 +1,14 @@ import django_tables2 as tables -from dcim.models import Region, Site, SiteGroup +from dcim.models import Location, Region, Site, SiteGroup from tenancy.tables import TenantColumn from utilities.tables import ( BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, ) +from .template_code import LOCATION_ELEVATIONS __all__ = ( + 'LocationTable', 'RegionTable', 'SiteTable', 'SiteGroupTable', @@ -86,3 +88,32 @@ class SiteTable(BaseTable): 'contact_email', 'tags', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description') + + +# +# Locations +# + +class LocationTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + site = tables.Column( + linkify=True + ) + rack_count = tables.Column( + verbose_name='Racks' + ) + device_count = tables.Column( + verbose_name='Devices' + ) + actions = ButtonsColumn( + model=Location, + prepend_template=LOCATION_ELEVATIONS + ) + + class Meta(BaseTable.Meta): + model = Location + fields = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'actions') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d892c082370..467382a7bd1 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -338,12 +338,18 @@ class SiteBulkDeleteView(generic.BulkDeleteView): # -# Rack groups +# Locations # class LocationListView(generic.ObjectListView): queryset = Location.objects.add_related_count( - Location.objects.all(), + Location.objects.add_related_count( + Location.objects.all(), + Device, + 'location', + 'device_count', + cumulative=True + ), Rack, 'location', 'rack_count', From 54d9ca8ed878c05744008f2499682374e165292a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 8 Apr 2021 10:10:46 -0400 Subject: [PATCH 191/223] Add racks count to location view --- netbox/templates/dcim/location.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 0371eeef49e..0efb742446b 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -40,6 +40,12 @@ {% endif %} + + Racks + + {{ object.racks.count }} + + Devices From d6fcd22752ef510b0fdf602cb48889c6f5001ffb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 8 Apr 2021 10:30:13 -0400 Subject: [PATCH 192/223] Fixes #6110: Fix handling of TemplateColumn values for table export --- docs/release-notes/version-2.11.md | 1 + netbox/utilities/tables.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 8183431bc48..5038cf29973 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -13,6 +13,7 @@ * [#6105](https://github.com/netbox-community/netbox/issues/6105) - Hide checkboxes for VMs under cluster VMs view * [#6106](https://github.com/netbox-community/netbox/issues/6106) - Allow assigning a virtual interface as the parent of an existing interface * [#6107](https://github.com/netbox-community/netbox/issues/6107) - Fix rack selection field on device form +* [#6110](https://github.com/netbox-community/netbox/issues/6110) - Fix handling of TemplateColumn values for table export --- diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 7e9cc9c3007..5bb5e9bbffe 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -15,15 +15,16 @@ from extras.models import CustomField from .paginator import EnhancedPaginator, get_paginate_count -def stripped_value(self, value): +def stripped_value(self, **kwargs): """ Replaces TemplateColumn's value() method to both strip HTML tags and remove any leading/trailing whitespace. """ - return strip_tags(value).strip() + html = super(tables.TemplateColumn, self).value(**kwargs) + return strip_tags(html).strip() if isinstance(html, str) else html # TODO: We're monkey-patching TemplateColumn here to strip leading/trailing whitespace. This will no longer -# be necessary if django-tables2 PR #794 is accepted. (See #5926) +# be necessary under django-tables2 v2.3.5+. (See #5926) tables.TemplateColumn.value = stripped_value From 696b5c80a7555055359808f187993bf3703806ec Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 8 Apr 2021 13:25:29 -0400 Subject: [PATCH 193/223] Closes #6097: Redirect old slug-based object views --- docs/release-notes/version-2.11.md | 1 + netbox/circuits/urls.py | 2 ++ netbox/dcim/urls.py | 2 ++ netbox/tenancy/urls.py | 2 ++ netbox/utilities/views.py | 13 +++++++++++++ 5 files changed, 20 insertions(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 5038cf29973..356577d0953 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -4,6 +4,7 @@ ### Enhancements (from Beta) +* [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views * [#6109](https://github.com/netbox-community/netbox/issues/6109) - Add device counts to locations table ### Bug Fixes (from Beta) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index e634eeeb4ac..1cea1965e54 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -2,6 +2,7 @@ from django.urls import path from dcim.views import CableCreateView, PathTraceView from extras.views import ObjectChangeLogView, ObjectJournalView +from utilities.views import SlugRedirectView from . import views from .models import * @@ -15,6 +16,7 @@ urlpatterns = [ path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), path('providers//', views.ProviderView.as_view(), name='provider'), + path('providers//', SlugRedirectView.as_view(), kwargs={'model': Provider}), path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index b23603c978e..3c84aae01e8 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -2,6 +2,7 @@ from django.urls import path from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView from ipam.views import ServiceEditView +from utilities.views import SlugRedirectView from . import views from .models import * @@ -37,6 +38,7 @@ urlpatterns = [ path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), path('sites//', views.SiteView.as_view(), name='site'), + path('sites//', SlugRedirectView.as_view(), kwargs={'model': Site}), path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index a3db431da55..a1f46c7ec2b 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -1,6 +1,7 @@ from django.urls import path from extras.views import ObjectChangeLogView, ObjectJournalView +from utilities.views import SlugRedirectView from . import views from .models import Tenant, TenantGroup @@ -25,6 +26,7 @@ urlpatterns = [ path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), path('tenants//', views.TenantView.as_view(), name='tenant'), + path('tenants//', SlugRedirectView.as_view(), kwargs={'model': Tenant}), path('tenants//edit/', views.TenantEditView.as_view(), name='tenant_edit'), path('tenants//delete/', views.TenantDeleteView.as_view(), name='tenant_delete'), path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index c291a3cf23c..a3afcb1c6e1 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,8 +1,10 @@ from django.contrib.auth.mixins import AccessMixin from django.core.exceptions import ImproperlyConfigured +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.urls.exceptions import NoReverseMatch from django.utils.http import is_safe_url +from django.views.generic import View from .permissions import resolve_permission @@ -123,3 +125,14 @@ class GetReturnURLMixin: # If all else fails, return home. Ideally this should never happen. return reverse('home') + + +# +# Views +# + +class SlugRedirectView(View): + + def get(self, request, model, slug): + obj = get_object_or_404(model.objects.restrict(request.user, 'view'), slug=slug) + return redirect(obj.get_absolute_url()) From 9e62d1ad8f864802cd627ba5e62a3a6e9d9b9422 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 9 Apr 2021 09:43:35 -0400 Subject: [PATCH 194/223] Fixes #6130: Improve display of assigned models in custom fields list --- docs/release-notes/version-2.11.md | 1 + netbox/extras/admin.py | 5 ++++- netbox/utilities/forms/fields.py | 4 ++-- netbox/utilities/utils.py | 8 ++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 356577d0953..ec53dee25a5 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -15,6 +15,7 @@ * [#6106](https://github.com/netbox-community/netbox/issues/6106) - Allow assigning a virtual interface as the parent of an existing interface * [#6107](https://github.com/netbox-community/netbox/issues/6107) - Fix rack selection field on device form * [#6110](https://github.com/netbox-community/netbox/issues/6110) - Fix handling of TemplateColumn values for table export +* [#6130](https://github.com/netbox-community/netbox/issues/6130) - Improve display of assigned models in custom fields list --- diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 90e7541e217..0ceb1cc5bcf 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,8 +1,10 @@ from django import forms from django.contrib import admin from django.contrib.contenttypes.models import ContentType +from django.utils.safestring import mark_safe from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField +from utilities.utils import content_type_name from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook from .utils import FeatureQuery @@ -110,7 +112,8 @@ class CustomFieldAdmin(admin.ModelAdmin): ) def models(self, obj): - return ', '.join([ct.name for ct in obj.content_types.all()]) + ct_names = [content_type_name(ct) for ct in obj.content_types.all()] + return mark_safe('
    '.join(ct_names)) # diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index bb74ead9905..9bc0e3df7fa 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -13,6 +13,7 @@ from django.forms import BoundField from django.urls import reverse from utilities.choices import unpack_grouped_choices +from utilities.utils import content_type_name from utilities.validators import EnhancedURLValidator from . import widgets from .constants import * @@ -124,8 +125,7 @@ class ContentTypeChoiceMixin: def label_from_instance(self, obj): try: - meta = obj.model_class()._meta - return f'{meta.app_config.verbose_name} > {meta.verbose_name}' + return content_type_name(obj) except AttributeError: return super().label_from_instance(obj) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index f0340f09479..e21b9a36d60 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -296,6 +296,14 @@ def array_to_string(array): return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) +def content_type_name(contenttype): + """ + Return a proper ContentType name. + """ + meta = contenttype.model_class()._meta + return f'{meta.app_config.verbose_name} > {meta.verbose_name}' + + # # Fake request object # From 6efe54aa88cdb759decc2aca76fb4551aac4e2be Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 9 Apr 2021 09:47:34 -0400 Subject: [PATCH 195/223] Closes #6125: Add locations count to home page --- docs/release-notes/version-2.11.md | 1 + netbox/netbox/views/__init__.py | 3 ++- netbox/templates/home.html | 12 +++++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index ec53dee25a5..d2ccb5282e0 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -6,6 +6,7 @@ * [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views * [#6109](https://github.com/netbox-community/netbox/issues/6109) - Add device counts to locations table +* [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page ### Bug Fixes (from Beta) diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index 5406e72069e..5cf560ada22 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -16,7 +16,7 @@ from packaging import version from circuits.models import Circuit, Provider from dcim.models import ( - Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site, + Cable, ConsolePort, Device, DeviceType, Interface, Location, PowerPanel, PowerFeed, PowerPort, Rack, Site, ) from extras.choices import JobResultStatusChoices from extras.models import ObjectChange, JobResult @@ -56,6 +56,7 @@ class HomeView(View): # Organization 'site_count': Site.objects.restrict(request.user, 'view').count(), + 'location_count': Location.objects.restrict(request.user, 'view').count(), 'tenant_count': Tenant.objects.restrict(request.user, 'view').count(), # DCIM diff --git a/netbox/templates/home.html b/netbox/templates/home.html index e9180eca3b5..273a78bc90f 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -31,7 +31,17 @@

    Sites

    {% endif %} -

    Geographic locations

    +

    Discrete points of presence

    + +
    + {% if perms.dcim.view_location %} + {{ stats.location_count }} +

    Locations

    + {% else %} + +

    Locations

    + {% endif %} +

    Locations within sites

    {% if perms.tenancy.view_tenant %} From 7439faad34301eb3dc546aea79d42ea79c4e6fe9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 9 Apr 2021 09:56:36 -0400 Subject: [PATCH 196/223] Fixes #6123: Prevent device from being assigned to mismatched site and location --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/models/devices.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index d2ccb5282e0..6a6b7f78a15 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -16,6 +16,7 @@ * [#6106](https://github.com/netbox-community/netbox/issues/6106) - Allow assigning a virtual interface as the parent of an existing interface * [#6107](https://github.com/netbox-community/netbox/issues/6107) - Fix rack selection field on device form * [#6110](https://github.com/netbox-community/netbox/issues/6110) - Fix handling of TemplateColumn values for table export +* [#6123](https://github.com/netbox-community/netbox/issues/6123) - Prevent device from being assigned to mismatched site and location * [#6130](https://github.com/netbox-community/netbox/issues/6130) - Improve display of assigned models in custom fields list --- diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index a5efadac56a..551fac2d4a4 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -652,6 +652,10 @@ class Device(PrimaryModel, ConfigContextModel): raise ValidationError({ 'rack': f"Rack {self.rack} does not belong to site {self.site}.", }) + if self.location and self.site != self.location.site: + raise ValidationError({ + 'location': f"Location {self.location} does not belong to site {self.site}.", + }) if self.rack and self.location and self.rack.location != self.location: raise ValidationError({ 'rack': f"Rack {self.rack} does not belong to location {self.location}.", From a3721a94ce4f13500d98eced74b7d5a4b84817d3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 9 Apr 2021 10:53:05 -0400 Subject: [PATCH 197/223] Closes #6121: Extend parent interface assignment to VM interfaces --- docs/release-notes/version-2.11.md | 5 ++- .../virtualization/virtualmachine/base.html | 2 +- .../templates/virtualization/vminterface.html | 23 +++++++++-- .../virtualization/vminterface_edit.html | 1 + netbox/virtualization/api/serializers.py | 5 ++- netbox/virtualization/api/views.py | 2 +- netbox/virtualization/filters.py | 5 +++ netbox/virtualization/forms.py | 39 ++++++++++++++++--- .../migrations/0022_vminterface_parent.py | 17 ++++++++ netbox/virtualization/models.py | 16 ++++++++ netbox/virtualization/tables.py | 7 +++- netbox/virtualization/tests/test_filters.py | 15 ++++++- netbox/virtualization/views.py | 9 +++++ 13 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 netbox/virtualization/migrations/0022_vminterface_parent.py diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 6a6b7f78a15..cd75d141b9c 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -6,6 +6,7 @@ * [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views * [#6109](https://github.com/netbox-community/netbox/issues/6109) - Add device counts to locations table +* [#6121](https://github.com/netbox-community/netbox/issues/6121) - Extend parent interface assignment to VM interfaces * [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page ### Bug Fixes (from Beta) @@ -44,7 +45,7 @@ NetBox now supports journaling for all primary objects. The journal is a collect #### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519)) -Virtual interfaces can now be assigned to a "parent" physical interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0. +Virtual device and VM interfaces can now be assigned to a "parent" interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0. #### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451)) @@ -186,3 +187,5 @@ A new provider network model has been introduced to represent the boundary of a * Dropped the `site` foreign key field * virtualization.VirtualMachine * `vcpus` has been changed from an integer to a decimal value +* virtualization.VMInterface + * Added the `parent` field diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html index 88f7da1decc..4d4f894a8dd 100644 --- a/netbox/templates/virtualization/virtualmachine/base.html +++ b/netbox/templates/virtualization/virtualmachine/base.html @@ -12,7 +12,7 @@ {% block buttons %} {% if perms.virtualization.add_vminterface %} - + Add Interfaces {% endif %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index e574e926e39..7141dcff1b6 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -39,6 +39,16 @@ {% endif %} + + Parent + + {% if object.parent %} + {{ object.parent }} + {% else %} + None + {% endif %} + + Description {{ object.description|placeholder }} @@ -91,9 +101,14 @@ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
    -
    -
    - {% plugin_full_width_page object %} -
    +
    +
    + {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    {% endblock %} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index c0ad6e98c7f..f3ab4f9c280 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -17,6 +17,7 @@ {% endif %} {% render_field form.name %} {% render_field form.enabled %} + {% render_field form.parent %} {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.description %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 0afa8f796a1..a1428f0cd12 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -106,6 +106,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class VMInterfaceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer() + parent = NestedVMInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -118,8 +119,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer): class Meta: model = VMInterface fields = [ - 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description', + 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', ] def validate(self, data): diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index ea2b33e4f42..5f67a7c74cd 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet) class VMInterfaceViewSet(ModelViewSet): queryset = VMInterface.objects.prefetch_related( - 'virtual_machine', 'tags', 'tagged_vlans' + 'virtual_machine', 'parent', 'tags', 'tagged_vlans' ) serializer_class = serializers.VMInterfaceSerializer filterset_class = filters.VMInterfaceFilterSet diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index be9b7074931..d710bcbe241 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -264,6 +264,11 @@ class VMInterfaceFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpda to_field_name='name', label='Virtual machine', ) + parent_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent', + queryset=VMInterface.objects.all(), + label='Parent interface (ID)', + ) mac_address = MultiValueMACAddressFilter( label='MAC address', ) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 014b73542aa..a5c6712872d 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -603,6 +603,11 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil # class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + label='Parent interface' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, @@ -621,8 +626,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) class Meta: model = VMInterface fields = [ - 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan', - 'tagged_vlans', + 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags', + 'untagged_vlan', 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), @@ -637,9 +642,12 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') + + # Restrict parent interface assignment by VM + self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id) # Limit VLAN choices by virtual machine - vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) @@ -655,6 +663,14 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): required=False, initial=True ) + parent = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + display_field='display_name', + query_params={ + 'virtualmachine_id': 'virtual_machine', + } + ) mtu = forms.IntegerField( required=False, min_value=INTERFACE_MTU_MIN, @@ -689,9 +705,12 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') + + # Restrict parent interface assignment by VM + self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id) # Limit VLAN choices by virtual machine - vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) @@ -730,6 +749,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): disabled=True, widget=forms.HiddenInput() ) + parent = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + display_field='display_name' + ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() @@ -760,14 +784,17 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): class Meta: nullable_fields = [ - 'mtu', 'description', + 'parent', 'mtu', 'description', ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') + + # Restrict parent interface assignment by VM + self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id) # Limit VLAN choices by virtual machine - vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) diff --git a/netbox/virtualization/migrations/0022_vminterface_parent.py b/netbox/virtualization/migrations/0022_vminterface_parent.py new file mode 100644 index 00000000000..d1249985f87 --- /dev/null +++ b/netbox/virtualization/migrations/0022_vminterface_parent.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0021_virtualmachine_vcpus_decimal'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='virtualization.vminterface'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index a34d096624d..ee8b3e62b78 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -395,6 +395,14 @@ class VMInterface(PrimaryModel, BaseInterface): max_length=200, blank=True ) + parent = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='child_interfaces', + null=True, + blank=True, + verbose_name='Parent interface' + ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -438,6 +446,7 @@ class VMInterface(PrimaryModel, BaseInterface): self.virtual_machine.name, self.name, self.enabled, + self.parent.name if self.parent else None, self.mac_address, self.mtu, self.description, @@ -447,6 +456,13 @@ class VMInterface(PrimaryModel, BaseInterface): def clean(self): super().clean() + # An interface's parent must belong to the same virtual machine + if self.parent and self.parent.virtual_machine != self.virtual_machine: + raise ValidationError({ + 'parent': f"The selected parent interface ({self.parent}) belongs to a different virtual machine " + f"({self.parent.virtual_machine})." + }) + # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: raise ValidationError({ diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index c30f1416593..65bd2b5d179 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -170,6 +170,9 @@ class VMInterfaceTable(BaseInterfaceTable): name = tables.Column( linkify=True ) + parent = tables.Column( + linkify=True + ) tags = TagColumn( url_name='virtualization:vminterface_list' ) @@ -177,10 +180,10 @@ class VMInterfaceTable(BaseInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'pk', 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) - default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description') + default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'parent', 'description') class VirtualMachineVMInterfaceTable(VMInterfaceTable): diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index e822d87639a..c11423663c7 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filters.py @@ -453,12 +453,25 @@ class VMInterfaceTestCase(TestCase): params = {'name': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_assigned_to_interface(self): + def test_enabled(self): params = {'enabled': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'enabled': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_parent(self): + # Create child interfaces + parent_interface = VMInterface.objects.first() + child_interfaces = ( + VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 1', parent=parent_interface), + VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 2', parent=parent_interface), + VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 3', parent=parent_interface), + ) + VMInterface.objects.bulk_create(child_interfaces) + + params = {'parent_id': [parent_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_mtu(self): params = {'mtu': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 6bf9eb6dd72..6b316de0e22 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -421,6 +421,14 @@ class VMInterfaceView(generic.ObjectView): orderable=False ) + # Get child interfaces + child_interfaces = VMInterface.objects.restrict(request.user, 'view').filter(parent=instance) + child_interfaces_tables = tables.VMInterfaceTable( + child_interfaces, + orderable=False + ) + child_interfaces_tables.columns.hide('virtual_machine') + # Get assigned VLANs and annotate whether each is tagged or untagged vlans = [] if instance.untagged_vlan is not None: @@ -437,6 +445,7 @@ class VMInterfaceView(generic.ObjectView): return { 'ipaddress_table': ipaddress_table, + 'child_interfaces_table': child_interfaces_tables, 'vlan_table': vlan_table, } From 65ed04708418f0edca7b88a0d2d4507c9addc990 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 11 Apr 2021 13:42:24 -0400 Subject: [PATCH 198/223] Fixes #6124: Location parent filter should return all child locations (not just those directly assigned) --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/filters.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index cd75d141b9c..0fbbb2f9ca9 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -18,6 +18,7 @@ * [#6107](https://github.com/netbox-community/netbox/issues/6107) - Fix rack selection field on device form * [#6110](https://github.com/netbox-community/netbox/issues/6110) - Fix handling of TemplateColumn values for table export * [#6123](https://github.com/netbox-community/netbox/issues/6123) - Prevent device from being assigned to mismatched site and location +* [#6124](https://github.com/netbox-community/netbox/issues/6124) - Location `parent` filter should return all child locations (not just those directly assigned) * [#6130](https://github.com/netbox-community/netbox/issues/6130) - Improve display of assigned models in custom fields list --- diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d8586eb331b..1a7ac266fec 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -191,15 +191,18 @@ class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet): to_field_name='slug', label='Site (slug)', ) - parent_id = django_filters.ModelMultipleChoiceFilter( + parent_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), - label='Rack group (ID)', + field_name='parent', + lookup_expr='in', + label='Location (ID)', ) - parent = django_filters.ModelMultipleChoiceFilter( - field_name='parent__slug', + parent = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), + field_name='parent', + lookup_expr='in', to_field_name='slug', - label='Rack group (slug)', + label='Location (slug)', ) class Meta: From 0bce1da4e3793f6a8b279c74c02af2148ddf4c0a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 11 Apr 2021 13:43:06 -0400 Subject: [PATCH 199/223] Clean up stray references to old RackGroup model --- netbox/dcim/api/views.py | 2 +- netbox/dcim/filters.py | 2 +- netbox/dcim/forms.py | 4 ++-- netbox/dcim/models/power.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 26313474cfe..cb46c1eca5c 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -142,7 +142,7 @@ class SiteViewSet(CustomFieldModelViewSet): # -# Rack groups +# Locations # class LocationViewSet(CustomFieldModelViewSet): diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 1a7ac266fec..4beed04b190 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1349,7 +1349,7 @@ class PowerPanelFilterSet(BaseFilterSet): queryset=Location.objects.all(), field_name='location', lookup_expr='in', - label='Rack group (ID)', + label='Location (ID)', ) tag = TagFilter() diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9437225de4c..feb8c5e819e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -521,9 +521,9 @@ class LocationCSVForm(CustomFieldModelCSVForm): queryset=Location.objects.all(), required=False, to_field_name='name', - help_text='Parent rack group', + help_text='Parent location', error_messages={ - 'invalid_choice': 'Rack group not found.', + 'invalid_choice': 'Location not found.', } ) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 06d234149e1..a5e3149f83f 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -66,9 +66,9 @@ class PowerPanel(PrimaryModel): # Location must belong to assigned Site if self.location and self.location.site != self.site: - raise ValidationError("Rack group {} ({}) is in a different site than {}".format( - self.location, self.location.site, self.site - )) + raise ValidationError( + f"Location {self.location} ({self.location.site}) is in a different site than {self.site}" + ) @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') From 18f206747cee8754e62ecb3702bc026a5ff8cc55 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Apr 2021 10:46:32 -0400 Subject: [PATCH 200/223] Closes #6088: Improved table configuration form --- docs/release-notes/version-2.11.md | 1 + netbox/netbox/views/generic.py | 2 +- netbox/project-static/js/tableconfig.js | 22 ++++++++++++++++-- .../templatetags/table_config_form.html | 16 +++++++++---- netbox/utilities/forms/forms.py | 14 ++++++++--- netbox/utilities/tables.py | 23 ++++++++++--------- 6 files changed, 56 insertions(+), 22 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 0fbbb2f9ca9..3bf3b2fbc27 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -4,6 +4,7 @@ ### Enhancements (from Beta) +* [#6088](https://github.com/netbox-community/netbox/issues/6088) - Improved table configuration form * [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views * [#6109](https://github.com/netbox-community/netbox/issues/6109) - Add device counts to locations table * [#6121](https://github.com/netbox-community/netbox/issues/6121) - Extend parent interface assignment to VM interfaces diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index dadd97b98b0..8f713fa6395 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -182,7 +182,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): if request.GET.get('export') == 'table': exclude_columns = {'pk'} exclude_columns.update({ - col for col in table.base_columns if col not in table.visible_columns + name for name, _ in table.available_columns }) exporter = TableExport( export_format=TableExport.CSV, diff --git a/netbox/project-static/js/tableconfig.js b/netbox/project-static/js/tableconfig.js index 8f4692ea492..6851d2e8c09 100644 --- a/netbox/project-static/js/tableconfig.js +++ b/netbox/project-static/js/tableconfig.js @@ -1,9 +1,27 @@ $(document).ready(function() { - $('form.userconfigform input.reset').click(function(event) { - // Deselect all columns when the reset button is clicked + + // Select or reset table columns + $('#save_tableconfig').click(function(event) { + $('select[name="columns"] option').attr("selected", "selected"); + }); + $('#reset_tableconfig').click(function(event) { $('select[name="columns"]').val([]); }); + // Swap columns between available and selected lists + $('#add_columns').click(function(e) { + let selected_columns = $('#id_available_columns option:selected'); + $('#id_columns').append($(selected_columns).clone()); + $(selected_columns).remove(); + e.preventDefault(); + }); + $('#remove_columns').click(function(e) { + let selected_columns = $('#id_columns option:selected'); + $('#id_available_columns').append($(selected_columns).clone()); + $(selected_columns).remove(); + e.preventDefault(); + }); + $('form.userconfigform').submit(function(event) { event.preventDefault(); diff --git a/netbox/templates/utilities/templatetags/table_config_form.html b/netbox/templates/utilities/templatetags/table_config_form.html index c92adaee1d9..65397c18d86 100644 --- a/netbox/templates/utilities/templatetags/table_config_form.html +++ b/netbox/templates/utilities/templatetags/table_config_form.html @@ -8,17 +8,23 @@
    - {% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %} +
    + {% csrf_token %} +
    + {% render_table powerfeed_table 'inc/table.html' %} + +
    +
    {% plugin_full_width_page object %}
    From 608bf30bda2fe3e09260e5da6cefd03e8adf7e5e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Apr 2021 15:52:24 -0400 Subject: [PATCH 202/223] Add cable trace view tests --- netbox/dcim/tests/test_views.py | 92 +++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 8fde267d9f7..9dffee03ca6 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1246,6 +1246,17 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Port 6", ) + def test_trace(self): + consoleport = ConsolePort.objects.first() + consoleserverport = ConsoleServerPort.objects.create( + device=consoleport.device, + name='Console Server Port 1' + ) + Cable(termination_a=consoleport, termination_b=consoleserverport).save() + + response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk})) + self.assertHttpStatus(response, 200) + class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsoleServerPort @@ -1290,6 +1301,17 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Server Port 6", ) + def test_trace(self): + consoleserverport = ConsoleServerPort.objects.first() + consoleport = ConsolePort.objects.create( + device=consoleserverport.device, + name='Console Port 1' + ) + Cable(termination_a=consoleserverport, termination_b=consoleport).save() + + response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk})) + self.assertHttpStatus(response, 200) + class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerPort @@ -1340,6 +1362,17 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Port 6", ) + def test_trace(self): + powerport = PowerPort.objects.first() + poweroutlet = PowerOutlet.objects.create( + device=powerport.device, + name='Power Outlet 1' + ) + Cable(termination_a=powerport, termination_b=poweroutlet).save() + + response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk})) + self.assertHttpStatus(response, 200) + class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerOutlet @@ -1396,6 +1429,14 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Outlet 6", ) + def test_trace(self): + poweroutlet = PowerOutlet.objects.first() + powerport = PowerPort.objects.first() + Cable(termination_a=poweroutlet, termination_b=powerport).save() + + response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk})) + self.assertHttpStatus(response, 200) + class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = Interface @@ -1475,6 +1516,13 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Interface 6,1000base-t", ) + def test_trace(self): + interface1, interface2 = Interface.objects.all()[:2] + Cable(termination_a=interface1, termination_b=interface2).save() + + response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk})) + self.assertHttpStatus(response, 200) + class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = FrontPort @@ -1534,6 +1582,17 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Front Port 6,8p8c,Rear Port 6,1", ) + def test_trace(self): + frontport = FrontPort.objects.first() + interface = Interface.objects.create( + device=frontport.device, + name='Interface 1' + ) + Cable(termination_a=frontport, termination_b=interface).save() + + response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk})) + self.assertHttpStatus(response, 200) + class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = RearPort @@ -1580,6 +1639,17 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Rear Port 6,8p8c,1", ) + def test_trace(self): + rearport = RearPort.objects.first() + interface = Interface.objects.create( + device=rearport.device, + name='Interface 1' + ) + Cable(termination_a=rearport, termination_b=interface).save() + + response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk})) + self.assertHttpStatus(response, 200) + class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay @@ -1938,3 +2008,25 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'max_utilization': 50, 'comments': 'New comments', } + + def test_trace(self): + manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' + ) + device_role = DeviceRole.objects.create( + name='Device Role', slug='device-role-1' + ) + device = Device.objects.create( + site=Site.objects.first(), device_type=device_type, device_role=device_role + ) + + powerfeed = PowerFeed.objects.first() + powerport = PowerPort.objects.create( + device=device, + name='Power Port 1' + ) + Cable(termination_a=powerfeed, termination_b=powerport).save() + + response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk})) + self.assertHttpStatus(response, 200) From b4b68c0b0052d2617408d1feb482eb14872003dd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Apr 2021 16:07:03 -0400 Subject: [PATCH 203/223] Move create_test_device() to testing utils --- netbox/dcim/tests/test_views.py | 15 +-------------- netbox/utilities/testing/utils.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 9dffee03ca6..40c0dff3453 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -12,20 +12,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import VLAN -from utilities.testing import ViewTestCases - - -def create_test_device(name): - """ - Convenience method for creating a Device (e.g. for component testing). - """ - site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') - manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') - devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) - devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') - device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) - - return device +from utilities.testing import ViewTestCases, create_test_device class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 9c30002b8b2..2cf9795b5ed 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -4,6 +4,8 @@ from contextlib import contextmanager from django.contrib.auth.models import Permission, User +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site + def post_data(data): """ @@ -29,6 +31,19 @@ def post_data(data): return ret +def create_test_device(name): + """ + Convenience method for creating a Device (e.g. for component testing). + """ + site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') + manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') + devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) + devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') + device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) + + return device + + def create_test_user(username='testuser', permissions=None): """ Create a User with the given permissions. From a1d32c3a216fa319ecbedf84697f8ba4e6810dc1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Apr 2021 16:07:52 -0400 Subject: [PATCH 204/223] Add view tests for CircuitTermination --- netbox/circuits/tests/test_views.py | 58 ++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 20f7b4d8382..b4effa11c0c 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,8 +1,11 @@ import datetime +from django.urls import reverse + from circuits.choices import * from circuits.models import * -from utilities.testing import ViewTestCases +from dcim.models import Cable, Interface, Site +from utilities.testing import ViewTestCases, create_test_device class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -175,3 +178,56 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', 'comments': 'New comments', } + + +class CircuitTerminationTestCase( + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, +): + model = CircuitTermination + + @classmethod + def setUpTestData(cls): + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + provider = Provider.objects.create(name='Provider 1', slug='provider-1') + circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') + + circuits = ( + Circuit(cid='Circuit 1', provider=provider, type=circuittype), + Circuit(cid='Circuit 2', provider=provider, type=circuittype), + Circuit(cid='Circuit 3', provider=provider, type=circuittype), + ) + Circuit.objects.bulk_create(circuits) + + circuit_terminations = ( + CircuitTermination(circuit=circuits[0], term_side='A', site=sites[0]), + CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]), + CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]), + CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]), + ) + CircuitTermination.objects.bulk_create(circuit_terminations) + + cls.form_data = { + 'term_side': 'A', + 'site': sites[2].pk, + 'description': 'New description', + } + + def test_trace(self): + device = create_test_device('Device 1') + + circuittermination = CircuitTermination.objects.first() + interface = Interface.objects.create( + device=device, + name='Interface 1' + ) + Cable(termination_a=circuittermination, termination_b=interface).save() + + response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk})) + self.assertHttpStatus(response, 200) From 9bda2a44aed490b34f5619a422a50118df416da4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Apr 2021 16:25:52 -0400 Subject: [PATCH 205/223] Fix permissions for cable trace view tests --- netbox/circuits/tests/test_views.py | 2 ++ netbox/dcim/tests/test_views.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index b4effa11c0c..62e3e3a2216 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,5 +1,6 @@ import datetime +from django.test import override_settings from django.urls import reverse from circuits.choices import * @@ -219,6 +220,7 @@ class CircuitTerminationTestCase( 'description': 'New description', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): device = create_test_device('Device 1') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 40c0dff3453..daba2a63958 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1233,6 +1233,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Port 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): consoleport = ConsolePort.objects.first() consoleserverport = ConsoleServerPort.objects.create( @@ -1288,6 +1289,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Server Port 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): consoleserverport = ConsoleServerPort.objects.first() consoleport = ConsolePort.objects.create( @@ -1349,6 +1351,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Port 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): powerport = PowerPort.objects.first() poweroutlet = PowerOutlet.objects.create( @@ -1416,6 +1419,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Outlet 6", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): poweroutlet = PowerOutlet.objects.first() powerport = PowerPort.objects.first() @@ -1503,6 +1507,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Interface 6,1000base-t", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): interface1, interface2 = Interface.objects.all()[:2] Cable(termination_a=interface1, termination_b=interface2).save() @@ -1569,6 +1574,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Front Port 6,8p8c,Rear Port 6,1", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): frontport = FrontPort.objects.first() interface = Interface.objects.create( @@ -1626,6 +1632,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Rear Port 6,8p8c,1", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): rearport = RearPort.objects.first() interface = Interface.objects.create( @@ -1996,6 +2003,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'comments': 'New comments', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1') device_type = DeviceType.objects.create( From e5bbf47ab92701b7c31c3b7d6e2115fbffbd9cd8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Apr 2021 10:14:25 -0400 Subject: [PATCH 206/223] Fixes #5583: Eliminate redundant change records when adding/removing tags --- docs/release-notes/version-2.11.md | 1 + netbox/extras/signals.py | 22 ++++++++++---- netbox/extras/tests/test_changelog.py | 42 ++++++++++++--------------- netbox/utilities/testing/api.py | 32 +++++++++++++++++++- netbox/utilities/testing/views.py | 30 +++++++++++++++++-- 5 files changed, 96 insertions(+), 31 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index c548d53579d..7ab536c6733 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -13,6 +13,7 @@ ### Bug Fixes (from Beta) +* [#5583](https://github.com/netbox-community/netbox/issues/5583) - Eliminate redundant change records when adding/removing tags * [#6100](https://github.com/netbox-community/netbox/issues/6100) - Fix VM interfaces table "add interfaces" link * [#6104](https://github.com/netbox-community/netbox/issues/6104) - Fix location column on racks table * [#6105](https://github.com/netbox-community/netbox/issues/6105) - Hide checkboxes for VMs under cluster VMs view diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 0d6295e5b27..63e8f07b770 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -22,23 +22,35 @@ def _handle_changed_object(request, sender, instance, **kwargs): """ Fires when an object is created or updated. """ - # Queue the object for processing once the request completes + m2m_changed = False + + # Determine the type of change being made if kwargs.get('created'): action = ObjectChangeActionChoices.ACTION_CREATE elif 'created' in kwargs: action = ObjectChangeActionChoices.ACTION_UPDATE elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']: # m2m_changed with objects added or removed + m2m_changed = True action = ObjectChangeActionChoices.ACTION_UPDATE else: return # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): - objectchange = instance.to_objectchange(action) - objectchange.user = request.user - objectchange.request_id = request.id - objectchange.save() + if m2m_changed: + ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk, + request_id=request.id + ).update( + postchange_data=instance.to_objectchange(action).postchange_data + ) + else: + objectchange = instance.to_objectchange(action) + objectchange.user = request.user + objectchange.request_id = request.id + objectchange.save() # Enqueue webhooks enqueue_webhooks(instance, request.user, request.id, action) diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 5e44c83d140..91868832c19 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -56,19 +56,18 @@ class ChangeLogViewTest(ModelViewTestCase): response = self.client.post(**request) self.assertHttpStatus(response, 302) + # Verify the creation of a new ObjectChange record site = Site.objects.get(name='Site 1') - # First OC is the creation; second is the tags update - oc_list = ObjectChange.objects.filter( + oc = ObjectChange.objects.get( changed_object_type=ContentType.objects.get_for_model(Site), changed_object_id=site.pk - ).order_by('pk') - self.assertEqual(oc_list[0].changed_object, site) - self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE) - self.assertEqual(oc_list[0].prechange_data, None) - self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) - self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) - self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2']) + ) + self.assertEqual(oc.changed_object, site) + self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(oc.prechange_data, None) + self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) + self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) + self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) def test_update_object(self): site = Site(name='Site 1', slug='site-1') @@ -93,8 +92,8 @@ class ChangeLogViewTest(ModelViewTestCase): response = self.client.post(**request) self.assertHttpStatus(response, 302) + # Verify the creation of a new ObjectChange record site.refresh_from_db() - # Get only the most recent OC oc = ObjectChange.objects.filter( changed_object_type=ContentType.objects.get_for_model(Site), changed_object_id=site.pk @@ -259,17 +258,15 @@ class ChangeLogAPITest(APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) site = Site.objects.get(pk=response.data['id']) - # First OC is the creation; second is the tags update - oc_list = ObjectChange.objects.filter( + oc = ObjectChange.objects.get( changed_object_type=ContentType.objects.get_for_model(Site), changed_object_id=site.pk - ).order_by('pk') - self.assertEqual(oc_list[0].changed_object, site) - self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE) - self.assertEqual(oc_list[0].prechange_data, None) - self.assertEqual(oc_list[0].postchange_data['custom_fields'], data['custom_fields']) - self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2']) + ) + self.assertEqual(oc.changed_object, site) + self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(oc.prechange_data, None) + self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) + self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) def test_update_object(self): site = Site(name='Site 1', slug='site-1') @@ -294,11 +291,10 @@ class ChangeLogAPITest(APITestCase): self.assertHttpStatus(response, status.HTTP_200_OK) site = Site.objects.get(pk=response.data['id']) - # Get only the most recent OC - oc = ObjectChange.objects.filter( + oc = ObjectChange.objects.get( changed_object_type=ContentType.objects.get_for_model(Site), changed_object_id=site.pk - ).first() + ) self.assertEqual(oc.changed_object, site) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index f4f4ffefe20..132eea2ae40 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -6,6 +6,8 @@ from django.test import override_settings from rest_framework import status from rest_framework.test import APIClient +from extras.choices import ObjectChangeActionChoices +from extras.models import ObjectChange from users.models import ObjectPermission, Token from .utils import disable_warnings from .views import ModelTestCase @@ -223,13 +225,23 @@ class APIViewTestCases: response = self.client.post(self._get_list_url(), self.create_data[0], format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(self._get_queryset().count(), initial_count + 1) + instance = self._get_queryset().get(pk=response.data['id']) self.assertInstanceEqual( - self._get_queryset().get(pk=response.data['id']), + instance, self.create_data[0], exclude=self.validation_excluded_fields, api=True ) + # Verify ObjectChange creation + if hasattr(self.model, 'to_objectchange'): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE) + def test_bulk_create_objects(self): """ POST a set of objects in a single request. @@ -304,6 +316,15 @@ class APIViewTestCases: api=True ) + # Verify ObjectChange creation + if hasattr(self.model, 'to_objectchange'): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) + def test_bulk_update_objects(self): """ PATCH a set of objects in a single request. @@ -367,6 +388,15 @@ class APIViewTestCases: self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertFalse(self._get_queryset().filter(pk=instance.pk).exists()) + # Verify ObjectChange creation + if hasattr(self.model, 'to_objectchange'): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE) + def test_bulk_delete_objects(self): """ DELETE a set of objects in a single request. diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 703780f9523..6b1f4f8a9c5 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -10,7 +10,8 @@ from django.utils.text import slugify from netaddr import IPNetwork from taggit.managers import TaggableManager -from extras.models import Tag +from extras.choices import ObjectChangeActionChoices +from extras.models import ObjectChange, Tag from users.models import ObjectPermission from utilities.permissions import resolve_permission_ct from .utils import disable_warnings, extract_form_failures, post_data @@ -323,7 +324,16 @@ class ViewTestCases: } self.assertHttpStatus(self.client.post(**request), 302) self.assertEqual(initial_count + 1, self._get_queryset().count()) - self.assertInstanceEqual(self._get_queryset().order_by('pk').last(), self.form_data) + instance = self._get_queryset().order_by('pk').last() + self.assertInstanceEqual(instance, self.form_data) + + # Verify ObjectChange creation + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_constrained_permission(self): @@ -410,6 +420,14 @@ class ViewTestCases: self.assertHttpStatus(self.client.post(**request), 302) self.assertInstanceEqual(self._get_queryset().get(pk=instance.pk), self.form_data) + # Verify ObjectChange creation + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_edit_object_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] @@ -489,6 +507,14 @@ class ViewTestCases: with self.assertRaises(ObjectDoesNotExist): self._get_queryset().get(pk=instance.pk) + # Verify ObjectChange creation + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_delete_object_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] From a296a9e10948c68faae1040c442a49e00f40d07c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Apr 2021 10:53:55 -0400 Subject: [PATCH 207/223] Closes #6150: Enable change logging for journal entries --- docs/release-notes/version-2.11.md | 1 + netbox/extras/migrations/0058_journalentry.py | 1 + netbox/extras/models/models.py | 12 ++-- netbox/extras/tables.py | 9 +-- netbox/extras/urls.py | 4 +- netbox/extras/views.py | 4 ++ netbox/templates/extras/journalentry.html | 57 +++++++++++++++++++ 7 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 netbox/templates/extras/journalentry.html diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 7ab536c6733..0afda238a00 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -10,6 +10,7 @@ * [#6121](https://github.com/netbox-community/netbox/issues/6121) - Extend parent interface assignment to VM interfaces * [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page * [#6146](https://github.com/netbox-community/netbox/issues/6146) - Add bulk disconnect support for power feeds +* [#6150](https://github.com/netbox-community/netbox/issues/6150) - Enable change logging for journal entries ### Bug Fixes (from Beta) diff --git a/netbox/extras/migrations/0058_journalentry.py b/netbox/extras/migrations/0058_journalentry.py index 14be2a50d84..22abf965ca2 100644 --- a/netbox/extras/migrations/0058_journalentry.py +++ b/netbox/extras/migrations/0058_journalentry.py @@ -18,6 +18,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(primary_key=True, serialize=False)), ('assigned_object_id', models.PositiveIntegerField()), ('created', models.DateTimeField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('kind', models.CharField(default='info', max_length=30)), ('comments', models.TextField()), ('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 3209a7037df..41bc345e2d1 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -7,13 +7,15 @@ from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models from django.http import HttpResponse +from django.urls import reverse from django.utils import timezone +from django.utils.formats import date_format, time_format from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * from extras.utils import extras_features, FeatureQuery, image_upload -from netbox.models import BigIDModel +from netbox.models import BigIDModel, ChangeLoggedModel from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -389,7 +391,7 @@ class ImageAttachment(BigIDModel): # Journal entries # -class JournalEntry(BigIDModel): +class JournalEntry(ChangeLoggedModel): """ A historical remark concerning an object; collectively, these form an object's journal. The journal is used to preserve historical context around an object, and complements NetBox's built-in change logging. For example, you @@ -427,8 +429,10 @@ class JournalEntry(BigIDModel): verbose_name_plural = 'journal entries' def __str__(self): - time_created = self.created.replace(microsecond=0) - return f"{time_created} - {self.get_kind_display()}" + return f"{date_format(self.created)} - {time_format(self.created)} ({self.get_kind_display()})" + + def get_absolute_url(self): + return reverse('extras:journalentry', args=[self.pk]) def get_kind_class(self): return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index a8efd8005a0..dd7b45c6f83 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -102,15 +102,15 @@ class ObjectJournalTable(BaseTable): Used for displaying a set of JournalEntries within the context of a single object. """ created = tables.DateTimeColumn( + linkify=True, format=settings.SHORT_DATETIME_FORMAT ) kind = ChoiceFieldColumn() comments = tables.TemplateColumn( - template_code='{% load helpers %}{{ value|render_markdown }}' + template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) actions = ButtonsColumn( - model=JournalEntry, - buttons=('edit', 'delete') + model=JournalEntry ) class Meta(BaseTable.Meta): @@ -128,9 +128,6 @@ class JournalEntryTable(ObjectJournalTable): orderable=False, verbose_name='Object' ) - comments = tables.TemplateColumn( - template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' - ) class Meta(BaseTable.Meta): model = JournalEntry diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f38a2ecd35a..9ec19d215b8 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,7 +1,7 @@ from django.urls import path from extras import views -from extras.models import ConfigContext, Tag +from extras.models import ConfigContext, JournalEntry, Tag app_name = 'extras' @@ -37,8 +37,10 @@ urlpatterns = [ path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'), path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'), path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'), + path('journal-entries//', views.JournalEntryView.as_view(), name='journalentry'), path('journal-entries//edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'), path('journal-entries//delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'), + path('journal-entries//changelog/', views.ObjectChangeLogView.as_view(), name='journalentry_changelog', kwargs={'model': JournalEntry}), # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 4b62604b924..4cda84d9949 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -306,6 +306,10 @@ class JournalEntryListView(generic.ObjectListView): action_buttons = ('export',) +class JournalEntryView(generic.ObjectView): + queryset = JournalEntry.objects.all() + + class JournalEntryEditView(generic.ObjectEditView): queryset = JournalEntry.objects.all() model_form = forms.JournalEntryForm diff --git a/netbox/templates/extras/journalentry.html b/netbox/templates/extras/journalentry.html new file mode 100644 index 00000000000..f64741f36ae --- /dev/null +++ b/netbox/templates/extras/journalentry.html @@ -0,0 +1,57 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load static %} + +{% block breadcrumbs %} +
  • Journal Entries
  • +
  • {{ object.assigned_object }}
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Journal Entry +
    + + + + + + + + + + + + + + + + + +
    Object + {{ object.assigned_object }} +
    Created + {{ object.created }} +
    Created By + {{ object.created_by }} +
    Kind + {{ object.get_kind_display }} +
    +
    +
    +
    +
    +
    + Comments +
    +
    + {{ object.comments|render_markdown }} +
    +
    +
    +
    +{% endblock %} From e5602abee010a16ca465e261be3aee57670e55b5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Apr 2021 11:30:45 -0400 Subject: [PATCH 208/223] Closes #5848: Filter custom fields by content type in format . --- docs/release-notes/version-2.11.md | 1 + netbox/extras/filters.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 0afda238a00..3a58069c6d6 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -4,6 +4,7 @@ ### Enhancements (from Beta) +* [#5848](https://github.com/netbox-community/netbox/issues/5848) - Filter custom fields by content type in format `.` * [#6088](https://github.com/netbox-community/netbox/issues/6088) - Improved table configuration form * [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views * [#6109](https://github.com/netbox-community/netbox/issues/6109) - Add device counts to locations table diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 495e0379756..4b5c42eeb63 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -90,6 +90,7 @@ class CustomFieldModelFilterSet(django_filters.FilterSet): class CustomFieldFilterSet(django_filters.FilterSet): + content_types = ContentTypeFilter() class Meta: model = CustomField From b1d20d322817c60fcd380082088f282a4141acbc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Apr 2021 11:39:04 -0400 Subject: [PATCH 209/223] Closes #6149: Support image attachments for locations --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/models/sites.py | 3 +++ netbox/dcim/urls.py | 1 + netbox/templates/dcim/location.html | 14 ++++++++++++++ 4 files changed, 19 insertions(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 3a58069c6d6..e8fb8500a06 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -11,6 +11,7 @@ * [#6121](https://github.com/netbox-community/netbox/issues/6121) - Extend parent interface assignment to VM interfaces * [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page * [#6146](https://github.com/netbox-community/netbox/issues/6146) - Add bulk disconnect support for power feeds +* [#6149](https://github.com/netbox-community/netbox/issues/6149) - Support image attachments for locations * [#6150](https://github.com/netbox-community/netbox/issues/6150) - Enable change logging for journal entries ### Bug Fixes (from Beta) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 247735627dc..225a8e749b2 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -314,6 +314,9 @@ class Location(NestedGroupModel): max_length=200, blank=True ) + images = GenericRelation( + to='extras.ImageAttachment' + ) csv_headers = ['site', 'parent', 'name', 'slug', 'description'] clone_fields = ['site', 'parent', 'description'] diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 534a9eec68c..11ffd44587c 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -55,6 +55,7 @@ urlpatterns = [ path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'), path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'), path('locations//changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}), + path('locations//images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}), # Rack roles path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 0efb742446b..a5eeb4e7177 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -58,6 +58,20 @@
    {% include 'inc/custom_fields_panel.html' %} +
    +
    + Images +
    + {% include 'inc/image_attachments.html' with images=object.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
    {% plugin_right_page object %}
    From 6ad20c53d9714c287d33a51bd9cf0f1be4cbc396 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Apr 2021 15:58:21 -0400 Subject: [PATCH 210/223] Delete unused template --- netbox/templates/ipam/inc/vlangroup_header.html | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 netbox/templates/ipam/inc/vlangroup_header.html diff --git a/netbox/templates/ipam/inc/vlangroup_header.html b/netbox/templates/ipam/inc/vlangroup_header.html deleted file mode 100644 index 2507a749fca..00000000000 --- a/netbox/templates/ipam/inc/vlangroup_header.html +++ /dev/null @@ -1,14 +0,0 @@ -
    - {% if perms.ipam.add_vlan and first_available_vlan %} - - Add a VLAN - - {% endif %} - {% if perms.ipam.change_vlangroup %} - - - Edit this VLAN Group - - {% endif %} -
    -

    {{ object }}

    From 46e144f647d3234c66828d024dcdf66f0d403d37 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Apr 2021 16:03:07 -0400 Subject: [PATCH 211/223] Clean up object header --- netbox/project-static/css/base.css | 3 +++ netbox/templates/extras/report.html | 2 +- netbox/templates/extras/script.html | 2 +- netbox/templates/extras/script_result.html | 2 +- netbox/templates/generic/object.html | 9 +++++++-- netbox/templates/inc/created_updated.html | 3 --- netbox/templates/users/userkey.html | 4 +++- 7 files changed, 16 insertions(+), 9 deletions(-) delete mode 100644 netbox/templates/inc/created_updated.html diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 2efa3978fb5..75348a5c940 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -362,6 +362,9 @@ table.report th a { border-radius: .25em; vertical-align: middle; } +h1.title { + margin-top: 0; +} .text-nowrap { white-space: nowrap; } diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 76a34c06096..f2c5edf2350 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -27,7 +27,7 @@ {% endif %} -

    {{ report.name }}

    +

    {{ report.name }}

    {% if report.description %}

    {{ report.description }}

    {% endif %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 3f083951227..7a99d245dfd 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -15,7 +15,7 @@ -

    {{ script }}

    +

    {{ script }}

    {{ script.Meta.description|render_markdown }}