Merge branch 'develop' into feature

This commit is contained in:
jeremystretch
2023-04-12 17:38:16 -04:00
25 changed files with 215 additions and 55 deletions
+3 -1
View File
@@ -64,7 +64,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z'
)
commit_rate = CommitRateColumn()
commit_rate = CommitRateColumn(
verbose_name='Commit Rate'
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:circuit_list'
+14
View File
@@ -1694,12 +1694,14 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
field_name='terminations__termination_type'
)
termination_a_id = MultiValueNumberFilter(
method='filter_by_cable_end_a',
field_name='terminations__termination_id'
)
termination_b_type = ContentTypeFilter(
field_name='terminations__termination_type'
)
termination_b_id = MultiValueNumberFilter(
method='filter_by_cable_end_b',
field_name='terminations__termination_id'
)
type = django_filters.MultipleChoiceFilter(
@@ -1757,6 +1759,18 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
# Supported objects: device, rack, location, site
return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
def filter_by_cable_end(self, queryset, name, value, side):
# Filter by termination id and cable_end type
return queryset.filter(**{f'{name}__in': value, 'terminations__cable_end': side}).distinct()
def filter_by_cable_end_a(self, queryset, name, value):
# Filter by termination id and cable_end type
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_A)
def filter_by_cable_end_b(self, queryset, name, value):
# Filter by termination id and cable_end type
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
class CableTerminationFilterSet(BaseFilterSet):
termination_type = ContentTypeFilter()
@@ -119,6 +119,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache the original DeviceType ID for reference under clean()
self._original_device_type = self.device_type_id
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
if self.device_type is not None:
@@ -130,6 +136,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
def clean(self):
super().clean()
if self.pk is not None and self._original_device_type != self.device_type_id:
raise ValidationError({
"device_type": "Component templates cannot be moved to a different device type."
})
# A component template must belong to a DeviceType *or* to a ModuleType
if self.device_type and self.module_type:
raise ValidationError(
+14
View File
@@ -78,6 +78,12 @@ class ComponentModel(NetBoxModel):
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache the original Device ID for reference under clean()
self._original_device = self.device_id
def __str__(self):
if self.label:
return f"{self.name} ({self.label})"
@@ -88,6 +94,14 @@ class ComponentModel(NetBoxModel):
objectchange.related_object = self.device
return objectchange
def clean(self):
super().clean()
if self.pk is not None and self._original_device != self.device_id:
raise ValidationError({
"device": "Components cannot be moved to a different device."
})
@property
def parent_object(self):
return self.device
+4
View File
@@ -128,6 +128,10 @@ class DeviceType(PrimaryModel, WeightMixin):
blank=True
)
images = GenericRelation(
to='extras.ImageAttachment'
)
clone_fields = (
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
)
+6
View File
@@ -104,6 +104,12 @@ class CustomFieldSerializer(ValidatedModelSerializer):
'last_updated',
]
def validate_type(self, value):
if self.instance and self.instance.type != value:
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
return value
@extend_schema_field(OpenApiTypes.STR)
def get_data_type(self, obj):
types = CustomFieldTypeChoices
+9 -1
View File
@@ -1,6 +1,7 @@
import json
from django import forms
from django.db.models import Q
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
@@ -39,7 +40,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
object_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
# TODO: Come up with a canonical way to register suitable models
limit_choices_to=FeatureQuery('webhooks'),
limit_choices_to=FeatureQuery('webhooks').get_query() | Q(app_label='auth', model__in=['user', 'group']),
required=False,
help_text=_("Type of the related object (for object/multi-object fields only)")
)
@@ -63,6 +64,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
)
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present.
if self.instance.pk:
self.fields['type'].disabled = True
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
+5
View File
@@ -103,6 +103,11 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
bulk_update_data = {
'description': 'New description',
}
update_data = {
'content_types': ['dcim.device'],
'name': 'New_Name',
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
+2 -1
View File
@@ -456,7 +456,8 @@ class L2VPNImportForm(NetBoxModelImportForm):
class Meta:
model = L2VPN
fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags')
fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
'comments', 'tags')
class L2VPNTerminationImportForm(NetBoxModelImportForm):
+28
View File
@@ -1,4 +1,5 @@
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.validators import ValidationError
from django.db import models
from mptt.models import MPTTModel, TreeForeignKey
@@ -58,6 +59,33 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
class Meta:
abstract = True
def clean(self):
"""
Validate the model for GenericForeignKey fields to ensure that the content type and object ID exist.
"""
super().clean()
for field in self._meta.get_fields():
if isinstance(field, GenericForeignKey):
ct_value = getattr(self, field.ct_field)
fk_value = getattr(self, field.fk_field)
if ct_value is None and fk_value is not None:
raise ValidationError({
field.ct_field: "This field cannot be null.",
})
if fk_value is None and ct_value is not None:
raise ValidationError({
field.fk_field: "This field cannot be null.",
})
if ct_value and fk_value:
klass = getattr(self, field.ct_field).model_class()
if not klass.objects.filter(pk=fk_value).exists():
raise ValidationError({
field.fk_field: f"Related object not found using the provided value: {fk_value}."
})
class PrimaryModel(NetBoxModel):
"""
+9 -2
View File
@@ -70,10 +70,17 @@ Blocks:
</div>
{% endif %}
{% if settings.DEBUG and not settings.DEVELOPER %}
<div class="alert alert-warning text-center mx-3" role="alert">
<strong><i class="mdi mdi-alert"></i> Debug mode is enabled.</strong>
Performance may be limited. Debugging should never be enabled on a production system.
</div>
{% endif %}
{% if config.MAINTENANCE_MODE %}
<div class="alert alert-warning text-center mx-3" role="alert">
<h4><i class="mdi mdi-alert"></i> Maintenance Mode</h4>
<span>NetBox is currently in maintenance mode. Functionality may be limited.</span>
<h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
NetBox is currently in maintenance mode. Functionality may be limited.
</div>
{% endif %}
+1
View File
@@ -99,6 +99,7 @@
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>
</div>
+3 -3
View File
@@ -15,17 +15,17 @@ Context:
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="import-form-tab" data-bs-toggle="tab" data-bs-target="#import-form" type="button" role="tab" aria-controls="import-form" aria-selected="true">
<button class="nav-link active" id="import-form-tab" data-bs-toggle="tab" data-bs-target="#import-form" data-href="#tab_import-form" type="button" role="tab" aria-controls="import-form" aria-selected="true">
Direct Import
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="upload-form-tab" data-bs-toggle="tab" data-bs-target="#upload-form" type="button" role="tab" aria-controls="upload-form" aria-selected="false">
<button class="nav-link" id="upload-form-tab" data-bs-toggle="tab" data-bs-target="#upload-form" data-href="#tab_upload-form" type="button" role="tab" aria-controls="upload-form" aria-selected="false">
Upload File
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="datafile-form-tab" data-bs-toggle="tab" data-bs-target="#datafile-form" type="button" role="tab" aria-controls="datafile-form" aria-selected="false">
<button class="nav-link" id="datafile-form-tab" data-bs-toggle="tab" data-bs-target="#datafile-form" data-href="#tab_datafile-form" type="button" role="tab" aria-controls="datafile-form" aria-selected="false">
Data File
</button>
</li>
+11
View File
@@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
from netbox.api.serializers import ValidatedModelSerializer
@@ -94,6 +95,16 @@ class TokenSerializer(ValidatedModelSerializer):
data['key'] = Token.generate_key()
return super().to_internal_value(data)
def validate(self, data):
# If the Token is being created on behalf of another user, enforce the grant_token permission.
request = self.context.get('request')
token_user = data.get('user')
if token_user and token_user != request.user and not request.user.has_perm('users.grant_token'):
raise PermissionDenied("This user does not have permission to create tokens for other users.")
return super().validate(data)
class TokenProvisionSerializer(serializers.Serializer):
username = serializers.CharField()
+20
View File
@@ -153,6 +153,26 @@ class TokenTest(
response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 403)
def test_provision_token_other_user(self):
"""
Test provisioning a Token for a different User with & without the grant_token permission.
"""
self.add_permissions('users.add_token')
user2 = User.objects.create_user(username='testuser2')
data = {
'user': user2.id,
}
url = reverse('users-api:token-list')
# Attempt to create a new Token for User2 *without* the grant_token permission
response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 403)
# Assign grant_token permission and successfully create a new Token for User2
self.add_permissions('users.grant_token')
response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 201)
class ObjectPermissionTest(
# No GraphQL support for ObjectPermission
+4
View File
@@ -48,6 +48,10 @@ def get_viewname(model, action=None, rest_api=False):
if is_plugin:
viewname = f'plugins-api:{app_label}-api:{model_name}'
else:
# Alter the app_label for group and user model_name to point to users app
if app_label == 'auth' and model_name in ['group', 'user']:
app_label = 'users'
viewname = f'{app_label}-api:{model_name}'
# Append the action, if any
if action: