diff --git a/.github/lock.yml b/.github/lock.yml deleted file mode 100644 index e00f3f4db..000000000 --- a/.github/lock.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Configuration for Lock (https://github.com/apps/lock) - -# Number of days of inactivity before a closed issue or pull request is locked -daysUntilLock: 90 - -# Skip issues and pull requests created before a given timestamp. Timestamp must -# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable -skipCreatedBefore: false - -# Issues and pull requests with these labels will be ignored. Set to `[]` to disable -exemptLabels: [] - -# Label to add before locking, such as `outdated`. Set to `false` to disable -lockLabel: false - -# Comment to post before locking. Set to `false` to disable -lockComment: false - -# Assign `resolved` as the reason for locking. Set to `false` to disable -setLockReason: true - -# Limit to only `issues` or `pulls` -# only: issues diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml new file mode 100644 index 000000000..b6073a71b --- /dev/null +++ b/.github/workflows/lock.yml @@ -0,0 +1,21 @@ +# lock-threads (https://github.com/marketplace/actions/lock-threads) +name: 'Lock threads' + +on: + schedule: + - cron: '0 3 * * *' + +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v2 + with: + github-token: ${{ github.token }} + issue-lock-inactive-days: '90' + issue-exclude-created-before: '' + issue-exclude-labels: '' + issue-lock-labels: '' + issue-lock-comment: '' + issue-lock-reason: 'resolved' + process-only: 'issues' diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 7fa07517c..23c1082bc 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -73,8 +73,9 @@ tar -xf netbox_media.tar.gz ## Cache Invalidation -If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache by performing this command: +If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache on the original instance by issuing the `invalidate all` management command (within the Python virtual environment): ```no-highlight -python3 manage.py invalidate all +# source /opt/netbox/venv/bin/activate +(venv) # python3 manage.py invalidate all ``` diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index dc6a10dae..042f75691 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -1,5 +1,19 @@ # NetBox v2.9 +## v2.9.9 (FUTURE) + +### Enhancements + +* [#5304](https://github.com/netbox-community/netbox/issues/5304) - Return server error messages as JSON when handling REST API requests +* [#5310](https://github.com/netbox-community/netbox/issues/5310) - Link to rack groups within rack list table + +### Bug Fixes + +* [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device + + +--- + ## v2.9.8 (2020-10-30) ### Enhancements diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 5e5a88080..4731c9adb 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -303,14 +303,24 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm # class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( - queryset=Site.objects.all() + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region' + } ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + 'term_side', 'region', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 78f81a3d2..4ba8318f4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -352,8 +352,18 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class RackGroupForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( - queryset=Site.objects.all() + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region' + } ) parent = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -367,7 +377,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = RackGroup fields = ( - 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site', 'parent', 'name', 'slug', 'description', ) @@ -447,14 +457,17 @@ class RackRoleCSVForm(CSVModelForm): # class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = DynamicModelChoiceField( - queryset=Site.objects.all() - ) - group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + region = DynamicModelChoiceField( + queryset=Region.objects.all(), required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), query_params={ - 'site_id': '$site' + 'region_id': '$region' } ) role = DynamicModelChoiceField( @@ -470,8 +483,9 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Rack fields = [ - 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', - 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags', + 'region', 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', + 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -548,9 +562,19 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -691,9 +715,19 @@ class RackElevationFilterForm(RackFilterForm): # class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -707,7 +741,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): display_field='display_name', query_params={ 'site_id': '$site', - 'group_id': 'rack', + 'group_id': '$rack', } ) units = NumericArrayField( @@ -809,15 +843,23 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): model = RackReservation - field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant'] + field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' ) + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False + ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - required=False + required=False, + query_params={ + 'region': '$region' + } ) group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related('site'), @@ -1672,7 +1714,10 @@ class PlatformCSVForm(CSVModelForm): class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), - required=False + required=False, + initial_params={ + 'sites': '$site' + } ) site = DynamicModelChoiceField( queryset=Site.objects.all(), @@ -1686,6 +1731,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): display_field='display_name', query_params={ 'site_id': '$site' + }, + initial_params={ + 'racks': '$rack' } ) rack = DynamicModelChoiceField( @@ -1711,7 +1759,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), - required=False + required=False, + initial_params={ + 'device_types': '$device_type' + } ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), @@ -1733,7 +1784,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, - null_option='None' + null_option='None', + initial_params={ + 'clusters': '$cluster' + } ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), @@ -1772,27 +1826,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } def __init__(self, *args, **kwargs): - - # Initialize helper selectors - instance = kwargs.get('instance') - if 'initial' not in kwargs: - kwargs['initial'] = {} - # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field - if instance and hasattr(instance, 'device_type'): - kwargs['initial']['manufacturer'] = instance.device_type.manufacturer - if instance and instance.cluster is not None: - kwargs['initial']['cluster_group'] = instance.cluster.group - - if 'device_type' in kwargs['initial'] and 'manufacturer' not in kwargs['initial']: - device_type_id = kwargs['initial']['device_type'] - manufacturer_id = DeviceType.objects.filter(pk=device_type_id).values_list('manufacturer__pk', flat=True).first() - kwargs['initial']['manufacturer'] = manufacturer_id - - if 'cluster' in kwargs['initial'] and 'cluster_group' not in kwargs['initial']: - cluster_id = kwargs['initial']['cluster'] - cluster_group_id = Cluster.objects.filter(pk=cluster_id).values_list('group__pk', flat=True).first() - kwargs['initial']['cluster_group'] = cluster_group_id - super().__init__(*args, **kwargs) if self.instance.pk: @@ -3441,10 +3474,18 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): """ Base form for connecting a Cable to a Device component """ + termination_b_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', - required=False + required=False, + query_params={ + 'region_id': '$termination_b_region' + } ) termination_b_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), @@ -3470,8 +3511,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Cable fields = [ - 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status', - 'label', 'color', 'length', 'length_unit', + 'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device', + 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', ] widgets = { 'status': StaticSelect2, @@ -3568,10 +3609,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): label='Provider', required=False ) + termination_b_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', - required=False + required=False, + query_params={ + 'region_id': '$termination_b_region' + } ) termination_b_circuit = DynamicModelChoiceField( queryset=Circuit.objects.all(), @@ -3595,8 +3644,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): class Meta: model = Cable fields = [ - 'termination_b_provider', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type', - 'status', 'label', 'color', 'length', 'length_unit', + 'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', + 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', ] def clean_termination_b_id(self): @@ -3605,11 +3654,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): + termination_b_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, - display_field='cid' + query_params={ + 'region_id': '$termination_b_region' + } ) termination_b_rackgroup = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -3851,10 +3907,18 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False + ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - required=False + required=False, + query_params={ + 'region': '$region' + } ) tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), @@ -3903,10 +3967,18 @@ class CableFilterForm(BootstrapMixin, forms.Form): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False + ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - required=False + required=False, + query_params={ + 'region': '$region' + } ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -3919,10 +3991,18 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): class PowerConnectionFilterForm(BootstrapMixin, forms.Form): + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False + ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - required=False + required=False, + query_params={ + 'region': '$region' + } ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -3935,10 +4015,18 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False + ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - required=False + required=False, + query_params={ + 'region': '$region' + } ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -3962,9 +4050,19 @@ class DeviceSelectionForm(forms.Form): class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), @@ -3997,7 +4095,7 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = VirtualChassis fields = [ - 'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags', + 'name', 'domain', 'region', 'site', 'rack', 'members', 'initial_position', 'tags', ] def save(self, *args, **kwargs): @@ -4094,9 +4192,19 @@ class DeviceVCMembershipForm(forms.ModelForm): class VCMemberSelectForm(BootstrapMixin, forms.Form): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), @@ -4195,8 +4303,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( - queryset=Site.objects.all() + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region' + } ) rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -4213,7 +4331,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = PowerPanel fields = [ - 'site', 'rack_group', 'name', 'tags', + 'region', 'site', 'rack_group', 'name', 'tags', ] @@ -4248,9 +4366,19 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=PowerPanel.objects.all(), widget=forms.MultipleHiddenInput ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -4302,9 +4430,22 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites__powerpanel': '$power_panel' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + initial_params={ + 'powerpanel': '$power_panel' + }, + query_params={ + 'region_id': '$region' + } ) power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), @@ -4329,7 +4470,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = PowerFeed fields = [ - 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', + 'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', ] widgets = { @@ -4339,14 +4480,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'phase': StaticSelect2(), } - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - # Initialize site field - if self.instance and hasattr(self.instance, 'power_panel'): - self.initial['site'] = self.instance.power_panel.site - class PowerFeedCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py new file mode 100644 index 000000000..2d8195f85 --- /dev/null +++ b/netbox/dcim/tables.py @@ -0,0 +1,1100 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from tenancy.tables import COL_TENANT +from utilities.tables import ( + BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn, +) +from .models import ( + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, +) + +MPTT_LINK = """ +{% if record.get_children %} + +{% else %} + +{% endif %} + {{ record.name }} + +""" + +SITE_REGION_LINK = """ +{% if record.region %} + {{ record.region }} +{% else %} + — +{% endif %} +""" + +COLOR_LABEL = """ +{% load helpers %} + +""" + +DEVICE_LINK = """ + + {{ record.name|default:'Unnamed device' }} + +""" + +RACKGROUP_ELEVATIONS = """ + + + +""" + +RACK_DEVICE_COUNT = """ +{{ value }} +""" + +DEVICE_COUNT = """ +{{ value|default:0 }} +""" + +RACKRESERVATION_ACTIONS = """ + + + +{% if perms.dcim.change_rackreservation %} + +{% endif %} +""" + +MANUFACTURER_ACTIONS = """ + + + +{% if perms.dcim.change_manufacturer %} + +{% endif %} +""" + +DEVICEROLE_DEVICE_COUNT = """ +{{ value|default:0 }} +""" + +DEVICEROLE_VM_COUNT = """ +{{ value|default:0 }} +""" + +DEVICEROLE_ACTIONS = """ + + + +{% if perms.dcim.change_devicerole %} + +{% endif %} +""" + +PLATFORM_DEVICE_COUNT = """ +{{ value|default:0 }} +""" + +PLATFORM_VM_COUNT = """ +{{ value|default:0 }} +""" + +STATUS_LABEL = """ +{{ record.get_status_display }} +""" + +TYPE_LABEL = """ +{{ record.get_type_display }} +""" + +DEVICE_PRIMARY_IP = """ +{{ record.primary_ip6.address.ip|default:"" }} +{% if record.primary_ip6 and record.primary_ip4 %}
{% endif %} +{{ record.primary_ip4.address.ip|default:"" }} +""" + +DEVICETYPE_INSTANCES_TEMPLATE = """ +{{ record.instance_count }} +""" + +UTILIZATION_GRAPH = """ +{% load helpers %} +{% utilization_graph value %} +""" + +CABLE_TERMINATION_PARENT = """ +{% if value.device %} + {{ value.device }} +{% elif value.circuit %} + {{ value.circuit }} +{% elif value.power_panel %} + {{ value.power_panel }} +{% endif %} +""" + +CABLE_LENGTH = """ +{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %} +""" + +POWERPANEL_POWERFEED_COUNT = """ +{{ value }} +""" + +INTERFACE_IPADDRESSES = """ +{% for ip in record.ip_addresses.unrestricted %} + {{ ip }}
+{% endfor %} +""" + +INTERFACE_TAGGED_VLANS = """ +{% for vlan in record.tagged_vlans.unrestricted %} + {{ vlan }}
+{% endfor %} +""" + +CONNECTION_STATUS = """ +{{ record.get_connection_status_display }} +""" + + +# +# Regions +# + +class RegionTable(BaseTable): + pk = ToggleColumn() + name = tables.TemplateColumn( + template_code=MPTT_LINK, + orderable=False + ) + site_count = tables.Column( + verbose_name='Sites' + ) + actions = ButtonsColumn(Region) + + class Meta(BaseTable.Meta): + model = Region + fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + + +# +# Sites +# + +class SiteTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn( + order_by=('_name',) + ) + status = tables.TemplateColumn( + template_code=STATUS_LABEL + ) + region = tables.TemplateColumn( + template_code=SITE_REGION_LINK + ) + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + tags = TagColumn( + url_name='dcim:site_list' + ) + + class Meta(BaseTable.Meta): + model = Site + fields = ( + 'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', 'tags', + ) + default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description') + + +# +# Rack groups +# + +class RackGroupTable(BaseTable): + pk = ToggleColumn() + name = tables.TemplateColumn( + template_code=MPTT_LINK, + orderable=False + ) + site = tables.LinkColumn( + viewname='dcim:site', + args=[Accessor('site__slug')], + verbose_name='Site' + ) + rack_count = tables.Column( + verbose_name='Racks' + ) + actions = ButtonsColumn( + model=RackGroup, + prepend_template=RACKGROUP_ELEVATIONS + ) + + class Meta(BaseTable.Meta): + model = RackGroup + fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions') + + +# +# Rack roles +# + +class RackRoleTable(BaseTable): + pk = ToggleColumn() + name = tables.Column(linkify=True) + rack_count = tables.Column(verbose_name='Racks') + color = tables.TemplateColumn(COLOR_LABEL) + actions = ButtonsColumn(RackRole) + + class Meta(BaseTable.Meta): + model = RackRole + fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') + + +# +# Racks +# + +class RackTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + order_by=('_name',), + linkify=True + ) + group = tables.Column( + linkify=True + ) + site = tables.Column( + linkify=True + ) + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + status = tables.TemplateColumn( + template_code=STATUS_LABEL + ) + role = ColoredLabelColumn() + u_height = tables.TemplateColumn( + template_code="{{ record.u_height }}U", + verbose_name='Height' + ) + + class Meta(BaseTable.Meta): + model = Rack + fields = ( + 'pk', 'name', 'site', 'group', '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') + + +class RackDetailTable(RackTable): + device_count = tables.TemplateColumn( + template_code=RACK_DEVICE_COUNT, + verbose_name='Devices' + ) + get_utilization = tables.TemplateColumn( + template_code=UTILIZATION_GRAPH, + orderable=False, + verbose_name='Space' + ) + get_power_utilization = tables.TemplateColumn( + template_code=UTILIZATION_GRAPH, + orderable=False, + verbose_name='Power' + ) + tags = TagColumn( + url_name='dcim:rack_list' + ) + + class Meta(RackTable.Meta): + fields = ( + 'pk', 'name', 'site', 'group', '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', + 'get_utilization', 'get_power_utilization', + ) + + +# +# Rack reservations +# + +class RackReservationTable(BaseTable): + pk = ToggleColumn() + reservation = tables.Column( + accessor='pk', + linkify=True + ) + site = tables.Column( + accessor=Accessor('rack__site'), + linkify=True + ) + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + rack = tables.Column( + linkify=True + ) + unit_list = tables.Column( + orderable=False, + verbose_name='Units' + ) + tags = TagColumn( + url_name='dcim:rackreservation_list' + ) + actions = ButtonsColumn(RackReservation) + + class Meta(BaseTable.Meta): + model = RackReservation + fields = ( + 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', + 'actions', + ) + default_columns = ( + 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', + ) + + +# +# Manufacturers +# + +class ManufacturerTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + devicetype_count = tables.Column( + verbose_name='Device Types' + ) + inventoryitem_count = tables.Column( + verbose_name='Inventory Items' + ) + platform_count = tables.Column( + verbose_name='Platforms' + ) + slug = tables.Column() + actions = ButtonsColumn(Manufacturer, pk_field='slug') + + class Meta(BaseTable.Meta): + model = Manufacturer + fields = ( + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + ) + + +# +# Device types +# + +class DeviceTypeTable(BaseTable): + pk = ToggleColumn() + model = tables.Column( + linkify=True, + verbose_name='Device Type' + ) + is_full_depth = BooleanColumn( + verbose_name='Full Depth' + ) + instance_count = tables.TemplateColumn( + template_code=DEVICETYPE_INSTANCES_TEMPLATE, + verbose_name='Instances' + ) + tags = TagColumn( + url_name='dcim:devicetype_list' + ) + + class Meta(BaseTable.Meta): + model = DeviceType + fields = ( + 'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'instance_count', 'tags', + ) + default_columns = ( + 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', + ) + + +# +# Device type components +# + +class ComponentTemplateTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + order_by=('_name',) + ) + + +class ConsolePortTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=ConsolePortTemplate, + buttons=('edit', 'delete'), + return_url_extra='%23tab_consoleports' + ) + + class Meta(BaseTable.Meta): + model = ConsolePortTemplate + fields = ('pk', 'name', 'label', 'type', 'description', 'actions') + empty_text = "None" + + +class ConsoleServerPortTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=ConsoleServerPortTemplate, + buttons=('edit', 'delete'), + return_url_extra='%23tab_consoleserverports' + ) + + class Meta(BaseTable.Meta): + model = ConsoleServerPortTemplate + fields = ('pk', 'name', 'label', 'type', 'description', 'actions') + empty_text = "None" + + +class PowerPortTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=PowerPortTemplate, + buttons=('edit', 'delete'), + return_url_extra='%23tab_powerports' + ) + + class Meta(BaseTable.Meta): + model = PowerPortTemplate + fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions') + empty_text = "None" + + +class PowerOutletTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=PowerOutletTemplate, + buttons=('edit', 'delete'), + return_url_extra='%23tab_poweroutlets' + ) + + class Meta(BaseTable.Meta): + model = PowerOutletTemplate + fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions') + empty_text = "None" + + +class InterfaceTemplateTable(ComponentTemplateTable): + mgmt_only = BooleanColumn( + verbose_name='Management Only' + ) + actions = ButtonsColumn( + model=InterfaceTemplate, + buttons=('edit', 'delete'), + return_url_extra='%23tab_interfaces' + ) + + class Meta(BaseTable.Meta): + model = InterfaceTemplate + fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions') + empty_text = "None" + + +class FrontPortTemplateTable(ComponentTemplateTable): + rear_port_position = tables.Column( + verbose_name='Position' + ) + actions = ButtonsColumn( + model=FrontPortTemplate, + buttons=('edit', 'delete'), + return_url_extra='%23tab_frontports' + ) + + class Meta(BaseTable.Meta): + model = FrontPortTemplate + fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions') + empty_text = "None" + + +class RearPortTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=RearPortTemplate, + buttons=('edit', 'delete'), + return_url_extra='%23tab_rearports' + ) + + class Meta(BaseTable.Meta): + model = RearPortTemplate + fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions') + empty_text = "None" + + +class DeviceBayTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=DeviceBayTemplate, + buttons=('edit', 'delete'), + return_url_extra='%23tab_devicebays' + ) + + class Meta(BaseTable.Meta): + model = DeviceBayTemplate + fields = ('pk', 'name', 'label', 'description', 'actions') + empty_text = "None" + + +# +# Device roles +# + +class DeviceRoleTable(BaseTable): + pk = ToggleColumn() + device_count = tables.TemplateColumn( + template_code=DEVICEROLE_DEVICE_COUNT, + verbose_name='Devices' + ) + vm_count = tables.TemplateColumn( + template_code=DEVICEROLE_VM_COUNT, + verbose_name='VMs' + ) + color = tables.TemplateColumn( + template_code=COLOR_LABEL, + verbose_name='Label' + ) + vm_role = BooleanColumn() + actions = ButtonsColumn(DeviceRole, pk_field='slug') + + class Meta(BaseTable.Meta): + model = DeviceRole + fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') + + +# +# Platforms +# + +class PlatformTable(BaseTable): + pk = ToggleColumn() + device_count = tables.TemplateColumn( + template_code=PLATFORM_DEVICE_COUNT, + verbose_name='Devices' + ) + vm_count = tables.TemplateColumn( + template_code=PLATFORM_VM_COUNT, + verbose_name='VMs' + ) + actions = ButtonsColumn(Platform, pk_field='slug') + + class Meta(BaseTable.Meta): + model = Platform + fields = ( + 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', + 'description', 'actions', + ) + default_columns = ( + 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', + ) + + +# +# Devices +# + +class DeviceTable(BaseTable): + pk = ToggleColumn() + name = tables.TemplateColumn( + order_by=('_name',), + template_code=DEVICE_LINK + ) + status = tables.TemplateColumn( + template_code=STATUS_LABEL + ) + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + site = tables.Column( + linkify=True + ) + rack = tables.Column( + linkify=True + ) + 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 + ) + primary_ip = tables.TemplateColumn( + template_code=DEVICE_PRIMARY_IP, + orderable=False, + verbose_name='IP Address' + ) + primary_ip4 = tables.Column( + linkify=True, + verbose_name='IPv4 Address' + ) + primary_ip6 = tables.Column( + linkify=True, + verbose_name='IPv6 Address' + ) + cluster = tables.LinkColumn( + viewname='virtualization:cluster', + args=[Accessor('cluster__pk')] + ) + virtual_chassis = tables.LinkColumn( + viewname='dcim:virtualchassis', + args=[Accessor('virtual_chassis__pk')] + ) + vc_position = tables.Column( + verbose_name='VC Position' + ) + vc_priority = tables.Column( + verbose_name='VC Priority' + ) + tags = TagColumn( + url_name='dcim:device_list' + ) + + class Meta(BaseTable.Meta): + model = Device + fields = ( + 'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site', + 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', + 'vc_position', 'vc_priority', 'tags', + ) + default_columns = ( + 'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip', + ) + + +class DeviceImportTable(BaseTable): + name = tables.TemplateColumn( + template_code=DEVICE_LINK + ) + status = tables.TemplateColumn( + template_code=STATUS_LABEL + ) + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + site = tables.Column( + linkify=True + ) + rack = tables.Column( + linkify=True + ) + device_role = tables.Column( + verbose_name='Role' + ) + device_type = tables.Column( + verbose_name='Type' + ) + + class Meta(BaseTable.Meta): + model = Device + fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') + empty_text = False + + +# +# Device components +# + +class DeviceComponentTable(BaseTable): + pk = ToggleColumn() + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True, + order_by=('_name',) + ) + cable = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + order_by = ('device', 'name') + + +class ConsolePortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:consoleport_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = ConsolePort + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') + + +class ConsoleServerPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:consoleserverport_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = ConsoleServerPort + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') + + +class PowerPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:powerport_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = PowerPort + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags', + ) + default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') + + +class PowerOutletTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:poweroutlet_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = PowerOutlet + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') + + +class BaseInterfaceTable(BaseTable): + enabled = BooleanColumn() + ip_addresses = tables.TemplateColumn( + template_code=INTERFACE_IPADDRESSES, + orderable=False, + verbose_name='IP Addresses' + ) + untagged_vlan = tables.Column(linkify=True) + tagged_vlans = tables.TemplateColumn( + template_code=INTERFACE_TAGGED_VLANS, + orderable=False, + verbose_name='Tagged VLANs' + ) + + +class InterfaceTable(DeviceComponentTable, BaseInterfaceTable): + tags = TagColumn( + url_name='dcim:interface_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = Interface + fields = ( + 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', + 'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + ) + default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') + + +class FrontPortTable(DeviceComponentTable): + rear_port_position = tables.Column( + verbose_name='Position' + ) + tags = TagColumn( + url_name='dcim:frontport_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = FrontPort + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags', + ) + default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') + + +class RearPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:rearport_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = RearPort + fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') + + +class DeviceBayTable(DeviceComponentTable): + installed_device = tables.Column( + linkify=True + ) + tags = TagColumn( + url_name='dcim:devicebay_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = DeviceBay + fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description') + + +class InventoryItemTable(DeviceComponentTable): + manufacturer = tables.Column( + linkify=True + ) + discovered = BooleanColumn() + tags = TagColumn( + url_name='dcim:inventoryitem_list' + ) + cable = None # Override DeviceComponentTable + + class Meta(DeviceComponentTable.Meta): + model = InventoryItem + fields = ( + 'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', + 'discovered', 'tags', + ) + default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag') + + +# +# Cables +# + +class CableTable(BaseTable): + pk = ToggleColumn() + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + termination_a_parent = tables.TemplateColumn( + template_code=CABLE_TERMINATION_PARENT, + accessor=Accessor('termination_a'), + orderable=False, + verbose_name='Side A' + ) + termination_a = tables.LinkColumn( + accessor=Accessor('termination_a'), + orderable=False, + verbose_name='Termination A' + ) + termination_b_parent = tables.TemplateColumn( + template_code=CABLE_TERMINATION_PARENT, + accessor=Accessor('termination_b'), + orderable=False, + verbose_name='Side B' + ) + termination_b = tables.LinkColumn( + accessor=Accessor('termination_b'), + orderable=False, + verbose_name='Termination B' + ) + status = tables.TemplateColumn( + template_code=STATUS_LABEL + ) + length = tables.TemplateColumn( + template_code=CABLE_LENGTH, + order_by='_abs_length' + ) + color = ColorColumn() + tags = TagColumn( + url_name='dcim:cable_list' + ) + + class Meta(BaseTable.Meta): + model = Cable + fields = ( + 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', + 'status', 'type', 'color', 'length', 'tags', + ) + default_columns = ( + 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', + 'status', 'type', + ) + + +# +# Device connections +# + +class ConsoleConnectionTable(BaseTable): + console_server = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('connected_endpoint__device'), + args=[Accessor('connected_endpoint__device__pk')], + verbose_name='Console Server' + ) + connected_endpoint = tables.Column( + linkify=True, + verbose_name='Port' + ) + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True, + verbose_name='Console Port' + ) + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) + + class Meta(BaseTable.Meta): + model = ConsolePort + fields = ('console_server', 'connected_endpoint', 'device', 'name', 'connection_status') + + +class PowerConnectionTable(BaseTable): + pdu = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('connected_endpoint__device'), + args=[Accessor('connected_endpoint__device__pk')], + order_by='_connected_poweroutlet__device', + verbose_name='PDU' + ) + outlet = tables.Column( + accessor=Accessor('_connected_poweroutlet'), + linkify=True, + verbose_name='Outlet' + ) + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True, + verbose_name='Power Port' + ) + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) + + class Meta(BaseTable.Meta): + model = PowerPort + fields = ('pdu', 'outlet', 'device', 'name', 'connection_status') + + +class InterfaceConnectionTable(BaseTable): + device_a = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('device'), + args=[Accessor('device__pk')], + verbose_name='Device A' + ) + interface_a = tables.LinkColumn( + viewname='dcim:interface', + accessor=Accessor('name'), + args=[Accessor('pk')], + verbose_name='Interface A' + ) + device_b = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('_connected_interface__device'), + args=[Accessor('_connected_interface__device__pk')], + verbose_name='Device B' + ) + interface_b = tables.LinkColumn( + viewname='dcim:interface', + accessor=Accessor('_connected_interface'), + args=[Accessor('_connected_interface__pk')], + verbose_name='Interface B' + ) + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) + + class Meta(BaseTable.Meta): + model = Interface + fields = ( + 'device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status', + ) + + +# +# Virtual chassis +# + +class VirtualChassisTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + master = tables.Column( + linkify=True + ) + member_count = tables.Column( + verbose_name='Members' + ) + tags = TagColumn( + url_name='dcim:virtualchassis_list' + ) + + class Meta(BaseTable.Meta): + model = VirtualChassis + fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags') + default_columns = ('pk', 'name', 'domain', 'master', 'member_count') + + +# +# Power panels +# + +class PowerPanelTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + site = tables.LinkColumn( + viewname='dcim:site', + args=[Accessor('site__slug')] + ) + powerfeed_count = tables.TemplateColumn( + template_code=POWERPANEL_POWERFEED_COUNT, + verbose_name='Feeds' + ) + tags = TagColumn( + url_name='dcim:powerpanel_list' + ) + + class Meta(BaseTable.Meta): + model = PowerPanel + fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags') + default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count') + + +# +# Power feeds +# + +class PowerFeedTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + power_panel = tables.Column( + linkify=True + ) + rack = tables.Column( + linkify=True + ) + status = tables.TemplateColumn( + template_code=STATUS_LABEL + ) + type = tables.TemplateColumn( + template_code=TYPE_LABEL + ) + max_utilization = tables.TemplateColumn( + template_code="{{ value }}%" + ) + available_power = tables.Column( + verbose_name='Available power (VA)' + ) + tags = TagColumn( + url_name='dcim:powerfeed_list' + ) + + class Meta(BaseTable.Meta): + model = PowerFeed + fields = ( + 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', + 'max_utilization', 'available_power', 'tags', + ) + default_columns = ( + 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', + ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 75a5c85ea..85943dd0d 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -70,12 +70,15 @@ class RackRoleTable(BaseTable): class RackTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn( - order_by=('_name',) + name = tables.Column( + order_by=('_name',), + linkify=True ) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] + group = tables.Column( + linkify=True + ) + site = tables.Column( + linkify=True ) tenant = tables.TemplateColumn( template_code=COL_TENANT diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index aadc2cbfc..e8cb73fe4 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -100,23 +100,6 @@ class DeviceTestCase(TestCase): self.assertIn('face', form.errors) self.assertIn('position', form.errors) - def test_initial_data_population(self): - device_type = DeviceType.objects.first() - cluster = Cluster.objects.first() - test = DeviceForm(initial={ - 'device_type': device_type.pk, - 'device_role': DeviceRole.objects.first().pk, - 'status': DeviceStatusChoices.STATUS_ACTIVE, - 'site': Site.objects.first().pk, - 'cluster': cluster.pk, - }) - - # Check that the initial value for the manufacturer is set automatically when assigning the device type - self.assertEqual(test.initial['manufacturer'], device_type.manufacturer.pk) - - # Check that the initial value for the cluster group is set automatically when assigning the cluster - self.assertEqual(test.initial['cluster_group'], cluster.group.pk) - class LabelTestCase(TestCase): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a5c3ee0e1..d9ed6db04 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2089,8 +2089,11 @@ class CableCreateView(ObjectEditView): initial_data = {k: request.GET[k] for k in request.GET} # Set initial site and rack based on side A termination (if not already set) + termination_a_site = getattr(obj.termination_a.parent, 'site', None) + if termination_a_site and 'termination_b_region' not in initial_data: + initial_data['termination_b_region'] = termination_a_site.region if 'termination_b_site' not in initial_data: - initial_data['termination_b_site'] = getattr(obj.termination_a.parent, 'site', None) + initial_data['termination_b_site'] = termination_a_site if 'termination_b_rack' not in initial_data: initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index a31ecdd60..3feacc685 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -351,10 +351,20 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): label='VRF', display_field='display_name' ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - null_option='None' + null_option='None', + query_params={ + 'region_id': '$region' + } ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -363,6 +373,9 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): null_option='None', query_params={ 'site_id': '$site' + }, + initial_params={ + 'vlans': '$vlan' } ) vlan = DynamicModelChoiceField( @@ -395,14 +408,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } def __init__(self, *args, **kwargs): - - # Initialize helper selectors - instance = kwargs.get('instance') - initial = kwargs.get('initial', {}).copy() - if instance and instance.vlan is not None: - initial['vlan_group'] = instance.vlan.group - kwargs['initial'] = initial - super().__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' @@ -472,9 +477,17 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput() ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + to_field_name='slug' + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region': '$region' + } ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -604,7 +617,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, - display_field='display_name' + display_field='display_name', + initial_params={ + 'interfaces': '$interface' + } ) interface = DynamicModelChoiceField( queryset=Interface.objects.all(), @@ -615,7 +631,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel ) virtual_machine = DynamicModelChoiceField( queryset=VirtualMachine.objects.all(), - required=False + required=False, + initial_params={ + 'interfaces': '$vminterface' + } ) vminterface = DynamicModelChoiceField( queryset=VMInterface.objects.all(), @@ -631,10 +650,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel label='VRF', display_field='display_name' ) + nat_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + label='Region', + initial_params={ + 'sites': '$nat_site' + } + ) nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - label='Site' + label='Site', + query_params={ + 'region_id': '$nat_region' + } ) nat_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), @@ -714,10 +744,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel initial = kwargs.get('initial', {}).copy() if instance: if type(instance.assigned_object) is Interface: - initial['device'] = instance.assigned_object.device initial['interface'] = instance.assigned_object elif type(instance.assigned_object) is VMInterface: - initial['virtual_machine'] = instance.assigned_object.virtual_machine initial['vminterface'] = instance.assigned_object if instance.nat_inside: nat_inside_parent = instance.nat_inside.assigned_object @@ -1028,16 +1056,26 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # class VLANGroupForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) slug = SlugField() class Meta: model = VLANGroup fields = [ - 'site', 'name', 'slug', 'description', + 'region', 'site', 'name', 'slug', 'description', ] @@ -1077,10 +1115,20 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - null_option='None' + null_option='None', + query_params={ + 'region_id': '$region' + } ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -1169,9 +1217,17 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput() ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + to_field_name='slug' + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region': '$region' + } ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index ad41e7d60..43dba8438 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -40,6 +40,7 @@

{{ form.term_side.value }}

+ {% render_field form.region %} {% render_field form.site %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index 644dfd3dc..05fd2de1a 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -32,6 +32,12 @@
{% if termination_a.device %} {# Device component #} +
+ +
+

{{ termination_a.device.site.region }}

+
+
@@ -111,6 +117,9 @@ {% if 'termination_b_provider' in form.fields %} {% render_field form.termination_b_provider %} {% endif %} + {% if 'termination_b_region' in form.fields %} + {% render_field form.termination_b_region %} + {% endif %} {% if 'termination_b_site' in form.fields %} {% render_field form.termination_b_site %} {% endif %} diff --git a/netbox/templates/dcim/powerfeed_edit.html b/netbox/templates/dcim/powerfeed_edit.html index f4b3ada46..e33aab19b 100644 --- a/netbox/templates/dcim/powerfeed_edit.html +++ b/netbox/templates/dcim/powerfeed_edit.html @@ -3,10 +3,16 @@ {% block form %}
-
Power Feed
+
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 %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index cd1192c19..c45aa190d 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -5,6 +5,7 @@
Rack
+ {% render_field form.region %} {% render_field form.site %} {% render_field form.name %} {% render_field form.facility_id %} diff --git a/netbox/templates/dcim/rackreservation_edit.html b/netbox/templates/dcim/rackreservation_edit.html index fd030d1fe..87b30abcc 100644 --- a/netbox/templates/dcim/rackreservation_edit.html +++ b/netbox/templates/dcim/rackreservation_edit.html @@ -5,6 +5,7 @@
Rack Reservation
+ {% render_field form.region %} {% render_field form.site %} {% render_field form.rack_group %} {% render_field form.rack %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html index 07b17f378..ac94cc7df 100644 --- a/netbox/templates/dcim/virtualchassis_add.html +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -13,6 +13,7 @@
Member Devices
+ {% render_field form.region %} {% render_field form.site %} {% render_field form.rack %} {% render_field form.members %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index e7894dbad..92093dea4 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -62,6 +62,7 @@
+ {% render_field form.nat_region %} {% render_field form.nat_site %} {% render_field form.nat_rack %} {% render_field form.nat_device %} diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html index 401a53e38..126593074 100644 --- a/netbox/templates/ipam/prefix_edit.html +++ b/netbox/templates/ipam/prefix_edit.html @@ -16,6 +16,7 @@
Site/VLAN Assignment
+ {% render_field form.region %} {% render_field form.site %} {% render_field form.vlan_group %} {% render_field form.vlan %} diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 1c191343d..9ea3cdabb 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -8,12 +8,18 @@ {% render_field form.vid %} {% render_field form.name %} {% render_field form.status %} - {% render_field form.site %} - {% render_field form.group %} {% render_field form.role %} {% render_field form.description %}
+
+
Assignment
+
+ {% render_field form.region %} + {% render_field form.site %} + {% render_field form.group %} +
+
Tenancy
diff --git a/netbox/templates/virtualization/cluster_edit.html b/netbox/templates/virtualization/cluster_edit.html index c4d39d12e..5ad7c53e1 100644 --- a/netbox/templates/virtualization/cluster_edit.html +++ b/netbox/templates/virtualization/cluster_edit.html @@ -8,6 +8,7 @@ {% render_field form.name %} {% render_field form.type %} {% render_field form.group %} + {% render_field form.region %} {% render_field form.site %}
diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 142333bff..bceab7ce7 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -119,7 +119,10 @@ class TenancyForm(forms.Form): tenant_group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False, - null_option='None' + null_option='None', + initial_params={ + 'tenants': '$tenant' + } ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -129,17 +132,6 @@ class TenancyForm(forms.Form): } ) - def __init__(self, *args, **kwargs): - - # Initialize helper selector - instance = kwargs.get('instance') - if instance and instance.tenant is not None: - initial = kwargs.get('initial', {}).copy() - initial['tenant_group'] = instance.tenant.group - kwargs['initial'] = initial - - super().__init__(*args, **kwargs) - class TenancyFilterForm(forms.Form): tenant_group = DynamicModelMultipleChoiceField( diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index d15445412..f0c242ceb 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -241,6 +241,7 @@ class DynamicModelChoiceMixin: """ :param display_field: The name of the attribute of an API response object to display in the selection list :param query_params: A dictionary of additional key/value pairs to attach to the API request + :param initial_params: A dictionary of child field references to use for selecting a parent field's initial value :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) @@ -249,10 +250,11 @@ class DynamicModelChoiceMixin: filter = django_filters.ModelChoiceFilter widget = widgets.APISelect - def __init__(self, display_field='name', query_params=None, null_option=None, disabled_indicator=None, - brief_mode=True, *args, **kwargs): + def __init__(self, display_field='name', query_params=None, initial_params=None, null_option=None, + disabled_indicator=None, brief_mode=True, *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 @@ -293,6 +295,16 @@ class DynamicModelChoiceMixin: def get_bound_field(self, form, field_name): bound_field = BoundField(form, self, field_name) + # Set initial value based on prescribed child fields (if not already set) + if not self.initial and self.initial_params: + filter_kwargs = {} + for kwarg, child_field in self.initial_params.items(): + value = form.initial.get(child_field.lstrip('$')) + if value: + filter_kwargs[kwarg] = value + if filter_kwargs: + self.initial = self.queryset.filter(**filter_kwargs).first() + # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. data = bound_field.value() diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index d86be752b..605f10e42 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -7,7 +7,7 @@ from django.http import Http404, HttpResponseRedirect from django.urls import reverse from .api import is_api_request -from .views import server_error +from .views import server_error, rest_api_server_error class LoginRequiredMiddleware(object): @@ -86,6 +86,10 @@ class ExceptionHandlingMiddleware(object): if isinstance(exception, Http404): return + # Handle exceptions that occur from REST API requests + if is_api_request(request): + return rest_api_server_error(request) + # Determine the type of exception. If it's a common issue, return a custom error page with instructions. custom_template = None if isinstance(exception, ProgrammingError): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 3fac91a58..399a0a5a8 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -13,7 +13,7 @@ from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, Obje from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea -from django.http import HttpResponse, HttpResponseServerError +from django.http import HttpResponse, HttpResponseServerError, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.template import loader from django.template.exceptions import TemplateDoesNotExist @@ -27,6 +27,7 @@ from django.views.decorators.csrf import requires_csrf_token from django.views.defaults import ERROR_500_TEMPLATE_NAME from django.views.generic import View from django_tables2 import RequestConfig +from rest_framework import status from extras.models import CustomField, ExportTemplate from utilities.exceptions import AbortTransaction @@ -1367,8 +1368,22 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): type_, error, traceback = sys.exc_info() return HttpResponseServerError(template.render({ - 'python_version': platform.python_version(), - 'netbox_version': settings.VERSION, - 'exception': str(type_), 'error': error, + 'exception': str(type_), + 'netbox_version': settings.VERSION, + 'python_version': platform.python_version(), })) + + +def rest_api_server_error(request, *args, **kwargs): + """ + Handle exceptions and return a useful error message for REST API requests. + """ + type_, error, traceback = sys.exc_info() + data = { + 'error': str(error), + 'exception': type_.__name__, + 'netbox_version': settings.VERSION, + 'python_version': platform.python_version(), + } + return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 712b39f9f..ce4ca3e3c 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -79,9 +79,19 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=ClusterGroup.objects.all(), required=False ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) comments = CommentField() tags = DynamicModelMultipleChoiceField( @@ -92,7 +102,7 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'tenant', 'region', 'site', 'comments', 'tags', ) @@ -143,9 +153,17 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Tenant.objects.all(), required=False ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + to_field_name='slug' + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region': '$region' + } ) comments = CommentField( widget=SmallTextarea, @@ -266,7 +284,10 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, - null_option='None' + null_option='None', + initial_params={ + 'clusters': '$cluster' + } ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), @@ -311,14 +332,6 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } def __init__(self, *args, **kwargs): - - # Initialize helper selector - instance = kwargs.get('instance') - if instance.pk and instance.cluster is not None: - initial = kwargs.get('initial', {}).copy() - initial['cluster_group'] = instance.cluster.group - kwargs['initial'] = initial - super().__init__(*args, **kwargs) if self.instance.pk: