diff --git a/docs/models/circuits/circuit.md b/docs/models/circuits/circuit.md index 9421f94fb..3aaa4e99f 100644 --- a/docs/models/circuits/circuit.md +++ b/docs/models/circuits/circuit.md @@ -13,7 +13,7 @@ Each circuit is also assigned one of the following operational statuses: * Deprovisioning * Decommissioned -Circuits also have optional fields for annotating their installation date and commit rate, and may be assigned to NetBox tenants. +Circuits also have optional fields for annotating their installation and termination dates and commit rate, and may be assigned to NetBox tenants. !!! note NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 7fa52fa9f..e3237c2ee 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -11,6 +11,10 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. +### Power over Ethernet (PoE) + +Physical interfaces can be assigned a PoE mode to indicate PoE capability: power supplying equipment (PSE) or powered device (PD). Additionally, a PoE mode may be specified. This can be one of the listed IEEE 802.3 standards, or a passive setting (24 or 48 volts across two or four pairs). + ### Wireless Interfaces Wireless interfaces may additionally track the following attributes: diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md index 901a68acf..fb72c218d 100644 --- a/docs/models/dcim/location.md +++ b/docs/models/dcim/location.md @@ -2,5 +2,4 @@ Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. -Each location must have a name that is unique within its parent site and location, if any. - +Each location must have a name that is unique within its parent site and location, if any, and must be assigned an operational status. (The set of available statuses is configurable.) diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index bb4a22e0d..08b5f4fd5 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -5,9 +5,11 @@ Sometimes it is desirable to associate additional data with a group of devices o * Region * Site group * Site +* Location (devices only) * Device type (devices only) * Role * Platform +* Cluster type (VMs only) * Cluster group (VMs only) * Cluster (VMs only) * Tenant group diff --git a/docs/models/users/token.md b/docs/models/users/token.md index d98b51369..367444477 100644 --- a/docs/models/users/token.md +++ b/docs/models/users/token.md @@ -9,4 +9,4 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. -Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. +Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. Tokens can also be restricted by IP range: If defined, authentication for API clients connecting from an IP address outside these ranges will fail. diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md index 80a3a40b0..cb478664c 100644 --- a/docs/models/wireless/wirelesslan.md +++ b/docs/models/wireless/wirelesslan.md @@ -1,6 +1,6 @@ # Wireless LANs -A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups. +A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups, and each may be associated with a particular tenant. An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs. diff --git a/docs/models/wireless/wirelesslink.md b/docs/models/wireless/wirelesslink.md index 85cdbd6d9..f52dc7191 100644 --- a/docs/models/wireless/wirelesslink.md +++ b/docs/models/wireless/wirelesslink.md @@ -1,6 +1,6 @@ # Wireless Links -A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model. +A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model. Each wireless link may also be assigned to a particular tenant. Each wireless link may have authentication attributes associated with it, including: diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 0e1fec6e5..43e3cad9a 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -49,6 +49,24 @@ class MyModel(NetBoxModel): ... ``` +### The `clone()` Method + +!!! info + This method was introduced in NetBox v3.3. + +The `NetBoxModel` class includes a `clone()` method to be used for gathering attriubtes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined. + +Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content: + +```python +class MyModel(NetBoxModel): + + def clone(self): + attrs = super().clone() + attrs['extra-value'] = 123 + return attrs +``` + ### Enabling Features Individually If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 229509b9c..1e18de1e6 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -11,15 +11,30 @@ #### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) +#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099)) + +#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations +* [#4434](https://github.com/netbox-community/netbox/issues/4434) - Enable highlighting devices within rack elevations * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster +* [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit +* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields +* [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links +* [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times +* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location + +### Plugins API + +* [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes ### Other Changes @@ -28,20 +43,35 @@ ### REST API Changes +* circuits.Circuit + * Added optional `termination_date` field * dcim.Device * The `position` field has been changed from an integer to a decimal * dcim.DeviceType * The `u_height` field has been changed from an integer to a decimal +* dcim.Interface + * Added the optional `poe_mode` and `poe_type` fields +* dcim.Location + * Added required `status` field (default value: `active`) * dcim.Rack * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit +* extras.ConfigContext + * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations * extras.CustomField * Added `group_name` and `ui_visibility` fields * ipam.IPAddress * The `nat_inside` field no longer requires a unique value * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses +* users.Token + * Added the `allowed_ips` array field + * Added the read-only `last_used` datetime field * virtualization.Cluster * Added required `status` field (default value: `active`) * virtualization.VirtualMachine * Added `device` field * The `site` field is now directly writable (rather than being inferred from the assigned cluster) * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned. +wireless.WirelessLAN + * Added `tenant` field +wireless.WirelessLink + * Added `tenant` field diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md index 11b8cd6bf..18b6bc4f8 100644 --- a/docs/rest-api/authentication.md +++ b/docs/rest-api/authentication.md @@ -29,6 +29,11 @@ $ curl https://netbox/api/dcim/sites/ } ``` +When a token is used to authenticate a request, its `last_updated` time updated to the current time if its last use was recorded more than 60 seconds ago (or was never recorded). This allows users to determine which tokens have been active recently. + +!!! note + The "last used" time for tokens will not be updated while maintenance mode is enabled. + ## Initial Token Provisioning Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index eac411261..508f468be 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -92,9 +92,9 @@ class CircuitSerializer(NetBoxModelSerializer): class Meta: model = Circuit fields = [ - 'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', - 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', + 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 4985d0529..b8d69cc44 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -183,7 +183,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte class Meta: model = Circuit - fields = ['id', 'cid', 'description', 'install_date', 'commit_rate'] + fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 6e283219a..b6ba42afb 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -7,7 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, + add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect, ) @@ -122,6 +122,14 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) + install_date = forms.DateField( + required=False, + widget=DatePicker() + ) + termination_date = forms.DateField( + required=False, + widget=DatePicker() + ) commit_rate = forms.IntegerField( required=False, label='Commit rate (Kbps)' @@ -137,7 +145,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): model = Circuit fieldsets = ( - (None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')), + ('Circuit', ('provider', 'type', 'status', 'description')), + ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')), + ('Tenancy', ('tenant',)), ) nullable_fields = ( 'tenant', 'commit_rate', 'description', 'comments', diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 6da79f75c..cc2d0409a 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -72,5 +72,6 @@ class CircuitCSVForm(NetBoxModelCSVForm): class Meta: model = Circuit fields = [ - 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', + 'description', 'comments', ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 46d3824bb..29410ffdf 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm -from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField +from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField __all__ = ( 'CircuitFilterForm', @@ -84,7 +84,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi fieldsets = ( (None, ('q', 'tag')), ('Provider', ('provider_id', 'provider_network_id')), - ('Attributes', ('type_id', 'status', 'commit_rate')), + ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -130,6 +130,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi }, label=_('Site') ) + install_date = forms.DateField( + required=False, + widget=DatePicker + ) + termination_date = forms.DateField( + required=False, + widget=DatePicker + ) commit_rate = forms.IntegerField( required=False, min_value=0, diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 8fd5fb92d..907c39586 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -93,15 +93,16 @@ class CircuitForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), + ('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')), + ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = Circuit fields = [ - 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', - 'comments', 'tags', + 'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description', + 'tenant_group', 'tenant', 'comments', 'tags', ] help_texts = { 'cid': "Unique circuit ID", @@ -110,6 +111,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm): widgets = { 'status': StaticSelect(), 'install_date': DatePicker(), + 'termination_date': DatePicker(), 'commit_rate': SelectSpeedWidget(), } diff --git a/netbox/circuits/migrations/0036_circuit_termination_date.py b/netbox/circuits/migrations/0036_circuit_termination_date.py new file mode 100644 index 000000000..0a8adfbe6 --- /dev/null +++ b/netbox/circuits/migrations/0036_circuit_termination_date.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.5 on 2022-06-22 18:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0035_provider_asns'), + ] + + operations = [ + migrations.AddField( + model_name='circuit', + name='termination_date', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/netbox/circuits/migrations/0036_new_cabling_models.py b/netbox/circuits/migrations/0037_new_cabling_models.py similarity index 85% rename from netbox/circuits/migrations/0036_new_cabling_models.py rename to netbox/circuits/migrations/0037_new_cabling_models.py index 7e5a89093..ddf4744f1 100644 --- a/netbox/circuits/migrations/0036_new_cabling_models.py +++ b/netbox/circuits/migrations/0037_new_cabling_models.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('circuits', '0035_provider_asns'), + ('circuits', '0036_circuit_termination_date'), ] operations = [ diff --git a/netbox/circuits/migrations/0037_cabling_cleanup.py b/netbox/circuits/migrations/0038_cabling_cleanup.py similarity index 80% rename from netbox/circuits/migrations/0037_cabling_cleanup.py rename to netbox/circuits/migrations/0038_cabling_cleanup.py index 243748e8b..0672057e3 100644 --- a/netbox/circuits/migrations/0037_cabling_cleanup.py +++ b/netbox/circuits/migrations/0038_cabling_cleanup.py @@ -4,8 +4,8 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('circuits', '0036_new_cabling_models'), - ('dcim', '0158_populate_cable_ends'), + ('circuits', '0037_new_cabling_models'), + ('dcim', '0160_populate_cable_ends'), ] operations = [ diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 02ba5209d..5df6f1b85 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -78,7 +78,12 @@ class Circuit(NetBoxModel): install_date = models.DateField( blank=True, null=True, - verbose_name='Date installed' + verbose_name='Installed' + ) + termination_date = models.DateField( + blank=True, + null=True, + verbose_name='Terminates' ) commit_rate = models.PositiveIntegerField( blank=True, @@ -119,7 +124,7 @@ class Circuit(NetBoxModel): ) clone_fields = [ - 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', + 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description', ] class Meta: diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 40f8918ae..8b59700ee 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -70,7 +70,7 @@ class CircuitTable(NetBoxTable): model = Circuit fields = ( 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', - 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 96a469951..abcfa8a00 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -208,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): ProviderNetwork.objects.bulk_create(provider_networks) circuits = ( - Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'), - Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'), - Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED), - Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED), - Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE), - Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE), + Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'), + Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'), + Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED), + Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED), + Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE), + Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE), ) Circuit.objects.bulk_create(circuits) @@ -235,6 +235,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'install_date': ['2020-01-01', '2020-01-02']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_termination_date(self): + params = {'termination_date': ['2021-01-01', '2021-01-02']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_commit_rate(self): params = {'commit_rate': ['1000', '2000']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 968aa2f21..fa6146b93 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -130,6 +130,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'status': CircuitStatusChoices.STATUS_DECOMMISSIONED, 'tenant': None, 'install_date': datetime.date(2020, 1, 1), + 'termination_date': datetime.date(2021, 1, 1), 'commit_rate': 1000, 'description': 'A new circuit', 'comments': 'Some comments', diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 104201182..e899062bd 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -196,6 +196,7 @@ class LocationSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') site = NestedSiteSerializer() parent = NestedLocationSerializer(required=False, allow_null=True) + status = ChoiceField(choices=LocationStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) @@ -203,8 +204,8 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'rack_count', 'device_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', ] @@ -297,7 +298,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') ) legend_width = serializers.IntegerField( - default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + ) + margin_width = serializers.IntegerField( + default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH ) exclude = serializers.IntegerField( required=False, @@ -854,6 +858,8 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) + poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) + poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -878,10 +884,10 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', - 'cable', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'connected_endpoints', - 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', + 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', + 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peers', 'link_peers_type', + 'wireless_lans', 'vrf', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', + 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index bcfa8128d..4a528b647 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -215,6 +215,14 @@ class RackViewSet(NetBoxModelViewSet): data = serializer.validated_data if data['render'] == 'svg': + # Determine attributes for highlighting devices (if any) + highlight_params = [] + for param in request.GET.getlist('highlight'): + try: + highlight_params.append(param.split(':', 1)) + except ValueError: + pass + # Render and return the elevation as an SVG drawing with the correct content type drawing = rack.get_elevation_svg( face=data['face'], @@ -223,7 +231,8 @@ class RackViewSet(NetBoxModelViewSet): unit_height=data['unit_height'], legend_width=data['legend_width'], include_images=data['include_images'], - base_url=request.build_absolute_uri('/') + base_url=request.build_absolute_uri('/'), + highlight_params=highlight_params ) return HttpResponse(drawing.tostring(), content_type='image/svg+xml') diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 4b0dd6c48..dbbc60a93 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet): ] +# +# Locations +# + +class LocationStatusChoices(ChoiceSet): + key = 'Location.status' + + STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' + STATUS_RETIRED = 'retired' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_RETIRED, 'Retired', 'red'), + ] + + # # Racks # @@ -1003,6 +1025,51 @@ class InterfaceModeChoices(ChoiceSet): ) +class InterfacePoEModeChoices(ChoiceSet): + + MODE_PD = 'pd' + MODE_PSE = 'pse' + + CHOICES = ( + (MODE_PD, 'Powered device (PD)'), + (MODE_PSE, 'Power sourcing equipment (PSE)'), + ) + + +class InterfacePoETypeChoices(ChoiceSet): + + TYPE_1_8023AF = 'type1-ieee802.3af' + TYPE_2_8023AT = 'type2-ieee802.3at' + TYPE_3_8023BT = 'type3-ieee802.3bt' + TYPE_4_8023BT = 'type4-ieee802.3bt' + + PASSIVE_24V_2PAIR = 'passive-24v-2pair' + PASSIVE_24V_4PAIR = 'passive-24v-4pair' + PASSIVE_48V_2PAIR = 'passive-48v-2pair' + PASSIVE_48V_4PAIR = 'passive-48v-4pair' + + CHOICES = ( + ( + 'IEEE Standard', + ( + (TYPE_1_8023AF, '802.3af (Type 1)'), + (TYPE_2_8023AT, '802.3at (Type 2)'), + (TYPE_3_8023BT, '802.3bt (Type 3)'), + (TYPE_4_8023BT, '802.3bt (Type 4)'), + ) + ), + ( + 'Passive', + ( + (PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'), + (PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'), + (PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'), + (PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'), + ) + ), + ) + + # # FrontPorts/RearPorts # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 38bf16f0b..68bbd1dbe 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff, RACK_U_HEIGHT_DEFAULT = 42 RACK_ELEVATION_BORDER_WIDTH = 2 -RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 +RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 +RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 00a51937a..f13c48423 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -217,10 +217,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM to_field_name='slug', label='Location (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=LocationStatusChoices, + null_value=None + ) class Meta: model = Location - fields = ['id', 'name', 'slug', 'description'] + fields = ['id', 'name', 'slug', 'status', 'description'] def search(self, queryset, name, value): if not value.strip(): @@ -1239,6 +1243,12 @@ class InterfaceFilterSet( ) mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() + poe_mode = django_filters.MultipleChoiceFilter( + choices=InterfacePoEModeChoices + ) + poe_type = django_filters.MultipleChoiceFilter( + choices=InterfacePoETypeChoices + ) vlan_id = django_filters.CharFilter( method='filter_vlan_id', label='Assigned VLAN' @@ -1272,8 +1282,8 @@ class InterfaceFilterSet( class Meta: model = Interface fields = [ - 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end', + 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end', ] def filter_device(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 314a7a75f..43b852928 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -72,12 +72,15 @@ class PowerOutletBulkCreateForm( class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']), + form_from_model(Interface, [ + 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', + ]), DeviceBulkAddComponentForm ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', + 'poe_type', 'mark_connected', 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 231d01ddd..b4ab226ae 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): 'site_id': '$site' } ) + status = forms.ChoiceField( + choices=add_blank_choice(LocationStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): model = Location fieldsets = ( - (None, ('site', 'parent', 'tenant', 'description')), + (None, ('site', 'parent', 'status', 'tenant', 'description')), ) nullable_fields = ('parent', 'tenant', 'description') @@ -1063,6 +1069,18 @@ class InterfaceBulkEditForm( widget=BulkEditNullBooleanSelect, label='Management only' ) + poe_mode = forms.ChoiceField( + choices=add_blank_choice(InterfacePoEModeChoices), + required=False, + initial='', + widget=StaticSelect() + ) + poe_type = forms.ChoiceField( + choices=add_blank_choice(InterfacePoETypeChoices), + required=False, + initial='', + widget=StaticSelect() + ) mark_connected = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -1105,14 +1123,15 @@ class InterfaceBulkEditForm( (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('PoE', ('poe_mode', 'poe_type')), ('Related Interfaces', ('parent', 'bridge', 'lag')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ) nullable_fields = ( 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', - 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan', - 'tagged_vlans', 'vrf', + 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index b28c16fad..d6ec0f6f4 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm): 'invalid_choice': 'Location not found.', } ) + status = CSVChoiceField( + choices=LocationStatusChoices, + help_text='Operational status' + ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description') class RackRoleCSVForm(NetBoxModelCSVForm): @@ -622,6 +626,16 @@ class InterfaceCSVForm(NetBoxModelCSVForm): choices=InterfaceDuplexChoices, required=False ) + poe_mode = CSVChoiceField( + choices=InterfacePoEModeChoices, + required=False, + help_text='PoE mode' + ) + poe_type = CSVChoiceField( + choices=InterfacePoETypeChoices, + required=False, + help_text='PoE type' + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, @@ -642,9 +656,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', - 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', + 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', + 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 1535e5718..d9bc79fb5 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -166,7 +166,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF model = Location fieldsets = ( (None, ('q', 'tag')), - ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), + ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF }, label=_('Parent') ) + status = MultipleChoiceField( + choices=LocationStatusChoices, + required=False + ) tag = TagFilterField(model) @@ -969,6 +973,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Addressing', ('vrf_id', 'mac_address', 'wwn')), + ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) @@ -1009,6 +1014,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='WWN' ) + poe_mode = MultipleChoiceField( + choices=InterfacePoEModeChoices, + required=False + ) + poe_type = MultipleChoiceField( + choices=InterfacePoEModeChoices, + required=False + ) rf_role = MultipleChoiceField( choices=WirelessRoleChoices, required=False, diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index fe461b061..7aa2a8584 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm): class Meta: model = Location fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', + 'tags', ) + widgets = { + 'status': StaticSelect(), + } class RackRoleForm(NetBoxModelForm): @@ -1314,6 +1318,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('PoE', ('poe_mode', 'poe_type')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', @@ -1324,14 +1329,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): model = Interface fields = [ 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', - 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', - 'vrf', 'tags', + 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', + 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': SelectSpeedWidget(), + 'poe_mode': StaticSelect(), + 'poe_type': StaticSelect(), 'duplex': StaticSelect(), 'mode': StaticSelect(), 'rf_role': StaticSelect(), diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 824e41fc9..31589dae3 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -234,6 +234,12 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType): exclude = ('_path',) filterset_class = filtersets.InterfaceFilterSet + def resolve_poe_mode(self, info): + return self.poe_mode or None + + def resolve_poe_type(self, info): + return self.poe_type or None + def resolve_mode(self, info): return self.mode or None diff --git a/netbox/dcim/migrations/0155_interface_poe_mode_type.py b/netbox/dcim/migrations/0155_interface_poe_mode_type.py new file mode 100644 index 000000000..0615d5d7e --- /dev/null +++ b/netbox/dcim/migrations/0155_interface_poe_mode_type.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.5 on 2022-06-22 00:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0154_half_height_rack_units'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='poe_mode', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interface', + name='poe_type', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/dcim/migrations/0156_location_status.py b/netbox/dcim/migrations/0156_location_status.py new file mode 100644 index 000000000..b20273755 --- /dev/null +++ b/netbox/dcim/migrations/0156_location_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.5 on 2022-06-22 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0155_interface_poe_mode_type'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/dcim/migrations/0155_new_cabling_models.py b/netbox/dcim/migrations/0157_new_cabling_models.py similarity index 98% rename from netbox/dcim/migrations/0155_new_cabling_models.py rename to netbox/dcim/migrations/0157_new_cabling_models.py index 4b4c6bf3c..def63d8bd 100644 --- a/netbox/dcim/migrations/0155_new_cabling_models.py +++ b/netbox/dcim/migrations/0157_new_cabling_models.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('dcim', '0154_half_height_rack_units'), + ('dcim', '0156_location_status'), ] operations = [ diff --git a/netbox/dcim/migrations/0156_populate_cable_terminations.py b/netbox/dcim/migrations/0158_populate_cable_terminations.py similarity index 97% rename from netbox/dcim/migrations/0156_populate_cable_terminations.py rename to netbox/dcim/migrations/0158_populate_cable_terminations.py index 94126dc67..c35c39a40 100644 --- a/netbox/dcim/migrations/0156_populate_cable_terminations.py +++ b/netbox/dcim/migrations/0158_populate_cable_terminations.py @@ -40,7 +40,7 @@ def populate_cable_terminations(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('dcim', '0155_new_cabling_models'), + ('dcim', '0157_new_cabling_models'), ] operations = [ diff --git a/netbox/dcim/migrations/0157_populate_cable_paths.py b/netbox/dcim/migrations/0159_populate_cable_paths.py similarity index 96% rename from netbox/dcim/migrations/0157_populate_cable_paths.py rename to netbox/dcim/migrations/0159_populate_cable_paths.py index a75358635..22fe4b67e 100644 --- a/netbox/dcim/migrations/0157_populate_cable_paths.py +++ b/netbox/dcim/migrations/0159_populate_cable_paths.py @@ -39,7 +39,7 @@ def populate_cable_paths(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('dcim', '0156_populate_cable_terminations'), + ('dcim', '0158_populate_cable_terminations'), ] operations = [ diff --git a/netbox/dcim/migrations/0158_populate_cable_ends.py b/netbox/dcim/migrations/0160_populate_cable_ends.py similarity index 93% rename from netbox/dcim/migrations/0158_populate_cable_ends.py rename to netbox/dcim/migrations/0160_populate_cable_ends.py index a2fab646c..0dac81df3 100644 --- a/netbox/dcim/migrations/0158_populate_cable_ends.py +++ b/netbox/dcim/migrations/0160_populate_cable_ends.py @@ -30,8 +30,8 @@ def populate_cable_terminations(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('circuits', '0036_new_cabling_models'), - ('dcim', '0157_populate_cable_paths'), + ('circuits', '0037_new_cabling_models'), + ('dcim', '0159_populate_cable_paths'), ] operations = [ diff --git a/netbox/dcim/migrations/0159_cabling_cleanup.py b/netbox/dcim/migrations/0161_cabling_cleanup.py similarity index 98% rename from netbox/dcim/migrations/0159_cabling_cleanup.py rename to netbox/dcim/migrations/0161_cabling_cleanup.py index 61c9221e0..db2a9b73e 100644 --- a/netbox/dcim/migrations/0159_cabling_cleanup.py +++ b/netbox/dcim/migrations/0161_cabling_cleanup.py @@ -4,7 +4,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('dcim', '0158_populate_cable_ends'), + ('dcim', '0160_populate_cable_ends'), ] operations = [ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 895614f32..d329f547c 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -575,6 +575,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo validators=(MaxValueValidator(127),), verbose_name='Transmit power (dBm)' ) + poe_mode = models.CharField( + max_length=50, + choices=InterfacePoEModeChoices, + blank=True, + verbose_name='PoE mode' + ) + poe_type = models.CharField( + max_length=50, + choices=InterfacePoETypeChoices, + blank=True, + verbose_name='PoE type' + ) wireless_link = models.ForeignKey( to='wireless.WirelessLink', on_delete=models.SET_NULL, @@ -623,7 +635,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo related_query_name='+' ) - clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] + clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type'] class Meta: ordering = ('device', CollateAsChar('_name')) @@ -711,6 +723,24 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo f"of virtual chassis {self.device.virtual_chassis}." }) + # PoE validation + + # Only physical interfaces may have a PoE mode/type assigned + if self.poe_mode and self.is_virtual: + raise ValidationError({ + 'poe_mode': "Virtual interfaces cannot have a PoE mode." + }) + if self.poe_type and self.is_virtual: + raise ValidationError({ + 'poe_type': "Virtual interfaces cannot have a PoE type." + }) + + # An interface with a PoE type set must also specify a mode + if self.poe_type and not self.poe_mode: + raise ValidationError({ + 'poe_type': "Must specify PoE mode when designating a PoE type." + }) + # Wireless validation # RF role & channel may only be set for wireless interfaces diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 12cc4dd38..31fbb71de 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -367,9 +367,11 @@ class Rack(NetBoxModel): user=None, unit_width=None, unit_height=None, - legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, + legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH, + margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH, include_images=True, - base_url=None + base_url=None, + highlight_params=None ): """ Return an SVG of the rack elevation @@ -381,6 +383,7 @@ class Rack(NetBoxModel): :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total height of the elevation :param legend_width: Width of the unit legend, in pixels + :param margin_width: Width of the rigth-hand margin, in pixels :param include_images: Embed front/rear device images where available :param base_url: Base URL for links and images. If none, URLs will be relative. """ @@ -389,9 +392,11 @@ class Rack(NetBoxModel): unit_width=unit_width, unit_height=unit_height, legend_width=legend_width, + margin_width=margin_width, user=user, include_images=include_images, - base_url=base_url + base_url=base_url, + highlight_params=highlight_params ) return elevation.render(face) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index d02bd0932..9b7ffdcf4 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -341,6 +341,11 @@ class Location(NestedGroupModel): null=True, db_index=True ) + status = models.CharField( + max_length=50, + choices=LocationStatusChoices, + default=LocationStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -367,7 +372,7 @@ class Location(NestedGroupModel): to='extras.ImageAttachment' ) - clone_fields = ['site', 'parent', 'tenant', 'description'] + clone_fields = ['site', 'parent', 'status', 'tenant', 'description'] class Meta: ordering = ['site', 'name'] @@ -409,6 +414,9 @@ class Location(NestedGroupModel): def get_absolute_url(self): return reverse('dcim:location', args=[self.pk]) + def get_status_color(self): + return LocationStatusChoices.colors.get(self.status) + def clean(self): super().clean() diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 4d518adf1..920bd662f 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -7,12 +7,13 @@ from svgwrite.shapes import Rect from svgwrite.text import Text from django.conf import settings +from django.core.exceptions import FieldError +from django.db.models import Q from django.urls import reverse from django.utils.http import urlencode from netbox.config import get_config -from utilities.utils import foreground_color -from dcim.choices import DeviceFaceChoices +from utilities.utils import foreground_color, array_to_ranges from dcim.constants import RACK_ELEVATION_BORDER_WIDTH @@ -51,12 +52,17 @@ class RackElevationSVG: Use this class to render a rack elevation as an SVG image. :param rack: A NetBox Rack instance + :param unit_width: Rendered unit width, in pixels + :param unit_height: Rendered unit height, in pixels + :param legend_width: Legend width, in pixels (where the unit labels appear) + :param margin_width: Margin width, in pixels (where reservations appear) :param user: User instance. If specified, only devices viewable by this user will be fully displayed. :param include_images: If true, the SVG document will embed front/rear device face images, where available :param base_url: Base URL for links within the SVG document. If none, links will be relative. + :param highlight_params: Iterable of two-tuples which identifies attributes of devices to highlight """ - def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True, - base_url=None): + def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None, + include_images=True, base_url=None, highlight_params=None): self.rack = rack self.include_images = include_images self.base_url = base_url.rstrip('/') if base_url is not None else '' @@ -65,7 +71,8 @@ class RackElevationSVG: config = get_config() self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT - self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH # Determine the subset of devices within this rack that are viewable by the user, if any permitted_devices = self.rack.devices @@ -73,6 +80,17 @@ class RackElevationSVG: permitted_devices = permitted_devices.restrict(user, 'view') self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) + # Determine device(s) to highlight within the elevation (if any) + self.highlight_devices = [] + if highlight_params: + q = Q() + for k, v in highlight_params: + q |= Q(**{k: v}) + try: + self.highlight_devices = permitted_devices.filter(q) + except FieldError: + pass + @staticmethod def _add_gradient(drawing, id_, color): gradient = LinearGradient( @@ -91,7 +109,7 @@ class RackElevationSVG: drawing.defs.add(gradient) def _setup_drawing(self): - width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2 + width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2 height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 drawing = svgwrite.Drawing(size=(width, height)) @@ -100,6 +118,7 @@ class RackElevationSVG: drawing.defs.add(drawing.style(css_file.read())) # Add gradients + RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff') RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') @@ -121,40 +140,44 @@ class RackElevationSVG: def _draw_device(self, device, coords, size, color=None, image=None): name = get_device_name(device) description = get_device_description(device) + text_color = f'#{foreground_color(color)}' if color else '#000000' text_coords = ( coords[0] + size[0] / 2, coords[1] + size[1] / 2 ) - text_color = f'#{foreground_color(color)}' if color else '#000000' + + # Determine whether highlighting is in use, and if so, whether to shade this device + is_shaded = self.highlight_devices and device not in self.highlight_devices + css_extra = ' shaded' if is_shaded else '' # Create hyperlink element - link = Hyperlink( - href='{}{}'.format( - self.base_url, - reverse('dcim:device', kwargs={'pk': device.pk}) - ), - target='_blank', - ) + link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_blank') link.set_desc(description) + + # Add rect element to hyperlink if color: - link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot')) + link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}')) else: - link.add(Rect(coords, size, class_='slot blocked')) - link.add(Text(name, insert=text_coords, fill=text_color)) + link.add(Rect(coords, size, class_=f'slot blocked{css_extra}')) + link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}')) # Embed device type image if provided if self.include_images and image: image = Image( - href='{}{}'.format(self.base_url, image.url), + href=f'{self.base_url}{image.url}', insert=coords, size=size, - class_='device-image' + class_=f'device-image{css_extra}' ) image.fit(scale='slice') link.add(image) - link.add(Text(name, insert=text_coords, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label')) + link.add( + Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round', + class_=f'device-image-label{css_extra}') + ) + link.add( + Text(name, insert=text_coords, fill='white', class_=f'device-image-label{css_extra}') + ) self.drawing.add(link) @@ -198,6 +221,29 @@ class RackElevationSVG: Text(str(unit), position_coordinates, class_='unit') ) + def draw_margin(self): + """ + Draw any rack reservations in the right-hand margin alongside the rack elevation. + """ + for reservation in self.rack.reservations.all(): + for segment in array_to_ranges(reservation.units): + u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0] + coords = self._get_device_coords(segment[0], u_height) + coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1]) + size = ( + self.margin_width, + u_height * self.unit_height + ) + link = Hyperlink( + href='{}{}'.format(self.base_url, reservation.get_absolute_url()), + target='_blank' + ) + link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}') + link.add( + Rect(coords, size, class_='reservation') + ) + self.drawing.add(link) + def draw_background(self, face): """ Draw the rack unit placeholders which form the "background" of the rack elevation. @@ -261,16 +307,12 @@ class RackElevationSVG: # Initialize the drawing self.drawing = self._setup_drawing() - # Draw the empty rack & legend + # Draw the empty rack, legend, and margin self.draw_legend() self.draw_background(face) + self.draw_margin() - # Draw the opposite rack face first, then the near face - if face == DeviceFaceChoices.FACE_REAR: - opposite_face = DeviceFaceChoices.FACE_FRONT - else: - opposite_face = DeviceFaceChoices.FACE_REAR - # self.draw_face(opposite_face, opposite=True) + # Draw the rack face self.draw_face(face) # Draw the rack border last diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 0f015b7f3..b3dd700cb 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -520,10 +520,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi model = Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', - 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', - 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', - 'tagged_vlans', 'created', 'last_updated', + 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', + 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', + 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index fa3c73e12..83db99aec 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -126,6 +126,7 @@ class LocationTable(NetBoxTable): site = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() tenant = TenantColumn() rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', @@ -150,7 +151,7 @@ class LocationTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts', - 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description', 'slug', + 'contacts', 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') + default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 896634d2f..1c6f45596 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -197,13 +197,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase): Site.objects.bulk_create(sites) parent_locations = ( - Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'), - Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'), + Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE), + Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE), ) - Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0]) - Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0]) - Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0]) + Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) + Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) + Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) cls.create_data = [ { @@ -211,18 +211,21 @@ class LocationTest(APIViewTestCases.APIViewTestCase): 'slug': 'test-location-4', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, { 'name': 'Test Location 5', 'slug': 'test-location-5', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, { 'name': 'Test Location 6', 'slug': 'test-location-6', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, ] @@ -1507,6 +1510,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'speed': 1000000, 'duplex': 'full', 'vrf': vrfs[0].pk, + 'poe_mode': InterfacePoEModeChoices.MODE_PD, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 174e02e67..027661556 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): location.save() locations = ( - Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'), - Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'), - Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'), + Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'), + Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'), + Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'), ) for location in locations: location.save() @@ -280,6 +280,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['location-1', 'location-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['A', 'B']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2540,14 +2544,109 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) interfaces = ( - Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'), - Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'), - Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'), - Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'), - Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), - Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40), - Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22), - Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20), + Interface( + device=devices[0], + module=modules[0], + name='Interface 1', + label='A', + type=InterfaceTypeChoices.TYPE_1GE_SFP, + enabled=True, + mgmt_only=True, + mtu=100, + mode=InterfaceModeChoices.MODE_ACCESS, + mac_address='00-00-00-00-00-01', + description='First', + vrf=vrfs[0], + speed=1000000, + duplex='half', + poe_mode=InterfacePoEModeChoices.MODE_PSE, + poe_type=InterfacePoETypeChoices.TYPE_1_8023AF + ), + Interface( + device=devices[1], + module=modules[1], + name='Interface 2', + label='B', + type=InterfaceTypeChoices.TYPE_1GE_GBIC, + enabled=True, + mgmt_only=True, + mtu=200, + mode=InterfaceModeChoices.MODE_TAGGED, + mac_address='00-00-00-00-00-02', + description='Second', + vrf=vrfs[1], + speed=1000000, + duplex='full', + poe_mode=InterfacePoEModeChoices.MODE_PD, + poe_type=InterfacePoETypeChoices.TYPE_1_8023AF + ), + Interface( + device=devices[2], + module=modules[2], + name='Interface 3', + label='C', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + enabled=False, + mgmt_only=False, + mtu=300, + mode=InterfaceModeChoices.MODE_TAGGED_ALL, + mac_address='00-00-00-00-00-03', + description='Third', + vrf=vrfs[2], + speed=100000, + duplex='half', + poe_mode=InterfacePoEModeChoices.MODE_PSE, + poe_type=InterfacePoETypeChoices.TYPE_2_8023AT + ), + Interface( + device=devices[3], + name='Interface 4', + label='D', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=True, + mgmt_only=True, + tx_power=40, + speed=100000, + duplex='full', + poe_mode=InterfacePoEModeChoices.MODE_PD, + poe_type=InterfacePoETypeChoices.TYPE_2_8023AT + ), + Interface( + device=devices[3], + name='Interface 5', + label='E', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=True, + mgmt_only=True, + tx_power=40 + ), + Interface( + device=devices[3], + name='Interface 6', + label='F', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=False, + mgmt_only=False, + tx_power=40 + ), + Interface( + device=devices[3], + name='Interface 7', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_role=WirelessRoleChoices.ROLE_AP, + rf_channel=WirelessChannelChoices.CHANNEL_24G_1, + rf_channel_frequency=2412, + rf_channel_width=22 + ), + Interface( + device=devices[3], + name='Interface 8', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_role=WirelessRoleChoices.ROLE_STATION, + rf_channel=WirelessChannelChoices.CHANNEL_5G_32, + rf_channel_frequency=5160, + rf_channel_width=20 + ), ) Interface.objects.bulk_create(interfaces) @@ -2594,6 +2693,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mgmt_only': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_poe_mode(self): + params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_poe_type(self): + params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_mode(self): params = {'mode': InterfaceModeChoices.MODE_ACCESS} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index ac9bcbdc3..c6a531a31 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -175,9 +175,9 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1') locations = ( - Location(name='Location 1', slug='location-1', site=site, tenant=tenant), - Location(name='Location 2', slug='location-2', site=site, tenant=tenant), - Location(name='Location 3', slug='location-3', site=site, tenant=tenant), + Location(name='Location 1', slug='location-1', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), + Location(name='Location 2', slug='location-2', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), + Location(name='Location 3', slug='location-3', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), ) for location in locations: location.save() @@ -188,16 +188,17 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Location X', 'slug': 'location-x', 'site': site.pk, + 'status': LocationStatusChoices.STATUS_PLANNED, 'tenant': tenant.pk, 'description': 'A new location', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "site,tenant,name,slug,description", - "Site 1,Tenant 1,Location 4,location-4,Fourth location", - "Site 1,Tenant 1,Location 5,location-5,Fifth location", - "Site 1,Tenant 1,Location 6,location-6,Sixth location", + "site,tenant,name,slug,status,description", + "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location", + "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location", + "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", ) cls.bulk_edit_data = { @@ -2204,6 +2205,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, + 'poe_mode': InterfacePoEModeChoices.MODE_PSE, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], @@ -2225,6 +2228,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'duplex': 'half', 'mgmt_only': True, 'description': 'A front port', + 'poe_mode': InterfacePoEModeChoices.MODE_PSE, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], @@ -2244,6 +2249,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'duplex': 'full', 'mgmt_only': True, 'description': 'New description', + 'poe_mode': InterfacePoEModeChoices.MODE_PD, + 'poe_type': InterfacePoETypeChoices.TYPE_2_8023AT, 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, 'untagged_vlan': vlans[0].pk, @@ -2252,10 +2259,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.csv_data = ( - f"device,name,type,vrf.pk", - f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}", - f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}", - f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}", + f"device,name,type,vrf.pk,poe_mode,poe_type", + f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", + f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", + f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", ) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ca4ea60b8..f6262c789 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -639,6 +639,11 @@ class RackView(generic.ObjectView): device_count = Device.objects.restrict(request.user, 'view').filter(rack=instance).count() + # Determine any additional parameters to pass when embedding the rack elevations + svg_extra = '&'.join([ + f'highlight=id:{pk}' for pk in request.GET.getlist('device') + ]) + return { 'device_count': device_count, 'reservations': reservations, @@ -646,6 +651,7 @@ class RackView(generic.ObjectView): 'nonracked_devices': nonracked_devices, 'next_rack': next_rack, 'prev_rack': prev_rack, + 'svg_extra': svg_extra, } diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index cb317d6c7..2060e3e86 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -5,10 +5,10 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from dcim.api.nested_serializers import ( - NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer, - NestedSiteSerializer, NestedSiteGroupSerializer, + NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, + NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -272,6 +272,12 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + locations = SerializedPKRelatedField( + queryset=Location.objects.all(), + serializer=NestedLocationSerializer, + required=False, + many=True + ) device_types = SerializedPKRelatedField( queryset=DeviceType.objects.all(), serializer=NestedDeviceTypeSerializer, @@ -331,8 +337,8 @@ class ConfigContextSerializer(ValidatedModelSerializer): model = ConfigContext fields = [ 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', - 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', - 'tenants', 'tags', 'data', 'created', 'last_updated', + 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', + 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated', ] diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 688f3c7ab..82c68c86d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -138,7 +138,7 @@ class JournalEntryViewSet(NetBoxModelViewSet): class ConfigContextViewSet(NetBoxModelViewSet): queryset = ConfigContext.objects.prefetch_related( - 'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', + 'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', ) serializer_class = serializers.ConfigContextSerializer filterset_class = filtersets.ConfigContextFilterSet diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index b59e28018..cca197c73 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter @@ -255,6 +255,17 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): to_field_name='slug', label='Site (slug)', ) + location_id = django_filters.ModelMultipleChoiceFilter( + field_name='locations', + queryset=Location.objects.all(), + label='Location', + ) + location = django_filters.ModelMultipleChoiceFilter( + field_name='locations__slug', + queryset=Location.objects.all(), + to_field_name='slug', + label='Location (slug)', + ) device_type_id = django_filters.ModelMultipleChoiceFilter( field_name='device_types', queryset=DeviceType.objects.all(), diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index aaeb45dbe..56f48f96b 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -170,7 +170,7 @@ class TagFilterForm(FilterForm): class ConfigContextFilterForm(FilterForm): fieldsets = ( (None, ('q', 'tag_id')), - ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Device', ('device_type_id', 'platform_id', 'role_id')), ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), ('Tenant', ('tenant_group_id', 'tenant_id')) @@ -190,6 +190,11 @@ class ConfigContextFilterForm(FilterForm): required=False, label=_('Sites') ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Locations') + ) device_type_id = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False, diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index ab423e2fb..1ef723e93 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -1,7 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -166,6 +166,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Site.objects.all(), required=False ) + locations = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False + ) device_types = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False @@ -202,15 +206,22 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Tag.objects.all(), required=False ) - data = JSONField( - label='' + data = JSONField() + + fieldsets = ( + ('Config Context', ('name', 'weight', 'description', 'data', 'is_active')), + ('Assignment', ( + 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', + 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', + )), ) class Meta: model = ConfigContext fields = ( - 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types', - 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + 'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations', + 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', + 'tenants', 'tags', ) diff --git a/netbox/extras/migrations/0076_configcontext_locations.py b/netbox/extras/migrations/0076_configcontext_locations.py new file mode 100644 index 000000000..f9b3a664b --- /dev/null +++ b/netbox/extras/migrations/0076_configcontext_locations.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.5 on 2022-06-22 19:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0156_location_status'), + ('extras', '0075_customfield_ui_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='locations', + field=models.ManyToManyField(blank=True, related_name='+', to='dcim.location'), + ), + ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 0dc5d57db..30fb07069 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.core.validators import ValidationError from django.db import models from django.urls import reverse @@ -55,6 +53,11 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel): related_name='+', blank=True ) + locations = models.ManyToManyField( + to='dcim.Location', + related_name='+', + blank=True + ) device_types = models.ManyToManyField( to='dcim.DeviceType', related_name='+', @@ -138,11 +141,10 @@ class ConfigContextModel(models.Model): def get_config_context(self): """ + Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs. Return the rendered configuration context for a device or VM. """ - - # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs - data = OrderedDict() + data = {} if not hasattr(self, 'config_context_data'): # The annotation is not available, so we fall back to manually querying for the config context objects diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 21727d3d4..2b97af0fb 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -19,8 +19,9 @@ class ConfigContextQuerySet(RestrictedQuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role - # Device type assignment is relevant only for Devices + # Device type and location assignment is relevant only for Devices device_type = getattr(obj, 'device_type', None) + location = getattr(obj, 'location', None) # Get assigned cluster, group, and type (if any) cluster = getattr(obj, 'cluster', None) @@ -42,6 +43,7 @@ class ConfigContextQuerySet(RestrictedQuerySet): Q(regions__in=regions) | Q(regions=None), Q(site_groups__in=sitegroups) | Q(site_groups=None), Q(sites=obj.site) | Q(sites=None), + Q(locations=location) | Q(locations=None), Q(device_types=device_type) | Q(device_types=None), Q(roles=role) | Q(roles=None), Q(platforms=obj.platform) | Q(platforms=None), @@ -114,6 +116,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) if self.model._meta.model_name == 'device': + base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND) base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND) base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND) base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 540034696..2fa13f98a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -167,8 +167,9 @@ class ConfigContextTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ConfigContext fields = ( - 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms', - 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated', + 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles', + 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'weight', 'is_active', 'description') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index bdb8de9db..a88ed9418 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import Provider -from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Manufacturer, Platform, Rack, Region, Site, SiteGroup from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices from extras.filtersets import * from extras.models import * @@ -368,9 +368,9 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): regions = ( - Region(name='Test Region 1', slug='test-region-1'), - Region(name='Test Region 2', slug='test-region-2'), - Region(name='Test Region 3', slug='test-region-3'), + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), ) for r in regions: r.save() @@ -384,12 +384,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1'), - Site(name='Test Site 2', slug='test-site-2'), - Site(name='Test Site 3', slug='test-site-3'), + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') device_types = ( DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), @@ -460,6 +468,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): c.regions.set([regions[i]]) c.site_groups.set([site_groups[i]]) c.sites.set([sites[i]]) + c.locations.set([locations[i]]) c.device_types.set([device_types[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) @@ -501,6 +510,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'location': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device_type(self): device_types = DeviceType.objects.all()[:2] params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 17138d42b..4929690e7 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -29,7 +29,8 @@ class ConfigContextTest(TestCase): self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') self.region = Region.objects.create(name="Region") self.sitegroup = SiteGroup.objects.create(name="Site Group") - self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup) + self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup) + self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site) self.platform = Platform.objects.create(name="Platform") self.tenantgroup = TenantGroup.objects.create(name="Tenant Group") self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup) @@ -40,7 +41,8 @@ class ConfigContextTest(TestCase): name='Device 1', device_type=self.devicetype, device_role=self.devicerole, - site=self.site + site=self.site, + location=self.location ) def test_higher_weight_wins(self): @@ -144,15 +146,6 @@ class ConfigContextTest(TestCase): self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context()) def test_annotation_same_as_get_for_object_device_relations(self): - - site_context = ConfigContext.objects.create( - name="site", - weight=100, - data={ - "site": 1 - } - ) - site_context.sites.add(self.site) region_context = ConfigContext.objects.create( name="region", weight=100, @@ -169,6 +162,22 @@ class ConfigContextTest(TestCase): } ) sitegroup_context.site_groups.add(self.sitegroup) + site_context = ConfigContext.objects.create( + name="site", + weight=100, + data={ + "site": 1 + } + ) + site_context.sites.add(self.site) + location_context = ConfigContext.objects.create( + name="location", + weight=100, + data={ + "location": 1 + } + ) + location_context.locations.add(self.location) platform_context = ConfigContext.objects.create( name="platform", weight=100, @@ -205,6 +214,7 @@ class ConfigContextTest(TestCase): device = Device.objects.create( name="Device 2", site=self.site, + location=self.location, tenant=self.tenant, platform=self.platform, device_role=self.devicerole, @@ -220,13 +230,6 @@ class ConfigContextTest(TestCase): cluster_group = ClusterGroup.objects.create(name="Cluster Group") cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type) - site_context = ConfigContext.objects.create( - name="site", - weight=100, - data={"site": 1} - ) - site_context.sites.add(self.site) - region_context = ConfigContext.objects.create( name="region", weight=100, @@ -241,6 +244,13 @@ class ConfigContextTest(TestCase): ) sitegroup_context.site_groups.add(self.sitegroup) + site_context = ConfigContext.objects.create( + name="site", + weight=100, + data={"site": 1} + ) + site_context.sites.add(self.site) + platform_context = ConfigContext.objects.create( name="platform", weight=100, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9825d10de..bb99536c3 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -281,6 +281,7 @@ class ConfigContextView(generic.ObjectView): ('Regions', instance.regions.all), ('Site Groups', instance.site_groups.all), ('Sites', instance.sites.all), + ('Locations', instance.locations.all), ('Device Types', instance.device_types.all), ('Roles', instance.roles.all), ('Platforms', instance.platforms.all), @@ -311,7 +312,6 @@ class ConfigContextView(generic.ObjectView): class ConfigContextEditView(generic.ObjectEditView): queryset = ConfigContext.objects.all() form = forms.ConfigContextForm - template_name = 'extras/configcontext_edit.html' class ConfigContextBulkEditView(generic.BulkEditView): diff --git a/netbox/netbox/api/__init__.py b/netbox/netbox/api/__init__.py index 1eaa7d1c4..231ab55e6 100644 --- a/netbox/netbox/api/__init__.py +++ b/netbox/netbox/api/__init__.py @@ -1,4 +1,4 @@ -from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField +from .fields import * from .routers import NetBoxRouter from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer @@ -7,6 +7,7 @@ __all__ = ( 'BulkOperationSerializer', 'ChoiceField', 'ContentTypeField', + 'IPNetworkSerializer', 'NetBoxRouter', 'SerializedPKRelatedField', 'ValidatedModelSerializer', diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 5e177bfcb..b8607a0bb 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -1,16 +1,42 @@ +import logging + from django.conf import settings +from django.utils import timezone from rest_framework import authentication, exceptions from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS +from netbox.config import get_config from users.models import Token +from utilities.request import get_client_ip class TokenAuthentication(authentication.TokenAuthentication): """ - A custom authentication scheme which enforces Token expiration times. + A custom authentication scheme which enforces Token expiration times and source IP restrictions. """ model = Token + def authenticate(self, request): + result = super().authenticate(request) + + if result: + token = result[1] + + # Enforce source IP restrictions (if any) set on the token + if token.allowed_ips: + client_ip = get_client_ip(request) + if client_ip is None: + raise exceptions.AuthenticationFailed( + "Client IP address could not be determined for validation. Check that the HTTP server is " + "correctly configured to pass the required header(s)." + ) + if not token.validate_client_ip(client_ip): + raise exceptions.AuthenticationFailed( + f"Source IP {client_ip} is not permitted to authenticate using this token." + ) + + return result + def authenticate_credentials(self, key): model = self.get_model() try: @@ -18,6 +44,16 @@ class TokenAuthentication(authentication.TokenAuthentication): except model.DoesNotExist: raise exceptions.AuthenticationFailed("Invalid token") + # Update last used, but only once per minute at most. This reduces write load on the database + if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60: + # If maintenance mode is enabled, assume the database is read-only, and disable updating the token's + # last_used time upon authentication. + if get_config().MAINTENANCE_MODE: + logger = logging.getLogger('netbox.auth.login') + logger.debug("Maintenance mode enabled: Disabling update of token's last used timestamp") + else: + Token.objects.filter(pk=token.pk).update(last_used=timezone.now()) + # Enforce the Token's expiration time, if one has been set. if token.is_expired: raise exceptions.AuthenticationFailed("Token expired") diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index d73cbcac2..1f3c40dc2 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -1,12 +1,18 @@ from collections import OrderedDict -import pytz -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from netaddr import IPNetwork from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.relations import PrimaryKeyRelatedField, RelatedField +__all__ = ( + 'ChoiceField', + 'ContentTypeField', + 'IPNetworkSerializer', + 'SerializedPKRelatedField', +) + class ChoiceField(serializers.Field): """ @@ -104,6 +110,17 @@ class ContentTypeField(RelatedField): return f"{obj.app_label}.{obj.model}" +class IPNetworkSerializer(serializers.Serializer): + """ + Representation of an IP network value (e.g. 192.0.2.0/24). + """ + def to_representation(self, instance): + return str(instance) + + def to_internal_value(self, value): + return IPNetwork(value) + + class SerializedPKRelatedField(PrimaryKeyRelatedField): """ Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index b3bfe06c0..2fe3503b7 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -2,6 +2,7 @@ from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey +from extras.utils import is_taggable from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet from netbox.models.features import * @@ -52,6 +53,25 @@ class NetBoxModel(NetBoxFeatureSet, models.Model): 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 NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 7fc12b4fd..ef4554b4b 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,3 +1,5 @@ +import datetime + from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType @@ -8,10 +10,77 @@ from netaddr import IPNetwork from rest_framework.test import APIClient from dcim.models import Site -from ipam.choices import PrefixStatusChoices from ipam.models import Prefix from users.models import ObjectPermission, Token from utilities.testing import TestCase +from utilities.testing.api import APITestCase + + +class TokenAuthenticationTestCase(APITestCase): + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_authentication(self): + url = reverse('dcim-api:site-list') + + # Request without a token should return a 403 + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + # Valid token should return a 200 + token = Token.objects.create(user=self.user) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 200) + + # Check that the token's last_used time has been updated + token.refresh_from_db() + self.assertIsNotNone(token.last_used) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_expiration(self): + url = reverse('dcim-api:site-list') + + # Request without a non-expired token should succeed + token = Token.objects.create(user=self.user) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 200) + + # Request with an expired token should fail + token.expires = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + token.save() + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_write_enabled(self): + url = reverse('dcim-api:site-list') + data = { + 'name': 'Site 1', + 'slug': 'site-1', + } + + # Request with a write-disabled token should fail + token = Token.objects.create(user=self.user, write_enabled=False) + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + # Request with a write-enabled token should succeed + token.write_enabled = True + token.save() + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_allowed_ips(self): + url = reverse('dcim-api:site-list') + + # Request from a non-allowed client IP should fail + token = Token.objects.create(user=self.user, allowed_ips=['192.0.2.0/24']) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='127.0.0.1') + self.assertEqual(response.status_code, 403) + + # Request with an expired token should fail + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='192.0.2.1') + self.assertEqual(response.status_code, 200) class ExternalAuthenticationTestCase(TestCase): diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 4ebfe71cc..88abfa48f 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -394,11 +394,11 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): if '_addanother' in request.POST: redirect_url = request.path - # If the object has clone_fields, pre-populate a new instance of the form + # If cloning is supported, pre-populate a new instance of the form params = prepare_cloned_fields(obj) - if 'return_url' in request.GET: - params['return_url'] = request.GET.get('return_url') if params: + if 'return_url' in request.GET: + params['return_url'] = request.GET.get('return_url') redirect_url += f"?{params.urlencode()}" return redirect(redirect_url) diff --git a/netbox/project-static/dist/rack_elevation.css b/netbox/project-static/dist/rack_elevation.css index 4f9361489..229ea2f97 100644 Binary files a/netbox/project-static/dist/rack_elevation.css and b/netbox/project-static/dist/rack_elevation.css differ diff --git a/netbox/project-static/styles/rack-elevation.scss b/netbox/project-static/styles/rack-elevation.scss index bf8063110..8d6bdddb9 100644 --- a/netbox/project-static/styles/rack-elevation.scss +++ b/netbox/project-static/styles/rack-elevation.scss @@ -48,6 +48,13 @@ svg { visibility: hidden; } + rect.shaded, image.shaded { + opacity: 25%; + } + text.shaded { + opacity: 50%; + } + // Rack elevation container. .rack { fill: none; @@ -81,17 +88,6 @@ svg { opacity: 1; } - // When a reserved slot is hovered, use a more readable color for the 'Add Device' text. - &.reserved:hover[class] + .add-device { - fill: $black; - } - - // Reserved rack unit background color. - &.reserved[class], - &.reserved:hover[class] { - fill: url(#reserved); - } - // Occupied rack unit background color. &.occupied[class], &.occupied:hover[class] { @@ -108,4 +104,9 @@ svg { opacity: 0; } } + + // Reservation background color. + .reservation[class] { + fill: url(#reserved); + } } diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 881b6cca6..a4c41f871 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -45,6 +45,10 @@
Devices
+Cables
+Wireless Links
SSID | -{{ object.ssid }} | -
---|---|
Group | -{{ object.group|linkify|placeholder }} | -
Description | -{{ object.description|placeholder }} | -
VLAN | -{{ object.vlan|linkify|placeholder }} | -
SSID | +{{ object.ssid }} | +
---|---|
Group | +{{ object.group|linkify|placeholder }} | +
Description | +{{ object.description|placeholder }} | +
VLAN | +{{ object.vlan|linkify|placeholder }} | +
Tenant | ++ {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} + | +