From 25877202983182fd12a7b6a31dedca7edf5a589c Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 14:44:35 +0200 Subject: [PATCH 01/24] Fix 8878: Restrict API key usage by Source IP --- docs/release-notes/version-3.3.md | 1 + netbox/netbox/api/authentication.py | 26 ++++++++++++++++++ netbox/templates/users/api_tokens.html | 15 ++++++++--- netbox/users/admin/__init__.py | 6 ++++- netbox/users/admin/forms.py | 2 +- netbox/users/api/serializers.py | 2 +- netbox/users/forms.py | 10 ++++++- .../migrations/0003_token_allowed_ips.py | 20 ++++++++++++++ netbox/users/models.py | 27 +++++++++++++++++++ 9 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 netbox/users/migrations/0003_token_allowed_ips.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 1dd19a5c0..09dcfcf22 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,6 +6,7 @@ * [#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 +* [#8878](https://github.com/netbox-community/netbox/issues/8878) - Restrict API key usage by source IP ### REST API Changes diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 5e177bfcb..2f86a1da2 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.core.exceptions import ValidationError from rest_framework import authentication, exceptions from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS @@ -11,6 +12,31 @@ class TokenAuthentication(authentication.TokenAuthentication): """ model = Token + def authenticate(self, request): + authenticationresult = super().authenticate(request) + if authenticationresult: + token_user, token = authenticationresult + + # Verify source IP is allowed + if token.allowed_ips: + # Replace 'HTTP_X_REAL_IP' with the settings variable choosen in #8867 + if 'HTTP_X_REAL_IP' in request.META: + clientip = request.META['HTTP_X_REAL_IP'].split(",")[0].strip() + http_header = 'HTTP_X_REAL_IP' + elif 'REMOTE_ADDR' in request.META: + clientip = request.META['REMOTE_ADDR'] + http_header = 'REMOTE_ADDR' + else: + raise exceptions.AuthenticationFailed(f"A HTTP header containing the SourceIP (HTTP_X_REAL_IP, REMOTE_ADDR) is missing from the request.") + + try: + if not token.validate_client_ip(clientip): + raise exceptions.AuthenticationFailed(f"Source IP {clientip} is not allowed to use this token.") + except ValidationError as ValidationErrorInfo: + raise exceptions.ValidationError(f"The value in the HTTP Header {http_header} has a ValidationError: {ValidationErrorInfo.message}") + + return authenticationresult + def authenticate_credentials(self, key): model = self.get_model() try: diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 01ffec23a..360e65a67 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -22,11 +22,11 @@
-
+
Created
{{ token.created|annotated_date }}
-
+
Expires
{% if token.expires %} {{ token.expires|annotated_date }} @@ -34,7 +34,7 @@ Never {% endif %}
-
+
Create/Edit/Delete Operations
{% if token.write_enabled %} Enabled @@ -42,7 +42,14 @@ Disabled {% endif %}
-
+
+ Allowed Source IPs
+ {% if token.allowed_ips %} + {{ token.allowed_ips|join:', ' }} + {% else %} + Any + {% endif %} +
{% if token.description %}
{{ token.description }} {% endif %} diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 1b163ed06..ede26cd1b 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -58,9 +58,13 @@ class UserAdmin(UserAdmin_): class TokenAdmin(admin.ModelAdmin): form = forms.TokenAdminForm list_display = [ - 'key', 'user', 'created', 'expires', 'write_enabled', 'description' + 'key', 'user', 'created', 'expires', 'write_enabled', 'description', 'list_allowed_ips' ] + def list_allowed_ips(self, obj): + return obj.allowed_ips or 'Any' + list_allowed_ips.short_description = "Allowed IPs" + # # Permissions diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index 7d0212441..bc3d44862 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm): class Meta: fields = [ - 'user', 'key', 'write_enabled', 'expires', 'description' + 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips' ] model = Token diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index d490e8fe9..4b1f5bff3 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -62,7 +62,7 @@ class TokenSerializer(ValidatedModelSerializer): class Meta: model = Token - fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description') + fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', 'allowed_ips') def to_internal_value(self, data): if 'key' not in data: diff --git a/netbox/users/forms.py b/netbox/users/forms.py index d5e6218e5..9720f92b7 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,7 +1,9 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe +from ipam.formfields import IPNetworkFormField from netbox.preferences import PREFERENCES from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict @@ -100,10 +102,16 @@ class TokenForm(BootstrapMixin, forms.ModelForm): help_text="If no key is provided, one will be generated automatically." ) + allowed_ips = SimpleArrayField( + base_field=IPNetworkFormField(), + required=False, + help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + ) + class Meta: model = Token fields = [ - 'key', 'write_enabled', 'expires', 'description', + 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), diff --git a/netbox/users/migrations/0003_token_allowed_ips.py b/netbox/users/migrations/0003_token_allowed_ips.py new file mode 100644 index 000000000..f4eaa9f96 --- /dev/null +++ b/netbox/users/migrations/0003_token_allowed_ips.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2022-04-19 12:37 + +import django.contrib.postgres.fields +from django.db import migrations +import ipam.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_standardize_id_fields'), + ] + + operations = [ + migrations.AddField( + model_name='token', + name='allowed_ips', + field=django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 722ec5ba6..40ff78b98 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -4,17 +4,20 @@ import os from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from ipam.fields import IPNetworkField from netbox.config import get_config from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * +import ipaddress __all__ = ( 'ObjectPermission', @@ -216,6 +219,12 @@ class Token(models.Model): max_length=200, blank=True ) + allowed_ips = ArrayField( + base_field=IPNetworkField(), + blank=True, + null=True, + help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + ) class Meta: pass @@ -240,6 +249,24 @@ class Token(models.Model): return False return True + def validate_client_ip(self, raw_ip_address): + """ + Checks that an IP address falls within the allowed IPs. + """ + if not self.allowed_ips: + return True + + try: + ip_address = ipaddress.ip_address(raw_ip_address) + except ValueError as e: + raise ValidationError(str(e)) + + for ip_network in self.allowed_ips: + if ip_address in ipaddress.ip_network(ip_network): + return True + + return False + # # Permissions From 086e34f728c4fb873b7e63561bc901e9954a5ec3 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 21:33:29 +0200 Subject: [PATCH 02/24] Updated docs relnotes to refer to 8233 --- docs/release-notes/version-3.3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 09dcfcf22..415e61963 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,7 +6,7 @@ * [#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 -* [#8878](https://github.com/netbox-community/netbox/issues/8878) - Restrict API key usage by source IP +* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key usage by source IP ### REST API Changes From fa4807be8ccf93aed93c42dbcb5231e7657c8e54 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 21:55:39 +0200 Subject: [PATCH 03/24] Update releasenotes --- docs/release-notes/version-3.3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 415e61963..294f8f4d7 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,7 +6,7 @@ * [#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 -* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key usage by source IP +* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key access by source IP ### REST API Changes From c04b4bbbfaab83f566bdd287b4b7b3b58752da99 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Fri, 17 Jun 2022 14:45:56 +0200 Subject: [PATCH 04/24] Add last_used to Token model and update when used --- netbox/netbox/api/authentication.py | 5 +++++ netbox/users/admin/__init__.py | 2 +- .../users/migrations/0003_token_last_used.py | 18 ++++++++++++++++++ netbox/users/models.py | 4 ++++ 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 netbox/users/migrations/0003_token_last_used.py diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 5e177bfcb..f40141de4 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -1,4 +1,5 @@ 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 @@ -18,6 +19,10 @@ class TokenAuthentication(authentication.TokenAuthentication): except model.DoesNotExist: raise exceptions.AuthenticationFailed("Invalid token") + # Update last used. + token.last_used = timezone.now() + token.save() + # Enforce the Token's expiration time, if one has been set. if token.is_expired: raise exceptions.AuthenticationFailed("Token expired") diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 1b163ed06..320c28df2 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -58,7 +58,7 @@ class UserAdmin(UserAdmin_): class TokenAdmin(admin.ModelAdmin): form = forms.TokenAdminForm list_display = [ - 'key', 'user', 'created', 'expires', 'write_enabled', 'description' + 'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description' ] diff --git a/netbox/users/migrations/0003_token_last_used.py b/netbox/users/migrations/0003_token_last_used.py new file mode 100644 index 000000000..cc014e59c --- /dev/null +++ b/netbox/users/migrations/0003_token_last_used.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-06-16 15:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_standardize_id_fields'), + ] + + operations = [ + migrations.AddField( + model_name='token', + name='last_used', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 23068442e..a0055914b 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -203,6 +203,10 @@ class Token(models.Model): blank=True, null=True ) + last_used = models.DateTimeField( + blank=True, + null=True + ) key = models.CharField( max_length=40, unique=True, From 5d4575ed258e8ccfa55a14648e6b0eb688cfd8e4 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Fri, 17 Jun 2022 14:57:19 +0200 Subject: [PATCH 05/24] Only update every 60 seconds --- netbox/netbox/api/authentication.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index f40141de4..3f223cf98 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -19,9 +19,11 @@ class TokenAuthentication(authentication.TokenAuthentication): except model.DoesNotExist: raise exceptions.AuthenticationFailed("Invalid token") - # Update last used. - token.last_used = timezone.now() - token.save() + # Update last used, but only once a minute. This reduces the write load on the db + timediff = timezone.now() - token.last_used + if timediff.total_seconds() > 60: + token.last_used = timezone.now() + token.save() # Enforce the Token's expiration time, if one has been set. if token.is_expired: From 5d868168a51e2a5864352c8518798eebe12d98ba Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Fri, 17 Jun 2022 14:58:20 +0200 Subject: [PATCH 06/24] Rename timediff to lasted_used_diff --- netbox/netbox/api/authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 3f223cf98..fce9036aa 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -20,8 +20,8 @@ class TokenAuthentication(authentication.TokenAuthentication): raise exceptions.AuthenticationFailed("Invalid token") # Update last used, but only once a minute. This reduces the write load on the db - timediff = timezone.now() - token.last_used - if timediff.total_seconds() > 60: + last_used_diff = timezone.now() - token.last_used + if last_used_diff.total_seconds() > 60: token.last_used = timezone.now() token.save() From d32bbd06cf4ee86392724cdf4f3b66b05d2d96df Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Fri, 17 Jun 2022 15:52:12 +0200 Subject: [PATCH 07/24] Fix last_used=None error --- netbox/netbox/api/authentication.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index fce9036aa..39008f366 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -20,8 +20,7 @@ class TokenAuthentication(authentication.TokenAuthentication): raise exceptions.AuthenticationFailed("Invalid token") # Update last used, but only once a minute. This reduces the write load on the db - last_used_diff = timezone.now() - token.last_used - if last_used_diff.total_seconds() > 60: + if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60: token.last_used = timezone.now() token.save() From f8221340af52b776e6dd972897bc6d491cb0775a Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Sun, 19 Jun 2022 12:40:52 +0200 Subject: [PATCH 08/24] Disable token last_used update when in Maint mode --- netbox/netbox/api/authentication.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 39008f366..e98376899 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -1,8 +1,11 @@ +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 @@ -20,9 +23,15 @@ class TokenAuthentication(authentication.TokenAuthentication): raise exceptions.AuthenticationFailed("Invalid token") # Update last used, but only once a minute. This reduces the write load on the db - if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60: - token.last_used = timezone.now() - token.save() + if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 6: + # 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.warning("Maintenance mode enabled: disabling update of token's last used timestamp") + else: + token.last_used = timezone.now() + token.save() # Enforce the Token's expiration time, if one has been set. if token.is_expired: From ae342a0506c627e3b23196ce49940b60b94f54f5 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Sun, 19 Jun 2022 12:41:44 +0200 Subject: [PATCH 09/24] Correct delay time from 6 to 60 seconds --- netbox/netbox/api/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index e98376899..847bcbfd9 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -23,7 +23,7 @@ class TokenAuthentication(authentication.TokenAuthentication): raise exceptions.AuthenticationFailed("Invalid token") # Update last used, but only once a minute. This reduces the write load on the db - if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 6: + 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: From 81cea9b9d9b86b945052d58c83341e189a58abb3 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Sun, 19 Jun 2022 13:03:03 +0200 Subject: [PATCH 10/24] Show LastUsed in /user/api-tokens/ --- netbox/templates/users/api_tokens.html | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 01ffec23a..a019cbd1f 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -22,11 +22,11 @@
-
+
Created
{{ token.created|annotated_date }}
-
+
Expires
{% if token.expires %} {{ token.expires|annotated_date }} @@ -34,7 +34,15 @@ Never {% endif %}
-
+
+ Last Used
+ {% if token.last_used %} + {{ token.last_used|annotated_date }} + {% else %} + Never + {% endif %} +
+
Create/Edit/Delete Operations
{% if token.write_enabled %} Enabled From 9c214622a119b990491b72402d58b8c2272d3974 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Jun 2022 16:30:27 -0400 Subject: [PATCH 11/24] Closes #4350: Illustrate reservations vertically alongside rack elevations --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/api/serializers.py | 5 +- netbox/dcim/constants.py | 3 +- netbox/dcim/models/racks.py | 5 +- netbox/dcim/svg/racks.py | 45 +++++++++++++----- netbox/project-static/dist/rack_elevation.css | Bin 1511 -> 1423 bytes .../project-static/styles/rack-elevation.scss | 16 ++----- netbox/utilities/utils.py | 24 +++++++++- 8 files changed, 71 insertions(+), 28 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 229509b9c..fc8c24f4c 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -14,6 +14,7 @@ ### 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 * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster * [#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 diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ba7f661b5..401c9a901 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -252,7 +252,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, 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/models/racks.py b/netbox/dcim/models/racks.py index 12cc4dd38..39e01cae3 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -367,7 +367,8 @@ 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 ): @@ -381,6 +382,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,6 +391,7 @@ 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 diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 4d518adf1..b344aad0a 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -11,7 +11,7 @@ from django.urls import reverse from django.utils.http import urlencode from netbox.config import get_config -from utilities.utils import foreground_color +from utilities.utils import foreground_color, array_to_ranges from dcim.choices import DeviceFaceChoices from dcim.constants import RACK_ELEVATION_BORDER_WIDTH @@ -55,8 +55,8 @@ class RackElevationSVG: :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. """ - 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): self.rack = rack self.include_images = include_images self.base_url = base_url.rstrip('/') if base_url is not None else '' @@ -65,7 +65,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 @@ -91,7 +92,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 +101,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') @@ -198,6 +200,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 +286,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/project-static/dist/rack_elevation.css b/netbox/project-static/dist/rack_elevation.css index 4f9361489cf7fe2a5553150afc12f6de625c9072..bfeed4150cf8f390dbb586e2e698af26ad196f89 100644 GIT binary patch delta 57 zcmaFP-Os&YDa&L{*1fvLW$6lfMXAN9MP-R4nfZCq$vKI|#j(|CnK?ODrA0X!$`Hxa I6wO*L0D|5Xxc~qF delta 114 zcmeC@e$KsNDN9&UYH?~&S!#+^Mt)gpQFL-nVsUY-wq9aNif&43S!Qx-by{Xlj+L^3 nfkAC?S-OH=aZY}T9!wWhy$-s}WI [(0, 2), (10,), (14, 16)]" + """ + group = ( + list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x) + ) + return [ + (g[0], g[-1])[:len(g)] for g in group + ] + + def array_to_string(array): """ Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField. For example: [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16" """ - group = (list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)) - return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) + ret = [] + ranges = array_to_ranges(array) + for value in ranges: + if len(value) == 1: + ret.append(str(value[0])) + else: + ret.append(f'{value[0]}-{value[1]}') + return ', '.join(ret) def content_type_name(ct): From 4587b83d8581ef4c945328c964764658837d784d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Jun 2022 21:22:24 -0400 Subject: [PATCH 12/24] Closes #1099: Add PoE mode & type for interfaces --- docs/models/dcim/interface.md | 4 + docs/release-notes/version-3.3.md | 4 + netbox/dcim/api/serializers.py | 10 +- netbox/dcim/choices.py | 45 +++++++ netbox/dcim/filtersets.py | 10 +- netbox/dcim/forms/bulk_create.py | 7 +- netbox/dcim/forms/bulk_edit.py | 17 ++- netbox/dcim/forms/bulk_import.py | 16 ++- netbox/dcim/forms/filtersets.py | 9 ++ netbox/dcim/forms/models.py | 9 +- netbox/dcim/graphql/types.py | 6 + .../0155_interface_poe_mode_type.py | 23 ++++ netbox/dcim/models/device_components.py | 32 ++++- netbox/dcim/tables/devices.py | 8 +- netbox/dcim/tests/test_api.py | 2 + netbox/dcim/tests/test_filtersets.py | 119 ++++++++++++++++-- netbox/dcim/tests/test_views.py | 14 ++- netbox/templates/dcim/interface.html | 8 ++ 18 files changed, 310 insertions(+), 33 deletions(-) create mode 100644 netbox/dcim/migrations/0155_interface_poe_mode_type.py 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/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index fc8c24f4c..801e45b51 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -11,6 +11,8 @@ #### 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)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses @@ -33,6 +35,8 @@ * 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 option `poe_mode` and `poe_type` fields * dcim.Rack * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit * extras.CustomField diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 401c9a901..f3d223d4c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -812,6 +812,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(), @@ -836,10 +838,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_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_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_peer', 'link_peer_type', 'wireless_lans', + 'vrf', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 2e96f9c67..44ec3fb88 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1003,6 +1003,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/filtersets.py b/netbox/dcim/filtersets.py index f052a8be9..7c2d02bb3 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1238,6 +1238,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' @@ -1271,8 +1277,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', + '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', ] 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..88f043c32 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1063,6 +1063,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 +1117,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..292f58785 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -622,6 +622,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 +652,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..bdef32ec3 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -969,6 +969,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 +1010,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..c58500198 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1314,6 +1314,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 +1325,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 d25a6bba6..17d6bc646 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -226,6 +226,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/models/device_components.py b/netbox/dcim/models/device_components.py index 9a0609c12..f49db08ab 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -590,6 +590,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, @@ -638,7 +650,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')) @@ -726,6 +738,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/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/tests/test_api.py b/netbox/dcim/tests/test_api.py index a6631208b..a61b44f91 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1507,6 +1507,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 273ee6570..f7d4c4e0a 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2540,14 +2540,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 +2689,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 e17f94682..9cce21a21 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2204,6 +2204,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 +2227,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 +2248,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 +2258,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/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index c4cb8b72f..e98750518 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -69,6 +69,14 @@ Description {{ object.description|placeholder }} + + PoE Mode + {{ object.get_poe_mode_display|placeholder }} + + + PoE Mode + {{ object.get_poe_type_display|placeholder }} + 802.1Q Mode {{ object.get_mode_display|placeholder }} From e4aa933d57ce8b7a5328962f021429545f21ce97 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 13:33:19 -0400 Subject: [PATCH 13/24] Closes #7744: Add status field to Location --- docs/models/dcim/location.md | 3 +-- docs/release-notes/version-3.3.md | 5 ++++- netbox/dcim/api/serializers.py | 5 +++-- netbox/dcim/choices.py | 22 +++++++++++++++++++ netbox/dcim/filtersets.py | 6 ++++- netbox/dcim/forms/bulk_edit.py | 8 ++++++- netbox/dcim/forms/bulk_import.py | 6 ++++- netbox/dcim/forms/filtersets.py | 6 ++++- netbox/dcim/forms/models.py | 8 +++++-- .../dcim/migrations/0156_location_status.py | 18 +++++++++++++++ netbox/dcim/models/sites.py | 10 ++++++++- netbox/dcim/tables/sites.py | 7 +++--- netbox/dcim/tests/test_api.py | 13 ++++++----- netbox/dcim/tests/test_filtersets.py | 10 ++++++--- netbox/dcim/tests/test_views.py | 15 +++++++------ netbox/templates/dcim/location.html | 4 ++++ 16 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 netbox/dcim/migrations/0156_location_status.py 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/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 801e45b51..6b825bd45 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -18,6 +18,7 @@ * [#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 * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster +* [#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 @@ -36,7 +37,9 @@ * dcim.DeviceType * The `u_height` field has been changed from an integer to a decimal * dcim.Interface - * Added the option `poe_mode` and `poe_type` fields + * 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.CustomField diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f3d223d4c..8ac2aa738 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -151,6 +151,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) @@ -158,8 +159,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', ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 44ec3fb88..94c8b255f 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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 7c2d02bb3..628bd58f6 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -216,10 +216,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(): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 88f043c32..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') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 292f58785..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): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index bdef32ec3..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) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index c58500198..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): 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/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/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 a61b44f91..436f43b6f 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, }, ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f7d4c4e0a..9df75f4c0 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) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 9cce21a21..748bf24c8 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 = { diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index b2b2bc4cd..f0335036f 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -43,6 +43,10 @@ Parent {{ object.parent|linkify|placeholder }} + + Status + {% badge object.get_status_display bg_color=object.get_status_color %} + Tenant From 341615668baeee010c0ca8c3faf735cad7068f4a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 15:09:50 -0400 Subject: [PATCH 14/24] Closes #7120: Add termination_date field to Circuit --- docs/models/circuits/circuit.md | 2 +- docs/release-notes/version-3.3.md | 3 +++ netbox/circuits/api/serializers.py | 6 +++--- netbox/circuits/filtersets.py | 2 +- netbox/circuits/forms/bulk_edit.py | 14 ++++++++++++-- netbox/circuits/forms/bulk_import.py | 3 ++- netbox/circuits/forms/filtersets.py | 12 ++++++++++-- netbox/circuits/forms/models.py | 8 +++++--- .../0036_circuit_termination_date.py | 18 ++++++++++++++++++ netbox/circuits/models/circuits.py | 9 +++++++-- netbox/circuits/tables/circuits.py | 2 +- netbox/circuits/tests/test_filtersets.py | 16 ++++++++++------ netbox/circuits/tests/test_views.py | 1 + netbox/templates/circuits/circuit.html | 4 ++++ 14 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 netbox/circuits/migrations/0036_circuit_termination_date.py 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/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 6b825bd45..66cfd2e66 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -18,6 +18,7 @@ * [#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 * [#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 @@ -32,6 +33,8 @@ ### 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 diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 19570f067..2bb3cd266 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 b7fa100a8..67a0d1b02 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/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 205236712..28e0a3fe3 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 17c846c86..f60275ff3 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/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 @@ Install Date {{ object.install_date|annotated_date|placeholder }} + + Termination Date + {{ object.termination_date|annotated_date|placeholder }} + Commit Rate {{ object.commit_rate|humanize_speed|placeholder }} From 379880cd8431da6cc39753a8b3a7c8bfcd8f9cc1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 16:10:48 -0400 Subject: [PATCH 15/24] Closes #9582: Enable assigning config contexts based on device location --- docs/models/extras/configcontext.md | 2 + docs/release-notes/version-3.3.md | 3 ++ netbox/extras/api/serializers.py | 16 +++++-- netbox/extras/api/views.py | 2 +- netbox/extras/filtersets.py | 13 ++++- netbox/extras/forms/filtersets.py | 9 +++- netbox/extras/forms/models.py | 21 ++++++-- .../0076_configcontext_locations.py | 19 ++++++++ netbox/extras/models/configcontexts.py | 12 +++-- netbox/extras/querysets.py | 5 +- netbox/extras/tables/tables.py | 5 +- netbox/extras/tests/test_filtersets.py | 30 +++++++++--- netbox/extras/tests/test_models.py | 48 +++++++++++-------- netbox/extras/views.py | 2 +- .../templates/extras/configcontext_edit.html | 37 -------------- 15 files changed, 138 insertions(+), 86 deletions(-) create mode 100644 netbox/extras/migrations/0076_configcontext_locations.py delete mode 100644 netbox/templates/extras/configcontext_edit.html 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/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 66cfd2e66..2a2d4f683 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -25,6 +25,7 @@ * [#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 +* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location ### Other Changes @@ -45,6 +46,8 @@ * 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 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/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html deleted file mode 100644 index 7b37a69c6..000000000 --- a/netbox/templates/extras/configcontext_edit.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
Config Context
-
- {% render_field form.name %} - {% render_field form.weight %} - {% render_field form.description %} - {% render_field form.is_active %} -
-
-
-
Assignment
-
- {% render_field form.regions %} - {% render_field form.site_groups %} - {% render_field form.sites %} - {% render_field form.device_types %} - {% render_field form.roles %} - {% render_field form.platforms %} - {% render_field form.cluster_types %} - {% render_field form.cluster_groups %} - {% render_field form.clusters %} - {% render_field form.tenant_groups %} - {% render_field form.tenants %} - {% render_field form.tags %} -
-
-
-
Data
-
- {% render_field form.data %} -
-
-{% endblock %} From a38a880e67d78eba52f19cc4c2613e9399939c2f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 17:01:07 -0400 Subject: [PATCH 16/24] Refactor source IP resolution logic --- docs/release-notes/version-3.3.md | 2 +- netbox/netbox/api/authentication.py | 40 +++++++++++++---------------- netbox/users/api/serializers.py | 5 +++- netbox/users/forms.py | 5 ++-- netbox/users/models.py | 15 +++++------ netbox/utilities/request.py | 27 +++++++++++++++++++ 6 files changed, 59 insertions(+), 35 deletions(-) create mode 100644 netbox/utilities/request.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 6e2f28730..f9a229aef 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -21,10 +21,10 @@ * [#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 +* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API token access by source IP * [#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 -* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key access by source IP * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields * [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 2f86a1da2..ea66dc5a6 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -1,41 +1,37 @@ from django.conf import settings -from django.core.exceptions import ValidationError from rest_framework import authentication, exceptions from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS 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): - authenticationresult = super().authenticate(request) - if authenticationresult: - token_user, token = authenticationresult + result = super().authenticate(request) - # Verify source IP is allowed + if result: + token = result[1] + + # Enforce source IP restrictions (if any) set on the token if token.allowed_ips: - # Replace 'HTTP_X_REAL_IP' with the settings variable choosen in #8867 - if 'HTTP_X_REAL_IP' in request.META: - clientip = request.META['HTTP_X_REAL_IP'].split(",")[0].strip() - http_header = 'HTTP_X_REAL_IP' - elif 'REMOTE_ADDR' in request.META: - clientip = request.META['REMOTE_ADDR'] - http_header = 'REMOTE_ADDR' - else: - raise exceptions.AuthenticationFailed(f"A HTTP header containing the SourceIP (HTTP_X_REAL_IP, REMOTE_ADDR) is missing from the request.") + 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." + ) - try: - if not token.validate_client_ip(clientip): - raise exceptions.AuthenticationFailed(f"Source IP {clientip} is not allowed to use this token.") - except ValidationError as ValidationErrorInfo: - raise exceptions.ValidationError(f"The value in the HTTP Header {http_header} has a ValidationError: {ValidationErrorInfo.message}") - - return authenticationresult + return result def authenticate_credentials(self, key): model = self.get_model() diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index b48a14d5c..2a40e45ac 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -67,7 +67,10 @@ class TokenSerializer(ValidatedModelSerializer): class Meta: model = Token - fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', 'allowed_ips') + fields = ( + 'id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', + 'allowed_ips', + ) def to_internal_value(self, data): if 'key' not in data: diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 9720f92b7..8692eb050 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -101,11 +101,12 @@ class TokenForm(BootstrapMixin, forms.ModelForm): required=False, help_text="If no key is provided, one will be generated automatically." ) - allowed_ips = SimpleArrayField( base_field=IPNetworkFormField(), required=False, - help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + label='Allowed IPs', + help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', ) class Meta: diff --git a/netbox/users/models.py b/netbox/users/models.py index 5372353c0..222b088d6 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -223,7 +223,9 @@ class Token(models.Model): base_field=IPNetworkField(), blank=True, null=True, - help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + verbose_name='Allowed IPs', + help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', ) class Meta: @@ -249,20 +251,15 @@ class Token(models.Model): return False return True - def validate_client_ip(self, raw_ip_address): + def validate_client_ip(self, client_ip): """ - Checks that an IP address falls within the allowed IPs. + Validate the API client IP address against the source IP restrictions (if any) set on the token. """ if not self.allowed_ips: return True - try: - ip_address = ipaddress.ip_address(raw_ip_address) - except ValueError as e: - raise ValidationError(str(e)) - for ip_network in self.allowed_ips: - if ip_address in ipaddress.ip_network(ip_network): + if client_ip in ipaddress.ip_network(ip_network): return True return False diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py new file mode 100644 index 000000000..0fac59d38 --- /dev/null +++ b/netbox/utilities/request.py @@ -0,0 +1,27 @@ +import ipaddress + +__all__ = ( + 'get_client_ip', +) + + +def get_client_ip(request, additional_headers=()): + """ + Return the client (source) IP address of the given request. + """ + HTTP_HEADERS = ( + 'HTTP_X_REAL_IP', + 'HTTP_X_FORWARDED_FOR', + 'REMOTE_ADDR', + *additional_headers + ) + for header in HTTP_HEADERS: + if header in request.META: + client_ip = request.META[header].split(',')[0] + try: + return ipaddress.ip_address(client_ip) + except ValueError: + raise ValueError(f"Invalid IP address set for {header}: {client_ip}") + + # Could not determine the client IP address from request headers + return None From e3b7bba84ff15ce0c1f512e5348cd13db89ecb0c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 21:10:18 -0400 Subject: [PATCH 17/24] Add token authentication tests --- netbox/netbox/tests/test_authentication.py | 67 +++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 7fc12b4fd..6597684fb 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,73 @@ 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) + + @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): From 3c15419bd0e10a153713707655798d1ac22f194f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 21:51:43 -0400 Subject: [PATCH 18/24] Introduce IPNetworkSerializer to serialize allowed token IPs --- docs/release-notes/version-3.3.md | 3 ++- netbox/netbox/api/__init__.py | 3 ++- netbox/netbox/api/fields.py | 21 +++++++++++++++++++-- netbox/users/api/serializers.py | 3 ++- netbox/users/models.py | 6 ++---- netbox/utilities/request.py | 4 ++-- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index f9a229aef..81125451e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -13,6 +13,8 @@ #### 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 @@ -21,7 +23,6 @@ * [#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 -* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API token access by source IP * [#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 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/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/users/api/serializers.py b/netbox/users/api/serializers.py index 2a40e45ac..e5ed1bb34 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers -from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField, ValidatedModelSerializer from users.models import ObjectPermission, Token from .nested_serializers import * @@ -64,6 +64,7 @@ class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) user = NestedUserSerializer() + allowed_ips = serializers.ListField(child=IPNetworkSerializer()) class Meta: model = Token diff --git a/netbox/users/models.py b/netbox/users/models.py index 222b088d6..704516c71 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -4,12 +4,12 @@ import os from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField -from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from netaddr import IPNetwork from ipam.fields import IPNetworkField from netbox.config import get_config @@ -17,8 +17,6 @@ from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * -import ipaddress - __all__ = ( 'ObjectPermission', 'Token', @@ -259,7 +257,7 @@ class Token(models.Model): return True for ip_network in self.allowed_ips: - if client_ip in ipaddress.ip_network(ip_network): + if client_ip in IPNetwork(ip_network): return True return False diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py index 0fac59d38..3b8e1edde 100644 --- a/netbox/utilities/request.py +++ b/netbox/utilities/request.py @@ -1,4 +1,4 @@ -import ipaddress +from netaddr import IPAddress __all__ = ( 'get_client_ip', @@ -19,7 +19,7 @@ def get_client_ip(request, additional_headers=()): if header in request.META: client_ip = request.META[header].split(',')[0] try: - return ipaddress.ip_address(client_ip) + return IPAddress(client_ip) except ValueError: raise ValueError(f"Invalid IP address set for {header}: {client_ip}") From d4db656940e98b597644106707b4d94a3628e895 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 08:09:39 -0400 Subject: [PATCH 19/24] Allowed IPs should be optional on Token --- netbox/users/api/serializers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index e5ed1bb34..177cce39c 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -64,7 +64,12 @@ class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) user = NestedUserSerializer() - allowed_ips = serializers.ListField(child=IPNetworkSerializer()) + allowed_ips = serializers.ListField( + child=IPNetworkSerializer(), + required=False, + allow_empty=True, + default=[] + ) class Meta: model = Token From 7e4b34560f47ef9f8189600ccd4377cb4fc7566c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 08:12:36 -0400 Subject: [PATCH 20/24] Update token model docs --- docs/models/users/token.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From dc05e62ce030419509149634d29586f536956e91 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 12:09:00 -0400 Subject: [PATCH 21/24] Documentation & clenaup for #9536 --- docs/release-notes/version-3.3.md | 4 ++++ docs/rest-api/authentication.md | 5 +++++ netbox/netbox/api/authentication.py | 7 +++---- netbox/netbox/tests/test_authentication.py | 4 ++++ netbox/users/api/serializers.py | 2 +- ....py => 0003_token_allowed_ips_last_used.py} | 9 ++++++--- .../users/migrations/0003_token_last_used.py | 18 ------------------ 7 files changed, 23 insertions(+), 26 deletions(-) rename netbox/users/migrations/{0003_token_allowed_ips.py => 0003_token_allowed_ips_last_used.py} (68%) delete mode 100644 netbox/users/migrations/0003_token_last_used.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 81125451e..f76cab4e2 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -27,6 +27,7 @@ * [#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 +* [#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 ### Other Changes @@ -55,6 +56,9 @@ * 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 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/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 1b7def3a3..b8607a0bb 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -44,16 +44,15 @@ class TokenAuthentication(authentication.TokenAuthentication): except model.DoesNotExist: raise exceptions.AuthenticationFailed("Invalid token") - # Update last used, but only once a minute. This reduces the write load on the db + # 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.warning("Maintenance mode enabled: disabling update of token's last used timestamp") + logger.debug("Maintenance mode enabled: Disabling update of token's last used timestamp") else: - token.last_used = timezone.now() - token.save() + 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: diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 6597684fb..ef4554b4b 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -31,6 +31,10 @@ class TokenAuthenticationTestCase(APITestCase): 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') diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 177cce39c..d05f6c7da 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -74,7 +74,7 @@ class TokenSerializer(ValidatedModelSerializer): class Meta: model = Token fields = ( - 'id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', + 'id', 'url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', 'description', 'allowed_ips', ) diff --git a/netbox/users/migrations/0003_token_allowed_ips.py b/netbox/users/migrations/0003_token_allowed_ips_last_used.py similarity index 68% rename from netbox/users/migrations/0003_token_allowed_ips.py rename to netbox/users/migrations/0003_token_allowed_ips_last_used.py index f4eaa9f96..946226f75 100644 --- a/netbox/users/migrations/0003_token_allowed_ips.py +++ b/netbox/users/migrations/0003_token_allowed_ips_last_used.py @@ -1,7 +1,5 @@ -# Generated by Django 3.2.12 on 2022-04-19 12:37 - import django.contrib.postgres.fields -from django.db import migrations +from django.db import migrations, models import ipam.fields @@ -17,4 +15,9 @@ class Migration(migrations.Migration): name='allowed_ips', field=django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None), ), + migrations.AddField( + model_name='token', + name='last_used', + field=models.DateTimeField(blank=True, null=True), + ), ] diff --git a/netbox/users/migrations/0003_token_last_used.py b/netbox/users/migrations/0003_token_last_used.py deleted file mode 100644 index cc014e59c..000000000 --- a/netbox/users/migrations/0003_token_last_used.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.0.4 on 2022-06-16 15:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0002_standardize_id_fields'), - ] - - operations = [ - migrations.AddField( - model_name='token', - name='last_used', - field=models.DateTimeField(blank=True, null=True), - ), - ] From f9d81fd36232e9bf3f60a215d2c6a405b9b342fb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 15:21:10 -0400 Subject: [PATCH 22/24] Closes #9414: Add clone() method to NetBoxModel for copying instance attributes --- docs/plugins/development/models.md | 18 ++++++++++++ docs/release-notes/version-3.3.md | 4 +++ netbox/netbox/models/__init__.py | 20 +++++++++++++ netbox/netbox/views/generic/object_views.py | 6 ++-- netbox/templates/generic/object.html | 2 +- netbox/utilities/utils.py | 32 +++++++++------------ 6 files changed, 60 insertions(+), 22 deletions(-) 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 f76cab4e2..e9125fea0 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -30,6 +30,10 @@ * [#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 * [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset 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/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/templates/generic/object.html b/netbox/templates/generic/object.html index 451c530e1..ef95ccdc0 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -59,7 +59,7 @@ Context: {# Extra buttons #} {% block extra_controls %}{% endblock %} - {% if object.clone_fields and request.user|can_add:object %} + {% if request.user|can_add:object %} {% clone_button object %} {% endif %} {% if request.user|can_change:object %} diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 97ab165fe..731b67e43 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -282,26 +282,22 @@ def render_jinja2(template_code, context): def prepare_cloned_fields(instance): """ - Compile an object's `clone_fields` list into a string of URL query parameters. Tags are automatically cloned where - applicable. + Generate a QueryDict comprising attributes from an object's clone() method. """ + # Generate the clone attributes from the instance + if not hasattr(instance, 'clone'): + return None + attrs = instance.clone() + + # Prepare querydict parameters params = [] - for field_name in getattr(instance, 'clone_fields', []): - field = instance._meta.get_field(field_name) - field_value = field.value_from_object(instance) - - # Pass False as null for boolean fields - if field_value is False: - params.append((field_name, '')) - - # Omit empty values - elif field_value not in (None, ''): - params.append((field_name, field_value)) - - # Copy tags - if is_taggable(instance): - for tag in instance.tags.all(): - params.append(('tags', tag.pk)) + for key, value in attrs.items(): + if type(value) in (list, tuple): + params.extend([(key, v) for v in value]) + elif value not in (False, None): + params.append((key, value)) + else: + params.append((key, '')) # Return a QueryDict with the parameters return QueryDict('&'.join([f'{k}={v}' for k, v in params]), mutable=True) From fc02e15fb10049df56ad0371ffb206abc15ff2a8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 24 Jun 2022 11:04:38 -0400 Subject: [PATCH 23/24] Closes #4434: Enable highlighting devices within rack elevations --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/api/views.py | 11 +++- netbox/dcim/models/racks.py | 6 +- netbox/dcim/svg/racks.py | 57 ++++++++++++------ netbox/dcim/views.py | 6 ++ netbox/project-static/dist/rack_elevation.css | Bin 1423 -> 1496 bytes .../project-static/styles/rack-elevation.scss | 7 +++ netbox/templates/dcim/device.html | 5 ++ netbox/templates/dcim/inc/rack_elevation.html | 4 +- netbox/templates/dcim/rack.html | 4 +- 10 files changed, 76 insertions(+), 25 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index e9125fea0..8deee0370 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -19,6 +19,7 @@ * [#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 diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index c4c25f654..fdf53053e 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/models/racks.py b/netbox/dcim/models/racks.py index 39e01cae3..31fbb71de 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -370,7 +370,8 @@ class Rack(NetBoxModel): 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 @@ -394,7 +395,8 @@ class Rack(NetBoxModel): 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/svg/racks.py b/netbox/dcim/svg/racks.py index b344aad0a..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, array_to_ranges -from dcim.choices import DeviceFaceChoices 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, margin_width=None, user=None, - include_images=True, base_url=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 '' @@ -74,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( @@ -123,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) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 35a1056b2..8e8ffbd82 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/project-static/dist/rack_elevation.css b/netbox/project-static/dist/rack_elevation.css index bfeed4150cf8f390dbb586e2e698af26ad196f89..229ea2f9797530fc766c7500a2a31dff4d586244 100644 GIT binary patch delta 81 zcmeC@zQMi0k=dmvHMvBuI3qD7HASbmEL|ZpH!(dGDo~wYkeHlVQfXyms#*(@EJ>|^ QDTd3L8mMlzVGd>j0Ll&>GXMYp delta 12 Tcmcb?-Os(jk$H0$vmX-x9z_HK diff --git a/netbox/project-static/styles/rack-elevation.scss b/netbox/project-static/styles/rack-elevation.scss index bc02995dd..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; diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d3d6f03dc..2ee1e1154 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -49,6 +49,11 @@ {% if object.rack %} {{ object.rack }} +
+ + + +
{% else %} {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 27372193d..d2c4e4e08 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,8 +1,8 @@
- +
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 42f6a8e99..51e873ffa 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -250,13 +250,13 @@

Front

- {% include 'dcim/inc/rack_elevation.html' with face='front' %} + {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}

Rear

- {% include 'dcim/inc/rack_elevation.html' with face='rear' %} + {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
From 7dd5f9e720cc06a06462952a0c619b86c49478af Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Jun 2022 11:30:52 -0400 Subject: [PATCH 24/24] Closes #9177: Add tenant assignment for wireless LANs & links --- docs/models/wireless/wirelesslan.md | 2 +- docs/models/wireless/wirelesslink.md | 2 +- docs/release-notes/version-3.3.md | 5 ++ netbox/templates/tenancy/tenant.html | 12 +++- netbox/templates/wireless/wirelesslan.html | 67 +++++++++++-------- netbox/templates/wireless/wirelesslink.html | 9 +++ netbox/tenancy/views.py | 3 + netbox/wireless/api/serializers.py | 9 ++- netbox/wireless/api/views.py | 4 +- netbox/wireless/filtersets.py | 5 +- netbox/wireless/forms/bulk_edit.py | 17 +++-- netbox/wireless/forms/bulk_import.py | 19 +++++- netbox/wireless/forms/filtersets.py | 12 +++- netbox/wireless/forms/models.py | 13 ++-- .../migrations/0004_wireless_tenancy.py | 25 +++++++ netbox/wireless/models.py | 14 ++++ netbox/wireless/tables/wirelesslan.py | 6 +- netbox/wireless/tables/wirelesslink.py | 4 +- netbox/wireless/tests/test_api.py | 30 +++++++-- netbox/wireless/tests/test_filtersets.py | 44 +++++++++--- netbox/wireless/tests/test_views.py | 46 +++++++++---- 21 files changed, 265 insertions(+), 83 deletions(-) create mode 100644 netbox/wireless/migrations/0004_wireless_tenancy.py 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/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 8deee0370..1e18de1e6 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -28,6 +28,7 @@ * [#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 @@ -70,3 +71,7 @@ * 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/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 52c13e1aa..e8dc4b23a 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -61,6 +61,10 @@

{{ stats.device_count }}

Devices

+

{{ stats.vrf_count }}

VRFs

@@ -102,8 +106,12 @@

Clusters

+
+

{{ stats.wirelesslink_count }}

+

Wireless Links

diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index 185a44904..9250ef7ef 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -6,36 +6,45 @@ {% block content %}
-
-
Wireless LAN
-
- - - - - - - - - - - - - - - - - -
SSID{{ object.ssid }}
Group{{ object.group|linkify|placeholder }}
Description{{ object.description|placeholder }}
VLAN{{ object.vlan|linkify|placeholder }}
-
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
+
Wireless LAN
+
+ + + + + + + + + + + + + + + + + + + + + +
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 }} +
+
-
- {% include 'wireless/inc/authentication_attrs.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+ {% include 'wireless/inc/authentication_attrs.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index 4795dcdde..d1a93e40d 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -23,6 +23,15 @@ SSID {{ object.ssid|placeholder }} + + Tenant + + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} + + Description {{ object.description|placeholder }} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index f6f95b123..07a25b5a4 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -7,6 +7,7 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN from netbox.views import generic from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster +from wireless.models import WirelessLAN, WirelessLink from . import filtersets, forms, tables from .models import * @@ -114,6 +115,8 @@ class TenantView(generic.ObjectView): 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'asn_count': ASN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'wirelesslan_count': WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'wirelesslink_count': WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance).count(), } return { diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 4a6abe94d..49d512e51 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -5,6 +5,7 @@ from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer from netbox.api import ChoiceField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from tenancy.api.nested_serializers import NestedTenantSerializer from wireless.choices import * from wireless.models import * from .nested_serializers import * @@ -33,14 +34,15 @@ class WirelessLANSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') group = NestedWirelessLANGroupSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) class Meta: model = WirelessLAN fields = [ - 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', + 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -49,12 +51,13 @@ class WirelessLinkSerializer(NetBoxModelSerializer): status = ChoiceField(choices=LinkStatusChoices, required=False) interface_a = NestedInterfaceSerializer() interface_b = NestedInterfaceSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) class Meta: model = WirelessLink fields = [ - 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', 'auth_type', + 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index 77a766c50..1103cec37 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -27,12 +27,12 @@ class WirelessLANGroupViewSet(NetBoxModelViewSet): class WirelessLANViewSet(NetBoxModelViewSet): - queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags') + queryset = WirelessLAN.objects.prefetch_related('vlan', 'tenant', 'tags') serializer_class = serializers.WirelessLANSerializer filterset_class = filtersets.WirelessLANFilterSet class WirelessLinkViewSet(NetBoxModelViewSet): - queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tags') + queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tenant', 'tags') serializer_class = serializers.WirelessLinkSerializer filterset_class = filtersets.WirelessLinkFilterSet diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 7b0be857b..60c4f935b 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -4,6 +4,7 @@ from django.db.models import Q from dcim.choices import LinkStatusChoices from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet +from tenancy.filtersets import TenancyFilterSet from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -30,7 +31,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class WirelessLANFilterSet(NetBoxModelFilterSet): +class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all(), field_name='group', @@ -66,7 +67,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet): return queryset.filter(qs_filter) -class WirelessLinkFilterSet(NetBoxModelFilterSet): +class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet): interface_a_id = MultiValueNumberFilter() interface_b_id = MultiValueNumberFilter() status = django_filters.MultipleChoiceFilter( diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 8a472e164..639a1ed1b 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -3,6 +3,7 @@ from django import forms from dcim.choices import LinkStatusChoices from ipam.models import VLAN from netbox.forms import NetBoxModelBulkEditForm +from tenancy.models import Tenant from utilities.forms import add_blank_choice, DynamicModelChoiceField from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH @@ -47,6 +48,10 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): required=False, label='SSID' ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) description = forms.CharField( required=False ) @@ -65,11 +70,11 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLAN fieldsets = ( - (None, ('group', 'vlan', 'ssid', 'description')), + (None, ('group', 'ssid', 'vlan', 'tenant', 'description')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) nullable_fields = ( - 'ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', ) @@ -83,6 +88,10 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): choices=add_blank_choice(LinkStatusChoices), required=False ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) description = forms.CharField( required=False ) @@ -101,9 +110,9 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLink fieldsets = ( - (None, ('ssid', 'status', 'description')), + (None, ('ssid', 'status', 'tenant', 'description')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')) ) nullable_fields = ( - 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 4b8acb385..6a1ca4f36 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -2,6 +2,7 @@ from dcim.choices import LinkStatusChoices from dcim.models import Interface from ipam.models import VLAN from netbox.forms import NetBoxModelCSVForm +from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.choices import * from wireless.models import * @@ -40,6 +41,12 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): to_field_name='name', help_text='Bridged VLAN' ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) auth_type = CSVChoiceField( choices=WirelessAuthTypeChoices, required=False, @@ -53,7 +60,7 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLAN - fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk') + fields = ('ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk') class WirelessLinkCSVForm(NetBoxModelCSVForm): @@ -67,6 +74,12 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm): interface_b = CSVModelChoiceField( queryset=Interface.objects.all() ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) auth_type = CSVChoiceField( choices=WirelessAuthTypeChoices, required=False, @@ -80,4 +93,6 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLink - fields = ('interface_a', 'interface_b', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk') + fields = ( + 'interface_a', 'interface_b', 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + ) diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 8dcb48673..9e8808e17 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext as _ from dcim.choices import LinkStatusChoices from netbox.forms import NetBoxModelFilterSetForm +from tenancy.forms import TenancyFilterForm from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField from wireless.choices import * from wireless.models import * @@ -24,11 +25,12 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class WirelessLANFilterForm(NetBoxModelFilterSetForm): +class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLAN fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('ssid', 'group_id',)), + ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) ssid = forms.CharField( @@ -57,8 +59,14 @@ class WirelessLANFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class WirelessLinkFilterForm(NetBoxModelFilterSetForm): +class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLink + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('ssid', 'status',)), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), + ) ssid = forms.CharField( required=False, label='SSID' diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index d1012ba59..bcffcf896 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,6 +1,7 @@ from dcim.models import Device, Interface, Location, Region, Site, SiteGroup from ipam.models import VLAN, VLANGroup from netbox.forms import NetBoxModelForm +from tenancy.forms import TenancyForm from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect from wireless.models import * @@ -25,7 +26,7 @@ class WirelessLANGroupForm(NetBoxModelForm): ] -class WirelessLANForm(NetBoxModelForm): +class WirelessLANForm(TenancyForm, NetBoxModelForm): group = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -79,14 +80,15 @@ class WirelessLANForm(NetBoxModelForm): fieldsets = ( ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), ('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)), + ('Tenancy', ('tenant_group', 'tenant')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) class Meta: model = WirelessLAN fields = [ - 'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'auth_type', - 'auth_cipher', 'auth_psk', 'tags', + 'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'tenant_group', + 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] widgets = { 'auth_type': StaticSelect, @@ -94,7 +96,7 @@ class WirelessLANForm(NetBoxModelForm): } -class WirelessLinkForm(NetBoxModelForm): +class WirelessLinkForm(TenancyForm, NetBoxModelForm): site_a = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, @@ -180,6 +182,7 @@ class WirelessLinkForm(NetBoxModelForm): ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')), ('Link', ('status', 'ssid', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) @@ -187,7 +190,7 @@ class WirelessLinkForm(NetBoxModelForm): model = WirelessLink fields = [ 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', - 'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'status', 'ssid', 'tenant_group', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] widgets = { 'status': StaticSelect, diff --git a/netbox/wireless/migrations/0004_wireless_tenancy.py b/netbox/wireless/migrations/0004_wireless_tenancy.py new file mode 100644 index 000000000..aa5837b0a --- /dev/null +++ b/netbox/wireless/migrations/0004_wireless_tenancy.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.5 on 2022-06-27 13:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0007_contact_link'), + ('wireless', '0003_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wireless_lans', to='tenancy.tenant'), + ), + migrations.AddField( + model_name='wirelesslink', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wireless_links', to='tenancy.tenant'), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 0543e5621..dd3945d50 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -101,6 +101,13 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): null=True, verbose_name='VLAN' ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='wireless_lans', + blank=True, + null=True + ) description = models.CharField( max_length=200, blank=True @@ -143,6 +150,13 @@ class WirelessLink(WirelessAuthenticationBase, NetBoxModel): choices=LinkStatusChoices, default=LinkStatusChoices.STATUS_CONNECTED ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='wireless_links', + blank=True, + null=True + ) description = models.CharField( max_length=200, blank=True diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py index 9955d4ac4..fc791d730 100644 --- a/netbox/wireless/tables/wirelesslan.py +++ b/netbox/wireless/tables/wirelesslan.py @@ -2,6 +2,7 @@ import django_tables2 as tables from dcim.models import Interface from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenantColumn from wireless.models import * __all__ = ( @@ -39,6 +40,7 @@ class WirelessLANTable(NetBoxTable): group = tables.Column( linkify=True ) + tenant = TenantColumn() interface_count = tables.Column( verbose_name='Interfaces' ) @@ -49,8 +51,8 @@ class WirelessLANTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLAN fields = ( - 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', - 'tags', 'created', 'last_updated', + 'pk', 'ssid', 'group', 'tenant', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', + 'auth_psk', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') diff --git a/netbox/wireless/tables/wirelesslink.py b/netbox/wireless/tables/wirelesslink.py index 72037c4d9..6a45a21ae 100644 --- a/netbox/wireless/tables/wirelesslink.py +++ b/netbox/wireless/tables/wirelesslink.py @@ -1,6 +1,7 @@ import django_tables2 as tables from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenantColumn from wireless.models import * __all__ = ( @@ -28,6 +29,7 @@ class WirelessLinkTable(NetBoxTable): interface_b = tables.Column( linkify=True ) + tenant = TenantColumn() tags = columns.TagColumn( url_name='wireless:wirelesslink_list' ) @@ -35,7 +37,7 @@ class WirelessLinkTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLink fields = ( - 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py index 917b7b320..9ef552eb7 100644 --- a/netbox/wireless/tests/test_api.py +++ b/netbox/wireless/tests/test_api.py @@ -1,10 +1,11 @@ from django.urls import reverse -from wireless.choices import * -from wireless.models import * from dcim.choices import InterfaceTypeChoices from dcim.models import Interface +from tenancy.models import Tenant from utilities.testing import APITestCase, APIViewTestCases, create_test_device +from wireless.choices import * +from wireless.models import * class AppTest(APITestCase): @@ -52,6 +53,12 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ) + Tenant.objects.bulk_create(tenants) + groups = ( WirelessLANGroup(name='Group 1', slug='group-1'), WirelessLANGroup(name='Group 2', slug='group-2'), @@ -71,21 +78,25 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): { 'ssid': 'WLAN4', 'group': groups[0].pk, + 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_OPEN, }, { 'ssid': 'WLAN5', 'group': groups[1].pk, + 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, }, { 'ssid': 'WLAN6', + 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE, }, ] cls.bulk_update_data = { 'group': groups[2].pk, + 'tenant': tenants[1].pk, 'description': 'New description', 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, 'auth_cipher': WirelessAuthCipherChoices.CIPHER_AES, @@ -115,10 +126,16 @@ class WirelessLinkTest(APIViewTestCases.APIViewTestCase): ] Interface.objects.bulk_create(interfaces) + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ) + Tenant.objects.bulk_create(tenants) + wireless_links = ( - WirelessLink(ssid='LINK1', interface_a=interfaces[0], interface_b=interfaces[1]), - WirelessLink(ssid='LINK2', interface_a=interfaces[2], interface_b=interfaces[3]), - WirelessLink(ssid='LINK3', interface_a=interfaces[4], interface_b=interfaces[5]), + WirelessLink(ssid='LINK1', interface_a=interfaces[0], interface_b=interfaces[1], tenant=tenants[0]), + WirelessLink(ssid='LINK2', interface_a=interfaces[2], interface_b=interfaces[3], tenant=tenants[0]), + WirelessLink(ssid='LINK3', interface_a=interfaces[4], interface_b=interfaces[5], tenant=tenants[0]), ) WirelessLink.objects.bulk_create(wireless_links) @@ -127,15 +144,18 @@ class WirelessLinkTest(APIViewTestCases.APIViewTestCase): 'interface_a': interfaces[6].pk, 'interface_b': interfaces[7].pk, 'ssid': 'LINK4', + 'tenant': tenants[1].pk, }, { 'interface_a': interfaces[8].pk, 'interface_b': interfaces[9].pk, 'ssid': 'LINK5', + 'tenant': tenants[1].pk, }, { 'interface_a': interfaces[10].pk, 'interface_b': interfaces[11].pk, 'ssid': 'LINK6', + 'tenant': tenants[1].pk, }, ] diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 5fee4fbf4..ffe919c32 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -3,6 +3,7 @@ from django.test import TestCase from dcim.choices import InterfaceTypeChoices, LinkStatusChoices from dcim.models import Interface from ipam.models import VLAN +from tenancy.models import Tenant from wireless.choices import * from wireless.filtersets import * from wireless.models import * @@ -43,10 +44,6 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['wireless-lan-group-1', 'wireless-lan-group-2']} 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) - def test_parent(self): parent_groups = WirelessLANGroup.objects.filter(parent__isnull=True)[:2] params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} @@ -81,10 +78,17 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): ) VLAN.objects.bulk_create(vlans) + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + wireless_lans = ( - WirelessLAN(ssid='WLAN1', group=groups[0], vlan=vlans[0], auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1'), - WirelessLAN(ssid='WLAN2', group=groups[1], vlan=vlans[1], auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2'), - WirelessLAN(ssid='WLAN3', group=groups[2], vlan=vlans[2], auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3'), + WirelessLAN(ssid='WLAN1', group=groups[0], vlan=vlans[0], tenant=tenants[0], auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1'), + WirelessLAN(ssid='WLAN2', group=groups[1], vlan=vlans[1], tenant=tenants[1], auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2'), + WirelessLAN(ssid='WLAN3', group=groups[2], vlan=vlans[2], tenant=tenants[2], auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3'), ) WirelessLAN.objects.bulk_create(wireless_lans) @@ -116,6 +120,13 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'auth_psk': ['PSK1', 'PSK2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = WirelessLink.objects.all() @@ -124,6 +135,13 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + devices = ( create_test_device('device1'), create_test_device('device2'), @@ -152,6 +170,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1', + tenant=tenants[0], description='foobar1' ).save() WirelessLink( @@ -162,6 +181,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2', + tenant=tenants[1], description='foobar2' ).save() WirelessLink( @@ -171,7 +191,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): status=LinkStatusChoices.STATUS_DECOMMISSIONING, auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, - auth_psk='PSK3' + auth_psk='PSK3', + tenant=tenants[2], ).save() WirelessLink( interface_a=interfaces[5], @@ -202,3 +223,10 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index 4141af6d6..7dea17d15 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -2,6 +2,7 @@ from wireless.choices import * from wireless.models import * from dcim.choices import InterfaceTypeChoices, LinkStatusChoices from dcim.models import Interface +from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -47,6 +48,13 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + groups = ( WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), @@ -55,9 +63,9 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): group.save() WirelessLAN.objects.bulk_create([ - WirelessLAN(group=groups[0], ssid='WLAN1'), - WirelessLAN(group=groups[0], ssid='WLAN2'), - WirelessLAN(group=groups[0], ssid='WLAN3'), + WirelessLAN(group=groups[0], ssid='WLAN1', tenant=tenants[0]), + WirelessLAN(group=groups[0], ssid='WLAN2', tenant=tenants[0]), + WirelessLAN(group=groups[0], ssid='WLAN3', tenant=tenants[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -65,14 +73,15 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'ssid': 'WLAN2', 'group': groups[1].pk, + 'tenant': tenants[1].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - "group,ssid", - "Wireless LAN Group 2,WLAN4", - "Wireless LAN Group 2,WLAN5", - "Wireless LAN Group 2,WLAN6", + f"group,ssid,tenant", + f"Wireless LAN Group 2,WLAN4,{tenants[0].name}", + f"Wireless LAN Group 2,WLAN5,{tenants[1].name}", + f"Wireless LAN Group 2,WLAN6,{tenants[2].name}", ) cls.bulk_edit_data = { @@ -85,6 +94,14 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + device = create_test_device('test-device') interfaces = [ Interface( @@ -98,9 +115,9 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): ] Interface.objects.bulk_create(interfaces) - WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1').save() - WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2').save() - WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3').save() + WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1', tenant=tenants[0]).save() + WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2', tenant=tenants[0]).save() + WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3', tenant=tenants[0]).save() tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -108,14 +125,15 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'interface_a': interfaces[6].pk, 'interface_b': interfaces[7].pk, 'status': LinkStatusChoices.STATUS_PLANNED, + 'tenant': tenants[1].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - "interface_a,interface_b,status", - f"{interfaces[6].pk},{interfaces[7].pk},connected", - f"{interfaces[8].pk},{interfaces[9].pk},connected", - f"{interfaces[10].pk},{interfaces[11].pk},connected", + f"interface_a,interface_b,status,tenant", + f"{interfaces[6].pk},{interfaces[7].pk},connected,{tenants[0].name}", + f"{interfaces[8].pk},{interfaces[9].pk},connected,{tenants[1].name}", + f"{interfaces[10].pk},{interfaces[11].pk},connected,{tenants[2].name}", ) cls.bulk_edit_data = {