Răsfoiți Sursa

Merge branch 'develop' into feature

jeremystretch 3 ani în urmă
părinte
comite
87af94a7d2

+ 11 - 3
README.md

@@ -2,8 +2,6 @@
   <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
   <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
 </div>
 </div>
 
 
-![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
-
 NetBox is the leading solution for modeling and documenting modern networks. By
 NetBox is the leading solution for modeling and documenting modern networks. By
 combining the traditional disciplines of IP address management (IPAM) and
 combining the traditional disciplines of IP address management (IPAM) and
 datacenter infrastructure management (DCIM) with powerful APIs and extensions,
 datacenter infrastructure management (DCIM) with powerful APIs and extensions,
@@ -11,6 +9,16 @@ NetBox provides the ideal "source of truth" to power network automation.
 Available as open source software under the Apache 2.0 license, NetBox is
 Available as open source software under the Apache 2.0 license, NetBox is
 employed by thousands of organizations around the world.
 employed by thousands of organizations around the world.
 
 
+![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
+
+[![Timeline graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg)](https://github.com/netbox-community/netbox/commits)
+[![Issue status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg)](https://github.com/netbox-community/netbox/issues)
+[![Pull request status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg)](https://github.com/netbox-community/netbox/pulls)
+[![Top contributors](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg)](https://github.com/netbox-community/netbox/graphs/contributors)
+<br />Stats via [Repography](https://repography.com)
+
+## About NetBox
+
 ![Screenshot of Netbox UI](docs/media/screenshots/netbox-ui.png "NetBox UI")
 ![Screenshot of Netbox UI](docs/media/screenshots/netbox-ui.png "NetBox UI")
 
 
 Myriad infrastructure components can be modeled in NetBox, including:
 Myriad infrastructure components can be modeled in NetBox, including:
@@ -57,7 +65,7 @@ complete list of requirements, see `requirements.txt`. The code is available
 [on GitHub](https://github.com/netbox-community/netbox).
 [on GitHub](https://github.com/netbox-community/netbox).
 
 
 <div align="center">
 <div align="center">
-  <h4>Thank you to our sponsors!</h4>
+  <h3>Thank you to our sponsors!</h3>
 
 
   [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
   [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;

+ 1 - 1
docs/installation/3-netbox.md

@@ -7,7 +7,7 @@ This section of the documentation discusses installing and configuring the NetBo
 Begin by installing all system packages required by NetBox and its dependencies.
 Begin by installing all system packages required by NetBox and its dependencies.
 
 
 !!! warning "Python 3.8 or later required"
 !!! warning "Python 3.8 or later required"
-    NetBox v3.2 requires Python 3.8, 3.9, or 3.10.
+    NetBox requires Python 3.8, 3.9, or 3.10.
 
 
 === "Ubuntu"
 === "Ubuntu"
 
 

+ 2 - 0
docs/installation/index.md

@@ -2,6 +2,8 @@
 
 
 The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
 The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
 
 
+<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
+
 The following sections detail how to set up a new instance of NetBox:
 The following sections detail how to set up a new instance of NetBox:
 
 
 1. [PostgreSQL database](1-postgresql.md)
 1. [PostgreSQL database](1-postgresql.md)

+ 15 - 6
docs/installation/upgrading.md

@@ -1,10 +1,19 @@
 # Upgrading to a New NetBox Release
 # Upgrading to a New NetBox Release
 
 
-## Review the Release Notes
+Upgrading NetBox to a new version is pretty simple, however users are cautioned to always review the release notes and save a backup of their current deployment prior to beginning an upgrade.
+
+NetBox can generally be upgraded directly to any newer release with no interim steps, with the one exception being incrementing major versions. This can be done only from the most recent _minor_ release of the major version. For example, NetBox v2.11.8 can be upgraded to version 3.3.2 following the steps below. However, a deployment of NetBox v2.10.10 or earlier must first be upgraded to any v2.11 release, and then to any v3.x release. (This is to accommodate the consolidation of database schema migrations effected by a major version change).
+
+[![Upgrade paths](../media/installation/upgrade_paths.png)](../media/installation/upgrade_paths.png)
+
+!!! warning "Perform a Backup"
+    Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process.
+
+## 1. Review the Release Notes
 
 
 Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
 Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
 
 
-## Update Dependencies to Required Versions
+## 2. Update Dependencies to Required Versions
 
 
 NetBox v3.0 and later require the following:
 NetBox v3.0 and later require the following:
 
 
@@ -14,7 +23,7 @@ NetBox v3.0 and later require the following:
 | PostgreSQL | 10              |
 | PostgreSQL | 10              |
 | Redis      | 4.0             |
 | Redis      | 4.0             |
 
 
-## Install the Latest Release
+## 3. Install the Latest Release
 
 
 As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. 
 As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. 
 
 
@@ -87,7 +96,7 @@ sudo git pull origin master
 
 
         sudo git checkout v2.11.11
         sudo git checkout v2.11.11
 
 
-## Run the Upgrade Script
+## 4. Run the Upgrade Script
 
 
 Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
 Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
 
 
@@ -118,7 +127,7 @@ This script performs the following actions:
     been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are
     been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are
     intentionally modifying the database schema.
     intentionally modifying the database schema.
 
 
-## Restart the NetBox Services
+## 5. Restart the NetBox Services
 
 
 !!! warning
 !!! warning
     If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
     If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
@@ -129,7 +138,7 @@ Finally, restart the gunicorn and RQ services:
 sudo systemctl restart netbox netbox-rq
 sudo systemctl restart netbox netbox-rq
 ```
 ```
 
 
-## Verify Housekeeping Scheduling
+## 6. Verify Housekeeping Scheduling
 
 
 If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
 If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
 
 

BIN
docs/media/installation/upgrade_paths.png


+ 22 - 0
docs/release-notes/version-3.3.md

@@ -1,5 +1,27 @@
 # NetBox v3.3
 # NetBox v3.3
 
 
+## v3.3.3 (FUTURE)
+
+### Enhancements
+
+* [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected`
+* [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types
+* [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI
+
+### Bug Fixes
+
+* [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters
+* [#10250](https://github.com/netbox-community/netbox/issues/10250) - Fix exception when CableTermination validation fails during bulk import of cables
+* [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed
+* [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services
+* [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments
+* [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field
+* [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links
+* [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection
+* [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import
+
+---
+
 ## v3.3.2 (2022-09-02)
 ## v3.3.2 (2022-09-02)
 
 
 ### Enhancements
 ### Enhancements

+ 12 - 2
netbox/circuits/tests/test_filtersets.py

@@ -344,6 +344,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
             Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
             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 5'),
             Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
             Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
+            Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'),
         )
         )
         Circuit.objects.bulk_create(circuits)
         Circuit.objects.bulk_create(circuits)
 
 
@@ -357,6 +358,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
             CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], 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[4], provider_network=provider_networks[1], term_side='A'),
             CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
             CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
+            CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True),
         ))
         ))
         CircuitTermination.objects.bulk_create(circuit_terminations)
         CircuitTermination.objects.bulk_create(circuit_terminations)
 
 
@@ -364,7 +366,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
 
 
     def test_term_side(self):
     def test_term_side(self):
         params = {'term_side': 'A'}
         params = {'term_side': 'A'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
 
 
     def test_port_speed(self):
     def test_port_speed(self):
         params = {'port_speed': ['1000', '2000']}
         params = {'port_speed': ['1000', '2000']}
@@ -397,11 +399,19 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
     def test_provider_network(self):
     def test_provider_network(self):
         provider_networks = ProviderNetwork.objects.all()[:2]
         provider_networks = ProviderNetwork.objects.all()[:2]
         params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
         params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
     def test_cabled(self):
     def test_cabled(self):
         params = {'cabled': True}
         params = {'cabled': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+
+    def test_occupied(self):
+        params = {'occupied': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'occupied': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
 
 
 
 
 class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):

+ 1 - 1
netbox/dcim/choices.py

@@ -1096,7 +1096,7 @@ class InterfacePoETypeChoices(ChoiceSet):
                 (PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
                 (PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
                 (PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
                 (PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
                 (PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
                 (PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
-                (PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'),
+                (PASSIVE_48V_4PAIR, 'Passive 48V (4-pair)'),
             )
             )
         ),
         ),
     )
     )

+ 29 - 0
netbox/dcim/filtersets.py

@@ -434,6 +434,14 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Manufacturer (slug)',
         label='Manufacturer (slug)',
     )
     )
+    has_front_image = django_filters.BooleanFilter(
+        label='Has a front image',
+        method='_has_front_image'
+    )
+    has_rear_image = django_filters.BooleanFilter(
+        label='Has a rear image',
+        method='_has_rear_image'
+    )
     console_ports = django_filters.BooleanFilter(
     console_ports = django_filters.BooleanFilter(
         method='_console_ports',
         method='_console_ports',
         label='Has console ports',
         label='Has console ports',
@@ -487,6 +495,18 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         )
         )
 
 
+    def _has_front_image(self, queryset, name, value):
+        if value:
+            return queryset.exclude(front_image='')
+        else:
+            return queryset.filter(front_image='')
+
+    def _has_rear_image(self, queryset, name, value):
+        if value:
+            return queryset.exclude(rear_image='')
+        else:
+            return queryset.filter(rear_image='')
+
     def _console_ports(self, queryset, name, value):
     def _console_ports(self, queryset, name, value):
         return queryset.exclude(consoleporttemplates__isnull=value)
         return queryset.exclude(consoleporttemplates__isnull=value)
 
 
@@ -1144,6 +1164,15 @@ class CabledObjectFilterSet(django_filters.FilterSet):
         lookup_expr='isnull',
         lookup_expr='isnull',
         exclude=True
         exclude=True
     )
     )
+    occupied = django_filters.BooleanFilter(
+        method='filter_occupied'
+    )
+
+    def filter_occupied(self, queryset, name, value):
+        if value:
+            return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True))
+        else:
+            return queryset.filter(cable__isnull=True, mark_connected=False)
 
 
 
 
 class PathEndpointFilterSet(django_filters.FilterSet):
 class PathEndpointFilterSet(django_filters.FilterSet):

+ 53 - 7
netbox/dcim/forms/filtersets.py

@@ -365,6 +365,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
         ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
+        ('Images', ('has_front_image', 'has_rear_image')),
         ('Components', (
         ('Components', (
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
             'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
@@ -386,6 +387,20 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
         choices=add_blank_choice(DeviceAirflowChoices),
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
         required=False
     )
     )
+    has_front_image = forms.NullBooleanField(
+        required=False,
+        label='Has a front image',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    has_rear_image = forms.NullBooleanField(
+        required=False,
+        label='Has a rear image',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
     console_ports = forms.NullBooleanField(
     console_ports = forms.NullBooleanField(
         required=False,
         required=False,
         label='Has console ports',
         label='Has console ports',
@@ -936,12 +951,37 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
 # Device components
 # Device components
 #
 #
 
 
-class ConsolePortFilterForm(DeviceComponentFilterForm):
+class CabledFilterForm(forms.Form):
+    cabled = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    occupied = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+
+
+class PathEndpointFilterForm(CabledFilterForm):
+    connected = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+
+
+class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = ConsolePort
     model = ConsolePort
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Connection', ('cabled', 'connected', 'occupied')),
     )
     )
     type = MultipleChoiceField(
     type = MultipleChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
@@ -954,12 +994,13 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
+class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = ConsoleServerPort
     model = ConsoleServerPort
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Connection', ('cabled', 'connected', 'occupied')),
     )
     )
     type = MultipleChoiceField(
     type = MultipleChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
@@ -972,12 +1013,13 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class PowerPortFilterForm(DeviceComponentFilterForm):
+class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = PowerPort
     model = PowerPort
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Attributes', ('name', 'label', 'type')),
         ('Attributes', ('name', 'label', 'type')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Connection', ('cabled', 'connected', 'occupied')),
     )
     )
     type = MultipleChoiceField(
     type = MultipleChoiceField(
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
@@ -986,12 +1028,13 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class PowerOutletFilterForm(DeviceComponentFilterForm):
+class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = PowerOutlet
     model = PowerOutlet
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Attributes', ('name', 'label', 'type')),
         ('Attributes', ('name', 'label', 'type')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Connection', ('cabled', 'connected', 'occupied')),
     )
     )
     type = MultipleChoiceField(
     type = MultipleChoiceField(
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
@@ -1000,7 +1043,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class InterfaceFilterForm(DeviceComponentFilterForm):
+class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     model = Interface
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
@@ -1009,6 +1052,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
         ('PoE', ('poe_mode', 'poe_type')),
         ('PoE', ('poe_mode', 'poe_type')),
         ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
         ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Connection', ('cabled', 'connected', 'occupied')),
     )
     )
     kind = MultipleChoiceField(
     kind = MultipleChoiceField(
         choices=InterfaceKindChoices,
         choices=InterfaceKindChoices,
@@ -1089,11 +1133,12 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class FrontPortFilterForm(DeviceComponentFilterForm):
+class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Cable', ('cabled', 'occupied')),
     )
     )
     model = FrontPort
     model = FrontPort
     type = MultipleChoiceField(
     type = MultipleChoiceField(
@@ -1106,12 +1151,13 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class RearPortFilterForm(DeviceComponentFilterForm):
+class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     model = RearPort
     model = RearPort
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Cable', ('cabled', 'occupied')),
     )
     )
     type = MultipleChoiceField(
     type = MultipleChoiceField(
         choices=PortTypeChoices,
         choices=PortTypeChoices,

+ 6 - 0
netbox/dcim/forms/models.py

@@ -1331,6 +1331,12 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
         label='VRF'
         label='VRF'
     )
     )
 
 
+    wwn = forms.CharField(
+        empty_value=None,
+        required=False,
+        label='WWN'
+    )
+
     fieldsets = (
     fieldsets = (
         ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')),
         ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')),
         ('Addressing', ('vrf', 'mac_address', 'wwn')),
         ('Addressing', ('vrf', 'mac_address', 'wwn')),

+ 2 - 6
netbox/dcim/models/cables.py

@@ -281,15 +281,11 @@ class CableTermination(models.Model):
 
 
         # Validate interface type (if applicable)
         # Validate interface type (if applicable)
         if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
         if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
-            raise ValidationError({
-                'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces'
-            })
+            raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
 
 
         # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
         # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
         if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
         if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
-            raise ValidationError({
-                'termination': "Circuit terminations attached to a provider network may not be cabled."
-            })
+            raise ValidationError("Circuit terminations attached to a provider network may not be cabled.")
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 

+ 7 - 4
netbox/dcim/tables/devices.py

@@ -152,6 +152,9 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
     rack = tables.Column(
     rack = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    position = columns.TemplateColumn(
+        template_code='{{ value|floatformat }}'
+    )
     device_role = columns.ColoredLabelColumn(
     device_role = columns.ColoredLabelColumn(
         verbose_name='Role'
         verbose_name='Role'
     )
     )
@@ -199,10 +202,10 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Device
         model = Device
         fields = (
         fields = (
-            'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
-            'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
-            'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags',
-            'created', 'last_updated',
+            'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
+            'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow',
+            'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
+            'contacts', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
             'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

+ 103 - 49
netbox/dcim/tests/test_filtersets.py

@@ -688,7 +688,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         Manufacturer.objects.bulk_create(manufacturers)
         Manufacturer.objects.bulk_create(manufacturers)
 
 
         device_types = (
         device_types = (
-            DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True),
+            DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'),
             DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR),
             DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR),
             DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT),
             DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT),
         )
         )
@@ -753,9 +753,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_is_full_depth(self):
     def test_is_full_depth(self):
-        params = {'is_full_depth': 'true'}
+        params = {'is_full_depth': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'is_full_depth': 'false'}
+        params = {'is_full_depth': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_subdevice_role(self):
     def test_subdevice_role(self):
@@ -773,6 +773,18 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
         params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_has_front_image(self):
+        params = {'has_front_image': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'has_front_image': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_has_rear_image(self):
+        params = {'has_rear_image': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'has_rear_image': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_console_ports(self):
     def test_console_ports(self):
         params = {'console_ports': 'true'}
         params = {'console_ports': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1983,12 +1995,6 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['First', 'Second']}
         params = {'description': ['First', 'Second']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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(), 1)
-
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2037,9 +2043,21 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_cabled(self):
     def test_cabled(self):
-        params = {'cabled': 'true'}
+        params = {'cabled': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_occupied(self):
+        params = {'occupied': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'cabled': 'false'}
+        params = {'occupied': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    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(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
@@ -2144,12 +2162,6 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['First', 'Second']}
         params = {'description': ['First', 'Second']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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(), 1)
-
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2198,9 +2210,21 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_cabled(self):
     def test_cabled(self):
-        params = {'cabled': 'true'}
+        params = {'cabled': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_occupied(self):
+        params = {'occupied': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'occupied': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_connected(self):
+        params = {'connected': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'cabled': 'false'}
+        params = {'connected': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
@@ -2313,12 +2337,6 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'allocated_draw': [50, 100]}
         params = {'allocated_draw': [50, 100]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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(), 1)
-
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2367,9 +2385,21 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_cabled(self):
     def test_cabled(self):
-        params = {'cabled': 'true'}
+        params = {'cabled': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'cabled': 'false'}
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_occupied(self):
+        params = {'occupied': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'occupied': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    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(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
@@ -2478,12 +2508,6 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
         params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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(), 1)
-
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2532,9 +2556,21 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_cabled(self):
     def test_cabled(self):
-        params = {'cabled': 'true'}
+        params = {'cabled': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_occupied(self):
+        params = {'occupied': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'occupied': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_connected(self):
+        params = {'connected': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'cabled': 'false'}
+        params = {'connected': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
@@ -2741,12 +2777,6 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'label': ['A', 'B']}
         params = {'label': ['A', 'B']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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(), 4)
-        params = {'connected': False}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-
     def test_enabled(self):
     def test_enabled(self):
         params = {'enabled': 'true'}
         params = {'enabled': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
@@ -2880,9 +2910,21 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_cabled(self):
     def test_cabled(self):
-        params = {'cabled': 'true'}
+        params = {'cabled': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_occupied(self):
+        params = {'occupied': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'occupied': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-        params = {'cabled': 'false'}
+
+    def test_connected(self):
+        params = {'connected': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'connected': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
     def test_kind(self):
     def test_kind(self):
@@ -3091,9 +3133,15 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_cabled(self):
     def test_cabled(self):
-        params = {'cabled': 'true'}
+        params = {'cabled': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-        params = {'cabled': 'false'}
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_occupied(self):
+        params = {'occupied': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'occupied': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
@@ -3255,9 +3303,15 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_cabled(self):
     def test_cabled(self):
-        params = {'cabled': 'true'}
+        params = {'cabled': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_occupied(self):
+        params = {'occupied': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-        params = {'cabled': 'false'}
+        params = {'occupied': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
@@ -4159,9 +4213,9 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_cabled(self):
     def test_cabled(self):
-        params = {'cabled': 'true'}
+        params = {'cabled': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'cabled': 'false'}
+        params = {'cabled': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_connected(self):
     def test_connected(self):

+ 5 - 1
netbox/extras/forms/bulk_import.py

@@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.forms import SimpleArrayField
 from django.contrib.postgres.forms import SimpleArrayField
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
-from extras.choices import CustomFieldTypeChoices
+from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices
 from extras.models import *
 from extras.models import *
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
@@ -38,6 +38,10 @@ class CustomFieldCSVForm(CSVModelForm):
         required=False,
         required=False,
         help_text='Comma-separated list of field choices'
         help_text='Comma-separated list of field choices'
     )
     )
+    ui_visibility = CSVChoiceField(
+        choices=CustomFieldVisibilityChoices,
+        help_text='How the custom field is displayed in the user interface'
+    )
 
 
     class Meta:
     class Meta:
         model = CustomField
         model = CustomField

+ 8 - 2
netbox/extras/models/customfields.py

@@ -14,7 +14,7 @@ from django.utils.safestring import mark_safe
 from extras.choices import *
 from extras.choices import *
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
-from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
+from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
 from utilities import filters
 from utilities import filters
 from utilities.forms import (
 from utilities.forms import (
     CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
@@ -41,7 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
         return self.get_queryset().filter(content_types=content_type)
         return self.get_queryset().filter(content_types=content_type)
 
 
 
 
-class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
+class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
     content_types = models.ManyToManyField(
     content_types = models.ManyToManyField(
         to=ContentType,
         to=ContentType,
         related_name='custom_fields',
         related_name='custom_fields',
@@ -143,8 +143,14 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
         verbose_name='UI visibility',
         verbose_name='UI visibility',
         help_text='Specifies the visibility of custom field in the UI'
         help_text='Specifies the visibility of custom field in the UI'
     )
     )
+
     objects = CustomFieldManager()
     objects = CustomFieldManager()
 
 
+    clone_fields = (
+        'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'filter_logic', 'default',
+        'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'ui_visibility',
+    )
+
     class Meta:
     class Meta:
         ordering = ['group_name', 'weight', 'name']
         ordering = ['group_name', 'weight', 'name']
 
 

+ 6 - 2
netbox/extras/models/models.py

@@ -21,7 +21,7 @@ from extras.conditions import ConditionSet
 from extras.utils import FeatureQuery, image_upload
 from extras.utils import FeatureQuery, image_upload
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import (
 from netbox.models.features import (
-    CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
+    CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
 )
 )
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.utils import render_jinja2
 from utilities.utils import render_jinja2
@@ -187,7 +187,7 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
         return render_jinja2(self.payload_url, context)
         return render_jinja2(self.payload_url, context)
 
 
 
 
-class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
+class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
     """
     """
     A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
     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.
     code to be rendered with an object as context.
@@ -230,6 +230,10 @@ class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
         help_text="Force link to open in a new window"
         help_text="Force link to open in a new window"
     )
     )
 
 
+    clone_fields = (
+        'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
+    )
+
     class Meta:
     class Meta:
         ordering = ['group_name', 'weight', 'name']
         ordering = ['group_name', 'weight', 'name']
 
 

+ 6 - 0
netbox/extras/views.py

@@ -441,6 +441,12 @@ class ImageAttachmentEditView(generic.ObjectEditView):
     def get_return_url(self, request, obj=None):
     def get_return_url(self, request, obj=None):
         return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
         return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
 
 
+    def get_extra_addanother_params(self, request):
+        return {
+            'content_type': request.GET.get('content_type'),
+            'object_id': request.GET.get('object_id'),
+        }
+
 
 
 class ImageAttachmentDeleteView(generic.ObjectDeleteView):
 class ImageAttachmentDeleteView(generic.ObjectDeleteView):
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()

+ 1 - 0
netbox/ipam/forms/models.py

@@ -854,6 +854,7 @@ class ServiceCreateForm(ServiceForm):
             del self.fields[field].widget.attrs['required']
             del self.fields[field].widget.attrs['required']
 
 
     def clean(self):
     def clean(self):
+        super().clean()
         if self.cleaned_data['service_template']:
         if self.cleaned_data['service_template']:
             # Create a new Service from the specified template
             # Create a new Service from the specified template
             service_template = self.cleaned_data['service_template']
             service_template = self.cleaned_data['service_template']

+ 14 - 6
netbox/ipam/tables/ip.py

@@ -21,6 +21,14 @@ __all__ = (
 AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
 AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
 
 
 PREFIX_LINK = """
 PREFIX_LINK = """
+{% if record.pk %}
+  <a href="{{ record.get_absolute_url }}">{{ record.prefix }}</a>
+{% else %}
+  <a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
+{% endif %}
+"""
+
+PREFIX_LINK_WITH_DEPTH = """
 {% load helpers %}
 {% load helpers %}
 {% if record.depth %}
 {% if record.depth %}
     <div class="record-depth">
     <div class="record-depth">
@@ -29,8 +37,7 @@ PREFIX_LINK = """
         {% endfor %}
         {% endfor %}
     </div>
     </div>
 {% endif %}
 {% endif %}
-<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
-"""
+""" + PREFIX_LINK
 
 
 IPADDRESS_LINK = """
 IPADDRESS_LINK = """
 {% if record.pk %}
 {% if record.pk %}
@@ -216,14 +223,15 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
 
 
 class PrefixTable(TenancyColumnsMixin, NetBoxTable):
 class PrefixTable(TenancyColumnsMixin, NetBoxTable):
     prefix = columns.TemplateColumn(
     prefix = columns.TemplateColumn(
-        template_code=PREFIX_LINK,
+        template_code=PREFIX_LINK_WITH_DEPTH,
         export_raw=True,
         export_raw=True,
         attrs={'td': {'class': 'text-nowrap'}}
         attrs={'td': {'class': 'text-nowrap'}}
     )
     )
-    prefix_flat = tables.Column(
+    prefix_flat = columns.TemplateColumn(
         accessor=Accessor('prefix'),
         accessor=Accessor('prefix'),
-        linkify=True,
-        verbose_name='Prefix (Flat)',
+        template_code=PREFIX_LINK,
+        export_raw=True,
+        verbose_name='Prefix (Flat)'
     )
     )
     depth = tables.Column(
     depth = tables.Column(
         accessor=Accessor('_depth'),
         accessor=Accessor('_depth'),

+ 7 - 0
netbox/netbox/filtersets.py

@@ -80,6 +80,13 @@ class BaseFilterSet(django_filters.FilterSet):
         },
         },
     })
     })
 
 
+    def __init__(self, *args, **kwargs):
+        # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
+        # however FilterSet Factory is setup before this which creates the
+        # initial filters.  This recreates the filters so Empty is picked up correctly.
+        self.base_filters = self.__class__.get_filters()
+        super().__init__(*args, **kwargs)
+
     @staticmethod
     @staticmethod
     def _get_filter_lookup_dict(existing_filter):
     def _get_filter_lookup_dict(existing_filter):
         # Choose the lookup expression map based on the filter type
         # Choose the lookup expression map based on the filter type

+ 2 - 22
netbox/netbox/models/__init__.py

@@ -2,7 +2,6 @@ from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 
 
-from extras.utils import is_taggable
 from utilities.mptt import TreeManager
 from utilities.mptt import TreeManager
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from netbox.models.features import *
 from netbox.models.features import *
@@ -32,7 +31,7 @@ class NetBoxFeatureSet(
     def get_prerequisite_models(cls):
     def get_prerequisite_models(cls):
         """
         """
         Return a list of model types that are required to create this model or empty list if none.  This is used for
         Return a list of model types that are required to create this model or empty list if none.  This is used for
-        showing prequisite warnings in the UI on the list and detail views.
+        showing prerequisite warnings in the UI on the list and detail views.
         """
         """
         return []
         return []
 
 
@@ -52,7 +51,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model)
         abstract = True
         abstract = True
 
 
 
 
-class NetBoxModel(NetBoxFeatureSet, models.Model):
+class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
     """
     """
     Primary models represent real objects within the infrastructure being modeled.
     Primary models represent real objects within the infrastructure being modeled.
     """
     """
@@ -61,25 +60,6 @@ class NetBoxModel(NetBoxFeatureSet, models.Model):
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
-    def clone(self):
-        """
-        Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre-
-        populating an object creation form in the UI.
-        """
-        attrs = {}
-
-        for field_name in getattr(self, 'clone_fields', []):
-            field = self._meta.get_field(field_name)
-            field_value = field.value_from_object(self)
-            if field_value not in (None, ''):
-                attrs[field_name] = field_value
-
-        # Include tags (if applicable)
-        if is_taggable(self):
-            attrs['tags'] = [tag.pk for tag in self.tags.all()]
-
-        return attrs
-
 
 
 class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
 class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
     """
     """

+ 29 - 1
netbox/netbox/models/features.py

@@ -10,12 +10,13 @@ from django.db import models
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
 from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
 from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
-from extras.utils import register_features
+from extras.utils import is_taggable, register_features
 from netbox.signals import post_clean
 from netbox.signals import post_clean
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
 
 
 __all__ = (
 __all__ = (
     'ChangeLoggingMixin',
     'ChangeLoggingMixin',
+    'CloningMixin',
     'CustomFieldsMixin',
     'CustomFieldsMixin',
     'CustomLinksMixin',
     'CustomLinksMixin',
     'CustomValidationMixin',
     'CustomValidationMixin',
@@ -82,6 +83,33 @@ class ChangeLoggingMixin(models.Model):
         return objectchange
         return objectchange
 
 
 
 
+class CloningMixin(models.Model):
+    """
+    Provides the clone() method used to prepare a copy of existing objects.
+    """
+    class Meta:
+        abstract = True
+
+    def clone(self):
+        """
+        Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre-
+        populating an object creation form in the UI.
+        """
+        attrs = {}
+
+        for field_name in getattr(self, 'clone_fields', []):
+            field = self._meta.get_field(field_name)
+            field_value = field.value_from_object(self)
+            if field_value not in (None, ''):
+                attrs[field_name] = field_value
+
+        # Include tags (if applicable)
+        if is_taggable(self):
+            attrs['tags'] = [tag.pk for tag in self.tags.all()]
+
+        return attrs
+
+
 class CustomFieldsMixin(models.Model):
 class CustomFieldsMixin(models.Model):
     """
     """
     Enables support for custom fields.
     Enables support for custom fields.

+ 1 - 1
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.3.2'
+VERSION = '3.3.3-dev'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 1 - 1
netbox/templates/dcim/device.html

@@ -70,7 +70,7 @@
                                         {% endif %}
                                         {% endif %}
                                     {% endwith %}
                                     {% endwith %}
                                 {% elif object.rack and object.position %}
                                 {% elif object.rack and object.position %}
-                                    <span>U{{ object.position }} / {{ object.get_face_display }}</span>
+                                    <span>U{{ object.position|floatformat }} / {{ object.get_face_display }}</span>
                                 {% elif object.rack and object.device_type.u_height %}
                                 {% elif object.rack and object.device_type.u_height %}
                                     <span class="badge bg-warning">Not racked</span>
                                     <span class="badge bg-warning">Not racked</span>
                                 {% else %}
                                 {% else %}

+ 1 - 1
netbox/templates/dcim/virtualchassis_edit.html

@@ -55,7 +55,7 @@
                               <td>{{ device.pk }}</td>
                               <td>{{ device.pk }}</td>
                               <td>
                               <td>
                                   {% if device.rack %}
                                   {% if device.rack %}
-                                      {{ device.rack }} / {{ device.position }}
+                                      {{ device.rack }} / {{ device.position|floatformat }}
                                   {% else %}
                                   {% else %}
                                       {{ ''|placeholder }}
                                       {{ ''|placeholder }}
                                   {% endif %}
                                   {% endif %}

+ 13 - 0
pyproject.toml

@@ -0,0 +1,13 @@
+# See PEP 518 for the spec of this file
+# https://www.python.org/dev/peps/pep-0518/
+
+[tool.black]
+line-length = 120
+target_version = ['py38', 'py39', 'py310']
+skip-string-normalization = true
+
+[tool.isort]
+profile = "black"
+
+[tool.pylint]
+max-line-length = 120

+ 7 - 4
scripts/git-hooks/pre-commit

@@ -40,10 +40,13 @@ if [ $? != 0 ]; then
 	EXIT=1
 	EXIT=1
 fi
 fi
 
 
-echo "Checking UI ESLint, TypeScript, and Prettier compliance..."
-yarn --cwd "$PWD/netbox/project-static" validate
-if [ $? != 0 ]; then
-	EXIT=1
+git diff --cached --name-only | if grep --quiet 'netbox/project-static/'
+then
+    echo "Checking UI ESLint, TypeScript, and Prettier compliance..."
+    yarn --cwd "$PWD/netbox/project-static" validate
+    if [ $? != 0 ]; then
+    	EXIT=1
+    fi
 fi
 fi
 
 
 if [ $EXIT != 0 ]; then
 if [ $EXIT != 0 ]; then