- {{ field }} |
+ {{ field }} |
{% if field.type == 'boolean' and value == True %}
@@ -15,7 +15,7 @@
{% elif field.type == 'url' and value %}
{{ value|truncatechars:70 }}
- {% elif field.type == 'integer' or value %}
+ {% elif value is not None %}
{{ value }}
{% elif field.required %}
Not defined
diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html
index d2eb93ebd..765df31cc 100644
--- a/netbox/templates/inc/nav_menu.html
+++ b/netbox/templates/inc/nav_menu.html
@@ -462,6 +462,7 @@
{% if perms.secrets.add_secret %}
{% endif %}
@@ -503,6 +504,9 @@
+ {% if registry.plugin_menu_items %}
+ {% include 'inc/plugin_menu_items.html' %}
+ {% endif %}
{% endif %}
diff --git a/netbox/templates/inc/plugin_menu_items.html b/netbox/templates/inc/plugin_menu_items.html
new file mode 100644
index 000000000..0df4a5e8a
--- /dev/null
+++ b/netbox/templates/inc/plugin_menu_items.html
@@ -0,0 +1,26 @@
+{% load helpers %}
+-
+ Plugins
+
+
diff --git a/netbox/templates/inc/table_config_form.html b/netbox/templates/inc/table_config_form.html
new file mode 100644
index 000000000..66844c7ca
--- /dev/null
+++ b/netbox/templates/inc/table_config_form.html
@@ -0,0 +1,28 @@
+{% load form_helpers %}
+
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html
index 590ed9208..998eb2066 100644
--- a/netbox/templates/ipam/aggregate.html
+++ b/netbox/templates/ipam/aggregate.html
@@ -1,7 +1,8 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
+{% load plugins %}
{% block header %}
@@ -26,6 +27,7 @@
+ {% plugin_buttons aggregate %}
{% if perms.ipam.add_aggregate %}
{% clone_button aggregate %}
{% endif %}
@@ -64,7 +66,7 @@
Family |
- {{ aggregate.get_family_display }} |
+ IPv{{ aggregate.family }} |
RIR |
@@ -88,10 +90,17 @@
+ {% plugin_left_page aggregate %}
{% include 'inc/custom_fields_panel.html' with obj=aggregate %}
{% include 'extras/inc/tags_panel.html' with tags=aggregate.tags.all url='ipam:aggregate_list' %}
+ {% plugin_right_page aggregate %}
+
+
+
+
+ {% plugin_full_width_page aggregate %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html
index 08a311492..6eba1a5e6 100644
--- a/netbox/templates/ipam/ipaddress.html
+++ b/netbox/templates/ipam/ipaddress.html
@@ -1,7 +1,8 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
+{% load plugins %}
{% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons ipaddress %}
{% if perms.ipam.add_ipaddress %}
{% clone_button ipaddress %}
{% endif %}
@@ -65,7 +67,7 @@
Family |
- {{ ipaddress.get_family_display }} |
+ IPv{{ ipaddress.family }} |
VRF |
@@ -152,6 +154,7 @@
{% include 'inc/custom_fields_panel.html' with obj=ipaddress %}
{% include 'extras/inc/tags_panel.html' with tags=ipaddress.tags.all url='ipam:ipaddress_list' %}
+ {% plugin_left_page ipaddress %}
{% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
@@ -159,6 +162,12 @@
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
{% endif %}
{% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %}
+ {% plugin_right_page ipaddress %}
+
+
+ {% plugin_full_width_page ipaddress %}
+
+
{% endblock %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html
index 7bfa7ba1f..4620f6bf4 100644
--- a/netbox/templates/ipam/prefix.html
+++ b/netbox/templates/ipam/prefix.html
@@ -1,7 +1,8 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
+{% load plugins %}
{% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons prefix %}
{% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
Add Child Prefix
@@ -85,7 +87,7 @@
Family |
- {{ prefix.get_family_display }} |
+ IPv{{ prefix.family }} |
VRF |
@@ -187,12 +189,19 @@
{% include 'inc/custom_fields_panel.html' with obj=prefix %}
{% include 'extras/inc/tags_panel.html' with tags=prefix.tags.all url='ipam:prefix_list' %}
+ {% plugin_left_page prefix %}
{% if duplicate_prefix_table.rows %}
{% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
{% endif %}
{% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
+ {% plugin_right_page prefix %}
+
+
+
+
+ {% plugin_full_width_page prefix %}
{% endblock %}
diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html
index b845aca17..b16e99aa3 100644
--- a/netbox/templates/ipam/service.html
+++ b/netbox/templates/ipam/service.html
@@ -1,7 +1,8 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
+{% load plugins %}
{% block content %}
@@ -26,6 +27,7 @@
+ {% plugin_buttons service %}
{% if perms.dcim.change_service %}
{% edit_button service %}
{% endif %}
@@ -81,6 +83,15 @@
{% include 'inc/custom_fields_panel.html' with obj=service %}
{% include 'extras/inc/tags_panel.html' with tags=service.tags.all url='ipam:service_list' %}
-
+ {% plugin_left_page service %}
+
+
+ {% plugin_right_page service %}
+
+
+
+
+ {% plugin_full_width_page service %}
+
{% endblock %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html
index 246f3c866..ec1f94b51 100644
--- a/netbox/templates/ipam/vlan.html
+++ b/netbox/templates/ipam/vlan.html
@@ -1,7 +1,8 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
+{% load plugins %}
{% block header %}
@@ -31,6 +32,7 @@
+ {% plugin_buttons vlan %}
{% if perms.ipam.add_vlan %}
{% clone_button vlan %}
{% endif %}
@@ -139,6 +141,7 @@
{% include 'inc/custom_fields_panel.html' with obj=vlan %}
{% include 'extras/inc/tags_panel.html' with tags=vlan.tags.all url='ipam:vlan_list' %}
+ {% plugin_left_page vlan %}
@@ -155,6 +158,12 @@
{% endif %}
+ {% plugin_right_page vlan %}
+
+
+
+
+ {% plugin_full_width_page vlan %}
{% endblock %}
diff --git a/netbox/templates/ipam/vlangroup_vlans.html b/netbox/templates/ipam/vlangroup_vlans.html
index 34810b1d2..7f8ac2044 100644
--- a/netbox/templates/ipam/vlangroup_vlans.html
+++ b/netbox/templates/ipam/vlangroup_vlans.html
@@ -1,4 +1,4 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% block title %}{{ vlan_group }} - VLANs{% endblock %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html
index 7bb2dea25..6fb6d725f 100644
--- a/netbox/templates/ipam/vrf.html
+++ b/netbox/templates/ipam/vrf.html
@@ -1,7 +1,8 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
+{% load plugins %}
{% block header %}
@@ -25,6 +26,7 @@
+ {% plugin_buttons vrf %}
{% if perms.ipam.add_vrf %}
{% clone_button vrf %}
{% endif %}
@@ -97,9 +99,16 @@
{% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %}
+ {% plugin_left_page vrf %}
{% include 'inc/custom_fields_panel.html' with obj=vrf %}
+ {% plugin_right_page vrf %}
+
+
+
+
+ {% plugin_full_width_page vrf %}
{% endblock %}
diff --git a/netbox/templates/login.html b/netbox/templates/login.html
index 7f3cca545..cb0e30240 100644
--- a/netbox/templates/login.html
+++ b/netbox/templates/login.html
@@ -1,4 +1,4 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load form_helpers %}
{% load account socialaccount %}
diff --git a/netbox/templates/search.html b/netbox/templates/search.html
index 6388cc022..68b69cf94 100644
--- a/netbox/templates/search.html
+++ b/netbox/templates/search.html
@@ -1,4 +1,4 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load helpers %}
{% load form_helpers %}
diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html
index 6045897c9..3ddb2fe98 100644
--- a/netbox/templates/secrets/secret.html
+++ b/netbox/templates/secrets/secret.html
@@ -1,9 +1,10 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
{% load secret_helpers %}
{% load static %}
+{% load plugins %}
{% block header %}
@@ -16,6 +17,7 @@
+ {% plugin_buttons secret %}
{% if perms.secrets.change_secret %}
{% edit_button secret %}
{% endif %}
@@ -65,6 +67,7 @@
{% include 'inc/custom_fields_panel.html' with obj=secret %}
+ {% plugin_left_page secret %}
{% if secret|decryptable_by:request.user %}
@@ -100,6 +103,12 @@
{% endif %}
{% include 'extras/inc/tags_panel.html' with tags=secret.tags.all url='secrets:secret_list' %}
+ {% plugin_right_page secret %}
+
+
+
+
+ {% plugin_full_width_page secret %}
diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html
index 875e53c5c..cb3935521 100644
--- a/netbox/templates/secrets/secret_edit.html
+++ b/netbox/templates/secrets/secret_edit.html
@@ -1,4 +1,4 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load static %}
{% load form_helpers %}
{% load secret_helpers %}
@@ -21,12 +21,7 @@
Secret Attributes
-
+ {% render_field form.device %}
{% render_field form.role %}
{% render_field form.name %}
{% render_field form.userkeys %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html
index 4ef26c451..a9cf67398 100644
--- a/netbox/templates/tenancy/tenant.html
+++ b/netbox/templates/tenancy/tenant.html
@@ -1,7 +1,8 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
+{% load plugins %}
{% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons tenant %}
{% if perms.tenancy.add_tenant %}
{% clone_button tenant %}
{% endif %}
@@ -93,6 +95,7 @@
{% endif %}
+ {% plugin_left_page tenant %}
+ {% plugin_right_page tenant %}
+
+
+
+
+ {% plugin_full_width_page tenant %}
{% endblock %}
diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html
index b775af73e..690a966b0 100644
--- a/netbox/templates/users/api_tokens.html
+++ b/netbox/templates/users/api_tokens.html
@@ -1,4 +1,4 @@
-{% extends 'users/_user.html' %}
+{% extends 'users/base.html' %}
{% load helpers %}
{% block title %}API Tokens{% endblock %}
@@ -19,7 +19,7 @@
{% endif %}
- {{ token.key }}
+ {{ token.key }}
{% if token.is_expired %}
Expired
{% endif %}
@@ -27,24 +27,24 @@
- {{ token.created|date }}
- Created
+ Created
+ {{ token.created|date }}
+ Expires
{% if token.expires %}
- {{ token.expires|date }}
+ {{ token.expires|date }}
{% else %}
- Never
+ Never
{% endif %}
- Expires
+ Create/edit/delete operations
{% if token.write_enabled %}
Enabled
{% else %}
Disabled
- {% endif %}
- Create/edit/delete operations
+ {% endif %}
{% if token.description %}
diff --git a/netbox/templates/users/_user.html b/netbox/templates/users/base.html
similarity index 75%
rename from netbox/templates/users/_user.html
rename to netbox/templates/users/base.html
index 55df34228..972b3d7b5 100644
--- a/netbox/templates/users/_user.html
+++ b/netbox/templates/users/base.html
@@ -1,17 +1,20 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% block content %}
-
+
{% block title %}{% endblock %}
-
+
-
+
{% block usercontent %}{% endblock %}
diff --git a/netbox/templates/users/change_password.html b/netbox/templates/users/change_password.html
index 700bf682d..20c6d048b 100644
--- a/netbox/templates/users/change_password.html
+++ b/netbox/templates/users/change_password.html
@@ -1,10 +1,10 @@
-{% extends 'users/_user.html' %}
+{% extends 'users/base.html' %}
{% load form_helpers %}
{% block title %}Change Password{% endblock %}
{% block usercontent %}
-
+ {% plugin_buttons virtualmachine %}
{% if perms.virtualization.add_virtualmachine %}
{% clone_button virtualmachine %}
{% endif %}
@@ -158,6 +160,7 @@
{% endif %}
+ {% plugin_left_page virtualmachine %}
@@ -235,6 +238,12 @@
{% endif %}
+ {% plugin_right_page virtualmachine %}
+
+
+
+
+ {% plugin_full_width_page virtualmachine %}
diff --git a/netbox/templates/virtualization/virtualmachine_component_add.html b/netbox/templates/virtualization/virtualmachine_component_add.html
index 90754519b..34a8f3c3d 100644
--- a/netbox/templates/virtualization/virtualmachine_component_add.html
+++ b/netbox/templates/virtualization/virtualmachine_component_add.html
@@ -1,4 +1,4 @@
-{% extends '_base.html' %}
+{% extends 'base.html' %}
{% load helpers %}
{% load form_helpers %}
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index 7599029c5..9c7a099e4 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -12,11 +12,12 @@ from .nested_serializers import *
#
class TenantGroupSerializer(ValidatedModelSerializer):
+ parent = NestedTenantGroupSerializer(required=False, allow_null=True)
tenant_count = serializers.IntegerField(read_only=True)
class Meta:
model = TenantGroup
- fields = ['id', 'name', 'slug', 'tenant_count']
+ fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count']
class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer):
diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py
index 5762f9a0d..645cc2edc 100644
--- a/netbox/tenancy/api/urls.py
+++ b/netbox/tenancy/api/urls.py
@@ -14,9 +14,6 @@ class TenancyRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = TenancyRootView
-# Field choices
-router.register('_choices', views.TenancyFieldChoicesViewSet, basename='field-choice')
-
# Tenants
router.register('tenant-groups', views.TenantGroupViewSet)
router.register('tenants', views.TenantViewSet)
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py
index ab82c3cf5..148058a33 100644
--- a/netbox/tenancy/api/views.py
+++ b/netbox/tenancy/api/views.py
@@ -4,20 +4,12 @@ from extras.api.views import CustomFieldModelViewSet
from ipam.models import IPAddress, Prefix, VLAN, VRF
from tenancy import filters
from tenancy.models import Tenant, TenantGroup
-from utilities.api import FieldChoicesViewSet, ModelViewSet
+from utilities.api import ModelViewSet
from utilities.utils import get_subquery
from virtualization.models import VirtualMachine
from . import serializers
-#
-# Field choices
-#
-
-class TenancyFieldChoicesViewSet(FieldChoicesViewSet):
- fields = ()
-
-
#
# Tenant Groups
#
diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py
index 8ba3054aa..af5ee0b2c 100644
--- a/netbox/tenancy/filters.py
+++ b/netbox/tenancy/filters.py
@@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
-from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter
+from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
from .models import Tenant, TenantGroup
@@ -14,36 +14,45 @@ __all__ = (
class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+ parent_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=TenantGroup.objects.all(),
+ label='Tenant group (ID)',
+ )
+ parent = django_filters.ModelMultipleChoiceFilter(
+ field_name='parent__slug',
+ queryset=TenantGroup.objects.all(),
+ to_field_name='slug',
+ label='Tenant group group (slug)',
+ )
class Meta:
model = TenantGroup
- fields = ['id', 'name', 'slug']
+ fields = ['id', 'name', 'slug', 'description']
class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
- id__in = NumericInFilter(
- field_name='id',
- lookup_expr='in'
- )
q = django_filters.CharFilter(
method='search',
label='Search',
)
- group_id = django_filters.ModelMultipleChoiceFilter(
+ group_id = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
- label='Group (ID)',
+ field_name='group',
+ lookup_expr='in',
+ label='Tenant group (ID)',
)
- group = django_filters.ModelMultipleChoiceFilter(
- field_name='group__slug',
+ group = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
+ field_name='group',
+ lookup_expr='in',
to_field_name='slug',
- label='Group (slug)',
+ label='Tenant group (slug)',
)
tag = TagFilter()
class Meta:
model = Tenant
- fields = ['name', 'slug']
+ fields = ['id', 'name', 'slug']
def search(self, queryset, name, value):
if not value.strip():
@@ -60,16 +69,17 @@ class TenancyFilterSet(django_filters.FilterSet):
"""
An inheritable FilterSet for models which support Tenant assignment.
"""
- tenant_group_id = django_filters.ModelMultipleChoiceFilter(
- field_name='tenant__group__id',
+ tenant_group_id = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
- to_field_name='id',
+ field_name='tenant__group',
+ lookup_expr='in',
label='Tenant Group (ID)',
)
- tenant_group = django_filters.ModelMultipleChoiceFilter(
- field_name='tenant__group__slug',
+ tenant_group = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
+ field_name='tenant__group',
to_field_name='slug',
+ lookup_expr='in',
label='Tenant Group (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
@@ -77,8 +87,8 @@ class TenancyFilterSet(django_filters.FilterSet):
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
- field_name='tenant__slug',
queryset=Tenant.objects.all(),
+ field_name='tenant__slug',
to_field_name='slug',
label='Tenant (slug)',
)
diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py
index 5b828b661..bf100f43a 100644
--- a/netbox/tenancy/forms.py
+++ b/netbox/tenancy/forms.py
@@ -1,12 +1,12 @@
from django import forms
-from taggit.forms import TagField
from extras.forms import (
- AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm,
+ AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
+ TagField,
)
from utilities.forms import (
- APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField,
- DynamicModelMultipleChoiceField, SlugField, TagFilterField,
+ APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,
+ DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField,
)
from .models import Tenant, TenantGroup
@@ -16,24 +16,34 @@ from .models import Tenant, TenantGroup
#
class TenantGroupForm(BootstrapMixin, forms.ModelForm):
+ parent = DynamicModelChoiceField(
+ queryset=TenantGroup.objects.all(),
+ required=False,
+ widget=APISelect(
+ api_url="/api/tenancy/tenant-groups/"
+ )
+ )
slug = SlugField()
class Meta:
model = TenantGroup
fields = [
- 'name', 'slug',
+ 'parent', 'name', 'slug', 'description',
]
-class TenantGroupCSVForm(forms.ModelForm):
+class TenantGroupCSVForm(CSVModelForm):
+ parent = CSVModelChoiceField(
+ queryset=TenantGroup.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Parent group'
+ )
slug = SlugField()
class Meta:
model = TenantGroup
fields = TenantGroup.csv_headers
- help_texts = {
- 'name': 'Group name',
- }
#
@@ -44,10 +54,7 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
- required=False,
- widget=APISelect(
- api_url="/api/tenancy/tenant-groups/"
- )
+ required=False
)
comments = CommentField()
tags = TagField(
@@ -61,25 +68,18 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
)
-class TenantCSVForm(CustomFieldModelForm):
+class TenantCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
- group = forms.ModelChoiceField(
+ group = CSVModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
to_field_name='name',
- help_text='Name of parent group',
- error_messages={
- 'invalid_choice': 'Group not found.'
- }
+ help_text='Assigned group'
)
class Meta:
model = Tenant
fields = Tenant.csv_headers
- help_texts = {
- 'name': 'Tenant name',
- 'comments': 'Free-form comments'
- }
class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -89,10 +89,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
)
group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
- required=False,
- widget=APISelect(
- api_url="/api/tenancy/tenant-groups/"
- )
+ required=False
)
class Meta:
@@ -112,7 +109,6 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url="/api/tenancy/tenant-groups/",
value_field="slug",
null_option=True,
)
@@ -129,7 +125,6 @@ class TenancyForm(forms.Form):
queryset=TenantGroup.objects.all(),
required=False,
widget=APISelect(
- api_url="/api/tenancy/tenant-groups/",
filter_for={
'tenant': 'group_id',
},
@@ -140,10 +135,7 @@ class TenancyForm(forms.Form):
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
- required=False,
- widget=APISelect(
- api_url='/api/tenancy/tenants/'
- )
+ required=False
)
def __init__(self, *args, **kwargs):
@@ -164,7 +156,6 @@ class TenancyFilterForm(forms.Form):
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url="/api/tenancy/tenant-groups/",
value_field="slug",
null_option=True,
filter_for={
@@ -177,7 +168,6 @@ class TenancyFilterForm(forms.Form):
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url="/api/tenancy/tenants/",
value_field="slug",
null_option=True,
)
diff --git a/netbox/tenancy/migrations/0001_initial_squashed_0005_change_logging.py b/netbox/tenancy/migrations/0001_initial_squashed_0005_change_logging.py
deleted file mode 100644
index 664ea5d1b..000000000
--- a/netbox/tenancy/migrations/0001_initial_squashed_0005_change_logging.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import django.db.models.deletion
-import taggit.managers
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- replaces = [('tenancy', '0001_initial'), ('tenancy', '0002_tenant_group_optional'), ('tenancy', '0003_unicode_literals'), ('tenancy', '0004_tags'), ('tenancy', '0005_change_logging')]
-
- dependencies = [
- ('taggit', '0002_auto_20150616_2121'),
- ]
-
- operations = [
- migrations.CreateModel(
- name='TenantGroup',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=50, unique=True)),
- ('slug', models.SlugField(unique=True)),
- ('created', models.DateField(auto_now_add=True, null=True)),
- ('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ],
- options={
- 'ordering': ['name'],
- },
- ),
- migrations.CreateModel(
- name='Tenant',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created', models.DateField(auto_now_add=True, null=True)),
- ('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('name', models.CharField(max_length=30, unique=True)),
- ('slug', models.SlugField(unique=True)),
- ('description', models.CharField(blank=True, help_text='Long-form name (optional)', max_length=100)),
- ('comments', models.TextField(blank=True)),
- ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tenants', to='tenancy.TenantGroup')),
- ('tags', taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')),
- ],
- options={
- 'ordering': ['group', 'name'],
- },
- ),
- ]
diff --git a/netbox/tenancy/migrations/0007_nested_tenantgroups.py b/netbox/tenancy/migrations/0007_nested_tenantgroups.py
new file mode 100644
index 000000000..4278b3409
--- /dev/null
+++ b/netbox/tenancy/migrations/0007_nested_tenantgroups.py
@@ -0,0 +1,43 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import mptt.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tenancy', '0006_custom_tag_models'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenantgroup',
+ name='parent',
+ field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='tenancy.TenantGroup'),
+ ),
+ migrations.AddField(
+ model_name='tenantgroup',
+ name='level',
+ field=models.PositiveIntegerField(default=0, editable=False),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='tenantgroup',
+ name='lft',
+ field=models.PositiveIntegerField(default=1, editable=False),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='tenantgroup',
+ name='rght',
+ field=models.PositiveIntegerField(default=2, editable=False),
+ preserve_default=False,
+ ),
+ # tree_id will be set to a valid value during the following migration (which needs to be a separate migration)
+ migrations.AddField(
+ model_name='tenantgroup',
+ name='tree_id',
+ field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
+ preserve_default=False,
+ ),
+ ]
diff --git a/netbox/tenancy/migrations/0008_nested_tenantgroups_rebuild.py b/netbox/tenancy/migrations/0008_nested_tenantgroups_rebuild.py
new file mode 100644
index 000000000..e31a75d36
--- /dev/null
+++ b/netbox/tenancy/migrations/0008_nested_tenantgroups_rebuild.py
@@ -0,0 +1,21 @@
+from django.db import migrations
+
+
+def rebuild_mptt(apps, schema_editor):
+ TenantGroup = apps.get_model('tenancy', 'TenantGroup')
+ for i, tenantgroup in enumerate(TenantGroup.objects.all(), start=1):
+ TenantGroup.objects.filter(pk=tenantgroup.pk).update(tree_id=i)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tenancy', '0007_nested_tenantgroups'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=rebuild_mptt,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/tenancy/migrations/0009_standardize_description.py b/netbox/tenancy/migrations/0009_standardize_description.py
new file mode 100644
index 000000000..0f65ced04
--- /dev/null
+++ b/netbox/tenancy/migrations/0009_standardize_description.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.3 on 2020-03-13 20:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tenancy', '0008_nested_tenantgroups_rebuild'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenantgroup',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AlterField(
+ model_name='tenant',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ ]
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py
index 9fa7f23ea..077fb6ad1 100644
--- a/netbox/tenancy/models.py
+++ b/netbox/tenancy/models.py
@@ -1,10 +1,13 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
+from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
-from extras.models import CustomFieldModel, TaggedItem
+from extras.models import CustomFieldModel, ObjectChange, TaggedItem
+from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
+from utilities.utils import serialize_object
__all__ = (
@@ -13,7 +16,7 @@ __all__ = (
)
-class TenantGroup(ChangeLoggedModel):
+class TenantGroup(MPTTModel, ChangeLoggedModel):
"""
An arbitrary collection of Tenants.
"""
@@ -24,12 +27,27 @@ class TenantGroup(ChangeLoggedModel):
slug = models.SlugField(
unique=True
)
+ parent = TreeForeignKey(
+ to='self',
+ on_delete=models.CASCADE,
+ related_name='children',
+ blank=True,
+ null=True,
+ db_index=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
- csv_headers = ['name', 'slug']
+ csv_headers = ['name', 'slug', 'parent', 'description']
class Meta:
ordering = ['name']
+ class MPTTMeta:
+ order_insertion_by = ['name']
+
def __str__(self):
return self.name
@@ -40,9 +58,21 @@ class TenantGroup(ChangeLoggedModel):
return (
self.name,
self.slug,
+ self.parent.name if self.parent else '',
+ self.description,
+ )
+
+ def to_objectchange(self, action):
+ # Remove MPTT-internal fields
+ return ObjectChange(
+ changed_object=self,
+ object_repr=str(self),
+ action=action,
+ object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
)
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Tenant(ChangeLoggedModel, CustomFieldModel):
"""
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
@@ -63,9 +93,8 @@ class Tenant(ChangeLoggedModel, CustomFieldModel):
null=True
)
description = models.CharField(
- max_length=100,
- blank=True,
- help_text='Long-form name (optional)'
+ max_length=200,
+ blank=True
)
comments = models.TextField(
blank=True
diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py
index af4fb34c0..147a20707 100644
--- a/netbox/tenancy/tables.py
+++ b/netbox/tenancy/tables.py
@@ -1,8 +1,18 @@
import django_tables2 as tables
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, TagColumn, ToggleColumn
from .models import Tenant, TenantGroup
+MPTT_LINK = """
+{% if record.get_children %}
+
+{% else %}
+
+{% endif %}
+ {{ record.name }}
+
+"""
+
TENANTGROUP_ACTIONS = """
@@ -27,16 +37,23 @@ COL_TENANT = """
class TenantGroupTable(BaseTable):
pk = ToggleColumn()
- name = tables.LinkColumn(verbose_name='Name')
- tenant_count = tables.Column(verbose_name='Tenants')
- slug = tables.Column(verbose_name='Slug')
+ name = tables.TemplateColumn(
+ template_code=MPTT_LINK,
+ orderable=False
+ )
+ tenant_count = tables.Column(
+ verbose_name='Tenants'
+ )
actions = tables.TemplateColumn(
- template_code=TENANTGROUP_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name=''
+ template_code=TENANTGROUP_ACTIONS,
+ attrs={'td': {'class': 'text-right noprint'}},
+ verbose_name=''
)
class Meta(BaseTable.Meta):
model = TenantGroup
- fields = ('pk', 'name', 'tenant_count', 'slug', 'actions')
+ fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'actions')
+ default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
#
@@ -46,7 +63,11 @@ class TenantGroupTable(BaseTable):
class TenantTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
+ tags = TagColumn(
+ url_name='tenancy:tenant_list'
+ )
class Meta(BaseTable.Meta):
model = Tenant
- fields = ('pk', 'name', 'group', 'description')
+ fields = ('pk', 'name', 'slug', 'group', 'description', 'tags')
+ default_columns = ('pk', 'name', 'group', 'description')
diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py
index 495cb250d..8da3d7594 100644
--- a/netbox/tenancy/tests/test_api.py
+++ b/netbox/tenancy/tests/test_api.py
@@ -14,13 +14,6 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
- def test_choices(self):
-
- url = reverse('tenancy-api:field-choice-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.status_code, 200)
-
class TenantGroupTest(APITestCase):
@@ -28,23 +21,34 @@ class TenantGroupTest(APITestCase):
super().setUp()
- self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1')
- self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2')
- self.tenantgroup3 = TenantGroup.objects.create(name='Test Tenant Group 3', slug='test-tenant-group-3')
+ self.parent_tenant_groups = (
+ TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
+ TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'),
+ )
+ for tenantgroup in self.parent_tenant_groups:
+ tenantgroup.save()
+
+ self.tenant_groups = (
+ TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=self.parent_tenant_groups[0]),
+ TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=self.parent_tenant_groups[0]),
+ TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=self.parent_tenant_groups[0]),
+ )
+ for tenantgroup in self.tenant_groups:
+ tenantgroup.save()
def test_get_tenantgroup(self):
- url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
+ url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
response = self.client.get(url, **self.header)
- self.assertEqual(response.data['name'], self.tenantgroup1.name)
+ self.assertEqual(response.data['name'], self.tenant_groups[0].name)
def test_list_tenantgroups(self):
url = reverse('tenancy-api:tenantgroup-list')
response = self.client.get(url, **self.header)
- self.assertEqual(response.data['count'], 3)
+ self.assertEqual(response.data['count'], 5)
def test_list_tenantgroups_brief(self):
@@ -59,33 +63,38 @@ class TenantGroupTest(APITestCase):
def test_create_tenantgroup(self):
data = {
- 'name': 'Test Tenant Group 4',
- 'slug': 'test-tenant-group-4',
+ 'name': 'Tenant Group 4',
+ 'slug': 'tenant-group-4',
+ 'parent': self.parent_tenant_groups[0].pk,
}
url = reverse('tenancy-api:tenantgroup-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(TenantGroup.objects.count(), 4)
+ self.assertEqual(TenantGroup.objects.count(), 6)
tenantgroup4 = TenantGroup.objects.get(pk=response.data['id'])
self.assertEqual(tenantgroup4.name, data['name'])
self.assertEqual(tenantgroup4.slug, data['slug'])
+ self.assertEqual(tenantgroup4.parent_id, data['parent'])
def test_create_tenantgroup_bulk(self):
data = [
{
- 'name': 'Test Tenant Group 4',
- 'slug': 'test-tenant-group-4',
+ 'name': 'Tenant Group 4',
+ 'slug': 'tenant-group-4',
+ 'parent': self.parent_tenant_groups[0].pk,
},
{
- 'name': 'Test Tenant Group 5',
- 'slug': 'test-tenant-group-5',
+ 'name': 'Tenant Group 5',
+ 'slug': 'tenant-group-5',
+ 'parent': self.parent_tenant_groups[0].pk,
},
{
- 'name': 'Test Tenant Group 6',
- 'slug': 'test-tenant-group-6',
+ 'name': 'Tenant Group 6',
+ 'slug': 'tenant-group-6',
+ 'parent': self.parent_tenant_groups[0].pk,
},
]
@@ -93,7 +102,7 @@ class TenantGroupTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(TenantGroup.objects.count(), 6)
+ self.assertEqual(TenantGroup.objects.count(), 8)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
@@ -101,26 +110,28 @@ class TenantGroupTest(APITestCase):
def test_update_tenantgroup(self):
data = {
- 'name': 'Test Tenant Group X',
- 'slug': 'test-tenant-group-x',
+ 'name': 'Tenant Group X',
+ 'slug': 'tenant-group-x',
+ 'parent': self.parent_tenant_groups[1].pk,
}
- url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
+ url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(TenantGroup.objects.count(), 3)
+ self.assertEqual(TenantGroup.objects.count(), 5)
tenantgroup1 = TenantGroup.objects.get(pk=response.data['id'])
self.assertEqual(tenantgroup1.name, data['name'])
self.assertEqual(tenantgroup1.slug, data['slug'])
+ self.assertEqual(tenantgroup1.parent_id, data['parent'])
def test_delete_tenantgroup(self):
- url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
+ url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(TenantGroup.objects.count(), 2)
+ self.assertEqual(TenantGroup.objects.count(), 4)
class TenantTest(APITestCase):
@@ -129,18 +140,26 @@ class TenantTest(APITestCase):
super().setUp()
- self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1')
- self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2')
- self.tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1', group=self.tenantgroup1)
- self.tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2', group=self.tenantgroup1)
- self.tenant3 = Tenant.objects.create(name='Test Tenant 3', slug='test-tenant-3', group=self.tenantgroup1)
+ self.tenant_groups = (
+ TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
+ TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
+ )
+ for tenantgroup in self.tenant_groups:
+ tenantgroup.save()
+
+ self.tenants = (
+ Tenant(name='Test Tenant 1', slug='test-tenant-1', group=self.tenant_groups[0]),
+ Tenant(name='Test Tenant 2', slug='test-tenant-2', group=self.tenant_groups[0]),
+ Tenant(name='Test Tenant 3', slug='test-tenant-3', group=self.tenant_groups[0]),
+ )
+ Tenant.objects.bulk_create(self.tenants)
def test_get_tenant(self):
- url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
+ url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
response = self.client.get(url, **self.header)
- self.assertEqual(response.data['name'], self.tenant1.name)
+ self.assertEqual(response.data['name'], self.tenants[0].name)
def test_list_tenants(self):
@@ -164,7 +183,7 @@ class TenantTest(APITestCase):
data = {
'name': 'Test Tenant 4',
'slug': 'test-tenant-4',
- 'group': self.tenantgroup1.pk,
+ 'group': self.tenant_groups[0].pk,
}
url = reverse('tenancy-api:tenant-list')
@@ -208,10 +227,10 @@ class TenantTest(APITestCase):
data = {
'name': 'Test Tenant X',
'slug': 'test-tenant-x',
- 'group': self.tenantgroup2.pk,
+ 'group': self.tenant_groups[1].pk,
}
- url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
+ url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
@@ -223,7 +242,7 @@ class TenantTest(APITestCase):
def test_delete_tenant(self):
- url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
+ url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
diff --git a/netbox/tenancy/tests/test_filters.py b/netbox/tenancy/tests/test_filters.py
index 300363c83..c78b25083 100644
--- a/netbox/tenancy/tests/test_filters.py
+++ b/netbox/tenancy/tests/test_filters.py
@@ -11,16 +11,24 @@ class TenantGroupTestCase(TestCase):
@classmethod
def setUpTestData(cls):
- groups = (
- TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
- TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
- TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
+ parent_tenant_groups = (
+ TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
+ TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'),
+ TenantGroup(name='Parent Tenant Group 3', slug='parent-tenant-group-3'),
)
- TenantGroup.objects.bulk_create(groups)
+ for tenantgroup in parent_tenant_groups:
+ tenantgroup.save()
+
+ tenant_groups = (
+ TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0], description='A'),
+ TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[1], description='B'),
+ TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[2], description='C'),
+ )
+ for tenantgroup in tenant_groups:
+ tenantgroup.save()
def test_id(self):
- id_list = self.queryset.values_list('id', flat=True)[:2]
- params = {'id': [str(id) for id in id_list]}
+ params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
@@ -31,6 +39,17 @@ class TenantGroupTestCase(TestCase):
params = {'slug': ['tenant-group-1', 'tenant-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 = TenantGroup.objects.filter(name__startswith='Parent')[:2]
+ params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class TenantTestCase(TestCase):
queryset = Tenant.objects.all()
@@ -39,20 +58,25 @@ class TenantTestCase(TestCase):
@classmethod
def setUpTestData(cls):
- groups = (
+ tenant_groups = (
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
)
- TenantGroup.objects.bulk_create(groups)
+ for tenantgroup in tenant_groups:
+ tenantgroup.save()
tenants = (
- Tenant(name='Tenant 1', slug='tenant-1', group=groups[0]),
- Tenant(name='Tenant 2', slug='tenant-2', group=groups[1]),
- Tenant(name='Tenant 3', slug='tenant-3', group=groups[2]),
+ Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
+ Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
+ Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
+ def test_id(self):
+ params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_name(self):
params = {'name': ['Tenant 1', 'Tenant 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -61,11 +85,6 @@ class TenantTestCase(TestCase):
params = {'slug': ['tenant-1', 'tenant-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_id__in(self):
- id_list = self.queryset.values_list('id', flat=True)[:2]
- params = {'id__in': ','.join([str(id) for id in id_list])}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
def test_group(self):
group = TenantGroup.objects.all()[:2]
params = {'group_id': [group[0].pk, group[1].pk]}
diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py
index 27e2c1591..ca2c2633f 100644
--- a/netbox/tenancy/tests/test_views.py
+++ b/netbox/tenancy/tests/test_views.py
@@ -8,22 +8,25 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod
def setUpTestData(cls):
- TenantGroup.objects.bulk_create([
+ tenant_groups = (
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
- ])
+ )
+ for tenanantgroup in tenant_groups:
+ tenanantgroup.save()
cls.form_data = {
'name': 'Tenant Group X',
'slug': 'tenant-group-x',
+ 'description': 'A new tenant group',
}
cls.csv_data = (
- "name,slug",
- "Tenant Group 4,tenant-group-4",
- "Tenant Group 5,tenant-group-5",
- "Tenant Group 6,tenant-group-6",
+ "name,slug,description",
+ "Tenant Group 4,tenant-group-4,Fourth tenant group",
+ "Tenant Group 5,tenant-group-5,Fifth tenant group",
+ "Tenant Group 6,tenant-group-6,Sixth tenant group",
)
@@ -33,22 +36,23 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
def setUpTestData(cls):
- tenantgroups = (
+ tenant_groups = (
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
)
- TenantGroup.objects.bulk_create(tenantgroups)
+ for tenanantgroup in tenant_groups:
+ tenanantgroup.save()
Tenant.objects.bulk_create([
- Tenant(name='Tenant 1', slug='tenant-1', group=tenantgroups[0]),
- Tenant(name='Tenant 2', slug='tenant-2', group=tenantgroups[0]),
- Tenant(name='Tenant 3', slug='tenant-3', group=tenantgroups[0]),
+ Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
+ Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[0]),
+ Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]),
])
cls.form_data = {
'name': 'Tenant X',
'slug': 'tenant-x',
- 'group': tenantgroups[1].pk,
+ 'group': tenant_groups[1].pk,
'description': 'A new tenant',
'comments': 'Some comments',
'tags': 'Alpha,Bravo,Charlie',
@@ -62,5 +66,5 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
cls.bulk_edit_data = {
- 'group': tenantgroups[1].pk,
+ 'group': tenant_groups[1].pk,
}
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 0319a20b0..afc363cd6 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -20,7 +20,13 @@ from .models import Tenant, TenantGroup
class TenantGroupListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'tenancy.view_tenantgroup'
- queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
+ queryset = TenantGroup.objects.add_related_count(
+ TenantGroup.objects.all(),
+ Tenant,
+ 'group',
+ 'tenant_count',
+ cumulative=True
+ )
table = tables.TenantGroupTable
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 4549945bf..42e651712 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -3,18 +3,25 @@ from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_
from django.contrib.auth.models import User
-from netbox.admin import admin_site
-from .models import Token
+from .models import Token, UserConfig
# Unregister the built-in UserAdmin so that we can use our custom admin view below
-admin_site.unregister(User)
+admin.site.unregister(User)
-@admin.register(User, site=admin_site)
+class UserConfigInline(admin.TabularInline):
+ model = UserConfig
+ readonly_fields = ('data',)
+ can_delete = False
+ verbose_name = 'Preferences'
+
+
+@admin.register(User)
class UserAdmin(UserAdmin_):
list_display = [
'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
]
+ inlines = (UserConfigInline,)
class TokenAdminForm(forms.ModelForm):
@@ -30,7 +37,7 @@ class TokenAdminForm(forms.ModelForm):
model = Token
-@admin.register(Token, site=admin_site)
+@admin.register(Token)
class TokenAdmin(admin.ModelAdmin):
form = TokenAdminForm
list_display = [
diff --git a/netbox/users/migrations/0001_api_tokens_squashed_0003_token_permissions.py b/netbox/users/migrations/0001_api_tokens_squashed_0003_token_permissions.py
deleted file mode 100644
index 1053dcd7a..000000000
--- a/netbox/users/migrations/0001_api_tokens_squashed_0003_token_permissions.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import django.core.validators
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- replaces = [('users', '0001_api_tokens'), ('users', '0002_unicode_literals'), ('users', '0003_token_permissions')]
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.CreateModel(
- name='Token',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created', models.DateTimeField(auto_now_add=True)),
- ('expires', models.DateTimeField(blank=True, null=True)),
- ('key', models.CharField(max_length=40, unique=True, validators=[django.core.validators.MinLengthValidator(40)])),
- ('write_enabled', models.BooleanField(default=True, help_text='Permit create/update/delete operations using this key')),
- ('description', models.CharField(blank=True, max_length=100)),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
- ],
- options={
- 'default_permissions': [],
- },
- ),
- migrations.AlterModelOptions(
- name='token',
- options={},
- ),
- ]
diff --git a/netbox/users/migrations/0004_standardize_description.py b/netbox/users/migrations/0004_standardize_description.py
new file mode 100644
index 000000000..b1f45666f
--- /dev/null
+++ b/netbox/users/migrations/0004_standardize_description.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.3 on 2020-03-13 20:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0003_token_permissions'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='token',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ ]
diff --git a/netbox/users/migrations/0005_userconfig.py b/netbox/users/migrations/0005_userconfig.py
new file mode 100644
index 000000000..f8dc64fc3
--- /dev/null
+++ b/netbox/users/migrations/0005_userconfig.py
@@ -0,0 +1,28 @@
+from django.conf import settings
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('users', '0004_standardize_description'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UserConfig',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('data', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='config', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['user'],
+ 'verbose_name': 'User Preferences',
+ 'verbose_name_plural': 'User Preferences'
+ },
+ ),
+ ]
diff --git a/netbox/users/migrations/0006_create_userconfigs.py b/netbox/users/migrations/0006_create_userconfigs.py
new file mode 100644
index 000000000..397bfdb24
--- /dev/null
+++ b/netbox/users/migrations/0006_create_userconfigs.py
@@ -0,0 +1,27 @@
+from django.contrib.auth import get_user_model
+from django.db import migrations
+
+
+def create_userconfigs(apps, schema_editor):
+ """
+ Create an empty UserConfig instance for each existing User.
+ """
+ User = get_user_model()
+ UserConfig = apps.get_model('users', 'UserConfig')
+ UserConfig.objects.bulk_create(
+ [UserConfig(user_id=user.pk) for user in User.objects.all()]
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0005_userconfig'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=create_userconfigs,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index cf0d826b5..ea5762232 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -2,16 +2,142 @@ import binascii
import os
from django.contrib.auth.models import User
+from django.contrib.postgres.fields import JSONField
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 utilities.utils import flatten_dict
+
__all__ = (
'Token',
+ 'UserConfig',
)
+class UserConfig(models.Model):
+ """
+ This model stores arbitrary user-specific preferences in a JSON data structure.
+ """
+ user = models.OneToOneField(
+ to=User,
+ on_delete=models.CASCADE,
+ related_name='config'
+ )
+ data = JSONField(
+ default=dict
+ )
+
+ class Meta:
+ ordering = ['user']
+ verbose_name = verbose_name_plural = 'User Preferences'
+
+ def get(self, path, default=None):
+ """
+ Retrieve a configuration parameter specified by its dotted path. Example:
+
+ userconfig.get('foo.bar.baz')
+
+ :param path: Dotted path to the configuration key. For example, 'foo.bar' returns self.data['foo']['bar'].
+ :param default: Default value to return for a nonexistent key (default: None).
+ """
+ d = self.data
+ keys = path.split('.')
+
+ # Iterate down the hierarchy, returning the default value if any invalid key is encountered
+ for key in keys:
+ if type(d) is dict and key in d:
+ d = d.get(key)
+ else:
+ return default
+
+ return d
+
+ def all(self):
+ """
+ Return a dictionary of all defined keys and their values.
+ """
+ return flatten_dict(self.data)
+
+ def set(self, path, value, commit=False):
+ """
+ Define or overwrite a configuration parameter. Example:
+
+ userconfig.set('foo.bar.baz', 123)
+
+ Leaf nodes (those which are not dictionaries of other nodes) cannot be overwritten as dictionaries. Similarly,
+ branch nodes (dictionaries) cannot be overwritten as single values. (A TypeError exception will be raised.) In
+ both cases, the existing key must first be cleared. This safeguard is in place to help avoid inadvertently
+ overwriting the wrong key.
+
+ :param path: Dotted path to the configuration key. For example, 'foo.bar' sets self.data['foo']['bar'].
+ :param value: The value to be written. This can be any type supported by JSON.
+ :param commit: If true, the UserConfig instance will be saved once the new value has been applied.
+ """
+ d = self.data
+ keys = path.split('.')
+
+ # Iterate through the hierarchy to find the key we're setting. Raise TypeError if we encounter any
+ # interim leaf nodes (keys which do not contain dictionaries).
+ for i, key in enumerate(keys[:-1]):
+ if key in d and type(d[key]) is dict:
+ d = d[key]
+ elif key in d:
+ err_path = '.'.join(path.split('.')[:i + 1])
+ raise TypeError(f"Key '{err_path}' is a leaf node; cannot assign new keys")
+ else:
+ d = d.setdefault(key, {})
+
+ # Set a key based on the last item in the path. Raise TypeError if attempting to overwrite a non-leaf node.
+ key = keys[-1]
+ if key in d and type(d[key]) is dict:
+ raise TypeError(f"Key '{path}' has child keys; cannot assign a value")
+ else:
+ d[key] = value
+
+ if commit:
+ self.save()
+
+ def clear(self, path, commit=False):
+ """
+ Delete a configuration parameter specified by its dotted path. The key and any child keys will be deleted.
+ Example:
+
+ userconfig.clear('foo.bar.baz')
+
+ Invalid keys will be ignored silently.
+
+ :param path: Dotted path to the configuration key. For example, 'foo.bar' deletes self.data['foo']['bar'].
+ :param commit: If true, the UserConfig instance will be saved once the new value has been applied.
+ """
+ d = self.data
+ keys = path.split('.')
+
+ for key in keys[:-1]:
+ if key not in d:
+ break
+ if type(d[key]) is dict:
+ d = d[key]
+
+ key = keys[-1]
+ d.pop(key, None) # Avoid a KeyError on invalid keys
+
+ if commit:
+ self.save()
+
+
+@receiver(post_save, sender=User)
+def create_userconfig(instance, created, **kwargs):
+ """
+ Automatically create a new UserConfig when a new User is created.
+ """
+ if created:
+ UserConfig(user=instance).save()
+
+
class Token(models.Model):
"""
An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens.
@@ -39,7 +165,7 @@ class Token(models.Model):
help_text='Permit create/update/delete operations using this key'
)
description = models.CharField(
- max_length=100,
+ max_length=200,
blank=True
)
diff --git a/netbox/users/tests/__init__.py b/netbox/users/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py
new file mode 100644
index 000000000..8047796c4
--- /dev/null
+++ b/netbox/users/tests/test_models.py
@@ -0,0 +1,108 @@
+from django.contrib.auth.models import User
+from django.test import TestCase
+
+from users.models import UserConfig
+
+
+class UserConfigTest(TestCase):
+
+ def setUp(self):
+
+ user = User.objects.create_user(username='testuser')
+ user.config.data = {
+ 'a': True,
+ 'b': {
+ 'foo': 101,
+ 'bar': 102,
+ },
+ 'c': {
+ 'foo': {
+ 'x': 201,
+ },
+ 'bar': {
+ 'y': 202,
+ },
+ 'baz': {
+ 'z': 203,
+ }
+ }
+ }
+ user.config.save()
+
+ self.userconfig = user.config
+
+ def test_get(self):
+ userconfig = self.userconfig
+
+ # Retrieve root and nested values
+ self.assertEqual(userconfig.get('a'), True)
+ self.assertEqual(userconfig.get('b.foo'), 101)
+ self.assertEqual(userconfig.get('c.baz.z'), 203)
+
+ # Invalid values should return None
+ self.assertIsNone(userconfig.get('invalid'))
+ self.assertIsNone(userconfig.get('a.invalid'))
+ self.assertIsNone(userconfig.get('b.foo.invalid'))
+ self.assertIsNone(userconfig.get('b.foo.x.invalid'))
+
+ # Invalid values with a provided default should return the default
+ self.assertEqual(userconfig.get('invalid', 'DEFAULT'), 'DEFAULT')
+ self.assertEqual(userconfig.get('a.invalid', 'DEFAULT'), 'DEFAULT')
+ self.assertEqual(userconfig.get('b.foo.invalid', 'DEFAULT'), 'DEFAULT')
+ self.assertEqual(userconfig.get('b.foo.x.invalid', 'DEFAULT'), 'DEFAULT')
+
+ def test_all(self):
+ userconfig = self.userconfig
+ flattened_data = {
+ 'a': True,
+ 'b.foo': 101,
+ 'b.bar': 102,
+ 'c.foo.x': 201,
+ 'c.bar.y': 202,
+ 'c.baz.z': 203,
+ }
+
+ # Retrieve a flattened dictionary containing all config data
+ self.assertEqual(userconfig.all(), flattened_data)
+
+ def test_set(self):
+ userconfig = self.userconfig
+
+ # Overwrite existing values
+ userconfig.set('a', 'abc')
+ userconfig.set('c.foo.x', 'abc')
+ self.assertEqual(userconfig.data['a'], 'abc')
+ self.assertEqual(userconfig.data['c']['foo']['x'], 'abc')
+
+ # Create new values
+ userconfig.set('d', 'abc')
+ userconfig.set('b.baz', 'abc')
+ self.assertEqual(userconfig.data['d'], 'abc')
+ self.assertEqual(userconfig.data['b']['baz'], 'abc')
+
+ # Set a value and commit to the database
+ userconfig.set('a', 'def', commit=True)
+
+ userconfig.refresh_from_db()
+ self.assertEqual(userconfig.data['a'], 'def')
+
+ # Attempt to change a branch node to a leaf node
+ with self.assertRaises(TypeError):
+ userconfig.set('b', 1)
+
+ # Attempt to change a leaf node to a branch node
+ with self.assertRaises(TypeError):
+ userconfig.set('a.x', 1)
+
+ def test_clear(self):
+ userconfig = self.userconfig
+
+ # Clear existing values
+ userconfig.clear('a')
+ userconfig.clear('b.foo')
+ self.assertTrue('a' not in userconfig.data)
+ self.assertTrue('foo' not in userconfig.data['b'])
+ self.assertEqual(userconfig.data['b']['bar'], 102)
+
+ # Clear a non-existing value; should fail silently
+ userconfig.clear('invalid')
diff --git a/netbox/users/urls.py b/netbox/users/urls.py
index dae540726..b8b16cdf8 100644
--- a/netbox/users/urls.py
+++ b/netbox/users/urls.py
@@ -6,6 +6,7 @@ app_name = 'user'
urlpatterns = [
path('profile/', views.ProfileView.as_view(), name='profile'),
+ path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
diff --git a/netbox/users/views.py b/netbox/users/views.py
index 6a2410274..c3e366542 100644
--- a/netbox/users/views.py
+++ b/netbox/users/views.py
@@ -1,3 +1,5 @@
+import logging
+
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
@@ -24,6 +26,9 @@ from .models import Token
#
class LoginView(View):
+ """
+ Perform user authentication via the web UI.
+ """
template_name = 'login.html'
@method_decorator(sensitive_post_parameters('password'))
@@ -38,36 +43,51 @@ class LoginView(View):
})
def post(self, request):
+ logger = logging.getLogger('netbox.auth.login')
form = LoginForm(request, data=request.POST)
+
if form.is_valid():
+ logger.debug("Login form validation was successful")
# Determine where to direct user after successful login
- redirect_to = request.POST.get('next', '')
- if not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
+ redirect_to = request.POST.get('next')
+ if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
+ logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
redirect_to = reverse('home')
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
# last_login time upon authentication.
if settings.MAINTENANCE_MODE:
+ logger.warning("Maintenance mode enabled: disabling update of most recent login time")
user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
# Authenticate user
auth_login(request, form.get_user())
+ logger.info(f"User {request.user} successfully authenticated")
messages.info(request, "Logged in as {}.".format(request.user))
+ logger.debug(f"Redirecting user to {redirect_to}")
return HttpResponseRedirect(redirect_to)
+ else:
+ logger.debug("Login form validation failed")
+
return render(request, self.template_name, {
'form': form,
})
class LogoutView(View):
-
+ """
+ Deauthenticate a web user.
+ """
def get(self, request):
+ logger = logging.getLogger('netbox.auth.logout')
# Log out the user
+ username = request.user
auth_logout(request)
+ logger.info(f"User {username} has logged out")
messages.info(request, "You have logged out.")
# Delete session key cookie (if set) upon logout
@@ -91,6 +111,30 @@ class ProfileView(LoginRequiredMixin, View):
})
+class UserConfigView(LoginRequiredMixin, View):
+ template_name = 'users/preferences.html'
+
+ def get(self, request):
+
+ return render(request, self.template_name, {
+ 'preferences': request.user.config.all(),
+ 'active_tab': 'preferences',
+ })
+
+ def post(self, request):
+ userconfig = request.user.config
+ data = userconfig.all()
+
+ # Delete selected preferences
+ for key in request.POST.getlist('pk'):
+ if key in data:
+ userconfig.clear(key)
+ userconfig.save()
+ messages.success(request, "Your preferences have been updated.")
+
+ return redirect('user:preferences')
+
+
class ChangePasswordView(LoginRequiredMixin, View):
template_name = 'users/change_password.html'
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 72a5735de..205055669 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -1,3 +1,4 @@
+import logging
from collections import OrderedDict
import pytz
@@ -234,6 +235,7 @@ class ValidatedModelSerializer(ModelSerializer):
for k, v in attrs.items():
setattr(instance, k, v)
instance.clean()
+ instance.validate_unique()
return data
@@ -303,25 +305,35 @@ class ModelViewSet(_ModelViewSet):
return super().get_serializer(*args, **kwargs)
def get_serializer_class(self):
+ logger = logging.getLogger('netbox.api.views.ModelViewSet')
# If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one
# exists
request = self.get_serializer_context()['request']
- if request.query_params.get('brief', False):
+ if request.query_params.get('brief'):
+ logger.debug("Request is for 'brief' format; initializing nested serializer")
try:
- return get_serializer_for_model(self.queryset.model, prefix='Nested')
+ serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
+ logger.debug(f"Using serializer {serializer}")
+ return serializer
except SerializerNotFound:
pass
# Fall back to the hard-coded serializer class
+ logger.debug(f"Using serializer {self.serializer_class}")
return self.serializer_class
def dispatch(self, request, *args, **kwargs):
+ logger = logging.getLogger('netbox.api.views.ModelViewSet')
+
try:
return super().dispatch(request, *args, **kwargs)
except ProtectedError as e:
- models = ['{} ({})'.format(o, o._meta) for o in e.protected_objects.all()]
+ models = [
+ '{} ({})'.format(o, o._meta) for o in e.protected_objects.all()
+ ]
msg = 'Unable to delete object. The following dependent objects were found: {}'.format(', '.join(models))
+ logger.warning(msg)
return self.finalize_response(
request,
Response({'detail': msg}, status=409),
@@ -341,48 +353,22 @@ class ModelViewSet(_ModelViewSet):
"""
return super().retrieve(*args, **kwargs)
+ #
+ # Logging
+ #
-class FieldChoicesViewSet(ViewSet):
- """
- Expose the built-in numeric values which represent static choices for a model's field.
- """
- permission_classes = [IsAuthenticatedOrLoginNotRequired]
- fields = []
+ def perform_create(self, serializer):
+ model = serializer.child.Meta.model if hasattr(serializer, 'many') else serializer.Meta.model
+ logger = logging.getLogger('netbox.api.views.ModelViewSet')
+ logger.info(f"Creating new {model._meta.verbose_name}")
+ return super().perform_create(serializer)
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
+ def perform_update(self, serializer):
+ logger = logging.getLogger('netbox.api.views.ModelViewSet')
+ logger.info(f"Updating {serializer.instance} (PK: {serializer.instance.pk})")
+ return super().perform_update(serializer)
- # Compile a dict of all fields in this view
- self._fields = OrderedDict()
- for serializer_class, field_list in self.fields:
- for field_name in field_list:
-
- model_name = serializer_class.Meta.model._meta.verbose_name
- key = ':'.join([model_name.lower().replace(' ', '-'), field_name])
- serializer = serializer_class()
- choices = []
-
- for k, v in serializer.get_fields()[field_name].choices.items():
- if type(v) in [list, tuple]:
- for k2, v2 in v:
- choices.append({
- 'value': k2,
- 'label': v2,
- })
- else:
- choices.append({
- 'value': k,
- 'label': v,
- })
- self._fields[key] = choices
-
- def list(self, request):
- return Response(self._fields)
-
- def retrieve(self, request, pk):
- if pk not in self._fields:
- raise Http404
- return Response(self._fields[pk])
-
- def get_view_name(self):
- return "Field Choices"
+ def perform_destroy(self, instance):
+ logger = logging.getLogger('netbox.api.views.ModelViewSet')
+ logger.info(f"Deleting {instance} (PK: {instance.pk})")
+ return super().perform_destroy(instance)
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 54541b0b5..6342bad2b 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -1,5 +1,8 @@
+import logging
+
from django.conf import settings
-from django.contrib.auth.backends import ModelBackend
+from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_
+from django.contrib.auth.models import Group, Permission
class ViewExemptModelBackend(ModelBackend):
@@ -26,3 +29,45 @@ class ViewExemptModelBackend(ModelBackend):
pass
return super().has_perm(user_obj, perm, obj)
+
+
+class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_):
+ """
+ Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization.
+ """
+ @property
+ def create_unknown_user(self):
+ return settings.REMOTE_AUTH_AUTO_CREATE_USER
+
+ def configure_user(self, request, user):
+ logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+
+ # Assign default groups to the user
+ group_list = []
+ for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
+ try:
+ group_list.append(Group.objects.get(name=name))
+ except Group.DoesNotExist:
+ logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
+ if group_list:
+ user.groups.add(*group_list)
+ logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}")
+
+ # Assign default permissions to the user
+ permissions_list = []
+ for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS:
+ try:
+ app_label, codename = permission_name.split('.')
+ permissions_list.append(
+ Permission.objects.get(content_type__app_label=app_label, codename=codename)
+ )
+ except (ValueError, Permission.DoesNotExist):
+ logging.error(
+ "Invalid permission name: '{permission_name}'. Permissions must be in the form "
+ "._. (Example: dcim.add_site)"
+ )
+ if permissions_list:
+ user.user_permissions.add(*permissions_list)
+ logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
+
+ return user
diff --git a/netbox/utilities/background_tasks.py b/netbox/utilities/background_tasks.py
new file mode 100644
index 000000000..79633f47f
--- /dev/null
+++ b/netbox/utilities/background_tasks.py
@@ -0,0 +1,52 @@
+import logging
+
+import requests
+from cacheops.simple import cache, CacheMiss
+from django.conf import settings
+from django_rq import job
+from packaging import version
+
+# Get an instance of a logger
+logger = logging.getLogger('netbox.releases')
+
+
+@job('check_releases')
+def get_releases(pre_releases=False):
+ url = settings.RELEASE_CHECK_URL
+ headers = {
+ 'Accept': 'application/vnd.github.v3+json',
+ }
+ releases = []
+
+ # Check whether this URL has failed recently and shouldn't be retried yet
+ try:
+ if url == cache.get('latest_release_no_retry'):
+ logger.info("Skipping release check; URL failed recently: {}".format(url))
+ return []
+ except CacheMiss:
+ pass
+
+ try:
+ logger.debug("Fetching new releases from {}".format(url))
+ response = requests.get(url, headers=headers, proxies=settings.HTTP_PROXIES)
+ response.raise_for_status()
+ total_releases = len(response.json())
+
+ for release in response.json():
+ if 'tag_name' not in release:
+ continue
+ if not pre_releases and (release.get('devrelease') or release.get('prerelease')):
+ continue
+ releases.append((version.parse(release['tag_name']), release.get('html_url')))
+ logger.debug("Found {} releases; {} usable".format(total_releases, len(releases)))
+
+ except requests.exceptions.RequestException:
+ # The request failed. Set a flag in the cache to disable future checks to this URL for 15 minutes.
+ logger.exception("Error while fetching {}. Disabling checks for 15 minutes.".format(url))
+ cache.set('latest_release_no_retry', url, 900)
+ return []
+
+ # Cache the most recent release
+ cache.set('latest_release', max(releases), settings.RELEASE_CHECK_TIMEOUT)
+
+ return releases
diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py
index 19082dbb6..ce0929a8b 100644
--- a/netbox/utilities/choices.py
+++ b/netbox/utilities/choices.py
@@ -78,3 +78,94 @@ def unpack_grouped_choices(choices):
else:
unpacked_choices.append((key, value))
return unpacked_choices
+
+
+#
+# Generic color choices
+#
+
+class ColorChoices(ChoiceSet):
+ COLOR_DARK_RED = 'aa1409'
+ COLOR_RED = 'f44336'
+ COLOR_PINK = 'e91e63'
+ COLOR_ROSE = 'ffe4e1'
+ COLOR_FUCHSIA = 'ff66ff'
+ COLOR_PURPLE = '9c27b0'
+ COLOR_DARK_PURPLE = '673ab7'
+ COLOR_INDIGO = '3f51b5'
+ COLOR_BLUE = '2196f3'
+ COLOR_LIGHT_BLUE = '03a9f4'
+ COLOR_CYAN = '00bcd4'
+ COLOR_TEAL = '009688'
+ COLOR_AQUA = '00ffff'
+ COLOR_DARK_GREEN = '2f6a31'
+ COLOR_GREEN = '4caf50'
+ COLOR_LIGHT_GREEN = '8bc34a'
+ COLOR_LIME = 'cddc39'
+ COLOR_YELLOW = 'ffeb3b'
+ COLOR_AMBER = 'ffc107'
+ COLOR_ORANGE = 'ff9800'
+ COLOR_DARK_ORANGE = 'ff5722'
+ COLOR_BROWN = '795548'
+ COLOR_LIGHT_GREY = 'c0c0c0'
+ COLOR_GREY = '9e9e9e'
+ COLOR_DARK_GREY = '607d8b'
+ COLOR_BLACK = '111111'
+ COLOR_WHITE = 'ffffff'
+
+ CHOICES = (
+ (COLOR_DARK_RED, 'Dark red'),
+ (COLOR_RED, 'Red'),
+ (COLOR_PINK, 'Pink'),
+ (COLOR_ROSE, 'Rose'),
+ (COLOR_FUCHSIA, 'Fuchsia'),
+ (COLOR_PURPLE, 'Purple'),
+ (COLOR_DARK_PURPLE, 'Dark purple'),
+ (COLOR_INDIGO, 'Indigo'),
+ (COLOR_BLUE, 'Blue'),
+ (COLOR_LIGHT_BLUE, 'Light blue'),
+ (COLOR_CYAN, 'Cyan'),
+ (COLOR_TEAL, 'Teal'),
+ (COLOR_AQUA, 'Aqua'),
+ (COLOR_DARK_GREEN, 'Dark green'),
+ (COLOR_GREEN, 'Green'),
+ (COLOR_LIGHT_GREEN, 'Light green'),
+ (COLOR_LIME, 'Lime'),
+ (COLOR_YELLOW, 'Yellow'),
+ (COLOR_AMBER, 'Amber'),
+ (COLOR_ORANGE, 'Orange'),
+ (COLOR_DARK_ORANGE, 'Dark orange'),
+ (COLOR_BROWN, 'Brown'),
+ (COLOR_LIGHT_GREY, 'Light grey'),
+ (COLOR_GREY, 'Grey'),
+ (COLOR_DARK_GREY, 'Dark grey'),
+ (COLOR_BLACK, 'Black'),
+ (COLOR_WHITE, 'White'),
+ )
+
+
+#
+# Button color choices
+#
+
+class ButtonColorChoices(ChoiceSet):
+ """
+ Map standard button color choices to Bootstrap color classes
+ """
+ DEFAULT = 'default'
+ BLUE = 'primary'
+ GREY = 'secondary'
+ GREEN = 'success'
+ RED = 'danger'
+ YELLOW = 'warning'
+ BLACK = 'dark'
+
+ CHOICES = (
+ (DEFAULT, 'Default'),
+ (BLUE, 'Blue'),
+ (GREY, 'Grey'),
+ (GREEN, 'Green'),
+ (RED, 'Red'),
+ (YELLOW, 'Yellow'),
+ (BLACK, 'Black')
+ )
diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py
index bdcdeef11..9a3a7d028 100644
--- a/netbox/utilities/constants.py
+++ b/netbox/utilities/constants.py
@@ -1,34 +1,3 @@
-COLOR_CHOICES = (
- ('aa1409', 'Dark red'),
- ('f44336', 'Red'),
- ('e91e63', 'Pink'),
- ('ffe4e1', 'Rose'),
- ('ff66ff', 'Fuschia'),
- ('9c27b0', 'Purple'),
- ('673ab7', 'Dark purple'),
- ('3f51b5', 'Indigo'),
- ('2196f3', 'Blue'),
- ('03a9f4', 'Light blue'),
- ('00bcd4', 'Cyan'),
- ('009688', 'Teal'),
- ('00ffff', 'Aqua'),
- ('2f6a31', 'Dark green'),
- ('4caf50', 'Green'),
- ('8bc34a', 'Light green'),
- ('cddc39', 'Lime'),
- ('ffeb3b', 'Yellow'),
- ('ffc107', 'Amber'),
- ('ff9800', 'Orange'),
- ('ff5722', 'Dark orange'),
- ('795548', 'Brown'),
- ('c0c0c0', 'Light grey'),
- ('9e9e9e', 'Grey'),
- ('607d8b', 'Dark grey'),
- ('111111', 'Black'),
- ('ffffff', 'White'),
-)
-
-
#
# Filter lookup expressions
#
diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py
index 06c5c8784..87a5e39d8 100644
--- a/netbox/utilities/context_processors.py
+++ b/netbox/utilities/context_processors.py
@@ -1,10 +1,13 @@
from django.conf import settings as django_settings
+from extras.registry import registry
-def settings(request):
+
+def settings_and_registry(request):
"""
- Expose Django settings in the template context. Example: {{ settings.DEBUG }}
+ Expose Django settings and NetBox registry stores in the template context. Example: {{ settings.DEBUG }}
"""
return {
'settings': django_settings,
+ 'registry': registry,
}
diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py
index 553d98982..2cbe1cfc5 100644
--- a/netbox/utilities/custom_inspectors.py
+++ b/netbox/utilities/custom_inspectors.py
@@ -92,7 +92,7 @@ class CustomChoiceFieldInspector(FieldInspector):
value_schema = openapi.Schema(type=schema_type, enum=choice_value)
value_schema['x-nullable'] = True
- if isinstance(choice_value[0], int):
+ if all(type(x) == int for x in [c for c in choice_value if c is not None]):
# Change value_schema for IPAddressFamilyChoices, RackWidthChoices
value_schema = openapi.Schema(type=openapi.TYPE_INTEGER, enum=choice_value)
@@ -131,16 +131,6 @@ class JSONFieldInspector(FieldInspector):
return result
-class IdInFilterInspector(FilterInspector):
- def process_result(self, result, method_name, obj, **kwargs):
- if isinstance(result, list):
- params = [p for p in result if isinstance(p, openapi.Parameter) and p.name == 'id__in']
- for p in params:
- p.type = 'string'
-
- return result
-
-
class NullablePaginatorInspector(PaginatorInspector):
def process_result(self, result, method_name, obj, **kwargs):
if method_name == 'get_paginated_response' and isinstance(result, openapi.Schema):
diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py
index ff34a6011..f628ca917 100644
--- a/netbox/utilities/filters.py
+++ b/netbox/utilities/filters.py
@@ -80,13 +80,6 @@ class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
return super().filter(qs, value)
-class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
- """
- Filters for a set of numeric values. Example: id__in=100,200,300
- """
- pass
-
-
class NullableCharFieldFilter(django_filters.CharFilter):
"""
Allow matching on null field values by passing a special string used to signify NULL.
@@ -217,9 +210,7 @@ class BaseFilterSet(django_filters.FilterSet):
For specific filter types, new filters are created based on defined lookup expressions in
the form `__`
"""
- # TODO: once 3.6 is the minimum required version of python, change this to a bare super() call
- # We have to do it this way in py3.5 becuase of django_filters.FilterSet's use of a metaclass
- filters = super(django_filters.FilterSet, cls).get_filters()
+ filters = super().get_filters()
new_filters = {}
for existing_filter_name, existing_filter in filters.items():
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
index 8825102d1..979b6ac32 100644
--- a/netbox/utilities/forms.py
+++ b/netbox/utilities/forms.py
@@ -8,11 +8,13 @@ import yaml
from django import forms
from django.conf import settings
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
+from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Count
from django.forms import BoundField
+from django.forms.models import fields_for_model
+from django.urls import reverse
-from .choices import unpack_grouped_choices
-from .constants import *
+from .choices import ColorChoices, unpack_grouped_choices
from .validators import EnhancedURLValidator
NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]'
@@ -122,6 +124,19 @@ def add_blank_choice(choices):
return ((None, '---------'),) + tuple(choices)
+def form_from_model(model, fields):
+ """
+ Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used
+ for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields
+ are marked as not required.
+ """
+ form_fields = fields_for_model(model, fields=fields)
+ for field in form_fields.values():
+ field.required = False
+
+ return type('FormFromModel', (forms.Form,), form_fields)
+
+
#
# Widgets
#
@@ -147,7 +162,7 @@ class ColorSelect(forms.Select):
option_template_name = 'widgets/colorselect_option.html'
def __init__(self, *args, **kwargs):
- kwargs['choices'] = add_blank_choice(COLOR_CHOICES)
+ kwargs['choices'] = add_blank_choice(ColorChoices)
super().__init__(*args, **kwargs)
self.attrs['class'] = 'netbox-select2-color-picker'
@@ -252,7 +267,7 @@ class APISelect(SelectWithDisabled):
"""
A select widget populated via an API call
- :param api_url: API URL
+ :param api_url: API endpoint URL. Required if not set automatically by the parent field.
:param display_field: (Optional) Field to display for child in selection list. Defaults to `name`.
:param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`.
:param disabled_indicator: (Optional) Mark option as disabled if this field equates true.
@@ -269,7 +284,7 @@ class APISelect(SelectWithDisabled):
"""
def __init__(
self,
- api_url,
+ api_url=None,
display_field=None,
value_field=None,
disabled_indicator=None,
@@ -285,7 +300,8 @@ class APISelect(SelectWithDisabled):
super().__init__(*args, **kwargs)
self.attrs['class'] = 'netbox-select2-api'
- self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
+ if api_url:
+ self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
if full:
self.attrs['data-full'] = full
if display_field:
@@ -384,15 +400,22 @@ class TimePicker(forms.TextInput):
class CSVDataField(forms.CharField):
"""
- A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping
- column headers to values. Each dictionary represents an individual record.
+ A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first
+ item is a dictionary of column headers, mapping field names to the attribute by which they match a related object
+ (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data.
+
+ :param from_form: The form from which the field derives its validation rules.
"""
widget = forms.Textarea
- def __init__(self, fields, required_fields=[], *args, **kwargs):
+ def __init__(self, from_form, *args, **kwargs):
- self.fields = fields
- self.required_fields = required_fields
+ form = from_form()
+ self.model = form.Meta.model
+ self.fields = form.fields
+ self.required_fields = [
+ name for name, field in form.fields.items() if field.required
+ ]
super().__init__(*args, **kwargs)
@@ -400,7 +423,7 @@ class CSVDataField(forms.CharField):
if not self.label:
self.label = ''
if not self.initial:
- self.initial = ','.join(required_fields) + '\n'
+ self.initial = ','.join(self.required_fields) + '\n'
if not self.help_text:
self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \
'commas to separate values. Multi-line data and values containing commas may be wrapped ' \
@@ -409,36 +432,55 @@ class CSVDataField(forms.CharField):
def to_python(self, value):
records = []
- reader = csv.reader(StringIO(value))
+ reader = csv.reader(StringIO(value.strip()))
- # Consume and validate the first line of CSV data as column headers
- headers = next(reader)
+ # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional
+ # "to" field specifying how the related object is being referenced. For example, importing a Device might use a
+ # `site.slug` header, to indicate the related site is being referenced by its slug.
+ headers = {}
+ for header in next(reader):
+ if '.' in header:
+ field, to_field = header.split('.', 1)
+ headers[field] = to_field
+ else:
+ headers[header] = None
+
+ # Parse CSV rows into a list of dictionaries mapped from the column headers.
+ for i, row in enumerate(reader, start=1):
+ if len(row) != len(headers):
+ raise forms.ValidationError(
+ f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
+ )
+ row = [col.strip() for col in row]
+ record = dict(zip(headers.keys(), row))
+ records.append(record)
+
+ return headers, records
+
+ def validate(self, value):
+ headers, records = value
+
+ # Validate provided column headers
+ for field, to_field in headers.items():
+ if field not in self.fields:
+ raise forms.ValidationError(f'Unexpected column header "{field}" found.')
+ if to_field and not hasattr(self.fields[field], 'to_field_name'):
+ raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots')
+ if to_field and not hasattr(self.fields[field].queryset.model, to_field):
+ raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}')
+
+ # Validate required fields
for f in self.required_fields:
if f not in headers:
- raise forms.ValidationError('Required column header "{}" not found.'.format(f))
- for f in headers:
- if f not in self.fields:
- raise forms.ValidationError('Unexpected column header "{}" found.'.format(f))
+ raise forms.ValidationError(f'Required column header "{f}" not found.')
- # Parse CSV data
- for i, row in enumerate(reader, start=1):
- if row:
- if len(row) != len(headers):
- raise forms.ValidationError(
- "Row {}: Expected {} columns but found {}".format(i, len(headers), len(row))
- )
- row = [col.strip() for col in row]
- record = dict(zip(headers, row))
- records.append(record)
-
- return records
+ return value
class CSVChoiceField(forms.ChoiceField):
"""
Invert the provided set of choices to take the human-friendly label as input, and return the database value.
"""
-
def __init__(self, choices, *args, **kwargs):
super().__init__(choices=choices, *args, **kwargs)
self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)]
@@ -453,6 +495,23 @@ class CSVChoiceField(forms.ChoiceField):
return self.choice_values[value]
+class CSVModelChoiceField(forms.ModelChoiceField):
+ """
+ Provides additional validation for model choices entered as CSV data.
+ """
+ default_error_messages = {
+ 'invalid_choice': 'Object not found.',
+ }
+
+ def to_python(self, value):
+ try:
+ return super().to_python(value)
+ except MultipleObjectsReturned as e:
+ raise forms.ValidationError(
+ f'"{value}" is not a unique value for this field; multiple objects were found'
+ )
+
+
class ExpandableNameField(forms.CharField):
"""
A field which allows for numeric range expansion
@@ -514,27 +573,6 @@ class CommentField(forms.CharField):
super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
-class FlexibleModelChoiceField(forms.ModelChoiceField):
- """
- Allow a model to be reference by either '{ID}' or the field specified by `to_field_name`.
- """
- def to_python(self, value):
- if value in self.empty_values:
- return None
- try:
- if not self.to_field_name:
- key = 'pk'
- elif re.match(r'^\{\d+\}$', value):
- key = 'pk'
- value = value.strip('{}')
- else:
- key = self.to_field_name
- value = self.queryset.get(**{key: value})
- except (ValueError, TypeError, self.queryset.model.DoesNotExist):
- raise forms.ValidationError(self.error_messages['invalid_choice'], code='invalid_choice')
- return value
-
-
class SlugField(forms.SlugField):
"""
Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
@@ -566,19 +604,34 @@ class TagFilterField(forms.MultipleChoiceField):
class DynamicModelChoiceMixin:
filter = django_filters.ModelChoiceFilter
+ widget = APISelect
+
+ def _get_initial_value(self, initial_data, field_name):
+ return initial_data.get(field_name)
def get_bound_field(self, form, field_name):
bound_field = BoundField(form, self, field_name)
+ # Override initial() to allow passing multiple values
+ bound_field.initial = self._get_initial_value(form.initial, field_name)
+
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
# will be populated on-demand via the APISelect widget.
- data = self.prepare_value(bound_field.data or bound_field.initial)
+ data = bound_field.value()
if data:
filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset)
self.queryset = filter.filter(self.queryset, data)
else:
self.queryset = self.queryset.none()
+ # Set the data URL on the APISelect widget (if not already set)
+ widget = bound_field.field.widget
+ if not widget.attrs.get('data-url'):
+ app_label = self.queryset.model._meta.app_label
+ model_name = self.queryset.model._meta.model_name
+ data_url = reverse('{}-api:{}-list'.format(app_label, model_name))
+ widget.attrs['data-url'] = data_url
+
return bound_field
@@ -595,6 +648,13 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
A multiple-choice version of DynamicModelChoiceField.
"""
filter = django_filters.ModelMultipleChoiceFilter
+ widget = APISelectMultiple
+
+ def _get_initial_value(self, initial_data, field_name):
+ # If a QueryDict has been passed as initial form data, get *all* listed values
+ if hasattr(initial_data, 'getlist'):
+ return initial_data.getlist(field_name)
+ return initial_data.get(field_name)
class LaxURLField(forms.URLField):
@@ -636,7 +696,10 @@ class BootstrapMixin(forms.BaseForm):
super().__init__(*args, **kwargs)
exempt_widgets = [
- forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect
+ forms.CheckboxInput,
+ forms.ClearableFileInput,
+ forms.FileInput,
+ forms.RadioSelect
]
for field_name, field in self.fields.items():
@@ -677,13 +740,27 @@ class BulkEditForm(forms.Form):
self.nullable_fields = self.Meta.nullable_fields
+class CSVModelForm(forms.ModelForm):
+ """
+ ModelForm used for the import of objects in CSV format.
+ """
+ def __init__(self, *args, headers=None, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Modify the model form to accommodate any customized to_field_name properties
+ if headers:
+ for field, to_field in headers.items():
+ if to_field is not None:
+ self.fields[field].to_field_name = to_field
+
+
class ImportForm(BootstrapMixin, forms.Form):
"""
Generic form for creating an object from JSON/YAML data
"""
data = forms.CharField(
widget=forms.Textarea,
- help_text="Enter object data in JSON or YAML format."
+ help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported."
)
format = forms.ChoiceField(
choices=(
@@ -702,14 +779,44 @@ class ImportForm(BootstrapMixin, forms.Form):
if format == 'json':
try:
self.cleaned_data['data'] = json.loads(data)
+ # Check for multiple JSON objects
+ if type(self.cleaned_data['data']) is not dict:
+ raise forms.ValidationError({
+ 'data': "Import is limited to one object at a time."
+ })
except json.decoder.JSONDecodeError as err:
raise forms.ValidationError({
'data': "Invalid JSON data: {}".format(err)
})
else:
+ # Check for multiple YAML documents
+ if '\n---' in data:
+ raise forms.ValidationError({
+ 'data': "Import is limited to one object at a time."
+ })
try:
self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader)
- except yaml.scanner.ScannerError as err:
+ except yaml.error.YAMLError as err:
raise forms.ValidationError({
'data': "Invalid YAML data: {}".format(err)
})
+
+
+class TableConfigForm(BootstrapMixin, forms.Form):
+ """
+ Form for configuring user's table preferences.
+ """
+ columns = forms.MultipleChoiceField(
+ choices=[],
+ widget=forms.SelectMultiple(
+ attrs={'size': 10}
+ ),
+ help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display."
+ )
+
+ def __init__(self, table, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Initialize columns field based on table attributes
+ self.fields['columns'].choices = table.configurable_columns
+ self.fields['columns'].initial = table.visible_columns
diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py
index c941f90e8..12a71c469 100644
--- a/netbox/utilities/middleware.py
+++ b/netbox/utilities/middleware.py
@@ -1,6 +1,7 @@
from urllib import parse
from django.conf import settings
+from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.db import ProgrammingError
from django.http import Http404, HttpResponseRedirect
from django.urls import reverse
@@ -31,6 +32,25 @@ class LoginRequiredMiddleware(object):
return self.get_response(request)
+class RemoteUserMiddleware(RemoteUserMiddleware_):
+ """
+ Custom implementation of Django's RemoteUserMiddleware which allows for a user-configurable HTTP header name.
+ """
+ force_logout_if_no_header = False
+
+ @property
+ def header(self):
+ return settings.REMOTE_AUTH_HEADER
+
+ def process_request(self, request):
+
+ # Bypass middleware if remote authentication is not enabled
+ if not settings.REMOTE_AUTH_ENABLED:
+ return
+
+ return super().process_request(request)
+
+
class APIVersionMiddleware(object):
"""
If the request is for an API endpoint, include the API version as a response header.
diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py
index 346a99488..c5287b1e1 100644
--- a/netbox/utilities/ordering.py
+++ b/netbox/utilities/ordering.py
@@ -75,7 +75,7 @@ def naturalize_interface(value, max_length):
if part is not None:
output += part.rjust(6, '0')
else:
- output += '000000'
+ output += '......'
# Finally, naturalize any remaining text and append it
if match.group('remainder') is not None and len(output) < max_length:
diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py
index cf91df3ca..cdad1f230 100644
--- a/netbox/utilities/paginator.py
+++ b/netbox/utilities/paginator.py
@@ -37,3 +37,25 @@ class EnhancedPage(Page):
page_list.insert(page_list.index(i), False)
return page_list
+
+
+def get_paginate_count(request):
+ """
+ Determine the length of a page, using the following in order:
+
+ 1. per_page URL query parameter
+ 2. Saved user preference
+ 3. PAGINATE_COUNT global setting.
+ """
+ if 'per_page' in request.GET:
+ try:
+ per_page = int(request.GET.get('per_page'))
+ if request.user.is_authenticated:
+ request.user.config.set('pagination.per_page', per_page, commit=True)
+ return per_page
+ except ValueError:
+ pass
+
+ if request.user.is_authenticated:
+ return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
+ return settings.PAGINATE_COUNT
diff --git a/netbox/utilities/query_functions.py b/netbox/utilities/query_functions.py
new file mode 100644
index 000000000..ee4310ea7
--- /dev/null
+++ b/netbox/utilities/query_functions.py
@@ -0,0 +1,9 @@
+from django.db.models import F, Func
+
+
+class CollateAsChar(Func):
+ """
+ Disregard localization by collating a field as a plain character string. Helpful for ensuring predictable ordering.
+ """
+ function = 'C'
+ template = '(%(expressions)s) COLLATE "%(function)s"'
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py
index 9e91aebd2..97108b5b2 100644
--- a/netbox/utilities/tables.py
+++ b/netbox/utilities/tables.py
@@ -1,22 +1,87 @@
import django_tables2 as tables
+from django.core.exceptions import FieldDoesNotExist
+from django.db.models.fields.related import RelatedField
from django.utils.safestring import mark_safe
+from django_tables2.data import TableQuerysetData
class BaseTable(tables.Table):
"""
Default table for object lists
+
+ :param add_prefetch: By default, modify the queryset passed to the table upon initialization to automatically
+ prefetch related data. Set this to False if it's necessary to avoid modifying the queryset (e.g. to
+ accommodate PrefixQuerySet.annotate_depth()).
"""
- def __init__(self, *args, **kwargs):
+ add_prefetch = True
+
+ class Meta:
+ attrs = {
+ 'class': 'table table-hover table-headings',
+ }
+
+ def __init__(self, *args, columns=None, **kwargs):
super().__init__(*args, **kwargs)
# Set default empty_text if none was provided
if self.empty_text is None:
self.empty_text = 'No {} found'.format(self._meta.model._meta.verbose_name_plural)
- class Meta:
- attrs = {
- 'class': 'table table-hover table-headings',
- }
+ # Hide non-default columns
+ default_columns = getattr(self.Meta, 'default_columns', list())
+ if default_columns:
+ for column in self.columns:
+ if column.name not in default_columns:
+ self.columns.hide(column.name)
+
+ # Apply custom column ordering
+ if columns is not None:
+ pk = self.base_columns.pop('pk', None)
+ actions = self.base_columns.pop('actions', None)
+
+ for name, column in self.base_columns.items():
+ if name in columns:
+ self.columns.show(name)
+ else:
+ self.columns.hide(name)
+ self.sequence = columns
+
+ # Always include PK and actions column, if defined on the table
+ if pk:
+ self.base_columns['pk'] = pk
+ self.sequence.insert(0, 'pk')
+ if actions:
+ self.base_columns['actions'] = actions
+ self.sequence.append('actions')
+
+ # Dynamically update the table's QuerySet to ensure related fields are pre-fetched
+ if self.add_prefetch and isinstance(self.data, TableQuerysetData):
+ model = getattr(self.Meta, 'model')
+ prefetch_fields = []
+ for column in self.columns:
+ if column.visible:
+ field_path = column.accessor.split('.')
+ try:
+ model_field = model._meta.get_field(field_path[0])
+ if isinstance(model_field, RelatedField):
+ prefetch_fields.append('__'.join(field_path))
+ except FieldDoesNotExist:
+ pass
+ self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields)
+
+ @property
+ def configurable_columns(self):
+ selected_columns = [
+ (name, self.columns[name].verbose_name) for name in self.sequence if name not in ['pk', 'actions']
+ ]
+ available_columns = [
+ (name, column.verbose_name) for name, column in self.columns.items() if name not in self.sequence and name not in ['pk', 'actions']
+ ]
+ return selected_columns + available_columns
+
+ @property
+ def visible_columns(self):
+ return [name for name in self.sequence if self.columns[name].visible]
class ToggleColumn(tables.CheckBoxColumn):
@@ -62,3 +127,22 @@ class ColorColumn(tables.Column):
return mark_safe(
' '.format(value)
)
+
+
+class TagColumn(tables.TemplateColumn):
+ """
+ Display a list of tags assigned to the object.
+ """
+ template_code = """
+ {% for tag in value.all %}
+ {% include 'utilities/templatetags/tag.html' %}
+ {% empty %}
+ —
+ {% endfor %}
+ """
+
+ def __init__(self, url_name=None):
+ super().__init__(
+ template_code=self.template_code,
+ extra_context={'url_name': url_name}
+ )
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py
index 618641a07..8a82fc48b 100644
--- a/netbox/utilities/templatetags/helpers.py
+++ b/netbox/utilities/templatetags/helpers.py
@@ -40,7 +40,7 @@ def render_markdown(value):
value = strip_tags(value)
# Render Markdown
- html = markdown(value, extensions=['fenced_code'])
+ html = markdown(value, extensions=['fenced_code', 'tables'])
return mark_safe(html)
@@ -116,28 +116,6 @@ def humanize_speed(speed):
return '{} Kbps'.format(speed)
-@register.filter()
-def example_choices(field, arg=3):
- """
- Returns a number (default: 3) of example choices for a ChoiceFiled (useful for CSV import forms).
- """
- examples = []
- if hasattr(field, 'queryset'):
- choices = [
- (obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1]
- ]
- else:
- choices = field.choices
- for value, label in unpack_grouped_choices(choices):
- if len(examples) == arg:
- examples.append('etc.')
- break
- if not value or not label:
- continue
- examples.append(label)
- return ', '.join(examples) or 'None'
-
-
@register.filter()
def tzoffset(value):
"""
@@ -196,11 +174,19 @@ def get_docs(model):
return "Unable to load documentation, error reading file: {}".format(path)
# Render Markdown with the admonition extension
- content = markdown(content, extensions=['admonition', 'fenced_code'])
+ content = markdown(content, extensions=['admonition', 'fenced_code', 'tables'])
return mark_safe(content)
+@register.filter()
+def has_perms(user, permissions_list):
+ """
+ Return True if the user has *all* permissions in the list.
+ """
+ return user.has_perms(permissions_list)
+
+
#
# Tags
#
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index de8b93232..d10bb025a 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -164,6 +164,13 @@ class ViewTestCases:
response = self.client.get(instance.get_absolute_url())
self.assertHttpStatus(response, 200)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_list_objects_anonymous(self):
+ # Make the request as an unauthenticated user
+ self.client.logout()
+ response = self.client.get(self.model.objects.first().get_absolute_url())
+ self.assertHttpStatus(response, 200)
+
class CreateObjectViewTestCase(ModelViewTestCase):
"""
Create a single new instance.
@@ -287,6 +294,13 @@ class ViewTestCases:
self.assertHttpStatus(response, 200)
self.assertEqual(response.get('Content-Type'), 'text/csv')
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_list_objects_anonymous(self):
+ # Make the request as an unauthenticated user
+ self.client.logout()
+ response = self.client.get(self._get_url('list'))
+ self.assertHttpStatus(response, 200)
+
class BulkCreateObjectsViewTestCase(ModelViewTestCase):
"""
Create multiple instances using a single form. Expects the creation of three new instances by default.
diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py
index 38ec6e196..fd8c70f05 100644
--- a/netbox/utilities/testing/utils.py
+++ b/netbox/utilities/testing/utils.py
@@ -36,33 +36,6 @@ def create_test_user(username='testuser', permissions=None):
return user
-def choices_to_dict(choices_list):
- """
- Convert a list of field choices to a dictionary suitable for direct comparison with a ChoiceSet. For example:
-
- [
- {
- "value": "choice-1",
- "label": "First Choice"
- },
- {
- "value": "choice-2",
- "label": "Second Choice"
- }
- ]
-
- Becomes:
-
- {
- "choice-1": "First Choice",
- "choice-2": "Second Choice
- }
- """
- return {
- choice['value']: choice['label'] for choice in choices_list
- }
-
-
@contextmanager
def disable_warnings(logger_name):
"""
diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py
index 2d7235505..d6af27b93 100644
--- a/netbox/utilities/tests/test_forms.py
+++ b/netbox/utilities/tests/test_forms.py
@@ -1,6 +1,8 @@
from django import forms
from django.test import TestCase
+from ipam.forms import IPAddressCSVForm
+from ipam.models import VRF
from utilities.forms import *
@@ -281,3 +283,85 @@ class ExpandAlphanumeric(TestCase):
with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r[a,,b]a'))
+
+
+class CSVDataFieldTest(TestCase):
+
+ def setUp(self):
+ self.field = CSVDataField(from_form=IPAddressCSVForm)
+
+ def test_clean(self):
+ input = """
+ address,status,vrf
+ 192.0.2.1/32,Active,Test VRF
+ """
+ output = (
+ {'address': None, 'status': None, 'vrf': None},
+ [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}]
+ )
+ self.assertEqual(self.field.clean(input), output)
+
+ def test_clean_invalid_header(self):
+ input = """
+ address,status,vrf,xxx
+ 192.0.2.1/32,Active,Test VRF,123
+ """
+ with self.assertRaises(forms.ValidationError):
+ self.field.clean(input)
+
+ def test_clean_missing_required_header(self):
+ input = """
+ status,vrf
+ Active,Test VRF
+ """
+ with self.assertRaises(forms.ValidationError):
+ self.field.clean(input)
+
+ def test_clean_default_to_field(self):
+ input = """
+ address,status,vrf.name
+ 192.0.2.1/32,Active,Test VRF
+ """
+ output = (
+ {'address': None, 'status': None, 'vrf': 'name'},
+ [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}]
+ )
+ self.assertEqual(self.field.clean(input), output)
+
+ def test_clean_pk_to_field(self):
+ input = """
+ address,status,vrf.pk
+ 192.0.2.1/32,Active,123
+ """
+ output = (
+ {'address': None, 'status': None, 'vrf': 'pk'},
+ [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123'}]
+ )
+ self.assertEqual(self.field.clean(input), output)
+
+ def test_clean_custom_to_field(self):
+ input = """
+ address,status,vrf.rd
+ 192.0.2.1/32,Active,123:456
+ """
+ output = (
+ {'address': None, 'status': None, 'vrf': 'rd'},
+ [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123:456'}]
+ )
+ self.assertEqual(self.field.clean(input), output)
+
+ def test_clean_invalid_to_field(self):
+ input = """
+ address,status,vrf.xxx
+ 192.0.2.1/32,Active,123:456
+ """
+ with self.assertRaises(forms.ValidationError):
+ self.field.clean(input)
+
+ def test_clean_to_field_on_non_object(self):
+ input = """
+ address,status.foo,vrf
+ 192.0.2.1/32,Bar,Test VRF
+ """
+ with self.assertRaises(forms.ValidationError):
+ self.field.clean(input)
diff --git a/netbox/utilities/tests/test_ordering.py b/netbox/utilities/tests/test_ordering.py
index d535443ea..8e85f9e8c 100644
--- a/netbox/utilities/tests/test_ordering.py
+++ b/netbox/utilities/tests/test_ordering.py
@@ -30,29 +30,32 @@ class NaturalizationTestCase(TestCase):
# Original, naturalized
data = (
+
# IOS/JunOS-style
- ('Gi', '9999999999999999Gi000000000000000000'),
- ('Gi1', '9999999999999999Gi000001000000000000'),
- ('Gi1.0', '9999999999999999Gi000001000000000000'),
- ('Gi1.1', '9999999999999999Gi000001000000000001'),
- ('Gi1:0', '9999999999999999Gi000001000000000000'),
+ ('Gi', '9999999999999999Gi..................'),
+ ('Gi1', '9999999999999999Gi000001............'),
+ ('Gi1.0', '9999999999999999Gi000001......000000'),
+ ('Gi1.1', '9999999999999999Gi000001......000001'),
+ ('Gi1:0', '9999999999999999Gi000001000000......'),
('Gi1:0.0', '9999999999999999Gi000001000000000000'),
('Gi1:0.1', '9999999999999999Gi000001000000000001'),
- ('Gi1:1', '9999999999999999Gi000001000001000000'),
+ ('Gi1:1', '9999999999999999Gi000001000001......'),
('Gi1:1.0', '9999999999999999Gi000001000001000000'),
('Gi1:1.1', '9999999999999999Gi000001000001000001'),
- ('Gi1/2', '0001999999999999Gi000002000000000000'),
- ('Gi1/2/3', '0001000299999999Gi000003000000000000'),
- ('Gi1/2/3/4', '0001000200039999Gi000004000000000000'),
- ('Gi1/2/3/4/5', '0001000200030004Gi000005000000000000'),
- ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006000000'),
+ ('Gi1/2', '0001999999999999Gi000002............'),
+ ('Gi1/2/3', '0001000299999999Gi000003............'),
+ ('Gi1/2/3/4', '0001000200039999Gi000004............'),
+ ('Gi1/2/3/4/5', '0001000200030004Gi000005............'),
+ ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006......'),
('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'),
+
# Generic
- ('Interface 1', '9999999999999999Interface 000001000000000000'),
- ('Interface 1 (other)', '9999999999999999Interface 000001000000000000 (other)'),
- ('Interface 99', '9999999999999999Interface 000099000000000000'),
- ('PCIe1-p1', '9999999999999999PCIe000001000000000000-p00000001'),
- ('PCIe1-p99', '9999999999999999PCIe000001000000000000-p00000099'),
+ ('Interface 1', '9999999999999999Interface 000001............'),
+ ('Interface 1 (other)', '9999999999999999Interface 000001............ (other)'),
+ ('Interface 99', '9999999999999999Interface 000099............'),
+ ('PCIe1-p1', '9999999999999999PCIe000001............-p00000001'),
+ ('PCIe1-p99', '9999999999999999PCIe000001............-p00000099'),
+
)
for origin, naturalized in data:
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 446622118..351b1fd68 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -239,3 +239,21 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=None):
difference[key] = destination_dict[key]
return difference
+
+
+def flatten_dict(d, prefix='', separator='.'):
+ """
+ Flatten netsted dictionaries into a single level by joining key names with a separator.
+
+ :param d: The dictionary to be flattened
+ :param prefix: Initial prefix (if any)
+ :param separator: The character to use when concatenating key names
+ """
+ ret = {}
+ for k, v in d.items():
+ key = separator.join([prefix, k]) if prefix else k
+ if type(v) is dict:
+ ret.update(flatten_dict(v, prefix=key))
+ else:
+ ret[key] = v
+ return ret
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 78acefa48..4b5993c5f 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -1,8 +1,9 @@
+import logging
import sys
from copy import deepcopy
-from django.conf import settings
from django.contrib import messages
+from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db import transaction, IntegrityError
@@ -13,6 +14,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.template import loader
from django.template.exceptions import TemplateDoesNotExist
from django.urls import reverse
+from django.utils.decorators import method_decorator
from django.utils.html import escape
from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe
@@ -24,11 +26,11 @@ from django_tables2 import RequestConfig
from extras.models import CustomField, CustomFieldValue, ExportTemplate
from extras.querysets import CustomFieldQueryset
from utilities.exceptions import AbortTransaction
-from utilities.forms import BootstrapMixin, CSVDataField
+from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
from utilities.utils import csv_format, prepare_cloned_fields
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm, ImportForm
-from .paginator import EnhancedPaginator
+from .paginator import EnhancedPaginator, get_paginate_count
class GetReturnURLMixin(object):
@@ -164,14 +166,18 @@ class ObjectListView(View):
permissions[action] = request.user.has_perm(perm_name)
# Construct the table based on the user's permissions
- table = self.table(self.queryset)
+ if request.user.is_authenticated:
+ columns = request.user.config.get(f"tables.{self.table.__name__}.columns")
+ else:
+ columns = None
+ table = self.table(self.queryset, columns=columns)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
# Apply the request context
paginate = {
'paginator_class': EnhancedPaginator,
- 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+ 'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(table)
@@ -180,12 +186,30 @@ class ObjectListView(View):
'table': table,
'permissions': permissions,
'action_buttons': self.action_buttons,
+ 'table_config_form': TableConfigForm(table=table),
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
}
context.update(self.extra_context())
return render(request, self.template_name, context)
+ @method_decorator(login_required)
+ def post(self, request):
+
+ # Update the user's table configuration
+ table = self.table(self.queryset)
+ form = TableConfigForm(table=table, data=request.POST)
+ preference_name = f"tables.{self.table.__name__}.columns"
+
+ if form.is_valid():
+ if 'set' in request.POST:
+ request.user.config.set(preference_name, form.cleaned_data['columns'], commit=True)
+ elif 'clear' in request.POST:
+ request.user.config.clear(preference_name, commit=True)
+ messages.success(request, "Your preferences have been updated.")
+
+ return redirect(request.get_full_path())
+
def alter_queryset(self, request):
# .all() is necessary to avoid caching queries
return self.queryset.all()
@@ -219,35 +243,36 @@ class ObjectEditView(GetReturnURLMixin, View):
# given some parameter from the request URL.
return obj
- def get(self, request, *args, **kwargs):
+ def dispatch(self, request, *args, **kwargs):
+ self.obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
- obj = self.get_object(kwargs)
- obj = self.alter_obj(obj, request, args, kwargs)
+ return super().dispatch(request, *args, **kwargs)
+
+ def get(self, request, *args, **kwargs):
# Parse initial data manually to avoid setting field values as lists
initial_data = {k: request.GET[k] for k in request.GET}
- form = self.model_form(instance=obj, initial=initial_data)
+ form = self.model_form(instance=self.obj, initial=initial_data)
return render(request, self.template_name, {
- 'obj': obj,
+ 'obj': self.obj,
'obj_type': self.model._meta.verbose_name,
'form': form,
- 'return_url': self.get_return_url(request, obj),
+ 'return_url': self.get_return_url(request, self.obj),
})
def post(self, request, *args, **kwargs):
-
- obj = self.get_object(kwargs)
- obj = self.alter_obj(obj, request, args, kwargs)
- form = self.model_form(request.POST, request.FILES, instance=obj)
+ logger = logging.getLogger('netbox.views.ObjectEditView')
+ form = self.model_form(request.POST, request.FILES, instance=self.obj)
if form.is_valid():
- obj_created = not form.instance.pk
- obj = form.save()
+ logger.debug("Form validation was successful")
+ obj = form.save()
msg = '{} {}'.format(
- 'Created' if obj_created else 'Modified',
+ 'Created' if not form.instance.pk else 'Modified',
self.model._meta.verbose_name
)
+ logger.info(f"{msg} {obj} (PK: {obj.pk})")
if hasattr(obj, 'get_absolute_url'):
msg = '{} {}'.format(msg, obj.get_absolute_url(), escape(obj))
else:
@@ -269,11 +294,14 @@ class ObjectEditView(GetReturnURLMixin, View):
else:
return redirect(self.get_return_url(request, obj))
+ else:
+ logger.debug("Form validation failed")
+
return render(request, self.template_name, {
- 'obj': obj,
+ 'obj': self.obj,
'obj_type': self.model._meta.verbose_name,
'form': form,
- 'return_url': self.get_return_url(request, obj),
+ 'return_url': self.get_return_url(request, self.obj),
})
@@ -295,7 +323,6 @@ class ObjectDeleteView(GetReturnURLMixin, View):
return get_object_or_404(self.model, pk=kwargs['pk'])
def get(self, request, **kwargs):
-
obj = self.get_object(kwargs)
form = ConfirmationForm(initial=request.GET)
@@ -307,18 +334,22 @@ class ObjectDeleteView(GetReturnURLMixin, View):
})
def post(self, request, **kwargs):
-
+ logger = logging.getLogger('netbox.views.ObjectDeleteView')
obj = self.get_object(kwargs)
form = ConfirmationForm(request.POST)
+
if form.is_valid():
+ logger.debug("Form validation was successful")
try:
obj.delete()
except ProtectedError as e:
+ logger.info("Caught ProtectedError while attempting to delete object")
handle_protectederror(obj, request, e)
return redirect(obj.get_absolute_url())
msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
+ logger.info(msg)
messages.success(request, msg)
return_url = form.cleaned_data.get('return_url')
@@ -327,6 +358,9 @@ class ObjectDeleteView(GetReturnURLMixin, View):
else:
return redirect(self.get_return_url(request, obj))
+ else:
+ logger.debug("Form validation failed")
+
return render(request, self.template_name, {
'obj': obj,
'form': form,
@@ -350,7 +384,6 @@ class BulkCreateView(GetReturnURLMixin, View):
template_name = None
def get(self, request):
-
# Set initial values for visible form fields from query args
initial = {}
for field in getattr(self.model_form._meta, 'fields', []):
@@ -368,13 +401,13 @@ class BulkCreateView(GetReturnURLMixin, View):
})
def post(self, request):
-
+ logger = logging.getLogger('netbox.views.BulkCreateView')
model = self.model_form._meta.model
form = self.form(request.POST)
model_form = self.model_form(request.POST)
if form.is_valid():
-
+ logger.debug("Form validation was successful")
pattern = form.cleaned_data['pattern']
new_objs = []
@@ -392,6 +425,7 @@ class BulkCreateView(GetReturnURLMixin, View):
# Validate each new object independently.
if model_form.is_valid():
obj = model_form.save()
+ logger.debug(f"Created {obj} (PK: {obj.pk})")
new_objs.append(obj)
else:
# Copy any errors on the pattern target field to the pattern form.
@@ -403,6 +437,7 @@ class BulkCreateView(GetReturnURLMixin, View):
# If we make it to this point, validation has succeeded on all new objects.
msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
+ logger.info(msg)
messages.success(request, msg)
if '_addanother' in request.POST:
@@ -412,6 +447,9 @@ class BulkCreateView(GetReturnURLMixin, View):
except IntegrityError:
pass
+ else:
+ logger.debug("Form validation failed")
+
return render(request, self.template_name, {
'form': form,
'model_form': model_form,
@@ -430,7 +468,6 @@ class ObjectImportView(GetReturnURLMixin, View):
template_name = 'utilities/obj_import.html'
def get(self, request):
-
form = ImportForm()
return render(request, self.template_name, {
@@ -440,9 +477,11 @@ class ObjectImportView(GetReturnURLMixin, View):
})
def post(self, request):
-
+ logger = logging.getLogger('netbox.views.ObjectImportView')
form = ImportForm(request.POST)
+
if form.is_valid():
+ logger.debug("Import form validation was successful")
# Initialize model form
data = form.cleaned_data['data']
@@ -463,9 +502,11 @@ class ObjectImportView(GetReturnURLMixin, View):
# Save the primary object
obj = model_form.save()
+ logger.debug(f"Created {obj} (PK: {obj.pk})")
# Iterate through the related object forms (if any), validating and saving each instance.
for field_name, related_object_form in self.related_object_forms.items():
+ logger.debug("Processing form for related objects: {related_object_form}")
for i, rel_obj_data in enumerate(data.get(field_name, list())):
@@ -489,7 +530,7 @@ class ObjectImportView(GetReturnURLMixin, View):
pass
if not model_form.errors:
-
+ logger.info(f"Import object {obj} (PK: {obj.pk})")
messages.success(request, mark_safe('Imported object: {}'.format(
obj.get_absolute_url(), obj
)))
@@ -504,6 +545,7 @@ class ObjectImportView(GetReturnURLMixin, View):
return redirect(self.get_return_url(request, obj))
else:
+ logger.debug("Model form validation failed")
# Replicate model form errors for display
for field, errors in model_form.errors.items():
@@ -513,6 +555,9 @@ class ObjectImportView(GetReturnURLMixin, View):
else:
form.add_error(None, "{}: {}".format(field, err))
+ else:
+ logger.debug("Import form validation failed")
+
return render(request, self.template_name, {
'form': form,
'obj_type': self.model._meta.verbose_name,
@@ -536,11 +581,11 @@ class BulkImportView(GetReturnURLMixin, View):
def _import_form(self, *args, **kwargs):
- fields = self.model_form().fields.keys()
- required_fields = [name for name, field in self.model_form().fields.items() if field.required]
-
class ImportForm(BootstrapMixin, Form):
- csv = CSVDataField(fields=fields, required_fields=required_fields, widget=Textarea(attrs=self.widget_attrs))
+ csv = CSVDataField(
+ from_form=self.model_form,
+ widget=Textarea(attrs=self.widget_attrs)
+ )
return ImportForm(*args, **kwargs)
@@ -560,18 +605,20 @@ class BulkImportView(GetReturnURLMixin, View):
})
def post(self, request):
-
+ logger = logging.getLogger('netbox.views.BulkImportView')
new_objs = []
form = self._import_form(request.POST)
if form.is_valid():
+ logger.debug("Form validation was successful")
try:
-
# Iterate through CSV data and bind each row to a new model form instance.
with transaction.atomic():
- for row, data in enumerate(form.cleaned_data['csv'], start=1):
- obj_form = self.model_form(data)
+ headers, records = form.cleaned_data['csv']
+ for row, data in enumerate(records, start=1):
+ obj_form = self.model_form(data, headers=headers)
+
if obj_form.is_valid():
obj = self._save_obj(obj_form, request)
new_objs.append(obj)
@@ -585,6 +632,7 @@ class BulkImportView(GetReturnURLMixin, View):
if new_objs:
msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
+ logger.info(msg)
messages.success(request, msg)
return render(request, "import_success.html", {
@@ -595,6 +643,9 @@ class BulkImportView(GetReturnURLMixin, View):
except ValidationError:
pass
+ else:
+ logger.debug("Form validation failed")
+
return render(request, self.template_name, {
'form': form,
'fields': self.model_form().fields,
@@ -623,7 +674,7 @@ class BulkEditView(GetReturnURLMixin, View):
return redirect(self.get_return_url(request))
def post(self, request, **kwargs):
-
+ logger = logging.getLogger('netbox.views.BulkEditView')
model = self.queryset.model
# If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
@@ -636,8 +687,9 @@ class BulkEditView(GetReturnURLMixin, View):
if '_apply' in request.POST:
form = self.form(model, request.POST)
- if form.is_valid():
+ if form.is_valid():
+ logger.debug("Form validation was successful")
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
standard_fields = [
field for field in form.fields if field not in custom_fields + ['pk']
@@ -677,6 +729,7 @@ class BulkEditView(GetReturnURLMixin, View):
obj.full_clean()
obj.save()
+ logger.debug(f"Saved {obj} (PK: {obj.pk})")
# Update custom fields
obj_type = ContentType.objects.get_for_model(model)
@@ -697,6 +750,7 @@ class BulkEditView(GetReturnURLMixin, View):
)
cfv.value = form.cleaned_data[name]
cfv.save()
+ logger.debug(f"Saved custom fields for {obj} (PK: {obj.pk})")
# Add/remove tags
if form.cleaned_data.get('add_tags', None):
@@ -708,6 +762,7 @@ class BulkEditView(GetReturnURLMixin, View):
if updated_count:
msg = 'Updated {} {}'.format(updated_count, model._meta.verbose_name_plural)
+ logger.info(msg)
messages.success(self.request, msg)
return redirect(self.get_return_url(request))
@@ -715,6 +770,9 @@ class BulkEditView(GetReturnURLMixin, View):
except ValidationError as e:
messages.error(self.request, "{} failed validation: {}".format(obj, e))
+ else:
+ logger.debug("Form validation failed")
+
else:
# Include the PK list as initial data for the form
initial_data = {'pk': pk_list}
@@ -761,7 +819,7 @@ class BulkDeleteView(GetReturnURLMixin, View):
return redirect(self.get_return_url(request))
def post(self, request, **kwargs):
-
+ logger = logging.getLogger('netbox.views.BulkDeleteView')
model = self.queryset.model
# Are we deleting *all* objects in the queryset or just a selected subset?
@@ -778,19 +836,25 @@ class BulkDeleteView(GetReturnURLMixin, View):
if '_confirm' in request.POST:
form = form_cls(request.POST)
if form.is_valid():
+ logger.debug("Form validation was successful")
# Delete objects
queryset = model.objects.filter(pk__in=pk_list)
try:
deleted_count = queryset.delete()[1][model._meta.label]
except ProtectedError as e:
+ logger.info("Caught ProtectedError while attempting to delete objects")
handle_protectederror(list(queryset), request, e)
return redirect(self.get_return_url(request))
msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural)
+ logger.info(msg)
messages.success(request, msg)
return redirect(self.get_return_url(request))
+ else:
+ logger.debug("Form validation failed")
+
else:
form = form_cls(initial={
'pk': pk_list,
@@ -814,12 +878,12 @@ class BulkDeleteView(GetReturnURLMixin, View):
"""
Provide a standard bulk delete form if none has been specified for the view
"""
-
class BulkDeleteForm(ConfirmationForm):
pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
if self.form:
return self.form
+
return BulkDeleteForm
@@ -908,7 +972,7 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
template_name = 'utilities/obj_bulk_add_component.html'
def post(self, request):
-
+ logger = logging.getLogger('netbox.views.BulkComponentCreateView')
parent_model_name = self.parent_model._meta.verbose_name_plural
model_name = self.model._meta.verbose_name_plural
@@ -926,38 +990,53 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
if '_create' in request.POST:
form = self.form(request.POST)
+
if form.is_valid():
+ logger.debug("Form validation was successful")
new_components = []
data = deepcopy(form.cleaned_data)
- for obj in data['pk']:
- names = data['name_pattern']
- for name in names:
- component_data = {
- self.parent_field: obj.pk,
- 'name': name,
- }
- component_data.update(data)
- component_form = self.model_form(component_data)
- if component_form.is_valid():
- new_components.append(component_form.save(commit=False))
- else:
- for field, errors in component_form.errors.as_data().items():
- for e in errors:
- form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
+ try:
+ with transaction.atomic():
+
+ for obj in data['pk']:
+
+ names = data['name_pattern']
+ for name in names:
+ component_data = {
+ self.parent_field: obj.pk,
+ 'name': name,
+ }
+ component_data.update(data)
+ component_form = self.model_form(component_data)
+ if component_form.is_valid():
+ instance = component_form.save()
+ logger.debug(f"Created {instance} on {instance.parent}")
+ new_components.append(instance)
+ else:
+ for field, errors in component_form.errors.as_data().items():
+ for e in errors:
+ form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
+
+ except IntegrityError:
+ pass
if not form.errors:
- self.model.objects.bulk_create(new_components)
-
- messages.success(request, "Added {} {} to {} {}.".format(
+ msg = "Added {} {} to {} {}.".format(
len(new_components),
model_name,
len(form.cleaned_data['pk']),
parent_model_name
- ))
+ )
+ logger.info(msg)
+ messages.success(request, msg)
+
return redirect(self.get_return_url(request))
+ else:
+ logger.debug("Form validation failed")
+
else:
form = self.form(initial={'pk': pk_list})
diff --git a/netbox/vapor/filters.py b/netbox/vapor/filters.py
index 5d86e53ef..f0c81d2d5 100644
--- a/netbox/vapor/filters.py
+++ b/netbox/vapor/filters.py
@@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q
from extras.filters import CustomFieldFilterSet
-from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, MultiValueNumberFilter
+from utilities.filters import NameSlugSearchFilterSet, TagFilter, MultiValueNumberFilter
from tenancy.models import Tenant, TenantGroup
from dcim.models import Site, Device, DeviceRole, Interface
from dcim.choices import (
@@ -12,10 +12,6 @@ from dcim.filters import MultiValueMACAddressFilter
class CustomerFilter(CustomFieldFilterSet):
- id__in = NumericInFilter(
- field_name='id',
- lookup_expr='in'
- )
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -41,7 +37,7 @@ class CustomerFilter(CustomFieldFilterSet):
class Meta:
model = Tenant
- fields = ['name', 'slug']
+ fields = ['id', 'name', 'slug']
def search(self, queryset, name, value):
if not value.strip():
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index a294cdb6f..3cca95b22 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -24,7 +24,7 @@ class ClusterTypeSerializer(ValidatedModelSerializer):
class Meta:
model = ClusterType
- fields = ['id', 'name', 'slug', 'cluster_count']
+ fields = ['id', 'name', 'slug', 'description', 'cluster_count']
class ClusterGroupSerializer(ValidatedModelSerializer):
@@ -32,7 +32,7 @@ class ClusterGroupSerializer(ValidatedModelSerializer):
class Meta:
model = ClusterGroup
- fields = ['id', 'name', 'slug', 'cluster_count']
+ fields = ['id', 'name', 'slug', 'description', 'cluster_count']
class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py
index a94e043b2..c237f1e68 100644
--- a/netbox/virtualization/api/urls.py
+++ b/netbox/virtualization/api/urls.py
@@ -14,9 +14,6 @@ class VirtualizationRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = VirtualizationRootView
-# Field choices
-router.register('_choices', views.VirtualizationFieldChoicesViewSet, basename='field-choice')
-
# Clusters
router.register('cluster-types', views.ClusterTypeViewSet)
router.register('cluster-groups', views.ClusterGroupViewSet)
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 415fc6289..2a1d7c3a9 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -2,24 +2,13 @@ from django.db.models import Count
from dcim.models import Device, Interface
from extras.api.views import CustomFieldModelViewSet
-from utilities.api import FieldChoicesViewSet, ModelViewSet
+from utilities.api import ModelViewSet
from utilities.utils import get_subquery
from virtualization import filters
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from . import serializers
-#
-# Field choices
-#
-
-class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet):
- fields = (
- (serializers.VirtualMachineSerializer, ['status']),
- (serializers.InterfaceSerializer, ['type']),
- )
-
-
#
# Clusters
#
diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py
index 59f09c401..a54b6ab28 100644
--- a/netbox/virtualization/filters.py
+++ b/netbox/virtualization/filters.py
@@ -4,9 +4,8 @@ from django.db.models import Q
from dcim.models import DeviceRole, Interface, Platform, Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet
from tenancy.filters import TenancyFilterSet
-from tenancy.models import Tenant
from utilities.filters import (
- BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter,
+ BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, TagFilter,
TreeNodeMultipleChoiceFilter,
)
from .choices import *
@@ -25,21 +24,17 @@ class ClusterTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta:
model = ClusterType
- fields = ['id', 'name', 'slug']
+ fields = ['id', 'name', 'slug', 'description']
class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta:
model = ClusterGroup
- fields = ['id', 'name', 'slug']
+ fields = ['id', 'name', 'slug', 'description']
class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
- id__in = NumericInFilter(
- field_name='id',
- lookup_expr='in'
- )
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -91,7 +86,7 @@ class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Cr
class Meta:
model = Cluster
- fields = ['name']
+ fields = ['id', 'name']
def search(self, queryset, name, value):
if not value.strip():
@@ -109,10 +104,6 @@ class VirtualMachineFilterSet(
CustomFieldFilterSet,
CreatedUpdatedFilterSet
):
- id__in = NumericInFilter(
- field_name='id',
- lookup_expr='in'
- )
q = django_filters.CharFilter(
method='search',
label='Search',
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py
index 0dbe38324..2f2ee4950 100644
--- a/netbox/virtualization/forms.py
+++ b/netbox/virtualization/forms.py
@@ -1,6 +1,5 @@
from django import forms
from django.core.exceptions import ValidationError
-from taggit.forms import TagField
from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
@@ -8,14 +7,16 @@ from dcim.forms import INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
+ TagField,
)
-from ipam.models import IPAddress, VLANGroup, VLAN
+from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
- CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
- ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField,
+ CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
+ DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea,
+ StaticSelect2, StaticSelect2Multiple, TagFilterField,
)
from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -31,19 +32,16 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ClusterType
fields = [
- 'name', 'slug',
+ 'name', 'slug', 'description',
]
-class ClusterTypeCSVForm(forms.ModelForm):
+class ClusterTypeCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = ClusterType
fields = ClusterType.csv_headers
- help_texts = {
- 'name': 'Name of cluster type',
- }
#
@@ -56,19 +54,16 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ClusterGroup
fields = [
- 'name', 'slug',
+ 'name', 'slug', 'description',
]
-class ClusterGroupCSVForm(forms.ModelForm):
+class ClusterGroupCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = ClusterGroup
fields = ClusterGroup.csv_headers
- help_texts = {
- 'name': 'Name of cluster group',
- }
#
@@ -77,24 +72,15 @@ class ClusterGroupCSVForm(forms.ModelForm):
class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
type = DynamicModelChoiceField(
- queryset=ClusterType.objects.all(),
- widget=APISelect(
- api_url="/api/virtualization/cluster-types/"
- )
+ queryset=ClusterType.objects.all()
)
group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
- required=False,
- widget=APISelect(
- api_url="/api/virtualization/cluster-groups/"
- )
+ required=False
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- required=False,
- widget=APISelect(
- api_url="/api/dcim/sites/"
- )
+ required=False
)
comments = CommentField()
tags = TagField(
@@ -109,40 +95,28 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class ClusterCSVForm(CustomFieldModelCSVForm):
- type = forms.ModelChoiceField(
+ type = CSVModelChoiceField(
queryset=ClusterType.objects.all(),
to_field_name='name',
- help_text='Name of cluster type',
- error_messages={
- 'invalid_choice': 'Invalid cluster type name.',
- }
+ help_text='Type of cluster'
)
- group = forms.ModelChoiceField(
+ group = CSVModelChoiceField(
queryset=ClusterGroup.objects.all(),
to_field_name='name',
required=False,
- help_text='Name of cluster group',
- error_messages={
- 'invalid_choice': 'Invalid cluster group name.',
- }
+ help_text='Assigned cluster group'
)
- site = forms.ModelChoiceField(
+ site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
required=False,
- help_text='Name of assigned site',
- error_messages={
- 'invalid_choice': 'Invalid site name.',
- }
+ help_text='Assigned site'
)
- tenant = forms.ModelChoiceField(
+ tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False,
- help_text='Name of assigned tenant',
- error_messages={
- 'invalid_choice': 'Invalid tenant name'
- }
+ help_text='Assigned tenant'
)
class Meta:
@@ -157,31 +131,19 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
)
type = DynamicModelChoiceField(
queryset=ClusterType.objects.all(),
- required=False,
- widget=APISelect(
- api_url="/api/virtualization/cluster-types/"
- )
+ required=False
)
group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
- required=False,
- widget=APISelect(
- api_url="/api/virtualization/cluster-groups/"
- )
+ required=False
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
- required=False,
- widget=APISelect(
- api_url="/api/tenancy/tenants/"
- )
+ required=False
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- required=False,
- widget=APISelect(
- api_url="/api/dcim/sites/"
- )
+ required=False
)
comments = CommentField(
widget=SmallTextarea,
@@ -205,7 +167,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url="/api/virtualization/cluster-types/",
value_field='slug',
)
)
@@ -214,7 +175,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
@@ -226,7 +186,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url="/api/dcim/sites/",
value_field='slug',
null_option=True,
)
@@ -236,7 +195,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url="/api/virtualization/cluster-groups/",
value_field='slug',
null_option=True,
)
@@ -249,7 +207,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
queryset=Region.objects.all(),
required=False,
widget=APISelect(
- api_url="/api/dcim/regions/",
filter_for={
"site": "region_id",
},
@@ -262,7 +219,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
queryset=Site.objects.all(),
required=False,
widget=APISelect(
- api_url='/api/dcim/sites/',
filter_for={
"rack": "site_id",
"devices": "site_id",
@@ -273,7 +229,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
queryset=Rack.objects.all(),
required=False,
widget=APISelect(
- api_url='/api/dcim/racks/',
filter_for={
"devices": "rack_id"
},
@@ -285,7 +240,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
devices = DynamicModelMultipleChoiceField(
queryset=Device.objects.filter(cluster__isnull=True),
widget=APISelectMultiple(
- api_url='/api/dcim/devices/',
display_field='display_name',
disabled_indicator='cluster'
)
@@ -334,7 +288,6 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=ClusterGroup.objects.all(),
required=False,
widget=APISelect(
- api_url='/api/virtualization/cluster-groups/',
filter_for={
"cluster": "group_id",
},
@@ -344,16 +297,12 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
)
cluster = DynamicModelChoiceField(
- queryset=Cluster.objects.all(),
- widget=APISelect(
- api_url='/api/virtualization/clusters/'
- )
+ queryset=Cluster.objects.all()
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
widget=APISelect(
- api_url="/api/dcim/device-roles/",
additional_query_params={
"vm_role": "True"
}
@@ -361,10 +310,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
- required=False,
- widget=APISelect(
- api_url='/api/dcim/platforms/'
- )
+ required=False
)
tags = TagField(
required=False
@@ -408,7 +354,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
ip_choices = [(None, '---------')]
# Collect interface IPs
interface_ips = IPAddress.objects.prefetch_related('interface').filter(
- family=family, interface__virtual_machine=self.instance
+ address__family=family, interface__virtual_machine=self.instance
)
if interface_ips:
ip_choices.append(
@@ -418,7 +364,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
# Collect NAT IPs
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
- family=family, nat_inside__interface__virtual_machine=self.instance
+ address__family=family, nat_inside__interface__virtual_machine=self.instance
)
if nat_ips:
ip_choices.append(
@@ -443,42 +389,30 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm):
required=False,
help_text='Operational status of device'
)
- cluster = forms.ModelChoiceField(
+ cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
- help_text='Name of parent cluster',
- error_messages={
- 'invalid_choice': 'Invalid cluster name.',
- }
+ help_text='Assigned cluster'
)
- role = forms.ModelChoiceField(
+ role = CSVModelChoiceField(
queryset=DeviceRole.objects.filter(
vm_role=True
),
required=False,
to_field_name='name',
- help_text='Name of functional role',
- error_messages={
- 'invalid_choice': 'Invalid role name.'
- }
+ help_text='Functional role'
)
- tenant = forms.ModelChoiceField(
+ tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
- help_text='Name of assigned tenant',
- error_messages={
- 'invalid_choice': 'Tenant not found.'
- }
+ help_text='Assigned tenant'
)
- platform = forms.ModelChoiceField(
+ platform = CSVModelChoiceField(
queryset=Platform.objects.all(),
required=False,
to_field_name='name',
- help_text='Name of assigned platform',
- error_messages={
- 'invalid_choice': 'Invalid platform.',
- }
+ help_text='Assigned platform'
)
class Meta:
@@ -499,10 +433,7 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
- required=False,
- widget=APISelect(
- api_url='/api/virtualization/clusters/'
- )
+ required=False
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.filter(
@@ -510,7 +441,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
),
required=False,
widget=APISelect(
- api_url="/api/dcim/device-roles/",
additional_query_params={
"vm_role": "True"
}
@@ -518,17 +448,11 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
- required=False,
- widget=APISelect(
- api_url='/api/tenancy/tenants/'
- )
+ required=False
)
platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
- required=False,
- widget=APISelect(
- api_url='/api/dcim/platforms/'
- )
+ required=False
)
vcpus = forms.IntegerField(
required=False,
@@ -568,7 +492,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url='/api/virtualization/cluster-groups/',
value_field="slug",
null_option=True,
)
@@ -578,7 +501,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url='/api/virtualization/cluster-types/',
value_field="slug",
null_option=True,
)
@@ -586,17 +508,13 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
- label='Cluster',
- widget=APISelectMultiple(
- api_url='/api/virtualization/clusters/',
- )
+ label='Cluster'
)
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url='/api/dcim/regions/',
value_field="slug",
filter_for={
'site': 'region'
@@ -608,7 +526,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url='/api/dcim/sites/',
value_field="slug",
null_option=True,
)
@@ -618,7 +535,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url='/api/dcim/device-roles/',
value_field="slug",
null_option=True,
additional_query_params={
@@ -636,7 +552,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
to_field_name='slug',
required=False,
widget=APISelectMultiple(
- api_url='/api/dcim/platforms/',
value_field="slug",
null_option=True,
)
@@ -657,7 +572,6 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
- api_url="/api/ipam/vlans/",
display_field='display_name',
full=True,
additional_query_params={
@@ -669,7 +583,6 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
- api_url="/api/ipam/vlans/",
display_field='display_name',
full=True,
additional_query_params={
@@ -766,7 +679,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
- api_url="/api/ipam/vlans/",
display_field='display_name',
full=True,
additional_query_params={
@@ -778,7 +690,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
- api_url="/api/ipam/vlans/",
display_field='display_name',
full=True,
additional_query_params={
@@ -836,7 +747,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
- api_url="/api/ipam/vlans/",
display_field='display_name',
full=True,
additional_query_params={
@@ -848,7 +758,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
- api_url="/api/ipam/vlans/",
display_field='display_name',
full=True,
additional_query_params={
@@ -889,24 +798,18 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
label='Name'
)
+ def clean_tags(self):
+ # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
+ # must first convert the list of tags to a string.
+ return ','.join(self.cleaned_data.get('tags'))
-class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm):
+
+class InterfaceBulkCreateForm(
+ form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']),
+ VirtualMachineBulkAddComponentForm
+):
type = forms.ChoiceField(
choices=VMInterfaceTypeChoices,
initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
widget=forms.HiddenInput()
)
- enabled = forms.BooleanField(
- required=False,
- initial=True
- )
- mtu = forms.IntegerField(
- required=False,
- min_value=INTERFACE_MTU_MIN,
- max_value=INTERFACE_MTU_MAX,
- label='MTU'
- )
- description = forms.CharField(
- max_length=100,
- required=False
- )
diff --git a/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0009_custom_tag_models.py b/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0009_custom_tag_models.py
deleted file mode 100644
index 4a8fa4ea5..000000000
--- a/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0009_custom_tag_models.py
+++ /dev/null
@@ -1,89 +0,0 @@
-import django.contrib.postgres.fields.jsonb
-import django.db.models.deletion
-import taggit.managers
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- replaces = [('virtualization', '0002_virtualmachine_add_status'), ('virtualization', '0003_cluster_add_site'), ('virtualization', '0004_virtualmachine_add_role'), ('virtualization', '0005_django2'), ('virtualization', '0006_tags'), ('virtualization', '0007_change_logging'), ('virtualization', '0008_virtualmachine_local_context_data'), ('virtualization', '0009_custom_tag_models')]
-
- dependencies = [
- ('dcim', '0044_virtualization'),
- ('virtualization', '0001_virtualization'),
- ('extras', '0019_tag_taggeditem'),
- ('taggit', '0002_auto_20150616_2121'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='virtualmachine',
- name='status',
- field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [3, 'Staged']], default=1, verbose_name='Status'),
- ),
- migrations.AddField(
- model_name='cluster',
- name='site',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='dcim.Site'),
- ),
- migrations.AddField(
- model_name='virtualmachine',
- name='role',
- field=models.ForeignKey(blank=True, limit_choices_to={'vm_role': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'),
- ),
- migrations.AddField(
- model_name='clustergroup',
- name='created',
- field=models.DateField(auto_now_add=True, null=True),
- ),
- migrations.AddField(
- model_name='clustergroup',
- name='last_updated',
- field=models.DateTimeField(auto_now=True, null=True),
- ),
- migrations.AddField(
- model_name='clustertype',
- name='created',
- field=models.DateField(auto_now_add=True, null=True),
- ),
- migrations.AddField(
- model_name='clustertype',
- name='last_updated',
- field=models.DateTimeField(auto_now=True, null=True),
- ),
- migrations.AlterField(
- model_name='cluster',
- name='created',
- field=models.DateField(auto_now_add=True, null=True),
- ),
- migrations.AlterField(
- model_name='cluster',
- name='last_updated',
- field=models.DateTimeField(auto_now=True, null=True),
- ),
- migrations.AlterField(
- model_name='virtualmachine',
- name='created',
- field=models.DateField(auto_now_add=True, null=True),
- ),
- migrations.AlterField(
- model_name='virtualmachine',
- name='last_updated',
- field=models.DateTimeField(auto_now=True, null=True),
- ),
- migrations.AddField(
- model_name='virtualmachine',
- name='local_context_data',
- field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
- ),
- migrations.AddField(
- model_name='cluster',
- name='tags',
- field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='extras.TaggedItem', to='extras.Tag', verbose_name='Tags'),
- ),
- migrations.AddField(
- model_name='virtualmachine',
- name='tags',
- field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='extras.TaggedItem', to='extras.Tag', verbose_name='Tags'),
- ),
- ]
diff --git a/netbox/virtualization/migrations/0010_cluster_add_tenant_squashed_0012_vm_name_nonunique.py b/netbox/virtualization/migrations/0010_cluster_add_tenant_squashed_0012_vm_name_nonunique.py
deleted file mode 100644
index eb7abd362..000000000
--- a/netbox/virtualization/migrations/0010_cluster_add_tenant_squashed_0012_vm_name_nonunique.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from django.db import migrations, models
-import django.db.models.deletion
-
-VIRTUALMACHINE_STATUS_CHOICES = (
- (0, 'offline'),
- (1, 'active'),
- (3, 'staged'),
-)
-
-
-def virtualmachine_status_to_slug(apps, schema_editor):
- VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
- for id, slug in VIRTUALMACHINE_STATUS_CHOICES:
- VirtualMachine.objects.filter(status=str(id)).update(status=slug)
-
-
-class Migration(migrations.Migration):
-
- replaces = [('virtualization', '0010_cluster_add_tenant'), ('virtualization', '0011_3569_virtualmachine_fields'), ('virtualization', '0012_vm_name_nonunique')]
-
- dependencies = [
- ('tenancy', '0001_initial'),
- ('tenancy', '0006_custom_tag_models'),
- ('virtualization', '0009_custom_tag_models'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='cluster',
- name='tenant',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='tenancy.Tenant'),
- ),
- migrations.AlterField(
- model_name='virtualmachine',
- name='name',
- field=models.CharField(max_length=64),
- ),
- migrations.AlterUniqueTogether(
- name='virtualmachine',
- unique_together={('cluster', 'tenant', 'name')},
- ),
- migrations.AlterField(
- model_name='virtualmachine',
- name='status',
- field=models.CharField(default='active', max_length=50),
- ),
- migrations.RunPython(
- code=virtualmachine_status_to_slug,
- ),
- ]
diff --git a/netbox/virtualization/migrations/0014_standardize_description.py b/netbox/virtualization/migrations/0014_standardize_description.py
new file mode 100644
index 000000000..e02655bb7
--- /dev/null
+++ b/netbox/virtualization/migrations/0014_standardize_description.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.3 on 2020-03-13 20:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0013_deterministic_ordering'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='clustergroup',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='clustertype',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 13b181137..3daeff013 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -7,6 +7,7 @@ from taggit.managers import TaggableManager
from dcim.models import Device
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
+from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
from .choices import *
@@ -34,8 +35,12 @@ class ClusterType(ChangeLoggedModel):
slug = models.SlugField(
unique=True
)
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
- csv_headers = ['name', 'slug']
+ csv_headers = ['name', 'slug', 'description']
class Meta:
ordering = ['name']
@@ -50,6 +55,7 @@ class ClusterType(ChangeLoggedModel):
return (
self.name,
self.slug,
+ self.description,
)
@@ -68,8 +74,12 @@ class ClusterGroup(ChangeLoggedModel):
slug = models.SlugField(
unique=True
)
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
- csv_headers = ['name', 'slug']
+ csv_headers = ['name', 'slug', 'description']
class Meta:
ordering = ['name']
@@ -84,6 +94,7 @@ class ClusterGroup(ChangeLoggedModel):
return (
self.name,
self.slug,
+ self.description,
)
@@ -91,6 +102,7 @@ class ClusterGroup(ChangeLoggedModel):
# Clusters
#
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Cluster(ChangeLoggedModel, CustomFieldModel):
"""
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
@@ -177,6 +189,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
# Virtual machines
#
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
"""
A virtual machine which runs inside a Cluster.
diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py
index fdb997dab..077add945 100644
--- a/netbox/virtualization/tables.py
+++ b/netbox/virtualization/tables.py
@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import COL_TENANT
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, TagColumn, ToggleColumn
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
CLUSTERTYPE_ACTIONS = """
@@ -46,7 +46,9 @@ VIRTUALMACHINE_PRIMARY_IP = """
class ClusterTypeTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
- cluster_count = tables.Column(verbose_name='Clusters')
+ cluster_count = tables.Column(
+ verbose_name='Clusters'
+ )
actions = tables.TemplateColumn(
template_code=CLUSTERTYPE_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
@@ -55,7 +57,8 @@ class ClusterTypeTable(BaseTable):
class Meta(BaseTable.Meta):
model = ClusterType
- fields = ('pk', 'name', 'cluster_count', 'actions')
+ fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions')
+ default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
#
@@ -65,7 +68,9 @@ class ClusterTypeTable(BaseTable):
class ClusterGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
- cluster_count = tables.Column(verbose_name='Clusters')
+ cluster_count = tables.Column(
+ verbose_name='Clusters'
+ )
actions = tables.TemplateColumn(
template_code=CLUSTERGROUP_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
@@ -74,7 +79,8 @@ class ClusterGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = ClusterGroup
- fields = ('pk', 'name', 'cluster_count', 'actions')
+ fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions')
+ default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
#
@@ -84,14 +90,32 @@ class ClusterGroupTable(BaseTable):
class ClusterTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
- tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
- site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
- device_count = tables.Column(accessor=Accessor('devices.count'), orderable=False, verbose_name='Devices')
- vm_count = tables.Column(accessor=Accessor('virtual_machines.count'), orderable=False, verbose_name='VMs')
+ tenant = tables.LinkColumn(
+ viewname='tenancy:tenant',
+ args=[Accessor('tenant.slug')]
+ )
+ site = tables.LinkColumn(
+ viewname='dcim:site',
+ args=[Accessor('site.slug')]
+ )
+ device_count = tables.Column(
+ accessor=Accessor('devices.count'),
+ orderable=False,
+ verbose_name='Devices'
+ )
+ vm_count = tables.Column(
+ accessor=Accessor('virtual_machines.count'),
+ orderable=False,
+ verbose_name='VMs'
+ )
+ tags = TagColumn(
+ url_name='virtualization:cluster_list'
+ )
class Meta(BaseTable.Meta):
model = Cluster
- fields = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
+ fields = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count', 'tags')
+ default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
#
@@ -101,10 +125,19 @@ class ClusterTable(BaseTable):
class VirtualMachineTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
- status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS)
- cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')])
- role = tables.TemplateColumn(VIRTUALMACHINE_ROLE)
- tenant = tables.TemplateColumn(template_code=COL_TENANT)
+ status = tables.TemplateColumn(
+ template_code=VIRTUALMACHINE_STATUS
+ )
+ cluster = tables.LinkColumn(
+ viewname='virtualization:cluster',
+ args=[Accessor('cluster.pk')]
+ )
+ role = tables.TemplateColumn(
+ template_code=VIRTUALMACHINE_ROLE
+ )
+ tenant = tables.TemplateColumn(
+ template_code=COL_TENANT
+ )
class Meta(BaseTable.Meta):
model = VirtualMachine
@@ -112,13 +145,34 @@ class VirtualMachineTable(BaseTable):
class VirtualMachineDetailTable(VirtualMachineTable):
+ primary_ip4 = tables.LinkColumn(
+ viewname='ipam:ipaddress',
+ args=[Accessor('primary_ip4.pk')],
+ verbose_name='IPv4 Address'
+ )
+ primary_ip6 = tables.LinkColumn(
+ viewname='ipam:ipaddress',
+ args=[Accessor('primary_ip6.pk')],
+ verbose_name='IPv6 Address'
+ )
primary_ip = tables.TemplateColumn(
- orderable=False, verbose_name='IP Address', template_code=VIRTUALMACHINE_PRIMARY_IP
+ orderable=False,
+ verbose_name='IP Address',
+ template_code=VIRTUALMACHINE_PRIMARY_IP
+ )
+ tags = TagColumn(
+ url_name='virtualization:virtualmachine_list'
)
class Meta(BaseTable.Meta):
model = VirtualMachine
- fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip')
+ fields = (
+ 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4',
+ 'primary_ip6', 'primary_ip', 'tags',
+ )
+ default_columns = (
+ 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
+ )
#
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index 719954c10..8568e21e9 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -5,7 +5,7 @@ from rest_framework import status
from dcim.choices import InterfaceModeChoices
from dcim.models import Interface
from ipam.models import IPAddress, VLAN
-from utilities.testing import APITestCase, choices_to_dict, disable_warnings
+from utilities.testing import APITestCase, disable_warnings
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -19,19 +19,6 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
- def test_choices(self):
-
- url = reverse('virtualization-api:field-choice-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.status_code, 200)
-
- # VirtualMachine
- self.assertEqual(choices_to_dict(response.data.get('virtual-machine:status')), VirtualMachineStatusChoices.as_dict())
-
- # Interface
- self.assertEqual(choices_to_dict(response.data.get('interface:type')), VMInterfaceTypeChoices.as_dict())
-
class ClusterTypeTest(APITestCase):
@@ -501,6 +488,18 @@ class VirtualMachineTest(APITestCase):
self.assertFalse('config_context' in response.data['results'][0])
+ def test_unique_name_per_cluster_constraint(self):
+
+ data = {
+ 'name': 'Test Virtual Machine 1',
+ 'cluster': self.cluster1.pk,
+ }
+
+ url = reverse('virtualization-api:virtualmachine-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
class InterfaceTest(APITestCase):
diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py
index 36595eb19..51c7c6e8d 100644
--- a/netbox/virtualization/tests/test_filters.py
+++ b/netbox/virtualization/tests/test_filters.py
@@ -15,15 +15,14 @@ class ClusterTypeTestCase(TestCase):
def setUpTestData(cls):
cluster_types = (
- ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
- ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
- ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
+ ClusterType(name='Cluster Type 1', slug='cluster-type-1', description='A'),
+ ClusterType(name='Cluster Type 2', slug='cluster-type-2', description='B'),
+ ClusterType(name='Cluster Type 3', slug='cluster-type-3', description='C'),
)
ClusterType.objects.bulk_create(cluster_types)
def test_id(self):
- id_list = self.queryset.values_list('id', flat=True)[:2]
- params = {'id': [str(id) for id in id_list]}
+ params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
@@ -34,6 +33,10 @@ class ClusterTypeTestCase(TestCase):
params = {'slug': ['cluster-type-1', 'cluster-type-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)
+
class ClusterGroupTestCase(TestCase):
queryset = ClusterGroup.objects.all()
@@ -43,15 +46,14 @@ class ClusterGroupTestCase(TestCase):
def setUpTestData(cls):
cluster_groups = (
- ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
- ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
- ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
+ ClusterGroup(name='Cluster Group 1', slug='cluster-group-1', description='A'),
+ ClusterGroup(name='Cluster Group 2', slug='cluster-group-2', description='B'),
+ ClusterGroup(name='Cluster Group 3', slug='cluster-group-3', description='C'),
)
ClusterGroup.objects.bulk_create(cluster_groups)
def test_id(self):
- id_list = self.queryset.values_list('id', flat=True)[:2]
- params = {'id': [str(id) for id in id_list]}
+ params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
@@ -62,6 +64,10 @@ class ClusterGroupTestCase(TestCase):
params = {'slug': ['cluster-group-1', 'cluster-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)
+
class ClusterTestCase(TestCase):
queryset = Cluster.objects.all()
@@ -105,7 +111,8 @@ class ClusterTestCase(TestCase):
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
- TenantGroup.objects.bulk_create(tenant_groups)
+ for tenantgroup in tenant_groups:
+ tenantgroup.save()
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
@@ -121,13 +128,12 @@ class ClusterTestCase(TestCase):
)
Cluster.objects.bulk_create(clusters)
- def test_name(self):
- params = {'name': ['Cluster 1', 'Cluster 2']}
+ def test_id(self):
+ params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_id__in(self):
- id_list = self.queryset.values_list('id', flat=True)[:2]
- params = {'id__in': ','.join([str(id) for id in id_list])}
+ def test_name(self):
+ params = {'name': ['Cluster 1', 'Cluster 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
@@ -236,7 +242,8 @@ class VirtualMachineTestCase(TestCase):
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
- TenantGroup.objects.bulk_create(tenant_groups)
+ for tenantgroup in tenant_groups:
+ tenantgroup.save()
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
@@ -260,8 +267,7 @@ class VirtualMachineTestCase(TestCase):
Interface.objects.bulk_create(interfaces)
def test_id(self):
- id_list = self.queryset.values_list('id', flat=True)[:2]
- params = {'id': [str(id) for id in id_list]}
+ params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
@@ -280,11 +286,6 @@ class VirtualMachineTestCase(TestCase):
params = {'disk': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_id__in(self):
- id_list = self.queryset.values_list('id', flat=True)[:2]
- params = {'id__in': ','.join([str(id) for id in id_list])}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
def test_status(self):
params = {'status': [VirtualMachineStatusChoices.STATUS_ACTIVE, VirtualMachineStatusChoices.STATUS_STAGED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 639908977..e7bb19285 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -23,13 +23,14 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.form_data = {
'name': 'Cluster Group X',
'slug': 'cluster-group-x',
+ 'description': 'A new cluster group',
}
cls.csv_data = (
- "name,slug",
- "Cluster Group 4,cluster-group-4",
- "Cluster Group 5,cluster-group-5",
- "Cluster Group 6,cluster-group-6",
+ "name,slug,description",
+ "Cluster Group 4,cluster-group-4,Fourth cluster group",
+ "Cluster Group 5,cluster-group-5,Fifth cluster group",
+ "Cluster Group 6,cluster-group-6,Sixth cluster group",
)
@@ -48,13 +49,14 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.form_data = {
'name': 'Cluster Type X',
'slug': 'cluster-type-x',
+ 'description': 'A new cluster type',
}
cls.csv_data = (
- "name,slug",
- "Cluster Type 4,cluster-type-4",
- "Cluster Type 5,cluster-type-5",
- "Cluster Type 6,cluster-type-6",
+ "name,slug,description",
+ "Cluster Type 4,cluster-type-4,Fourth cluster type",
+ "Cluster Type 5,cluster-type-5,Fifth cluster type",
+ "Cluster Type 6,cluster-type-6,Sixth cluster type",
)
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 291392eb4..0a05833f4 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -168,7 +168,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
def get(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
- form = self.form(cluster)
+ form = self.form(cluster, initial=request.GET)
return render(request, self.template_name, {
'cluster': cluster,
@@ -366,7 +366,7 @@ class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentC
permission_required = 'dcim.add_interface'
parent_model = VirtualMachine
parent_field = 'virtual_machine'
- form = forms.VirtualMachineBulkAddInterfaceForm
+ form = forms.InterfaceBulkCreateForm
model = Interface
model_form = forms.InterfaceForm
filterset = filters.VirtualMachineFilterSet
diff --git a/requirements.txt b/requirements.txt
index 3e7687f1a..f4c2514d6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,26 +1,26 @@
-Django>=2.2,<2.3
+Django>=3.0,<3.1
django-allauth==0.41.0
django-cacheops==4.2
django-cors-headers==3.2.1
-django-debug-toolbar==2.1
+django-debug-toolbar==2.2
django-filter==2.2.0
-django-mptt==0.9.1
+django-mptt==0.11.0
django-pglocks==1.0.4
-django-prometheus==1.1.0
-django-rq==2.2.0
-django-tables2==2.2.1
+django-prometheus==2.0.0
+django-rq==2.3.2
+django-tables2==2.3.1
django-taggit==1.2.0
django-taggit-serializer==0.1.7
django-timezone-field==4.0
-djangorestframework==3.10.3
-drf-yasg[validation]==1.17.0
+djangorestframework==3.11.0
+drf-yasg[validation]==1.17.1
gunicorn==20.0.4
-Jinja2==2.10.3
+Jinja2==2.11.1
Markdown==3.2.1
netaddr==0.7.19
-Pillow==7.0.0
-psycopg2-binary==2.8.4
-pycryptodome==3.9.4
-PyYAML==5.3
-redis==3.3.11
-svgwrite==1.3.1
+Pillow==7.1.1
+psycopg2-binary==2.8.5
+pycryptodome==3.9.7
+PyYAML==5.3.1
+redis==3.4.1
+svgwrite==1.4
diff --git a/scripts/cibuild.sh b/scripts/cibuild.sh
index 282000b0a..6a0422308 100755
--- a/scripts/cibuild.sh
+++ b/scripts/cibuild.sh
@@ -34,11 +34,8 @@ if [[ $RC != 0 ]]; then
EXIT=$RC
fi
-# Prepare configuration file for use in CI
-CONFIG="netbox/netbox/configuration.py"
-cp netbox/netbox/configuration.example.py $CONFIG
-sed -i -e "s/ALLOWED_HOSTS = \[\]/ALLOWED_HOSTS = \['*'\]/g" $CONFIG
-sed -i -e "s/SECRET_KEY = ''/SECRET_KEY = 'netboxci'/g" $CONFIG
+# Point to the testing configuration file for use in CI
+ln -s configuration.testing.py netbox/netbox/configuration.py
# Run NetBox tests
coverage run --source="netbox/" netbox/manage.py test netbox/
|